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] {
|
||||
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)"
|
||||
@filters-reset="emit('filters-reset')" />
|
||||
|
||||
<div class="relative min-w-0 rounded-md border">
|
||||
<!-- Loading Overlay -->
|
||||
<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 class="flex items-center gap-2 rounded-lg border bg-background px-4 py-2 shadow-md">
|
||||
<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>
|
||||
<div class="relative min-w-0 rounded-md border overflow-hidden">
|
||||
<div
|
||||
v-if="loading"
|
||||
class="absolute top-0 left-0 right-0 h-0.5 z-10 overflow-hidden bg-primary/10"
|
||||
>
|
||||
<div class="h-full w-1/3 animate-[loading-bar_1.2s_ease-in-out_infinite] bg-primary rounded-full" />
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ListFilter, Search } from '@lucide/vue';
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import { computed } from 'vue';
|
||||
import { useDebounceFn, useMediaQuery } from '@vueuse/core';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@ -24,6 +24,7 @@ const props = defineProps<{
|
||||
filterDefs?: DataTableFilterDef[];
|
||||
filterValues?: Record<string, string>;
|
||||
searchPlaceholder?: string;
|
||||
debounceMs?: number;
|
||||
}>();
|
||||
|
||||
const search = defineModel<string>('search', { default: '' });
|
||||
@ -33,6 +34,25 @@ const emit = defineEmits<{
|
||||
'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(() => {
|
||||
if (!props.filterDefs?.length || !props.filterValues) {
|
||||
return 0;
|
||||
@ -65,8 +85,8 @@ const selectSideOffset = computed(() => (isMobile.value ? 4 : 12));
|
||||
<template>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<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" />
|
||||
<Input v-model="search" type="search" :placeholder="searchPlaceholder ?? 'Ketikkan sesuatu...'" class="pl-9" />
|
||||
<Search class="text-muted-foreground absolute top-1/2 left-3 size-4 -translate-y-1/2 pointer-events-none" />
|
||||
<Input v-model="localSearch" type="search" :placeholder="searchPlaceholder ?? 'Cari...'" class="pl-9" />
|
||||
</div>
|
||||
|
||||
<Popover v-if="filterDefs?.length">
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { ref, watch } from 'vue';
|
||||
import type {WatchSource} from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import type { WatchSource } from 'vue';
|
||||
import type { DataTableQuery, DataTableSort } from '@/types/data-table';
|
||||
|
||||
type UseDataTableQueryOptions = {
|
||||
url: string;
|
||||
initial: DataTableQuery;
|
||||
filterKeys?: string[];
|
||||
debounceMs?: number;
|
||||
only?: string[];
|
||||
};
|
||||
|
||||
@ -31,7 +29,9 @@ export function useDataTableQuery(options: UseDataTableQueryOptions) {
|
||||
const query = ref<DataTableQuery>({ ...options.initial });
|
||||
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), {
|
||||
preserveState: true,
|
||||
replace: true,
|
||||
@ -45,7 +45,7 @@ export function useDataTableQuery(options: UseDataTableQueryOptions) {
|
||||
isLoading.value = false;
|
||||
},
|
||||
});
|
||||
}, options.debounceMs ?? 300);
|
||||
}
|
||||
|
||||
function setSearch(value: string): void {
|
||||
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')" />
|
||||
|
||||
<div class="relative min-h-[100px]">
|
||||
<!-- Loading Overlay -->
|
||||
<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 class="flex items-center gap-2 rounded-lg border bg-background px-4 py-2 shadow-md">
|
||||
<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>
|
||||
<!-- Subtle loading bar — tidak ganggu layout -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="absolute top-0 left-0 right-0 h-0.5 z-10 overflow-hidden bg-primary/10"
|
||||
>
|
||||
<div class="h-full w-1/3 animate-[loading-bar_1.2s_ease-in-out_infinite] bg-primary rounded-full" />
|
||||
</div>
|
||||
|
||||
<div v-if="materials.length" class="space-y-4">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user