feat: implement subtle loading bar in DataTable and enhance search functionality with debounce in DataTableToolbar for improved user experience
Some checks are pending
linter / quality (push) Waiting to run
tests / ci (8.3) (push) Waiting to run
tests / ci (8.4) (push) Waiting to run
tests / ci (8.5) (push) Waiting to run

This commit is contained in:
Yoga Pangestu 2026-07-08 11:53:17 +07:00
parent 2efc13643b
commit a37b47a660
5 changed files with 55 additions and 23 deletions

View File

@ -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%);
}
}

View File

@ -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>

View File

@ -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">

View File

@ -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;

View File

@ -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">