Data Table
React dataFull-feature table with sorting, filtering, column pinning, pagination, row selection, and an opinionated header/toolbar. Built on TanStack Table — pass `columns` + `data` and configure as needed.
Also available for Vue ->Installation
$ pnpm dlx shadcn@latest add https://react.uipkge.dev/r/react/data-table.json$ npx shadcn@latest add https://react.uipkge.dev/r/react/data-table.json$ yarn dlx shadcn@latest add https://react.uipkge.dev/r/react/data-table.json$ bunx shadcn@latest add https://react.uipkge.dev/r/react/data-table.json
Or with the named registry:
npx shadcn@latest add @uipkge-react/data-table
Examples
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
columns | ColumnDef<TData, TValue>[] | — | required |
data | TData[] | — | required |
filterColumn | string | — | optional |
filterPlaceholder | string | — | optional |
filters | FilterDefinition[] | — | optional |
filterMode | 'inline' | 'modal' | 'popover' | — | optional |
enableSearch Show the global search input. Default true. | boolean | — | optional |
enableColumnVisibility Show the View column-visibility dropdown. Default false. | boolean | — | optional |
enablePagination Show the pagination footer. Default true. | boolean | — | optional |
hideToolbar Hide the entire toolbar (search + filters + view). Default false. | boolean | — | optional |
enableExport Show Export-CSV button in toolbar. Default false. | boolean | — | optional |
infinite hides the pagination footer. Combine with append-on-success on the consumer side. | boolean | — | optional |
enableResize Allow drag-to-resize on column borders. | boolean | — | optional |
defaultColumnPinning Initial column pinning. Each column id is pinned to the given side. | { left?: string[]; right?: string[] } | — | optional |
enableReorder Allow drag-to-reorder on column headers. | boolean | — | optional |
defaultGrouping Initial column ids to group by. Pass an empty array to disable grouping. | string[] | — | optional |
virtual datasets and `maxHeight` for a scroll container. | boolean | — | optional |
stickyHeader Sticky header — keeps `<thead>` visible when scrolling. Pair with `maxHeight`. | boolean | — | optional |
density Treated as the INITIAL density when `enableDensityToggle` is on; the user can override it at runtime from the toolbar. | 'compact' | 'cozy' | 'comfortable' | — | optional |
enableDensityToggle Show 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 |
toolbarPosition bordered container; `'above'` floats outside it. | 'inside' | 'above' | — | optional |
paginationPosition Where the pagination footer renders. `'inside'` (default) or `'below'`. | 'inside' | 'below' | — | optional |
maxHeight Max height; enables vertical scroll inside the card. | string | — | optional |
onRowClick Row click — when set, rows become clickable + cursor-pointer. | (row: TData) => void | — | optional |
totalRows Server-side mode: total row count from API (enables manual pagination) | number | — | optional |
loading Server-side mode: loading state indicator | boolean | — | optional |
onStateChange Emitted when server-side state changes (pagination, sorting, filters) | (state: DataTableState) => void | — | optional |
onFetchMore Infinite-scroll: last row entered viewport, time to load more | () => void | — | optional |
emptyState Custom empty-state content (replaces "No results."). | React.ReactNode | — | optional |
renderExpanded Expanded-row content. Receives the original row + the TanStack row. | (row: TData, tanstackRow: Row<TData>) => React.ReactNode | — | optional |
renderBulkActions Bulk action bar content (shown above the table when rows are selected). | (rows: Row<TData>[], clear: () => void) => React.ReactNode | — | optional |
renderFooter Footer (<tfoot>) content. | (rows: Row<TData>[]) => React.ReactNode | — | optional |
toolbarExtra Extra toolbar controls (e.g. group-by selector). | React.ReactNode | — | optional |
customFilters Consumer-supplied custom filter UI inside the filter surface. | React.ReactNode | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
DateRangeValue interface DateRangeValue {
from?: string
to?: string
} DataTableState interface DataTableState {
page: number
pageSize: number
sortBy: string
sortOrder: 'asc' | 'desc'
filters: Record<string, any>
search: string
} FilterOption interface FilterOption {
value: string
label: string
} FilterDefinition interface FilterDefinition {
column: string
label: string
type: 'text' | 'select' | 'multiselect' | 'date'
options?: (string | FilterOption)[]
} DraftDateValue 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?: React.ReactNode
} Dependencies
Files (9)
-
components/ui/data-table/DataTable.tsx 32.1 kB
'use client' import * as React from 'react' import type { ColumnDef, ColumnFiltersState, ColumnPinningState, FilterFn, SortingState, VisibilityState, ExpandedState, GroupingState, Table as TanstackTable, Row, } from '@tanstack/react-table' import { flexRender, getCoreRowModel, getExpandedRowModel, getFilteredRowModel, getGroupedRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, } from '@tanstack/react-table' import type { DateRange } from 'react-day-picker' // `valueUpdater` lives next to the low-level table primitive (which // data-table depends on transitively via registryDependencies). Keep // `@/lib/utils` reserved for the cn() helper shipped by the init // bootstrap -- importing valueUpdater from there would force every // consumer to hand-edit lib/utils.ts on install. import { valueUpdater } from '@/components/ui/table/utils' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { DataTableToolbar } from './DataTableToolbar' import { DataTableFilterSheet } from './DataTableFilterSheet' import { DataTablePagination } from './DataTablePagination' import { type FilterDefinition, type FilterOption, resolveOption } from './types' import { isoToDate, dateToIso } from './date-utils' export type { FilterDefinition, FilterOption } interface DateRangeValue { from?: string to?: string } 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?: React.ReactNode /** Expanded-row content. Receives the original row + the TanStack row. */ renderExpanded?: (row: TData, tanstackRow: Row<TData>) => React.ReactNode /** Bulk action bar content (shown above the table when rows are selected). */ renderBulkActions?: (rows: Row<TData>[], clear: () => void) => React.ReactNode /** Footer (<tfoot>) content. */ renderFooter?: (rows: Row<TData>[]) => React.ReactNode /** Extra toolbar controls (e.g. group-by selector). */ toolbarExtra?: React.ReactNode /** Consumer-supplied custom filter UI inside the filter surface. */ customFilters?: React.ReactNode } export interface DataTableHandle<TData> { table: TanstackTable<TData> exportCsv: () => void exportJson: () => void } function DataTableInner<TData, TValue>( props: DataTableProps<TData, TValue>, forwardedRef: React.Ref<DataTableHandle<TData>>, ) { const { columns, data, filterColumn = '', filterPlaceholder = 'Filter...', filters = [], filterMode = 'modal', enableSearch = true, enableColumnVisibility = false, enablePagination = true, hideToolbar = false, enableExport = false, infinite = false, enableResize = false, defaultColumnPinning = { left: [], right: [] }, enableReorder = false, defaultGrouping = [], virtual = false, stickyHeader = false, density = 'cozy', enableDensityToggle = false, borderless, toolbarPosition = 'inside', paginationPosition = 'inside', maxHeight = '', onRowClick, totalRows = -1, loading = false, onStateChange, onFetchMore, emptyState, renderExpanded, renderBulkActions, renderFooter, toolbarExtra, customFilters, } = props // User-mutable density -- initial value from prop, toggleable via toolbar // when `enableDensityToggle` is on. Sync the prop so parents can still // drive it externally. const [currentDensity, setCurrentDensity] = React.useState<'compact' | 'cozy' | 'comfortable'>(density) React.useEffect(() => { setCurrentDensity(density) }, [density]) // `borderless` is an enum (`'inner'` vs `'full'`); children only need a // boolean "should I drop my borders?". const dropInnerBorders = !!borderless const densityClass = currentDensity === 'compact' ? '[&_td]:py-1.5 [&_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 = totalRows >= 0 const [isFilterSheetOpen, setIsFilterSheetOpen] = React.useState(false) // ── Filter snapshot (restore on close without apply) ─────────────────── const filterSnapshot = React.useRef<ColumnFiltersState | null>(null) const dateRangeSnapshot = React.useRef<Record<string, DateRangeValue> | null>(null) const filterApplied = React.useRef(false) // ── Timers ────────────────────────────────────────────────────────────── const searchDebounceTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null) const paginationEmitTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null) // 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 = React.useMemo(() => { return 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 }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [columns, filters]) const [sorting, setSorting] = React.useState<SortingState>([]) const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([]) const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>({}) const [rowSelection, setRowSelection] = React.useState({}) const [expanded, setExpanded] = React.useState<ExpandedState>({}) const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({ left: defaultColumnPinning.left ?? [], right: defaultColumnPinning.right ?? [], }) const [columnOrder, setColumnOrder] = React.useState<string[]>([]) const [grouping, setGrouping] = React.useState<GroupingState>(defaultGrouping) const [pagination, setPagination] = React.useState({ pageIndex: 0, pageSize: 10 }) // Per-filter reactive range calendar model (ISO strings), keyed by column. const [dateRangeModels, setDateRangeModels] = React.useState<Record<string, DateRangeValue>>({}) const table = useReactTable({ data, columns: processedColumns, getCoreRowModel: getCoreRowModel(), getPaginationRowModel: isServerSide ? undefined : getPaginationRowModel(), getSortedRowModel: isServerSide ? undefined : getSortedRowModel(), getFilteredRowModel: isServerSide ? undefined : getFilteredRowModel(), getExpandedRowModel: getExpandedRowModel(), getGroupedRowModel: getGroupedRowModel(), enableColumnResizing: enableResize, columnResizeMode: 'onChange', manualPagination: isServerSide, manualSorting: isServerSide, manualFiltering: isServerSide, rowCount: isServerSide ? totalRows : undefined, onSortingChange: (updaterOrValue) => { valueUpdater(updaterOrValue, setSorting) if (isServerSide) queueMicrotask(emitStateUpdate) }, 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, setColumnFilters) }, onColumnVisibilityChange: (updaterOrValue) => valueUpdater(updaterOrValue, setColumnVisibility), onRowSelectionChange: (updaterOrValue) => valueUpdater(updaterOrValue, setRowSelection), onExpandedChange: (updaterOrValue) => valueUpdater(updaterOrValue, setExpanded), onPaginationChange: (updaterOrValue) => { valueUpdater(updaterOrValue, setPagination) if (isServerSide) { if (paginationEmitTimer.current) clearTimeout(paginationEmitTimer.current) paginationEmitTimer.current = setTimeout(() => emitStateUpdate(), 0) } }, onColumnPinningChange: (updaterOrValue) => valueUpdater(updaterOrValue, setColumnPinning), onColumnOrderChange: (updaterOrValue) => valueUpdater(updaterOrValue, setColumnOrder), onGroupingChange: (updaterOrValue) => valueUpdater(updaterOrValue, setGrouping), state: { sorting, columnFilters, columnVisibility, pagination, rowSelection, expanded, columnPinning, columnOrder, grouping, }, }) /** Build and emit current server-side state */ function emitStateUpdate() { 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.current) clearTimeout(searchDebounceTimer.current) searchDebounceTimer.current = setTimeout(() => { table.setPageIndex(0) emitStateUpdate() }, 500) } } // ── Filter snapshot helpers ────────────────────────────────────────────── function snapshotFilters() { filterApplied.current = false filterSnapshot.current = JSON.parse(JSON.stringify(columnFilters)) dateRangeSnapshot.current = JSON.parse(JSON.stringify(dateRangeModels)) } function maybeRestoreFilters() { if (!filterApplied.current && filterSnapshot.current) { setColumnFilters(filterSnapshot.current) setDateRangeModels(dateRangeSnapshot.current ?? {}) } filterSnapshot.current = null dateRangeSnapshot.current = null } function onFilterSheetOpen() { snapshotFilters() setIsFilterSheetOpen(true) } function onFilterSheetClose(open: boolean) { if (!open) maybeRestoreFilters() setIsFilterSheetOpen(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 { const dr = getDateRangeValue(column) if (!dr.from && !dr.to) return undefined return { from: isoToDate(dr.from), to: isoToDate(dr.to) } } function onCalendarUpdate(column: string, val: DateRange | undefined) { const from = val?.from ? dateToIso(val.from) : undefined const to = val?.to ? dateToIso(val.to) : undefined setDateRangeModels((m) => ({ ...m, [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.current = true if (searchDebounceTimer.current) clearTimeout(searchDebounceTimer.current) filters.forEach((f) => { table.getColumn(f.column)?.setFilterValue(undefined) if (f.type === 'date') { setDateRangeModels((m) => ({ ...m, [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 = !!(filterColumn && (table.getColumn(filterColumn)?.getFilterValue() as string)) const isAnyFilterActive = 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 = filters.filter(isFilterActive).length function clearDateFilter(filter: FilterDefinition) { clearFilter(filter) setDateRangeModels((m) => ({ ...m, [filter.column]: {} })) } function onApplyFilters() { filterApplied.current = true if (isServerSide) { if (table.getState().pagination.pageIndex === 0) { emitStateUpdate() } else { table.setPageIndex(0) } } setIsFilterSheetOpen(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 setDateRangeModels((m) => ({ ...m, [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) ── const [dragColId, setDragColId] = React.useState<string | null>(null) const [dragOverColId, setDragOverColId] = React.useState<string | null>(null) function onColDragStart(id: string, e: React.DragEvent) { setDragColId(id) document.body.style.cursor = 'grabbing' if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', id) } } function onColDragOver(targetId: string, e: React.DragEvent) { if (!enableReorder || !dragColId) return e.preventDefault() setDragOverColId(targetId) if (e.dataTransfer) e.dataTransfer.dropEffect = 'move' } function onColDragEnd() { document.body.style.cursor = '' setDragColId(null) setDragOverColId(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)) setColumnOrder(order) } // ── Pinning — return style for sticky pinned cells (header or body) ── function pinStyle(col: any): React.CSSProperties | undefined { const side = col.getIsPinned() if (!side) return undefined if (side === 'left') return { position: 'sticky', left: `${col.getStart('left')}px`, zIndex: 2 } return { position: 'sticky', right: `${col.getAfter('right')}px`, zIndex: 2 } } // ── Infinite scroll — sentinel row triggers fetch-more when visible ── const sentinelEl = React.useRef<HTMLDivElement | null>(null) React.useEffect(() => { if (!infinite || !sentinelEl.current) return const io = new IntersectionObserver( (entries) => { if (entries[0]?.isIntersecting) onFetchMore?.() }, { rootMargin: '100px' }, ) io.observe(sentinelEl.current) return () => io.disconnect() // eslint-disable-next-line react-hooks/exhaustive-deps }, [infinite]) // ── CSV export — currently filtered + visible data, skips select/actions/expander cols. ── function exportCsv() { const visibleCols = table .getVisibleLeafColumns() .filter((c) => c.id !== 'select' && c.id !== 'actions' && c.id !== 'expander') const headers = visibleCols.map((c) => String(c.columnDef.header && typeof c.columnDef.header === 'string' ? c.columnDef.header : c.id), ) const rows = table.getFilteredRowModel().rows.map((row) => visibleCols.map((c) => { const v = row.getValue(c.id) const s = v == null ? '' : String(v) return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s }), ) const csv = [headers.join(','), ...rows.map((r) => r.join(','))].join('\n') downloadBlob(csv, 'text/csv;charset=utf-8;', `export-${Date.now()}.csv`) } // ── JSON export — same scope as CSV, but emits underlying row.original objects ── function exportJson() { const visibleColIds = table .getVisibleLeafColumns() .filter((c) => c.id !== 'select' && c.id !== 'actions' && c.id !== 'expander') .map((c) => c.id) const rows = table.getFilteredRowModel().rows.map((row) => { const original = row.original as Record<string, unknown> 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) } React.useImperativeHandle(forwardedRef, () => ({ table, exportCsv, exportJson })) const selectedRows = table.getSelectedRowModel().rows const toolbarProps = { 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, onDensityChange: setCurrentDensity, toolbarExtra, customFilters, } return ( <div data-uipkge="" data-slot="data-table" className="w-full"> {/* Toolbar (above-mode: floats outside the bordered card). */} {!hideToolbar && toolbarPosition === 'above' && <DataTableToolbar {...toolbarProps} borderless />} <div className={[ 'bg-card text-card-foreground overflow-hidden', borderless === 'full' ? '' : 'rounded-md border', ].join(' ')} > {/* Toolbar (inside-mode, default). */} {!hideToolbar && toolbarPosition === 'inside' && ( <DataTableToolbar {...toolbarProps} borderless={dropInnerBorders} /> )} {/* Filter Sheet (modal mode) */} <DataTableFilterSheet open={isFilterSheetOpen} onOpenChange={onFilterSheetClose} table={table as TanstackTable<any>} filters={filters} activeFilterCount={activeFilterCount} isAnyFilterActive={isAnyFilterActive} isServerSide={isServerSide} borderless={dropInnerBorders} getMultiSelectValue={getMultiSelectValue} getDateRangeValue={getDateRangeValue} formatDateRange={formatDateRange} getCalendarModel={getCalendarModel} onApply={onApplyFilters} onClearAll={clearAllFilters} onToggleMultiselect={toggleMultiSelectValue} onClearFilter={clearFilter} onClearDateFilter={clearDateFilter} onCalendarUpdate={onCalendarUpdate} onTextFilterUpdate={(col, val) => table.getColumn(col)?.setFilterValue(val)} customFilters={customFilters} /> {/* Bulk action bar (shown above table when rows selected) */} {renderBulkActions && selectedRows.length > 0 && ( <div className="bg-muted/40 flex items-center gap-3 border-b px-4 py-2"> <span className="text-sm font-medium"> {selectedRows.length} selected </span> {renderBulkActions(selectedRows, () => table.toggleAllRowsSelected(false))} <button type="button" className="text-muted-foreground hover:text-foreground ml-auto text-xs transition" onClick={() => table.toggleAllRowsSelected(false)} > Clear selection </button> </div> )} {/* 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 className={[densityClass, 'relative [&_[data-slot=table-container]]:overflow-visible', maxHeight ? 'overflow-auto' : ''].join(' ')} style={maxHeight ? { maxHeight } : undefined} > {/* Loading overlay */} {loading && ( <div className="bg-card/60 absolute inset-0 z-10 flex items-center justify-center backdrop-blur-[1px]"> <div className="border-primary size-5 animate-spin rounded-full border-2 border-t-transparent" /> </div> )} <Table> <TableHeader className={stickyHeader ? 'bg-muted/95 sticky top-0 z-10 backdrop-blur-sm' : ''}> {table.getHeaderGroups().map((headerGroup) => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map((header) => { const reorderable = enableReorder && header.column.id !== 'select' && header.column.id !== 'actions' && header.column.id !== 'expander' return ( <TableHead key={header.id} className={[ '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={{ ...(enableResize ? { width: `${header.getSize()}px` } : {}), ...(pinStyle(header.column) ?? {}), }} 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} > {!header.isPlaceholder && flexRender(header.column.columnDef.header, header.getContext())} {enableResize && header.column.getCanResize() && ( <div className={[ '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()} /> )} </TableHead> ) })} </TableRow> ))} </TableHeader> <TableBody> {table.getRowModel().rows?.length ? ( table.getRowModel().rows.map((row) => ( <React.Fragment key={row.id}> <TableRow data-state={row.getIsSelected() ? 'selected' : undefined} className={onRowClick ? 'hover:bg-muted/40 cursor-pointer' : ''} style={ virtual ? { contentVisibility: 'auto', containIntrinsicSize: 'auto 48px' } : undefined } onClick={() => onRowClick?.(row.original)} > {row.getVisibleCells().map((cell) => ( <TableCell key={cell.id} className="bg-card" style={pinStyle(cell.column)}> {flexRender(cell.column.columnDef.cell, cell.getContext())} </TableCell> ))} </TableRow> {row.getIsExpanded() && renderExpanded && ( <TableRow> <TableCell colSpan={row.getVisibleCells().length} className="bg-muted/30 px-6 py-4"> {renderExpanded(row.original, row)} </TableCell> </TableRow> )} </React.Fragment> )) ) : ( <TableRow> <TableCell colSpan={columns.length} className="h-24 text-center"> {emptyState ?? <div className="text-muted-foreground text-sm">No results.</div>} </TableCell> </TableRow> )} </TableBody> {renderFooter && ( <tfoot className="bg-muted/20 sticky bottom-0 border-t">{renderFooter(table.getRowModel().rows)}</tfoot> )} </Table> {/* Infinite scroll sentinel */} {infinite && <div ref={sentinelEl} className="h-1" />} {infinite && loading && ( <div className="border-border text-muted-foreground border-t px-4 py-3 text-center text-sm"> Loading more… </div> )} </div> {/* Pagination (inside-mode, default) */} {enablePagination && !infinite && paginationPosition === 'inside' && ( <DataTablePagination table={table as TanstackTable<any>} totalRows={totalRows} isServerSide={isServerSide} borderless={dropInnerBorders} /> )} </div> {/* Pagination (below-mode: floats outside the bordered card). */} {enablePagination && !infinite && paginationPosition === 'below' && ( <DataTablePagination table={table as TanstackTable<any>} totalRows={totalRows} isServerSide={isServerSide} borderless /> )} </div> ) } // forwardRef + generics: cast the wrapped component back to a generic-aware // signature so consumers keep `<DataTable<Row>>` inference. export const DataTable = React.forwardRef(DataTableInner) as <TData, TValue = unknown>( props: DataTableProps<TData, TValue> & { ref?: React.Ref<DataTableHandle<TData>> }, ) => React.ReactElement -
components/ui/data-table/DataTableColumnHeader.tsx 11.5 kB
'use client' /** * Sortable / hideable header cell for TanStack DataTable columns. Use it from * a column definition like: * header: ({ column }) => <DataTableColumnHeader column={column} label="Email" /> * * Click on the label cycles sort asc → desc → none. * * Optional per-column filter: * header: ({ column }) => ( * <DataTableColumnHeader * column={column} * label="Status" * filter={{ column: 'status', label: 'Status', type: 'multiselect', options: [...] }} * /> * ) * * When `filter` is provided, a funnel icon renders next to the sort button. * Click opens a popover with the right filter UI for the type (text / select * / multiselect / date). The funnel shows a primary-coloured dot when the * column has an active filter. */ import * as React from 'react' import type { Column } from '@tanstack/react-table' import { ArrowUp, ArrowUpDown, Check, Filter, FilterX } from 'lucide-react' import type { DateRange } from 'react-day-picker' import { cn } from '@/lib/utils' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { RangeCalendar } from '@/components/ui/range-calendar' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { isoToDate, dateToIso } from './date-utils' 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 className?: string } function resolveOption(opt: string | FilterOption): FilterOption { return typeof opt === 'string' ? { value: opt, label: opt } : opt } export function DataTableColumnHeader<TData, TValue>({ column, label, align = 'left', filter, className, }: DataTableColumnHeaderProps<TData, TValue>) { const [open, setOpen] = React.useState(false) function next() { const current = column.getIsSorted() if (!current) column.toggleSorting(false) else if (current === 'asc') column.toggleSorting(true) else column.clearSorting() } const filterValue = column.getFilterValue() const isFilterActive = (() => { 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. const [textDraft, setTextDraft] = React.useState('') React.useEffect(() => { if (open && filter?.type === 'text') { setTextDraft((filterValue as string) ?? '') } // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) function applyText() { column.setFilterValue(textDraft || undefined) } function clearText() { setTextDraft('') 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) setOpen(false) } function clearFilter() { column.setFilterValue(undefined) setTextDraft('') } // Date-range bridging. The column stores ISO strings; the calendar wants // native Date instances. Reads/writes both shapes. const dateModel: DateRange | undefined = (() => { const v = filterValue as { from?: string; to?: string } | undefined if (!v?.from && !v?.to) return undefined return { from: v.from ? isoToDate(v.from) : undefined, to: v.to ? isoToDate(v.to) : undefined, } })() function onDateSelect(range: DateRange | undefined) { const isoFrom = range?.from ? dateToIso(range.from) : undefined const isoTo = range?.to ? dateToIso(range.to) : 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) } return ( <div className={cn( 'flex items-center gap-0.5', align === 'right' && 'justify-end', align === 'center' && 'justify-center', className, )} > {column.getCanSort() ? ( <button type="button" className="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> {column.getIsSorted() ? ( <ArrowUp className={cn( 'text-foreground size-3.5 transition-transform duration-200 ease-in-out', column.getIsSorted() === 'desc' ? 'rotate-180' : 'rotate-0', )} /> ) : ( <ArrowUpDown className="size-3.5 opacity-40 transition-opacity duration-150 group-hover:opacity-70" /> )} </button> ) : ( <span className="text-muted-foreground text-sm font-medium">{label}</span> )} {/* Optional per-column header filter */} {filter && ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <button type="button" className={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 className="size-3.5" /> {isFilterActive && ( <span aria-hidden className="bg-primary absolute -top-0.5 -right-0.5 size-1.5 rounded-full" /> )} </button> </PopoverTrigger> <PopoverContent className="w-72 p-0" align="start"> <div className="border-border flex items-center justify-between border-b px-3 py-2 text-xs font-medium"> <span>Filter · {filter.label}</span> {isFilterActive && ( <button type="button" className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 transition-colors" onClick={clearFilter} > <FilterX className="size-3" /> Clear </button> )} </div> {/* TEXT */} {filter.type === 'text' && ( <div className="space-y-2 p-3"> <Input value={textDraft} placeholder={`Search ${filter.label.toLowerCase()}…`} className="h-9" onChange={(e) => setTextDraft(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { applyText() setOpen(false) } }} onBlur={applyText} /> <div className="flex gap-2"> <Button size="sm" className="h-8 flex-1" onClick={() => { applyText() setOpen(false) }} > Apply </Button> <Button size="sm" variant="outline" className="h-8" onClick={clearText}> Clear </Button> </div> </div> )} {/* SELECT / MULTISELECT */} {(filter.type === 'select' || filter.type === 'multiselect') && ( <Command className="max-h-[280px]"> <CommandInput placeholder={`Search ${filter.label.toLowerCase()}…`} className="h-9" /> <CommandList> <CommandEmpty>No matches.</CommandEmpty> <CommandGroup> {(filter.options ?? []).map((opt) => { const o = resolveOption(opt) return ( <CommandItem key={o.value} value={o.value} onSelect={() => filter.type === 'multiselect' ? toggleMultiselect(o.value) : selectOne(o.value) } > {filter.type === 'multiselect' ? ( <div className={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 className="size-3" /> </div> ) : ( <Check className={cn('mr-2 size-4', filterValue === o.value ? 'opacity-100' : 'opacity-0')} /> )} <span>{o.label}</span> </CommandItem> ) })} </CommandGroup> </CommandList> </Command> )} {/* DATE RANGE */} {filter.type === 'date' && ( <div className="p-2"> <RangeCalendar selected={dateModel} onSelect={onDateSelect} /> <div className="flex gap-2 px-1 pt-2"> <Button size="sm" className="h-8 flex-1" onClick={() => setOpen(false)}> Apply </Button> <Button size="sm" variant="outline" className="h-8" onClick={clearFilter}> Clear </Button> </div> </div> )} {/* Active selection summary */} {isFilterActive && (filter.type === 'multiselect' || filter.type === 'select') && ( <div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]"> {filter.type === 'multiselect' ? ( <span> {selectedLabels().length} selected:{' '} <span className="text-foreground">{selectedLabels().join(', ')}</span> </span> ) : ( <span> <span className="text-foreground">{filterValue as string}</span> </span> )} </div> )} </PopoverContent> </Popover> )} </div> ) } -
components/ui/data-table/DataTableFilterPopover.tsx 16.8 kB
'use client' /** * Popover variant of DataTableFilterSheet. Same filter UI (text / * multiselect / date) packed into a Popover instead of a side Sheet. * Slot in via `filterMode="popover"` on <DataTable>. * * Trigger button is rendered inline by the toolbar; this component owns * the popover surface and content. * * ── Draft semantics ────────────────────────────────────────────────── * Unlike the Sheet (which mutates live TanStack column filters and rolls * back on cancel via a parent snapshot), this popover stages every edit * inside a local `draft` map. Real `columnFilters` are never touched * until the user clicks Apply -- at which point we emit `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. */ import * as React from 'react' import type { Table } from '@tanstack/react-table' import type { DateRange } from 'react-day-picker' import { Check, SlidersHorizontal, X } from 'lucide-react' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { RangeCalendar } from '@/components/ui/range-calendar' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { type FilterDefinition, resolveOption } from './types' import { isoToDate, dateToIso } from './date-utils' 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?: React.ReactNode } export function DataTableFilterPopover({ table, filters, activeFilterCount, isAnyFilterActive: _isAnyFilterActive, isServerSide, getMultiSelectValue, getDateRangeValue, formatDateRange: _formatDateRange, getCalendarModel: _getCalendarModel, onCommitDraft, onClearAll: _onClearAll, customFilters, }: DataTableFilterPopoverProps) { const [open, setOpen] = React.useState(false) const [draft, setDraft] = React.useState<Draft>({}) const filterScrollRef = React.useRef<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] setDraft((d) => ({ ...d, [column]: next })) } function setDraftText(column: string, value: string) { setDraft((d) => ({ ...d, [column]: value })) } function clearDraftSection(filter: FilterDefinition) { if (filter.type === 'multiselect' || filter.type === 'select') { setDraft((d) => ({ ...d, [filter.column]: [] })) } else if (filter.type === 'date') { setDraft((d) => ({ ...d, [filter.column]: {} })) } else if (filter.type === 'text') { setDraft((d) => ({ ...d, [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] = '' } setDraft(next) } // ── Date helpers (local; popover is fully self-contained for draft) ── function getDraftCalendarModel(column: string): DateRange | undefined { const dr = getDraftDate(column) if (!dr.from && !dr.to) return undefined return { from: isoToDate(dr.from), to: isoToDate(dr.to), } } function onDraftCalendarUpdate(column: string, val: DateRange | undefined) { const from = val?.from ? dateToIso(val.from) : undefined const to = val?.to ? dateToIso(val.to) : undefined setDraft((d) => ({ ...d, [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 = filters.some(isDraftSectionActive) function handleOpenChange(isOpen: boolean) { if (isOpen) { setDraft(seedDraft()) setOpen(true) setTimeout(() => filterScrollRef.current?.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. setDraft({}) setOpen(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) setOpen(false) } const filteredRowCount = table.getFilteredRowModel().rows.length return ( <Popover open={open} onOpenChange={handleOpenChange}> <PopoverTrigger asChild> <Button variant="outline" size="sm" className="h-9 gap-2"> <SlidersHorizontal className="size-3.5" /> Filters {activeFilterCount > 0 && ( <Badge variant="secondary" className="ml-1 h-5 min-w-5 rounded-full px-1.5 text-xs font-semibold"> {activeFilterCount} </Badge> )} </Button> </PopoverTrigger> <PopoverContent align="start" className="flex max-h-[min(560px,80vh)] w-[380px] flex-col overflow-hidden p-0"> {/* Header */} <div className="border-b px-4 pt-3.5 pb-3"> <div className="flex items-center gap-2.5"> <div className="bg-muted flex size-7 items-center justify-center rounded-md"> <SlidersHorizontal className="text-muted-foreground size-3.5" /> </div> <div className="flex-1"> <p className="text-sm leading-none font-semibold">Filters</p> <p className="text-muted-foreground mt-1 text-xs"> {activeFilterCount > 0 ? ( <> {activeFilterCount} active {!isServerSide && ( <> {' '}· {filteredRowCount} result{filteredRowCount !== 1 ? 's' : ''} </> )} </> ) : ( 'Narrow down results' )} </p> </div> </div> </div> {/* Scrollable filter sections */} <div ref={filterScrollRef} className="flex-1 overflow-y-auto"> <div className="space-y-2 p-3"> {filters.map((filter) => { if (filter.type === 'text') { return ( <div key={filter.column} className="bg-muted/40 rounded-lg p-2.5"> <div className="mb-2 flex items-center justify-between"> <Label className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> {filter.label} </Label> {getDraftText(filter.column) && ( <button type="button" className="text-muted-foreground hover:text-foreground text-xs transition-colors" onClick={() => clearDraftSection(filter)} > Clear </button> )} </div> <Input placeholder={`Filter by ${filter.label.toLowerCase()}...`} value={getDraftText(filter.column)} className="h-8 text-sm" onChange={(e) => setDraftText(filter.column, e.target.value ?? '')} /> </div> ) } if (filter.type === 'multiselect' || filter.type === 'select') { const multi = getDraftMulti(filter.column) return ( <div key={filter.column} className={[ 'rounded-lg p-2.5 transition-colors', multi.length > 0 ? 'bg-primary/[0.04] ring-primary/20 ring-1' : 'bg-muted/40', ].join(' ')} > <div className="mb-2 flex items-center justify-between"> <div className="flex items-center gap-2"> <Label className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> {filter.label} </Label> {multi.length > 0 && ( <Badge variant="secondary" className="bg-primary/15 text-primary h-4 rounded-full px-1.5 text-xs font-semibold" > {multi.length} </Badge> )} </div> {multi.length > 0 && ( <button type="button" className="text-muted-foreground hover:text-foreground text-xs transition-colors" onClick={() => clearDraftSection(filter)} > Clear </button> )} </div> <Command className="[&_[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 className="h-7 text-sm" placeholder={`Search ${filter.label.toLowerCase()}...`} /> <CommandList className="mt-1 max-h-[132px]"> <CommandEmpty>No results.</CommandEmpty> <CommandGroup className="p-0"> {filter.options?.map((rawOpt) => { const opt = resolveOption(rawOpt) const OptIcon = opt.icon return ( <CommandItem key={opt.value} value={opt.label} className="rounded-md px-2 py-1.5 text-sm" onSelect={() => toggleDraftMulti(filter.column, opt.value)} > <div className={[ '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 className="size-3" /> </div> {OptIcon && <OptIcon className="text-muted-foreground size-4" />} <span>{opt.label}</span> </CommandItem> ) })} </CommandGroup> </CommandList> </Command> </div> ) } if (filter.type === 'date') { const dr = getDraftDate(filter.column) const hasDate = !!(dr.from || dr.to) return ( <div key={filter.column} className={[ 'rounded-lg p-2.5 transition-colors', hasDate ? 'bg-primary/[0.04] ring-primary/20 ring-1' : 'bg-muted/40', ].join(' ')} > <div className="mb-2 flex items-center justify-between"> <div className="flex items-center gap-2"> <Label className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> {filter.label} </Label> {hasDate && ( <Badge variant="secondary" className="bg-primary/15 text-primary h-auto rounded-full px-1.5 py-0 text-xs font-medium" > {formatDraftDateRange(filter.column)} </Badge> )} </div> {hasDate && ( <button type="button" className="text-muted-foreground hover:text-foreground text-xs transition-colors" onClick={() => clearDraftSection(filter)} > Clear </button> )} </div> <div className="flex justify-center overflow-hidden rounded-md border"> <RangeCalendar selected={getDraftCalendarModel(filter.column)} numberOfMonths={1} className="p-2" onSelect={(range) => onDraftCalendarUpdate(filter.column, range)} /> </div> </div> ) } return null })} {/* Consumer-supplied custom filter UI */} {customFilters} </div> </div> {/* Footer */} <div className="flex gap-2 border-t px-3 py-2.5"> <Button variant="outline" size="sm" className="h-8 flex-1" disabled={!isDraftDirty} onClick={resetDraft}> <X className="size-3.5" /> Reset </Button> <Button size="sm" className="h-8 flex-1" onClick={applyAndClose}> Apply </Button> </div> </PopoverContent> </Popover> ) } -
components/ui/data-table/DataTableFilterSheet.tsx 12.2 kB
'use client' import * as React from 'react' import type { Table } from '@tanstack/react-table' import type { DateRange } from 'react-day-picker' import { Check, SlidersHorizontal, X } from 'lucide-react' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Label } from '@/components/ui/label' import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription } from '@/components/ui/sheet' import { RangeCalendar } from '@/components/ui/range-calendar' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command' import { type FilterDefinition, resolveOption } 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?: React.ReactNode } export function DataTableFilterSheet({ open, onOpenChange, table, filters, activeFilterCount, isAnyFilterActive, isServerSide, borderless = false, getMultiSelectValue, getDateRangeValue, formatDateRange, getCalendarModel, onApply, onClearAll, onToggleMultiselect, onClearFilter, onClearDateFilter, onCalendarUpdate, onTextFilterUpdate, customFilters, }: DataTableFilterSheetProps) { const filterScrollRef = React.useRef<HTMLDivElement | null>(null) React.useEffect(() => { if (open) { setTimeout(() => { filterScrollRef.current?.scrollTo({ top: 0 }) }, 50) } }, [open]) const filteredRowCount = table.getFilteredRowModel().rows.length return ( <Sheet open={open} onOpenChange={onOpenChange}> <SheetContent className={['flex flex-col gap-0 p-0 sm:max-w-[400px]', borderless ? 'border-0' : ''].join(' ')}> {/* Header */} <div className={borderless ? 'px-5 pt-5 pb-2' : 'border-b px-5 pt-5 pb-4'}> <div className="flex items-center gap-3"> <div className="bg-muted flex size-9 items-center justify-center rounded-lg"> <SlidersHorizontal className="text-muted-foreground size-4" /> </div> <div className="flex-1"> <SheetHeader className="space-y-0.5 p-0"> <SheetTitle className="text-sm font-semibold">Filters</SheetTitle> <SheetDescription className="text-xs"> {activeFilterCount > 0 ? ( <> {activeFilterCount} active filter{activeFilterCount > 1 ? 's' : ''} {!isServerSide && ( <> {' '}· {filteredRowCount} result{filteredRowCount !== 1 ? 's' : ''} </> )} </> ) : ( 'Narrow down results' )} </SheetDescription> </SheetHeader> </div> </div> </div> {/* Scrollable filter sections */} <div ref={filterScrollRef} className="flex-1 overflow-y-auto"> <div className="space-y-2 p-4"> {filters.map((filter) => { 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') { return ( <div key={filter.column} className={borderless ? 'py-2' : 'bg-muted/40 rounded-lg p-3'}> <div className="mb-2 flex items-center justify-between"> <Label className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> {filter.label} </Label> {textValue && ( <button type="button" className="text-muted-foreground hover:text-foreground text-xs transition-colors" onClick={() => onTextFilterUpdate(filter.column, undefined)} > Clear </button> )} </div> <Input placeholder={`Filter by ${filter.label.toLowerCase()}...`} value={textValue} className="h-8 text-sm" onChange={(e) => onTextFilterUpdate(filter.column, e.target.value || undefined)} /> </div> ) } if (filter.type === 'multiselect' || filter.type === 'select') { return ( <div key={filter.column} className={ 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 className="mb-2 flex items-center justify-between"> <div className="flex items-center gap-2"> <Label className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> {filter.label} </Label> {multi.length > 0 && ( <Badge variant="secondary" className="bg-primary/15 text-primary h-4 rounded-full px-1.5 text-xs font-semibold" > {multi.length} </Badge> )} </div> {multi.length > 0 && ( <button type="button" className="text-muted-foreground hover:text-foreground text-xs transition-colors" onClick={() => onClearFilter(filter)} > Clear </button> )} </div> <Command className="[&_[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 className="h-7 text-sm" placeholder={`Search ${filter.label.toLowerCase()}...`} /> <CommandList className="mt-1 max-h-[132px]"> <CommandEmpty>No results.</CommandEmpty> <CommandGroup className="p-0"> {filter.options?.map((rawOpt) => { const opt = resolveOption(rawOpt) const OptIcon = opt.icon return ( <CommandItem key={opt.value} value={opt.label} className="rounded-md px-2 py-1.5 text-sm" onSelect={() => onToggleMultiselect(filter.column, opt.value)} > <div className={[ '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 className="size-3" /> </div> {OptIcon && <OptIcon className="text-muted-foreground size-4" />} <span>{opt.label}</span> </CommandItem> ) })} </CommandGroup> </CommandList> </Command> </div> ) } if (filter.type === 'date') { return ( <div key={filter.column} className={ 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 className="mb-2 flex items-center justify-between"> <div className="flex items-center gap-2"> <Label className="text-muted-foreground text-xs font-medium tracking-wide uppercase"> {filter.label} </Label> {hasDate && ( <Badge variant="secondary" className="bg-primary/15 text-primary h-auto rounded-full px-1.5 py-0 text-xs font-medium" > {formatDateRange(filter.column)} </Badge> )} </div> {hasDate && ( <button type="button" className="text-muted-foreground hover:text-foreground text-xs transition-colors" onClick={() => onClearDateFilter(filter)} > Clear </button> )} </div> <div className={['flex justify-center overflow-hidden', borderless ? '' : 'rounded-md border'].join(' ')}> <RangeCalendar selected={getCalendarModel(filter.column)} numberOfMonths={1} className="p-2" onSelect={(range) => onCalendarUpdate(filter.column, range)} /> </div> </div> ) } return null })} {/* Consumer-supplied custom filter UI */} {customFilters} </div> </div> {/* Footer */} <div className={['flex gap-2 px-4 py-3', borderless ? '' : 'border-t'].join(' ')}> <Button variant="outline" size="sm" className="flex-1" disabled={!isAnyFilterActive} onClick={onClearAll}> <X className="size-3.5" /> Reset All </Button> <Button size="sm" className="flex-1" onClick={onApply}> {isServerSide ? ( 'Apply Filters' ) : ( <> Show {filteredRowCount} result{filteredRowCount !== 1 ? 's' : ''} </> )} </Button> </div> </SheetContent> </Sheet> ) } -
components/ui/data-table/DataTablePagination.tsx 3.5 kB
'use client' import type { Table } from '@tanstack/react-table' import { ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight } from 'lucide-react' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' export interface DataTablePaginationProps { table: Table<any> totalRows: number isServerSide: boolean borderless?: boolean } export function DataTablePagination({ table, totalRows, isServerSide, borderless = false }: DataTablePaginationProps) { return ( <div className={['flex items-center justify-between py-3', borderless ? '' : 'border-t px-4'].join(' ')}> <div className="text-muted-foreground flex-1 text-sm"> {table.getFilteredSelectedRowModel().rows.length} of{' '} {isServerSide ? totalRows : table.getFilteredRowModel().rows.length} row(s) selected. </div> <div className="flex items-center gap-6 lg:gap-8"> <div className="flex items-center gap-2"> <p className="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 className="h-8 w-[70px]"> <SelectValue placeholder={`${table.getState().pagination.pageSize}`} /> </SelectTrigger> <SelectContent side="top"> {[10, 20, 30, 40, 50].map((pageSize) => ( <SelectItem key={pageSize} value={`${pageSize}`}> {pageSize} </SelectItem> ))} </SelectContent> </Select> </div> <div className="flex items-center justify-center text-sm font-medium whitespace-nowrap"> Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} </div> <div className="flex items-center gap-1"> <Button variant="outline" size="icon" className="size-8" disabled={!table.getCanPreviousPage()} onClick={() => table.setPageIndex(0)} > <ChevronsLeft className="size-4" aria-hidden="true" /> <span className="sr-only">First page</span> </Button> <Button variant="outline" size="icon" className="size-8" disabled={!table.getCanPreviousPage()} onClick={() => table.previousPage()} > <ChevronLeft className="size-4" aria-hidden="true" /> <span className="sr-only">Previous page</span> </Button> <Button variant="outline" size="icon" className="size-8" disabled={!table.getCanNextPage()} onClick={() => table.nextPage()} > <ChevronRight className="size-4" aria-hidden="true" /> <span className="sr-only">Next page</span> </Button> <Button variant="outline" size="icon" className="size-8" disabled={!table.getCanNextPage()} onClick={() => table.setPageIndex(table.getPageCount() - 1)} > <ChevronsRight className="size-4" aria-hidden="true" /> <span className="sr-only">Last page</span> </Button> </div> </div> </div> ) } -
components/ui/data-table/DataTableToolbar.tsx 16 kB
'use client' import * as React from 'react' import type { Table } from '@tanstack/react-table' import type { DateRange } from 'react-day-picker' import { CalendarIcon, Check, ChevronDown, Download, ListFilter, Plus, Rows3, SlidersHorizontal, X, } from 'lucide-react' import { Input } from '@/components/ui/input' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Separator } from '@/components/ui/separator' import { DropdownMenu, DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator, } from '@/components/ui/command' import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' import { RangeCalendar } from '@/components/ui/range-calendar' import { DataTableFilterPopover } from './DataTableFilterPopover' import { type FilterDefinition, resolveOption } 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 onDensityChange: (value: 'compact' | 'cozy' | 'comfortable') => void toolbarExtra?: React.ReactNode customFilters?: React.ReactNode } export function DataTableToolbar({ 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, onApplyFilters: _onApplyFilters, onClearAllFilters, onToggleMultiselect, onClearFilter, onClearDateFilter, onCalendarUpdate, onTextFilterUpdate, onCommitFilters, onExportCsv, onExportJson, onDensityChange, toolbarExtra, customFilters, }: DataTableToolbarProps) { return ( <div className={['flex flex-col gap-2 py-3', borderless ? '' : 'border-b px-4'].join(' ')}> <div className="flex items-center gap-2"> {/* Search only renders when filterColumn maps to an actual column. */} {enableSearch && filterColumn && table.getColumn(filterColumn) && ( <Input className="h-9 max-w-xs" placeholder={filterPlaceholder} value={(table.getColumn(filterColumn)?.getFilterValue() as string) ?? ''} onChange={(e) => onSearch(e.target.value)} /> )} {/* ── INLINE filter mode ── */} {filterMode === 'inline' && filters.map((filter) => { if (filter.type === 'multiselect' || filter.type === 'select') { const multi = getMultiSelectValue(filter.column) return ( <Popover key={filter.column}> <PopoverTrigger asChild> <Button variant="outline" size="sm" className="h-9 border-dashed"> <Plus className="size-4" aria-hidden="true" /> {filter.label} {multi.length > 0 && ( <> <Separator orientation="vertical" className="mx-1 h-4" /> <div className="flex gap-1"> {multi.length > 2 ? ( <Badge variant="secondary" className="rounded-sm px-1 font-normal"> {multi.length} selected </Badge> ) : ( getFilterSelectedLabels(filter).map((label) => ( <Badge key={label} variant="secondary" className="rounded-sm px-1 font-normal"> {label} </Badge> )) )} </div> </> )} </Button> </PopoverTrigger> <PopoverContent className="w-52 p-0" align="start"> <Command> <CommandInput placeholder={`Search ${filter.label.toLowerCase()}...`} /> <CommandList> <CommandEmpty>No results.</CommandEmpty> <CommandGroup> {filter.options?.map((rawOpt) => { const opt = resolveOption(rawOpt) const OptIcon = opt.icon return ( <CommandItem key={opt.value} value={opt.label} onSelect={() => onToggleMultiselect(filter.column, opt.value)} > <div className={[ '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 className="size-3" /> </div> {OptIcon && <OptIcon className="text-muted-foreground size-4" />} <span>{opt.label}</span> </CommandItem> ) })} </CommandGroup> {multi.length > 0 && ( <> <CommandSeparator /> <CommandGroup> <CommandItem value="__clear__" className="justify-center text-center" onSelect={() => onClearFilter(filter)} > Clear filter </CommandItem> </CommandGroup> </> )} </CommandList> </Command> </PopoverContent> </Popover> ) } if (filter.type === 'date') { const dr = getDateRangeValue(filter.column) const hasDate = !!(dr.from || dr.to) return ( <Popover key={filter.column}> <PopoverTrigger asChild> <Button variant="outline" size="sm" className="h-9 border-dashed"> <CalendarIcon className="size-4" aria-hidden="true" /> {filter.label} {hasDate && ( <> <Separator orientation="vertical" className="mx-1 h-4" /> <Badge variant="secondary" className="rounded-sm px-1 font-normal"> {formatDateRange(filter.column)} </Badge> </> )} </Button> </PopoverTrigger> <PopoverContent className="w-auto p-0" align="start"> <RangeCalendar selected={getCalendarModel(filter.column)} numberOfMonths={2} onSelect={(range) => onCalendarUpdate(filter.column, range)} /> {hasDate && ( <div className="border-t p-2"> <Button variant="ghost" size="sm" className="h-7 w-full text-xs" onClick={() => onClearDateFilter(filter)} > Clear dates </Button> </div> )} </PopoverContent> </Popover> ) } if (filter.type === 'text') { const textValue = table.getColumn(filter.column)?.getFilterValue() as string | undefined return ( <Popover key={filter.column}> <PopoverTrigger asChild> <Button variant="outline" size="sm" className="h-9 border-dashed"> <Plus className="size-4" aria-hidden="true" /> {filter.label} {textValue && ( <> <Separator orientation="vertical" className="mx-1 h-4" /> <Badge variant="secondary" className="rounded-sm px-1 font-normal"> {textValue} </Badge> </> )} </Button> </PopoverTrigger> <PopoverContent className="w-60 p-3" align="start"> <div className="space-y-2"> <p className="text-sm font-medium">{filter.label}</p> <Input placeholder={`Filter by ${filter.label.toLowerCase()}...`} value={(table.getColumn(filter.column)?.getFilterValue() as string) ?? ''} className="h-8 text-sm" onChange={(e) => onTextFilterUpdate(filter.column, e.target.value || undefined)} /> </div> </PopoverContent> </Popover> ) } return null })} {/* ── MODAL filter mode (Sheet from the right) ── */} {filterMode === 'modal' && ( <Button variant="outline" size="sm" className={[ 'h-9', activeFilterCount > 0 ? 'border-primary/40 bg-primary/5 text-primary hover:bg-primary/10' : '', ].join(' ')} onClick={onOpenFilterSheet} > <SlidersHorizontal className="size-4" aria-hidden="true" /> Filters {activeFilterCount > 0 && ( <Badge className="bg-primary text-primary-foreground ml-0.5 size-5 rounded-full p-0 text-xs font-semibold"> {activeFilterCount} </Badge> )} </Button> )} {/* ── POPOVER filter mode ── */} {filterMode === 'popover' && filters.length > 0 && ( <DataTableFilterPopover table={table} filters={filters} activeFilterCount={activeFilterCount} isAnyFilterActive={isAnyFilterActive} isServerSide={isServerSide} getMultiSelectValue={getMultiSelectValue} getDateRangeValue={getDateRangeValue} formatDateRange={formatDateRange} getCalendarModel={getCalendarModel} onCommitDraft={(d) => onCommitFilters(d)} onClearAll={onClearAllFilters} customFilters={customFilters} /> )} {/* Inline custom filters (when filterMode === 'inline') */} {filterMode === 'inline' && customFilters} {/* Toolbar extras (e.g. group-by selector, density toggle) */} {toolbarExtra} {/* Reset button */} {isAnyFilterActive && ( <Button variant="ghost" size="sm" className="h-9" onClick={onClearAllFilters}> Reset <X className="size-4" aria-hidden="true" /> </Button> )} {/* Export (CSV / JSON) */} {enableExport && ( <div className="ml-auto"> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm" className="h-9"> <Download className="size-3.5" /> Export <ChevronDown className="size-3.5 opacity-60" /> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem onSelect={onExportCsv}>Export as CSV</DropdownMenuItem> <DropdownMenuItem onSelect={onExportJson}>Export as JSON</DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> </div> )} {/* Density toggle */} {enableDensityToggle && ( <div className={enableExport ? '' : 'ml-auto'}> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm" className="h-9" aria-label={`Row density: ${density}`}> <Rows3 className="size-4" aria-hidden="true" /> <span className="capitalize">{density}</span> </Button> </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> </div> )} {/* Column Visibility */} {enableColumnVisibility && ( <div className={enableExport || enableDensityToggle ? '' : 'ml-auto'}> <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="outline" size="sm" className="h-9"> <ListFilter className="size-4" aria-hidden="true" /> View </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> {table .getAllColumns() .filter((column) => column.getCanHide()) .map((column) => ( <DropdownMenuCheckboxItem key={column.id} className="capitalize" checked={column.getIsVisible()} onCheckedChange={(value) => column.toggleVisibility(!!value)} > {column.id} </DropdownMenuCheckboxItem> ))} </DropdownMenuContent> </DropdownMenu> </div> )} </div> </div> ) } -
components/ui/data-table/types.ts 0.5 kB
import type * as React from 'react' export interface FilterOption { value: string label: string icon?: React.ComponentType<{ className?: 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 } -
components/ui/data-table/date-utils.ts 1 kB
/** * ISO-string ↔ native Date helpers shared across the DataTable filter * surfaces. The Vue registry leaned on `@internationalized/date`'s * CalendarDate because its RangeCalendar (reka-ui) speaks DateValue; the * React RangeCalendar wraps react-day-picker, which speaks native `Date`. * So instead of pulling `@internationalized/date` into the React tree we * keep these two tiny converters local. Column filter values are still * stored as `YYYY-MM-DD` ISO strings on the wire, identical to Vue, 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')}` } -
components/ui/data-table/index.ts 0.6 kB
export { DataTable, type DataTableProps, type DataTableHandle, type DataTableState } from './DataTable' export { DataTableColumnHeader, type DataTableColumnHeaderProps } from './DataTableColumnHeader' export { DataTableToolbar, type DataTableToolbarProps } from './DataTableToolbar' export { DataTableFilterSheet, type DataTableFilterSheetProps } from './DataTableFilterSheet' export { DataTableFilterPopover, type DataTableFilterPopoverProps } from './DataTableFilterPopover' export { DataTablePagination, type DataTablePaginationProps } from './DataTablePagination' export type { FilterDefinition, FilterOption } from './types'
Raw manifest: https://react.uipkge.dev/r/react/data-table.json