feat(datatable): typed sort, debounced filters, shared Button/Input, EmptyState

This commit is contained in:
Vadym Samoilenko 2026-05-13 11:06:17 +01:00
parent 34c40798be
commit 2425e241c0
2 changed files with 65 additions and 25 deletions

View file

@ -1,5 +1,9 @@
<script setup lang="ts">
import { ref, computed, reactive, watch } from 'vue'
import { ref, computed, reactive, watch, toRef } from 'vue'
import Input from '@/components/ui/Input.vue'
import Button from '@/components/ui/Button.vue'
import EmptyState from '@/components/ui/EmptyState.vue'
import { useDebouncedRef } from '@/composables/useDebounce'
export interface TableColumn {
key: string
@ -11,6 +15,7 @@ export interface TableColumn {
resizable?: boolean
align?: 'left' | 'center' | 'right'
className?: string
type?: 'string' | 'number' | 'date' | 'auto'
}
const props = withDefaults(defineProps<{
@ -27,7 +32,7 @@ const emit = defineEmits<{
const sortKey = ref<string | null>(null)
const sortDir = ref<'asc' | 'desc'>('asc')
const filters = reactive<Record<string, string>>({})
const filters = ref<Record<string, string>>({})
const colWidths = reactive<Record<string, number>>({})
const showFilters = ref(false)
@ -35,6 +40,9 @@ props.columns.forEach((col) => {
if (col.width) colWidths[col.key] = col.width
})
// Debounced copy of filters used for the filtered computed (250ms)
const debouncedFilters = useDebouncedRef(filters, 250)
function getWidth(col: TableColumn): string {
const w = colWidths[col.key] ?? col.width
return w ? `${w}px` : 'auto'
@ -50,9 +58,22 @@ function toggleSort(col: TableColumn) {
}
}
function compare(a: unknown, b: unknown, type: TableColumn['type'] = 'auto'): number {
if (a == null && b == null) return 0
if (a == null) return -1
if (b == null) return 1
if (type === 'number') return Number(a) - Number(b)
if (type === 'date') return new Date(String(a)).getTime() - new Date(String(b)).getTime()
if (type === 'auto') {
const an = Number(a), bn = Number(b)
if (!isNaN(an) && !isNaN(bn)) return an - bn
}
return String(a).localeCompare(String(b))
}
const filtered = computed(() => {
let rows = [...props.rows]
for (const [k, v] of Object.entries(filters)) {
for (const [k, v] of Object.entries(debouncedFilters.value)) {
if (!v) continue
rows = rows.filter((row) => {
const val = String(row[k] ?? '').toLowerCase()
@ -65,17 +86,16 @@ const filtered = computed(() => {
const displayed = computed(() => {
if (!sortKey.value) return filtered.value
const k = sortKey.value
const col = props.columns.find((c) => c.key === k)
return [...filtered.value].sort((a, b) => {
const av = a[k]
const bv = b[k]
if (av == null) return 1
if (bv == null) return -1
const cmp = String(av) < String(bv) ? -1 : String(av) > String(bv) ? 1 : 0
const cmp = compare(av, bv, col?.type)
return sortDir.value === 'asc' ? cmp : -cmp
})
})
const hasActiveFilters = computed(() => Object.values(filters).some(Boolean))
const hasActiveFilters = computed(() => Object.values(filters.value).some(Boolean))
let resizing: { key: string; startX: number; startW: number } | null = null
@ -103,9 +123,7 @@ function startResize(col: TableColumn, e: MouseEvent) {
}
function clearFilters() {
for (const key of Object.keys(filters)) {
filters[key] = ''
}
filters.value = {}
}
</script>
@ -115,13 +133,13 @@ function clearFilters() {
<div class="flex items-center gap-2 mb-2">
<span class="text-xs text-muted-foreground">{{ displayed.length }} items</span>
<div class="flex-1" />
<button
<Button
v-if="columns.some(c => c.filterable)"
variant="ghost"
size="sm"
:class="[
'flex items-center gap-1.5 px-2.5 py-1 rounded-lg text-xs font-medium border transition-colors',
(showFilters || hasActiveFilters)
? 'bg-primary/10 border-primary/20 text-primary'
: 'border-border text-muted-foreground hover:text-foreground hover:bg-muted/40',
'flex items-center gap-1.5',
(showFilters || hasActiveFilters) ? 'text-primary' : '',
]"
@click="showFilters = !showFilters"
>
@ -130,14 +148,15 @@ function clearFilters() {
</svg>
Filter
<span v-if="hasActiveFilters" class="h-1.5 w-1.5 rounded-full bg-primary" />
</button>
<button
</Button>
<Button
v-if="hasActiveFilters"
class="text-xs text-muted-foreground hover:text-foreground"
variant="ghost"
size="sm"
@click="clearFilters"
>
Clear
</button>
</Button>
</div>
<!-- Table wrapper with horizontal scroll -->
@ -203,12 +222,13 @@ function clearFilters() {
:key="`filter-${col.key}`"
class="px-2 pb-2 pt-0"
>
<input
<Input
v-if="col.filterable"
v-model="filters[col.key]"
:model-value="filters[col.key] ?? ''"
type="text"
class="w-full px-2 py-1 text-xs rounded-lg border border-border bg-background text-foreground placeholder:text-muted-foreground/60 focus:outline-none focus:ring-1 focus:ring-primary focus:border-primary"
:placeholder="`Filter…`"
class="h-7 text-xs py-1"
placeholder="Filter…"
@update:model-value="(v) => { filters[col.key] = v }"
@click.stop
/>
</th>
@ -239,8 +259,12 @@ function clearFilters() {
</tr>
<tr v-if="displayed.length === 0">
<td :colspan="columns.length" class="text-center py-10 text-sm text-muted-foreground">
No items found
<td :colspan="columns.length" class="py-12">
<EmptyState
title="No items found"
description="Try adjusting your filter."
size="sm"
/>
</td>
</tr>
</tbody>

View file

@ -0,0 +1,16 @@
import { ref, watch } from 'vue'
import type { Ref } from 'vue'
export function useDebouncedRef<T>(source: Ref<T>, ms: number): Ref<T> {
const debounced = ref(source.value) as Ref<T>
let timeout: ReturnType<typeof setTimeout>
watch(source, (val) => {
clearTimeout(timeout)
timeout = setTimeout(() => {
debounced.value = val
}, ms)
})
return debounced
}