Data Table
Vue dataFull-feature table with sorting, filtering, column pinning, pagination, row selection, and an opinionated header/toolbar. Built on TanStack Table — pass `columns` + `data` and configure as needed.
Also available for React ->Installation
$ pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/data-table.json$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/data-table.json$ yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/data-table.json$ bunx shadcn-vue@latest add https://uipkge.dev/r/vue/data-table.json
Or with the named registry:
npx shadcn-vue@latest add @uipkge/data-table
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
columns | ColumnDef<TData, TValue>[] | — | required |
data | TData[] | — | required |
filterColumn | string | — | optional |
filterPlaceholder | string | — | optional |
filters | FilterDefinition[] | — | optional |
filterMode | 'inline''modal''popover' | — | optional |
enableSearch Show the global search input. Default true. | boolean | — | optional |
enableColumnVisibility Show the View column-visibility dropdown. Default true. | boolean | — | optional |
enablePagination Show the pagination footer. Default true. | boolean | — | optional |
hideToolbar Hide the entire toolbar (search + filters + view). Default false. | boolean | — | optional |
enableExport Show Export-CSV button in toolbar. Default false. | boolean | — | optional |
infinite consumer side. | boolean | — | optional |
enableResize Allow drag-to-resize on column borders. | boolean | — | optional |
defaultColumnPinning Initial column pinning. Each column id is pinned to the given side. | { left?: string[], right?: string[] | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
FilterOption interface FilterOption {
value: string
label: string
icon?: any
} FilterDefinition interface FilterDefinition {
column: string
label: string
type: 'text' | 'select' | 'multiselect' | 'date'
options?: (string | FilterOption)[]
} DateRange interface DateRange {
from?: string
to?: string
} DraftDateValue type DraftDateValue { from?: string, to?: string }
type DraftValue = string[] | string | DraftDateValue | undefined
type Draft = Record<string, DraftValue>
function resolveOption(opt: string | FilterOption): FilterOption {
if (typeof opt === 'string') return { value: opt, label: opt }
return opt
} Dependencies
Files (7)
-
app/components/ui/data-table/DataTable.vue 37.5 kB
<script setup lang="ts" generic="TData, TValue"> import type { ColumnDef, ColumnFiltersState, FilterFn, SortingState, VisibilityState, ExpandedState, GroupingState } from '@tanstack/vue-table' import { FlexRender, getCoreRowModel, getExpandedRowModel, getFilteredRowModel, getGroupedRowModel, getPaginationRowModel, getSortedRowModel, useVueTable, } from '@tanstack/vue-table' // `valueUpdater` lives next to the low-level table primitive (which // data-table depends on transitively via registryDependencies). Keep // `@/lib/utils` reserved for the cn() helper shipped by the init // bootstrap -- importing valueUpdater from there would force every // consumer to hand-edit lib/utils.ts on install. import { valueUpdater } from '@/components/ui/table/utils' import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue' import { CalendarDate, type DateValue } from '@internationalized/date' import type { DateRange as RekaDateRange } from 'reka-ui' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import DataTableToolbar from './DataTableToolbar.vue' import DataTableFilterSheet from './DataTableFilterSheet.vue' import DataTablePagination from './DataTablePagination.vue' export interface FilterOption { value: string label: string icon?: any } export interface FilterDefinition { column: string label: string type: 'text' | 'select' | 'multiselect' | 'date' options?: (string | FilterOption)[] } interface DateRange { from?: string to?: string } const props = withDefaults( defineProps<{ columns: ColumnDef<TData, TValue>[] data: TData[] filterColumn?: string filterPlaceholder?: string filters?: FilterDefinition[] filterMode?: 'inline' | 'modal' | 'popover' /** Show the global search input. Default true. */ enableSearch?: boolean /** Show the View column-visibility dropdown. Default true. */ enableColumnVisibility?: boolean /** Show the pagination footer. Default true. */ enablePagination?: boolean /** Hide the entire toolbar (search + filters + view). Default false. */ hideToolbar?: boolean /** Show Export-CSV button in toolbar. Default false. */ enableExport?: boolean /** Infinite-scroll mode: emits `fetch-more` when last row enters viewport, * hides the pagination footer. Combine with append-on-success on the * consumer side. */ infinite?: boolean /** Allow drag-to-resize on column borders. */ enableResize?: boolean /** Initial column pinning. Each column id is pinned to the given side. */ defaultColumnPinning?: { left?: string[], right?: string[] } /** Allow drag-to-reorder on column headers. */ enableReorder?: boolean /** Initial column ids to group by. Pass an empty array to disable grouping. */ defaultGrouping?: string[] /** Virtual-scrolling mode (CSS content-visibility based). Best with large * datasets and `max-height` for a scroll container. */ virtual?: boolean /** Sticky header — keeps `<thead>` visible when scrolling. Pair with `max-height`. */ stickyHeader?: boolean /** Density of cell padding: 'compact' | 'cozy' | 'comfortable'. Default cozy. * Treated as the INITIAL density when `enableDensityToggle` is on; the user * can override it at runtime from the toolbar. */ density?: 'compact' | 'cozy' | 'comfortable' /** Show the density toggle in the toolbar. Default false. */ enableDensityToggle?: boolean /** Strip the DataTable's borders. * - `'inner'` removes toolbar bottom-divider, pagination top-divider, * and filter-sheet section bgs/borders. The outer container border * is kept. * - `'full'` additionally removes the outer container border + rounded * corners so the table renders completely flat on the canvas. */ borderless?: 'inner' | 'full' /** Where the toolbar (search / filters / export / view) renders. * - `'inside'` (default) is the canonical layout: toolbar lives inside * the bordered container above the table, separated by a divider. * - `'above'` floats the toolbar outside the bordered container, so * the table's border only wraps the table (and pagination unless * that is also moved out). Useful when you want the chrome to * feel like a page action bar rather than card-internal. */ toolbarPosition?: 'inside' | 'above' /** Where the pagination footer renders. * - `'inside'` (default): footer sits inside the bordered container, * separated by a top-divider. * - `'below'` floats the footer outside the bordered container. */ paginationPosition?: 'inside' | 'below' /** Max height; enables vertical scroll inside the card. */ maxHeight?: string /** Row click — when set, rows become clickable + cursor-pointer. */ onRowClick?: (row: TData) => void /** Server-side mode: total row count from API (enables manual pagination) */ totalRows?: number /** Server-side mode: loading state indicator */ loading?: boolean }>(), { filterColumn: '', filterPlaceholder: 'Filter...', filters: () => [], filterMode: 'modal', enableSearch: true, enableColumnVisibility: false, enablePagination: true, hideToolbar: false, enableExport: false, infinite: false, enableResize: false, defaultColumnPinning: () => ({ left: [], right: [] }), enableReorder: false, defaultGrouping: () => [], virtual: false, stickyHeader: false, density: 'cozy', enableDensityToggle: false, toolbarPosition: 'inside', paginationPosition: 'inside', maxHeight: '', totalRows: -1, loading: false, }, ) // User-mutable density -- initial value from prop, toggleable via toolbar // when `enableDensityToggle` is on. Watch the prop so parents can still // drive it externally (e.g. saving the user's last choice to a setting). const currentDensity = ref<'compact' | 'cozy' | 'comfortable'>(props.density) watch(() => props.density, (v) => { currentDensity.value = v }) // `borderless` is an enum at the DataTable level (`'inner'` vs `'full'`), but // child components only need to know "should I drop my borders?" -- collapse // to a single boolean for the pass-through. const dropInnerBorders = computed(() => !!props.borderless) const densityClass = computed(() => { if (currentDensity.value === 'compact') return '[&_td]:py-1.5 [&_td]:px-3 [&_td]:text-xs [&_th]:h-8 [&_th]:px-3 [&_th]:text-xs' if (currentDensity.value === 'comfortable') return '[&_td]:py-3 [&_th]:h-12' // cozy -- 8px vertical cell padding, 40px header. Tightens the // TableCell/TableHead defaults (py-3 / h-12) so a cozy DataTable // doesn't feel airy compared with the surrounding canvas. return '[&_td]:py-2 [&_th]:h-10' }) const emit = defineEmits<{ /** Emitted when server-side state changes (pagination, sorting, filters) */ ( e: 'update:state', state: { page: number pageSize: number sortBy: string sortOrder: 'asc' | 'desc' filters: Record<string, any> search: string }, ): void /** Infinite-scroll: last row entered viewport, time to load more */ (e: 'fetch-more'): void }>() const isServerSide = computed(() => props.totalRows >= 0) const isFilterSheetOpen = ref(false) // ── Filter snapshot (restore on close without apply) ───────────────────────── // Shared between the modal Sheet and the inline Popover. Both surfaces stage // edits in the live `columnFilters` ref (so the in-popover "N selected" // badges update as the user clicks). Closing without hitting Apply rolls // back via the snapshot. let filterSnapshot: ColumnFiltersState | null = null let dateRangeSnapshot: Record<string, RekaDateRange> | null = null let filterApplied = false function snapshotFilters() { filterApplied = false filterSnapshot = JSON.parse(JSON.stringify(columnFilters.value)) dateRangeSnapshot = JSON.parse(JSON.stringify(dateRangeModels.value)) } function maybeRestoreFilters() { if (!filterApplied && filterSnapshot) { columnFilters.value = filterSnapshot dateRangeModels.value = dateRangeSnapshot ?? {} } filterSnapshot = null dateRangeSnapshot = null } function onFilterSheetOpen() { snapshotFilters() isFilterSheetOpen.value = true } function onFilterSheetClose(open: boolean) { if (!open) maybeRestoreFilters() isFilterSheetOpen.value = open } // ── Timers for debounce / batching ────────────────────────────────────────── let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null let paginationEmitTimer: ReturnType<typeof setTimeout> | null = null function onSearchInput(val: string) { if (!props.filterColumn || !table.getColumn(props.filterColumn)) return table.getColumn(props.filterColumn)?.setFilterValue(val || undefined) if (isServerSide.value) { if (searchDebounceTimer) clearTimeout(searchDebounceTimer) searchDebounceTimer = setTimeout(() => { table.setPageIndex(0) emitStateUpdate() }, 500) } } // Custom filter functions for multiselect and date range const multiSelectFilterFn: FilterFn<TData> = (row, columnId, filterValue: string[]) => { if (!filterValue || filterValue.length === 0) return true const cellValue = String(row.getValue(columnId)).toLowerCase() return filterValue.some((v: string) => v.toLowerCase() === cellValue) } const dateRangeFilterFn: FilterFn<TData> = (row, columnId, filterValue: DateRange) => { if (!filterValue) return true const { from, to } = filterValue if (!from && !to) return true const cellValue = String(row.getValue(columnId)) if (from && cellValue < from) return false if (to && cellValue > to) return false return true } // Augment columns with custom filter functions based on filter definitions const processedColumns = computed(() => { return props.columns.map((col) => { const colId = (col as any).accessorKey || (col as any).id const filter = props.filters.find(f => f.column === colId) if (!filter) return col if (filter.type === 'multiselect') return { ...col, filterFn: multiSelectFilterFn } if (filter.type === 'date') return { ...col, filterFn: dateRangeFilterFn } return col }) }) const sorting = ref<SortingState>([]) const columnFilters = ref<ColumnFiltersState>([]) const columnVisibility = ref<VisibilityState>({}) const rowSelection = ref({}) const expanded = ref<ExpandedState>({}) const columnPinning = ref({ left: props.defaultColumnPinning.left ?? [], right: props.defaultColumnPinning.right ?? [], }) const columnOrder = ref<string[]>([]) const grouping = ref<GroupingState>(props.defaultGrouping) const pagination = ref({ pageIndex: 0, pageSize: 10 }) const table = useVueTable({ get data() { return props.data }, get columns() { return processedColumns.value }, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: isServerSide.value ? undefined : getPaginationRowModel(), getSortedRowModel: isServerSide.value ? undefined : getSortedRowModel(), getFilteredRowModel: isServerSide.value ? undefined : getFilteredRowModel(), getExpandedRowModel: getExpandedRowModel(), getGroupedRowModel: getGroupedRowModel(), get enableColumnResizing() { return props.enableResize }, columnResizeMode: 'onChange', get manualPagination() { return isServerSide.value }, get manualSorting() { return isServerSide.value }, get manualFiltering() { return isServerSide.value }, get rowCount() { return isServerSide.value ? props.totalRows : undefined }, onSortingChange: (updaterOrValue) => { valueUpdater(updaterOrValue, sorting) if (isServerSide.value) emitStateUpdate() }, onColumnFiltersChange: (updaterOrValue) => { valueUpdater(updaterOrValue, columnFilters) // Server-side: never auto-emit on filter change — handled by Apply button / search debounce // Client-side inline: filters apply locally via TanStack, no emit needed }, onColumnVisibilityChange: updaterOrValue => valueUpdater(updaterOrValue, columnVisibility), onRowSelectionChange: updaterOrValue => valueUpdater(updaterOrValue, rowSelection), onExpandedChange: updaterOrValue => valueUpdater(updaterOrValue, expanded), onPaginationChange: (updaterOrValue) => { valueUpdater(updaterOrValue, pagination) if (isServerSide.value) { // Debounce to batch rapid pagination changes (e.g. setPageSize + setPageIndex) if (paginationEmitTimer) clearTimeout(paginationEmitTimer) paginationEmitTimer = setTimeout(() => emitStateUpdate(), 0) } }, state: { get sorting() { return sorting.value }, get columnFilters() { return columnFilters.value }, get columnVisibility() { return columnVisibility.value }, get pagination() { return pagination.value }, get rowSelection() { return rowSelection.value }, get expanded() { return expanded.value }, get columnPinning() { return columnPinning.value }, get columnOrder() { return columnOrder.value }, get grouping() { return grouping.value }, }, onColumnPinningChange: updaterOrValue => valueUpdater(updaterOrValue, columnPinning), onColumnOrderChange: updaterOrValue => valueUpdater(updaterOrValue, columnOrder), onGroupingChange: updaterOrValue => valueUpdater(updaterOrValue, grouping), }) /** Build and emit current server-side state */ function emitStateUpdate() { const s = sorting.value[0] const filterMap: Record<string, any> = {} for (const cf of columnFilters.value) { filterMap[cf.id] = cf.value } emit('update:state', { page: table.getState().pagination.pageIndex + 1, pageSize: table.getState().pagination.pageSize, sortBy: s?.id || '', sortOrder: s?.desc ? 'desc' : 'asc', filters: filterMap, search: (props.filterColumn && (table.getColumn(props.filterColumn)?.getFilterValue() as string)) || '', }) } // ── Filter helpers ─────────────────────────────────────────────────────────── function getMultiSelectValue(column: string): string[] { return (table.getColumn(column)?.getFilterValue() as string[]) ?? [] } function getDateRangeValue(column: string): DateRange { return (table.getColumn(column)?.getFilterValue() as DateRange) ?? {} } // ── Date conversion helpers ────────────────────────────────────────────────── function stringToDateValue(str: string): DateValue | undefined { if (!str) return undefined const [y, m, d] = str.split('-').map(Number) if (y === undefined || m === undefined || d === undefined) return undefined return new CalendarDate(y, m, d) } function dateValueToString(dv: DateValue | undefined): string { if (!dv) return '' return `${dv.year}-${String(dv.month).padStart(2, '0')}-${String(dv.day).padStart(2, '0')}` } /** Per-filter reactive range calendar model, keyed by column name */ const dateRangeModels = ref<Record<string, RekaDateRange>>({}) function getCalendarModel(column: string): RekaDateRange { if (!dateRangeModels.value[column]) { const dr = getDateRangeValue(column) dateRangeModels.value[column] = { start: stringToDateValue(dr.from ?? '') as DateValue, end: stringToDateValue(dr.to ?? '') as DateValue, } } return dateRangeModels.value[column] } function onCalendarUpdate(column: string, val: RekaDateRange) { dateRangeModels.value[column] = val const from = val.start ? dateValueToString(val.start) : undefined const to = val.end ? dateValueToString(val.end) : undefined const hasValue = from || to table.getColumn(column)?.setFilterValue(hasValue ? { from, to } : undefined) } function formatDateRange(column: string): string { const dr = getDateRangeValue(column) if (dr.from && dr.to) return `${dr.from} - ${dr.to}` if (dr.from) return `From ${dr.from}` if (dr.to) return `Until ${dr.to}` return '' } function toggleMultiSelectValue(column: string, option: string) { const current = getMultiSelectValue(column) const next = current.includes(option) ? current.filter(v => v !== option) : [...current, option] table.getColumn(column)?.setFilterValue(next.length > 0 ? next : undefined) } function clearAllFilters() { filterApplied = true if (searchDebounceTimer) clearTimeout(searchDebounceTimer) props.filters.forEach((f) => { table.getColumn(f.column)?.setFilterValue(undefined) if (f.type === 'date') { dateRangeModels.value[f.column] = { start: undefined as any, end: undefined as any } } }) if (props.filterColumn && table.getColumn(props.filterColumn)) { table.getColumn(props.filterColumn)?.setFilterValue(undefined) } if (isServerSide.value) { table.setPageIndex(0) nextTick(() => emitStateUpdate()) } } function isFilterActive(filter: FilterDefinition): boolean { const value = table.getColumn(filter.column)?.getFilterValue() if (value === undefined || value === '') return false if (filter.type === 'multiselect') return Array.isArray(value) && value.length > 0 if (filter.type === 'date') { const d = value as DateRange return !!(d.from || d.to) } return true } const isAnyFilterActive = computed(() => { const hasSearchValue = !!(props.filterColumn && (table.getColumn(props.filterColumn)?.getFilterValue() as string)) return hasSearchValue || props.filters.some(isFilterActive) }) function getFilterSelectedLabels(filter: FilterDefinition): string[] { const vals = getMultiSelectValue(filter.column) return vals.map((v) => { const opt = filter.options?.find(o => resolveOption(o).value === v) return opt ? resolveOption(opt).label : v }) } function resolveOption(opt: string | FilterOption): FilterOption { if (typeof opt === 'string') return { value: opt, label: opt } return opt } function clearFilter(filter: FilterDefinition) { table.getColumn(filter.column)?.setFilterValue(undefined) } const activeFilterCount = computed(() => { return props.filters.filter(isFilterActive).length }) function clearDateFilter(filter: FilterDefinition) { clearFilter(filter) dateRangeModels.value[filter.column] = { start: undefined as any, end: undefined as any } } function onApplyFilters() { filterApplied = true if (isServerSide.value) { if (table.getState().pagination.pageIndex === 0) { emitStateUpdate() } else { table.setPageIndex(0) } } isFilterSheetOpen.value = false } /** * Popover filter mode: handle the staged-edit commit. The popover never * mutates `columnFilters` during interaction — it builds a draft locally * and emits it here on Apply. Walk the draft, write each column's value * via `setFilterValue`, and sync the calendar model for date filters so * a subsequent reopen reflects the just-applied range. */ function onCommitDraft(draft: Record<string, any>) { for (const f of props.filters) { if (!(f.column in draft)) continue const val = draft[f.column] if (f.type === 'date') { const dr = (val ?? {}) as DateRange dateRangeModels.value[f.column] = { start: stringToDateValue(dr.from ?? '') as DateValue, end: stringToDateValue(dr.to ?? '') as DateValue, } table.getColumn(f.column)?.setFilterValue(val) } else { table.getColumn(f.column)?.setFilterValue(val) } } // Reuse the same apply-side bookkeeping (server-side re-fetch + // pagination reset). filterApplied flips so any stale Sheet snapshot // doesn't try to roll back. onApplyFilters() } // Column reorder — HTML5 drag/drop swaps the dragged column with the drop target. const dragColId = ref<string | null>(null) const dragOverColId = ref<string | null>(null) function onColDragStart(id: string, e: DragEvent) { dragColId.value = id document.body.style.cursor = 'grabbing' if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', id) } } function onColDragOver(targetId: string, e: DragEvent) { if (!props.enableReorder || !dragColId.value) return e.preventDefault() dragOverColId.value = targetId if (e.dataTransfer) e.dataTransfer.dropEffect = 'move' } function onColDragEnd() { document.body.style.cursor = '' dragColId.value = null dragOverColId.value = null } function onColDrop(targetId: string) { const src = dragColId.value onColDragEnd() if (!src || src === targetId) return const order = (columnOrder.value.length ? columnOrder.value : table.getAllLeafColumns().map(c => c.id)).slice() const from = order.indexOf(src) const to = order.indexOf(targetId) if (from === -1 || to === -1) return order.splice(to, 0, ...order.splice(from, 1)) columnOrder.value = order } // Pinning — return style for sticky pinned cells (header or body) function pinStyle(col: any): Record<string, string> | undefined { const side = col.getIsPinned() if (!side) return undefined if (side === 'left') return { position: 'sticky', left: `${col.getStart('left')}px`, zIndex: '2' } return { position: 'sticky', right: `${col.getAfter('right')}px`, zIndex: '2' } } // Infinite scroll — sentinel row triggers fetch-more when visible const sentinelEl = ref<HTMLElement | null>(null) let io: IntersectionObserver | null = null onMounted(() => { if (!props.infinite || !sentinelEl.value) return io = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting) emit('fetch-more') }, { rootMargin: '100px' }, ) io.observe(sentinelEl.value) }) onBeforeUnmount(() => { io?.disconnect() io = null }) watch( () => props.infinite, (on) => { if (on && sentinelEl.value && !io) { io = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting) emit('fetch-more') }, { rootMargin: '100px' }, ) io.observe(sentinelEl.value) } else if (!on) { io?.disconnect() io = null } }, ) // CSV export — uses currently filtered + visible data, skips select/actions cols. function exportCsv() { const visibleCols = table .getVisibleLeafColumns() .filter(c => c.id !== 'select' && c.id !== 'actions' && c.id !== 'expander') const headers = visibleCols.map(c => String(c.columnDef.header && typeof c.columnDef.header === 'string' ? c.columnDef.header : c.id), ) const rows = table.getFilteredRowModel().rows.map(row => visibleCols.map((c) => { const v = row.getValue(c.id) const s = v == null ? '' : String(v) return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s }), ) const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n') const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `export-${Date.now()}.csv` document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } // JSON export -- same row/column scope as CSV, but emits the underlying // row.original objects so downstream tools get typed data, not strings. function exportJson() { const visibleColIds = table .getVisibleLeafColumns() .filter(c => c.id !== 'select' && c.id !== 'actions' && c.id !== 'expander') .map(c => c.id) const rows = table.getFilteredRowModel().rows.map((row) => { const original = row.original as Record<string, unknown> // When the page projects via accessorKey, original keys match. When it // uses accessorFn (eg. a synthesized "name" column), surface that under // the column id so consumers see the same shape they exported. const out: Record<string, unknown> = {} for (const id of visibleColIds) { out[id] = id in original ? original[id] : row.getValue(id) } return out }) const json = JSON.stringify(rows, null, 2) const blob = new Blob([json], { type: 'application/json;charset=utf-8;' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `export-${Date.now()}.json` document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } defineExpose({ table, exportCsv, exportJson }) </script> <template> <!-- ClientOnly: the TanStack table renders identically on server and client in principle, but its interactive Reka-based controls resolve internal state in the browser, producing benign hydration mismatches. SSR of an interactive data grid adds little, so render it client-side. --> <ClientOnly> <div data-uipkge data-slot="data-table" class="w-full" > <!-- Toolbar (above-mode: floats outside the bordered card, no bottom-divider since nothing sits directly below it inside the same surface). --> <DataTableToolbar v-if="!hideToolbar && toolbarPosition === 'above'" :table="table" :filter-column="filterColumn" :filter-placeholder="filterPlaceholder" :filters="filters" :filter-mode="filterMode" :enable-search="enableSearch" :enable-column-visibility="enableColumnVisibility" :enable-export="enableExport" :enable-density-toggle="enableDensityToggle" :density="currentDensity" :borderless="true" :active-filter-count="activeFilterCount" :is-any-filter-active="isAnyFilterActive" :is-server-side="isServerSide" :get-multi-select-value="getMultiSelectValue" :get-date-range-value="getDateRangeValue" :get-filter-selected-labels="getFilterSelectedLabels" :format-date-range="formatDateRange" :get-calendar-model="getCalendarModel" @search="onSearchInput" @open-filter-sheet="onFilterSheetOpen" @apply-filters="onApplyFilters" @clear-all-filters="clearAllFilters" @toggle-multiselect="toggleMultiSelectValue" @clear-filter="clearFilter" @clear-date-filter="clearDateFilter" @calendar-update="onCalendarUpdate" @text-filter-update="(col, val) => table.getColumn(col)?.setFilterValue(val)" @commit-filters="onCommitDraft" @export-csv="exportCsv" @export-json="exportJson" @update:density="(d: 'compact' | 'cozy' | 'comfortable') => (currentDensity = d)" > <template v-if="$slots['toolbar-extra']" #toolbar-extra > <slot name="toolbar-extra" :table="table" /> </template> <template v-if="$slots['custom-filters']" #custom-filters > <slot name="custom-filters" :table="table" /> </template> </DataTableToolbar> <div :class="[ 'bg-card text-card-foreground overflow-hidden', borderless === 'full' ? '' : 'rounded-md border', ]" > <!-- Toolbar (inside-mode, default): canonical card layout, toolbar sits at the top of the bordered card with its bottom-divider. --> <DataTableToolbar v-if="!hideToolbar && toolbarPosition === 'inside'" :table="table" :filter-column="filterColumn" :filter-placeholder="filterPlaceholder" :filters="filters" :filter-mode="filterMode" :enable-search="enableSearch" :enable-column-visibility="enableColumnVisibility" :enable-export="enableExport" :enable-density-toggle="enableDensityToggle" :density="currentDensity" :borderless="dropInnerBorders" :active-filter-count="activeFilterCount" :is-any-filter-active="isAnyFilterActive" :is-server-side="isServerSide" :get-multi-select-value="getMultiSelectValue" :get-date-range-value="getDateRangeValue" :get-filter-selected-labels="getFilterSelectedLabels" :format-date-range="formatDateRange" :get-calendar-model="getCalendarModel" @search="onSearchInput" @open-filter-sheet="onFilterSheetOpen" @apply-filters="onApplyFilters" @clear-all-filters="clearAllFilters" @toggle-multiselect="toggleMultiSelectValue" @clear-filter="clearFilter" @clear-date-filter="clearDateFilter" @calendar-update="onCalendarUpdate" @text-filter-update="(col, val) => table.getColumn(col)?.setFilterValue(val)" @commit-filters="onCommitDraft" @export-csv="exportCsv" @export-json="exportJson" @update:density="(d: 'compact' | 'cozy' | 'comfortable') => (currentDensity = d)" > <template v-if="$slots['toolbar-extra']" #toolbar-extra > <slot name="toolbar-extra" :table="table" /> </template> <template v-if="$slots['custom-filters']" #custom-filters > <slot name="custom-filters" :table="table" /> </template> </DataTableToolbar> <!-- Filter Sheet (modal mode) --> <DataTableFilterSheet :open="isFilterSheetOpen" :table="table" :filters="filters" :active-filter-count="activeFilterCount" :is-any-filter-active="isAnyFilterActive" :is-server-side="isServerSide" :borderless="dropInnerBorders" :get-multi-select-value="getMultiSelectValue" :get-date-range-value="getDateRangeValue" :format-date-range="formatDateRange" :get-calendar-model="getCalendarModel" @update:open="onFilterSheetClose" @apply="onApplyFilters" @clear-all="clearAllFilters" @toggle-multiselect="toggleMultiSelectValue" @clear-filter="clearFilter" @clear-date-filter="clearDateFilter" @calendar-update="onCalendarUpdate" @text-filter-update="(col, val) => table.getColumn(col)?.setFilterValue(val)" > <template v-if="$slots['custom-filters']" #custom-filters > <slot name="custom-filters" :table="table" /> </template> </DataTableFilterSheet> <!-- Bulk action bar (slot above table when rows selected) --> <div v-if="$slots['bulk-actions'] && table.getSelectedRowModel().rows.length > 0" class="bg-muted/40 flex items-center gap-3 border-b px-4 py-2" > <span class="text-sm font-medium"> {{ table.getSelectedRowModel().rows.length }} selected </span> <slot name="bulk-actions" :rows="table.getSelectedRowModel().rows" :clear="() => table.toggleAllRowsSelected(false)" /> <button type="button" class="text-muted-foreground hover:text-foreground ml-auto text-xs transition" @click="table.toggleAllRowsSelected(false)" > Clear selection </button> </div> <!-- Table. The Table primitive wraps the <table> in its own overflow-auto div which would normally trap sticky-positioned children + draw native scrollbars. Neutralize that inner overflow and let OverlayScrollbarsComponent own both axes so we get a single, themed, auto-hiding pair of scrollbars across the whole region. --> <div v-os-scroll :class="[densityClass, 'relative [&_[data-slot=table-container]]:overflow-visible']" :style="maxHeight ? `max-height: ${maxHeight}` : ''" > <!-- Loading overlay --> <div v-if="loading" class="bg-card/60 absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[1px]" > <div class="border-primary size-5 animate-spin rounded-full border-2 border-t-transparent" /> </div> <Table> <TableHeader :class="stickyHeader ? 'bg-muted/95 sticky top-0 z-10 backdrop-blur-sm' : ''"> <TableRow v-for="headerGroup in table.getHeaderGroups()" :key="headerGroup.id" > <TableHead v-for="header in headerGroup.headers" :key="header.id" class="relative transition-colors duration-150" :class="[ enableReorder && header.column.id !== 'select' && header.column.id !== 'actions' && header.column.id !== 'expander' ? 'cursor-grab active:cursor-grabbing' : '', dragColId === header.column.id ? 'opacity-50' : '', dragOverColId === header.column.id && dragColId !== header.column.id ? 'border-foreground/60 border-l-2' : '', ]" :style="{ ...(enableResize ? { width: `${header.getSize()}px` } : {}), ...(pinStyle(header.column) ?? {}), }" :draggable=" enableReorder && header.column.id !== 'select' && header.column.id !== 'actions' && header.column.id !== 'expander' " @dragstart="enableReorder ? onColDragStart(header.column.id, $event) : undefined" @dragover="enableReorder ? onColDragOver(header.column.id, $event) : undefined" @dragend="enableReorder ? onColDragEnd() : undefined" @drop="enableReorder ? onColDrop(header.column.id) : undefined" > <FlexRender v-if="!header.isPlaceholder" :render="header.column.columnDef.header" :props="header.getContext()" /> <div v-if="enableResize && header.column.getCanResize()" class="hover:bg-foreground/30 absolute top-0 right-0 h-full w-1 cursor-col-resize touch-none transition-colors select-none" :class="header.column.getIsResizing() ? 'bg-foreground/60' : ''" @mousedown="header.getResizeHandler()?.($event)" @touchstart="header.getResizeHandler()?.($event)" /> </TableHead> </TableRow> </TableHeader> <TableBody> <template v-if="table.getRowModel().rows?.length"> <template v-for="row in table.getRowModel().rows" :key="row.id" > <TableRow :data-state="row.getIsSelected() ? 'selected' : undefined" :class="onRowClick ? 'hover:bg-muted/40 cursor-pointer' : ''" :style="virtual ? 'content-visibility: auto; contain-intrinsic-size: auto 48px;' : undefined" @click="onRowClick?.(row.original)" > <TableCell v-for="cell in row.getVisibleCells()" :key="cell.id" class="bg-card" :style="pinStyle(cell.column)" > <FlexRender :render="cell.column.columnDef.cell" :props="cell.getContext()" /> </TableCell> </TableRow> <TableRow v-if="row.getIsExpanded() && $slots.expanded" :key="`${row.id}-expanded`" > <TableCell :colspan="row.getVisibleCells().length" class="bg-muted/30 px-6 py-4" > <slot name="expanded" :row="row.original" :tanstack-row="row" /> </TableCell> </TableRow> </template> </template> <template v-else> <TableRow> <TableCell :colspan="columns.length" class="h-24 text-center" > <slot name="empty"> <div class="text-muted-foreground text-sm"> No results. </div> </slot> </TableCell> </TableRow> </template> </TableBody> <tfoot v-if="$slots.footer" class="bg-muted/20 sticky bottom-0 border-t" > <slot name="footer" :rows="table.getRowModel().rows" /> </tfoot> </Table> <!-- Infinite scroll sentinel --> <div v-if="infinite" ref="sentinelEl" class="h-1" /> <div v-if="infinite && loading" class="border-border text-muted-foreground border-t px-4 py-3 text-center text-sm" > Loading more… </div> </div> <!-- Pagination (inside-mode, default) --> <DataTablePagination v-if="enablePagination && !infinite && paginationPosition === 'inside'" :table="table" :total-rows="totalRows" :is-server-side="isServerSide" :borderless="dropInnerBorders" /> </div> <!-- Pagination (below-mode: floats outside the bordered card, dividerless since nothing sits directly above it on the same surface). --> <DataTablePagination v-if="enablePagination && !infinite && paginationPosition === 'below'" :table="table" :total-rows="totalRows" :is-server-side="isServerSide" :borderless="true" /> </div> </ClientOnly> </template> -
app/components/ui/data-table/DataTableColumnHeader.vue 10.5 kB
<script setup lang="ts" generic="TData, TValue"> /** * Sortable / hideable header cell for TanStack DataTable columns. Use it from * a column definition like: * header: ({ column }) => h(DataTableColumnHeader, { column, label: 'Email' }) * * Click on the label cycles sort asc → desc → none. * * Optional per-column filter: * header: ({ column }) => h(DataTableColumnHeader, { * column, * label: 'Status', * filter: { column: 'status', label: 'Status', type: 'multiselect', options: [...] } * }) * * When `filter` is provided, a funnel icon renders next to the sort button. * Click opens a popover with the right filter UI for the type (text / select * / multiselect / date). The funnel shows a primary-coloured dot when the * column has an active filter. */ import { computed, ref, watch } from 'vue' import type { Column } from '@tanstack/vue-table' import { ArrowDown, ArrowUp, ArrowUpDown, Check, Filter, FilterX } from 'lucide-vue-next' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { RangeCalendar } from '@/components/ui/range-calendar' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { parseDate, CalendarDate, type DateValue } from '@internationalized/date' interface FilterOption { value: string label: string } interface FilterDefinition { column: string label: string type: 'text' | 'select' | 'multiselect' | 'date' options?: (string | FilterOption)[] } const props = defineProps<{ column: Column<TData, TValue> label: string align?: 'left' | 'right' | 'center' filter?: FilterDefinition class?: string }>() const align = computed(() => props.align ?? 'left') function next() { const current = props.column.getIsSorted() if (!current) props.column.toggleSorting(false) else if (current === 'asc') props.column.toggleSorting(true) else props.column.clearSorting() } function resolveOption(opt: string | FilterOption): FilterOption { return typeof opt === 'string' ? { value: opt, label: opt } : opt } // Filter state read directly off the TanStack column so the popover stays // reactive to external clears (toolbar "Clear all", row-level edits, etc.). const open = ref(false) const filterValue = computed(() => props.column.getFilterValue()) const isFilterActive = computed(() => { const v = filterValue.value if (v === undefined || v === null || v === '') return false if (Array.isArray(v)) return v.length > 0 if (typeof v === 'object') return Object.keys(v).length > 0 return true }) // Text-input bound separately so we can apply on blur / Enter rather than // thrashing the column filter on every keystroke. const textDraft = ref('') watch(open, (isOpen) => { if (isOpen && props.filter?.type === 'text') { textDraft.value = (filterValue.value as string) ?? '' } }) function applyText() { props.column.setFilterValue(textDraft.value || undefined) } function clearText() { textDraft.value = '' props.column.setFilterValue(undefined) } function toggleMultiselect(value: string) { const current = (filterValue.value as string[]) ?? [] const next = current.includes(value) ? current.filter((v) => v !== value) : [...current, value] props.column.setFilterValue(next.length > 0 ? next : undefined) } function selectOne(value: string) { props.column.setFilterValue(value || undefined) open.value = false } function clearFilter() { props.column.setFilterValue(undefined) textDraft.value = '' } // Date-range bridging. The column stores ISO strings; the calendar wants // CalendarDate instances. Reads/writes both shapes. const dateModel = computed({ get() { const v = filterValue.value as { from?: string; to?: string } | undefined if (!v?.from && !v?.to) return undefined try { return { start: v.from ? parseDate(v.from) : undefined, end: v.to ? parseDate(v.to) : undefined, } } catch { return undefined } }, set(next: { start?: DateValue; end?: DateValue } | undefined) { const isoFrom = next?.start ? (next.start as CalendarDate).toString() : undefined const isoTo = next?.end ? (next.end as CalendarDate).toString() : undefined if (!isoFrom && !isoTo) { props.column.setFilterValue(undefined) } else { props.column.setFilterValue({ from: isoFrom, to: isoTo }) } }, }) function selectedLabels(): string[] { const f = props.filter if (!f?.options) return [] const selected = (filterValue.value as string[]) ?? [] return f.options .map(resolveOption) .filter((o) => selected.includes(o.value)) .map((o) => o.label) } </script> <template> <div :class=" cn( 'flex items-center gap-0.5', align === 'right' && 'justify-end', align === 'center' && 'justify-center', props.class, ) " > <button type="button" v-if="column.getCanSort()" class="group text-muted-foreground hover:text-foreground hover:bg-muted/60 -mx-2 inline-flex items-center gap-1.5 rounded px-2 py-1 text-sm font-medium transition-colors duration-150" :aria-label="`Sort by ${label}`" @click="next" > <span>{{ label }}</span> <ArrowUp v-if="column.getIsSorted()" class="text-foreground size-3.5 transition-transform duration-200 ease-in-out" :class="column.getIsSorted() === 'desc' ? 'rotate-180' : 'rotate-0'" /> <ArrowUpDown v-else class="size-3.5 opacity-40 transition-opacity duration-150 group-hover:opacity-70" /> </button> <span v-else class="text-muted-foreground text-sm font-medium">{{ label }}</span> <!-- Optional per-column header filter --> <Popover v-if="filter" v-model:open="open"> <PopoverTrigger as-child> <button type="button" :class=" cn( 'text-muted-foreground relative inline-flex size-6 items-center justify-center rounded transition-colors', 'hover:text-foreground hover:bg-muted/60', isFilterActive && 'text-foreground', ) " :aria-label="`Filter ${label}`" > <Filter class="size-3.5" /> <span v-if="isFilterActive" aria-hidden class="bg-primary absolute -top-0.5 -right-0.5 size-1.5 rounded-full" /> </button> </PopoverTrigger> <PopoverContent class="w-72 p-0" align="start"> <div class="border-border flex items-center justify-between border-b px-3 py-2 text-xs font-medium"> <span>Filter · {{ filter.label }}</span> <button type="button" v-if="isFilterActive" class="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 transition-colors" @click="clearFilter" > <FilterX class="size-3" /> Clear </button> </div> <!-- TEXT --> <div v-if="filter.type === 'text'" class="space-y-2 p-3"> <Input v-model="textDraft" :placeholder="`Search ${filter.label.toLowerCase()}…`" class="h-9" @keydown.enter=" () => { applyText() open = false } " @blur="applyText" /> <div class="flex gap-2"> <Button size="sm" class="h-8 flex-1" @click=" () => { applyText() open = false } " >Apply</Button > <Button size="sm" variant="outline" class="h-8" @click="clearText()">Clear</Button> </div> </div> <!-- SELECT / MULTISELECT --> <Command v-else-if="filter.type === 'select' || filter.type === 'multiselect'" class="max-h-[280px]"> <CommandInput :placeholder="`Search ${filter.label.toLowerCase()}…`" class="h-9" /> <CommandList> <CommandEmpty>No matches.</CommandEmpty> <CommandGroup> <template v-for="opt in filter.options ?? []" :key="resolveOption(opt).value"> <CommandItem :value="resolveOption(opt).value" @select=" filter.type === 'multiselect' ? toggleMultiselect(resolveOption(opt).value) : selectOne(resolveOption(opt).value) " > <div v-if="filter.type === 'multiselect'" :class=" cn( 'border-primary/50 mr-2 flex size-4 items-center justify-center rounded-sm border transition-colors', ((filterValue as string[]) ?? []).includes(resolveOption(opt).value) ? 'bg-primary text-primary-foreground' : 'opacity-50', ) " > <Check class="size-3" /> </div> <Check v-else :class="cn('mr-2 size-4', filterValue === resolveOption(opt).value ? 'opacity-100' : 'opacity-0')" /> <span>{{ resolveOption(opt).label }}</span> </CommandItem> </template> </CommandGroup> </CommandList> </Command> <!-- DATE RANGE --> <div v-else-if="filter.type === 'date'" class="p-2"> <RangeCalendar v-model="dateModel as any" /> <div class="flex gap-2 px-1 pt-2"> <Button size="sm" class="h-8 flex-1" @click="open = false">Apply</Button> <Button size="sm" variant="outline" class="h-8" @click="clearFilter">Clear</Button> </div> </div> <!-- Active selection summary --> <div v-if="isFilterActive && (filter.type === 'multiselect' || filter.type === 'select')" class="border-border text-muted-foreground border-t px-3 py-2 text-[11px]" > <span v-if="filter.type === 'multiselect'"> {{ selectedLabels().length }} selected: <span class="text-foreground">{{ selectedLabels().join(', ') }}</span> </span> <span v-else> <span class="text-foreground">{{ filterValue }}</span> </span> </div> </PopoverContent> </Popover> </div> </template> -
app/components/ui/data-table/DataTableFilterPopover.vue 17.3 kB
<script setup lang="ts"> /** * Popover variant of DataTableFilterSheet. Same filter UI (text / * multiselect / date) packed into a Popover instead of a side Sheet. * Slot in via `filterMode="popover"` on <DataTable>. * * Trigger button is rendered inline by the toolbar; this component owns * the popover surface and content. * * ── Draft semantics ────────────────────────────────────────────────── * Unlike the Sheet (which mutates live TanStack column filters and rolls * back on cancel via a parent snapshot), this popover stages every edit * inside a local `draft` map. Real `columnFilters` are never touched * until the user clicks Apply -- at which point we emit `commit-draft` * with the full draft and the parent walks the entries calling * `setFilterValue` per column. Closing the popover (Escape / click out) * simply discards the draft. * * Why: ticking a checkbox inside the popover used to write straight to * `columnFilters`, causing the underlying table to flash filtered before * the user committed. Truly staging the edits avoids that flash and * makes "Reset" / per-section "Clear" behave intuitively (they clear * the draft, not the live table). */ import type { Table } from '@tanstack/vue-table' import type { FilterDefinition, FilterOption } from './DataTable.vue' import type { DateValue } from '@internationalized/date' import type { DateRange as RekaDateRange } from 'reka-ui' import { computed, ref, watch } from 'vue' import { CalendarDate } from '@internationalized/date' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { RangeCalendar } from '@/components/ui/range-calendar' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { Check, SlidersHorizontal, X } from 'lucide-vue-next' type DraftDateValue = { from?: string, to?: string } type DraftValue = string[] | string | DraftDateValue | undefined type Draft = Record<string, DraftValue> function resolveOption(opt: string | FilterOption): FilterOption { if (typeof opt === 'string') return { value: opt, label: opt } return opt } const props = defineProps<{ table: Table<any> filters: FilterDefinition[] activeFilterCount: number isAnyFilterActive: boolean isServerSide: boolean getMultiSelectValue: (column: string) => string[] getDateRangeValue: (column: string) => { from?: string, to?: string } formatDateRange: (column: string) => string getCalendarModel: (column: string) => any }>() const open = ref(false) const emit = defineEmits<{ (e: 'clear-all'): void // Committed draft on Apply -- a `Record<columnId, value>` where each // value is the final shape TanStack's `setFilterValue` expects // (multiselect: string[]|undefined, text: string|undefined, // date: {from?,to?}|undefined). (e: 'commit-draft', draft: Draft): void (e: 'open'): void }>() // ── Draft state ────────────────────────────────────────────────────── // Seeded from real column filter values when the popover opens; cleared // when it closes. All in-popover UI binds to this map -- never to the // live TanStack filter values directly. const draft = ref<Draft>({}) function seedDraft() { const next: Draft = {} for (const f of props.filters) { if (f.type === 'multiselect' || f.type === 'select') { next[f.column] = [...props.getMultiSelectValue(f.column)] } else if (f.type === 'date') { const dr = props.getDateRangeValue(f.column) next[f.column] = { from: dr.from, to: dr.to } } else if (f.type === 'text') { const v = props.table.getColumn(f.column)?.getFilterValue() as string | undefined next[f.column] = v ?? '' } } draft.value = next } function getDraftMulti(column: string): string[] { const v = draft.value[column] return Array.isArray(v) ? v : [] } function getDraftText(column: string): string { const v = draft.value[column] return typeof v === 'string' ? v : '' } function getDraftDate(column: string): DraftDateValue { const v = draft.value[column] if (v && typeof v === 'object' && !Array.isArray(v)) return v as DraftDateValue return {} } function toggleDraftMulti(column: string, option: string) { const current = getDraftMulti(column) const next = current.includes(option) ? current.filter(v => v !== option) : [...current, option] draft.value = { ...draft.value, [column]: next } } function setDraftText(column: string, value: string) { draft.value = { ...draft.value, [column]: value } } function clearDraftSection(filter: FilterDefinition) { if (filter.type === 'multiselect' || filter.type === 'select') { draft.value = { ...draft.value, [filter.column]: [] } } else if (filter.type === 'date') { draft.value = { ...draft.value, [filter.column]: {} } } else if (filter.type === 'text') { draft.value = { ...draft.value, [filter.column]: '' } } } function resetDraft() { const next: Draft = {} for (const f of props.filters) { if (f.type === 'multiselect' || f.type === 'select') next[f.column] = [] else if (f.type === 'date') next[f.column] = {} else if (f.type === 'text') next[f.column] = '' } draft.value = next } // ── Date helpers (local; popover is fully self-contained for draft) ── function stringToDateValue(str: string | undefined): DateValue | undefined { if (!str) return undefined const [y, m, d] = str.split('-').map(Number) if (y === undefined || m === undefined || d === undefined) return undefined return new CalendarDate(y, m, d) } function dateValueToString(dv: DateValue | undefined): string { if (!dv) return '' return `${dv.year}-${String(dv.month).padStart(2, '0')}-${String(dv.day).padStart(2, '0')}` } function getDraftCalendarModel(column: string): RekaDateRange { const dr = getDraftDate(column) return { start: stringToDateValue(dr.from) as DateValue, end: stringToDateValue(dr.to) as DateValue, } } function onDraftCalendarUpdate(column: string, val: RekaDateRange) { const from = val?.start ? dateValueToString(val.start) : undefined const to = val?.end ? dateValueToString(val.end) : undefined draft.value = { ...draft.value, [column]: { from, to } } } function formatDraftDateRange(column: string): string { const dr = getDraftDate(column) if (dr.from && dr.to) return `${dr.from} - ${dr.to}` if (dr.from) return `From ${dr.from}` if (dr.to) return `Until ${dr.to}` return '' } // ── Active section detection (draft, not live) ─────────────────────── function isDraftSectionActive(filter: FilterDefinition): boolean { if (filter.type === 'multiselect' || filter.type === 'select') { return getDraftMulti(filter.column).length > 0 } if (filter.type === 'date') { const dr = getDraftDate(filter.column) return !!(dr.from || dr.to) } if (filter.type === 'text') { return !!getDraftText(filter.column) } return false } // `Reset` is disabled when the draft has nothing to clear. const isDraftDirty = computed(() => props.filters.some(isDraftSectionActive)) // ── Popover lifecycle ──────────────────────────────────────────────── watch(open, (isOpen, wasOpen) => { if (isOpen && !wasOpen) { seedDraft() emit('open') return } if (!isOpen && wasOpen) { // Close-without-apply: nothing to do. Real column filters were never // mutated by the popover; the draft is local and can be left to // garbage-collect (next open re-seeds from live state). draft.value = {} } }) function applyAndClose() { // Snapshot the draft so the parent receives a stable object even if // the close watcher fires before they finish consuming it. const snapshot: Draft = {} for (const f of props.filters) { const v = draft.value[f.column] if (f.type === 'multiselect' || f.type === 'select') { const arr = Array.isArray(v) ? v : [] snapshot[f.column] = arr.length > 0 ? arr : undefined } else if (f.type === 'date') { const dr = (v && typeof v === 'object' && !Array.isArray(v)) ? v as DraftDateValue : {} snapshot[f.column] = (dr.from || dr.to) ? { from: dr.from, to: dr.to } : undefined } else if (f.type === 'text') { const s = typeof v === 'string' ? v : '' snapshot[f.column] = s || undefined } } emit('commit-draft', snapshot) open.value = false } function onResetClick() { resetDraft() } const filterScrollRef = ref<HTMLElement | null>(null) watch(open, (isOpen) => { if (isOpen) { setTimeout(() => { filterScrollRef.value?.scrollTo({ top: 0 }) }, 50) } }) </script> <template> <Popover v-model:open="open"> <PopoverTrigger as-child> <Button variant="outline" size="sm" class="h-9 gap-2" > <SlidersHorizontal class="size-3.5" /> Filters <Badge v-if="activeFilterCount > 0" variant="secondary" class="ml-1 h-5 min-w-5 rounded-full px-1.5 text-xs font-semibold" > {{ activeFilterCount }} </Badge> </Button> </PopoverTrigger> <PopoverContent align="start" class="flex max-h-[min(560px,80vh)] w-[380px] flex-col overflow-hidden p-0" > <!-- Header --> <div class="border-b px-4 pt-3.5 pb-3"> <div class="flex items-center gap-2.5"> <div class="bg-muted flex size-7 items-center justify-center rounded-md"> <SlidersHorizontal class="text-muted-foreground size-3.5" /> </div> <div class="flex-1"> <p class="text-sm leading-none font-semibold"> Filters </p> <p class="text-muted-foreground mt-1 text-xs"> <template v-if="activeFilterCount > 0"> {{ activeFilterCount }} active <template v-if="!isServerSide"> · {{ table.getFilteredRowModel().rows.length }} result{{ table.getFilteredRowModel().rows.length !== 1 ? 's' : '' }} </template> </template> <template v-else> Narrow down results </template> </p> </div> </div> </div> <!-- Scrollable filter sections --> <div ref="filterScrollRef" class="flex-1 overflow-y-auto" > <div class="space-y-2 p-3"> <template v-for="filter in filters" :key="filter.column" > <!-- Text filter --> <div v-if="filter.type === 'text'" class="bg-muted/40 rounded-lg p-2.5" > <div class="mb-2 flex items-center justify-between"> <Label class="text-muted-foreground text-xs font-medium tracking-wide uppercase">{{ filter.label }}</Label> <button v-if="getDraftText(filter.column)" type="button" class="text-muted-foreground hover:text-foreground text-xs transition-colors" @click="clearDraftSection(filter)" > Clear </button> </div> <Input :placeholder="`Filter by ${filter.label.toLowerCase()}...`" :model-value="getDraftText(filter.column)" class="h-8 text-sm" @update:model-value="setDraftText(filter.column, ($event as string) ?? '')" /> </div> <!-- Multiselect / Select filter --> <div v-else-if="filter.type === 'multiselect' || filter.type === 'select'" class="rounded-lg p-2.5 transition-colors" :class="[ getDraftMulti(filter.column).length > 0 ? 'bg-primary/[0.04] ring-primary/20 ring-1' : 'bg-muted/40', ]" > <div class="mb-2 flex items-center justify-between"> <div class="flex items-center gap-2"> <Label class="text-muted-foreground text-xs font-medium tracking-wide uppercase">{{ filter.label }}</Label> <Badge v-if="getDraftMulti(filter.column).length > 0" variant="secondary" class="bg-primary/15 text-primary h-4 rounded-full px-1.5 text-xs font-semibold" > {{ getDraftMulti(filter.column).length }} </Badge> </div> <button v-if="getDraftMulti(filter.column).length > 0" type="button" class="text-muted-foreground hover:text-foreground text-xs transition-colors" @click="clearDraftSection(filter)" > Clear </button> </div> <Command class="[&_[data-slot=command-input-wrapper]]:border-input overflow-visible bg-transparent [&_[data-slot=command-input-wrapper]]:h-8 [&_[data-slot=command-input-wrapper]]:rounded-md [&_[data-slot=command-input-wrapper]]:border [&_[data-slot=command-input-wrapper]]:px-2.5" > <CommandInput class="h-7 text-sm" :placeholder="`Search ${filter.label.toLowerCase()}...`" /> <CommandList class="mt-1 max-h-[132px]"> <CommandEmpty>No results.</CommandEmpty> <CommandGroup class="p-0"> <CommandItem v-for="rawOpt in filter.options" :key="resolveOption(rawOpt).value" :value="resolveOption(rawOpt).label" class="rounded-md px-2 py-1.5 text-sm" @select="toggleDraftMulti(filter.column, resolveOption(rawOpt).value)" > <div class="flex size-4 shrink-0 items-center justify-center rounded-sm border transition-colors" :class="[ getDraftMulti(filter.column).includes(resolveOption(rawOpt).value) ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/40 [&_svg]:invisible', ]" > <Check class="size-3" /> </div> <component :is="resolveOption(rawOpt).icon" v-if="resolveOption(rawOpt).icon" class="text-muted-foreground size-4" /> <span>{{ resolveOption(rawOpt).label }}</span> </CommandItem> </CommandGroup> </CommandList> </Command> </div> <!-- Date range filter --> <div v-else-if="filter.type === 'date'" class="rounded-lg p-2.5 transition-colors" :class="[ getDraftDate(filter.column).from || getDraftDate(filter.column).to ? 'bg-primary/[0.04] ring-primary/20 ring-1' : 'bg-muted/40', ]" > <div class="mb-2 flex items-center justify-between"> <div class="flex items-center gap-2"> <Label class="text-muted-foreground text-xs font-medium tracking-wide uppercase">{{ filter.label }}</Label> <Badge v-if="getDraftDate(filter.column).from || getDraftDate(filter.column).to" variant="secondary" class="bg-primary/15 text-primary h-auto rounded-full px-1.5 py-0 text-xs font-medium" > {{ formatDraftDateRange(filter.column) }} </Badge> </div> <button v-if="getDraftDate(filter.column).from || getDraftDate(filter.column).to" type="button" class="text-muted-foreground hover:text-foreground text-xs transition-colors" @click="clearDraftSection(filter)" > Clear </button> </div> <div class="flex justify-center overflow-hidden rounded-md border"> <RangeCalendar :model-value="getDraftCalendarModel(filter.column)" :number-of-months="1" class="p-2" @update:model-value="onDraftCalendarUpdate(filter.column, $event as RekaDateRange)" /> </div> </div> </template> <!-- Consumer-supplied custom filter UI --> <slot name="custom-filters" /> </div> </div> <!-- Footer --> <div class="flex gap-2 border-t px-3 py-2.5"> <Button variant="outline" size="sm" class="h-8 flex-1" :disabled="!isDraftDirty" @click="onResetClick" > <X class="size-3.5" /> Reset </Button> <Button size="sm" class="h-8 flex-1" @click="applyAndClose" > Apply </Button> </div> </PopoverContent> </Popover> </template> -
app/components/ui/data-table/DataTableFilterSheet.vue 11.2 kB
<script setup lang="ts"> import type { Table } from '@tanstack/vue-table' import type { FilterDefinition, FilterOption } from './DataTable.vue' import { ref, watch } from 'vue' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet' import { RangeCalendar } from '@/components/ui/range-calendar' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { Check, SlidersHorizontal, X } from 'lucide-vue-next' function resolveOption(opt: string | FilterOption): FilterOption { if (typeof opt === 'string') return { value: opt, label: opt } return opt } withDefaults( defineProps<{ table: Table<any> filters: FilterDefinition[] activeFilterCount: number isAnyFilterActive: boolean isServerSide: boolean /** Strip section bgs / rings / dividers / SheetContent side border. */ borderless?: boolean getMultiSelectValue: (column: string) => string[] getDateRangeValue: (column: string) => { from?: string, to?: string } formatDateRange: (column: string) => string getCalendarModel: (column: string) => any }>(), { borderless: false }, ) const open = defineModel<boolean>('open', { default: false }) const emit = defineEmits<{ (e: 'apply' | 'clear-all'): void (e: 'toggle-multiselect', column: string, value: string): void (e: 'clear-filter' | 'clear-date-filter', filter: FilterDefinition): void (e: 'calendar-update', column: string, value: any): void (e: 'text-filter-update', column: string, value: string | undefined): void }>() const filterScrollRef = ref<HTMLElement | null>(null) watch(open, (isOpen) => { if (isOpen) { setTimeout(() => { filterScrollRef.value?.scrollTo({ top: 0 }) }, 50) } }) </script> <template> <Sheet v-model:open="open"> <SheetContent :class="['flex flex-col gap-0 p-0 sm:max-w-[400px]', borderless ? 'border-0' : '']"> <!-- Header --> <div :class="[borderless ? 'px-5 pt-5 pb-2' : 'border-b px-5 pt-5 pb-4']"> <div class="flex items-center gap-3"> <div class="bg-muted flex size-9 items-center justify-center rounded-lg"> <SlidersHorizontal class="text-muted-foreground size-4" /> </div> <div class="flex-1"> <SheetHeader class="space-y-0.5 p-0"> <SheetTitle class="text-sm font-semibold"> Filters </SheetTitle> <SheetDescription class="text-xs"> <template v-if="activeFilterCount > 0"> {{ activeFilterCount }} active filter{{ activeFilterCount > 1 ? 's' : '' }} <template v-if="!isServerSide"> · {{ table.getFilteredRowModel().rows.length }} result{{ table.getFilteredRowModel().rows.length !== 1 ? 's' : '' }} </template> </template> <template v-else> Narrow down results </template> </SheetDescription> </SheetHeader> </div> </div> </div> <!-- Scrollable filter sections --> <div ref="filterScrollRef" class="flex-1 overflow-y-auto" > <div class="space-y-2 p-4"> <template v-for="filter in filters" :key="filter.column" > <!-- Text filter --> <div v-if="filter.type === 'text'" :class="[borderless ? 'py-2' : 'bg-muted/40 rounded-lg p-3']" > <div class="mb-2 flex items-center justify-between"> <Label class="text-muted-foreground text-xs font-medium tracking-wide uppercase">{{ filter.label }}</Label> <button v-if="table.getColumn(filter.column)?.getFilterValue() as string" type="button" class="text-muted-foreground hover:text-foreground text-xs transition-colors" @click="emit('text-filter-update', filter.column, undefined)" > Clear </button> </div> <Input :placeholder="`Filter by ${filter.label.toLowerCase()}...`" :model-value="(table.getColumn(filter.column)?.getFilterValue() as string) ?? ''" class="h-8 text-sm" @update:model-value="emit('text-filter-update', filter.column, ($event as string) || undefined)" /> </div> <!-- Multiselect / Select filter --> <div v-else-if="filter.type === 'multiselect' || filter.type === 'select'" :class=" borderless ? 'py-2 transition-colors' : [ 'rounded-lg p-3 transition-colors', getMultiSelectValue(filter.column).length > 0 ? 'bg-primary/[0.04] ring-primary/20 ring-1' : 'bg-muted/40', ] " > <div class="mb-2 flex items-center justify-between"> <div class="flex items-center gap-2"> <Label class="text-muted-foreground text-xs font-medium tracking-wide uppercase">{{ filter.label }}</Label> <Badge v-if="getMultiSelectValue(filter.column).length > 0" variant="secondary" class="bg-primary/15 text-primary h-4 rounded-full px-1.5 text-xs font-semibold" > {{ getMultiSelectValue(filter.column).length }} </Badge> </div> <button v-if="getMultiSelectValue(filter.column).length > 0" type="button" class="text-muted-foreground hover:text-foreground text-xs transition-colors" @click="emit('clear-filter', filter)" > Clear </button> </div> <Command class="[&_[data-slot=command-input-wrapper]]:border-input overflow-visible bg-transparent [&_[data-slot=command-input-wrapper]]:h-8 [&_[data-slot=command-input-wrapper]]:rounded-md [&_[data-slot=command-input-wrapper]]:border [&_[data-slot=command-input-wrapper]]:px-2.5" > <CommandInput class="h-7 text-sm" :placeholder="`Search ${filter.label.toLowerCase()}...`" /> <CommandList class="mt-1 max-h-[132px]"> <CommandEmpty>No results.</CommandEmpty> <CommandGroup class="p-0"> <CommandItem v-for="rawOpt in filter.options" :key="resolveOption(rawOpt).value" :value="resolveOption(rawOpt).label" class="rounded-md px-2 py-1.5 text-sm" @select="emit('toggle-multiselect', filter.column, resolveOption(rawOpt).value)" > <div class="flex size-4 shrink-0 items-center justify-center rounded-sm border transition-colors" :class="[ getMultiSelectValue(filter.column).includes(resolveOption(rawOpt).value) ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/40 [&_svg]:invisible', ]" > <Check class="size-3" /> </div> <component :is="resolveOption(rawOpt).icon" v-if="resolveOption(rawOpt).icon" class="text-muted-foreground size-4" /> <span>{{ resolveOption(rawOpt).label }}</span> </CommandItem> </CommandGroup> </CommandList> </Command> </div> <!-- Date range filter --> <div v-else-if="filter.type === 'date'" :class=" borderless ? 'py-2 transition-colors' : [ 'rounded-lg p-3 transition-colors', getDateRangeValue(filter.column).from || getDateRangeValue(filter.column).to ? 'bg-primary/[0.04] ring-primary/20 ring-1' : 'bg-muted/40', ] " > <div class="mb-2 flex items-center justify-between"> <div class="flex items-center gap-2"> <Label class="text-muted-foreground text-xs font-medium tracking-wide uppercase">{{ filter.label }}</Label> <Badge v-if="getDateRangeValue(filter.column).from || getDateRangeValue(filter.column).to" variant="secondary" class="bg-primary/15 text-primary h-auto rounded-full px-1.5 py-0 text-xs font-medium" > {{ formatDateRange(filter.column) }} </Badge> </div> <button v-if="getDateRangeValue(filter.column).from || getDateRangeValue(filter.column).to" type="button" class="text-muted-foreground hover:text-foreground text-xs transition-colors" @click="emit('clear-date-filter', filter)" > Clear </button> </div> <div :class="['flex justify-center overflow-hidden', borderless ? '' : 'rounded-md border']"> <RangeCalendar :model-value="getCalendarModel(filter.column)" :number-of-months="1" class="p-2" @update:model-value="emit('calendar-update', filter.column, $event)" /> </div> </div> </template> <!-- Consumer-supplied custom filter UI --> <slot name="custom-filters" /> </div> </div> <!-- Footer --> <div :class="['flex gap-2 px-4 py-3', borderless ? '' : 'border-t']"> <Button variant="outline" size="sm" class="flex-1" :disabled="!isAnyFilterActive" @click="emit('clear-all')" > <X class="size-3.5" /> Reset All </Button> <Button size="sm" class="flex-1" @click="emit('apply')" > <template v-if="isServerSide"> Apply Filters </template> <template v-else> Show {{ table.getFilteredRowModel().rows.length }} result{{ table.getFilteredRowModel().rows.length !== 1 ? 's' : '' }} </template> </Button> </div> </SheetContent> </Sheet> </template> -
app/components/ui/data-table/DataTablePagination.vue 3.4 kB
<script setup lang="ts"> import type { Table } from '@tanstack/vue-table' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-vue-next' withDefaults( defineProps<{ table: Table<any> totalRows: number isServerSide: boolean borderless?: boolean }>(), { borderless: false }, ) </script> <template> <div :class="['flex items-center justify-between py-3', borderless ? '' : 'border-t px-4']"> <div class="text-muted-foreground flex-1 text-sm"> {{ table.getFilteredSelectedRowModel().rows.length }} of {{ isServerSide ? totalRows : table.getFilteredRowModel().rows.length }} row(s) selected. </div> <div class="flex items-center gap-6 lg:gap-8"> <div class="flex items-center gap-2"> <p class="text-sm font-medium whitespace-nowrap"> Rows per page </p> <Select :model-value="`${table.getState().pagination.pageSize}`" @update:model-value=" (value) => { table.setPageSize(Number(value)) table.setPageIndex(0) } " > <SelectTrigger class="h-8 w-[70px]"> <SelectValue :placeholder="`${table.getState().pagination.pageSize}`" /> </SelectTrigger> <SelectContent side="top"> <SelectItem v-for="pageSize in [10, 20, 30, 40, 50]" :key="pageSize" :value="`${pageSize}`" > {{ pageSize }} </SelectItem> </SelectContent> </Select> </div> <div class="flex items-center justify-center text-sm font-medium whitespace-nowrap"> Page {{ table.getState().pagination.pageIndex + 1 }} of {{ table.getPageCount() }} </div> <div class="flex items-center gap-1"> <Button variant="outline" size="icon" class="size-8" :disabled="!table.getCanPreviousPage()" @click="table.setPageIndex(0)" > <ChevronsLeft class="size-4" aria-hidden="true" /> <span class="sr-only">First page</span> </Button> <Button variant="outline" size="icon" class="size-8" :disabled="!table.getCanPreviousPage()" @click="table.previousPage()" > <ChevronLeft class="size-4" aria-hidden="true" /> <span class="sr-only">Previous page</span> </Button> <Button variant="outline" size="icon" class="size-8" :disabled="!table.getCanNextPage()" @click="table.nextPage()" > <ChevronRight class="size-4" aria-hidden="true" /> <span class="sr-only">Next page</span> </Button> <Button variant="outline" size="icon" class="size-8" :disabled="!table.getCanNextPage()" @click="table.setPageIndex(table.getPageCount() - 1)" > <ChevronsRight class="size-4" aria-hidden="true" /> <span class="sr-only">Last page</span> </Button> </div> </div> </div> </template> -
app/components/ui/data-table/DataTableToolbar.vue 16.1 kB
<script setup lang="ts"> import type { Table } from '@tanstack/vue-table' import type { FilterDefinition, FilterOption } from './DataTable.vue' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { RangeCalendar } from '@/components/ui/range-calendar' import { CalendarIcon, Check, ChevronDown, Download, ListFilter, Plus, Rows3, SlidersHorizontal, X } from 'lucide-vue-next' import DataTableFilterPopover from './DataTableFilterPopover.vue' function resolveOption(opt: string | FilterOption): FilterOption { if (typeof opt === 'string') return { value: opt, label: opt } return opt } withDefaults( defineProps<{ table: Table<any> filterColumn: string filterPlaceholder: string filters: FilterDefinition[] filterMode: 'inline' | 'modal' | 'popover' enableSearch?: boolean enableColumnVisibility?: boolean enableExport?: boolean enableDensityToggle?: boolean density?: 'compact' | 'cozy' | 'comfortable' borderless?: boolean activeFilterCount: number isAnyFilterActive: boolean isServerSide: boolean getMultiSelectValue: (column: string) => string[] getDateRangeValue: (column: string) => { from?: string, to?: string } getFilterSelectedLabels: (filter: FilterDefinition) => string[] formatDateRange: (column: string) => string getCalendarModel: (column: string) => any }>(), { enableSearch: true, enableColumnVisibility: true, enableExport: false, enableDensityToggle: false, density: 'cozy', borderless: false }, ) const emit = defineEmits<{ (e: 'search', value: string): void (e: 'open-filter-sheet' | 'clear-all-filters' | 'apply-filters' | 'export-csv' | 'export-json'): void (e: 'toggle-multiselect', column: string, value: string): void (e: 'clear-filter' | 'clear-date-filter', filter: FilterDefinition): void (e: 'calendar-update', column: string, value: any): void (e: 'text-filter-update', column: string, value: string | undefined): void // Popover filter mode: commit-draft fires when the user clicks Apply // in the staged-edit popover. The payload is a Record<columnId, value> // ready to be fed into `setFilterValue` per column. (e: 'commit-filters', draft: Record<string, any>): void (e: 'update:density', value: 'compact' | 'cozy' | 'comfortable'): void }>() </script> <template> <div :class="['flex flex-col gap-2 py-3', borderless ? '' : 'border-b px-4']"> <div class="flex items-center gap-2"> <!-- Search only renders when filterColumn maps to an actual column in the table; otherwise TanStack's getColumn() logs a noisy "[Table] Column with id '<x>' does not exist" warning even though our optional-chaining keeps the call safe. --> <Input v-if="enableSearch && filterColumn && table.getColumn(filterColumn)" class="h-9 max-w-xs" :placeholder="filterPlaceholder" :model-value="table.getColumn(filterColumn)?.getFilterValue() as string" @update:model-value="emit('search', $event as string)" /> <!-- ── INLINE filter mode ── --> <template v-if="filterMode === 'inline'"> <template v-for="filter in filters" :key="filter.column" > <!-- Multiselect / Select --> <Popover v-if="filter.type === 'multiselect' || filter.type === 'select'"> <PopoverTrigger as-child> <Button variant="outline" size="sm" class="h-9 border-dashed" > <Plus class="size-4" aria-hidden="true" /> {{ filter.label }} <template v-if="getMultiSelectValue(filter.column).length > 0"> <Separator orientation="vertical" class="mx-1 h-4" /> <div class="flex gap-1"> <Badge v-if="getMultiSelectValue(filter.column).length > 2" variant="secondary" class="rounded-sm px-1 font-normal" > {{ getMultiSelectValue(filter.column).length }} selected </Badge> <template v-else> <Badge v-for="label in getFilterSelectedLabels(filter)" :key="label" variant="secondary" class="rounded-sm px-1 font-normal" > {{ label }} </Badge> </template> </div> </template> </Button> </PopoverTrigger> <PopoverContent class="w-52 p-0" align="start" > <Command> <CommandInput :placeholder="`Search ${filter.label.toLowerCase()}...`" /> <CommandList> <CommandEmpty>No results.</CommandEmpty> <CommandGroup> <CommandItem v-for="rawOpt in filter.options" :key="resolveOption(rawOpt).value" :value="resolveOption(rawOpt).label" @select="emit('toggle-multiselect', filter.column, resolveOption(rawOpt).value)" > <div class="border-primary flex size-4 shrink-0 items-center justify-center rounded-sm border" :class="[ getMultiSelectValue(filter.column).includes(resolveOption(rawOpt).value) ? 'bg-primary text-primary-foreground' : 'opacity-50 [&_svg]:invisible', ]" > <Check class="size-3" /> </div> <component :is="resolveOption(rawOpt).icon" v-if="resolveOption(rawOpt).icon" class="text-muted-foreground size-4" /> <span>{{ resolveOption(rawOpt).label }}</span> </CommandItem> </CommandGroup> <template v-if="getMultiSelectValue(filter.column).length > 0"> <CommandSeparator /> <CommandGroup> <CommandItem value="__clear__" class="justify-center text-center" @select="emit('clear-filter', filter)" > Clear filter </CommandItem> </CommandGroup> </template> </CommandList> </Command> </PopoverContent> </Popover> <!-- Date range filter --> <Popover v-else-if="filter.type === 'date'"> <PopoverTrigger as-child> <Button variant="outline" size="sm" class="h-9 border-dashed" > <CalendarIcon class="size-4" aria-hidden="true" /> {{ filter.label }} <template v-if="getDateRangeValue(filter.column).from || getDateRangeValue(filter.column).to"> <Separator orientation="vertical" class="mx-1 h-4" /> <Badge variant="secondary" class="rounded-sm px-1 font-normal" > {{ formatDateRange(filter.column) }} </Badge> </template> </Button> </PopoverTrigger> <PopoverContent class="w-auto p-0" align="start" > <RangeCalendar :model-value="getCalendarModel(filter.column)" initial-focus :number-of-months="2" @update:model-value="emit('calendar-update', filter.column, $event)" /> <div v-if="getDateRangeValue(filter.column).from || getDateRangeValue(filter.column).to" class="border-t p-2" > <Button variant="ghost" size="sm" class="h-7 w-full text-xs" @click="emit('clear-date-filter', filter)" > Clear dates </Button> </div> </PopoverContent> </Popover> <!-- Text filter --> <Popover v-else-if="filter.type === 'text'"> <PopoverTrigger as-child> <Button variant="outline" size="sm" class="h-9 border-dashed" > <Plus class="size-4" aria-hidden="true" /> {{ filter.label }} <template v-if="table.getColumn(filter.column)?.getFilterValue() as string"> <Separator orientation="vertical" class="mx-1 h-4" /> <Badge variant="secondary" class="rounded-sm px-1 font-normal" > {{ table.getColumn(filter.column)?.getFilterValue() }} </Badge> </template> </Button> </PopoverTrigger> <PopoverContent class="w-60 p-3" align="start" > <div class="space-y-2"> <p class="text-sm font-medium"> {{ filter.label }} </p> <Input :placeholder="`Filter by ${filter.label.toLowerCase()}...`" :model-value="(table.getColumn(filter.column)?.getFilterValue() as string) ?? ''" class="h-8 text-sm" @update:model-value="table.getColumn(filter.column)?.setFilterValue($event || undefined)" /> </div> </PopoverContent> </Popover> </template> </template> <!-- ── MODAL filter mode (Sheet from the right) ── --> <template v-if="filterMode === 'modal'"> <Button variant="outline" size="sm" class="h-9" :class="[activeFilterCount > 0 ? 'border-primary/40 bg-primary/5 text-primary hover:bg-primary/10' : '']" @click="emit('open-filter-sheet')" > <SlidersHorizontal class="size-4" aria-hidden="true" /> Filters <Badge v-if="activeFilterCount > 0" class="bg-primary text-primary-foreground ml-0.5 size-5 rounded-full p-0 text-xs font-semibold" > {{ activeFilterCount }} </Badge> </Button> </template> <!-- ── POPOVER filter mode ── --> <template v-if="filterMode === 'popover' && filters.length"> <DataTableFilterPopover :table="table" :filters="filters" :active-filter-count="activeFilterCount" :is-any-filter-active="isAnyFilterActive" :is-server-side="isServerSide" :get-multi-select-value="getMultiSelectValue" :get-date-range-value="getDateRangeValue" :format-date-range="formatDateRange" :get-calendar-model="getCalendarModel" @commit-draft="(d) => emit('commit-filters', d)" @clear-all="emit('clear-all-filters')" > <template v-if="$slots['custom-filters']" #custom-filters > <slot name="custom-filters" /> </template> </DataTableFilterPopover> </template> <!-- Inline custom filters (when filterMode === 'inline') --> <slot v-if="filterMode === 'inline'" name="custom-filters" /> <!-- Toolbar extras (e.g. group-by selector, density toggle) --> <slot name="toolbar-extra" /> <!-- Reset button --> <Button v-if="isAnyFilterActive" variant="ghost" size="sm" class="h-9" @click="emit('clear-all-filters')" > Reset <X class="size-4" aria-hidden="true" /> </Button> <!-- Export (CSV / JSON) --> <div v-if="enableExport" class="ml-auto" > <DropdownMenu> <DropdownMenuTrigger as-child> <Button variant="outline" size="sm" class="h-9" > <Download class="size-3.5" /> Export <ChevronDown class="size-3.5 opacity-60" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem @select="emit('export-csv')"> Export as CSV </DropdownMenuItem> <DropdownMenuItem @select="emit('export-json')"> Export as JSON </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </div> <!-- Density toggle --> <div v-if="enableDensityToggle" :class="enableExport ? '' : 'ml-auto'" > <DropdownMenu> <DropdownMenuTrigger as-child> <Button variant="outline" size="sm" class="h-9" :aria-label="`Row density: ${density}`" > <Rows3 class="size-4" aria-hidden="true" /> <span class="capitalize">{{ density }}</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuRadioGroup :model-value="density" @update:model-value="(v: any) => emit('update:density', v as 'compact' | 'cozy' | 'comfortable')" > <DropdownMenuRadioItem value="compact"> Compact </DropdownMenuRadioItem> <DropdownMenuRadioItem value="cozy"> Cozy </DropdownMenuRadioItem> <DropdownMenuRadioItem value="comfortable"> Comfortable </DropdownMenuRadioItem> </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> </div> <!-- Column Visibility --> <div v-if="enableColumnVisibility" :class="enableExport || enableDensityToggle ? '' : 'ml-auto'" > <DropdownMenu> <DropdownMenuTrigger as-child> <Button variant="outline" size="sm" class="h-9" > <ListFilter class="size-4" aria-hidden="true" /> View </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuCheckboxItem v-for="column in table.getAllColumns().filter((column) => column.getCanHide())" :key="column.id" class="capitalize" :checked="column.getIsVisible()" @update:checked="(value: boolean) => column.toggleVisibility(!!value)" > {{ column.id }} </DropdownMenuCheckboxItem> </DropdownMenuContent> </DropdownMenu> </div> </div> </div> </template> -
app/components/ui/data-table/index.ts 0.5 kB
export { default as DataTable } from './DataTable.vue' export { default as DataTableColumnHeader } from './DataTableColumnHeader.vue' export { default as DataTableToolbar } from './DataTableToolbar.vue' export { default as DataTableFilterSheet } from './DataTableFilterSheet.vue' export { default as DataTableFilterPopover } from './DataTableFilterPopover.vue' export { default as DataTablePagination } from './DataTablePagination.vue' export type { FilterDefinition, FilterOption } from './DataTable.vue'
Raw manifest: https://uipkge.dev/r/vue/data-table.json