155 lines
5.9 KiB
Vue
155 lines
5.9 KiB
Vue
<script setup lang="ts">
|
|
import { ListFilter, Search } from '@lucide/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 { DatePicker } from '@/components/ui/date-picker';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import {
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from '@/components/ui/popover';
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select';
|
|
import type { DataTableFilterDef } from '@/types/data-table';
|
|
|
|
const props = defineProps<{
|
|
filterDefs?: DataTableFilterDef[];
|
|
filterValues?: Record<string, string>;
|
|
searchPlaceholder?: string;
|
|
debounceMs?: number;
|
|
}>();
|
|
|
|
const search = defineModel<string>('search', { default: '' });
|
|
|
|
const emit = defineEmits<{
|
|
'filter-change': [key: string, value: string];
|
|
'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;
|
|
}
|
|
|
|
return props.filterDefs.filter((filter) => {
|
|
const value = props.filterValues?.[filter.key];
|
|
|
|
return value !== undefined && value !== '';
|
|
}).length;
|
|
});
|
|
|
|
function filterValue(key: string): string {
|
|
return props.filterValues?.[key] ?? '';
|
|
}
|
|
|
|
function onFilterChange(key: string, value: string): void {
|
|
emit('filter-change', key, value === 'all' ? '' : value);
|
|
}
|
|
|
|
function resetFilters(): void {
|
|
emit('filters-reset');
|
|
}
|
|
|
|
const isMobile = useMediaQuery('(max-width: 640px)');
|
|
const selectSide = computed(() => (isMobile.value ? 'bottom' : 'left'));
|
|
const selectSideOffset = computed(() => (isMobile.value ? 4 : 12));
|
|
</script>
|
|
|
|
<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 pointer-events-none" />
|
|
<Input v-model="localSearch" type="search" :placeholder="searchPlaceholder ?? 'Cari...'" class="pl-9" />
|
|
</div>
|
|
|
|
<Popover v-if="filterDefs?.length">
|
|
<PopoverTrigger as-child>
|
|
<Button variant="outline" class="shrink-0">
|
|
<ListFilter class="size-4" />
|
|
Filter
|
|
<Badge v-if="activeFilterCount > 0" variant="secondary"
|
|
class="ml-1 size-5 rounded-full px-1 text-xs">
|
|
{{ activeFilterCount }}
|
|
</Badge>
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent align="end" class="w-80 space-y-4 p-4" @pointer-down-outside="(event) => {
|
|
const target = event.target as HTMLElement;
|
|
if (target.closest('[data-slot=select-content]') || target.closest('[data-slot=select-trigger]') || target.closest('[data-slot=popover-content]')) {
|
|
event.preventDefault();
|
|
}
|
|
}">
|
|
<div class="space-y-1">
|
|
<h4 class="text-sm font-medium">
|
|
Filter
|
|
</h4>
|
|
<p class="text-muted-foreground text-xs">
|
|
Sesuaikan filter untuk mempersempit data.
|
|
</p>
|
|
</div>
|
|
|
|
<div v-for="filter in filterDefs" :key="filter.key" class="space-y-2">
|
|
<Label :for="`filter-${filter.key}`">{{ filter.label }}</Label>
|
|
|
|
<Input v-if="filter.type === 'text'" :id="`filter-${filter.key}`"
|
|
:model-value="filterValue(filter.key)"
|
|
:placeholder="filter.placeholder ?? `Filter ${filter.label.toLowerCase()}`"
|
|
@update:model-value="onFilterChange(filter.key, String($event ?? ''))" />
|
|
|
|
<DatePicker v-else-if="filter.type === 'date'"
|
|
:model-value="filterValue(filter.key)"
|
|
:placeholder="filter.placeholder ?? 'Pilih tanggal'"
|
|
@update:model-value="onFilterChange(filter.key, String($event ?? ''))" />
|
|
|
|
<Select v-else :model-value="filterValue(filter.key) || 'all'"
|
|
@update:model-value="onFilterChange(filter.key, String($event ?? ''))">
|
|
<SelectTrigger :id="`filter-${filter.key}`" class="w-full">
|
|
<SelectValue :placeholder="filter.placeholder ?? 'Semua'" />
|
|
</SelectTrigger>
|
|
<SelectContent class="z-[300]" :side="selectSide" :side-offset="selectSideOffset">
|
|
<SelectItem value="all">
|
|
Semua
|
|
</SelectItem>
|
|
<SelectItem v-for="option in filter.options" :key="option.value" :value="option.value">
|
|
{{ option.label }}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<Button v-if="activeFilterCount > 0" variant="ghost" size="sm" class="w-full" @click="resetFilters">
|
|
Reset Filter
|
|
</Button>
|
|
</PopoverContent>
|
|
</Popover>
|
|
</div>
|
|
</template>
|