Data Table
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.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/data-table.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/data-table.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/data-table.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/data-table.jsonhttps://uipkge.dev/r/svelte/data-table.jsonInstalls to:src/lib/components/ui/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 |
enableSearchShow the global search input. Default true. | boolean | — | optional |
enableColumnVisibilityShow the View column-visibility dropdown. Default false. | 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 |
infinitehides the pagination footer. Combine with append-on-success on the consumer 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 |
enableReorderAllow drag-to-reorder on column headers. | boolean | — | optional |
defaultGroupingInitial column ids to group by. Pass an empty array to disable grouping. | string[] | — | optional |
virtualdatasets and `maxHeight` for a scroll container. | boolean | — | optional |
stickyHeaderSticky header — keeps `<thead>` visible when scrolling. Pair with `maxHeight`. | boolean | — | optional |
densityTreated as the INITIAL density when `enableDensityToggle` is on; the user can override it at runtime from the toolbar. | 'compact' | 'cozy' | 'comfortable' | — | optional |
enableDensityToggleShow the density toggle in the toolbar. Default false. | boolean | — | optional |
borderless- `'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. | 'inner' | 'full' | — | optional |
toolbarPositionbordered container; `'above'` floats outside it. | 'inside' | 'above' | — | optional |
paginationPositionWhere the pagination footer renders. `'inside'` (default) or `'below'`. | 'inside' | 'below' | — | optional |
maxHeightMax height; enables vertical scroll inside the card. | string | — | optional |
onRowClickRow click — when set, rows become clickable + cursor-pointer. | (row: TData) => void | — | optional |
totalRowsServer-side mode: total row count from API (enables manual pagination) | number | — | optional |
loadingServer-side mode: loading state indicator | boolean | — | optional |
onStateChangeEmitted when server-side state changes (pagination, sorting, filters) | (state: DataTableState) => void | — | optional |
onFetchMoreInfinite-scroll: last row entered viewport, time to load more | () => void | — | optional |
emptyStateCustom empty-state content (replaces "No results."). | Snippet | — | optional |
renderExpandedExpanded-row content. Receives the original row + the TanStack row. | Snippet<[TData, Row<TData>]> | — | optional |
renderBulkActionsBulk action bar content (shown above the table when rows are selected). | Snippet<[Row<TData>[], () => void]> | — | optional |
renderFooterFooter (<tfoot>) content. | Snippet<[Row<TData>[]]> | — | optional |
toolbarExtraExtra toolbar controls (e.g. group-by selector). | Snippet | — | optional |
customFiltersConsumer-supplied custom filter UI inside the filter surface. | Snippet | — | optional |
enableKeyboardNavigationEnable keyboard navigation (Arrow keys, J/K, Space/X, Enter, Esc, Cmd+A, Cmd+C). Default true. | boolean | — | optional |
bulkActionPositionPlacement of the bulk actions dock: 'floating' (Linear/Raycast HUD dock at bottom-center) or 'inline' (top banner). Default 'floating'. | 'floating' | 'inline' | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
FilterOptioninterface FilterOption {
value: string
label: string
icon?: Component<{ class?: string }>
}FilterDefinitioninterface FilterDefinition {
column: string
label: string
type: 'text' | 'select' | 'multiselect' | 'date'
options?: (string | FilterOption)[]
}Files installed (13)
src/lib/components/ui/data-table/DataTable.svelte43.7 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { ColumnDef, Row, Table as TanstackTable } from '@tanstack/table-core' import type { FilterDefinition, FilterOption } from './types' export type { FilterDefinition, FilterOption } export interface DataTableState { page: number pageSize: number sortBy: string sortOrder: 'asc' | 'desc' filters: Record<string, any> search: string } export interface DataTableProps<TData, TValue> { 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 false. */ 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: calls `onFetchMore` 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 `maxHeight` for a scroll container. */ virtual?: boolean /** Sticky header — keeps `<thead>` visible when scrolling. Pair with `maxHeight`. */ 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 renders. `'inside'` (default) lives inside the * bordered container; `'above'` floats outside it. */ toolbarPosition?: 'inside' | 'above' /** Where the pagination footer renders. `'inside'` (default) or `'below'`. */ 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 /** Emitted when server-side state changes (pagination, sorting, filters) */ onStateChange?: (state: DataTableState) => void /** Infinite-scroll: last row entered viewport, time to load more */ onFetchMore?: () => void /** Custom empty-state content (replaces "No results."). */ emptyState?: Snippet /** Expanded-row content. Receives the original row + the TanStack row. */ renderExpanded?: Snippet<[TData, Row<TData>]> /** Bulk action bar content (shown above the table when rows are selected). */ renderBulkActions?: Snippet<[Row<TData>[], () => void]> /** Footer (<tfoot>) content. */ renderFooter?: Snippet<[Row<TData>[]]> /** Extra toolbar controls (e.g. group-by selector). */ toolbarExtra?: Snippet /** Consumer-supplied custom filter UI inside the filter surface. */ customFilters?: Snippet /** 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' } /** Instance API, via `bind:this` (the Svelte twin of React's forwarded ref). */ export interface DataTableHandle<TData> { table: TanstackTable<TData> exportCsv: () => void exportJson: () => void copyTsv: (rows?: Row<TData>[]) => string copyMarkdown: (rows?: Row<TData>[]) => string } </script> <script lang="ts" generics="TData, TValue"> import type { Column, ColumnFiltersState, ColumnPinningState, ExpandedState, FilterFn, GroupingState, Header, RowSelectionState, SortingState, VisibilityState, } from '@tanstack/table-core' import { getCoreRowModel, getExpandedRowModel, getFilteredRowModel, getGroupedRowModel, getPaginationRowModel, getSortedRowModel, } from '@tanstack/table-core' // `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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, valueUpdater, } from '$lib/components/ui/table' import type { DateRange } from '$lib/components/ui/range-calendar' import { ChevronDown, ChevronRight, Copy, Download, Search, X } from '@lucide/svelte' import DataTableToolbar from './DataTableToolbar.svelte' import DataTableFilterSheet from './DataTableFilterSheet.svelte' import DataTablePagination from './DataTablePagination.svelte' import FlexRender from './FlexRender.svelte' import { createSvelteTable } from './table.svelte' import { resolveOption } from './types' import { dateToIso, isoRangeToCalendar } from './date-utils' interface DateRangeValue { from?: string to?: string } let { columns, data, 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, borderless, toolbarPosition = 'inside', paginationPosition = 'inside', maxHeight = '', onRowClick, totalRows = -1, loading = false, onStateChange, onFetchMore, emptyState, renderExpanded, renderBulkActions, renderFooter, toolbarExtra, customFilters, enableKeyboardNavigation = true, bulkActionPosition = 'floating', }: DataTableProps<TData, TValue> = $props() // User-mutable density -- initial value from prop, toggleable via toolbar // when `enableDensityToggle` is on. let densityOverride = $state<'compact' | 'cozy' | 'comfortable' | null>(null) const currentDensity = $derived(densityOverride ?? density) // `borderless` is an enum (`'inner'` vs `'full'`); children only need a // boolean "should I drop my borders?". const dropInnerBorders = $derived(!!borderless) const densityClass = $derived( currentDensity === 'compact' ? '[&_td]:py-2 [&_td]:px-3 [&_td]:text-xs [&_th]:h-8 [&_th]:px-3 [&_th]:text-xs' : currentDensity === 'comfortable' ? '[&_td]:py-3 [&_th]:h-12' : // cozy -- tightens TableCell/TableHead defaults (py-3 / h-12) '[&_td]:py-2 [&_th]:h-10', ) const isServerSide = $derived(totalRows >= 0) let isFilterSheetOpen = $state(false) // ── Filter snapshot (restore on close without apply) ─────────────────── let filterSnapshot: ColumnFiltersState | null = null let dateRangeSnapshot: Record<string, DateRangeValue> | null = null let filterApplied = false // ── Timers ────────────────────────────────────────────────────────────── let searchDebounceTimer: ReturnType<typeof setTimeout> | null = null let paginationEmitTimer: ReturnType<typeof setTimeout> | null = null let isMounted = false $effect(() => { isMounted = true return () => { isMounted = false if (searchDebounceTimer) clearTimeout(searchDebounceTimer) if (paginationEmitTimer) clearTimeout(paginationEmitTimer) } }) // 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: DateRangeValue) => { 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 = $derived( columns.map((col) => { const colId = (col as any).accessorKey || (col as any).id const filter = 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 }), ) // Table state slices are replaced immutably by TanStack updaters, so raw // state (no deep proxy) is enough and keeps reference-equality memos cheap. let sorting = $state.raw<SortingState>([]) let columnFilters = $state.raw<ColumnFiltersState>([]) let columnVisibility = $state.raw<VisibilityState>({}) let rowSelection = $state.raw<RowSelectionState>({}) let expanded = $state.raw<ExpandedState>({}) // svelte-ignore state_referenced_locally: pinning / grouping props are initial values by design. let columnPinning = $state.raw<ColumnPinningState>({ left: defaultColumnPinning.left ?? [], right: defaultColumnPinning.right ?? [], }) let columnOrder = $state.raw<string[]>([]) // svelte-ignore state_referenced_locally: initial grouping only. let grouping = $state.raw<GroupingState>(defaultGrouping) let pagination = $state.raw({ pageIndex: 0, pageSize: 10 }) // Per-filter reactive range calendar model (ISO strings), keyed by column. let dateRangeModels = $state.raw<Record<string, DateRangeValue>>({}) /** `{ value }` box over a state slice so the shared `valueUpdater` can drive it. */ function box<T>(get: () => T, set: (v: T) => void): { value: T } { return { get value() { return get() }, set value(v: T) { set(v) }, } } const paginationRowModel = getPaginationRowModel<TData>() const sortedRowModel = getSortedRowModel<TData>() const filteredRowModel = getFilteredRowModel<TData>() export const table: TanstackTable<TData> = createSvelteTable<TData>({ get data() { return data }, get columns() { return processedColumns }, getCoreRowModel: getCoreRowModel(), // Skip client pagination entirely when disabled (not just hide the footer UI). get getPaginationRowModel() { return isServerSide || !enablePagination ? undefined : paginationRowModel }, get getSortedRowModel() { return isServerSide ? undefined : sortedRowModel }, get getFilteredRowModel() { return isServerSide ? undefined : filteredRowModel }, getExpandedRowModel: getExpandedRowModel(), getGroupedRowModel: getGroupedRowModel(), get enableColumnResizing() { return enableResize }, columnResizeMode: 'onChange', get manualPagination() { return isServerSide }, get manualSorting() { return isServerSide }, get manualFiltering() { return isServerSide }, get rowCount() { return isServerSide ? totalRows : undefined }, onSortingChange: (updaterOrValue) => { valueUpdater(updaterOrValue, box(() => sorting, (v) => (sorting = v))) if (isServerSide) { setTimeout(() => emitStateUpdate(), 0) } }, onColumnFiltersChange: (updaterOrValue) => { // Server-side: never auto-emit on filter change — handled by Apply / search debounce // Client-side inline: filters apply locally via TanStack, no emit needed valueUpdater(updaterOrValue, box(() => columnFilters, (v) => (columnFilters = v))) }, onColumnVisibilityChange: (updaterOrValue) => { valueUpdater(updaterOrValue, box(() => columnVisibility, (v) => (columnVisibility = v))) }, onRowSelectionChange: (updaterOrValue) => { valueUpdater(updaterOrValue, box(() => rowSelection, (v) => (rowSelection = v))) }, onExpandedChange: (updaterOrValue) => { valueUpdater(updaterOrValue, box(() => expanded, (v) => (expanded = v))) }, onPaginationChange: (updaterOrValue) => { valueUpdater(updaterOrValue, box(() => pagination, (v) => (pagination = v))) if (isServerSide) { if (paginationEmitTimer) clearTimeout(paginationEmitTimer) paginationEmitTimer = setTimeout(() => emitStateUpdate(), 0) } }, onColumnPinningChange: (updaterOrValue) => { valueUpdater(updaterOrValue, box(() => columnPinning, (v) => (columnPinning = v))) }, onColumnOrderChange: (updaterOrValue) => { valueUpdater(updaterOrValue, box(() => columnOrder, (v) => (columnOrder = v))) }, onGroupingChange: (updaterOrValue) => { valueUpdater(updaterOrValue, box(() => grouping, (v) => (grouping = v))) }, state: { get sorting() { return sorting }, get columnFilters() { return columnFilters }, get columnVisibility() { return columnVisibility }, get pagination() { return pagination }, get rowSelection() { return rowSelection }, get expanded() { return expanded }, get columnPinning() { return columnPinning }, get columnOrder() { return columnOrder }, get grouping() { return grouping }, }, }) /** Build and emit current server-side state */ function emitStateUpdate() { if (!isMounted) return const s = sorting[0] const filterMap: Record<string, any> = {} for (const cf of columnFilters) { filterMap[cf.id] = cf.value } onStateChange?.({ page: table.getState().pagination.pageIndex + 1, pageSize: table.getState().pagination.pageSize, sortBy: s?.id || '', sortOrder: s?.desc ? 'desc' : 'asc', filters: filterMap, search: (filterColumn && (table.getColumn(filterColumn)?.getFilterValue() as string)) || '', }) } function onSearchInput(val: string) { if (!filterColumn || !table.getColumn(filterColumn)) return table.getColumn(filterColumn)?.setFilterValue(val || undefined) if (isServerSide) { if (searchDebounceTimer) clearTimeout(searchDebounceTimer) searchDebounceTimer = setTimeout(() => { table.setPageIndex(0) emitStateUpdate() }, 500) } } // ── Filter snapshot helpers ────────────────────────────────────────────── function snapshotFilters() { filterApplied = false filterSnapshot = JSON.parse(JSON.stringify(columnFilters)) dateRangeSnapshot = JSON.parse(JSON.stringify(dateRangeModels)) } function maybeRestoreFilters() { if (!filterApplied && filterSnapshot) { columnFilters = filterSnapshot dateRangeModels = dateRangeSnapshot ?? {} } filterSnapshot = null dateRangeSnapshot = null } function onFilterSheetOpen() { snapshotFilters() isFilterSheetOpen = true } function onFilterSheetClose(open: boolean) { if (!open) maybeRestoreFilters() isFilterSheetOpen = open } // ── Filter helpers ───────────────────────────────────────────────────── function getMultiSelectValue(column: string): string[] { return (table.getColumn(column)?.getFilterValue() as string[]) ?? [] } function getDateRangeValue(column: string): DateRangeValue { return (table.getColumn(column)?.getFilterValue() as DateRangeValue) ?? {} } function getCalendarModel(column: string): DateRange | undefined { return isoRangeToCalendar(getDateRangeValue(column)) } function onCalendarUpdate(column: string, val: DateRange | undefined) { const from = val?.start ? dateToIso(val.start) : undefined const to = val?.end ? dateToIso(val.end) : undefined dateRangeModels = { ...dateRangeModels, [column]: { from, to } } 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) filters.forEach((f) => { table.getColumn(f.column)?.setFilterValue(undefined) if (f.type === 'date') { dateRangeModels = { ...dateRangeModels, [f.column]: {} } } }) if (filterColumn && table.getColumn(filterColumn)) { table.getColumn(filterColumn)?.setFilterValue(undefined) } if (isServerSide) { table.setPageIndex(0) queueMicrotask(() => 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 DateRangeValue return !!(d.from || d.to) } return true } const hasSearchValue = $derived(!!(filterColumn && (table.getColumn(filterColumn)?.getFilterValue() as string))) const isAnyFilterActive = $derived(hasSearchValue || 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 clearFilter(filter: FilterDefinition) { table.getColumn(filter.column)?.setFilterValue(undefined) } const activeFilterCount = $derived(filters.filter(isFilterActive).length) function clearDateFilter(filter: FilterDefinition) { clearFilter(filter) dateRangeModels = { ...dateRangeModels, [filter.column]: {} } } function onApplyFilters() { filterApplied = true if (isServerSide) { if (table.getState().pagination.pageIndex === 0) { emitStateUpdate() } else { table.setPageIndex(0) } } isFilterSheetOpen = false } /** * Popover filter mode: handle the staged-edit commit. 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 filters) { if (!(f.column in draft)) continue const val = draft[f.column] if (f.type === 'date') { const dr = (val ?? {}) as DateRangeValue dateRangeModels = { ...dateRangeModels, [f.column]: { from: dr.from, to: dr.to } } table.getColumn(f.column)?.setFilterValue(val) } else { table.getColumn(f.column)?.setFilterValue(val) } } onApplyFilters() } // ── Column reorder (HTML5 drag/drop swaps dragged column with drop target) ── let dragColId = $state<string | null>(null) let dragOverColId = $state<string | null>(null) function onColDragStart(id: string, e: DragEvent) { dragColId = 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 (!enableReorder || !dragColId) return e.preventDefault() dragOverColId = targetId if (e.dataTransfer) e.dataTransfer.dropEffect = 'move' } function onColDragEnd() { document.body.style.cursor = '' dragColId = null dragOverColId = null } function onColDrop(targetId: string) { const src = dragColId onColDragEnd() if (!src || src === targetId) return const order = (columnOrder.length ? columnOrder : 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 = order } // ── Pinning — return style for sticky pinned cells (header or body) ── function pinStyle(col: Column<TData, unknown>): string | undefined { const side = col.getIsPinned() if (!side) return undefined if (side === 'left') return `position: sticky; left: ${col.getStart('left')}px; z-index: 2` return `position: sticky; right: ${col.getAfter('right')}px; z-index: 2` } function headStyle(header: Header<TData, unknown>): string | undefined { const parts: string[] = [] if (enableResize) parts.push(`width: ${header.getSize()}px`) const pin = pinStyle(header.column) if (pin) parts.push(pin) return parts.length ? parts.join('; ') : undefined } // ── Infinite scroll — sentinel row triggers fetch-more when visible ── // Re-attaches when the sentinel mounts, `infinite` flips, or rows / the // callback change (so a still-visible sentinel re-fires after an append). let sentinelEl = $state<HTMLDivElement | null>(null) $effect(() => { const node = sentinelEl const fetchMore = onFetchMore void data if (!infinite || !node) return const io = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting) fetchMore?.() }, { rootMargin: '100px' }, ) io.observe(node) return () => io.disconnect() }) function exportableColumns() { return table.getVisibleLeafColumns().filter((c) => c.id !== 'select' && c.id !== 'actions' && c.id !== 'expander') } function headerLabels(cols: Column<TData, unknown>[]) { return cols.map((c) => String(c.columnDef.header && typeof c.columnDef.header === 'string' ? c.columnDef.header : c.id), ) } // ── CSV export — currently filtered + visible data, skips select/actions/expander cols. ── export function exportCsv() { const visibleCols = exportableColumns() const headers = headerLabels(visibleCols) 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') downloadBlob(csv, 'text/csv;charset=utf-8;', `export-${Date.now()}.csv`) } // ── JSON export — same scope as CSV, but emits underlying row.original objects ── export function exportJson() { const visibleColIds = exportableColumns().map((c) => c.id) const rows = table.getFilteredRowModel().rows.map((row) => { const original = row.original as Record<string, unknown> 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) downloadBlob(json, 'application/json;charset=utf-8;', `export-${Date.now()}.json`) } function downloadBlob(content: string, type: string, filename: string) { const blob = new Blob([content], { type }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = filename document.body.appendChild(a) a.click() document.body.removeChild(a) URL.revokeObjectURL(url) } function targetRowsFor(rowsToCopy?: Row<TData>[]) { return rowsToCopy && rowsToCopy.length > 0 ? rowsToCopy : table.getSelectedRowModel().rows.length > 0 ? table.getSelectedRowModel().rows : table.getFilteredRowModel().rows } // ── TSV clipboard copy — formatted for instant paste into Excel / Sheets / Notion ── export function copyTsv(rowsToCopy?: Row<TData>[]) { const visibleCols = exportableColumns() const headers = headerLabels(visibleCols) const rowData = targetRowsFor(rowsToCopy).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 docs ── export function copyMarkdown(rowsToCopy?: Row<TData>[]) { const visibleCols = exportableColumns() const headers = headerLabels(visibleCols) const rowData = targetRowsFor(rowsToCopy).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) ─────── let focusedRowIndex = $state(-1) let lastSelectedRowIndex = $state(-1) function onTableKeyDown(e: KeyboardEvent) { if (!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 + 1, rows.length - 1) if (e.shiftKey && focusedRowIndex >= 0) { const start = Math.min(focusedRowIndex, nextIdx) const end = Math.max(focusedRowIndex, nextIdx) for (let i = start; i <= end; i++) { rows[i]?.toggleSelected(true) } } focusedRowIndex = nextIdx } else if (isUp) { e.preventDefault() const prevIdx = Math.max(focusedRowIndex - 1, 0) if (e.shiftKey && focusedRowIndex >= 0) { const start = Math.min(focusedRowIndex, prevIdx) const end = Math.max(focusedRowIndex, prevIdx) for (let i = start; i <= end; i++) { rows[i]?.toggleSelected(true) } } focusedRowIndex = prevIdx } else if (isSpace) { if (focusedRowIndex >= 0 && focusedRowIndex < rows.length) { e.preventDefault() rows[focusedRowIndex]!.toggleSelected() lastSelectedRowIndex = focusedRowIndex } } else if (isEnter) { if (focusedRowIndex >= 0 && focusedRowIndex < rows.length) { e.preventDefault() onRowClick?.(rows[focusedRowIndex]!.original) } } else if (isEscape) { if (table.getSelectedRowModel().rows.length > 0) { e.preventDefault() table.toggleAllRowsSelected(false) } focusedRowIndex = -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 >= 0 && focusedRowIndex < rows.length) { e.preventDefault() copyTsv([rows[focusedRowIndex]!]) } } } function onRowClicked(row: Row<TData>, idx: number, event: MouseEvent) { focusedRowIndex = idx if (event.shiftKey && lastSelectedRowIndex >= 0) { const rows = table.getRowModel().rows const start = Math.min(lastSelectedRowIndex, idx) const end = Math.max(lastSelectedRowIndex, idx) for (let i = start; i <= end; i++) { rows[i]?.toggleSelected(true) } } else { lastSelectedRowIndex = idx } onRowClick?.(row.original) } const selectedRows = $derived(table.getSelectedRowModel().rows) const bodyRows = $derived(table.getRowModel().rows) const toolbarProps = $derived({ table: table as TanstackTable<any>, filterColumn, filterPlaceholder, filters, filterMode, enableSearch, enableColumnVisibility, enableExport, enableDensityToggle, density: currentDensity, activeFilterCount, isAnyFilterActive, isServerSide, getMultiSelectValue, getDateRangeValue, getFilterSelectedLabels, formatDateRange, getCalendarModel, onSearch: onSearchInput, onOpenFilterSheet: onFilterSheetOpen, onApplyFilters, onClearAllFilters: clearAllFilters, onToggleMultiselect: toggleMultiSelectValue, onClearFilter: clearFilter, onClearDateFilter: clearDateFilter, onCalendarUpdate, onTextFilterUpdate: (col: string, val: string | undefined) => table.getColumn(col)?.setFilterValue(val), onCommitFilters: onCommitDraft, onExportCsv: exportCsv, onExportJson: exportJson, onCopyTsv: () => copyTsv(), onCopyMarkdown: () => copyMarkdown(), onDensityChange: (v: 'compact' | 'cozy' | 'comfortable') => (densityOverride = v), toolbarExtra, customFilters, }) </script> <!-- svelte-ignore a11y_no_noninteractive_tabindex, a11y_no_static_element_interactions: the table container is a focusable keyboard-navigation surface (React/Vue parity: no role). --> <div data-uipkge="" data-slot="data-table" class="focus-visible:ring-border/50 relative w-full outline-none focus-visible:ring-1" tabindex={0} onkeydown={onTableKeyDown} > <!-- Toolbar (above-mode: floats outside the bordered card). --> {#if !hideToolbar && toolbarPosition === 'above'} <DataTableToolbar {...toolbarProps} borderless /> {/if} <div class={['bg-card text-card-foreground overflow-hidden', borderless === 'full' ? '' : 'rounded-md border'].join(' ')}> <!-- Toolbar (inside-mode, default). --> {#if !hideToolbar && toolbarPosition === 'inside'} <DataTableToolbar {...toolbarProps} borderless={dropInnerBorders} /> {/if} <!-- Filter Sheet (modal mode) --> <DataTableFilterSheet open={isFilterSheetOpen} onOpenChange={onFilterSheetClose} table={table as TanstackTable<any>} {filters} {activeFilterCount} {isAnyFilterActive} {isServerSide} borderless={dropInnerBorders} {getMultiSelectValue} {getDateRangeValue} {formatDateRange} {getCalendarModel} onApply={onApplyFilters} onClearAll={clearAllFilters} onToggleMultiselect={toggleMultiSelectValue} onClearFilter={clearFilter} onClearDateFilter={clearDateFilter} {onCalendarUpdate} onTextFilterUpdate={(col, val) => table.getColumn(col)?.setFilterValue(val)} {customFilters} /> <!-- Bulk action bar (inline mode) --> {#if bulkActionPosition === 'inline' && renderBulkActions && selectedRows.length > 0} <div 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"> {selectedRows.length} selected </span> <div class="flex min-w-0 flex-1 items-center gap-2"> {@render renderBulkActions(selectedRows, () => table.toggleAllRowsSelected(false))} </div> <button type="button" class="text-muted-foreground hover:text-foreground shrink-0 text-xs font-medium transition" onclick={() => table.toggleAllRowsSelected(false)} > Clear </button> </div> {/if} <!-- Table. The Table primitive wraps the <table> in its own overflow-auto div; neutralize that inner overflow and let the outer scroll container own the scroll region. --> <div class={[densityClass, 'relative [&_[data-slot=table-container]]:overflow-visible', maxHeight ? 'overflow-auto' : ''].join( ' ', )} style:max-height={maxHeight || undefined} > <Table> <TableHeader class={stickyHeader ? 'bg-card/95 sticky top-0 z-10 shadow-[0_1px_0_0_var(--border)] backdrop-blur-sm' : undefined} > {#each table.getHeaderGroups() as headerGroup (headerGroup.id)} <TableRow> {#each headerGroup.headers as header (header.id)} {@const reorderable = enableReorder && header.column.id !== 'select' && header.column.id !== 'actions' && header.column.id !== 'expander'} {@const sortDir = header.column.getIsSorted()} <TableHead scope="col" aria-sort={header.column.getCanSort() ? sortDir === 'asc' ? 'ascending' : sortDir === 'desc' ? 'descending' : 'none' : undefined} class={[ 'relative transition-colors duration-150', reorderable ? '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' : '', ].join(' ')} style={headStyle(header)} draggable={reorderable} ondragstart={reorderable ? (e) => onColDragStart(header.column.id, e) : undefined} ondragover={reorderable ? (e) => onColDragOver(header.column.id, e) : undefined} ondragend={reorderable ? () => onColDragEnd() : undefined} ondrop={reorderable ? () => onColDrop(header.column.id) : undefined} > {#if !header.isPlaceholder} <FlexRender content={header.column.columnDef.header} context={header.getContext()} /> {/if} {#if enableResize && header.column.getCanResize()} <!-- svelte-ignore a11y_no_static_element_interactions: pointer-only resize handle (React parity). --> <div class={[ 'hover:bg-foreground/30 absolute top-0 right-0 h-full w-1 cursor-col-resize touch-none transition-colors select-none', header.column.getIsResizing() ? 'bg-foreground/60' : '', ].join(' ')} onmousedown={header.getResizeHandler()} ontouchstart={header.getResizeHandler()} ></div> {/if} </TableHead> {/each} </TableRow> {/each} </TableHeader> <TableBody class={loading && bodyRows.length ? 'pointer-events-none opacity-60' : undefined} aria-busy={loading || undefined} > {#if loading && !bodyRows.length} {#each Array.from({ length: 5 }) as _, i (`sk-${i}`)} <TableRow class="hover:bg-transparent"> {#each Array.from({ length: Math.max(columns.length, 1) }) as __, j (`sk-${i}-${j}`)} <TableCell> <div class="bg-muted h-4 max-w-48 animate-pulse rounded" style:width={`${48 + ((i * 17 + j * 23) % 40)}%`} ></div> </TableCell> {/each} </TableRow> {/each} {:else if bodyRows.length} {#each bodyRows as row, idx (row.id)} {#if row.getIsGrouped()} <TableRow class="bg-muted/50 hover:bg-muted/70 cursor-pointer" onclick={() => row.toggleExpanded()}> <TableCell colspan={row.getVisibleCells().length}> <button type="button" class="flex items-center gap-2 text-sm font-medium"> {#if row.getIsExpanded()} <ChevronDown class="size-4" /> {:else} <ChevronRight class="size-4" /> {/if} <span>{String(row.groupingValue ?? '')}</span> <span class="text-muted-foreground font-normal">{row.subRows.length}</span> </button> </TableCell> </TableRow> {: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' : '', virtual ? '[contain-intrinsic-size:auto_48px] [content-visibility:auto]' : '', ] .filter(Boolean) .join(' ') || undefined} onclick={(e) => onRowClicked(row, idx, e)} > {#each row.getVisibleCells() as cell (cell.id)} <TableCell class={row.getIsSelected() ? 'bg-muted' : 'bg-card'} style={pinStyle(cell.column)}> <FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} /> </TableCell> {/each} </TableRow> {#if row.getIsExpanded() && renderExpanded} <TableRow> <TableCell colspan={row.getVisibleCells().length} class="bg-muted/30 px-6 py-4"> {@render renderExpanded(row.original, row)} </TableCell> </TableRow> {/if} {/if} {/each} {:else} <TableRow class="hover:bg-transparent"> <TableCell colspan={columns.length} class="h-36 text-center"> {#if emptyState} {@render emptyState()} {:else} <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> {/if} </TableCell> </TableRow> {/if} </TableBody> {#if renderFooter} <tfoot class="bg-muted/20 sticky bottom-0 border-t">{@render renderFooter(bodyRows)}</tfoot> {/if} </Table> <!-- Infinite scroll sentinel --> {#if infinite} <div bind:this={sentinelEl} class="h-1"></div> {/if} {#if infinite && loading} <div class="border-border text-muted-foreground border-t px-4 py-3 text-center text-sm">Loading more…</div> {/if} </div> <!-- Pagination (inside-mode, default) --> {#if enablePagination && !infinite && paginationPosition === 'inside'} <DataTablePagination table={table as TanstackTable<any>} {totalRows} {isServerSide} borderless={dropInnerBorders} /> {/if} </div> <!-- Pagination (below-mode: floats outside the bordered card). --> {#if enablePagination && !infinite && paginationPosition === 'below'} <DataTablePagination table={table as TanstackTable<any>} {totalRows} {isServerSide} borderless /> {/if} <!-- Floating Bulk Actions HUD Dock — Linear/Raycast craft dock --> {#if bulkActionPosition === 'floating' && selectedRows.length > 0} <div 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" > {selectedRows.length} selected </span> <div class="bg-border h-3.5 w-px"></div> <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)" onclick={() => copyTsv(selectedRows)} > <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" onclick={exportCsv} > <Download class="size-3" /> Export </button> {@render renderBulkActions?.(selectedRows, () => table.toggleAllRowsSelected(false))} <div class="bg-border h-3.5 w-px"></div> <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" onclick={() => table.toggleAllRowsSelected(false)} > <X class="size-3.5" /> </button> </div> {/if} </div>src/lib/components/ui/data-table/DataTableColumnHeader.svelte10.5 kB
<!-- Sortable / hideable header cell for TanStack DataTable columns. Use it from a column definition like: header: ({ column }) => renderComponent(DataTableColumnHeader, { column, label: 'Email' }) Click on the label cycles sort asc → desc → none. Optional per-column filter: header: ({ column }) => renderComponent(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. --> <script lang="ts" module> import type { Column } from '@tanstack/table-core' interface FilterOption { value: string label: string } interface FilterDefinition { column: string label: string type: 'text' | 'select' | 'multiselect' | 'date' options?: (string | FilterOption)[] } export interface DataTableColumnHeaderProps<TData, TValue> { column: Column<TData, TValue> label: string align?: 'left' | 'right' | 'center' filter?: FilterDefinition class?: string } function resolveOption(opt: string | FilterOption): FilterOption { return typeof opt === 'string' ? { value: opt, label: opt } : opt } </script> <script lang="ts" generics="TData, TValue"> import { untrack } from 'svelte' import { ArrowUp, ArrowUpDown, Check, Filter, FilterX } from '@lucide/svelte' import { cn } from '$lib/utils' import { Button } from '$lib/components/ui/button' import { Input } from '$lib/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '$lib/components/ui/popover' import type { DateRange } from '$lib/components/ui/range-calendar' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '$lib/components/ui/command' import DataTableRangeCalendar from './DataTableRangeCalendar.svelte' import { dateToIso, isoRangeToCalendar } from './date-utils' let { column, label, align = 'left', filter, class: className }: DataTableColumnHeaderProps<TData, TValue> = $props() let open = $state(false) function next() { const current = column.getIsSorted() if (!current) column.toggleSorting(false) else if (current === 'asc') column.toggleSorting(true) else column.clearSorting() } const filterValue = $derived(column.getFilterValue()) const isFilterActive = $derived.by(() => { const v = filterValue 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. let textDraft = $state('') $effect(() => { if (open && filter?.type === 'text') { textDraft = untrack(() => (filterValue as string) ?? '') } }) function applyText() { column.setFilterValue(textDraft || undefined) } function clearText() { textDraft = '' column.setFilterValue(undefined) } function toggleMultiselect(value: string) { const current = (filterValue as string[]) ?? [] const nextVal = current.includes(value) ? current.filter((v) => v !== value) : [...current, value] column.setFilterValue(nextVal.length > 0 ? nextVal : undefined) } function selectOne(value: string) { column.setFilterValue(value || undefined) open = false } function clearFilter() { column.setFilterValue(undefined) textDraft = '' } // Date-range bridging. The column stores ISO strings; the calendar wants // native Date instances. Reads/writes both shapes. const dateModel = $derived(isoRangeToCalendar(filterValue as { from?: string; to?: string } | undefined)) function onDateSelect(range: DateRange | undefined) { const isoFrom = range?.start ? dateToIso(range.start) : undefined const isoTo = range?.end ? dateToIso(range.end) : undefined if (!isoFrom && !isoTo) { column.setFilterValue(undefined) } else { column.setFilterValue({ from: isoFrom, to: isoTo }) } } function selectedLabels(): string[] { if (!filter?.options) return [] const selected = (filterValue as string[]) ?? [] return filter.options .map(resolveOption) .filter((o) => selected.includes(o.value)) .map((o) => o.label) } </script> <div class={cn( 'flex items-center gap-0.5', align === 'right' && 'justify-end', align === 'center' && 'justify-center', className, )} > {#if column.getCanSort()} <button type="button" 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}`} onclick={next} > <span>{label}</span> {#if column.getIsSorted()} <ArrowUp class={cn( 'text-foreground size-3.5 transition-transform duration-200 ease-in-out', column.getIsSorted() === 'desc' ? 'rotate-180' : 'rotate-0', )} /> {:else} <ArrowUpDown class="size-3.5 opacity-40 transition-opacity duration-150 group-hover:opacity-70" /> {/if} </button> {:else} <span class="text-muted-foreground text-sm font-medium">{label}</span> {/if} <!-- Optional per-column header filter --> {#if filter} <Popover bind:open> <PopoverTrigger> {#snippet child({ props })} <button {...props} 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" /> {#if isFilterActive} <span aria-hidden="true" class="bg-primary absolute -top-0.5 -right-0.5 size-1.5 rounded-full"></span> {/if} </button> {/snippet} </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> {#if isFilterActive} <button type="button" class="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 transition-colors" onclick={clearFilter} > <FilterX class="size-3" /> Clear </button> {/if} </div> <!-- TEXT --> {#if filter.type === 'text'} <div class="space-y-2 p-3"> <Input value={textDraft} placeholder={`Search ${filter.label.toLowerCase()}…`} class="h-8" onValueChange={(v) => (textDraft = v)} onkeydown={(e) => { if (e.key === 'Enter') { applyText() open = false } }} onfocusout={applyText} /> <div class="flex gap-2"> <Button size="sm" class="h-8 flex-1" onclick={() => { applyText() open = false }} > Apply </Button> <Button size="sm" variant="outline" class="h-8" onclick={clearText}>Clear</Button> </div> </div> {/if} <!-- SELECT / MULTISELECT --> {#if filter.type === 'select' || filter.type === 'multiselect'} <Command class="max-h-[280px]"> <CommandInput placeholder={`Search ${filter.label.toLowerCase()}…`} class="h-8" /> <CommandList> <CommandEmpty>No matches.</CommandEmpty> <CommandGroup> {#each filter.options ?? [] as opt (resolveOption(opt).value)} {@const o = resolveOption(opt)} <CommandItem value={o.value} onSelect={() => (filter.type === 'multiselect' ? toggleMultiselect(o.value) : selectOne(o.value))} > {#if filter.type === 'multiselect'} <div class={cn( 'border-primary/50 mr-2 flex size-4 items-center justify-center rounded-sm border transition-colors', ((filterValue as string[]) ?? []).includes(o.value) ? 'bg-primary text-primary-foreground' : 'opacity-50', )} > <Check class="size-3" /> </div> {:else} <Check class={cn('mr-2 size-4', filterValue === o.value ? 'opacity-100' : 'opacity-0')} /> {/if} <span>{o.label}</span> </CommandItem> {/each} </CommandGroup> </CommandList> </Command> {/if} <!-- DATE RANGE --> {#if filter.type === 'date'} <div class="p-2"> <DataTableRangeCalendar value={dateModel} onValueChange={onDateSelect} /> <div class="flex gap-2 px-1 pt-2"> <Button size="sm" class="h-8 flex-1" onclick={() => (open = false)}>Apply</Button> <Button size="sm" variant="outline" class="h-8" onclick={clearFilter}>Clear</Button> </div> </div> {/if} <!-- Active selection summary --> {#if isFilterActive && (filter.type === 'multiselect' || filter.type === 'select')} <div class="border-border text-muted-foreground border-t px-3 py-2 text-xs"> {#if filter.type === 'multiselect'} <span> {selectedLabels().length} selected: <span class="text-foreground">{selectedLabels().join(', ')}</span> </span> {:else} <span> <span class="text-foreground">{filterValue as string}</span> </span> {/if} </div> {/if} </PopoverContent> </Popover> {/if} </div>src/lib/components/ui/data-table/DataTableFilterPopover.svelte15.3 kB
<!-- 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 call `onCommitDraft` with the full draft and the parent walks the entries calling `setFilterValue` per column. Closing the popover (Escape / click out) simply discards the draft. --> <script lang="ts" module> import type { Snippet } from 'svelte' import type { Table } from '@tanstack/table-core' import type { DateRange } from '$lib/components/ui/range-calendar' import type { FilterDefinition } from './types' type DraftDateValue = { from?: string; to?: string } type DraftValue = string[] | string | DraftDateValue | undefined type Draft = Record<string, DraftValue> export interface DataTableFilterPopoverProps { 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) => DateRange | undefined // Committed draft on Apply -- a `Record<columnId, value>` where each // value is the final shape TanStack's `setFilterValue` expects. onCommitDraft: (draft: Draft) => void onClearAll: () => void customFilters?: Snippet } </script> <script lang="ts"> import { Check, SlidersHorizontal, X } from '@lucide/svelte' import { Input } from '$lib/components/ui/input' import { Badge } from '$lib/components/ui/badge' import { Button } from '$lib/components/ui/button' import { Label } from '$lib/components/ui/label' import { Popover, PopoverContent, PopoverTrigger } from '$lib/components/ui/popover' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '$lib/components/ui/command' import DataTableRangeCalendar from './DataTableRangeCalendar.svelte' import { resolveOption } from './types' import { dateToIso, isoRangeToCalendar } from './date-utils' let { table, filters, activeFilterCount, isServerSide, getMultiSelectValue, getDateRangeValue, onCommitDraft, customFilters, }: DataTableFilterPopoverProps = $props() let open = $state(false) let draft = $state<Draft>({}) let filterScrollEl = $state<HTMLDivElement | null>(null) function seedDraft(): Draft { const next: Draft = {} for (const f of filters) { if (f.type === 'multiselect' || f.type === 'select') { next[f.column] = [...getMultiSelectValue(f.column)] } else if (f.type === 'date') { const dr = getDateRangeValue(f.column) next[f.column] = { from: dr.from, to: dr.to } } else if (f.type === 'text') { const v = table.getColumn(f.column)?.getFilterValue() as string | undefined next[f.column] = v ?? '' } } return next } function getDraftMulti(column: string): string[] { const v = draft[column] return Array.isArray(v) ? v : [] } function getDraftText(column: string): string { const v = draft[column] return typeof v === 'string' ? v : '' } function getDraftDate(column: string): DraftDateValue { const v = draft[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 = { ...draft, [column]: next } } function setDraftText(column: string, value: string) { draft = { ...draft, [column]: value } } function clearDraftSection(filter: FilterDefinition) { if (filter.type === 'multiselect' || filter.type === 'select') { draft = { ...draft, [filter.column]: [] } } else if (filter.type === 'date') { draft = { ...draft, [filter.column]: {} } } else if (filter.type === 'text') { draft = { ...draft, [filter.column]: '' } } } function resetDraft() { const next: Draft = {} for (const f of 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 = next } // ── Date helpers (local; popover is fully self-contained for draft) ── function getDraftCalendarModel(column: string): DateRange | undefined { return isoRangeToCalendar(getDraftDate(column)) } function onDraftCalendarUpdate(column: string, val: DateRange | undefined) { const from = val?.start ? dateToIso(val.start) : undefined const to = val?.end ? dateToIso(val.end) : undefined draft = { ...draft, [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 '' } 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 = $derived(filters.some(isDraftSectionActive)) function handleOpenChange(isOpen: boolean) { if (isOpen) { draft = seedDraft() open = true setTimeout(() => filterScrollEl?.scrollTo({ top: 0 }), 50) } else { // Close-without-apply: nothing to commit. Real column filters were never // mutated by the popover; the draft is local and discarded. draft = {} open = false } } function applyAndClose() { const snapshot: Draft = {} for (const f of filters) { const v = draft[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 } } onCommitDraft(snapshot) open = false } const filteredRowCount = $derived(table.getFilteredRowModel().rows.length) </script> <Popover {open} onOpenChange={handleOpenChange}> <PopoverTrigger> {#snippet child({ props })} <Button {...props} variant="outline" size="sm" class={[ 'h-8 gap-2', activeFilterCount > 0 ? 'border-primary/40 bg-primary/5 text-primary hover:bg-primary/10' : '', ].join(' ')} > <SlidersHorizontal class="size-4" /> Filters {#if activeFilterCount > 0} <Badge class="bg-primary text-primary-foreground ml-0.5 size-5 rounded-full p-0 text-xs font-semibold"> {activeFilterCount} </Badge> {/if} </Button> {/snippet} </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"> {#if activeFilterCount > 0} {activeFilterCount} active{#if !isServerSide}{' '}· {filteredRowCount} result{filteredRowCount !== 1 ? 's' : ''}{/if} {:else} Narrow down results {/if} </p> </div> </div> </div> <!-- Scrollable filter sections --> <div bind:this={filterScrollEl} class="flex-1 overflow-y-auto"> <div class="space-y-2 p-3"> {#each filters as filter (filter.column)} {#if filter.type === 'text'} <div 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> {#if getDraftText(filter.column)} <button type="button" class="text-muted-foreground hover:text-foreground text-xs transition-colors" onclick={() => clearDraftSection(filter)} > Clear </button> {/if} </div> <Input placeholder={`Filter by ${filter.label.toLowerCase()}...`} value={getDraftText(filter.column)} class="h-8 text-sm" onValueChange={(v) => setDraftText(filter.column, v ?? '')} /> </div> {:else if filter.type === 'multiselect' || filter.type === 'select'} {@const multi = getDraftMulti(filter.column)} <div class={[ 'rounded-lg p-2.5 transition-colors', multi.length > 0 ? 'bg-primary/[0.04] ring-primary/20 ring-1' : 'bg-muted/40', ].join(' ')} > <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> {#if multi.length > 0} <Badge variant="secondary" class="bg-primary/15 text-primary h-4 rounded-full px-1.5 text-xs font-semibold" > {multi.length} </Badge> {/if} </div> {#if multi.length > 0} <button type="button" class="text-muted-foreground hover:text-foreground text-xs transition-colors" onclick={() => clearDraftSection(filter)} > Clear </button> {/if} </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"> {#each filter.options ?? [] as rawOpt (resolveOption(rawOpt).value)} {@const opt = resolveOption(rawOpt)} {@const OptIcon = opt.icon} <CommandItem value={opt.label} class="rounded-md px-2 py-1.5 text-sm" onSelect={() => toggleDraftMulti(filter.column, opt.value)} > <div class={[ 'flex size-4 shrink-0 items-center justify-center rounded-sm border transition-colors', multi.includes(opt.value) ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/40 [&_svg]:invisible', ].join(' ')} > <Check class="size-3" /> </div> {#if OptIcon}<OptIcon class="text-muted-foreground size-4" />{/if} <span>{opt.label}</span> </CommandItem> {/each} </CommandGroup> </CommandList> </Command> </div> {:else if filter.type === 'date'} {@const dr = getDraftDate(filter.column)} {@const hasDate = !!(dr.from || dr.to)} <div class={[ 'rounded-lg p-2.5 transition-colors', hasDate ? 'bg-primary/[0.04] ring-primary/20 ring-1' : 'bg-muted/40', ].join(' ')} > <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> {#if hasDate} <Badge 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> {/if} </div> {#if hasDate} <button type="button" class="text-muted-foreground hover:text-foreground text-xs transition-colors" onclick={() => clearDraftSection(filter)} > Clear </button> {/if} </div> <div class="flex justify-center overflow-hidden rounded-md border"> <DataTableRangeCalendar value={getDraftCalendarModel(filter.column)} numberOfMonths={1} class="p-2" onValueChange={(range) => onDraftCalendarUpdate(filter.column, range)} /> </div> </div> {/if} {/each} <!-- Consumer-supplied custom filter UI --> {@render customFilters?.()} </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} onclick={resetDraft}> <X class="size-3.5" /> Reset </Button> <Button size="sm" class="h-8 flex-1" onclick={applyAndClose}>Apply</Button> </div> </PopoverContent> </Popover>src/lib/components/ui/data-table/DataTableFilterSheet.svelte10.7 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { Table } from '@tanstack/table-core' import type { DateRange } from '$lib/components/ui/range-calendar' import type { FilterDefinition } from './types' export interface DataTableFilterSheetProps { open: boolean onOpenChange: (open: boolean) => void 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) => DateRange | undefined onApply: () => void onClearAll: () => void onToggleMultiselect: (column: string, value: string) => void onClearFilter: (filter: FilterDefinition) => void onClearDateFilter: (filter: FilterDefinition) => void onCalendarUpdate: (column: string, value: DateRange | undefined) => void onTextFilterUpdate: (column: string, value: string | undefined) => void customFilters?: Snippet } </script> <script lang="ts"> import { Check, SlidersHorizontal, X } from '@lucide/svelte' import { Input } from '$lib/components/ui/input' import { Badge } from '$lib/components/ui/badge' import { Button } from '$lib/components/ui/button' import { Label } from '$lib/components/ui/label' import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from '$lib/components/ui/sheet' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, } from '$lib/components/ui/command' import DataTableRangeCalendar from './DataTableRangeCalendar.svelte' import { resolveOption } from './types' let { open, onOpenChange, table, filters, activeFilterCount, isAnyFilterActive, isServerSide, borderless = false, getMultiSelectValue, getDateRangeValue, formatDateRange, getCalendarModel, onApply, onClearAll, onToggleMultiselect, onClearFilter, onClearDateFilter, onCalendarUpdate, onTextFilterUpdate, customFilters, }: DataTableFilterSheetProps = $props() let filterScrollEl = $state<HTMLDivElement | null>(null) $effect(() => { if (open) { const t = setTimeout(() => { filterScrollEl?.scrollTo({ top: 0 }) }, 50) return () => clearTimeout(t) } }) const filteredRowCount = $derived(table.getFilteredRowModel().rows.length) </script> <Sheet {open} {onOpenChange}> <SheetContent class={['flex flex-col gap-0 p-0 sm:max-w-[400px]', borderless ? 'border-0' : ''].join(' ')}> <!-- 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"> {#if activeFilterCount > 0} {activeFilterCount} active filter{activeFilterCount > 1 ? 's' : ''}{#if !isServerSide}{' '}· {filteredRowCount} result{filteredRowCount !== 1 ? 's' : ''}{/if} {:else} Narrow down results {/if} </SheetDescription> </SheetHeader> </div> </div> </div> <!-- Scrollable filter sections --> <div bind:this={filterScrollEl} class="flex-1 overflow-y-auto"> <div class="space-y-2 p-4"> {#each filters as filter (filter.column)} {@const textValue = (table.getColumn(filter.column)?.getFilterValue() as string) ?? ''} {@const multi = getMultiSelectValue(filter.column)} {@const dr = getDateRangeValue(filter.column)} {@const hasDate = !!(dr.from || dr.to)} {#if filter.type === 'text'} <div 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> {#if textValue} <button type="button" class="text-muted-foreground hover:text-foreground text-xs transition-colors" onclick={() => onTextFilterUpdate(filter.column, undefined)} > Clear </button> {/if} </div> <Input placeholder={`Filter by ${filter.label.toLowerCase()}...`} value={textValue} class="h-8 text-sm" onValueChange={(v) => onTextFilterUpdate(filter.column, v || undefined)} /> </div> {:else if filter.type === 'multiselect' || filter.type === 'select'} <div class={borderless ? 'py-2 transition-colors' : [ 'rounded-lg p-3 transition-colors', multi.length > 0 ? 'bg-primary/[0.04] ring-primary/20 ring-1' : 'bg-muted/40', ].join(' ')} > <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> {#if multi.length > 0} <Badge variant="secondary" class="bg-primary/15 text-primary h-4 rounded-full px-1.5 text-xs font-semibold" > {multi.length} </Badge> {/if} </div> {#if multi.length > 0} <button type="button" class="text-muted-foreground hover:text-foreground text-xs transition-colors" onclick={() => onClearFilter(filter)} > Clear </button> {/if} </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"> {#each filter.options ?? [] as rawOpt (resolveOption(rawOpt).value)} {@const opt = resolveOption(rawOpt)} {@const OptIcon = opt.icon} <CommandItem value={opt.label} class="rounded-md px-2 py-1.5 text-sm" onSelect={() => onToggleMultiselect(filter.column, opt.value)} > <div class={[ 'flex size-4 shrink-0 items-center justify-center rounded-sm border transition-colors', multi.includes(opt.value) ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/40 [&_svg]:invisible', ].join(' ')} > <Check class="size-3" /> </div> {#if OptIcon}<OptIcon class="text-muted-foreground size-4" />{/if} <span>{opt.label}</span> </CommandItem> {/each} </CommandGroup> </CommandList> </Command> </div> {:else if filter.type === 'date'} <div class={borderless ? 'py-2 transition-colors' : [ 'rounded-lg p-3 transition-colors', hasDate ? 'bg-primary/[0.04] ring-primary/20 ring-1' : 'bg-muted/40', ].join(' ')} > <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> {#if hasDate} <Badge 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> {/if} </div> {#if hasDate} <button type="button" class="text-muted-foreground hover:text-foreground text-xs transition-colors" onclick={() => onClearDateFilter(filter)} > Clear </button> {/if} </div> <div class={['flex justify-center overflow-hidden', borderless ? '' : 'rounded-md border'].join(' ')}> <DataTableRangeCalendar value={getCalendarModel(filter.column)} numberOfMonths={1} class="p-2" onValueChange={(range) => onCalendarUpdate(filter.column, range)} /> </div> </div> {/if} {/each} <!-- Consumer-supplied custom filter UI --> {@render customFilters?.()} </div> </div> <!-- Footer --> <div class={['flex gap-2 px-4 py-3', borderless ? '' : 'border-t'].join(' ')}> <Button variant="outline" size="sm" class="flex-1" disabled={!isAnyFilterActive} onclick={onClearAll}> <X class="size-3.5" /> Reset All </Button> <Button size="sm" class="flex-1" onclick={onApply}> {#if isServerSide} Apply Filters {:else} Show {filteredRowCount} result{filteredRowCount !== 1 ? 's' : ''} {/if} </Button> </div> </SheetContent> </Sheet>src/lib/components/ui/data-table/DataTablePagination.svelte3.3 kB
<script lang="ts" module> import type { Table } from '@tanstack/table-core' export interface DataTablePaginationProps { table: Table<any> totalRows: number isServerSide: boolean borderless?: boolean } </script> <script lang="ts"> import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from '@lucide/svelte' import { Button } from '$lib/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '$lib/components/ui/select' let { table, totalRows, isServerSide, borderless = false }: DataTablePaginationProps = $props() const selected = $derived(table.getFilteredSelectedRowModel().rows.length) const total = $derived(isServerSide ? totalRows : table.getFilteredRowModel().rows.length) </script> <div class={[ 'flex flex-col gap-3 py-3 sm:flex-row sm:items-center sm:justify-between', borderless ? '' : 'border-t px-4', ].join(' ')} > <div class="text-muted-foreground text-sm tabular-nums"> <span class="text-foreground font-medium">{selected}</span> of {total} 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 value={`${table.getState().pagination.pageSize}`} onValueChange={(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"> {#each [10, 20, 30, 40, 50] as pageSize (pageSize)} <SelectItem value={`${pageSize}`}>{pageSize}</SelectItem> {/each} </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()} onclick={() => 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()} onclick={() => 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()} onclick={() => 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()} onclick={() => table.setPageIndex(table.getPageCount() - 1)} > <ChevronsRight class="size-4" aria-hidden="true" /> <span class="sr-only">Last page</span> </Button> </div> </div> </div>src/lib/components/ui/data-table/DataTableToolbar.svelte15.7 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { Table } from '@tanstack/table-core' import type { DateRange } from '$lib/components/ui/range-calendar' import type { FilterDefinition } from './types' export interface DataTableToolbarProps { 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) => DateRange | undefined onSearch: (value: string) => void onOpenFilterSheet: () => void onApplyFilters: () => void onClearAllFilters: () => void onToggleMultiselect: (column: string, value: string) => void onClearFilter: (filter: FilterDefinition) => void onClearDateFilter: (filter: FilterDefinition) => void onCalendarUpdate: (column: string, value: DateRange | undefined) => void onTextFilterUpdate: (column: string, value: string | undefined) => void onCommitFilters: (draft: Record<string, any>) => void onExportCsv: () => void onExportJson: () => void onCopyTsv?: () => void onCopyMarkdown?: () => void onDensityChange: (value: 'compact' | 'cozy' | 'comfortable') => void toolbarExtra?: Snippet customFilters?: Snippet } </script> <script lang="ts"> import { CalendarIcon, Check, ChevronDown, Columns3, Download, Plus, Rows3, Search, SlidersHorizontal, X, } from '@lucide/svelte' import { Input } from '$lib/components/ui/input' import { Badge } from '$lib/components/ui/badge' import { Button } from '$lib/components/ui/button' import { Separator } from '$lib/components/ui/separator' import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from '$lib/components/ui/dropdown-menu' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from '$lib/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '$lib/components/ui/popover' import DataTableRangeCalendar from './DataTableRangeCalendar.svelte' import DataTableFilterPopover from './DataTableFilterPopover.svelte' import { resolveOption } from './types' let { table, filterColumn, filterPlaceholder, filters, filterMode, enableSearch = true, enableColumnVisibility = true, enableExport = false, enableDensityToggle = false, density = 'cozy', borderless = false, activeFilterCount, isAnyFilterActive, isServerSide, getMultiSelectValue, getDateRangeValue, getFilterSelectedLabels, formatDateRange, getCalendarModel, onSearch, onOpenFilterSheet, onClearAllFilters, onToggleMultiselect, onClearFilter, onClearDateFilter, onCalendarUpdate, onTextFilterUpdate, onCommitFilters, onExportCsv, onExportJson, onCopyTsv, onCopyMarkdown, onDensityChange, toolbarExtra, customFilters, }: DataTableToolbarProps = $props() </script> <div class={['flex flex-col gap-2 py-3', borderless ? '' : 'border-b px-4'].join(' ')}> <!-- flex-wrap so faceted chips reflow instead of overflowing on narrow tables --> <div class="flex flex-wrap items-center gap-2"> <!-- Search only renders when filterColumn maps to an actual column. --> {#if enableSearch && filterColumn && table.getColumn(filterColumn)} <div 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'} value={(table.getColumn(filterColumn)?.getFilterValue() as string) ?? ''} onValueChange={(v) => onSearch(v)} /> </div> {/if} <!-- ── INLINE filter mode ── --> {#if filterMode === 'inline'} {#each filters as filter (filter.column)} {#if filter.type === 'multiselect' || filter.type === 'select'} {@const multi = getMultiSelectValue(filter.column)} <Popover> <PopoverTrigger> {#snippet child({ props })} <Button {...props} variant="outline" size="sm" class={[ 'h-8 border-dashed', multi.length > 0 ? 'border-primary/40 bg-primary/5 border-solid' : '', ].join(' ')} > <Plus class="size-4" aria-hidden="true" /> {filter.label} {#if multi.length > 0} <Separator orientation="vertical" class="mx-1 h-4" /> <div class="flex gap-1"> {#if multi.length > 2} <Badge variant="secondary" class="rounded-sm px-1 font-normal"> {multi.length} selected </Badge> {:else} {#each getFilterSelectedLabels(filter) as label (label)} <Badge variant="secondary" class="rounded-sm px-1 font-normal">{label}</Badge> {/each} {/if} </div> {/if} </Button> {/snippet} </PopoverTrigger> <PopoverContent class="w-52 p-0" align="start"> <Command> <CommandInput placeholder={`Search ${filter.label.toLowerCase()}...`} /> <CommandList> <CommandEmpty>No results.</CommandEmpty> <CommandGroup> {#each filter.options ?? [] as rawOpt (resolveOption(rawOpt).value)} {@const opt = resolveOption(rawOpt)} {@const OptIcon = opt.icon} <CommandItem value={opt.label} onSelect={() => onToggleMultiselect(filter.column, opt.value)}> <div class={[ 'border-primary flex size-4 shrink-0 items-center justify-center rounded-sm border', multi.includes(opt.value) ? 'bg-primary text-primary-foreground' : 'opacity-50 [&_svg]:invisible', ].join(' ')} > <Check class="size-3" /> </div> {#if OptIcon}<OptIcon class="text-muted-foreground size-4" />{/if} <span>{opt.label}</span> </CommandItem> {/each} </CommandGroup> {#if multi.length > 0} <CommandSeparator /> <CommandGroup> <CommandItem value="__clear__" class="justify-center text-center" onSelect={() => onClearFilter(filter)} > Clear filter </CommandItem> </CommandGroup> {/if} </CommandList> </Command> </PopoverContent> </Popover> {:else if filter.type === 'date'} {@const dr = getDateRangeValue(filter.column)} {@const hasDate = !!(dr.from || dr.to)} <Popover> <PopoverTrigger> {#snippet child({ props })} <Button {...props} variant="outline" size="sm" class={['h-8 border-dashed', hasDate ? 'border-primary/40 bg-primary/5 border-solid' : ''].join(' ')} > <CalendarIcon class="size-4" aria-hidden="true" /> {filter.label} {#if hasDate} <Separator orientation="vertical" class="mx-1 h-4" /> <Badge variant="secondary" class="rounded-sm px-1 font-normal"> {formatDateRange(filter.column)} </Badge> {/if} </Button> {/snippet} </PopoverTrigger> <PopoverContent class="w-auto p-0" align="start"> <DataTableRangeCalendar value={getCalendarModel(filter.column)} numberOfMonths={2} onValueChange={(range) => onCalendarUpdate(filter.column, range)} /> {#if hasDate} <div class="border-t p-2"> <Button variant="ghost" size="sm" class="h-7 w-full text-xs" onclick={() => onClearDateFilter(filter)}> Clear dates </Button> </div> {/if} </PopoverContent> </Popover> {:else if filter.type === 'text'} {@const textValue = table.getColumn(filter.column)?.getFilterValue() as string | undefined} <Popover> <PopoverTrigger> {#snippet child({ props })} <Button {...props} variant="outline" size="sm" class={['h-8 border-dashed', textValue ? 'border-primary/40 bg-primary/5 border-solid' : ''].join( ' ', )} > <Plus class="size-4" aria-hidden="true" /> {filter.label} {#if textValue} <Separator orientation="vertical" class="mx-1 h-4" /> <Badge variant="secondary" class="rounded-sm px-1 font-normal">{textValue}</Badge> {/if} </Button> {/snippet} </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()}...`} value={(table.getColumn(filter.column)?.getFilterValue() as string) ?? ''} class="h-8 text-sm" onValueChange={(v) => onTextFilterUpdate(filter.column, v || undefined)} /> </div> </PopoverContent> </Popover> {/if} {/each} {/if} <!-- ── MODAL filter mode (Sheet from the right) ── --> {#if filterMode === 'modal'} <Button variant="outline" size="sm" class={[ 'h-8', activeFilterCount > 0 ? 'border-primary/40 bg-primary/5 text-primary hover:bg-primary/10' : '', ].join(' ')} onclick={onOpenFilterSheet} > <SlidersHorizontal class="size-4" aria-hidden="true" /> Filters {#if activeFilterCount > 0} <Badge class="bg-primary text-primary-foreground ml-0.5 size-5 rounded-full p-0 text-xs font-semibold"> {activeFilterCount} </Badge> {/if} </Button> {/if} <!-- ── POPOVER filter mode ── --> {#if filterMode === 'popover' && filters.length > 0} <DataTableFilterPopover {table} {filters} {activeFilterCount} {isAnyFilterActive} {isServerSide} {getMultiSelectValue} {getDateRangeValue} {formatDateRange} {getCalendarModel} onCommitDraft={(d) => onCommitFilters(d)} onClearAll={onClearAllFilters} {customFilters} /> {/if} <!-- Inline custom filters (when filterMode === 'inline') --> {#if filterMode === 'inline'} {@render customFilters?.()} {/if} <!-- Toolbar extras (e.g. group-by selector, density toggle) --> {@render toolbarExtra?.()} <!-- Reset button --> {#if isAnyFilterActive} <Button variant="ghost" size="sm" class="h-8 px-2" onclick={onClearAllFilters}> Reset <X class="size-4" aria-hidden="true" /> </Button> {/if} <!-- Right cluster: export / density / columns --> {#if enableExport || enableDensityToggle || enableColumnVisibility} <div class="ml-auto flex items-center gap-2"> {#if enableExport} <DropdownMenu> <DropdownMenuTrigger> {#snippet child({ props })} <Button {...props} variant="outline" size="sm" class="h-8"> <Download class="size-4" /> Export <ChevronDown class="size-4 opacity-60" /> </Button> {/snippet} </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem onSelect={onExportCsv}>Export as CSV</DropdownMenuItem> <DropdownMenuItem onSelect={onExportJson}>Export as JSON</DropdownMenuItem> <DropdownMenuItem onSelect={() => onCopyTsv?.()}>Copy as TSV (Excel / Sheets)</DropdownMenuItem> <DropdownMenuItem onSelect={() => onCopyMarkdown?.()}>Copy as Markdown</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> {/if} {#if enableDensityToggle} <DropdownMenu> <DropdownMenuTrigger> {#snippet child({ props })} <Button {...props} 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> {/snippet} </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuRadioGroup value={density} onValueChange={(v) => onDensityChange(v as 'compact' | 'cozy' | 'comfortable')} > <DropdownMenuRadioItem value="compact">Compact</DropdownMenuRadioItem> <DropdownMenuRadioItem value="cozy">Cozy</DropdownMenuRadioItem> <DropdownMenuRadioItem value="comfortable">Comfortable</DropdownMenuRadioItem> </DropdownMenuRadioGroup> </DropdownMenuContent> </DropdownMenu> {/if} {#if enableColumnVisibility} <DropdownMenu> <DropdownMenuTrigger> {#snippet child({ props })} <Button {...props} variant="outline" size="sm" class="h-8"> <Columns3 class="size-4" aria-hidden="true" /> View </Button> {/snippet} </DropdownMenuTrigger> <DropdownMenuContent align="end" class="w-44"> {#each table.getAllColumns().filter((column) => column.getCanHide()) as column (column.id)} <DropdownMenuCheckboxItem class="capitalize" checked={column.getIsVisible()} onCheckedChange={(value) => column.toggleVisibility(!!value)} > {column.id} </DropdownMenuCheckboxItem> {/each} </DropdownMenuContent> </DropdownMenu> {/if} </div> {/if} </div> </div>src/lib/components/ui/data-table/DataTableRangeCalendar.svelte1.1 kB
<script lang="ts" module> import type { DateRange } from '$lib/components/ui/range-calendar' export interface DataTableRangeCalendarProps { value: DateRange | undefined onValueChange: (value: DateRange | undefined) => void numberOfMonths?: number class?: string } </script> <script lang="ts"> import { RangeCalendar } from '$lib/components/ui/range-calendar' // Internal bridge used by every DataTable filter surface. The Svelte // RangeCalendar keeps an internal copy of the last committed range, so a // filter cleared from outside (Clear / Reset / cancel-restore) would keep // showing the old selection. Remount the calendar whenever the model is // cleared so it always reflects the column filter. let { value, onValueChange, numberOfMonths = 1, class: className }: DataTableRangeCalendarProps = $props() let epoch = $state(0) let hadValue = false $effect.pre(() => { const hasValue = value !== undefined if (hadValue && !hasValue) epoch++ hadValue = hasValue }) </script> {#key epoch} <RangeCalendar {value} {numberOfMonths} class={className} onValueChange={(range) => onValueChange(range)} /> {/key}src/lib/components/ui/data-table/FlexRender.svelte1.2 kB
<script lang="ts" module> import type { ColumnDefTemplate } from '@tanstack/table-core' export interface FlexRenderProps<TContext extends object> { /** `columnDef.header` / `columnDef.cell` / `columnDef.footer`. */ content: ColumnDefTemplate<TContext> | undefined /** `header.getContext()` / `cell.getContext()`. */ context: TContext } </script> <script lang="ts" generics="TContext extends object"> import { RenderComponentConfig, RenderSnippetConfig } from './render-helpers' // Svelte twin of @tanstack/react-table's `flexRender`: strings render as // text; functions are called with the context and may return a primitive, // `renderComponent(...)` or `renderSnippet(...)`. let { content, context }: FlexRenderProps<TContext> = $props() const result = $derived(typeof content === 'function' ? (content as (ctx: TContext) => unknown)(context) : content) </script> {#if result instanceof RenderComponentConfig} {@const Comp = result.component} <Comp {...result.props} /> {:else if result instanceof RenderSnippetConfig} {@render result.snippet(result.params)} {:else if result !== null && result !== undefined && result !== false} {result} {/if}src/lib/components/ui/data-table/render-helpers.ts1.6 kB
/** * Cell / header renderers for Svelte. React's column defs return JSX from * `header` / `cell`; in Svelte a column def returns one of these configs (or * a plain string / number) and `<FlexRender>` mounts it: * * cell: ({ row }) => renderComponent(StatusBadge, { status: row.original.status }) * cell: ({ row }) => renderSnippet(statusCell, row.original) * header: ({ column }) => renderComponent(DataTableColumnHeader, { column, label: 'Name' }) */ import type { Component, ComponentProps, Snippet } from 'svelte' export class RenderComponentConfig<TComponent extends Component<any>> { component: TComponent props: ComponentProps<TComponent> | Record<string, never> constructor(component: TComponent, props: ComponentProps<TComponent> | Record<string, never> = {}) { this.component = component this.props = props } } export class RenderSnippetConfig<TProps> { snippet: Snippet<[TProps]> params: TProps constructor(snippet: Snippet<[TProps]>, params: TProps) { this.snippet = snippet this.params = params } } /** Mount a Svelte component as a cell / header. */ export function renderComponent<TComponent extends Component<any>>( component: TComponent, props: ComponentProps<TComponent>, ): RenderComponentConfig<TComponent> { return new RenderComponentConfig(component, props) } /** Render a snippet (declared at the top level of your component) as a cell / header. */ export function renderSnippet<TProps>(snippet: Snippet<[TProps]>, params: TProps): RenderSnippetConfig<TProps> { return new RenderSnippetConfig(snippet, params) }src/lib/components/ui/data-table/table.svelte.ts3.2 kB
/** * Svelte 5 adapter for `@tanstack/table-core` (the pattern shadcn-svelte * ships). `@tanstack/svelte-table` v8 is store-based (Svelte 3/4 era), so we * wrap `createTable()` directly: options are merged through lazy getters, and * the table's own internal state (anything not controlled via `options.state`, * e.g. column sizing) lives in a `$state` so reads made while rendering are * tracked by Svelte's fine-grained reactivity. */ import { createTable, type RowData, type TableOptions, type TableOptionsResolved, type TableState, } from '@tanstack/table-core' export function createSvelteTable<TData extends RowData>(options: TableOptions<TData>) { const resolvedOptions: TableOptionsResolved<TData> = mergeObjects( { state: {}, onStateChange() {}, renderFallbackValue: null, mergeOptions: (defaultOptions: TableOptions<TData>, opts: Partial<TableOptions<TData>>) => mergeObjects(defaultOptions, opts), }, options, ) const table = createTable(resolvedOptions) let state = $state<Partial<TableState>>(table.initialState) function updateOptions() { table.setOptions((prev) => mergeObjects(prev, options, { // Thunk (not the value) so every read goes through the `state` signal: // reassigning `state` then invalidates renders that read internal // slices such as columnSizing / columnSizingInfo. state: mergeObjects(() => state, options.state || {}), onStateChange: (updater: unknown) => { if (updater instanceof Function) state = updater(state) else state = mergeObjects(state, updater as Partial<TableState>) options.onStateChange?.(updater as never) }, }), ) } updateOptions() $effect.pre(() => { updateOptions() }) return table } type MaybeThunk<T extends object> = T | (() => T | null | undefined) type Intersection<T extends readonly unknown[]> = (T extends [infer H, ...infer R] ? H & Intersection<R> : unknown) & {} /** * Merge objects into one whose properties are lazy getters resolving * right-to-left (later sources win, `undefined` falls through). Keeping the * getters lazy is what lets reactive `$state`/`$props` reads flow through * TanStack's option object without being snapshotted. */ export function mergeObjects<Sources extends readonly MaybeThunk<object>[]>( ...sources: Sources ): Intersection<{ [K in keyof Sources]: Sources[K] extends () => infer R ? NonNullable<R> : Sources[K] }> { const target = {} for (let i = 0; i < sources.length; i++) { let source = sources[i] if (typeof source === 'function') source = source() if (source) { const descriptors = Object.getOwnPropertyDescriptors(source) for (const key in descriptors) { if (key in target) continue Object.defineProperty(target, key, { enumerable: true, get() { for (let j = sources.length - 1; j >= 0; j--) { let s = sources[j] if (typeof s === 'function') s = s() const v = ((s || {}) as Record<string, unknown>)[key] if (v !== undefined) return v } }, }) } } } return target as never }src/lib/components/ui/data-table/types.ts0.5 kB
import type { Component } from 'svelte' export interface FilterOption { value: string label: string icon?: Component<{ class?: string }> } export interface FilterDefinition { column: string label: string type: 'text' | 'select' | 'multiselect' | 'date' options?: (string | FilterOption)[] } export function resolveOption(opt: string | FilterOption): FilterOption { if (typeof opt === 'string') return { value: opt, label: opt } return opt }src/lib/components/ui/data-table/date-utils.ts1.4 kB
import type { DateRange } from '$lib/components/ui/range-calendar' /** * ISO-string ↔ native Date helpers shared across the DataTable filter * surfaces. The Svelte RangeCalendar speaks native `Date` (as a * `{ start, end }` range), so -- like the React twin -- we keep these tiny * converters local instead of pulling `@internationalized/date`. Column * filter values are still stored as `YYYY-MM-DD` ISO strings on the wire, * identical to Vue/React, so server-side payloads match 1:1. */ export function isoToDate(str: string | undefined): Date | undefined { if (!str) return undefined const [y, m, d] = str.split('-').map(Number) if (y === undefined || m === undefined || d === undefined) return undefined return new Date(y, m - 1, d) } export function dateToIso(date: Date | undefined): string { if (!date) return '' return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}` } /** `{ from, to }` ISO filter value -> RangeCalendar model (`undefined` when empty). */ export function isoRangeToCalendar(v: { from?: string; to?: string } | undefined): DateRange | undefined { if (!v?.from && !v?.to) return undefined const from = isoToDate(v.from) const to = isoToDate(v.to) // The Svelte range model needs a start; an until-only filter anchors on `to`. if (!from) return to ? { start: to, end: to } : undefined return { start: from, end: to } }src/lib/components/ui/data-table/index.ts1 kB
export { default as DataTable, type DataTableProps, type DataTableHandle, type DataTableState, } from './DataTable.svelte' export { default as DataTableColumnHeader, type DataTableColumnHeaderProps } from './DataTableColumnHeader.svelte' export { default as DataTableToolbar, type DataTableToolbarProps } from './DataTableToolbar.svelte' export { default as DataTableFilterSheet, type DataTableFilterSheetProps } from './DataTableFilterSheet.svelte' export { default as DataTableFilterPopover, type DataTableFilterPopoverProps } from './DataTableFilterPopover.svelte' export { default as DataTablePagination, type DataTablePaginationProps } from './DataTablePagination.svelte' export type { FilterDefinition, FilterOption } from './types' // Svelte-only: cell / header rendering (React gets these from JSX + flexRender). export { default as FlexRender, type FlexRenderProps } from './FlexRender.svelte' export { renderComponent, renderSnippet, RenderComponentConfig, RenderSnippetConfig } from './render-helpers' export { createSvelteTable } from './table.svelte'
Raw manifest:https://uipkge.dev/r/svelte/data-table.json