
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
data-tableuiFull-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 ->$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.jsonnpx shadcn-vue@latest add @uipkge/data-tableInstalls to:app/components/ui/data-table/| 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 |
enableSearchShow the global search input. Default true. | boolean | — | optional |
enableColumnVisibilityShow the View column-visibility dropdown. Default true. | boolean | — | optional |
enablePaginationShow the pagination footer. Default true. | boolean | — | optional |
hideToolbarHide the entire toolbar (search + filters + view). Default false. | boolean | — | optional |
enableExportShow Export-CSV button in toolbar. Default false. | boolean | — | optional |
infiniteconsumer side. | boolean | — | optional |
enableResizeAllow drag-to-resize on column borders. | boolean | — | optional |
defaultColumnPinningInitial column pinning. Each column id is pinned to the given side. | { left?: string[]; right?: string[] | — | optional |
Type aliases from this item's source — use them to shape the data you pass in.
FilterOptioninterface FilterOption {
value: string
label: string
icon?: any
}FilterDefinitioninterface FilterDefinition {
column: string
label: string
type: 'text' | 'select' | 'multiselect' | 'date'
options?: (string | FilterOption)[]
}DateRangeinterface DateRange {
from?: string
to?: string
}DraftDateValuetype 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
}<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, 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 { Search, Copy, Download, X, ChevronDown, ChevronRight } from 'lucide-vue-next'
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
/** Enable keyboard navigation (Arrow keys, J/K, Space/X, Enter, Esc, Cmd+A, Cmd+C). Default true. */
enableKeyboardNavigation?: boolean
/** Placement of the bulk actions dock: 'floating' (Linear/Raycast HUD dock at bottom-center) or 'inline' (top banner). Default 'floating'. */
bulkActionPosition?: 'floating' | 'inline'
}>(),
{
filterColumn: '',
filterPlaceholder: 'Filter...',
filters: () => [],
// Industry default (shadcn / Linear / Airtable): faceted filter chips in the
// toolbar. Use `modal` for a right Sheet when you have many complex filters.
filterMode: 'inline',
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,
enableKeyboardNavigation: true,
bulkActionPosition: 'floating',
},
)
// 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-2 [&_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(),
// Skip client pagination entirely when disabled (not just hide the footer UI).
get getPaginationRowModel() {
if (isServerSide.value || !props.enablePagination) return undefined
return 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.
// Watch the element itself (flush post) so the observer attaches after
// ClientOnly hydrates the sentinel into the DOM — onMounted alone races
// and often finds null.
const sentinelEl = ref<HTMLElement | null>(null)
let io: IntersectionObserver | null = null
function attachInfiniteObserver(el: HTMLElement | null) {
io?.disconnect()
io = null
if (!props.infinite || !el) return
io = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) emit('fetch-more')
},
{ rootMargin: '100px' },
)
io.observe(el)
}
watch([() => props.infinite, sentinelEl], () => attachInfiniteObserver(sentinelEl.value), {
flush: 'post',
})
onBeforeUnmount(() => {
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)
}
// TSV clipboard copy — formatted for instant paste into Excel / Sheets / Notion
function copyTsv(rowsToCopy?: any[]) {
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 targetRows =
rowsToCopy && rowsToCopy.length > 0
? rowsToCopy
: table.getSelectedRowModel().rows.length > 0
? table.getSelectedRowModel().rows
: table.getFilteredRowModel().rows
const rowData = targetRows.map((row) =>
visibleCols.map((c) => {
const v = row.getValue(c.id)
return v == null ? '' : String(v).replace(/[\t\n\r]/g, ' ')
}),
)
const tsv = [headers.join('\t'), ...rowData.map((r) => r.join('\t'))].join('\n')
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
navigator.clipboard.writeText(tsv).catch(() => {})
}
return tsv
}
// Markdown table copy — formatted for PR descriptions, issues, and documentation
function copyMarkdown(rowsToCopy?: any[]) {
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 targetRows =
rowsToCopy && rowsToCopy.length > 0
? rowsToCopy
: table.getSelectedRowModel().rows.length > 0
? table.getSelectedRowModel().rows
: table.getFilteredRowModel().rows
const rowData = targetRows.map((row) =>
visibleCols.map((c) => {
const v = row.getValue(c.id)
return v == null ? '' : String(v).replace(/[|\n\r]/g, ' ')
}),
)
const sep = visibleCols.map(() => '---')
const md = [
`| ${headers.join(' | ')} |`,
`| ${sep.join(' | ')} |`,
...rowData.map((r) => `| ${r.join(' | ')} |`),
].join('\n')
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
navigator.clipboard.writeText(md).catch(() => {})
}
return md
}
// ── Keyboard ergonomics & focused row tracking (Linear / Raycast craft) ───────
const focusedRowIndex = ref<number>(-1)
const lastSelectedRowIndex = ref<number>(-1)
function onTableKeyDown(e: KeyboardEvent) {
if (!props.enableKeyboardNavigation) return
const target = e.target as HTMLElement | null
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return
}
const rows = table.getRowModel().rows
if (!rows || rows.length === 0) return
const isDown = e.key === 'ArrowDown' || e.key === 'j'
const isUp = e.key === 'ArrowUp' || e.key === 'k'
const isSpace = e.key === ' ' || e.key === 'x'
const isEnter = e.key === 'Enter'
const isEscape = e.key === 'Escape'
const isSelectAll = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'a'
const isCopy = (e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'c'
if (isDown) {
e.preventDefault()
const nextIdx = Math.min(focusedRowIndex.value + 1, rows.length - 1)
if (e.shiftKey && focusedRowIndex.value >= 0) {
const start = Math.min(focusedRowIndex.value, nextIdx)
const end = Math.max(focusedRowIndex.value, nextIdx)
for (let i = start; i <= end; i++) {
rows[i]?.toggleSelected(true)
}
}
focusedRowIndex.value = nextIdx
} else if (isUp) {
e.preventDefault()
const prevIdx = Math.max(focusedRowIndex.value - 1, 0)
if (e.shiftKey && focusedRowIndex.value >= 0) {
const start = Math.min(focusedRowIndex.value, prevIdx)
const end = Math.max(focusedRowIndex.value, prevIdx)
for (let i = start; i <= end; i++) {
rows[i]?.toggleSelected(true)
}
}
focusedRowIndex.value = prevIdx
} else if (isSpace) {
if (focusedRowIndex.value >= 0 && focusedRowIndex.value < rows.length) {
e.preventDefault()
rows[focusedRowIndex.value].toggleSelected()
lastSelectedRowIndex.value = focusedRowIndex.value
}
} else if (isEnter) {
if (focusedRowIndex.value >= 0 && focusedRowIndex.value < rows.length) {
e.preventDefault()
props.onRowClick?.(rows[focusedRowIndex.value].original)
}
} else if (isEscape) {
if (table.getSelectedRowModel().rows.length > 0) {
e.preventDefault()
table.toggleAllRowsSelected(false)
}
focusedRowIndex.value = -1
} else if (isSelectAll) {
e.preventDefault()
table.toggleAllRowsSelected(true)
} else if (isCopy) {
const selected = table.getSelectedRowModel().rows
if (selected.length > 0) {
e.preventDefault()
copyTsv(selected)
} else if (focusedRowIndex.value >= 0 && focusedRowIndex.value < rows.length) {
e.preventDefault()
copyTsv([rows[focusedRowIndex.value]])
}
}
}
function onRowClicked(row: any, idx: number, event: MouseEvent) {
focusedRowIndex.value = idx
if (event.shiftKey && lastSelectedRowIndex.value >= 0) {
const rows = table.getRowModel().rows
const start = Math.min(lastSelectedRowIndex.value, idx)
const end = Math.max(lastSelectedRowIndex.value, idx)
for (let i = start; i <= end; i++) {
rows[i]?.toggleSelected(true)
}
} else {
lastSelectedRowIndex.value = idx
}
props.onRowClick?.(row.original)
}
defineExpose({ table, exportCsv, exportJson, copyTsv, copyMarkdown })
</script>
<template>
<div
data-uipkge
data-slot="data-table"
class="focus-visible:ring-border/50 relative w-full outline-none focus-visible:ring-1"
tabindex="0"
@keydown="onTableKeyDown"
>
<!-- 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"
@copy-tsv="copyTsv()"
@copy-markdown="copyMarkdown()"
@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"
@copy-tsv="copyTsv()"
@copy-markdown="copyMarkdown()"
@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 (inline mode) -->
<div
v-if="bulkActionPosition === 'inline' && $slots['bulk-actions'] && table.getSelectedRowModel().rows.length > 0"
class="bg-primary/5 border-primary/15 flex items-center gap-3 border-b px-4 py-2"
>
<span class="bg-primary/10 text-primary rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums">
{{ table.getSelectedRowModel().rows.length }} selected
</span>
<div class="flex min-w-0 flex-1 items-center gap-2">
<slot
name="bulk-actions"
:rows="table.getSelectedRowModel().rows"
:clear="() => table.toggleAllRowsSelected(false)"
/>
</div>
<button
type="button"
class="text-muted-foreground hover:text-foreground shrink-0 text-xs font-medium transition"
@click="table.toggleAllRowsSelected(false)"
>
Clear
</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
:class="[densityClass, 'relative overflow-auto [&_[data-slot=table-container]]:overflow-visible']"
:style="maxHeight ? `max-height: ${maxHeight}` : ''"
>
<Table>
<TableHeader
:class="
stickyHeader ? 'bg-card/95 sticky top-0 z-10 shadow-[0_1px_0_0_var(--border)] 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
:class="loading && table.getRowModel().rows?.length ? 'pointer-events-none opacity-60' : ''"
:aria-busy="loading || undefined"
>
<!-- Skeleton rows while initial load (preferred over full-table spinner) -->
<template v-if="loading && !table.getRowModel().rows?.length">
<TableRow v-for="i in 5" :key="`sk-${i}`" class="hover:bg-transparent">
<TableCell v-for="j in Math.max(columns.length, 1)" :key="`sk-${i}-${j}`">
<div
class="bg-muted h-4 animate-pulse rounded"
:style="{ width: `${48 + ((i * 17 + j * 23) % 40)}%`, maxWidth: '12rem' }"
/>
</TableCell>
</TableRow>
</template>
<template v-else-if="table.getRowModel().rows?.length">
<template v-for="(row, idx) in table.getRowModel().rows" :key="row.id">
<TableRow
v-if="row.getIsGrouped()"
class="bg-muted/50 hover:bg-muted/70 cursor-pointer"
@click="row.toggleExpanded()"
>
<TableCell :colspan="row.getVisibleCells().length">
<button type="button" class="flex items-center gap-2 text-sm font-medium">
<ChevronDown v-if="row.getIsExpanded()" class="size-4" />
<ChevronRight v-else class="size-4" />
<span>{{ row.groupingValue }}</span>
<span class="text-muted-foreground font-normal">{{ row.subRows.length }}</span>
</button>
</TableCell>
</TableRow>
<template v-else>
<TableRow
:data-state="row.getIsSelected() ? 'selected' : undefined"
:data-focused="focusedRowIndex === idx ? 'true' : undefined"
:class="[
onRowClick ? 'cursor-pointer' : '',
focusedRowIndex === idx ? 'ring-primary/60 bg-muted/40 ring-1 ring-inset' : '',
]"
:style="virtual ? 'content-visibility: auto; contain-intrinsic-size: auto 48px;' : undefined"
@click="onRowClicked(row, idx, $event)"
>
<TableCell
v-for="cell in row.getVisibleCells()"
:key="cell.id"
:class="row.getIsSelected() ? 'bg-muted' : '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>
<template v-else>
<TableRow class="hover:bg-transparent">
<TableCell :colspan="columns.length" class="h-36 text-center">
<slot name="empty">
<div class="text-muted-foreground flex flex-col items-center justify-center gap-2 py-8">
<div class="bg-muted/80 text-muted-foreground grid size-10 place-items-center rounded-full">
<Search class="size-4" aria-hidden="true" />
</div>
<div class="space-y-1">
<p class="text-foreground text-sm font-medium">No results</p>
<p class="text-muted-foreground max-w-[28ch] text-xs leading-relaxed">
Try adjusting search or filters to find what you need.
</p>
</div>
</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"
/>
<!-- Floating Bulk Actions HUD Dock — Linear/Raycast craft dock -->
<div
v-if="bulkActionPosition === 'floating' && table.getSelectedRowModel().rows.length > 0"
data-slot="data-table-bulk-dock"
class="border-border/80 bg-background/95 text-foreground fixed bottom-4 left-1/2 z-30 flex -translate-x-1/2 items-center gap-2.5 rounded-full border px-3.5 py-1.5 shadow-xl backdrop-blur-md select-none sm:absolute"
>
<span
class="bg-primary/10 text-primary flex items-center gap-1 rounded-full px-2 py-0.5 font-mono text-xs font-semibold tabular-nums"
>
{{ table.getSelectedRowModel().rows.length }} selected
</span>
<div class="bg-border h-3.5 w-px" />
<button
type="button"
class="text-muted-foreground hover:text-foreground hover:bg-muted flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors"
title="Copy selected rows as TSV (Excel / Sheets)"
@click="copyTsv(table.getSelectedRowModel().rows)"
>
<Copy class="size-3" />
Copy TSV
</button>
<button
type="button"
class="text-muted-foreground hover:text-foreground hover:bg-muted flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium transition-colors"
title="Export table as CSV"
@click="exportCsv"
>
<Download class="size-3" />
Export
</button>
<slot
v-if="$slots['bulk-actions']"
name="bulk-actions"
:rows="table.getSelectedRowModel().rows"
:clear="() => table.toggleAllRowsSelected(false)"
/>
<div class="bg-border h-3.5 w-px" />
<button
type="button"
class="text-muted-foreground hover:text-foreground hover:bg-muted flex size-5 items-center justify-center rounded-full transition-colors"
title="Deselect all (Esc)"
aria-label="Clear selection"
@click="table.toggleAllRowsSelected(false)"
>
<X class="size-3.5" />
</button>
</div>
</div>
</template>
<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 { 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-64 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-8"
@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-72">
<CommandInput :placeholder="`Search ${filter.label.toLowerCase()}…`" class="h-8" />
<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-xs"
>
<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>
<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-8 gap-2',
activeFilterCount > 0 ? 'border-primary/40 bg-primary/5 text-primary hover:bg-primary/10' : '',
]"
>
<SlidersHorizontal class="size-4" />
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>
</PopoverTrigger>
<PopoverContent align="start" class="flex max-h-[min(560px,80vh)] w-96 flex-col overflow-hidden p-0">
<!-- Header -->
<div class="border-b px-4 pt-4 pb-3">
<div class="flex items-center gap-3">
<div class="bg-muted flex size-7 items-center justify-center rounded-md">
<SlidersHorizontal class="text-muted-foreground size-4" />
</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-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="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-3 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-2 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-3"
>
<CommandInput class="h-7 text-sm" :placeholder="`Search ${filter.label.toLowerCase()}...`" />
<CommandList class="mt-1 max-h-36">
<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-2 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-3 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-2 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-3">
<Button variant="outline" size="sm" class="h-8 flex-1" :disabled="!isDraftDirty" @click="onResetClick">
<X class="size-4" />
Reset
</Button>
<Button size="sm" class="h-8 flex-1" @click="applyAndClose"> Apply </Button>
</div>
</PopoverContent>
</Popover>
</template>
<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-md', 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-2 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-3"
>
<CommandInput class="h-7 text-sm" :placeholder="`Search ${filter.label.toLowerCase()}...`" />
<CommandList class="mt-1 max-h-36">
<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-2 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-4" />
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>
<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 flex-col gap-3 py-3 sm:flex-row sm:items-center sm:justify-between',
borderless ? '' : 'border-t px-4',
]"
>
<div class="text-muted-foreground text-sm tabular-nums">
<span class="text-foreground font-medium">{{ table.getFilteredSelectedRowModel().rows.length }}</span>
of
{{ isServerSide ? totalRows : table.getFilteredRowModel().rows.length }}
row(s) selected
</div>
<div class="flex flex-wrap items-center gap-4 sm:gap-6">
<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-[4.5rem]">
<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="text-sm font-medium whitespace-nowrap tabular-nums">
Page {{ table.getState().pagination.pageIndex + 1 }} of
{{ Math.max(table.getPageCount(), 1) }}
</div>
<div class="flex items-center gap-1">
<Button
variant="outline"
size="icon"
class="hidden size-8 lg:flex"
: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="hidden size-8 lg:flex"
: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>
<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,
Columns3,
Download,
Plus,
Rows3,
Search,
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'
| 'copy-tsv'
| 'copy-markdown',
): 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']">
<!-- shadcn canonical toolbar: search + faceted chips left, actions right -->
<div class="flex flex-wrap 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. -->
<div
v-if="enableSearch && filterColumn && table.getColumn(filterColumn)"
class="relative w-full max-w-xs min-w-[12rem] flex-1 sm:flex-none"
>
<Search
class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
aria-hidden="true"
/>
<Input
class="h-8 pl-8"
:placeholder="filterPlaceholder"
:aria-label="filterPlaceholder || 'Search table'"
:model-value="table.getColumn(filterColumn)?.getFilterValue() as string"
@update:model-value="emit('search', $event as string)"
/>
</div>
<!-- ── 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-8 border-dashed',
getMultiSelectValue(filter.column).length > 0 ? 'border-primary/40 bg-primary/5 border-solid' : '',
]"
>
<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-8 border-dashed',
getDateRangeValue(filter.column).from || getDateRangeValue(filter.column).to
? 'border-primary/40 bg-primary/5 border-solid'
: '',
]"
>
<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-8 border-dashed',
table.getColumn(filter.column)?.getFilterValue() ? 'border-primary/40 bg-primary/5 border-solid' : '',
]"
>
<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-8"
: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-8 px-2" @click="emit('clear-all-filters')">
Reset
<X class="size-4" aria-hidden="true" />
</Button>
<!-- Right cluster: export / density / columns (shadcn View options) -->
<div v-if="enableExport || enableDensityToggle || enableColumnVisibility" class="ml-auto flex items-center gap-2">
<!-- Export (CSV / JSON) -->
<DropdownMenu v-if="enableExport">
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="h-8">
<Download class="size-4" />
Export
<ChevronDown class="size-4 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>
<DropdownMenuItem @select="emit('copy-tsv')"> Copy as TSV (Excel / Sheets) </DropdownMenuItem>
<DropdownMenuItem @select="emit('copy-markdown')"> Copy as Markdown </DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<!-- Density toggle -->
<DropdownMenu v-if="enableDensityToggle">
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="h-8" :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>
<!-- Column Visibility -->
<DropdownMenu v-if="enableColumnVisibility">
<DropdownMenuTrigger as-child>
<Button variant="outline" size="sm" class="h-8">
<Columns3 class="size-4" aria-hidden="true" />
View
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" class="w-44">
<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>
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