feat(datatable): typed sort, debounced filters, shared Button/Input, EmptyState
This commit is contained in:
parent
34c40798be
commit
2425e241c0
2 changed files with 65 additions and 25 deletions
|
|
@ -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>
|
||||
|
|
|
|||
16
web/src/composables/useDebounce.ts
Normal file
16
web/src/composables/useDebounce.ts
Normal 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue