
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
data-tableuiFull-feature table with sorting, filtering, column pinning, pagination, row selection, and an opinionated header/toolbar. Built on TanStack Table — pass `columns` + `data` and configure as needed.
Also available for Vue ->$pnpm dlx shadcn@latest add https://uipkge.dev/r/react/data-table.json$npx shadcn@latest add https://uipkge.dev/r/react/data-table.json$yarn dlx shadcn@latest add https://uipkge.dev/r/react/data-table.json$bunx shadcn@latest add https://uipkge.dev/r/react/data-table.jsonnpx shadcn@latest add @uipkge-react/data-tableInstalls to:components/ui/data-table/| Name | Type / Values | Default | Required |
|---|---|---|---|
columns | ColumnDef<TData, TValue>[] | — | required |
data | TData[] | — | required |
filterColumn | string | — | optional |
filterPlaceholder | string | — | optional |
filters | FilterDefinition[] | — | optional |
filterMode | 'inline' | 'modal' | 'popover' | — | optional |
enableSearchShow the global search input. Default true. | boolean | — | optional |
enableColumnVisibilityShow the View column-visibility dropdown. Default 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."). | React.ReactNode | — | optional |
renderExpandedExpanded-row content. Receives the original row + the TanStack row. | (row: TData, tanstackRow: Row<TData>) => React.ReactNode | — | optional |
renderBulkActionsBulk action bar content (shown above the table when rows are selected). | (rows: Row<TData>[], clear: () => void) => React.ReactNode | — | optional |
renderFooterFooter (<tfoot>) content. | (rows: Row<TData>[]) => React.ReactNode | — | optional |
toolbarExtraExtra toolbar controls (e.g. group-by selector). | React.ReactNode | — | optional |
customFiltersConsumer-supplied custom filter UI inside the filter surface. | React.ReactNode | — | 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 |
Type aliases from this item's source — use them to shape the data you pass in.
DateRangeValueinterface DateRangeValue {
from?: string
to?: string
}DataTableStateinterface DataTableState {
page: number
pageSize: number
sortBy: string
sortOrder: 'asc' | 'desc'
filters: Record<string, any>
search: string
}FilterOptioninterface FilterOption {
value: string
label: string
}FilterDefinitioninterface FilterDefinition {
column: string
label: string
type: 'text' | 'select' | 'multiselect' | 'date'
options?: (string | FilterOption)[]
}DraftDateValuetype 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
}'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 { Search, Copy, Download, X, ChevronDown, ChevronRight } from 'lucide-react'
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
/** 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'
}
export interface DataTableHandle<TData> {
table: TanstackTable<TData>
exportCsv: () => void
exportJson: () => void
copyTsv: (rows?: Row<TData>[]) => string
copyMarkdown: (rows?: Row<TData>[]) => string
}
function DataTableInner<TData, TValue>(
props: DataTableProps<TData, TValue>,
forwardedRef: React.Ref<DataTableHandle<TData>>,
) {
const {
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',
} = props
// User-mutable density -- initial value from prop, toggleable via toolbar
// when `enableDensityToggle` is on.
const [densityOverride, setDensityOverride] = React.useState<'compact' | 'cozy' | 'comfortable' | null>(null)
const currentDensity = densityOverride ?? 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-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 = 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)
const isMounted = React.useRef(false)
React.useEffect(() => {
isMounted.current = true
return () => {
isMounted.current = false
if (searchDebounceTimer.current) clearTimeout(searchDebounceTimer.current)
if (paginationEmitTimer.current) clearTimeout(paginationEmitTimer.current)
}
}, [])
// 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(),
// Skip client pagination entirely when disabled (not just hide the footer UI).
getPaginationRowModel: isServerSide || !enablePagination ? 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) => {
if (isMounted.current) {
valueUpdater(updaterOrValue, setSorting)
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
if (isMounted.current) valueUpdater(updaterOrValue, setColumnFilters)
},
onColumnVisibilityChange: (updaterOrValue) => {
if (isMounted.current) valueUpdater(updaterOrValue, setColumnVisibility)
},
onRowSelectionChange: (updaterOrValue) => {
if (isMounted.current) valueUpdater(updaterOrValue, setRowSelection)
},
onExpandedChange: (updaterOrValue) => {
if (isMounted.current) valueUpdater(updaterOrValue, setExpanded)
},
onPaginationChange: (updaterOrValue) => {
if (isMounted.current) {
valueUpdater(updaterOrValue, setPagination)
if (isServerSide) {
if (paginationEmitTimer.current) clearTimeout(paginationEmitTimer.current)
paginationEmitTimer.current = setTimeout(() => emitStateUpdate(), 0)
}
}
},
onColumnPinningChange: (updaterOrValue) => {
if (isMounted.current) valueUpdater(updaterOrValue, setColumnPinning)
},
onColumnOrderChange: (updaterOrValue) => {
if (isMounted.current) valueUpdater(updaterOrValue, setColumnOrder)
},
onGroupingChange: (updaterOrValue) => {
if (isMounted.current) valueUpdater(updaterOrValue, setGrouping)
},
state: {
sorting,
columnFilters,
columnVisibility,
pagination,
rowSelection,
expanded,
columnPinning,
columnOrder,
grouping,
},
})
/** Build and emit current server-side state */
function emitStateUpdate() {
if (!isMounted.current) 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.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 ──
// Callback ref so the observer attaches when the sentinel mounts (not only
// when `infinite` flips), avoiding a missed attach on first paint.
const [sentinelNode, setSentinelNode] = React.useState<HTMLDivElement | null>(null)
React.useEffect(() => {
if (!infinite || !sentinelNode) return
const io = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) onFetchMore?.()
},
{ rootMargin: '100px' },
)
io.observe(sentinelNode)
return () => io.disconnect()
}, [infinite, sentinelNode, onFetchMore])
// ── 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)
}
// ── TSV clipboard copy — formatted for instant paste into Excel / Sheets / Notion ──
function copyTsv(rowsToCopy?: Row<TData>[]) {
const visibleCols = table
.getVisibleLeafColumns()
.filter((c) => c.id !== 'select' && c.id !== 'actions' && c.id !== 'expander')
const headers = visibleCols.map((c) =>
String(c.columnDef.header && typeof c.columnDef.header === 'string' ? c.columnDef.header : c.id),
)
const targetRows =
rowsToCopy && rowsToCopy.length > 0
? rowsToCopy
: table.getSelectedRowModel().rows.length > 0
? table.getSelectedRowModel().rows
: table.getFilteredRowModel().rows
const rowData = targetRows.map((row) =>
visibleCols.map((c) => {
const v = row.getValue(c.id)
return v == null ? '' : String(v).replace(/[\t\n\r]/g, ' ')
}),
)
const tsv = [headers.join('\t'), ...rowData.map((r) => r.join('\t'))].join('\n')
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
navigator.clipboard.writeText(tsv).catch(() => {})
}
return tsv
}
// ── Markdown table copy — formatted for PR descriptions, issues, and docs ──
function copyMarkdown(rowsToCopy?: Row<TData>[]) {
const visibleCols = table
.getVisibleLeafColumns()
.filter((c) => c.id !== 'select' && c.id !== 'actions' && c.id !== 'expander')
const headers = visibleCols.map((c) =>
String(c.columnDef.header && typeof c.columnDef.header === 'string' ? c.columnDef.header : c.id),
)
const targetRows =
rowsToCopy && rowsToCopy.length > 0
? rowsToCopy
: table.getSelectedRowModel().rows.length > 0
? table.getSelectedRowModel().rows
: table.getFilteredRowModel().rows
const rowData = targetRows.map((row) =>
visibleCols.map((c) => {
const v = row.getValue(c.id)
return v == null ? '' : String(v).replace(/[|\n\r]/g, ' ')
}),
)
const sep = visibleCols.map(() => '---')
const md = [
`| ${headers.join(' | ')} |`,
`| ${sep.join(' | ')} |`,
...rowData.map((r) => `| ${r.join(' | ')} |`),
].join('\n')
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
navigator.clipboard.writeText(md).catch(() => {})
}
return md
}
// ── Keyboard ergonomics & focused row tracking (Linear / Raycast craft) ───────
const [focusedRowIndex, setFocusedRowIndex] = React.useState<number>(-1)
const [lastSelectedRowIndex, setLastSelectedRowIndex] = React.useState<number>(-1)
function onTableKeyDown(e: React.KeyboardEvent<HTMLDivElement>) {
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)
}
}
setFocusedRowIndex(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)
}
}
setFocusedRowIndex(prevIdx)
} else if (isSpace) {
if (focusedRowIndex >= 0 && focusedRowIndex < rows.length) {
e.preventDefault()
rows[focusedRowIndex].toggleSelected()
setLastSelectedRowIndex(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)
}
setFocusedRowIndex(-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: React.MouseEvent) {
setFocusedRowIndex(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 {
setLastSelectedRowIndex(idx)
}
onRowClick?.(row.original)
}
React.useImperativeHandle(forwardedRef, () => ({
table,
exportCsv,
exportJson,
copyTsv,
copyMarkdown,
}))
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,
onCopyTsv: () => copyTsv(),
onCopyMarkdown: () => copyMarkdown(),
onDensityChange: setDensityOverride,
toolbarExtra,
customFilters,
}
return (
<div
data-uipkge=""
data-slot="data-table"
className="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). */}
{!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 (inline mode) */}
{bulkActionPosition === 'inline' && renderBulkActions && selectedRows.length > 0 && (
<div className="bg-primary/5 border-primary/15 flex items-center gap-3 border-b px-4 py-2">
<span className="bg-primary/10 text-primary rounded-md px-2 py-0.5 text-xs font-semibold tabular-nums">
{selectedRows.length} selected
</span>
<div className="flex min-w-0 flex-1 items-center gap-2">
{renderBulkActions(selectedRows, () => table.toggleAllRowsSelected(false))}
</div>
<button
type="button"
className="text-muted-foreground hover:text-foreground shrink-0 text-xs font-medium transition"
onClick={() => table.toggleAllRowsSelected(false)}
>
Clear
</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}
>
<Table>
<TableHeader
className={
stickyHeader
? 'bg-card/95 sticky top-0 z-10 shadow-[0_1px_0_0_var(--border)] backdrop-blur-sm'
: undefined
}
>
{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
className={loading && table.getRowModel().rows?.length ? 'pointer-events-none opacity-60' : undefined}
aria-busy={loading || undefined}
>
{loading && !table.getRowModel().rows?.length ? (
Array.from({ length: 5 }).map((_, i) => (
<TableRow key={`sk-${i}`} className="hover:bg-transparent">
{Array.from({ length: Math.max(columns.length, 1) }).map((__, j) => (
<TableCell key={`sk-${i}-${j}`}>
<div
className="bg-muted h-4 animate-pulse rounded"
style={{ width: `${48 + ((i * 17 + j * 23) % 40)}%`, maxWidth: '12rem' }}
/>
</TableCell>
))}
</TableRow>
))
) : table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row, idx) => (
<React.Fragment key={row.id}>
{row.getIsGrouped() ? (
<TableRow
className="bg-muted/50 hover:bg-muted/70 cursor-pointer"
onClick={() => row.toggleExpanded()}
>
<TableCell colSpan={row.getVisibleCells().length}>
<button type="button" className="flex items-center gap-2 text-sm font-medium">
{row.getIsExpanded() ? (
<ChevronDown className="size-4" />
) : (
<ChevronRight className="size-4" />
)}
<span>{String(row.groupingValue ?? '')}</span>
<span className="text-muted-foreground font-normal">{row.subRows.length}</span>
</button>
</TableCell>
</TableRow>
) : (
<>
<TableRow
data-state={row.getIsSelected() ? 'selected' : undefined}
data-focused={focusedRowIndex === idx ? 'true' : undefined}
className={
[
onRowClick ? 'cursor-pointer' : '',
focusedRowIndex === idx ? 'ring-primary/60 bg-muted/40 ring-1 ring-inset' : '',
]
.filter(Boolean)
.join(' ') || undefined
}
style={virtual ? { contentVisibility: 'auto', containIntrinsicSize: 'auto 48px' } : undefined}
onClick={(e) => onRowClicked(row, idx, e)}
>
{row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
className={row.getIsSelected() ? 'bg-muted' : '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 className="hover:bg-transparent">
<TableCell colSpan={columns.length} className="h-36 text-center">
{emptyState ?? (
<div className="text-muted-foreground flex flex-col items-center justify-center gap-2 py-8">
<div className="bg-muted/80 text-muted-foreground grid size-10 place-items-center rounded-full">
<Search className="size-4" aria-hidden="true" />
</div>
<div className="space-y-1">
<p className="text-foreground text-sm font-medium">No results</p>
<p className="text-muted-foreground max-w-[28ch] text-xs leading-relaxed">
Try adjusting search or filters to find what you need.
</p>
</div>
</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={setSentinelNode} 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
/>
)}
{/* Floating Bulk Actions HUD Dock — Linear/Raycast craft dock */}
{bulkActionPosition === 'floating' && selectedRows.length > 0 && (
<div
data-slot="data-table-bulk-dock"
className="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 className="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 className="bg-border h-3.5 w-px" />
<button
type="button"
className="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 className="size-3" />
Copy TSV
</button>
<button
type="button"
className="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 className="size-3" />
Export
</button>
{renderBulkActions?.(selectedRows, () => table.toggleAllRowsSelected(false))}
<div className="bg-border h-3.5 w-px" />
<button
type="button"
className="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 className="size-3.5" />
</button>
</div>
)}
</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
'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-8"
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-8" />
<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-xs">
{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>
)
}
'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-8 gap-2',
activeFilterCount > 0 ? 'border-primary/40 bg-primary/5 text-primary hover:bg-primary/10' : '',
].join(' ')}
>
<SlidersHorizontal className="size-4" />
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>
</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>
)
}
'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>
)
}
'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) {
const selected = table.getFilteredSelectedRowModel().rows.length
const total = isServerSide ? totalRows : table.getFilteredRowModel().rows.length
return (
<div
className={[
'flex flex-col gap-3 py-3 sm:flex-row sm:items-center sm:justify-between',
borderless ? '' : 'border-t px-4',
].join(' ')}
>
<div className="text-muted-foreground text-sm tabular-nums">
<span className="text-foreground font-medium">{selected}</span> of {total} row(s) selected
</div>
<div className="flex flex-wrap items-center gap-4 sm:gap-6">
<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-[4.5rem]">
<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="text-sm font-medium whitespace-nowrap tabular-nums">
Page {table.getState().pagination.pageIndex + 1} of {Math.max(table.getPageCount(), 1)}
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="hidden size-8 lg:flex"
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="hidden size-8 lg:flex"
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>
)
}
'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,
Columns3,
Download,
Plus,
Rows3,
Search,
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
onCopyTsv?: () => void
onCopyMarkdown?: () => 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,
onCopyTsv,
onCopyMarkdown,
onDensityChange,
toolbarExtra,
customFilters,
}: DataTableToolbarProps) {
return (
<div className={['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 className="flex flex-wrap items-center gap-2">
{/* Search only renders when filterColumn maps to an actual column. */}
{enableSearch && filterColumn && table.getColumn(filterColumn) && (
<div className="relative w-full max-w-xs min-w-[12rem] flex-1 sm:flex-none">
<Search
className="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2"
aria-hidden="true"
/>
<Input
className="h-8 pl-8"
placeholder={filterPlaceholder}
aria-label={filterPlaceholder || 'Search table'}
value={(table.getColumn(filterColumn)?.getFilterValue() as string) ?? ''}
onChange={(e) => onSearch(e.target.value)}
/>
</div>
)}
{/* ── 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-8 border-dashed',
multi.length > 0 ? 'border-primary/40 bg-primary/5 border-solid' : '',
].join(' ')}
>
<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-8 border-dashed',
hasDate ? 'border-primary/40 bg-primary/5 border-solid' : '',
].join(' ')}
>
<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-8 border-dashed',
textValue ? 'border-primary/40 bg-primary/5 border-solid' : '',
].join(' ')}
>
<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-8',
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-8 px-2" onClick={onClearAllFilters}>
Reset
<X className="size-4" aria-hidden="true" />
</Button>
)}
{/* Right cluster: export / density / columns */}
{(enableExport || enableDensityToggle || enableColumnVisibility) && (
<div className="ml-auto flex items-center gap-2">
{enableExport && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<Download className="size-4" />
Export
<ChevronDown className="size-4 opacity-60" />
</Button>
</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>
)}
{enableDensityToggle && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8" 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>
)}
{enableColumnVisibility && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="h-8">
<Columns3 className="size-4" aria-hidden="true" />
View
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{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>
)
}
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
}
/**
* 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')}`
}
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://uipkge.dev/r/react/data-table.json