feat: implement subtle loading bar in DataTable and enhance search functionality with debounce in DataTableToolbar for improved user experience
This commit is contained in:
parent
2efc13643b
commit
a37b47a660
@ -222,3 +222,16 @@ .toaster [data-sonner-toast][data-type="info"] {
|
|||||||
.toaster [data-sonner-toast][data-type="info"] [data-icon] {
|
.toaster [data-sonner-toast][data-type="info"] [data-icon] {
|
||||||
color: rgb(59 130 246) !important;
|
color: rgb(59 130 246) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* DataTable loading bar */
|
||||||
|
@keyframes loading-bar {
|
||||||
|
0% {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateX(150%);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateX(400%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -149,13 +149,12 @@ function resolveRowSpan(row: TData, columnId: string): number | undefined {
|
|||||||
:search-placeholder="searchPlaceholder" @filter-change="(key, value) => emit('filter-change', key, value)"
|
:search-placeholder="searchPlaceholder" @filter-change="(key, value) => emit('filter-change', key, value)"
|
||||||
@filters-reset="emit('filters-reset')" />
|
@filters-reset="emit('filters-reset')" />
|
||||||
|
|
||||||
<div class="relative min-w-0 rounded-md border">
|
<div class="relative min-w-0 rounded-md border overflow-hidden">
|
||||||
<!-- Loading Overlay -->
|
<div
|
||||||
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-background/50 backdrop-blur-[1px] transition-all duration-300">
|
v-if="loading"
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-background px-4 py-2 shadow-md">
|
class="absolute top-0 left-0 right-0 h-0.5 z-10 overflow-hidden bg-primary/10"
|
||||||
<span class="size-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
>
|
||||||
<span class="text-sm font-medium text-muted-foreground select-none">Memuat data...</span>
|
<div class="h-full w-1/3 animate-[loading-bar_1.2s_ease-in-out_infinite] bg-primary rounded-full" />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Table>
|
<Table>
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ListFilter, Search } from '@lucide/vue';
|
import { ListFilter, Search } from '@lucide/vue';
|
||||||
import { useMediaQuery } from '@vueuse/core';
|
import { useDebounceFn, useMediaQuery } from '@vueuse/core';
|
||||||
import { computed } from 'vue';
|
import { computed, ref, watch } from 'vue';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@ -24,6 +24,7 @@ const props = defineProps<{
|
|||||||
filterDefs?: DataTableFilterDef[];
|
filterDefs?: DataTableFilterDef[];
|
||||||
filterValues?: Record<string, string>;
|
filterValues?: Record<string, string>;
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
|
debounceMs?: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const search = defineModel<string>('search', { default: '' });
|
const search = defineModel<string>('search', { default: '' });
|
||||||
@ -33,6 +34,25 @@ const emit = defineEmits<{
|
|||||||
'filters-reset': [];
|
'filters-reset': [];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
const localSearch = ref(search.value || '');
|
||||||
|
|
||||||
|
const emitSearchDebounced = useDebounceFn((value: string) => {
|
||||||
|
search.value = value;
|
||||||
|
}, props.debounceMs ?? 500);
|
||||||
|
|
||||||
|
watch(localSearch, (value) => {
|
||||||
|
emitSearchDebounced(value || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(search, (val) => {
|
||||||
|
const safeVal = val || '';
|
||||||
|
|
||||||
|
if (safeVal !== localSearch.value) {
|
||||||
|
localSearch.value = safeVal;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Filter state ─────────────────────────────────────────────────────────────
|
||||||
const activeFilterCount = computed(() => {
|
const activeFilterCount = computed(() => {
|
||||||
if (!props.filterDefs?.length || !props.filterValues) {
|
if (!props.filterDefs?.length || !props.filterValues) {
|
||||||
return 0;
|
return 0;
|
||||||
@ -65,8 +85,8 @@ const selectSideOffset = computed(() => (isMobile.value ? 4 : 12));
|
|||||||
<template>
|
<template>
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div class="relative w-full max-w-sm">
|
<div class="relative w-full max-w-sm">
|
||||||
<Search class="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2" />
|
<Search class="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2 pointer-events-none" />
|
||||||
<Input v-model="search" type="search" :placeholder="searchPlaceholder ?? 'Ketikkan sesuatu...'" class="pl-9" />
|
<Input v-model="localSearch" type="search" :placeholder="searchPlaceholder ?? 'Cari...'" class="pl-9" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Popover v-if="filterDefs?.length">
|
<Popover v-if="filterDefs?.length">
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
import { router } from '@inertiajs/vue3';
|
import { router } from '@inertiajs/vue3';
|
||||||
import { useDebounceFn } from '@vueuse/core';
|
import { ref, watch } from 'vue';
|
||||||
import { ref, watch } from 'vue';
|
import type { WatchSource } from 'vue';
|
||||||
import type {WatchSource} from 'vue';
|
|
||||||
import type { DataTableQuery, DataTableSort } from '@/types/data-table';
|
import type { DataTableQuery, DataTableSort } from '@/types/data-table';
|
||||||
|
|
||||||
type UseDataTableQueryOptions = {
|
type UseDataTableQueryOptions = {
|
||||||
url: string;
|
url: string;
|
||||||
initial: DataTableQuery;
|
initial: DataTableQuery;
|
||||||
filterKeys?: string[];
|
filterKeys?: string[];
|
||||||
debounceMs?: number;
|
|
||||||
only?: string[];
|
only?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -31,7 +29,9 @@ export function useDataTableQuery(options: UseDataTableQueryOptions) {
|
|||||||
const query = ref<DataTableQuery>({ ...options.initial });
|
const query = ref<DataTableQuery>({ ...options.initial });
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
|
||||||
const reload = useDebounceFn(() => {
|
// Tidak pakai debounce di sini — debounce sudah dilakukan di DataTableToolbar
|
||||||
|
// untuk search. Filter lain (select) langsung reload karena tidak butuh debounce.
|
||||||
|
function reload(): void {
|
||||||
router.get(options.url, buildParams(query.value, filterKeys), {
|
router.get(options.url, buildParams(query.value, filterKeys), {
|
||||||
preserveState: true,
|
preserveState: true,
|
||||||
replace: true,
|
replace: true,
|
||||||
@ -45,7 +45,7 @@ export function useDataTableQuery(options: UseDataTableQueryOptions) {
|
|||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, options.debounceMs ?? 300);
|
}
|
||||||
|
|
||||||
function setSearch(value: string): void {
|
function setSearch(value: string): void {
|
||||||
query.value.search = value;
|
query.value.search = value;
|
||||||
|
|||||||
@ -76,12 +76,12 @@ function openVerificationDetail(requestId: number | undefined) {
|
|||||||
@filter-change="(key, value) => emit('filter-change', key, value)" @filters-reset="emit('filters-reset')" />
|
@filter-change="(key, value) => emit('filter-change', key, value)" @filters-reset="emit('filters-reset')" />
|
||||||
|
|
||||||
<div class="relative min-h-[100px]">
|
<div class="relative min-h-[100px]">
|
||||||
<!-- Loading Overlay -->
|
<!-- Subtle loading bar — tidak ganggu layout -->
|
||||||
<div v-if="loading" class="absolute inset-0 z-10 flex items-center justify-center bg-background/50 backdrop-blur-[1px] transition-all duration-300">
|
<div
|
||||||
<div class="flex items-center gap-2 rounded-lg border bg-background px-4 py-2 shadow-md">
|
v-if="loading"
|
||||||
<span class="size-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
class="absolute top-0 left-0 right-0 h-0.5 z-10 overflow-hidden bg-primary/10"
|
||||||
<span class="text-sm font-medium text-muted-foreground select-none">Memuat data...</span>
|
>
|
||||||
</div>
|
<div class="h-full w-1/3 animate-[loading-bar_1.2s_ease-in-out_infinite] bg-primary rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="materials.length" class="space-y-4">
|
<div v-if="materials.length" class="space-y-4">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user