Installation
How to install and set up the DataTable component in your project.
Prerequisites
Section titled “Prerequisites”Before installing the DataTable component, make sure you have:
- A React project (Next.js, Vite, etc.)
- Shadcn UI set up in your project (Installation Guide)
- TailwindCSS configured
- TypeScript (recommended)
Architecture Note
Section titled “Architecture Note”The DataTable components use a two-layer architecture for maximum flexibility:
- Components (
data-table-*.tsxin/componentsfolder) - Context-aware wrapper components that useuseDataTable()hook to automatically get the table fromDataTableRootcontext. These eliminate prop drilling and are the recommended way to use components:DataTableSearchFilter,DataTableFilterMenu,DataTableSortMenu,DataTablePagination, etc.
- Filters (
table-*.tsxin/filtersfolder) - Core implementation components that accept atableprop directly and use TanStack Table hooks (liketable.getState(),table.setGlobalFilter(), etc.). These can be used standalone:TableSearchFilter,TableFilterMenu,TableSortMenu, etc.
Why this architecture?
- Use
DataTable*components from their direct paths (e.g.@/components/niko-table/components/data-table-pagination) when you want context-based, zero-config usage - Use
Table*components from filters (e.g.@/components/niko-table/filters/table-pagination) when you want to build custom components or manage the table instance yourself - All filter components use TanStack Table hooks directly, giving you full control
Learn more in the Introduction.
Custom Table Component
Section titled “Custom Table Component”The DataTable registry automatically installs a custom components/ui/table.tsx that extends the default Shadcn table with:
- A
TableComponentexport (used internally by DataTable) data-slotattributes on all elements- A container div for overflow handling
Note: This is 100% backward compatible — your existing Shadcn tables will continue to work exactly as before. If you already have a
table.tsx, the CLI will prompt you before overwriting.
Monorepo / Non-Standard Layouts
Section titled “Monorepo / Non-Standard Layouts”If your project uses a non-standard UI component path (e.g., packages/ui/src/components/ instead of src/components/ui/), the shadcn CLI may place table.tsx in the wrong location. This is a known upstream issue with registry:ui path resolution.
Workaround: After installing, check that table.tsx landed in the correct directory. If it was placed in a nested ui/ folder, move it to your actual UI components path and ensure it exports TableComponent:
// Your existing table.tsx — add this function and exportfunction TableComponent({ className, ...props}: React.ComponentProps<"table">) { return ( <table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} /> )}
// Add TableComponent to your existing exportsexport { TableComponent, Table, TableHeader, // ... your other exports}Configure the Niko Table Registry
Section titled “Configure the Niko Table Registry”To use the @niko-table/ namespace with the shadcn CLI, you must first register it in your project’s components.json. Add the registries field:
{ "$schema": "https://ui.shadcn.com/schema.json", // ... your existing config (style, tailwind, aliases, etc.) "registries": { "@niko-table": "https://niko-table.com/r/{name}.json" }}Tip: If you prefer not to modify
components.json, you can install any component directly via URL instead – see the URL-based commands in each section below.
Installation
Section titled “Installation”Install in two steps (or all at once):
- Install the core once — one command copies the base table (Root, DataTable, context, structure, column header, empty state, hooks, lib, types) into your project.
- Add features one by one — run the CLI for each feature you need (pagination, search filter, DnD, etc.). Each add-on depends on the core, so the CLI will install or reuse it.
Now install the core:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function TableComponent({ className, ...props}: React.ComponentProps<"table">) { return ( <table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} /> )}
function Table({ className, ...props }: React.ComponentProps<"table">) { return ( <div data-slot="table-container" className="relative w-full overflow-x-auto" > <TableComponent className={className} {...props} /> </div> )}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) { return ( <thead data-slot="table-header" className={cn("[&_tr]:border-b", className)} {...props} /> )}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) { return ( <tbody data-slot="table-body" className={cn("[&_tr:last-child]:border-0", className)} {...props} /> )}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) { return ( <tfoot data-slot="table-footer" className={cn( "border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className, )} {...props} /> )}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) { return ( <tr data-slot="table-row" className={cn( "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", className, )} {...props} /> )}
function TableHead({ className, ...props }: React.ComponentProps<"th">) { return ( <th data-slot="table-head" className={cn( "h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-[2px]", className, )} {...props} /> )}
function TableCell({ className, ...props }: React.ComponentProps<"td">) { return ( <td data-slot="table-cell" className={cn( "p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-[2px]", className, )} {...props} /> )}
function TableCaption({ className, ...props}: React.ComponentProps<"caption">) { return ( <caption data-slot="table-caption" className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} /> )}
export { TableComponent, Table, TableHeader, TableBody, TableFooter, TableHead, TableRow, TableCell, TableCaption,}"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import { cn } from "@/lib/utils"import { TableComponent } from "@/components/ui/table"
/** * Extracts height from Tailwind arbitrary values (e.g., h-[600px], max-h-[400px]). * Converts them to inline styles to ensure scroll events work reliably. * For other height utilities, use the height/maxHeight props directly. */function parseHeightFromClassName(className?: string) { if (!className) return { height: undefined, maxHeight: undefined, safeClassName: className }
const classes = className.split(/\s+/) let height: string | undefined let maxHeight: string | undefined const remainingClasses: string[] = []
for (const cls of classes) { // Match arbitrary values: h-[600px], max-h-[400px] const heightMatch = cls.match(/^h-\[([^\]]+)\]$/) const maxHeightMatch = cls.match(/^max-h-\[([^\]]+)\]$/)
if (heightMatch) { height = heightMatch[1] } else if (maxHeightMatch) { maxHeight = maxHeightMatch[1] } else { remainingClasses.push(cls) } }
return { height, maxHeight, safeClassName: remainingClasses.join(" "), }}
export interface DataTableContainerProps { children: React.ReactNode /** * Additional CSS classes for the container. * Arbitrary height values (e.g., h-[600px], max-h-[400px]) are automatically extracted * and applied as inline styles to ensure scroll event callbacks work reliably. * For other height utilities, use the height/maxHeight props directly. */ className?: string /** * Sets the height of the table container. * When provided, enables vertical scrolling and allows DataTableBody/DataTableVirtualizedBody * to use onScroll, onScrolledTop, and onScrolledBottom callbacks. * Takes precedence over height utilities in className. */ height?: number | string /** * Sets the maximum height of the table container. * Defaults to the height value if not specified. * Takes precedence over max-height utilities in className. */ maxHeight?: number | string}
/** * DataTable container component that wraps the table and provides scrolling behavior. * * @example * Without height - table grows with content, no scroll * <DataTable> * <DataTableHeader /> * <DataTableBody /> * </DataTable> * * @example * With height prop - enables scrolling and scroll event callbacks * <DataTable height={600}> * <DataTableHeader /> * <DataTableBody * onScroll={(e) => console.log(`Scrolled ${e.percentage}%`)} * onScrolledBottom={() => console.log('Load more data')} * /> * </DataTable> * * @example * With arbitrary height in className - automatically extracted and applied as inline style * <DataTable className="h-[600px]"> * <DataTableBody onScroll={...} /> * </DataTable> * * @example * Prefer using height prop for better type safety and clarity * <DataTable height="600px" className="rounded-lg"> * <DataTableBody onScroll={...} /> * </DataTable> */export function DataTable({ children, className, height, maxHeight,}: DataTableContainerProps) { // Parse height from className if not provided via props const parsed = React.useMemo( () => parseHeightFromClassName(className), [className], )
const finalHeight = height ?? parsed.height const finalMaxHeight = maxHeight ?? parsed.maxHeight ?? finalHeight
return ( <div data-slot="table-container" className={cn( "relative w-full overflow-auto rounded-lg border", // Custom scrollbar styling to match ScrollArea aesthetic // Scrollbar visible but subtle by default, more prominent on hover "[&::-webkit-scrollbar]:h-2.5 [&::-webkit-scrollbar]:w-2.5", "[&::-webkit-scrollbar-track]:bg-transparent", "[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/40", "hover:[&::-webkit-scrollbar-thumb]:bg-border", "[&::-webkit-scrollbar-thumb:hover]:bg-border/80!", // Firefox scrollbar styling "scrollbar-thin scrollbar-track-transparent scrollbar-thumb-border/40", "hover:scrollbar-thumb-border", parsed.safeClassName, )} style={{ height: finalHeight, maxHeight: finalMaxHeight, }} > <TableComponent>{children}</TableComponent> </div> )}
DataTable.displayName = "DataTable""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import { useReactTable, getCoreRowModel, getExpandedRowModel, getFacetedRowModel, getFacetedUniqueValues, getFacetedMinMaxValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, type Table, type TableOptions, type PaginationState, type SortingState, type ColumnFiltersState, type RowSelectionState, type VisibilityState, type ExpandedState, type ColumnOrderState, type ColumnPinningState, type Updater, type FilterFn, type FilterFnOption,} from "@tanstack/react-table"import { DataTableProvider } from "./data-table-context"import { cn } from "@/lib/utils"import { type DataTableColumnDef, type GlobalFilter } from "../types"import { detectFeaturesFromChildren } from "../config/feature-detection"import { extendedFilter, globalFilter as globalFilterFn, numberRangeFilter, dateRangeFilter,} from "../lib/filter-functions"import { FILTER_VARIANTS, SYSTEM_COLUMN_IDS, SYSTEM_COLUMN_ID_LIST,} from "../lib/constants"
export interface DataTableConfig { // Feature toggles enablePagination?: boolean enableFilters?: boolean enableSorting?: boolean enableRowSelection?: boolean enableMultiSort?: boolean enableGrouping?: boolean enableExpanding?: boolean
// Manual modes (for server-side) manualSorting?: boolean manualPagination?: boolean manualFiltering?: boolean pageCount?: number
// Initial state initialPageSize?: number initialPageIndex?: number
// Auto-reset behaviors autoResetPageIndex?: boolean autoResetExpanded?: boolean}
interface TableRootProps<TData, TValue> extends Partial<TableOptions<TData>> { // Option 1: Pass a pre-configured table instance table?: Table<TData>
// Option 2: Let DataTableRoot create its own table columns?: DataTableColumnDef<TData, TValue>[] data?: TData[]
children: React.ReactNode className?: string
// Configuration object config?: DataTableConfig getRowId?: (originalRow: TData, index: number) => string
// Loading state isLoading?: boolean
// Event handlers onGlobalFilterChange?: (value: GlobalFilter) => void onPaginationChange?: (updater: Updater<PaginationState>) => void onSortingChange?: (updater: Updater<SortingState>) => void onColumnVisibilityChange?: (updater: Updater<VisibilityState>) => void onColumnFiltersChange?: (updater: Updater<ColumnFiltersState>) => void onRowSelectionChange?: (updater: Updater<RowSelectionState>) => void onExpandedChange?: (updater: Updater<ExpandedState>) => void onColumnOrderChange?: (updater: Updater<ColumnOrderState>) => void onRowSelection?: (selectedRows: TData[]) => void}
// Internal component that handles hooks for direct props modefunction DataTableRootInternal<TData, TValue>({ columns, data, children, className, config, getRowId, isLoading, onGlobalFilterChange, onPaginationChange, onSortingChange, onColumnVisibilityChange, onColumnFiltersChange, onRowSelectionChange, onExpandedChange, onColumnOrderChange, onColumnPinningChange, onRowSelection, // Destructured by name so the `tableOptions` memo depends on stable values // — depending on the whole `rest` bag invalidated the memo every render // and triggered the "state update on a component that hasn't mounted yet" // warning under React 19 + Strict Mode + Turbopack HMR. state: restState, initialState: restInitialState, globalFilterFn: restGlobalFilterFn, // Spread into `tableOptions` but NOT in the memo deps. Lift any passthrough // option that needs to invalidate the memo into the destructure list above. ...passthroughTableOptions}: Omit<TableRootProps<TData, TValue>, "table"> & { columns: DataTableColumnDef<TData, TValue>[] data: TData[]}) { // Memoize so `columns.some()` only runs when the columns array changes. const hasSelectColumn = React.useMemo( () => columns?.some(col => col.id === SYSTEM_COLUMN_IDS.SELECT) ?? false, [columns], )
const hasExpandColumn = React.useMemo( () => columns?.some( col => col.id === SYSTEM_COLUMN_IDS.EXPAND || (col.meta && "expandedContent" in col.meta && col.meta.expandedContent), ) ?? false, [columns], )
// Stable identity prevents downstream memo cascades (detectFeatures, // processedColumns, tableOptions) from invalidating each render. const finalConfig: DataTableConfig = React.useMemo( () => ({ enablePagination: config?.enablePagination, enableFilters: config?.enableFilters, enableSorting: config?.enableSorting, enableRowSelection: config?.enableRowSelection ?? hasSelectColumn, enableMultiSort: config?.enableMultiSort, enableGrouping: config?.enableGrouping, enableExpanding: config?.enableExpanding ?? hasExpandColumn, manualSorting: config?.manualSorting, manualPagination: config?.manualPagination, manualFiltering: config?.manualFiltering, pageCount: config?.pageCount, initialPageSize: config?.initialPageSize, initialPageIndex: config?.initialPageIndex, // Default `false` — preserves pagination cursor across filter changes // (better UX for server-side / infinite scroll) and avoids the async // `onPaginationChange` race that fires "state update on unmounted // component" warnings. Opt in via `config={{ autoResetPageIndex: true }}`. autoResetPageIndex: config?.autoResetPageIndex ?? false, autoResetExpanded: config?.autoResetExpanded ?? false, }), [ config?.enablePagination, config?.enableFilters, config?.enableSorting, config?.enableRowSelection, hasSelectColumn, config?.enableMultiSort, config?.enableGrouping, config?.enableExpanding, hasExpandColumn, config?.manualSorting, config?.manualPagination, config?.manualFiltering, config?.pageCount, config?.initialPageSize, config?.initialPageIndex, config?.autoResetPageIndex, config?.autoResetExpanded, ], )
// Cache once: `detectFeaturesFromChildren` recursively walks the React tree // (50-150ms on deep trees). Children structure is stable post-mount. const detectedFeaturesRef = React.useRef<ReturnType< typeof detectFeaturesFromChildren > | null>(null)
// Only detect features once on mount (children structure is stable) if (detectedFeaturesRef.current === null) { detectedFeaturesRef.current = detectFeaturesFromChildren(children, columns) }
// Memoize merged feature object so tableOptions stays stable. const detectFeatures = React.useMemo(() => { const detectedFeatures = detectedFeaturesRef.current ?? {}
const features = { // Use config first, then explicit props, then detected features, then defaults enablePagination: finalConfig.enablePagination ?? detectedFeatures.enablePagination ?? false, enableFilters: finalConfig.enableFilters ?? detectedFeatures.enableFilters ?? false, enableRowSelection: finalConfig.enableRowSelection ?? detectedFeatures.enableRowSelection ?? false, enableSorting: finalConfig.enableSorting ?? detectedFeatures.enableSorting ?? true, enableMultiSort: finalConfig.enableMultiSort ?? detectedFeatures.enableMultiSort ?? true, enableGrouping: finalConfig.enableGrouping ?? detectedFeatures.enableGrouping ?? true, enableExpanding: finalConfig.enableExpanding ?? detectedFeatures.enableExpanding ?? false, manualSorting: finalConfig.manualSorting ?? detectedFeatures.manualSorting ?? false, manualPagination: finalConfig.manualPagination ?? detectedFeatures.manualPagination ?? false, manualFiltering: finalConfig.manualFiltering ?? detectedFeatures.manualFiltering ?? false, pageCount: finalConfig.pageCount ?? detectedFeatures.pageCount, }
return features }, [finalConfig])
// State management const [globalFilter, setGlobalFilter] = React.useState<GlobalFilter>( restInitialState?.globalFilter ?? "", ) const [rowSelection, setRowSelection] = React.useState<RowSelectionState>( restInitialState?.rowSelection ?? {}, ) const [columnVisibility, setColumnVisibility] = React.useState<VisibilityState>(restInitialState?.columnVisibility ?? {}) const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>( restInitialState?.columnFilters ?? [], ) const [sorting, setSorting] = React.useState<SortingState>( restInitialState?.sorting ?? [], ) const [expanded, setExpanded] = React.useState<ExpandedState>( restInitialState?.expanded ?? {}, ) const [columnPinning, setColumnPinning] = React.useState<{ left: string[] right: string[] }>({ left: restInitialState?.columnPinning?.left ?? [], right: restInitialState?.columnPinning?.right ?? [], }) const [columnOrder, setColumnOrder] = React.useState<ColumnOrderState>( restInitialState?.columnOrder ?? [], ) const [pagination, setPagination] = React.useState<PaginationState>({ pageIndex: finalConfig.initialPageIndex ?? restInitialState?.pagination?.pageIndex ?? 0, pageSize: finalConfig.initialPageSize ?? restInitialState?.pagination?.pageSize ?? 10, })
// Mount-ref guards prevent React-19 + StrictMode "state update on unmounted // component" warnings when TanStack's async dispatches land on a torn-down fiber. const isMountedRef = React.useRef(true) React.useEffect(() => { isMountedRef.current = true return () => { isMountedRef.current = false } }, [])
// Stable identity keeps tableOptions memo from invalidating each render. const handleGlobalFilterChange = React.useCallback( (value: GlobalFilter) => { // Mount-guard local writes; external handler is caller's responsibility. if (isMountedRef.current) { setGlobalFilter(value) } onGlobalFilterChange?.(value) }, [onGlobalFilterChange], )
// O(1) row-by-id lookup; Array.find()-per-selection is O(n × m) — ~500ms lag // at 10k rows × 100 selected. const rowIdMap = React.useMemo(() => { const map = new Map<string, TData>() data?.forEach((row, idx) => { const rowId = getRowId?.(row, idx) ?? (row as { id?: string | number }).id?.toString() ?? String(idx) map.set(rowId, row) }) return map }, [data, getRowId])
// Stable identity prevents table re-init. Pure setter — `onRowSelection` // fires from the effect below so concurrent-mode double-invokes don't double-fire. // Honors the full TanStack `Updater<T> = T | ((old: T) => T)` contract. const handleRowSelectionChange = React.useCallback( (valueFn: Updater<RowSelectionState>) => { if (!isMountedRef.current) return if (typeof valueFn === "function") { setRowSelection(prev => valueFn(prev)) } else { setRowSelection(valueFn) } }, [], )
// Fire `onRowSelection` only on user-driven changes — skip the initial mount. const skipInitialRowSelectionRef = React.useRef(true) React.useEffect(() => { if (!isMountedRef.current) return if (skipInitialRowSelectionRef.current) { skipInitialRowSelectionRef.current = false return } if (!onRowSelection) return const selectedRows = Object.keys(rowSelection) .filter(key => rowSelection[key]) .map(key => rowIdMap.get(key)) .filter((row): row is TData => row !== undefined) onRowSelection(selectedRows) }, [rowSelection, rowIdMap, onRowSelection])
/** * PERFORMANCE: Stable mount-guarded fallback setters * * WHY: Inline `(u) => isMounted && setX(u)` closures inside `tableOptions` get * recreated on every memo invalidation, and the 6 setX refs added noise to the * dep array (state setters are already stable by React contract). * * IMPACT: tableOptions memo no longer depends on 6 setters; fallback handlers * keep referential identity across renders. * * WHAT: Hoists each fallback to a `useCallback([])`. Mount-guard preserved so * StrictMode-unmounted fibers don't receive setState calls. */ const handleSortingChange = React.useCallback((u: Updater<SortingState>) => { if (isMountedRef.current) setSorting(u) }, [])
const handleColumnFiltersChange = React.useCallback( (u: Updater<ColumnFiltersState>) => { if (isMountedRef.current) setColumnFilters(u) }, [], )
const handleColumnVisibilityChange = React.useCallback( (u: Updater<VisibilityState>) => { if (isMountedRef.current) setColumnVisibility(u) }, [], )
const handleColumnPinningChange = React.useCallback( (updater: Updater<ColumnPinningState>) => { if (!isMountedRef.current) return setColumnPinning(prev => { const next = typeof updater === "function" ? updater(prev) : updater return { left: next.left ?? [], right: next.right ?? [], } }) }, [], )
const handleColumnOrderChange = React.useCallback( (u: Updater<ColumnOrderState>) => { if (isMountedRef.current) setColumnOrder(u) }, [], )
const handleExpandedChange = React.useCallback( (u: Updater<ExpandedState>) => { if (isMountedRef.current) setExpanded(u) }, [], )
const handlePaginationChange = React.useCallback( (u: Updater<PaginationState>) => { if (isMountedRef.current) setPagination(u) }, [], )
/** * Auto-apply filterFn based on meta.variant if not explicitly provided * This allows developers to set variant in meta and get the right filterFn automatically */ const processedColumns = React.useMemo(() => { return columns.map(col => { // If filterFn is already defined, use it (manual override) if (col.filterFn) return col
const meta = col.meta ?? {} const variant = meta.variant
// Auto-apply filterFn based on variant let autoFilterFn: FilterFnOption<TData> | undefined if ( variant === FILTER_VARIANTS.RANGE || variant === FILTER_VARIANTS.NUMBER ) { // For number/range variants, use numberRangeFilter if no explicit filterFn autoFilterFn = "numberRange" as FilterFnOption<TData> } else if ( variant === FILTER_VARIANTS.DATE || variant === FILTER_VARIANTS.DATE_RANGE ) { // For date variants, use dateRangeFilter if no explicit filterFn autoFilterFn = "dateRange" as FilterFnOption<TData> }
// Only override if we have an auto filterFn and no explicit one if (autoFilterFn) { return { ...col, filterFn: autoFilterFn, } }
return col }) }, [columns])
// TanStack's `defaultColumn` is per-render-cheaper than mapping columns ourselves. const defaultColumn = React.useMemo<Partial<DataTableColumnDef<TData>>>( () => ({ enableSorting: true, enableHiding: true, filterFn: "extended" as FilterFnOption<TData>, // Override TanStack's internal default (150) so unset `size` stays undefined // — virtualized flex layout uses this to distinguish fixed vs flexible cols. // `column.getSize()` still falls back to 150 internally. size: undefined, }), [], )
// Extract controlled-state slices for the tableOptions dep array. const controlledSorting = restState?.sorting ?? sorting const controlledColumnVisibility = restState?.columnVisibility ?? columnVisibility const controlledRowSelection = restState?.rowSelection ?? rowSelection const controlledColumnFilters = restState?.columnFilters ?? columnFilters const controlledGlobalFilter = restState?.globalFilter !== undefined ? restState.globalFilter : globalFilter const controlledColumnPinning = restState?.columnPinning ?? columnPinning const controlledColumnOrder = restState?.columnOrder ?? columnOrder const controlledExpanded = restState?.expanded ?? expanded const controlledPagination = restState?.pagination ?? pagination
// System columns (select, expand) follow the first data column's pinning so // they stay visually attached as the "row header". const finalColumnPinning = React.useMemo(() => { // Use centralized system column IDs from constants
// Helper to safely extract column ID (handles both id and accessorKey) const getColumnId = ( col: DataTableColumnDef<TData, TValue>, ): string | undefined => { if (col.id) return col.id // Type-safe check for accessorKey property if ("accessorKey" in col && typeof col.accessorKey === "string") { return col.accessorKey } return undefined }
// 1. Identify the "First Data Column" (first non-system column) const firstDataCol = columns.find(col => { const id = getColumnId(col) return id && !SYSTEM_COLUMN_ID_LIST.includes(id) })
if (!firstDataCol) return controlledColumnPinning
const firstDataColId = getColumnId(firstDataCol) if (!firstDataColId) return controlledColumnPinning
// 2. Check pinning state of the first data column const isPinnedLeft = controlledColumnPinning.left?.includes(firstDataColId) const isPinnedRight = controlledColumnPinning.right?.includes(firstDataColId)
// If not fixed to either side, return default (system cols float naturally) if (!isPinnedLeft && !isPinnedRight) { return controlledColumnPinning }
const left = [...(controlledColumnPinning.left ?? [])] const right = [...(controlledColumnPinning.right ?? [])]
// 3. Prepare system columns list const systemColsPresent: string[] = [] if (hasSelectColumn) systemColsPresent.push(SYSTEM_COLUMN_IDS.SELECT) if (hasExpandColumn) systemColsPresent.push(SYSTEM_COLUMN_IDS.EXPAND)
// 4. Clean existing lists (remove system cols to avoid duplication) const cleanLeft = left.filter(id => !SYSTEM_COLUMN_ID_LIST.includes(id)) const cleanRight = right.filter(id => !SYSTEM_COLUMN_ID_LIST.includes(id))
// 5. Construct new pinning state if (isPinnedLeft) { // Pin Left: [System, ...Others] return { left: [...systemColsPresent, ...cleanLeft], right: cleanRight, } }
if (isPinnedRight) { // Pin Right: [System, ...Others] // We place system cols *before* others in the Right group so they appear // to the immediate left of the right-pinned data columns. return { left: cleanLeft, right: [...systemColsPresent, ...cleanRight], } }
return controlledColumnPinning }, [controlledColumnPinning, columns, hasSelectColumn, hasExpandColumn])
// Critical: stable options reference. New object → useReactTable recreates // the instance → state resets and sorting/filter/expand break. const tableOptions = React.useMemo<TableOptions<TData>>( () => ({ ...passthroughTableOptions, data, columns: processedColumns, defaultColumn, state: { ...restState, // Always use our local state as the source of truth // External state (restState) takes precedence only if explicitly provided sorting: controlledSorting, columnVisibility: controlledColumnVisibility, columnPinning: finalColumnPinning, columnOrder: controlledColumnOrder, rowSelection: controlledRowSelection, columnFilters: controlledColumnFilters, globalFilter: controlledGlobalFilter, expanded: controlledExpanded, pagination: controlledPagination, }, enableRowSelection: detectFeatures.enableRowSelection, enableFilters: detectFeatures.enableFilters, enableSorting: detectFeatures.enableSorting, enableMultiSort: detectFeatures.enableMultiSort, enableGrouping: detectFeatures.enableGrouping, enableExpanding: detectFeatures.enableExpanding, manualSorting: detectFeatures.manualSorting, manualPagination: detectFeatures.manualPagination, manualFiltering: detectFeatures.manualFiltering, // Enable auto-reset behaviors by default (standard TanStack Table behavior) // Can be overridden via config autoResetPageIndex: finalConfig.autoResetPageIndex, autoResetExpanded: finalConfig.autoResetExpanded, onGlobalFilterChange: handleGlobalFilterChange, onRowSelectionChange: onRowSelectionChange ?? handleRowSelectionChange, // Default state setters are mount-ref guarded so TanStack's async // auto-reset dispatches don't land on a StrictMode-unmounted fiber. // Consumer-supplied handlers are NOT guarded — caller's responsibility. onSortingChange: onSortingChange ?? handleSortingChange, onColumnFiltersChange: onColumnFiltersChange ?? handleColumnFiltersChange, onColumnVisibilityChange: onColumnVisibilityChange ?? handleColumnVisibilityChange, onColumnPinningChange: onColumnPinningChange ?? handleColumnPinningChange, onColumnOrderChange: onColumnOrderChange ?? handleColumnOrderChange, onExpandedChange: onExpandedChange ?? handleExpandedChange, onPaginationChange: onPaginationChange ?? handlePaginationChange, getCoreRowModel: getCoreRowModel(), getFacetedRowModel: detectFeatures.enableFilters ? getFacetedRowModel() : undefined, getFacetedUniqueValues: detectFeatures.enableFilters ? getFacetedUniqueValues() : undefined, getFacetedMinMaxValues: detectFeatures.enableFilters ? getFacetedMinMaxValues() : undefined, getFilteredRowModel: detectFeatures.enableFilters ? getFilteredRowModel() : undefined, getSortedRowModel: detectFeatures.enableSorting ? getSortedRowModel() : undefined, getPaginationRowModel: detectFeatures.enablePagination ? getPaginationRowModel() : undefined, getExpandedRowModel: detectFeatures.enableExpanding ? getExpandedRowModel() : undefined, filterFns: { extended: extendedFilter, numberRange: numberRangeFilter, dateRange: dateRangeFilter, }, // Allow globalFilterFn to be overridden via rest props, otherwise use default globalFilterFn: (restGlobalFilterFn as FilterFn<TData>) ?? (globalFilterFn as unknown as FilterFn<TData>), // Use provided getRowId or fallback to checking for 'id' property, then index getRowId: getRowId ?? ((originalRow, index) => { // Try to use 'id' property if it exists const rowWithId = originalRow as { id?: string | number } if (rowWithId.id !== undefined && rowWithId.id !== null) { return String(rowWithId.id) } // Fallback to index return String(index) }), pageCount: (() => { if (!detectFeatures.manualPagination) return undefined return finalConfig.pageCount !== undefined ? finalConfig.pageCount : detectFeatures.pageCount !== undefined ? detectFeatures.pageCount : -1 })(), }), // Deps are the *destructured* rest props, NOT the whole rest bag — see // destructure-site comment. `passthroughTableOptions` is intentionally // NOT a dep (lift any option that needs to invalidate the memo). // eslint-disable-next-line react-hooks/exhaustive-deps [ restState, restGlobalFilterFn, data, processedColumns, defaultColumn, detectFeatures, finalConfig, handleGlobalFilterChange, onRowSelectionChange, handleRowSelectionChange, onSortingChange, handleSortingChange, onColumnFiltersChange, handleColumnFiltersChange, onColumnVisibilityChange, handleColumnVisibilityChange, onColumnPinningChange, handleColumnPinningChange, onColumnOrderChange, handleColumnOrderChange, onExpandedChange, handleExpandedChange, onPaginationChange, handlePaginationChange, getRowId, // Use controlled state values - these update when either external or local state changes controlledSorting, controlledColumnVisibility, controlledRowSelection, controlledColumnFilters, controlledGlobalFilter, controlledColumnOrder, controlledExpanded, controlledPagination, // Add column pinning state to dependencies so the table updates when it changes finalColumnPinning, ], )
// Instance ref is stable across state changes; React Compiler warns about // incompatible-library here — TanStack manages its own memoization (expected). const table = useReactTable<TData>(tableOptions)
return ( <DataTableProvider table={table} columns={processedColumns as DataTableColumnDef<TData>[]} isLoading={isLoading} > <div className={cn("w-full space-y-4", className)}>{children}</div> </DataTableProvider> )}
// Main wrapper componentexport function DataTableRoot<TData, TValue>({ table: externalTable, columns, data, children, className, isLoading, ...rest}: TableRootProps<TData, TValue>) { // If a table instance is provided, use it directly (no hooks needed) if (externalTable) { return ( <DataTableProvider table={externalTable} columns={columns as DataTableColumnDef<TData>[]} isLoading={isLoading} > <div className={cn("w-full space-y-4", className)}>{children}</div> </DataTableProvider> ) }
// Validate required props for internal table creation if (!columns || !data) { throw new Error( "DataTableRoot: Either provide a 'table' prop or both 'columns' and 'data' props", ) }
// Otherwise, delegate to the internal component that handles hooks return ( <DataTableRootInternal columns={columns} data={data} className={className} isLoading={isLoading} {...rest} > {children} </DataTableRootInternal> )}
DataTableRoot.displayName = "DataTableRoot""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React, { createContext, useCallback, useContext, useEffect, useReducer,} from "react"import { useGeneratedOptions } from "../hooks/use-generated-options"import type { DataTableInstance, DataTableColumnDef, Option } from "../types"
export type DataTableContextState = { isLoading: boolean}
type DataTableContextProps<TData> = DataTableContextState & { table: DataTableInstance<TData> columns: DataTableColumnDef<TData>[] generatedOptionsMap: Record<string, Option[]> setIsLoading: (isLoading: boolean) => void}
// eslint-disable-next-line @typescript-eslint/no-explicit-anyconst DataTableContext = createContext<DataTableContextProps<any> | undefined>( undefined,)
export function useDataTable<TData>(): DataTableContextProps<TData> { const context = useContext(DataTableContext) if (context === undefined) { throw new Error("useDataTable must be used within DataTableRoot") } return context as DataTableContextProps<TData>}
export enum DataTableActions { SET, SET_IS_LOADING,}
type DataTableAction = { type: DataTableActions.SET_IS_LOADING value: boolean}
function dataTableReducer( state: DataTableContextState, action: DataTableAction,): DataTableContextState { switch (action.type) { case DataTableActions.SET_IS_LOADING: return { ...state, isLoading: action.value } default: return state }}
function deriveInitialState(isLoading?: boolean): DataTableContextState { return { isLoading: isLoading ?? false, }}
interface DataTableProviderProps<TData> { children: React.ReactNode table: DataTableInstance<TData> columns?: DataTableColumnDef<TData>[] isLoading?: boolean}
export function DataTableProvider<TData>({ children, table, columns, isLoading: externalIsLoading,}: DataTableProviderProps<TData>) { const initialState = deriveInitialState(externalIsLoading)
const [state, dispatch] = useReducer(dataTableReducer, initialState)
const setIsLoading = useCallback((value: boolean) => { dispatch({ type: DataTableActions.SET_IS_LOADING, value, }) }, [])
// Sync external isLoading prop with internal state useEffect(() => { if ( externalIsLoading !== undefined && externalIsLoading !== state.isLoading ) { setIsLoading(externalIsLoading) } }, [externalIsLoading, state.isLoading, setIsLoading])
// Table instance ref is stable across state changes — extract individual // state slices so context consumers re-render on filter/sort/select. const tableState = table.getState()
const globalFilter = tableState.globalFilter const sorting = tableState.sorting const columnFilters = tableState.columnFilters const columnVisibility = tableState.columnVisibility const expanded = tableState.expanded const rowSelection = tableState.rowSelection const pagination = tableState.pagination const columnPinning = tableState.columnPinning const columnOrder = tableState.columnOrder
// Lightweight state hash beats JSON.stringify for large selections // (~0.1ms vs 20-50ms at 1k rows) while still triggering consumer updates. const tableStateKey = React.useMemo(() => { // Full sorted-keys hash — a "first 3 keys" signature collided on // sequential row IDs (`r1,r2,r3` vs `r1,r2,r4`). const getObjectHash = ( obj: Record<string, unknown> | undefined, ): string => { if (!obj) return "0" const keys = Object.keys(obj) if (keys.length === 0) return "0" return keys.sort().join(",") }
const paginationKey = `${pagination.pageIndex ?? 0}:${pagination.pageSize ?? 0}`
// Handle globalFilter - can be string or object (for complex filters) const globalFilterHash = typeof globalFilter === "string" ? globalFilter : globalFilter && typeof globalFilter === "object" ? getObjectHash(globalFilter) : ""
return { globalFilter: globalFilterHash, sortingHash: JSON.stringify(sorting), columnFiltersHash: JSON.stringify(columnFilters), columnVisibilityHash: getObjectHash( columnVisibility as Record<string, unknown> | undefined, ), expandedHash: getObjectHash( expanded as Record<string, unknown> | undefined, ), rowSelectionHash: getObjectHash( rowSelection as Record<string, unknown> | undefined, ), paginationKey, columnPinningHash: JSON.stringify(columnPinning), columnOrderHash: JSON.stringify(columnOrder), } }, [ globalFilter, sorting, columnFilters, columnVisibility, expanded, rowSelection, pagination, columnPinning, columnOrder, ])
// Generate options for all select/multiSelect columns in a single pass. // This replaces N separate per-column scans in faceted filter consumers. const generatedOptionsMap = useGeneratedOptions(table)
// Memoize so context consumers (10+ filter/action components) only re-render // when table, columns, loading, or actual table state changes. const value = React.useMemo( () => ({ table, columns: columns || (table.options.columns as DataTableColumnDef<TData>[]), isLoading: state.isLoading, generatedOptionsMap, setIsLoading, }) as DataTableContextProps<TData>, [ table, columns, state.isLoading, generatedOptionsMap, setIsLoading, tableStateKey, ], )
return ( <DataTableContext.Provider value={value}> {children} </DataTableContext.Provider> )}
export { DataTableContext }"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import { cn } from "@/lib/utils"import { useDataTable } from "./data-table-context"import { TableHeader, TableRow, TableHead, TableBody, TableCell,} from "@/components/ui/table"import { flexRender, type Row } from "@tanstack/react-table"import { Skeleton } from "@/components/ui/skeleton"import { DataTableEmptyState } from "../components/data-table-empty-state"import { DataTableColumnHeaderRoot } from "../components/data-table-column-header"import { createScrollHandler } from "../lib/create-scroll-handler"import { resolveRowFromClick } from "../lib/row-click"import { getCommonPinningStyles } from "../lib/styles"
// ============================================================================// ScrollEvent Type// ============================================================================
export interface ScrollEvent { scrollTop: number scrollHeight: number clientHeight: number isTop: boolean isBottom: boolean percentage: number}
// ============================================================================// DataTableHeader// ============================================================================
export interface DataTableHeaderProps { className?: string /** * Makes the header sticky at the top when scrolling. * @default true */ sticky?: boolean}
export const DataTableHeader = React.memo(function DataTableHeader({ className, sticky = true,}: DataTableHeaderProps) { const { table } = useDataTable()
const headerGroups = table?.getHeaderGroups() ?? []
if (headerGroups.length === 0) { return null }
return ( <TableHeader className={cn( sticky && "sticky top-0 z-30 bg-background", // Ensure border is visible when sticky using pseudo-element sticky && "after:absolute after:right-0 after:bottom-0 after:left-0 after:h-px after:bg-border", className, )} > {headerGroups.map(headerGroup => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map(header => { const size = header.column.columnDef.size const headerStyle = { width: size ? `${size}px` : undefined, ...getCommonPinningStyles(header.column, true), }
return ( <TableHead key={header.id} style={headerStyle} className={cn(header.column.getIsPinned() && "bg-background")} > {header.isPlaceholder ? null : ( <DataTableColumnHeaderRoot column={header.column}> {flexRender( header.column.columnDef.header, header.getContext(), )} </DataTableColumnHeaderRoot> )} </TableHead> ) })} </TableRow> ))} </TableHeader> )})
DataTableHeader.displayName = "DataTableHeader"
// ============================================================================// BodyRow — memoized to avoid cascading re-renders across visible rows// ============================================================================
/** * Per-row component for `DataTableBody`. Wrapped with `React.memo` so a * single-row state change (selection toggle, expansion) doesn't cascade * into a re-render across every visible row. * * Default shallow equality is sufficient: all props are either primitive * (`isExpanded`, `isSelected`, `isClickable`, `expandColumnId`) or stable * by contract (`row` is a TanStack row instance, kept stable across * renders unless the source data array reference changes). */interface BodyRowProps { row: Row<unknown> expandColumnId: string | undefined isClickable: boolean isExpanded: boolean isSelected: boolean /** Column layout signature — invalidates React.memo on visibility/order/pinning change. */ columnLayoutSignature: string /** * Per-row memo key. Change this string to force React.memo to re-render a * specific row when row-level state changes outside of TanStack Table's * tracked props (e.g. inline edit mode, optimistic state). */ rowMemoKey: string}
const BodyRow = React.memo(function BodyRow({ row, expandColumnId, isClickable, isExpanded, isSelected,}: BodyRowProps) { const expandCell = isExpanded && expandColumnId ? row.getAllCells().find(c => c.column.id === expandColumnId) : undefined
const visibleCells = row.getVisibleCells()
return ( <> <TableRow data-row-index={row.index} data-row-id={row.id} data-state={isSelected ? "selected" : undefined} className={cn(isClickable && "cursor-pointer", "group")} > {visibleCells.map(cell => { const size = cell.column.columnDef.size const cellStyle = { width: size ? `${size}px` : undefined, ...getCommonPinningStyles(cell.column, false), }
return ( <TableCell key={cell.id} style={cellStyle} className={cn( cell.column.getIsPinned() && "bg-background group-hover:bg-muted/50 group-data-[state=selected]:bg-muted", )} > {flexRender(cell.column.columnDef.cell, cell.getContext())} </TableCell> ) })} </TableRow>
{expandCell && ( <TableRow> <TableCell colSpan={visibleCells.length} className="p-0"> {expandCell.column.columnDef.meta?.expandedContent?.(row.original)} </TableCell> </TableRow> )} </> )})
BodyRow.displayName = "BodyRow"
// ============================================================================// DataTableBody// ============================================================================
export interface DataTableBodyProps<TData> { children?: React.ReactNode className?: string onScroll?: (event: ScrollEvent) => void onScrolledTop?: () => void onScrolledBottom?: () => void scrollThreshold?: number /** * Click is dispatched per-row from each cell's onClick. The * event's `currentTarget` is the `<td>` cell — typed as * `HTMLElement` to stay consistent with the virtualized variants * (which delegate on `<tbody>`). Consumers needing the row * element can `event.target.closest("tr[data-row-id]")`. */ onRowClick?: (row: TData, event: React.MouseEvent<HTMLElement>) => void /** * Return a per-row memo invalidation key. When this key changes for a * specific row, only that row re-renders. */ getRowMemoKey?: (row: TData) => string}
export function DataTableBody<TData>({ children, className, onScroll, onScrolledTop, onScrolledBottom, scrollThreshold = 50, onRowClick, getRowMemoKey,}: DataTableBodyProps<TData>) { const { table, columns, isLoading } = useDataTable<TData>() const { rows } = table.getRowModel() const containerRef = React.useRef<HTMLTableSectionElement>(null)
// Single delegated click handler — avoids one inline fn per row. const handleRowClick = React.useCallback( (event: React.MouseEvent<HTMLTableSectionElement>) => { if (!onRowClick) return const row = resolveRowFromClick(event.target as HTMLElement, table) if (!row) return onRowClick(row.original, event) }, [onRowClick, table], )
// Passive scroll listener — shared `createScrollHandler` keeps all four body // variants in sync; passive flag unlocks the browser's scroll-thread path. React.useEffect(() => { const container = containerRef.current?.closest( '[data-slot="table-container"]', ) as HTMLDivElement if (!container) return if (!onScroll && !onScrolledTop && !onScrolledBottom) return
const handleScroll = createScrollHandler({ onScroll, onScrolledTop, onScrolledBottom, scrollThreshold, }) container.addEventListener("scroll", handleScroll, { passive: true }) return () => container.removeEventListener("scroll", handleScroll) }, [onScroll, onScrolledTop, onScrolledBottom, scrollThreshold])
// Hoist expand-column lookup above the row map (was O(rows × cols) per render). // `columns` is in deps because the table reference is too stable on its own. const expandColumnId = React.useMemo( () => table.getAllColumns().find(col => col.columnDef.meta?.expandedContent) ?.id, [table, columns], )
const { columnVisibility, columnOrder, columnPinning } = table.getState() // Encodes visible column ids + pinning so memoized rows re-render on layout changes. const columnLayoutSignature = React.useMemo( () => table .getVisibleLeafColumns() .map(c => { const pinned = c.getIsPinned() return pinned ? `${c.id}:${pinned}` : c.id }) .join(","), [table, columnVisibility, columnOrder, columnPinning], )
const isClickable = !!onRowClick
return ( <TableBody ref={containerRef} className={className} onClick={onRowClick ? handleRowClick : undefined} > {/* Only show rows when not loading */} {!isLoading && rows?.length ? rows.map(row => ( <BodyRow key={row.id} row={row as Row<unknown>} expandColumnId={expandColumnId} isClickable={isClickable} isExpanded={row.getIsExpanded()} isSelected={row.getIsSelected()} columnLayoutSignature={columnLayoutSignature} rowMemoKey={ getRowMemoKey ? getRowMemoKey(row.original as TData) : "" } /> )) : null}
{children} </TableBody> )}
DataTableBody.displayName = "DataTableBody"
// ============================================================================// DataTableEmptyBody// ============================================================================
export interface DataTableEmptyBodyProps { children?: React.ReactNode colSpan?: number className?: string}
/** * Empty state component for data tables. * Use composition pattern with DataTableEmpty* components for full customization. * * @example * <DataTableEmptyBody> * <DataTableEmptyIcon> * <PackageOpen className="size-12" /> * </DataTableEmptyIcon> * <DataTableEmptyMessage> * <DataTableEmptyTitle>No products found</DataTableEmptyTitle> * <DataTableEmptyDescription> * Get started by adding your first product * </DataTableEmptyDescription> * </DataTableEmptyMessage> * <DataTableEmptyFilteredMessage> * No matches found * </DataTableEmptyFilteredMessage> * <DataTableEmptyActions> * <Button onClick={handleAdd}>Add Product</Button> * </DataTableEmptyActions> * </DataTableEmptyBody> */export function DataTableEmptyBody({ children, colSpan, className,}: DataTableEmptyBodyProps) { const { table, columns, isLoading } = useDataTable()
// Hooks first (rules-of-hooks), then early-return below skips work when // the empty state isn't visible. const tableState = table.getState() const isFiltered = React.useMemo( () => (tableState.globalFilter && tableState.globalFilter.length > 0) || (tableState.columnFilters && tableState.columnFilters.length > 0), [tableState.globalFilter, tableState.columnFilters], )
// Early return after hooks - this prevents rendering when not needed const rowCount = table.getRowModel().rows.length if (isLoading || rowCount > 0) return null
return ( <TableRow> <TableCell colSpan={colSpan ?? columns.length} className={className}> <DataTableEmptyState isFiltered={isFiltered}> {children} </DataTableEmptyState> </TableCell> </TableRow> )}
DataTableEmptyBody.displayName = "DataTableEmptyBody"
// ============================================================================// DataTableSkeleton// ============================================================================
export interface DataTableSkeletonProps { children?: React.ReactNode colSpan?: number /** * Number of skeleton rows to display. * @default 5 * @recommendation Set this to match your page size for better UX (e.g., if page size is 10, set rows={10}) */ rows?: number className?: string cellClassName?: string skeletonClassName?: string}
export function DataTableSkeleton({ children, colSpan, rows = 5, className, cellClassName, skeletonClassName,}: DataTableSkeletonProps) { const { table, columns, isLoading } = useDataTable()
// Show skeleton only when loading if (!isLoading) return null
// Get visible columns from table to match actual structure const visibleColumns = table.getVisibleLeafColumns() const numColumns = colSpan ?? columns.length
// If custom children provided, show single row with custom content if (children) { return ( <TableRow> <TableCell colSpan={numColumns} className={cn("h-24 text-center", className)} > {children} </TableCell> </TableRow> ) }
// Show skeleton rows that mimic the table structure return ( <> {Array.from({ length: rows }).map((_, rowIndex) => ( <TableRow key={rowIndex}> {visibleColumns.map((column, colIndex) => { const size = column.columnDef.size const cellStyle = size ? { width: `${size}px` } : undefined
return ( <TableCell key={colIndex} className={cellClassName} style={cellStyle} > <Skeleton className={cn("h-4 w-full", skeletonClassName)} /> </TableCell> ) })} </TableRow> ))} </> )}
DataTableSkeleton.displayName = "DataTableSkeleton"
// ============================================================================// DataTableLoading// ============================================================================
export interface DataTableLoadingProps { children?: React.ReactNode colSpan?: number className?: string}
export function DataTableLoading({ children, colSpan, className,}: DataTableLoadingProps) { const { columns, isLoading } = useDataTable()
// Self-gate on `isLoading` to match peer composables — otherwise the row // stays visible after data resolves. if (!isLoading) return null
return ( <TableRow> <TableCell colSpan={colSpan ?? columns.length} className={className ?? "h-24 text-center"} > {children ?? ( <div className="flex items-center justify-center gap-2"> <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" /> <span className="text-sm text-muted-foreground">Loading...</span> </div> )} </TableCell> </TableRow> )}
DataTableLoading.displayName = "DataTableLoading"
// ============================================================================// DataTableLoadingMore// ============================================================================
export interface DataTableLoadingMoreProps { /** * Whether a next-page fetch is currently in flight. Typically wired * to a library state like TanStack Query's `isFetchingNextPage`, * SWR's `isValidating`, or a plain `useState` flag. When false, this * component renders nothing. */ isFetching: boolean /** * Optional custom content. Defaults to a spinner + "Loading more..." * label. Pass children to customize per-table (e.g. "Loading more * products..."). */ children?: React.ReactNode colSpan?: number className?: string}
/** * Composable "loading more" row for infinite-scroll tables. Renders at * the end of the body when `isFetching` is true, and nothing when * false — designed to be dropped as a child of `DataTableBody` * alongside `DataTableSkeleton` and `DataTableEmptyBody`. * * Self-gates on its own `isFetching` prop. Combine with * `onScrolledBottom` on `DataTableBody` to trigger next-page fetches. * * @example * <DataTableBody * onScrolledBottom={() => { * if (hasMore && !isFetching) void loadMore() * }} * > * <DataTableSkeleton rows={5} /> * <DataTableEmptyBody>No results</DataTableEmptyBody> * <DataTableLoadingMore isFetching={isFetching}> * Loading more products... * </DataTableLoadingMore> * </DataTableBody> */export function DataTableLoadingMore({ isFetching, children, colSpan, className,}: DataTableLoadingMoreProps) { const { columns } = useDataTable()
// Self-gating — nothing to render when no fetch is in flight. if (!isFetching) return null
return ( <TableRow data-slot="datatable-loading-more-row"> <TableCell colSpan={colSpan ?? columns.length} className={cn( "py-3 text-center text-xs text-muted-foreground", className, )} > <span className="inline-flex items-center justify-center gap-2"> <span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-primary border-t-transparent" aria-hidden="true" /> <span>{children ?? "Loading more..."}</span> </span> </TableCell> </TableRow> )}
DataTableLoadingMore.displayName = "DataTableLoadingMore""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import { AlertCircle } from "lucide-react"import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"import { Button } from "@/components/ui/button"
export interface DataTableErrorBoundaryProps { /** * The content to render when there's no error */ children: React.ReactNode /** * Custom fallback UI to show when an error occurs */ fallback?: React.ReactNode /** * Callback fired when an error is caught */ onError?: (error: Error, errorInfo: React.ErrorInfo) => void /** * Whether to show a reset button * @default true */ showResetButton?: boolean /** * Custom reset button text * @default "Try Again" */ resetButtonText?: string}
interface DataTableErrorBoundaryState { hasError: boolean error: Error | null}
/** * Error boundary component for DataTable. * Catches JavaScript errors anywhere in the data table component tree, * logs those errors, and displays a fallback UI instead of crashing. * * @example * Basic usage * <DataTableErrorBoundary> * <DataTableRoot data={data} columns={columns}> * <DataTable> * <DataTableHeader /> * <DataTableBody /> * </DataTable> * </DataTableRoot> * </DataTableErrorBoundary> * * @example * // With custom fallback * <DataTableErrorBoundary * fallback={ * <div className="p-8 text-center"> * <h3>Oops! Something went wrong.</h3> * <p>Please contact support if this persists.</p> * </div> * } * > * <DataTableRoot data={data} columns={columns}> * {/* ... *\/} * </DataTableRoot> * </DataTableErrorBoundary> * * @example * // With error logging * <DataTableErrorBoundary * onError={(error, errorInfo) => { * console.error("DataTable Error:", error, errorInfo) * // Send to error tracking service * trackError(error) * }} * > * <DataTableRoot data={data} columns={columns}> * {/* ... *\/} * </DataTableRoot> * </DataTableErrorBoundary> */export class DataTableErrorBoundary extends React.Component< DataTableErrorBoundaryProps, DataTableErrorBoundaryState> { static displayName = "DataTableErrorBoundary"
constructor(props: DataTableErrorBoundaryProps) { super(props) this.state = { hasError: false, error: null } }
static getDerivedStateFromError(error: Error): DataTableErrorBoundaryState { return { hasError: true, error } }
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { console.error("DataTable Error Boundary caught an error:", error, errorInfo) this.props.onError?.(error, errorInfo) }
handleReset = () => { this.setState({ hasError: false, error: null }) }
render() { if (this.state.hasError) { // Use custom fallback if provided if (this.props.fallback) { return this.props.fallback }
const { showResetButton = true, resetButtonText = "Try Again" } = this.props
// Default error UI return ( <Alert variant="destructive" className="my-4"> <AlertCircle className="h-4 w-4" /> <AlertTitle>Table Error</AlertTitle> <AlertDescription className="mt-2 flex flex-col gap-2"> <p> {this.state.error?.message || "Something went wrong while displaying the table."} </p> {showResetButton && ( <Button variant="outline" size="sm" onClick={this.handleReset} className="mt-2 w-fit" > {resetButtonText} </Button> )} </AlertDescription> </Alert> ) }
return this.props.children }}"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import type { Column } from "@tanstack/react-table"
import { cn } from "@/lib/utils"
// ============================================================================// CONTEXT// ============================================================================
interface TableColumnHeaderContextValue<TData, TValue> { column: Column<TData, TValue>}
const TableColumnHeaderContext = React.createContext< TableColumnHeaderContextValue<unknown, unknown> | undefined>(undefined)
export function useColumnHeaderContext<TData, TValue>( required: true,): TableColumnHeaderContextValue<TData, TValue>export function useColumnHeaderContext<TData, TValue>( required: false,): TableColumnHeaderContextValue<TData, TValue> | undefinedexport function useColumnHeaderContext<TData, TValue>(required = true) { const context = React.useContext(TableColumnHeaderContext) as | TableColumnHeaderContextValue<TData, TValue> | undefined
if (required && !context) { throw new Error( "useColumnHeaderContext must be used within DataTableColumnHeaderRoot", ) } return context}
// ============================================================================// CONTEXT PROVIDER// ============================================================================
/** * Provider for column header context. * Used internally by DataTableHeader to provide context to composable header components. */export function DataTableColumnHeaderRoot<TData, TValue>({ column, children,}: { column: Column<TData, TValue> children: React.ReactNode}) { // Memoize so context subscribers only re-render when `column` identity changes. const contextValue = React.useMemo( () => ({ column }) as TableColumnHeaderContextValue<unknown, unknown>, [column], ) return ( <TableColumnHeaderContext.Provider value={contextValue}> {children} </TableColumnHeaderContext.Provider> )}
// ============================================================================// ROOT COMPONENT// ============================================================================
export type DataTableColumnHeaderProps = React.HTMLAttributes<HTMLDivElement>
/** * Composable Column Header container. */export function DataTableColumnHeader({ className, children, ...props}: DataTableColumnHeaderProps) { return ( <div className={cn( "group flex w-full items-center justify-between gap-1", className, )} {...props} > {children} </div> )}
DataTableColumnHeaderRoot.displayName = "DataTableColumnHeaderRoot"DataTableColumnHeader.displayName = "DataTableColumnHeader""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"
import { TableColumnTitle } from "../filters/table-column-title"import { useColumnHeaderContext } from "./data-table-column-header"
/** * Renders the column title using context. */export function DataTableColumnTitle<TData, TValue>( props: Omit<React.ComponentProps<typeof TableColumnTitle>, "column">,) { const { column } = useColumnHeaderContext<TData, TValue>(true) return <TableColumnTitle column={column} {...props} />}
DataTableColumnTitle.displayName = "DataTableColumnTitle""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"
import { TableColumnActions } from "../filters/table-column-actions"import { useColumnHeaderContext } from "./data-table-column-header"
/** * Composable container for column actions. * * Uses column context to automatically detect active states (pinned, sorted, etc.). * * @example * ```tsx * <DataTableColumnActions> * <DataTableColumnSortOptions /> * <DataTableColumnPinOptions /> * <DataTableColumnHideOptions /> * </DataTableColumnActions> * ``` */export function DataTableColumnActions<TData, TValue>( props: Omit<React.ComponentProps<typeof TableColumnActions>, "isActive"> & { /** Override to manually set active state */ isActive?: boolean },) { const context = useColumnHeaderContext<TData, TValue>(false)
// Auto-detect active state from column context const autoIsActive = context?.column ? !!( context.column.getIsSorted() || context.column.getIsPinned() || context.column.getIsFiltered() ) : false
const isActive = props.isActive ?? autoIsActive
return <TableColumnActions {...props} isActive={isActive} />}
DataTableColumnActions.displayName = "DataTableColumnActions""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"
import { cn } from "@/lib/utils"
/** * Wrapper for groups of column filters. */export function DataTableColumnFilter({ children, className,}: { children?: React.ReactNode className?: string}) { if (children) { return <div className={cn("flex items-center", className)}>{children}</div> } return null}
DataTableColumnFilter.displayName = "DataTableColumnFilter""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import { cn } from "@/lib/utils"
export interface DataTableToolbarSectionProps extends React.ComponentProps<"div"> { children?: React.ReactNode}
/** * A simple, flexible toolbar container for composing table controls. * Use this as a layout container and add your own search, filters, sorting, etc. * * @example - Basic toolbar with search and filters * <DataTableToolbarSection> * <DataTableSearchInput placeholder="Search..." /> * <DataTableFilterButton column="status" title="Status" /> * <DataTableSortMenu /> * </DataTableToolbarSection> * * @example - Custom layout with left and right sections * <DataTableToolbarSection className="justify-between"> * <div className="flex gap-2"> * <DataTableSearchInput /> * <DataTableFilterButton column="status" /> * </div> * <div className="flex gap-2"> * <DataTableSortMenu /> * <DataTableViewMenu /> * </div> * </DataTableToolbarSection> * * @example - With custom elements * <DataTableToolbarSection> * <DataTableSearchInput /> * <span className="text-sm text-muted-foreground"> * {table.getFilteredRowModel().rows.length} results * </span> * <Button variant="outline">Export</Button> * </DataTableToolbarSection> */
const DataTableToolbarSectionInternal = React.forwardRef< HTMLDivElement, DataTableToolbarSectionProps>(({ children, className, ...props }, ref) => { return ( <div ref={ref} role="toolbar" aria-orientation="horizontal" className={cn("flex w-full flex-wrap items-center gap-2 p-1", className)} {...props} > {children} </div> )})
DataTableToolbarSectionInternal.displayName = "DataTableToolbarSectionInternal"
// Memoized so table-state changes don't re-render unchanged toolbars.export const DataTableToolbarSection = React.memo( DataTableToolbarSectionInternal,)
DataTableToolbarSection.displayName = "DataTableToolbarSection""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import { cn } from "@/lib/utils"
// ============================================================================// Context for Empty State// ============================================================================
interface DataTableEmptyStateContextValue { isFiltered: boolean}
const DataTableEmptyStateContext = React.createContext<DataTableEmptyStateContextValue | null>(null)
function useDataTableEmptyState() { const context = React.useContext(DataTableEmptyStateContext) if (!context) { throw new Error( "Empty state components must be used within DataTableEmptyState", ) } return context}
// ============================================================================// Empty State Root// ============================================================================
export interface DataTableEmptyStateProps { children: React.ReactNode isFiltered: boolean className?: string}
/** * Root component for empty state composition. * Provides context to child components about filter state. * * @internal - Used by DataTableEmptyBody and DataTableVirtualizedEmptyBody */export function DataTableEmptyState({ children, isFiltered, className,}: DataTableEmptyStateProps) { return ( <DataTableEmptyStateContext.Provider value={{ isFiltered }}> <div className={cn( "flex flex-col items-center justify-center gap-3 py-4", className, )} > {children} </div> </DataTableEmptyStateContext.Provider> )}
// ============================================================================// Empty State Icon// ============================================================================
export interface DataTableEmptyIconProps { children: React.ReactNode className?: string}
/** * Icon component for empty state. Memoized so table-state changes don't re-render it. * * @example * <DataTableEmptyIcon> * <PackageOpen /> * </DataTableEmptyIcon> */export const DataTableEmptyIcon = React.memo(function DataTableEmptyIcon({ children, className,}: DataTableEmptyIconProps) { return ( <div className={cn("text-muted-foreground/50", className)}>{children}</div> )})
DataTableEmptyIcon.displayName = "DataTableEmptyIcon"
// ============================================================================// Empty State Message// ============================================================================
export interface DataTableEmptyMessageProps { children: React.ReactNode className?: string}
/** * Message component for empty state. Memoized; renders only when not filtered. * * @example * <DataTableEmptyMessage> * <p className="font-semibold">No products found</p> * <p className="text-sm text-muted-foreground"> * Get started by adding your first product * </p> * </DataTableEmptyMessage> */export const DataTableEmptyMessage = React.memo(function DataTableEmptyMessage({ children, className,}: DataTableEmptyMessageProps) { const { isFiltered } = useDataTableEmptyState()
if (isFiltered) return null
return ( <div className={cn( "flex flex-col items-center gap-1 text-center text-muted-foreground", className, )} > {children} </div> )})
DataTableEmptyMessage.displayName = "DataTableEmptyMessage"
// ============================================================================// Empty State Filtered Message// ============================================================================
export interface DataTableEmptyFilteredMessageProps { children: React.ReactNode className?: string}
/** * Filtered-state message — renders only when a filter is active. Memoized. * * @example * <DataTableEmptyFilteredMessage> * No matches found for your search * </DataTableEmptyFilteredMessage> */export const DataTableEmptyFilteredMessage = React.memo( function DataTableEmptyFilteredMessage({ children, className, }: DataTableEmptyFilteredMessageProps) { const { isFiltered } = useDataTableEmptyState()
if (!isFiltered) return null
return ( <div className={cn( "flex flex-col items-center gap-1 text-center text-muted-foreground", className, )} > {children} </div> ) },)
DataTableEmptyFilteredMessage.displayName = "DataTableEmptyFilteredMessage"
// ============================================================================// Empty State Actions// ============================================================================
export interface DataTableEmptyActionsProps { children: React.ReactNode className?: string}
/** * Actions component for empty state. * Displays action buttons or links (e.g., "Add Item", "Clear Filters"). * Memoized to prevent unnecessary re-renders. * * @example * <DataTableEmptyActions> * <Button onClick={handleAdd}>Add Product</Button> * </DataTableEmptyActions> */export const DataTableEmptyActions = React.memo(function DataTableEmptyActions({ children, className,}: DataTableEmptyActionsProps) { return <div className={cn("mt-2 flex gap-2", className)}>{children}</div>})
DataTableEmptyActions.displayName = "DataTableEmptyActions"
// ============================================================================// Convenience Components// ============================================================================
export interface DataTableEmptyTitleProps { children: React.ReactNode className?: string}
/** * Title component for empty state messages. * Convenience wrapper for consistent title styling. * Memoized to prevent unnecessary re-renders. * * @example * <DataTableEmptyMessage> * <DataTableEmptyTitle>No products found</DataTableEmptyTitle> * <DataTableEmptyDescription> * Get started by adding your first product * </DataTableEmptyDescription> * </DataTableEmptyMessage> */export const DataTableEmptyTitle = React.memo(function DataTableEmptyTitle({ children, className,}: DataTableEmptyTitleProps) { return <p className={cn("font-semibold", className)}>{children}</p>})
DataTableEmptyTitle.displayName = "DataTableEmptyTitle"
export interface DataTableEmptyDescriptionProps { children: React.ReactNode className?: string}
/** * Description component for empty state messages. * Convenience wrapper for consistent description styling. * Memoized to prevent unnecessary re-renders. * * @example * <DataTableEmptyMessage> * <DataTableEmptyTitle>No products found</DataTableEmptyTitle> * <DataTableEmptyDescription> * Get started by adding your first product * </DataTableEmptyDescription> * </DataTableEmptyMessage> */export const DataTableEmptyDescription = React.memo( function DataTableEmptyDescription({ children, className, }: DataTableEmptyDescriptionProps) { return ( <p className={cn("text-sm text-muted-foreground", className)}> {children} </p> ) },)
DataTableEmptyDescription.displayName = "DataTableEmptyDescription""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import { MoreVertical } from "lucide-react"
import { Button } from "@/components/ui/button"import { DropdownMenu, DropdownMenuContent, DropdownMenuLabel, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu"import { cn } from "@/lib/utils"
export interface TableColumnActionsProps { children: React.ReactNode className?: string /** * Optional label shown at the top of the dropdown. * @default "Column Actions" */ label?: string /** * Whether to show a visual indicator when actions are active. */ isActive?: boolean /** * Custom trigger element. If not provided, uses a MoreVertical icon button. */ trigger?: React.ReactNode /** * Alignment of the dropdown content. * @default "end" */ align?: "start" | "center" | "end"}
/** * A simple dropdown container for composing column actions. * * Use with `*Options` components to compose actions in a single dropdown: * * @example * ```tsx * <TableColumnActions> * <TableColumnSortOptions /> * <TableColumnPinOptions /> * <TableColumnHideOptions /> * </TableColumnActions> * ``` * * For standalone dropdowns, use the `*Menu` variants instead: * ```tsx * <TableColumnSortMenu /> * <TableColumnPin /> * ``` */export function TableColumnActions({ children, className, label = "Column Actions", isActive = false, trigger, align = "end",}: TableColumnActionsProps) { return ( <DropdownMenu> <DropdownMenuTrigger asChild> {trigger ?? ( <Button variant="ghost" size="icon" className={cn( "size-7 transition-opacity group-hover:opacity-100 dark:text-muted-foreground", isActive ? "text-primary opacity-100" : "opacity-0", className, )} > <MoreVertical className="size-4" /> <span className="sr-only">{label}</span> </Button> )} </DropdownMenuTrigger> <DropdownMenuContent align={align} className="w-48"> <DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> {label} </DropdownMenuLabel> {children} </DropdownMenuContent> </DropdownMenu> )}
TableColumnActions.displayName = "TableColumnActions""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import type { Column } from "@tanstack/react-table"import { cn } from "@/lib/utils"import { useDerivedColumnTitle } from "../hooks/use-derived-column-title"
/** * Renders the column title. */export function TableColumnTitle<TData, TValue>({ column, title, className, children,}: { column: Column<TData, TValue> title?: string className?: string children?: React.ReactNode}) { const derivedTitle = useDerivedColumnTitle(column, column.id, title)
return ( <div className={cn( "truncate py-0.5 text-sm font-semibold transition-colors", className, )} > {children ?? derivedTitle} </div> )}
TableColumnTitle.displayName = "TableColumnTitle"/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
import { useEffect, useState } from "react"
/** * Debounces a value by delaying updates until after a specified delay period. * * @template T - The type of the value to debounce * @param value - The value to debounce * @param delay - The delay in milliseconds before updating the debounced value (default: 300ms) * @returns The debounced value * * @example * // Basic usage with search input * function SearchFilter() { * const [search, setSearch] = useState("") * const debouncedSearch = useDebounce(search, 500) * * useEffect(() => { * // This only runs after user stops typing for 500ms * console.log("Searching for:", debouncedSearch) * }, [debouncedSearch]) * * return ( * <input * value={search} * onChange={(e) => setSearch(e.target.value)} * placeholder="Search..." * /> * ) * } * * @example * // With API calls * function ProductSearch() { * const [query, setQuery] = useState("") * const debouncedQuery = useDebounce(query, 300) * * useEffect(() => { * if (debouncedQuery) { * // API call only happens after 300ms of no typing * fetchProducts(debouncedQuery).then(setProducts) * } * }, [debouncedQuery]) * * return <input value={query} onChange={(e) => setQuery(e.target.value)} /> * } * * @example * // With table filtering * function DataTableWithDebounce() { * const [filterValue, setFilterValue] = useState("") * const debouncedFilter = useDebounce(filterValue, 400) * * return ( * <DataTableRoot * data={data} * columns={columns} * onGlobalFilterChange={debouncedFilter} * > * <DataTableToolbarSection> * <input * value={filterValue} * onChange={(e) => setFilterValue(e.target.value)} * /> * </DataTableToolbarSection> * <DataTable> * <DataTableHeader /> * <DataTableBody /> * </DataTable> * </DataTableRoot> * ) * } */export function useDebounce<T>(value: T, delay = 300): T { const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value) }, delay) return () => { clearTimeout(handler) } }, [value, delay])
return debouncedValue}/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
import type { Column } from "@tanstack/react-table"import * as React from "react"import { formatLabel } from "../lib/format"
/** * A hook that derives the title for a column filter component. * It follows this priority order: * 1. Provided title prop * 2. Column metadata label (column.columnDef.meta?.label) * 3. Formatted accessor key * * @param column - The table column * @param accessorKey - The accessor key of the column * @param title - Optional title override * @returns The derived title string * * @example * const derivedTitle = useDerivedColumnTitle(column, "firstName", "First Name") * Returns "First Name" * * @example - With column.meta.label = "First Name" * const derivedTitle = useDerivedColumnTitle(column, "firstName") * Returns "First Name" from metadata * * @example - Without title or metadata * const derivedTitle = useDerivedColumnTitle(column, "first_name") * Returns "First Name" (formatted from accessorKey) */export function useDerivedColumnTitle<TData>( column: Column<TData, unknown> | undefined, accessorKey: string, title?: string,): string { return React.useMemo(() => { if (title) return title if (!column) return formatLabel(accessorKey) const label = column.columnDef.meta?.label return label ?? formatLabel(accessorKey) }, [title, column, accessorKey])}"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import * as React from "react"import type { Table } from "@tanstack/react-table"
import type { Option } from "../types"import { formatLabel } from "../lib/format"import { FILTER_VARIANTS } from "../lib/constants"import { getFilteredRowsExcludingColumn } from "../lib/filter-rows"
export interface GenerateOptionsConfig { /** * Whether to include counts for each option label * @default true */ showCounts?: boolean /** * If true, recompute counts based on the filtered rows; otherwise use all core rows * @default true */ dynamicCounts?: boolean /** * If true, only generate options from filtered rows. If false, generate from all rows. * This controls which rows are used to generate the option list itself. * Note: This is separate from dynamicCounts which controls count calculation. * @default true */ limitToFilteredRows?: boolean /** * Only generate options for these column ids (if provided) */ includeColumns?: string[] /** * Exclude these column ids from option generation */ excludeColumns?: string[] /** * Optional cap on number of options per column (after sorting) */ limitPerColumn?: number}
/** * Generate a map of options for select/multiSelect columns based on table data. * Uses either filtered rows (dynamicCounts) or all core rows. */export function useGeneratedOptions<TData>( table: Table<TData>, config: GenerateOptionsConfig = {},): Record<string, Option[]> { const { showCounts = true, dynamicCounts = true, limitToFilteredRows = true, includeColumns, excludeColumns, limitPerColumn, } = config
// Pull state slices to use as memo deps (stable values) const state = table.getState() const columnFilters = state.columnFilters const globalFilter = state.globalFilter
// `table.options.columns` in deps so updated `meta.options` (e.g. from // server-side facets) invalidate the memo — `table` ref alone is too stable. const columns = React.useMemo( () => table.getAllColumns(), [table, table.options.columns], )
// Extract `coreRows` so async-data row-array identity changes drive recompute; // the `table` ref is stable and would otherwise hold stale (empty) results. const coreRows = table.getCoreRowModel().rows
// Normalize array deps to stable strings for React hook linting const includeKey = includeColumns?.join(",") ?? "" const excludeKey = excludeColumns?.join(",") ?? ""
// Expensive: walks all columns × all rows (~50-100ms at 1k rows × 5 selects). // Memoize so generation only runs when columns, filters, or config change. const optionsByColumn = React.useMemo(() => { const result: Record<string, Option[]> = {}
// Note: row selection is done per-column based on overrides
for (const column of columns) { const meta = column.columnDef.meta ?? {} const variant = meta.variant ?? FILTER_VARIANTS.TEXT
// Only generate for select-like variants if ( variant !== FILTER_VARIANTS.SELECT && variant !== FILTER_VARIANTS.MULTI_SELECT ) continue
const colId = column.id
if (includeColumns && !includeColumns.includes(colId)) continue if (excludeColumns && excludeColumns.includes(colId)) continue
// Respect per-column overrides const colAutoOptions = meta.autoOptions ?? true const colShowCounts = meta.showCounts ?? showCounts const colDynamicCounts = meta.dynamicCounts ?? dynamicCounts const colMerge = meta.mergeStrategy const colAutoOptionsFormat = meta.autoOptionsFormat ?? true
if (!colAutoOptions) { result[column.id] = meta.options ?? [] continue }
// `limitToFilteredRows` selects rows for option discovery; `dynamicCounts` // selects rows for count computation. Both exclude this column's own // filter. When both are true, compute once and reuse — was a double walk. const filteredRowsExcl = limitToFilteredRows || colDynamicCounts ? getFilteredRowsExcludingColumn( table, coreRows, colId, columnFilters, globalFilter, ) : coreRows const optionSourceRows = limitToFilteredRows ? filteredRowsExcl : coreRows const countSourceRows = colDynamicCounts ? filteredRowsExcl : coreRows
// If we have static options with augment strategy, we use static options and only calculate counts if (meta.options && meta.options.length > 0 && colMerge === "augment") { // Calculate counts from countSourceRows for all static options const countMap = new Map<string, number>() for (const row of countSourceRows) { const raw = row.getValue(colId as string) as unknown const values: unknown[] = Array.isArray(raw) ? raw : [raw] for (const v of values) { if (v === null || v === undefined) continue const str = String(v) if (str.trim() === "") continue countMap.set(str, (countMap.get(str) ?? 0) + 1) } }
// If limitToFilteredRows is true, we should only return static options that have counts > 0 // in the optionSourceRows. let filteredStaticOptions = meta.options if (limitToFilteredRows) { const occurrenceMap = new Map<string, boolean>() for (const row of optionSourceRows) { const raw = row.getValue(colId as string) as unknown const values: unknown[] = Array.isArray(raw) ? raw : [raw] for (const v of values) { if (v == null) continue occurrenceMap.set(String(v), true) } } filteredStaticOptions = meta.options.filter((opt: Option) => occurrenceMap.has(opt.value), ) }
// Fresh `countMap` always wins. The wrapper component mutates // `meta.options` to inject counts, so on subsequent renders // `opt.count` here is whatever was pinned last render — using it // would freeze counts at their first-render value. // Server-side tables that need true dataset-wide counts should pass // them through the faceted column-header `options` prop instead, // where caller-supplied counts are honored without mutation. result[colId] = filteredStaticOptions.map((opt: Option) => ({ ...opt, count: colShowCounts ? (countMap.get(opt.value) ?? 0) : undefined, })) continue }
// For auto-generated options, discover from optionSourceRows const optionValues = new Set<string>() for (const row of optionSourceRows) { const raw = row.getValue(colId as string) as unknown
// Support array values (multi-select like arrays on the row) const values: unknown[] = Array.isArray(raw) ? raw : [raw]
for (const v of values) { if (v === null || v === undefined) continue const str = String(v) if (str.trim() === "") continue optionValues.add(str) } }
// If we couldn't derive anything, skip (caller may still have static options) if (optionValues.size === 0) { result[colId] = [] continue }
// Compute counts from countSourceRows const counts = new Map<string, number>() for (const row of countSourceRows) { const raw = row.getValue(colId as string) as unknown const values: unknown[] = Array.isArray(raw) ? raw : [raw] for (const v of values) { if (v === null || v === undefined) continue const str = String(v) if (str.trim() === "") continue if (optionValues.has(str)) { counts.set(str, (counts.get(str) ?? 0) + 1) } } }
const options: Option[] = Array.from(optionValues) .map(value => ({ value, label: colAutoOptionsFormat ? formatLabel(value) : value, count: colShowCounts ? (counts.get(value) ?? 0) : undefined, })) .sort((a, b) => a.label.localeCompare(b.label))
const finalOptions = typeof limitPerColumn === "number" && limitPerColumn > 0 ? options.slice(0, limitPerColumn) : options
// If static options exist and strategy is preserve, keep them untouched. // Per docs, `preserve` returns user-defined options as-is; counts are only // injected by `augment`. We still respect limitToFilteredRows to hide // options whose value is not present in the current option-source rows. if ( meta.options && meta.options.length > 0 && (!colMerge || colMerge === "preserve") ) { if (limitToFilteredRows) { const availableOptions = new Set<string>() for (const row of optionSourceRows) { const raw = row.getValue(colId as string) as unknown const values: unknown[] = Array.isArray(raw) ? raw : [raw] for (const v of values) { if (v != null) availableOptions.add(String(v)) } } result[colId] = meta.options.filter((opt: Option) => availableOptions.has(opt.value), ) } else { result[colId] = meta.options } continue }
// Else, replace with generated result[colId] = finalOptions }
return result // eslint-disable-next-line react-hooks/exhaustive-deps }, [ columns, coreRows, table, dynamicCounts, showCounts, includeKey, excludeKey, limitPerColumn, limitToFilteredRows, // Recompute when filters/global filter change to keep counts in sync columnFilters, globalFilter, ])
return optionsByColumn}
/** * Convenience: generate options only for a specific column id */export function useGeneratedOptionsForColumn<TData>( table: Table<TData>, columnId: string, config?: GenerateOptionsConfig,): Option[] { const map = useGeneratedOptions(table, { ...config, includeColumns: [columnId], }) return map[columnId] ?? []}/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
import { useEffect, useCallback, useLayoutEffect, useRef } from "react"
export interface UseKeyboardShortcutOptions { /** * The key to listen for (e.g., 'f', 's', 'Enter') */ key: string
/** * Function to call when the shortcut is triggered */ onTrigger: () => void
/** * Whether the shortcut is enabled * @default true */ enabled?: boolean
/** * Whether to require Shift key * @default false */ requireShift?: boolean
/** * Whether to require Ctrl/Cmd key * @default false */ requireCtrl?: boolean
/** * Whether to require Alt key * @default false */ requireAlt?: boolean
/** * Whether to prevent default browser behavior * @default true */ preventDefault?: boolean
/** * Whether to stop event propagation * @default false */ stopPropagation?: boolean
/** * Condition function to determine if shortcut should trigger * Useful for checking if modals are open, inputs are focused, etc. */ condition?: () => boolean}
/** * Hook for managing keyboard shortcuts with fine-grained control * * @example * ```tsx * // Simple shortcut * useKeyboardShortcut({ * key: 'f', * onTrigger: () => setFilterOpen(true) * }) * * // Toggle behavior with condition * useKeyboardShortcut({ * key: 's', * onTrigger: () => setSortOpen(prev => !prev), * condition: () => !isInputFocused * }) * * // Shift + key combination * useKeyboardShortcut({ * key: 'f', * requireShift: true, * onTrigger: () => clearAllFilters() * }) * ``` */export function useKeyboardShortcut({ key, onTrigger, enabled = true, requireShift = false, requireCtrl = false, requireAlt = false, preventDefault = true, stopPropagation = false, condition,}: UseKeyboardShortcutOptions) { // Mirror params into a ref so callers can pass inline `onTrigger` / // `condition` without the listener detaching every render. const paramsRef = useRef({ key, onTrigger, enabled, requireShift, requireCtrl, requireAlt, preventDefault, stopPropagation, condition, }) useLayoutEffect(() => { paramsRef.current = { key, onTrigger, enabled, requireShift, requireCtrl, requireAlt, preventDefault, stopPropagation, condition, } })
const handleKeyDown = useCallback((event: KeyboardEvent) => { const p = paramsRef.current if (!p.enabled) return
if (event.key.toLowerCase() !== p.key.toLowerCase()) return
if (p.requireShift && !event.shiftKey) return if (p.requireCtrl && !(event.ctrlKey || event.metaKey)) return if (p.requireAlt && !event.altKey) return
if (!p.requireShift && event.shiftKey) return if (!p.requireCtrl && (event.ctrlKey || event.metaKey)) return if (!p.requireAlt && event.altKey) return
if (p.condition && !p.condition()) return
if ( event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || (event.target as HTMLElement)?.isContentEditable ) { return }
if (p.preventDefault) event.preventDefault() if (p.stopPropagation) event.stopPropagation() p.onTrigger() }, [])
useEffect(() => { // Attach unconditionally — handler short-circuits on `enabled === false`. window.addEventListener("keydown", handleKeyDown) return () => window.removeEventListener("keydown", handleKeyDown) }, [handleKeyDown])}
/** * Hook for managing multiple keyboard shortcuts at once * * @example * ```tsx * useKeyboardShortcuts([ * { key: 'f', onTrigger: () => setFilterOpen(true) }, * { key: 's', onTrigger: () => setSortOpen(prev => !prev) }, * { key: 'f', requireShift: true, onTrigger: () => clearFilters() } * ]) * ``` */export function useKeyboardShortcuts(shortcuts: UseKeyboardShortcutOptions[]) { // Mirror `shortcuts` into a ref so callers can pass inline array literals // without the window-level listener detaching on every render. const shortcutsRef = useRef(shortcuts)
useLayoutEffect(() => { shortcutsRef.current = shortcuts })
const handleKeyDown = useCallback((event: KeyboardEvent) => { // Check each shortcut for (const shortcut of shortcutsRef.current) { const { key, onTrigger, enabled = true, requireShift = false, requireCtrl = false, requireAlt = false, preventDefault = true, stopPropagation = false, condition, } = shortcut
// Skip if disabled if (!enabled) continue
// Skip if wrong key if (event.key.toLowerCase() !== key.toLowerCase()) continue
// Skip if modifier requirements not met if (requireShift && !event.shiftKey) continue if (requireCtrl && !(event.ctrlKey || event.metaKey)) continue if (requireAlt && !event.altKey) continue
// Skip if modifiers are present when not required if (!requireShift && event.shiftKey) continue if (!requireCtrl && (event.ctrlKey || event.metaKey)) continue if (!requireAlt && event.altKey) continue
// Skip if custom condition fails if (condition && !condition()) continue
// Skip if user is typing in an input field if ( event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLSelectElement || (event.target as HTMLElement)?.isContentEditable ) { continue }
// Prevent default behavior if requested if (preventDefault) { event.preventDefault() }
// Stop propagation if requested if (stopPropagation) { event.stopPropagation() }
// Trigger the callback and break (only one shortcut should trigger) onTrigger() break } }, [])
useEffect(() => { // Attach unconditionally — the handler short-circuits per-shortcut on // `enabled === false`, so an idle listener is free. window.addEventListener("keydown", handleKeyDown)
return () => { window.removeEventListener("keydown", handleKeyDown) } }, [handleKeyDown])}/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
/** * Data table constants * @description Centralized constants for the data table components */
/** * Join operator constants for combining multiple filters. */export const JOIN_OPERATORS = { /** Logical AND (all filters must match) */ AND: "and", /** Logical OR (any filter can match) */ OR: "or", /** Mixed logic (combination of AND/OR) */ MIXED: "mixed",} as const
/** * Filter operator constants defining the comparison logic. * Naming follows SQL/PostgREST standards (ilike, eq, ne, etc.). */export const FILTER_OPERATORS = { /** SQL ILIKE (Case-insensitive search) */ ILIKE: "ilike", /** SQL NOT ILIKE */ NOT_ILIKE: "not.ilike", /** SQL EQUAL (=) */ EQ: "eq", /** SQL NOT EQUAL (!=) */ NEQ: "neq", /** SQL IN (one of) */ IN: "in", /** SQL NOT IN (none of) */ NOT_IN: "not.in", /** Value is null or empty string */ EMPTY: "empty", /** Value is not null and not empty string */ NOT_EMPTY: "not.empty", /** SQL LESS THAN (<) */ LT: "lt", /** SQL LESS THAN OR EQUAL (<=) */ LTE: "lte", /** SQL GREATER THAN (>) */ GT: "gt", /** SQL GREATER THAN OR EQUAL (>=) */ GTE: "gte", /** SQL BETWEEN (range) */ BETWEEN: "between", /** Relative date calculation (e.g., "today", "last-7-days") */ RELATIVE: "relative",} as const
/** * Filter variant constants defining the UI control type. */export const FILTER_VARIANTS = { /** Standard text input */ TEXT: "text", /** Numeric input */ NUMBER: "number", /** Two-value range input */ RANGE: "range", /** Single date picker */ DATE: "date", /** Date range picker */ DATE_RANGE: "dateRange", /** Single select dropdown */ SELECT: "select", /** Multi-select dropdown */ MULTI_SELECT: "multiSelect", /** Checkbox or toggle */ BOOLEAN: "boolean",} as const
// ============================================================================// DERIVED TYPES// ============================================================================
/** Join operators for combining multiple filters */export type JoinOperator = (typeof JOIN_OPERATORS)[keyof typeof JOIN_OPERATORS]
/** Filter operators supported by the data table */export type FilterOperator = (typeof FILTER_OPERATORS)[keyof typeof FILTER_OPERATORS]
/** Filter variants supported by the data table (UI control type) */export type FilterVariant = (typeof FILTER_VARIANTS)[keyof typeof FILTER_VARIANTS]
// ============================================================================// DEFAULT VALUES & UI CONFIG// ============================================================================
/** Global default values */export const DEFAULT_VALUES = { JOIN_OPERATOR: JOIN_OPERATORS.AND, PAGE_SIZE: 10, PAGE_INDEX: 0,} as const
/** System column IDs - used for smart pinning and feature detection */export const SYSTEM_COLUMN_IDS = { /** Row selection checkbox column */ SELECT: "select", /** Row expand/collapse column */ EXPAND: "expand", /** Row actions column (edit, delete, etc.) */ ACTIONS: "actions",} as const
/** Array of all system column IDs for filtering */export const SYSTEM_COLUMN_ID_LIST: string[] = [ SYSTEM_COLUMN_IDS.SELECT, SYSTEM_COLUMN_IDS.EXPAND,]
/** UI-related constraints and settings */export const UI_CONSTANTS = { /** Max characters allowed for a filter ID */ FILTER_ID_MAX_LENGTH: 100, /** Default max height for scrollable filter popovers */ MAX_FILTER_DISPLAY_HEIGHT: 300, /** Default debounce delay in milliseconds for search inputs */ DEBOUNCE_DELAY: 300,} as const
/** Default keyboard shortcut key mappings */export const KEYBOARD_SHORTCUTS = { /** Open/Toggle filter menu */ FILTER_TOGGLE: "f", /** Remove active filter (usually combined with Shift) */ FILTER_REMOVE: "f", /** Close active UI elements */ ESCAPE: "escape", /** Confirm or submit active action */ ENTER: "enter", /** Remove character or navigate back */ BACKSPACE: "backspace", /** Item deletion */ DELETE: "delete",} as const
/** Standard internalized error messages */export const ERROR_MESSAGES = { /** Thrown when using the old global operator pattern */ DEPRECATED_GLOBAL_JOIN_OPERATOR: "Global join operator is deprecated. Use individual filter join operators.", /** General configuration error */ INVALID_FILTER_CONFIGURATION: "Invalid filter configuration provided.", /** Thrown when mandatory metadata is missing from columns */ MISSING_COLUMN_META: "Column metadata is required for filtering.",} as const/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
import { dataTableConfig } from "../config/data-table"import { FILTER_OPERATORS, FILTER_VARIANTS, JOIN_OPERATORS } from "./constants"import type { ExtendedColumnFilter, FilterOperator, FilterVariant,} from "../types"
export function getFilterOperators(filterVariant: FilterVariant) { const operatorMap: Record< FilterVariant, { label: string; value: FilterOperator }[] > = { [FILTER_VARIANTS.TEXT]: dataTableConfig.textOperators, [FILTER_VARIANTS.NUMBER]: dataTableConfig.numericOperators, [FILTER_VARIANTS.RANGE]: dataTableConfig.numericOperators, [FILTER_VARIANTS.DATE]: dataTableConfig.dateOperators, [FILTER_VARIANTS.DATE_RANGE]: dataTableConfig.dateOperators, [FILTER_VARIANTS.BOOLEAN]: dataTableConfig.booleanOperators, [FILTER_VARIANTS.SELECT]: dataTableConfig.selectOperators, [FILTER_VARIANTS.MULTI_SELECT]: dataTableConfig.multiSelectOperators, }
return operatorMap[filterVariant] ?? dataTableConfig.textOperators}
export function getDefaultFilterOperator(filterVariant: FilterVariant) { const operators = getFilterOperators(filterVariant)
return ( operators[0]?.value ?? (filterVariant === FILTER_VARIANTS.TEXT ? FILTER_OPERATORS.ILIKE : FILTER_OPERATORS.EQ) )}
export function getValidFilters<TData>( filters: ExtendedColumnFilter<TData>[],): ExtendedColumnFilter<TData>[] { return filters.filter(filter => { // isEmpty and isNotEmpty don't need values if ( filter.operator === FILTER_OPERATORS.EMPTY || filter.operator === FILTER_OPERATORS.NOT_EMPTY ) { return true }
// For array values (like isBetween with range [min, max]) if (Array.isArray(filter.value)) { // All array elements must be non-empty return ( filter.value.length > 0 && filter.value.every( val => val !== "" && val !== null && val !== undefined, ) ) }
// For non-array values return ( filter.value !== "" && filter.value !== null && filter.value !== undefined ) })}
/** * Process filters to detect OR logic and same-column filters. Auto-converts * same-column AND to OR for UX (e.g. "brand=apple AND brand=samsung" is * impossible). * * @param filters - Array of filters to process * @returns Object with `processedFilters`, `hasOrFilters`, * `hasSameColumnFilters`, `shouldUseGlobalFilter`, and effective `joinOperator`. * * @example * ```ts * const result = processFiltersForLogic(filters) * if (result.shouldUseGlobalFilter) { * setGlobalFilter({ filters: result.processedFilters, joinOperator: result.joinOperator }) * } else { * setColumnFilters(result.processedFilters.map(f => ({ id: f.id, value: f }))) * } * ``` */export function processFiltersForLogic<TData>( filters: ExtendedColumnFilter<TData>[],): { processedFilters: ExtendedColumnFilter<TData>[] hasOrFilters: boolean hasSameColumnFilters: boolean shouldUseGlobalFilter: boolean joinOperator: typeof JOIN_OPERATORS.MIXED | typeof JOIN_OPERATORS.AND} { // Check for explicit OR operators const hasOrFilters = filters.some( (filter, index) => index > 0 && filter.joinOperator === JOIN_OPERATORS.OR, )
// Check for multiple filters on the same column (UX: should use OR logic) const columnIds = filters.map(f => f.id) const hasSameColumnFilters = columnIds.length !== new Set(columnIds).size
// Process filters: convert same-column AND to OR for better UX const processedFilters = hasSameColumnFilters ? filters.map((filter, index) => { // If this is not the first filter and it's on the same column as a previous filter, // convert AND to OR for better UX (same column filters should use OR logic) const previousFilters = filters.slice(0, index) const hasSameColumnBefore = previousFilters.some( f => f.id === filter.id, ) if (hasSameColumnBefore && filter.joinOperator === JOIN_OPERATORS.AND) { return { ...filter, joinOperator: JOIN_OPERATORS.OR } } return filter }) : filters
const shouldUseGlobalFilter = hasOrFilters || hasSameColumnFilters const joinOperator = shouldUseGlobalFilter ? JOIN_OPERATORS.MIXED : JOIN_OPERATORS.AND
return { processedFilters, hasOrFilters, hasSameColumnFilters, shouldUseGlobalFilter, joinOperator, }}/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
import type { FilterFn, RowData } from "@tanstack/react-table"import type { ExtendedColumnFilter, FilterOperator } from "../types"import { JOIN_OPERATORS, FILTER_OPERATORS, FILTER_VARIANTS } from "./constants"
// ============================================================================// Regex Cache for Performance// ============================================================================
// LRU-style regex cache. At 1k rows × 10 cols, naive `new RegExp` per cell// burns 100-500ms per keystroke; reuse drops it to 5-20ms.const regexCache = new Map<string, RegExp>()const MAX_REGEX_CACHE_SIZE = 100
// Module-scoped guard so the RELATIVE-not-implemented warning fires once,// not once per row × filter (would emit thousands of lines).let hasLoggedRelativeFilterWarning = false
// LRU-evicted regex cache lookup.function getOrCreateRegex(pattern: string, flags: string): RegExp { const key = `${pattern}:${flags}`
if (regexCache.has(key)) { const cachedRegex = regexCache.get(key) if (cachedRegex !== undefined) { return cachedRegex } }
// Limit cache size to prevent memory leaks if (regexCache.size >= MAX_REGEX_CACHE_SIZE) { const firstKey = regexCache.keys().next().value if (firstKey !== undefined) { regexCache.delete(firstKey) } }
try { const regex = new RegExp(pattern, flags) regexCache.set(key, regex) return regex } catch { // Return a regex that matches nothing if pattern is invalid const fallbackRegex = /(?!)/ regexCache.set(key, fallbackRegex) return fallbackRegex }}
/** * Custom filter function that handles our extended filter operators */export const extendedFilter: FilterFn<RowData> = ( row, columnId, filterValue, _addMeta,) => { // If no filter value, show all rows if (!filterValue) return true
// Handle our extended filter format if ( typeof filterValue === "object" && filterValue.operator && filterValue.value !== undefined ) { const filter = filterValue as ExtendedColumnFilter<RowData> return applyFilterOperator( row.getValue(columnId), filter.operator, filter.value, ) }
// Handle raw array filter values if (Array.isArray(filterValue)) { const cellValue = row.getValue(columnId) if (cellValue == null) return false
// Handle numeric range arrays [min, max] from slider filters // Check if both values are numbers - if so, treat as range if ( filterValue.length === 2 && typeof filterValue[0] === "number" && typeof filterValue[1] === "number" ) { const [min, max] = filterValue const value = Number(cellValue) if (isNaN(value)) return false return value >= min && value <= max }
// Handle string arrays (from TableFacetedFilter with multiple selection) // When filterValue is an array like ["electronics", "clothing"], check if cell value is in the array
// Case-insensitive comparison for strings if (typeof cellValue === "string") { const cellLower = cellValue.toLowerCase() return filterValue.some(val => typeof val === "string" ? val.toLowerCase() === cellLower : String(val) === cellValue, ) } // For non-string types, convert to string for comparison return filterValue.some(val => String(val) === String(cellValue)) }
// Fallback to default string contains behavior for simple values const cellValue = row.getValue(columnId) if (cellValue == null) return false
try { const cellStr = String(cellValue).toLowerCase() const filterStr = String(filterValue).toLowerCase() const escapedFilter = filterStr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") const regex = getOrCreateRegex(escapedFilter, "i") // ✅ Use cached regex return regex.test(cellStr) } catch { return String(cellValue) .toLowerCase() .includes(String(filterValue).toLowerCase()) }}
/** * Global filter with operator precedence. Supports plain string search, * pure OR, and mixed AND/OR (AND has higher precedence than OR — splits * filters into OR-separated AND-groups). */export const globalFilter: FilterFn<RowData> = ( row, _columnId, filterValue, _addMeta,) => { // If no filter value, show all rows if (!filterValue) return true
// Check if this is a complex filter object (from filter menu) if ( typeof filterValue === "object" && filterValue.filters && Array.isArray(filterValue.filters) ) { const filters = filterValue.filters
// Handle different join operator modes if (filterValue.joinOperator === "or") { // Pure OR logic: at least one filter must match return filters.some((filter: ExtendedColumnFilter<RowData>) => { const cellValue = row.getValue(filter.id) return applyFilterOperator( cellValue as string | number | boolean | null | undefined, filter.operator, filter.value as string | number | boolean | null | undefined, ) }) } else if (filterValue.joinOperator === JOIN_OPERATORS.MIXED) { // Mixed logic: process with proper operator precedence (AND before OR) if (filters.length === 0) return true if (filters.length === 1) { const filter = filters[0] const cellValue = row.getValue(filter.id) return applyFilterOperator( cellValue as string | number | boolean | null | undefined, filter.operator, filter.value as string | number | boolean | null | undefined, ) }
// Apply mathematical precedence: AND has higher precedence than OR // Split filters into OR-separated groups, then AND within each group const orGroups: (typeof filters)[] = [] let currentAndGroup: typeof filters = []
// Add first filter to the first AND group currentAndGroup.push(filters[0])
// Process remaining filters for (let i = 1; i < filters.length; i++) { const filter = filters[i]
if (filter.joinOperator === JOIN_OPERATORS.OR) { // OR breaks the current AND group, start a new one orGroups.push(currentAndGroup) currentAndGroup = [filter] } else { // AND continues the current group currentAndGroup.push(filter) } }
// Add the last group orGroups.push(currentAndGroup)
// Evaluate each OR group (AND logic within each group) const groupResults = orGroups.map(andGroup => { return andGroup.every((filter: ExtendedColumnFilter<RowData>) => { const cellValue = row.getValue(filter.id) return applyFilterOperator( cellValue as string | number | boolean | null | undefined, filter.operator, filter.value as string | number | boolean | null | undefined, ) }) })
// OR all group results together return groupResults.some(result => result) }
// Default to AND logic for other cases return filters.every((filter: ExtendedColumnFilter<RowData>) => { const cellValue = row.getValue(filter.id) return applyFilterOperator( cellValue as string | number | boolean | null | undefined, filter.operator, filter.value as string | number | boolean | null | undefined, ) }) }
// Regular global search (string search across all columns) const searchValue = String(filterValue).toLowerCase()
// Search across all columns that have filtering enabled return row.getAllCells().some(cell => { const column = cell.column
// Skip columns that have filtering disabled if (column.getCanFilter() === false) return false
const cellValue = cell.getValue()
// Skip null/undefined values if (cellValue == null) return false
try { // Convert cell value to string and search using regex const cellStr = String(cellValue).toLowerCase() const escapedFilter = searchValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") const regex = getOrCreateRegex(escapedFilter, "i") // ✅ Use cached regex return regex.test(cellStr) } catch { // Fallback to simple includes if regex fails return String(cellValue).toLowerCase().includes(searchValue) } })}
/** * Apply filter operator to a cell value */function applyFilterOperator( cellValue: string | number | boolean | null | undefined, operator: FilterOperator, filterValue: string | number | boolean | null | undefined | string[],): boolean { // Handle null/undefined cell values if (cellValue == null) { switch (operator) { case FILTER_OPERATORS.EMPTY: return true case FILTER_OPERATORS.NOT_EMPTY: return false default: return false } }
// Convert cell value to string for text operations const cellStr = String(cellValue).toLowerCase() const filterStr = String(filterValue).toLowerCase()
switch (operator) { // Text operators case FILTER_OPERATORS.ILIKE: try { // Escape special regex characters in the filter string const escapedFilter = filterStr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") const regex = getOrCreateRegex(escapedFilter, "i") // ✅ Use cached regex return regex.test(cellStr) } catch { // Fallback to simple includes if regex fails return cellStr.includes(filterStr) }
case FILTER_OPERATORS.NOT_ILIKE: try { // Escape special regex characters in the filter string const escapedFilter = filterStr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") const regex = getOrCreateRegex(escapedFilter, "i") // ✅ Use cached regex return !regex.test(cellStr) } catch { // Fallback to simple includes if regex fails return !cellStr.includes(filterStr) }
case FILTER_OPERATORS.EQ: // Case-insensitive comparison for strings if (typeof cellValue === "string" && typeof filterValue === "string") { return cellStr === filterStr } // Boolean comparison - convert boolean to string for comparison with string filter values // This handles cases where cellValue is boolean (true/false) and filterValue is string ("true"/"false") if (typeof cellValue === "boolean") { const cellBoolStr = String(cellValue) return cellBoolStr === String(filterValue) } if (typeof filterValue === "boolean") { const filterBoolStr = String(filterValue) return filterBoolStr === String(cellValue) } // Date comparison - check if cellValue is a Date object if ( typeof cellValue === "object" && cellValue !== null && "getTime" in cellValue ) { const dateCell = (cellValue as { getTime: () => number }).getTime() const dateFilter = Number(filterValue) // For date equality, compare dates at day level (midnight to midnight) if (!isNaN(dateCell) && !isNaN(dateFilter)) { const cellDate = new Date(dateCell).setHours(0, 0, 0, 0) const filterDate = new Date(dateFilter).setHours(0, 0, 0, 0) return cellDate === filterDate } } // Numeric comparison - convert both to numbers if (typeof cellValue === "number" || typeof filterValue === "number") { const numCell = Number(cellValue) const numFilter = Number(filterValue) // Check for valid numbers before comparing if (!isNaN(numCell) && !isNaN(numFilter)) { return numCell === numFilter } } return cellValue === filterValue
case FILTER_OPERATORS.NEQ: // Case-insensitive comparison for strings if (typeof cellValue === "string" && typeof filterValue === "string") { return cellStr !== filterStr } // Date comparison - check if cellValue is a Date object if ( typeof cellValue === "object" && cellValue !== null && "getTime" in cellValue ) { const dateCell = (cellValue as { getTime: () => number }).getTime() const dateFilter = Number(filterValue) // For date inequality, compare dates at day level (midnight to midnight) if (!isNaN(dateCell) && !isNaN(dateFilter)) { const cellDate = new Date(dateCell).setHours(0, 0, 0, 0) const filterDate = new Date(dateFilter).setHours(0, 0, 0, 0) return cellDate !== filterDate } } // Numeric comparison - convert both to numbers if (typeof cellValue === "number" || typeof filterValue === "number") { const numCell = Number(cellValue) const numFilter = Number(filterValue) // Check for valid numbers before comparing if (!isNaN(numCell) && !isNaN(numFilter)) { return numCell !== numFilter } } return cellValue !== filterValue
case FILTER_OPERATORS.EMPTY: // Check for empty strings and whitespace-only strings if (typeof cellValue === "string") { return cellValue.trim() === "" } return cellValue == null
case FILTER_OPERATORS.NOT_EMPTY: // Check for non-empty strings (excluding whitespace-only) if (typeof cellValue === "string") { return cellValue.trim() !== "" } return cellValue != null
// Numeric operators case FILTER_OPERATORS.LT: { const numCell = Number(cellValue) const numFilter = Number(filterValue) // Check for valid numbers (NaN would make comparison false) if (isNaN(numCell) || isNaN(numFilter)) return false return numCell < numFilter }
case FILTER_OPERATORS.LTE: { const numCell = Number(cellValue) const numFilter = Number(filterValue) if (isNaN(numCell) || isNaN(numFilter)) return false return numCell <= numFilter }
case FILTER_OPERATORS.GT: { const numCell = Number(cellValue) const numFilter = Number(filterValue) if (isNaN(numCell) || isNaN(numFilter)) return false return numCell > numFilter }
case FILTER_OPERATORS.GTE: { const numCell = Number(cellValue) const numFilter = Number(filterValue) if (isNaN(numCell) || isNaN(numFilter)) return false return numCell >= numFilter }
case FILTER_OPERATORS.BETWEEN: if (Array.isArray(filterValue) && filterValue.length === 2) { const [min, max] = filterValue const numValue = Number(cellValue) const numMin = Number(min) const numMax = Number(max) // Validate all numbers are valid if (isNaN(numValue) || isNaN(numMin) || isNaN(numMax)) return false return numValue >= numMin && numValue <= numMax } return false
// Array operators case FILTER_OPERATORS.IN: if (Array.isArray(filterValue)) { // Handle case-insensitive string comparison if (typeof cellValue === "string") { const cellLower = cellValue.toLowerCase() return filterValue.some(val => typeof val === "string" ? val.toLowerCase() === cellLower : val === cellValue, ) } // For non-string types, convert to string for comparison return filterValue.some(val => String(val) === String(cellValue)) } return false
case FILTER_OPERATORS.NOT_IN: if (Array.isArray(filterValue)) { // Handle case-insensitive string comparison if (typeof cellValue === "string") { const cellLower = cellValue.toLowerCase() return !filterValue.some(val => typeof val === "string" ? val.toLowerCase() === cellLower : val === cellValue, ) } // For non-string types, convert to string for comparison return !filterValue.some(val => String(val) === String(cellValue)) } return true
// Date operators (basic implementation) case FILTER_OPERATORS.RELATIVE: // Not implemented — throw in dev (loud), return no matches in prod // (safer than silently passing every row). if (process.env.NODE_ENV !== "production") { throw new Error( "FILTER_OPERATORS.RELATIVE is not yet implemented. Either remove the 'Is relative to today' option from the date filter UI or implement this case.", ) } if (!hasLoggedRelativeFilterWarning) { hasLoggedRelativeFilterWarning = true console.error( "FILTER_OPERATORS.RELATIVE is not yet implemented — returning no matches in production to avoid silently passing all rows.", ) } return false
default: // Fallback to contains behavior using regex try { const escapedFilter = filterStr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") const regex = getOrCreateRegex(escapedFilter, "i") // ✅ Use cached regex return regex.test(cellStr) } catch { return cellStr.includes(filterStr) } }}
/** * Filter function for number range (slider) filters * Handles array values [min, max] for range filtering */export const numberRangeFilter: FilterFn<RowData> = ( row, columnId, filterValue,
addMeta,) => { if (!filterValue) return true
// Handle ExtendedColumnFilter format if ( typeof filterValue === "object" && filterValue.operator && filterValue.value !== undefined ) { const filter = filterValue as ExtendedColumnFilter<RowData> return applyFilterOperator( row.getValue(columnId), filter.operator, filter.value, ) }
// Handle array format [min, max] from slider if (Array.isArray(filterValue) && filterValue.length === 2) { const [min, max] = filterValue const value = Number(row.getValue(columnId)) if (isNaN(value)) return false const numMin = Number(min) const numMax = Number(max) if (isNaN(numMin) || isNaN(numMax)) return false return value >= numMin && value <= numMax }
// Fallback to extendedFilter for other formats return extendedFilter(row, columnId, filterValue, addMeta)}
/** * Filter function for date range filters * Handles both single date (timestamp) and date range [from, to] (timestamps) */export const dateRangeFilter: FilterFn<RowData> = ( row, columnId, filterValue,
addMeta,) => { if (!filterValue) return true
// Handle ExtendedColumnFilter format if ( typeof filterValue === "object" && filterValue.operator && filterValue.value !== undefined ) { const filter = filterValue as ExtendedColumnFilter<RowData> return applyFilterOperator( row.getValue(columnId), filter.operator, filter.value, ) }
const rowValue = row.getValue(columnId) if (!rowValue) return false
// Handle Date objects - convert to timestamp const rowTimestamp = rowValue instanceof Date ? rowValue.getTime() : typeof rowValue === "number" ? rowValue : new Date(rowValue as string).getTime()
if (isNaN(rowTimestamp)) return false
// Handle array format [from, to] from date range picker if (Array.isArray(filterValue)) { if (filterValue.length === 2) { const [from, to] = filterValue const fromTime = Number(from) const toTime = Number(to) if (isNaN(fromTime) || isNaN(toTime)) return false return rowTimestamp >= fromTime && rowTimestamp <= toTime } // Single date in array if (filterValue.length === 1) { const dateTime = Number(filterValue[0]) if (isNaN(dateTime)) return false // Compare dates at day level (midnight to midnight) const rowDate = new Date(rowTimestamp).setHours(0, 0, 0, 0) const filterDate = new Date(dateTime).setHours(0, 0, 0, 0) return rowDate === filterDate } }
// Handle single timestamp if (typeof filterValue === "number") { // Compare dates at day level (midnight to midnight) const rowDate = new Date(rowTimestamp).setHours(0, 0, 0, 0) const filterDate = new Date(filterValue).setHours(0, 0, 0, 0) return rowDate === filterDate }
// Fallback to extendedFilter for other formats return extendedFilter(row, columnId, filterValue, addMeta)}
/** * Helper function to create filter value with operator * * @param operator - The filter operator to apply * @param value - The value to filter by * @returns ExtendedColumnFilter object with default properties */export const createFilterValue = <TData extends RowData = RowData>( operator: FilterOperator, value: string | number | boolean | null | undefined | string[],): ExtendedColumnFilter<TData> => { return { id: "" as Extract<keyof TData, string>, // Will be set by the column filterId: "", // Will be set by the filter system operator, value: value as string | string[], variant: FILTER_VARIANTS.TEXT, // Default variant joinOperator: JOIN_OPERATORS.AND, // Default join operator }}// Mixed AND/OR: filters tagged JOIN_OPERATORS.MIXED apply AND-before-OR// precedence. Pure AND still goes through columnFilters for perf./** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
import type { Table, Row } from "@tanstack/react-table"
/** * Get filtered rows excluding a specific column's filter. * This is useful when generating options for a column - we want to see * options that exist in the filtered dataset (from other filters) but * not be limited by the current column's own filter. */export function getFilteredRowsExcludingColumn<TData>( table: Table<TData>, coreRows: Row<TData>[], excludeColumnId: string, columnFilters: Array<{ id: string; value: unknown }>, globalFilter: unknown,): Row<TData>[] { // Filter out the current column's filter const otherFilters = columnFilters.filter( filter => filter.id !== excludeColumnId, )
// If no filters to apply (excluding the current column), return core rows if (otherFilters.length === 0 && !globalFilter) { return coreRows }
// Filter rows manually, excluding the current column's filter return coreRows.filter(row => { // Apply column filters (excluding the current column) for (const filter of otherFilters) { const column = table.getColumn(filter.id) if (!column) continue
const filterValue = filter.value const filterFn = column.columnDef.filterFn || "extended"
// Skip if filter function is a string (built-in) and we don't have access if (typeof filterFn === "string") { // Use the table's filterFns const fn = table.options.filterFns?.[filterFn] if (fn && typeof fn === "function") { if (!fn(row, filter.id, filterValue, () => {})) { return false } } } else if (typeof filterFn === "function") { if (!filterFn(row, filter.id, filterValue, () => {})) { return false } } }
// Apply global filter if present if (globalFilter) { const globalFilterFn = table.options.globalFilterFn if (globalFilterFn && typeof globalFilterFn === "function") { if (!globalFilterFn(row, "global", globalFilter, () => {})) { return false } } }
return true })}/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
export function formatDate( date: Date | string | number | undefined, opts: Intl.DateTimeFormatOptions = {},) { if (!date) return ""
try { return new Intl.DateTimeFormat("en-US", { month: opts.month ?? "long", day: opts.day ?? "numeric", year: opts.year ?? "numeric", ...opts, }).format(new Date(date)) } catch { return "" }}
/** * Format a value into a human-readable label. * Capitalizes first letter of each word and replaces hyphens/underscores with spaces. * * @example * formatLabel("firstName") // "FirstName" * formatLabel("first-name") // "First Name" * formatLabel("first_name") // "First Name" * formatLabel("true") // "Yes" * formatLabel("false") // "No" */export function formatLabel(value: string): string { // Handle boolean values if (value === "true") return "Yes" if (value === "false") return "No"
return value .replace(/[-_]/g, " ") .split(" ") .map(word => word.charAt(0).toUpperCase() + word.slice(1)) .join(" ")}
/** * Create a date relative to the current date by subtracting days. * Useful for generating dynamic test data with relative dates. * * @param days - Number of days to subtract from current date * @returns Date object representing the date N days ago * * @example * daysAgo(7) // 7 days ago * daysAgo(30) // 30 days ago (1 month) * daysAgo(365) // 365 days ago (1 year) */export function daysAgo(days: number): Date { const date = new Date() date.setDate(date.getDate() - days) return date}
/** * Format URL query parameters into a human-readable query string for display. * Decodes URL-encoded values and formats JSON objects in a readable way. * * @param urlParams - The parsed URL parameters object * @param urlKeys - Mapping of parameter keys to URL query keys * @returns Formatted query string (e.g., `?search=i&global={"filters":[...]}`) * * @example * ```ts * const urlParams = { search: "i", globalFilter: { filters: [...], joinOperator: "mixed" } } * const urlKeys = { search: "search", globalFilter: "global" } * formatQueryString(urlParams, urlKeys) * // Returns: "?search=i&global={"filters":[...], "joinOperator":"mixed"}" * ``` */export function formatQueryString( urlParams: Record<string, unknown>, urlKeys: Record<string, string>,): string { const parts: string[] = []
// Helper to format JSON compactly for display (but show full for global filter) const formatJson = (obj: unknown, showFull = false): string => { try { if (showFull) { // For global filter, show full JSON return JSON.stringify(obj) } const str = JSON.stringify(obj) // For short values, return as-is if (str.length <= 80) { return str } // For arrays, show count if (Array.isArray(obj) && obj.length > 0) { return `[{...}] (${obj.length} items)` } // For objects, show structure if (typeof obj === "object" && obj !== null) { const keys = Object.keys(obj) if (keys.length > 0) { const firstKey = keys[0] const firstValue = (obj as Record<string, unknown>)[firstKey] if (Array.isArray(firstValue)) { return `{${firstKey}: [...], ...}` } if (typeof firstValue === "object" && firstValue !== null) { return `{${firstKey}: {...}, ...}` } return `{${firstKey}: ${String(firstValue)}, ...}` } } // Fallback: truncate long strings return str.length > 100 ? `${str.slice(0, 100)}...` : str } catch { return String(obj) } }
// Add all non-empty params using the URL key mapping if (urlParams.pageIndex !== undefined && urlParams.pageIndex !== 0) { parts.push(`${urlKeys.pageIndex}=${urlParams.pageIndex}`) } if (urlParams.pageSize !== undefined && urlParams.pageSize !== 10) { parts.push(`${urlKeys.pageSize}=${urlParams.pageSize}`) } if ( urlParams.sort && Array.isArray(urlParams.sort) && urlParams.sort.length > 0 ) { parts.push(`${urlKeys.sort}=${formatJson(urlParams.sort)}`) } if ( urlParams.filters && Array.isArray(urlParams.filters) && urlParams.filters.length > 0 ) { // Show full JSON for filters parts.push(`${urlKeys.filters}=${formatJson(urlParams.filters, true)}`) } if (urlParams.search && typeof urlParams.search === "string") { parts.push(`${urlKeys.search}=${urlParams.search}`) } // Only include globalFilter if it's an object (complex filters) // Show full JSON for global filter if ( urlParams.globalFilter && typeof urlParams.globalFilter === "object" && urlParams.globalFilter !== null && "filters" in urlParams.globalFilter ) { parts.push( `${urlKeys.globalFilter}=${formatJson(urlParams.globalFilter, true)}`, ) } if ( urlParams.columnVisibility && typeof urlParams.columnVisibility === "object" && urlParams.columnVisibility !== null && Object.keys(urlParams.columnVisibility).length > 0 ) { parts.push( `${urlKeys.columnVisibility}=${formatJson(urlParams.columnVisibility)}`, ) } if ( urlParams.inlineFilters && Array.isArray(urlParams.inlineFilters) && urlParams.inlineFilters.length > 0 ) { parts.push( `${urlKeys.inlineFilters}=${formatJson(urlParams.inlineFilters)}`, ) } if (urlParams.filterMode && urlParams.filterMode !== "standard") { parts.push(`${urlKeys.filterMode}=${urlParams.filterMode}`) }
return parts.length > 0 ? `?${parts.join("&")}` : "No query params"}/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
import { type Column } from "@tanstack/react-table"import type React from "react"
export const getCommonPinningStyles = <TData>( column: Column<TData>, isHeader: boolean = false,): React.CSSProperties => { const isPinned = column.getIsPinned() if (!isPinned) return {}
const isLeft = isPinned === "left" const columnSize = column.getSize()
return { position: "sticky", top: isHeader ? 0 : undefined, left: isLeft ? `${column.getStart("left")}px` : undefined, right: !isLeft ? `${column.getAfter("right")}px` : undefined, opacity: 1, width: columnSize, minWidth: columnSize, // Prevent column from shrinking maxWidth: columnSize, // Prevent column from growing flexShrink: 0, // Prevent flex shrinking // Headers: z-20 to stay above other headers and body. // Body: z-10 to stay above other body cells. zIndex: isHeader ? 20 : 10, backgroundColor: "var(--background)", // Ensure opaque background // Create a visual separation for pinned columns boxShadow: isLeft ? "1px 0 0 var(--border)" // Right border for left pinned : "-1px 0 0 var(--border)", // Left border for right pinned }}/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
/** * @file Data Table Configuration * Defines runtime configuration values for the Data Table component. * This includes filter operators, sort icons, and other constants.
*/
import type { LucideIcon } from "lucide-react"import { ArrowDownAZ, ArrowDownZA, ArrowDown01, ArrowDown10, ArrowUpDown, Calendar, Check, X as XIcon,} from "lucide-react"import { JOIN_OPERATORS, FILTER_OPERATORS, FILTER_VARIANTS, type JoinOperator, type FilterOperator, type FilterVariant,} from "../lib/constants"
export type SortIconVariant = FilterVariant
interface SortIcons { asc: LucideIcon desc: LucideIcon unsorted: LucideIcon}
interface SortLabels { asc: string desc: string}
export const SORT_ICONS: Record<SortIconVariant, SortIcons> = { [FILTER_VARIANTS.TEXT]: { asc: ArrowDownAZ, desc: ArrowDownZA, unsorted: ArrowUpDown, }, [FILTER_VARIANTS.NUMBER]: { asc: ArrowDown01, desc: ArrowDown10, unsorted: ArrowUpDown, }, [FILTER_VARIANTS.RANGE]: { asc: ArrowDown01, desc: ArrowDown10, unsorted: ArrowUpDown, }, [FILTER_VARIANTS.DATE]: { asc: ArrowUpDown, desc: ArrowUpDown, unsorted: Calendar, }, [FILTER_VARIANTS.DATE_RANGE]: { asc: ArrowUpDown, desc: ArrowUpDown, unsorted: Calendar, }, [FILTER_VARIANTS.BOOLEAN]: { asc: XIcon, // False First desc: Check, // True First unsorted: ArrowUpDown, }, [FILTER_VARIANTS.SELECT]: { asc: ArrowDownAZ, desc: ArrowDownZA, unsorted: ArrowUpDown, }, [FILTER_VARIANTS.MULTI_SELECT]: { asc: ArrowDownAZ, desc: ArrowDownZA, unsorted: ArrowUpDown, },}
export const SORT_LABELS: Record<SortIconVariant, SortLabels> = { [FILTER_VARIANTS.TEXT]: { asc: "Asc", desc: "Desc", }, [FILTER_VARIANTS.NUMBER]: { asc: "Low to High", desc: "High to Low", }, [FILTER_VARIANTS.RANGE]: { asc: "Low to High", desc: "High to Low", }, [FILTER_VARIANTS.DATE]: { asc: "Oldest First", desc: "Newest First", }, [FILTER_VARIANTS.DATE_RANGE]: { asc: "Oldest First", desc: "Newest First", }, [FILTER_VARIANTS.BOOLEAN]: { asc: "False First", desc: "True First", }, [FILTER_VARIANTS.SELECT]: { asc: "Asc", desc: "Desc", }, [FILTER_VARIANTS.MULTI_SELECT]: { asc: "Asc", desc: "Desc", },}
/** * @credit Adapted from React Table's default config * @see https://react-table.tanstack.com/docs/overview */
export const dataTableConfig = { debounceMs: 300, throttleMs: 50, textOperators: [ { label: "Contains", value: FILTER_OPERATORS.ILIKE }, { label: "Does not contain", value: FILTER_OPERATORS.NOT_ILIKE }, { label: "Is", value: FILTER_OPERATORS.EQ }, { label: "Is not", value: FILTER_OPERATORS.NEQ }, { label: "Is empty", value: FILTER_OPERATORS.EMPTY }, { label: "Is not empty", value: FILTER_OPERATORS.NOT_EMPTY }, ] satisfies { label: string; value: FilterOperator }[], numericOperators: [ { label: "Is", value: FILTER_OPERATORS.EQ }, { label: "Is not", value: FILTER_OPERATORS.NEQ }, { label: "Is less than", value: FILTER_OPERATORS.LT }, { label: "Is less than or equal to", value: FILTER_OPERATORS.LTE, }, { label: "Is greater than", value: FILTER_OPERATORS.GT }, { label: "Is greater than or equal to", value: FILTER_OPERATORS.GTE, }, { label: "Is between", value: FILTER_OPERATORS.BETWEEN }, { label: "Is empty", value: FILTER_OPERATORS.EMPTY }, { label: "Is not empty", value: FILTER_OPERATORS.NOT_EMPTY }, ] satisfies { label: string; value: FilterOperator }[], dateOperators: [ { label: "Is", value: FILTER_OPERATORS.EQ }, { label: "Is not", value: FILTER_OPERATORS.NEQ }, { label: "Is before", value: FILTER_OPERATORS.LT }, { label: "Is after", value: FILTER_OPERATORS.GT }, { label: "Is on or before", value: FILTER_OPERATORS.LTE }, { label: "Is on or after", value: FILTER_OPERATORS.GTE }, { label: "Is between", value: FILTER_OPERATORS.BETWEEN }, // FILTER_OPERATORS.RELATIVE hidden until its filter case ships in // `lib/filter-functions.ts`. The enum stays in the catalogue for // server-side consumers — re-add this entry when wiring the logic. { label: "Is empty", value: FILTER_OPERATORS.EMPTY }, { label: "Is not empty", value: FILTER_OPERATORS.NOT_EMPTY }, ] satisfies { label: string; value: FilterOperator }[], selectOperators: [ { label: "Is", value: FILTER_OPERATORS.EQ }, { label: "Is not", value: FILTER_OPERATORS.NEQ }, { label: "Is empty", value: FILTER_OPERATORS.EMPTY }, { label: "Is not empty", value: FILTER_OPERATORS.NOT_EMPTY }, ] satisfies { label: string; value: FilterOperator }[], multiSelectOperators: [ { label: "Has any of", value: FILTER_OPERATORS.IN }, { label: "Has none of", value: FILTER_OPERATORS.NOT_IN }, { label: "Is empty", value: FILTER_OPERATORS.EMPTY }, { label: "Is not empty", value: FILTER_OPERATORS.NOT_EMPTY }, ] satisfies { label: string; value: FilterOperator }[], booleanOperators: [ { label: "Is", value: FILTER_OPERATORS.EQ }, { label: "Is not", value: FILTER_OPERATORS.NEQ }, ] satisfies { label: string; value: FilterOperator }[], sortOrders: [ { label: "Asc", value: "asc" as const }, { label: "Desc", value: "desc" as const }, ], filterVariants: [ FILTER_VARIANTS.TEXT, FILTER_VARIANTS.NUMBER, FILTER_VARIANTS.RANGE, FILTER_VARIANTS.DATE, FILTER_VARIANTS.DATE_RANGE, FILTER_VARIANTS.BOOLEAN, FILTER_VARIANTS.SELECT, FILTER_VARIANTS.MULTI_SELECT, ] satisfies FilterVariant[], operators: [ FILTER_OPERATORS.ILIKE, FILTER_OPERATORS.NOT_ILIKE, FILTER_OPERATORS.EQ, FILTER_OPERATORS.NEQ, FILTER_OPERATORS.IN, FILTER_OPERATORS.NOT_IN, FILTER_OPERATORS.EMPTY, FILTER_OPERATORS.NOT_EMPTY, FILTER_OPERATORS.LT, FILTER_OPERATORS.LTE, FILTER_OPERATORS.GT, FILTER_OPERATORS.GTE, FILTER_OPERATORS.BETWEEN, FILTER_OPERATORS.RELATIVE, ] satisfies FilterOperator[], joinOperators: [ JOIN_OPERATORS.AND, JOIN_OPERATORS.OR, ] satisfies JoinOperator[],} as const
export type DataTableConfig = typeof dataTableConfig/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
import { Children, isValidElement, type ReactNode, type ComponentType, type PropsWithChildren,} from "react"
/** * Feature requirements that components can declare */export interface FeatureRequirements { enableFilters?: boolean enablePagination?: boolean enableRowSelection?: boolean enableSorting?: boolean enableMultiSort?: boolean enableGrouping?: boolean enableExpanding?: boolean manualSorting?: boolean manualPagination?: boolean manualFiltering?: boolean pageCount?: number}
// Tree-walk detection is 50-150ms; cache by `children` identity. Client-only// (SSR cache would mismatch) and skipped when `columns` provided (changes too// often). Map (not WeakMap) since ReactNode can be primitive.const detectionCache = typeof window !== "undefined" ? new Map<unknown, FeatureRequirements>() : null
// LRU cap so long-running apps don't leak.const MAX_CACHE_SIZE = 50
/** * Component feature registry - maps component displayNames to their requirements */const COMPONENT_FEATURES: Record<string, FeatureRequirements> = { // Pagination components DataTablePagination: { enablePagination: true }, TablePagination: { enablePagination: true },
// Filtering components DataTableViewMenu: { enableFilters: true }, TableViewMenu: { enableFilters: true }, DataTableSearchFilter: { enableFilters: true }, TableSearchFilter: { enableFilters: true }, DataTableFacetedFilter: { enableFilters: true }, TableFacetedFilter: { enableFilters: true }, DataTableSliderFilter: { enableFilters: true }, TableSliderFilter: { enableFilters: true },
// Advanced filtering & sorting components DataTableSortMenu: { enableSorting: true }, TableSortMenu: { enableSorting: true }, DataTableFilterMenu: { enableFilters: true }, TableFilterMenu: { enableFilters: true },
DataTableDateFilter: { enableFilters: true }, DataTableInlineFilter: { enableFilters: true }, TableInlineFilter: { enableFilters: true }, DataTableClearFilter: { enableFilters: true }, TableClearFilter: { enableFilters: true },
// Column-level filter menu components DataTableColumnFacetedFilterMenu: { enableFilters: true }, TableColumnFacetedFilterMenu: { enableFilters: true }, DataTableColumnFacetedFilterOptions: { enableFilters: true }, TableColumnFacetedFilterOptions: { enableFilters: true }, DataTableColumnSliderFilterMenu: { enableFilters: true }, TableColumnSliderFilterMenu: { enableFilters: true }, DataTableColumnSliderFilterOptions: { enableFilters: true }, TableColumnSliderFilterOptions: { enableFilters: true }, DataTableColumnDateFilterMenu: { enableFilters: true }, TableColumnDateFilterMenu: { enableFilters: true }, DataTableColumnDateFilterOptions: { enableFilters: true }, TableColumnDateFilterOptions: { enableFilters: true },
// Selection components DataTableSelectionBar: { enableRowSelection: true },
// Sorting components (most components support sorting by default) DataTableColumnHeader: { enableSorting: true }, TableColumnHeader: { enableSorting: true }, TableColumnSortMenu: { enableSorting: true, enableMultiSort: true }, DataTableColumnSortMenu: { enableSorting: true, enableMultiSort: true }, TableColumnSortOptions: { enableSorting: true, enableMultiSort: true }, DataTableColumnSortOptions: { enableSorting: true, enableMultiSort: true },}
/** * Walks the React tree to aggregate feature requirements declared by child * components (via displayName) and column header functions. */export function detectFeaturesFromChildren( children: ReactNode, columns?: Array<{ header?: unknown; enableColumnFilter?: boolean }>,): FeatureRequirements { // Skip cache when `columns` provided — column content drives detection and // changes frequently, would return stale results. const shouldCache = detectionCache && !columns && children && typeof children === "object"
if (shouldCache) { const cached = detectionCache.get(children) if (cached) { return cached } }
const requirements: FeatureRequirements = {}
const searchRecursively = (children: ReactNode) => { const childrenArray = Children.toArray(children)
for (const child of childrenArray) { if (isValidElement(child)) { // Check if this component has feature requirements if (typeof child.type === "function") { const componentType = child.type as ComponentType<unknown> & { displayName?: string } const displayName = componentType.displayName const componentFeatures = displayName ? COMPONENT_FEATURES[displayName] : undefined
if (componentFeatures) { // Merge requirements (any component requiring a feature enables it) Object.keys(componentFeatures).forEach(key => { const featureKey = key as keyof FeatureRequirements if (componentFeatures[featureKey]) { ;(requirements as Record<string, unknown>)[featureKey] = true } }) } }
// Recursively check nested children const propsWithChildren = child.props as PropsWithChildren<unknown> if (propsWithChildren?.children) { searchRecursively(propsWithChildren.children) } } } }
// Check columns for header components (like TableColumnHeader, TableColumnSortMenu) if (columns && Array.isArray(columns)) { for (const column of columns) { // Check if column has enableColumnFilter set if (column.enableColumnFilter) { requirements.enableFilters = true }
if (column.header && typeof column.header === "function") { try { // Try to call the header function with mock context to get the rendered component // Using unknown for the context type since we're creating a minimal mock const headerFn = column.header as (context: { column: Record<string, unknown> }) => ReactNode const headerResult = headerFn({ column: { getCanSort: () => true, getIsSorted: () => false, toggleSorting: () => {}, clearSorting: () => {}, getCanHide: () => true, getIsVisible: () => true, toggleVisibility: () => {}, getCanPin: () => true, getIsPinned: () => false, pin: () => {}, columnDef: { meta: {} }, id: "mock", }, })
// Recursively check the header result and all its children for feature components const checkElementForFeatures = (element: ReactNode) => { if (!isValidElement(element)) return
if (typeof element.type === "function") { const componentType = element.type as ComponentType<unknown> & { displayName?: string } const displayName = componentType.displayName const componentFeatures = displayName ? COMPONENT_FEATURES[displayName] : undefined
if (componentFeatures) { Object.keys(componentFeatures).forEach(key => { const featureKey = key as keyof FeatureRequirements if (componentFeatures[featureKey]) { ;(requirements as Record<string, unknown>)[featureKey] = true } }) } }
// Recursively check children const propsWithChildren = element.props as PropsWithChildren<unknown> if (propsWithChildren?.children) { Children.toArray(propsWithChildren.children).forEach( checkElementForFeatures, ) } }
checkElementForFeatures(headerResult) } catch { // Ignore errors from calling header function } } } }
searchRecursively(children)
// Cache the result only when caching is appropriate (no columns provided) if (shouldCache && detectionCache) { // Limit cache size to prevent memory leaks if (detectionCache.size >= MAX_CACHE_SIZE) { // Remove oldest entry (first in the map) const firstKey = detectionCache.keys().next().value if (firstKey !== undefined) { detectionCache.delete(firstKey) } }
detectionCache.set(children, requirements) }
return requirements}/** * Register a component's feature requirements * This allows third-party components to declare their needs */export function registerComponentFeatures( displayName: string, features: FeatureRequirements,) { COMPONENT_FEATURES[displayName] = features}
/** * Get all registered components and their features (for debugging) */export function getRegisteredComponents() { return { ...COMPONENT_FEATURES }}/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
import * as React from "react"import { type Table, type ColumnDef, type Row, type RowData,} from "@tanstack/react-table"import { JOIN_OPERATORS, FILTER_OPERATORS, FILTER_VARIANTS,} from "../lib/constants"
// ============================================================================// TANSTACK REACT-TABLE MODULE AUGMENTATION// ============================================================================declare module "@tanstack/react-table" { // eslint-disable-next-line @typescript-eslint/no-unused-vars interface ColumnMeta<TData extends RowData, TValue> { // Display label?: string placeholder?: string
// Filtering variant?: FilterVariant options?: Option[] range?: [number, number] /** * Automatically generate options for select/multiSelect columns if not provided. * When true and no static `options` exist, generation logic (wrappers / hooks) may supply them. */ autoOptions?: boolean /** Whether to automatically rename option labels using formatLabel. When false, uses raw value as label. */ autoOptionsFormat?: boolean /** Per-column override for showing counts (falls back to wrapper prop). */ showCounts?: boolean /** Per-column override for using filtered rows for counts (falls back to wrapper prop). */ dynamicCounts?: boolean /** Merge strategy override: preserve | augment | replace (falls back to wrapper prop). */ mergeStrategy?: "preserve" | "augment" | "replace"
// Formatting unit?: string icon?: React.ComponentType<{ className?: string }>
// Row Expansion expandedContent?: (row: TData) => React.ReactNode }
// eslint-disable-next-line @typescript-eslint/no-unused-vars interface TableMeta<TData extends RowData> { joinOperator?: JoinOperator hasIndividualJoinOperators?: boolean }}
// ============================================================================// CORE TYPES// ============================================================================
export interface Option { label: string value: string count?: number icon?: React.ComponentType<{ className?: string }>}
// ============================================================================// FILTER TYPES// ============================================================================
import type { FilterVariant as _FilterVariant, FilterOperator as _FilterOperator, JoinOperator as _JoinOperator,} from "../lib/constants"
export type FilterVariant = _FilterVariantexport type FilterOperator = _FilterOperatorexport type JoinOperator = _JoinOperator
/** * Extended column filter with additional metadata */export interface ExtendedColumnFilter<TData> { id: Extract<keyof TData, string> value: string | string[] variant: FilterVariant operator: FilterOperator filterId: string joinOperator?: JoinOperator // Individual join operator for each filter // You can extend with additional properties if needed}
/** Global filter type */export type GlobalFilter = string | Record<string, unknown>
/** * Extended column sort (for URL state management) */export interface ExtendedColumnSort<TData> { id: Extract<keyof TData, string> desc: boolean // You can extend with additional properties if needed}
/** * Query keys for URL state management */export interface QueryKeys { page?: string perPage?: string sort?: string filters?: string joinOperator?: string // Additional keys can be added as needed}
// ============================================================================// COLUMN DEFINITION// ============================================================================
/** * Extended column definition for data table * Inherits all TanStack Table ColumnDef properties */export type DataTableColumnDef<TData, TValue = unknown> = ColumnDef< TData, TValue> & { // You can extend with additional properties if needed}
// ============================================================================// ROW TYPES// ============================================================================
/** * Data table row type * Alias for TanStack Table Row */export type DataTableRow<TData> = Row<TData> & { // You can extend with additional properties if needed}
export type DataTableInstance<TData> = Table<TData> & { // You can extend with additional properties if needed}
// ============================================================================// CONVENIENCE TYPE HELPERS// ============================================================================
/** * Convenience type for accessing constant values with better type safety */export type JoinOperatorValues = typeof JOIN_OPERATORSexport type FilterOperatorValues = typeof FILTER_OPERATORSexport type FilterVariantValues = typeof FILTER_VARIANTS
/** * Utility type to get the literal values from constant objects */export type ValueOf<T> = T[keyof T]Update the import paths to match your project setup.
This installs only the core. For pagination, filters, DnD, virtualization, aside, and other features, add the optional blocks below (one by one) or use Install Everything.
Install Everything
Section titled “Install Everything”Want all components at once? The following command installs core + all optional add-ons in one go:
Install everything (via URLs)
Section titled “Install everything (via URLs)”If you prefer not to configure the registry, you can install core and every optional block in one command using URLs:
Optional Components
Section titled “Optional Components”Or install only the components you need. Each component can be added individually:
Table Controls
Section titled “Table Controls”DataTablePagination:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import { useDataTable } from "../core/data-table-context"import { TablePagination, type TablePaginationProps,} from "../filters/table-pagination"
type DataTablePaginationProps<TData> = Omit< TablePaginationProps<TData>, "table" | "isLoading"> & { /** * Override the loading state from context */ isLoading?: boolean}
export function DataTablePagination<TData>({ isLoading: externalLoading, ...props}: DataTablePaginationProps<TData>) { const { table, isLoading: contextLoading } = useDataTable<TData>()
// Use external loading if provided, otherwise use context loading const isLoading = externalLoading ?? contextLoading
return <TablePagination table={table} isLoading={isLoading} {...props} />}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
DataTablePagination.displayName = "DataTablePagination""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
import React from "react"import { type Table } from "@tanstack/react-table"import { Button } from "@/components/ui/button"import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"import { Input } from "@/components/ui/input"import { ChevronLeft, ChevronRight } from "lucide-react"import { Skeleton } from "@/components/ui/skeleton"
export interface TablePaginationProps<TData> { table: Table<TData> pageSizeOptions?: number[] defaultPageSize?: number /** * External loading state (e.g., from API) */ isLoading?: boolean /** * External fetching state (e.g., from TanStack Query). * Disables nav while a request is in flight so users can't advance the * cursor before the next page resolves. */ isFetching?: boolean /** * Explicitly disable the next page button. * Useful when you want to prevent navigation during initial load but allow it during background fetching. */ disableNextPage?: boolean /** * Explicitly disable the previous page button. * Useful when you want to prevent navigation during initial load but allow it during background fetching. */ disablePreviousPage?: boolean /** * Total count of items from server (for server-side pagination). * If provided, this will be used instead of table.getFilteredRowModel().rows.length */ totalCount?: number onPageSizeChange?: (pageSize: number, pageIndex: number) => void onPageChange?: (pageIndex: number) => void onNextPage?: (pageIndex: number) => void onPreviousPage?: (pageIndex: number) => void /** * Callback when pagination initialization is complete */ onPaginationReady?: () => void}export function TablePagination<TData>({ table, pageSizeOptions = [10, 25, 50, 100], defaultPageSize = pageSizeOptions[0], isLoading, isFetching, disableNextPage, disablePreviousPage, totalCount, onPageSizeChange, onPageChange, onNextPage, onPreviousPage, onPaginationReady,}: TablePaginationProps<TData>) { const { pageIndex, pageSize } = table.getState().pagination
// Use totalCount if provided (server-side), otherwise use filtered row model (client-side) const totalRows = totalCount ?? table.getFilteredRowModel().rows.length const startItem = totalRows === 0 ? 0 : pageIndex * pageSize + 1 const endItem = Math.min((pageIndex + 1) * pageSize, totalRows) const totalPages = table.getPageCount() const currentPage = pageIndex + 1
const [pageInput, setPageInput] = React.useState<string | null>(null) const displayValue = pageInput ?? currentPage.toString()
// Disable nav during any in-flight load (initial OR background fetch) so // users can't advance the cursor while the next page is still resolving. const canNextPage = table.getCanNextPage() const isDisabled = isLoading || isFetching const canGoNext = !disableNextPage && !isDisabled && canNextPage const canGoPrevious = !disablePreviousPage && !isDisabled && table.getCanPreviousPage()
// Set default page size on initial render React.useEffect(() => { if (pageSize !== defaultPageSize) { table.setPageSize(defaultPageSize) } onPaginationReady?.() // eslint-disable-next-line react-hooks/exhaustive-deps }, [])
const handlePageSizeChange = React.useCallback( (value: string) => { const newPageSize = Number(value) const newPageIndex = Math.floor((pageIndex * pageSize) / newPageSize) table.setPageSize(newPageSize) onPageSizeChange?.(newPageSize, newPageIndex) }, [table, pageIndex, pageSize, onPageSizeChange], )
const handlePageInputChange = React.useCallback( (e: React.ChangeEvent<HTMLInputElement>) => { setPageInput(e.target.value) }, [], )
const handlePageInputBlur = React.useCallback(() => { const page = parseInt(pageInput ?? "", 10) if (!Number.isNaN(page) && page >= 1 && page <= totalPages) { const newPageIndex = page - 1 table.setPageIndex(newPageIndex) onPageChange?.(newPageIndex) } setPageInput(null) }, [pageInput, totalPages, table, onPageChange])
const handlePageInputKeyDown = React.useCallback( (e: React.KeyboardEvent<HTMLInputElement>) => { if (e.key === "Enter") { e.currentTarget.blur() } }, [], )
const handlePreviousPage = React.useCallback(() => { const newPageIndex = pageIndex - 1 table.previousPage() onPreviousPage?.(newPageIndex) }, [table, pageIndex, onPreviousPage])
const handleNextPage = React.useCallback(() => { const newPageIndex = pageIndex + 1 table.nextPage() onNextPage?.(newPageIndex) }, [table, pageIndex, onNextPage])
// Show loading skeleton while initializing if (isLoading) { return ( <div className="flex flex-wrap items-center justify-between gap-4 px-4 py-2"> <div className="flex items-center space-x-2"> <Skeleton className="h-8 w-24" /> <Skeleton className="h-8 w-16" /> </div> <Skeleton className="h-8 w-32" /> <div className="flex items-center space-x-4"> <div className="flex items-center space-x-2"> <Skeleton className="h-8 w-12" /> <Skeleton className="h-8 w-20" /> </div> <div className="flex items-center space-x-1"> <Skeleton className="h-8 w-8" /> <Skeleton className="h-8 w-8" /> </div> </div> </div> ) }
return ( <nav className="flex flex-wrap items-center justify-between gap-x-6 gap-y-2 px-4 py-2" aria-label="Table pagination" > <div className="flex items-center space-x-2"> <span className="text-sm whitespace-nowrap text-muted-foreground" id="pagination-page-size-label" > Items per page </span> <Select value={`${Number(pageSize) === 0 ? defaultPageSize : Number(pageSize)}`} onValueChange={handlePageSizeChange} disabled={isLoading} > <SelectTrigger size="sm" className="w-16 focus:ring-0" aria-label="Select page size" aria-labelledby="pagination-page-size-label" > <SelectValue /> </SelectTrigger> <SelectContent> {pageSizeOptions?.map(size => ( <SelectItem key={size} value={`${size}`}> {size} </SelectItem> ))} </SelectContent> </Select> </div>
<div className="flex-1 text-right text-sm whitespace-nowrap text-muted-foreground md:text-center" role="status" aria-live="polite" aria-atomic="true" > {totalRows === 0 ? "0 items" : `${startItem}-${endItem} of ${totalRows} items`} </div>
<div className="ml-auto flex items-center space-x-4"> <div className="flex items-center space-x-2 text-sm text-muted-foreground"> <label htmlFor="page-number-input" className="sr-only"> Page number </label> <Input id="page-number-input" type="number" min="1" max={totalPages} value={displayValue} onChange={handlePageInputChange} onBlur={handlePageInputBlur} onKeyDown={handlePageInputKeyDown} className="h-8 min-w-12 text-center" style={{ width: `${Math.max(String(totalPages).length, 2) + 1}ch`, }} disabled={totalPages === 0 || isLoading || isFetching} aria-label={`Page ${currentPage} of ${totalPages}`} /> <span className="whitespace-nowrap" aria-hidden="true"> of {Math.max(1, totalPages)} pages </span> </div>
<div className="flex items-center space-x-1"> <Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={handlePreviousPage} disabled={!canGoPrevious} aria-label={`Go to previous page, page ${pageIndex}`} title="Go to previous page" > <ChevronLeft className="h-4 w-4" aria-hidden="true" /> </Button> <Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={handleNextPage} disabled={!canGoNext} aria-label={`Go to next page, page ${pageIndex + 2}`} title="Go to next page" > <ChevronRight className="h-4 w-4" aria-hidden="true" /> </Button> </div> </div> </nav> )}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
TablePagination.displayName = "TablePagination"Update the import paths to match your project setup.
DataTableSearchFilter:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import { useDataTable } from "../core/data-table-context"import { TableSearchFilter, type TableSearchFilterProps,} from "../filters/table-search-filter"
type DataTableSearchFilterProps<TData> = Omit< TableSearchFilterProps<TData>, "table">
/** * A search filter component for DataTable. * Can be used in controlled or uncontrolled mode. * * @example * // Uncontrolled (manages its own state) * <DataTableSearchFilter placeholder="Search products..." /> * * @example * // Controlled (you manage the state) * const [search, setSearch] = useState("") * <DataTableSearchFilter * value={search} * onChange={setSearch} * placeholder="Search..." * /> * * @example * // With nuqs for URL state * const [search, setSearch] = useQueryState('search') * <DataTableSearchFilter * value={search ?? ""} * onChange={setSearch} * /> */export function DataTableSearchFilter<TData>( props: DataTableSearchFilterProps<TData>,) { const { table } = useDataTable<TData>() return <TableSearchFilter table={table} {...props} />}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */DataTableSearchFilter.displayName = "DataTableSearchFilter""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import type { Table } from "@tanstack/react-table"import * as React from "react"import { Input } from "@/components/ui/input"import { Button } from "@/components/ui/button"import { cn } from "@/lib/utils"import { Search, X } from "lucide-react"
export interface TableSearchFilterProps<TData> { table: Table<TData> className?: string placeholder?: string showClearButton?: boolean onChange?: (value: string) => void value?: string /** * Debounce ms before pushing the typed value into table state. The * input reflects keystrokes immediately; only the call to * `table.setGlobalFilter` (and the supplied `onChange`) is delayed. * Useful for client-side filtering of larger fully-loaded datasets * (e.g. 1k+ rows) where each keystroke would otherwise re-walk the * row model synchronously. * * Server-driven search (small `data` array, infinite-query-backed) * usually wants this OFF because the network request is already * the natural rate limiter — keep at the default. * * Only applies in uncontrolled mode (when neither `value` nor * `onChange` is supplied). In controlled mode, debounce in the * consumer's `onChange` instead. * * @default 0 */ debounceMs?: number}
export function TableSearchFilter<TData>({ table, className, placeholder = "Search...", showClearButton = true, onChange, value, debounceMs = 0,}: TableSearchFilterProps<TData>) { // Determine if we're in controlled mode const isControlled = value !== undefined
// Get current globalFilter from table state - this will trigger re-renders via context const tableState = table.getState() const tableGlobalFilter = tableState.globalFilter const globalFilterValue = typeof tableGlobalFilter === "string" ? tableGlobalFilter : ""
// Debounce only kicks in for uncontrolled use; the consumer owns // rate-limiting in controlled mode. const debounceEnabled = !isControlled && debounceMs > 0
// Local input value lets keystrokes render at 60fps even when the // expensive `setGlobalFilter` call is delayed. Seeded from table // state on mount and re-synced whenever table state changes // out-of-band (e.g. URL update, programmatic clear). const [pendingValue, setPendingValue] = React.useState<string>(globalFilterValue)
// Stable timeout ref — debounce state lives outside the React tree // so input renders aren't gated on it. const debounceTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>( null, )
React.useEffect(() => { // Cancel any pending debounce flush before checking mode — if debounceEnabled // just switched to false, a stale timer from the previous mode must be cleared. if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current) debounceTimerRef.current = null } if (!debounceEnabled) return setPendingValue(globalFilterValue) }, [globalFilterValue, debounceEnabled])
// Cancel any pending flush on unmount so we don't write to a torn-down table. React.useEffect(() => { return () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current) } }, [])
// Use controlled value if provided; otherwise the locally-tracked // input value when debouncing, falling back to live table state. const currentValue = isControlled ? value : debounceEnabled ? pendingValue : globalFilterValue
const handleClear = React.useCallback(() => { const emptyValue = "" if (debounceTimerRef.current) { clearTimeout(debounceTimerRef.current) debounceTimerRef.current = null } if (debounceEnabled) setPendingValue(emptyValue) table.setGlobalFilter(emptyValue) onChange?.(emptyValue) }, [table, onChange, debounceEnabled])
const handleChange = React.useCallback( (event: React.ChangeEvent<HTMLInputElement>) => { const newValue = event.target.value
if (!debounceEnabled) { table.setGlobalFilter(newValue) onChange?.(newValue) return }
// Render the keystroke immediately, defer the table mutation. setPendingValue(newValue) if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current) debounceTimerRef.current = setTimeout(() => { debounceTimerRef.current = null table.setGlobalFilter(newValue) onChange?.(newValue) }, debounceMs) }, [table, onChange, debounceEnabled, debounceMs], )
const hasValue = currentValue.length > 0
return ( <div className={cn("relative flex flex-1 items-center", className)} role="search" > <Search className="absolute left-3 h-4 w-4 text-muted-foreground" aria-hidden="true" /> <Input placeholder={placeholder} value={currentValue} onChange={handleChange} className="pr-9 pl-9" aria-label="Search table" /> {hasValue && showClearButton && ( <Button variant="ghost" size="sm" onClick={handleClear} className="absolute right-1 h-7 w-7 p-0 hover:bg-muted" type="button" aria-label="Clear search" > <X className="h-3 w-3" aria-hidden="true" /> <span className="sr-only">Clear search</span> </Button> )} </div> )}
/** * @required displayName is required for auto feature detection * @see src/components/niko-table/config/feature-detection.ts */TableSearchFilter.displayName = "TableSearchFilter"Update the import paths to match your project setup.
DataTableSortMenu:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import { useDataTable } from "../core/data-table-context"import { TableSortMenu, type TableSortMenuProps,} from "../filters/table-sort-menu"
type DataTableSortMenuProps<TData> = Omit<TableSortMenuProps<TData>, "table">
/** * A sort menu component that automatically connects to the DataTable context * and allows users to manage multiple sorting criteria. * * @example - Basic usage with default settings * <DataTableSortMenu /> * * @example - Custom alignment and positioning * <DataTableSortMenu align="end" side="bottom" /> * * @example - With debounce for performance * <DataTableSortMenu debounceMs={300} /> * * @example - With throttle for frequent updates * <DataTableSortMenu throttleMs={100} /> * * @example - Custom styling * <DataTableSortMenu className="w-[400px]" /> */export function DataTableSortMenu<TData>(props: DataTableSortMenuProps<TData>) { const { table } = useDataTable<TData>() return <TableSortMenu<TData> table={table} {...props} />}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
DataTableSortMenu.displayName = "DataTableSortMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry *//** * Table sort menu component * @description A sort menu component for DataTable that allows users to manage multiple sorting criteria. Users can add, remove, and reorder sorting fields, as well as select sort directions. */
import type { ColumnSort, SortDirection, Table } from "@tanstack/react-table"import { ArrowDownUp, Trash2, CircleHelp } from "lucide-react"import * as React from "react"
import { Badge } from "@/components/ui/badge"import { Button } from "@/components/ui/button"import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,} from "@/components/ui/command"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"import { Sortable, SortableContent, SortableItem, SortableItemHandle, SortableOverlay,} from "@/components/ui/sortable"import { useKeyboardShortcut } from "../hooks/use-keyboard-shortcut"import { cn } from "@/lib/utils"import { ChevronsUpDown, Grip } from "lucide-react"
// Import sort labels from TableColumnHeader for consistencyimport { SORT_LABELS } from "../config/data-table"import { FILTER_VARIANTS } from "../lib/constants"
interface TableSortItemProps { sort: ColumnSort sortItemId: string columns: { id: string; label: string }[] columnLabels: Map<string, string> onSortUpdate: (sortId: string, updates: Partial<ColumnSort>) => void onSortRemove: (sortId: string) => void getVariantForColumn?: (id: string) => string | undefined className?: string}
function TableSortItem({ sort, sortItemId, columns, columnLabels, onSortUpdate, onSortRemove, getVariantForColumn,}: TableSortItemProps) { const fieldListboxId = `${sortItemId}-field-listbox` const fieldTriggerId = `${sortItemId}-field-trigger` const directionListboxId = `${sortItemId}-direction-listbox`
const [showFieldSelector, setShowFieldSelector] = React.useState(false) const [showDirectionSelector, setShowDirectionSelector] = React.useState(false)
const onItemKeyDown = React.useCallback( (event: React.KeyboardEvent<HTMLLIElement>) => { if ( event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement ) { return }
if (showFieldSelector || showDirectionSelector) { return }
if (["backspace", "delete"].includes(event.key.toLowerCase())) { event.preventDefault() onSortRemove(sort.id) } }, [sort.id, showFieldSelector, showDirectionSelector, onSortRemove], )
const variant = (getVariantForColumn?.(sort.id) as keyof typeof SORT_LABELS | undefined) ?? FILTER_VARIANTS.TEXT const labels = SORT_LABELS[variant] || SORT_LABELS[FILTER_VARIANTS.TEXT]
return ( <SortableItem value={sort.id} asChild> <li id={sortItemId} tabIndex={-1} className="flex items-center gap-2" onKeyDown={onItemKeyDown} > <Popover open={showFieldSelector} onOpenChange={setShowFieldSelector}> <PopoverTrigger asChild> <Button id={fieldTriggerId} aria-controls={fieldListboxId} variant="outline" size="sm" className="w-44 justify-between rounded font-normal" > <span className="truncate">{columnLabels.get(sort.id)}</span> <ChevronsUpDown className="opacity-50" /> </Button> </PopoverTrigger> <PopoverContent id={fieldListboxId} className="w-(--radix-popover-trigger-width) origin-(--radix-popover-content-transform-origin) p-0" > <Command> <CommandInput placeholder="Search fields..." /> <CommandList> <CommandEmpty>No fields found.</CommandEmpty> <CommandGroup> {columns.map(column => ( <CommandItem key={column.id} value={column.id} onSelect={value => onSortUpdate(sort.id, { id: value })} > <span className="truncate">{column.label}</span> </CommandItem> ))} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> <Select open={showDirectionSelector} onOpenChange={setShowDirectionSelector} value={sort.desc ? "desc" : "asc"} onValueChange={(value: SortDirection) => onSortUpdate(sort.id, { desc: value === "desc" }) } > <SelectTrigger aria-controls={directionListboxId} className="h-8 w-24 rounded data-size:h-8" > <SelectValue /> </SelectTrigger> <SelectContent id={directionListboxId} className="min-w-(--radix-select-trigger-width) origin-(--radix-select-content-transform-origin)" > <SelectItem value="asc">{labels.asc}</SelectItem> <SelectItem value="desc">{labels.desc}</SelectItem> </SelectContent> </Select> <Button aria-controls={sortItemId} variant="outline" size="icon" className="size-8 shrink-0 rounded" onClick={() => onSortRemove(sort.id)} > <Trash2 /> </Button> <SortableItemHandle asChild> <Button variant="outline" size="icon" className="size-8 shrink-0 rounded" > <Grip /> </Button> </SortableItemHandle> </li> </SortableItem> )}
export interface TableSortMenuProps<TData> extends React.ComponentProps< typeof PopoverContent> { table: Table<TData> debounceMs?: number throttleMs?: number shallow?: boolean className?: string /** * Callback fired when sorting state changes * Useful for server-side sorting or external state management */ onSortingChange?: (sorting: ColumnSort[]) => void}
export function TableSortMenu<TData>({ table, onSortingChange: externalOnSortingChange, className, ...props}: TableSortMenuProps<TData>) { const getVariantForColumn = React.useCallback( (id: string): string | undefined => table.getAllColumns().find(c => c.id === id)?.columnDef?.meta?.variant, [table], ) // ============================================================================ // State & Refs // ============================================================================ const id = React.useId() const labelId = React.useId() const descriptionId = React.useId() const [open, setOpen] = React.useState(false) const addButtonRef = React.useRef<HTMLButtonElement>(null)
const sorting = table.getState().sorting
// ============================================================================ // Sorting State Management // ============================================================================ const onSortingChange = React.useCallback( (updater: React.SetStateAction<ColumnSort[]>) => { // Resolve the next sorting against the table's current state, not the // closure-captured `sorting` — eliminates any chance of drift if the // callback fires from an interaction queued before the latest render. const nextSorting = typeof updater === "function" ? updater(table.getState().sorting) : updater table.setSorting(nextSorting) externalOnSortingChange?.(nextSorting) }, [table, externalOnSortingChange], )
// ============================================================================ // Column Labels & Available Columns // ============================================================================ const { columnLabels, columns } = React.useMemo(() => { const labels = new Map<string, string>() const sortingIds = new Set(sorting.map(s => s.id)) const availableColumns: { id: string; label: string }[] = []
for (const column of table.getAllColumns()) { if (!column.getCanSort()) continue
const label = column.columnDef.meta?.label ?? column.id labels.set(column.id, label)
if (!sortingIds.has(column.id)) { availableColumns.push({ id: column.id, label }) } }
return { columnLabels: labels, columns: availableColumns, } // Depend on the column set, not just the (stable) table ref. // eslint-disable-next-line react-hooks/exhaustive-deps }, [sorting, table, table.options.columns])
// ============================================================================ // Sort Actions // ============================================================================ const onSortAdd = React.useCallback(() => { const firstColumn = columns[0] if (!firstColumn) return
onSortingChange(prevSorting => [ ...prevSorting, { id: firstColumn.id, desc: false }, ]) }, [columns, onSortingChange])
const onSortUpdate = React.useCallback( (sortId: string, updates: Partial<ColumnSort>) => { onSortingChange(prevSorting => { if (!prevSorting) return prevSorting return prevSorting.map(sort => sort.id === sortId ? { ...sort, ...updates } : sort, ) }) }, [onSortingChange], )
const onSortRemove = React.useCallback( (sortId: string) => { onSortingChange(prevSorting => prevSorting.filter(item => item.id !== sortId), ) }, [onSortingChange], )
const onSortingReset = React.useCallback( () => onSortingChange(table.initialState.sorting), [onSortingChange, table.initialState.sorting], )
// ============================================================================ // Keyboard Shortcuts // ============================================================================ // Toggle sort menu with 'S' key useKeyboardShortcut({ key: "s", onTrigger: () => setOpen(prev => !prev), })
// Reset sorting with Shift+S useKeyboardShortcut({ key: "s", requireShift: true, onTrigger: () => onSortingReset(), condition: () => sorting.length > 0, })
// Trigger button keyboard shortcuts (Backspace/Delete to reset) const onTriggerKeyDown = React.useCallback( (event: React.KeyboardEvent<HTMLButtonElement>) => { if ( ["backspace", "delete"].includes(event.key.toLowerCase()) && sorting.length > 0 ) { event.preventDefault() onSortingReset() } }, [sorting.length, onSortingReset], )
// ============================================================================ // Render // ============================================================================
return ( <Sortable value={sorting} onValueChange={onSortingChange} getItemValue={item => item.id} > <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <Button variant="outline" size="sm" onKeyDown={onTriggerKeyDown} className={className} > <ArrowDownUp /> Sort {sorting.length > 0 && ( <Badge variant="secondary" className="h-[18.24px] rounded-[3.2px] px-[5.12px] font-mono text-[10.4px] font-normal" > {sorting.length} </Badge> )} </Button> </PopoverTrigger> <PopoverContent aria-labelledby={labelId} aria-describedby={descriptionId} className="flex w-full max-w-(--radix-popover-content-available-width) origin-(--radix-popover-content-transform-origin) flex-col gap-3.5 p-4 sm:min-w-[380px]" {...props} > <div className="flex flex-col gap-1"> <div className="flex items-center gap-2"> <h4 id={labelId} className="leading-none font-medium"> {sorting.length > 0 ? "Sort by" : "No sorting applied"} </h4> {sorting.length > 1 && ( <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help text-muted-foreground" /> </TooltipTrigger> <TooltipContent side="right"> The order of fields determines sort priority </TooltipContent> </Tooltip> )} </div> <p id={descriptionId} className={cn( "text-sm text-muted-foreground", sorting.length > 0 && "sr-only", )} > {sorting.length > 0 ? "Modify sorting to organize your rows." : "Add sorting to organize your rows."} </p> </div> {sorting.length > 0 && ( <SortableContent asChild> <ul className="flex max-h-[300px] flex-col gap-2 overflow-y-auto p-1"> {sorting.map(sort => ( <TableSortItem key={sort.id} sort={sort} sortItemId={`${id}-sort-${sort.id}`} columns={columns} columnLabels={columnLabels} onSortUpdate={onSortUpdate} onSortRemove={onSortRemove} getVariantForColumn={getVariantForColumn} /> ))} </ul> </SortableContent> )} <div className="flex w-full items-center gap-2"> <Button size="sm" className="rounded" ref={addButtonRef} onClick={onSortAdd} disabled={columns.length === 0} > Add sort </Button> {sorting.length > 0 && ( <Button variant="outline" size="sm" className="rounded" onClick={onSortingReset} > Reset sorting </Button> )} </div> </PopoverContent> </Popover> <SortableOverlay> <div className="flex items-center gap-2"> <div className="h-8 w-[180px] rounded-sm bg-primary/10" /> <div className="h-8 w-24 rounded-sm bg-primary/10" /> <div className="size-8 shrink-0 rounded-sm bg-primary/10" /> <div className="size-8 shrink-0 rounded-sm bg-primary/10" /> </div> </SortableOverlay> </Sortable> )}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */TableSortMenu.displayName = "TableSortMenu"Update the import paths to match your project setup.
DataTableViewMenu:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import { useDataTable } from "../core/data-table-context"import { TableViewMenu, type TableViewMenuProps,} from "../filters/table-view-menu"
type DataTableViewMenuProps<TData> = Omit<TableViewMenuProps<TData>, "table">
export function DataTableViewMenu<TData>(props: DataTableViewMenuProps<TData>) { const { table } = useDataTable<TData>() return <TableViewMenu table={table} {...props} />}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
DataTableViewMenu.displayName = "DataTableViewMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
/** * A dropdown menu component that allows users to toggle the visibility of table columns. * It uses a popover to display a list of columns with checkboxes. * Users can search for columns and toggle their visibility. */
import type { Column, Table } from "@tanstack/react-table"import { Check, ChevronsUpDown, Settings2 } from "lucide-react"import * as React from "react"import { Button } from "@/components/ui/button"import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,} from "@/components/ui/command"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"import { cn } from "@/lib/utils"import { formatLabel } from "../lib/format"
/** * Derives the display title for a column. * Priority: column.meta.label > formatted column.id */function getColumnTitle<TData>(column: Column<TData, unknown>): string { return column.columnDef.meta?.label ?? formatLabel(column.id)}
export interface TableViewMenuProps<TData> { table: Table<TData> className?: string onColumnVisibilityChange?: (columnId: string, isVisible: boolean) => void}
export function TableViewMenu<TData>({ table, onColumnVisibilityChange,}: TableViewMenuProps<TData>) { /** * PERFORMANCE: Memoize filtered columns to avoid recalculating on every render * * WHY: `getAllColumns().filter()` iterates through all columns and checks properties. * Without memoization, this runs on every render, even when columns haven't changed. * * IMPACT: Prevents unnecessary column filtering operations. * With 20 columns: saves ~0.2-0.5ms per render. * * NOTE: Column visibility changes are tracked via table state in context, * so this memoization correctly updates when visibility changes. * * WHAT: Only recalculates when table instance changes (rare). */ const columns = React.useMemo( () => table .getAllColumns() .filter( column => typeof column.accessorFn !== "undefined" && column.getCanHide(), ), // Depend on the column set, not just the (stable) table ref. // eslint-disable-next-line react-hooks/exhaustive-deps [table, table.options.columns], )
return ( <Popover> <PopoverTrigger asChild> <Button aria-label="Toggle columns" role="combobox" variant="outline" size="sm" className="ml-auto hidden h-8 lg:flex" > <Settings2 /> View <ChevronsUpDown className="ml-auto opacity-50" /> </Button> </PopoverTrigger> <PopoverContent align="end" className="w-fit p-0"> <Command> <CommandInput placeholder="Search columns..." /> <CommandList> <CommandEmpty>No columns found.</CommandEmpty> <CommandGroup> {columns.map(column => ( <CommandItem key={column.id} onSelect={() => { const newVisibility = !column.getIsVisible() column.toggleVisibility(newVisibility) onColumnVisibilityChange?.(column.id, newVisibility) }} > <span className="truncate">{getColumnTitle(column)}</span> <Check className={cn( "ml-auto size-4 shrink-0", column.getIsVisible() ? "opacity-100" : "opacity-0", )} /> </CommandItem> ))} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> )}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
TableViewMenu.displayName = "TableViewMenu"Update the import paths to match your project setup.
DataTableClearFilter:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import { useDataTable } from "../core/data-table-context"import { TableClearFilter, type TableClearFilterProps,} from "../filters/table-clear-filter"
type DataTableClearFilterProps<TData> = Omit< TableClearFilterProps<TData>, "table">
/** * Context-aware clear filter button component that automatically gets the table from DataTableRoot context. * Automatically hides when there are no active filters to clear. * * @example - Clear all filters (default) * <DataTableClearFilter /> * * @example - Only reset column filters, keep search * <DataTableClearFilter enableResetGlobalFilter={false} /> * * @example - Only reset search, keep column filters * <DataTableClearFilter enableResetColumnFilters={false} /> * * @example - Only reset sorting * <DataTableClearFilter enableResetColumnFilters={false} enableResetGlobalFilter={false} /> * * @example - Custom styling and text * <DataTableClearFilter * variant="ghost" * size="sm" * className="text-red-500" * > * Clear All * </DataTableClearFilter> * * @example - Without icon * <DataTableClearFilter showIcon={false}> * Reset Filters * </DataTableClearFilter> */export function DataTableClearFilter<TData>( props: DataTableClearFilterProps<TData>,) { const { table } = useDataTable<TData>() return <TableClearFilter table={table} {...props} />}
DataTableClearFilter.displayName = "DataTableClearFilter""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import * as React from "react"import type { Table } from "@tanstack/react-table"import { Button } from "@/components/ui/button"import { cn } from "@/lib/utils"import { X } from "lucide-react"
export interface TableClearFilterProps<TData> { table: Table<TData> className?: string variant?: "default" | "outline" | "ghost" size?: "default" | "sm" | "lg" showIcon?: boolean children?: React.ReactNode /** * Enable resetting column filters * @default true */ enableResetColumnFilters?: boolean /** * Enable resetting global filter (search) * @default true */ enableResetGlobalFilter?: boolean /** * Enable resetting sorting * @default true */ enableResetSorting?: boolean}
/** * Core clear filter button component that accepts a table prop directly. * Use this when you want to manage the table instance yourself. * * Automatically hides when there are no active filters to clear. * * @example * ```tsx * const table = useReactTable({ ... }) * <TableClearFilter table={table} /> * ``` */export function TableClearFilter<TData>({ table, className, variant = "outline", size = "sm", showIcon = true, children, enableResetColumnFilters = true, enableResetGlobalFilter = true, enableResetSorting = true,}: TableClearFilterProps<TData>) { // Read state directly - should be reactive via table re-renders const state = table.getState() const hasActiveFilters = state.columnFilters.length > 0 const hasGlobalFilter = Boolean(state.globalFilter) const hasSorting = state.sorting.length > 0
// Only check for states that are meant to be reset const hasAnythingToReset = (enableResetColumnFilters && hasActiveFilters) || (enableResetGlobalFilter && hasGlobalFilter) || (enableResetSorting && hasSorting)
const handleClearAll = React.useCallback(() => { if (enableResetColumnFilters) { table.resetColumnFilters() } if (enableResetGlobalFilter) { table.setGlobalFilter("") } if (enableResetSorting) { table.resetSorting() } }, [ table, enableResetColumnFilters, enableResetGlobalFilter, enableResetSorting, ])
if (!hasAnythingToReset) { return null }
return ( <Button variant={variant} size={size} onClick={handleClearAll} className={cn("h-8", className)} > {showIcon && <X className="mr-2 h-4 w-4" />} {children || "Reset"} </Button> )}
TableClearFilter.displayName = "TableClearFilter"Update the import paths to match your project setup.
DataTableExportButton:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import { useDataTable } from "../core/data-table-context"import { TableExportButton, type TableExportButtonProps,} from "../filters/table-export-button"
export type DataTableExportButtonProps<TData> = Omit< TableExportButtonProps<TData>, "table">
/** * Context-aware export button component that automatically gets the table from DataTableRoot context. * This is the recommended way to use the export button in most cases. * * @example * ```tsx * <DataTableRoot data={data} columns={columns}> * <DataTableExportButton filename="products" /> * </DataTableRoot> * ``` */export function DataTableExportButton<TData>({ ...props}: DataTableExportButtonProps<TData>) { const { table } = useDataTable<TData>()
return <TableExportButton table={table} {...props} />}
DataTableExportButton.displayName = "DataTableExportButton""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import * as React from "react"import type { Table } from "@tanstack/react-table"import { Button } from "@/components/ui/button"import { Download } from "lucide-react"
/** * Escape a cell value for CSV output. * Handles strings, numbers, booleans, dates, arrays, null, and undefined. */function escapeCsvValue(value: unknown): string { if (value === null || value === undefined) return ""
if (value instanceof Date) { return `"${value.toISOString()}"` }
if (Array.isArray(value)) { const joined = value.map(String).join(", ") return `"${joined.replace(/"/g, '""')}"` }
if (typeof value === "boolean") return value ? "true" : "false" if (typeof value === "number") return String(value)
// Plain object — JSON-encode rather than letting `String(obj)` produce // the useless "[object Object]". Falls through on cyclic refs. if (typeof value === "object") { try { const json = JSON.stringify(value) return `"${json.replace(/"/g, '""')}"` } catch { // Cyclic / non-serializable — drop through to the String() path. } }
// Default: treat as string and escape quotes const str = String(value) // Wrap in quotes if the value contains commas, quotes, or newlines if (str.includes(",") || str.includes('"') || str.includes("\n")) { return `"${str.replace(/"/g, '""')}"` } return str}
export interface ExportTableToCSVOptions<TData> { /** Filename for the exported CSV (without extension). @default "table" */ filename?: string /** Column IDs to exclude from export. */ excludeColumns?: (keyof TData)[] /** Whether to export only selected rows. @default false */ onlySelected?: boolean /** * Use human-readable labels from `column.columnDef.meta.label` as CSV * header names instead of raw column IDs. * @default false */ useHeaderLabels?: boolean}
/** * Core utility function to export a TanStack Table to CSV. * This is the base implementation that can be used directly or wrapped in components. * * @param table - The TanStack Table instance * @param opts - Export options * * @example * ```ts * import { exportTableToCSV } from "@/components/niko-table/filters/table-export-button" * * // Basic export * exportTableToCSV(table, { filename: "users" }) * * // Export with human-readable headers * exportTableToCSV(table, { filename: "users", useHeaderLabels: true }) * * // Export only selected rows * exportTableToCSV(table, { filename: "selected-users", onlySelected: true }) * ``` */export function exportTableToCSV<TData>( table: Table<TData>, opts: ExportTableToCSVOptions<TData> = {},): void { const { filename = "table", excludeColumns = [], onlySelected = false, useHeaderLabels = false, } = opts
// Retrieve columns, filtering out excluded ones const columns = table .getAllLeafColumns() .filter(column => !excludeColumns.includes(column.id as keyof TData))
// Build header row — use meta.label when available and useHeaderLabels is true const headerRow = columns .map(column => { if (useHeaderLabels) { const label = ( column.columnDef.meta as Record<string, unknown> | undefined )?.label as string | undefined return escapeCsvValue(label ?? column.id) } return escapeCsvValue(column.id) }) .join(",")
// Column IDs for value lookup const columnIds = columns.map(column => column.id)
// Build data rows const rows = onlySelected ? table.getFilteredSelectedRowModel().rows : table.getRowModel().rows
const dataRows = rows.map(row => columnIds.map(id => escapeCsvValue(row.getValue(id))).join(","), )
const csvContent = [headerRow, ...dataRows].join("\n")
// Create blob and trigger download const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }) const url = URL.createObjectURL(blob) const link = document.createElement("a") link.setAttribute("href", url) link.setAttribute("download", `${filename}.csv`) link.style.visibility = "hidden" document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(url)}
export interface TableExportButtonProps<TData> { /** * The table instance from TanStack Table */ table: Table<TData> /** * Optional filename for the exported CSV (without extension) * @default "table" */ filename?: string /** * Columns to exclude from the export */ excludeColumns?: (keyof TData)[] /** * Whether to export only selected rows * @default false */ onlySelected?: boolean /** * Use human-readable labels from column.columnDef.meta.label as CSV * header names instead of raw column IDs. * @default false */ useHeaderLabels?: boolean /** * Button variant * @default "outline" */ variant?: | "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" /** * Button size * @default "sm" */ size?: "default" | "sm" | "lg" | "icon" /** * Custom button label * @default "Export CSV" */ label?: string /** * Show icon * @default true */ showIcon?: boolean /** * Additional className */ className?: string}
/** * Core export button component that accepts a table prop directly. * Use this when you want to manage the table instance yourself. * * @example * ```tsx * const table = useReactTable({ ... }) * <TableExportButton table={table} filename="products" /> * ``` */export function TableExportButton<TData>({ table, filename = "table", excludeColumns, onlySelected = false, useHeaderLabels = false, variant = "outline", size = "sm", label = "Export CSV", showIcon = true, className,}: TableExportButtonProps<TData>) { const handleExport = React.useCallback(() => { exportTableToCSV(table, { filename, excludeColumns, onlySelected, useHeaderLabels, }) }, [table, filename, excludeColumns, onlySelected, useHeaderLabels])
return ( <Button variant={variant} size={size} onClick={handleExport} className={className} > {showIcon && <Download className="mr-2 h-4 w-4" />} {label} </Button> )}
TableExportButton.displayName = "TableExportButton"Update the import paths to match your project setup.
Filter Components
Section titled “Filter Components”DataTableFilterMenu:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import { useDataTable } from "../core/data-table-context"import { TableFilterMenu } from "../filters/table-filter-menu"import { FILTER_VARIANTS } from "../lib/constants"import type { Option } from "../types"
type BaseTableFilterMenuProps<TData> = Omit< React.ComponentProps<typeof TableFilterMenu<TData>>, "table">
interface AutoOptionProps { /** * Automatically generate select/multiSelect options for columns lacking static options * @default true */ autoOptions?: boolean /** Show counts beside each option (computed from rows) */ showCounts?: boolean /** Recompute counts based on currently filtered rows */ dynamicCounts?: boolean /** * If true, only generate options from filtered rows. If false, generate from all rows. * This controls which rows are used to generate the option list itself. * Note: This is separate from dynamicCounts which controls count calculation. * @default true */ limitToFilteredRows?: boolean /** Only generate options for these column ids */ includeColumns?: string[] /** Exclude these column ids from generation */ excludeColumns?: string[] /** Limit number of generated options per column */ limitPerColumn?: number /** * Merge strategy when static options already exist: * - "preserve" keeps user options untouched (default) * - "augment" adds counts to matching values * - "replace" overrides with generated options */ mergeStrategy?: "preserve" | "augment" | "replace"}
type DataTableFilterMenuProps<TData> = BaseTableFilterMenuProps<TData> & AutoOptionProps
/** * A filter menu component that automatically connects to the DataTable context. * Filters are managed directly by the table state - no internal state needed. * * @example - Basic usage * <DataTableFilterMenu /> * * @example - Custom alignment and positioning * <DataTableFilterMenu align="end" side="bottom" /> * * @example - Custom styling * <DataTableFilterMenu className="w-[400px]" /> */export function DataTableFilterMenu<TData>({ autoOptions = true, showCounts = true, dynamicCounts = true, limitToFilteredRows = true, includeColumns, excludeColumns, limitPerColumn, mergeStrategy = "preserve", ...props}: DataTableFilterMenuProps<TData>) { const { table, generatedOptionsMap } = useDataTable<TData>()
// Batch options are computed upstream in DataTableProvider. // Keep local shaping props (include/exclude/limit/showCounts) for API parity. const generatedOptions = React.useMemo(() => { const includeSet = includeColumns ? new Set(includeColumns) : null const excludeSet = excludeColumns ? new Set(excludeColumns) : null
const entries = Object.entries(generatedOptionsMap) .filter(([columnId]) => { if (includeSet && !includeSet.has(columnId)) return false if (excludeSet && excludeSet.has(columnId)) return false return true }) .map(([columnId, options]) => { const limited = typeof limitPerColumn === "number" && limitPerColumn > 0 ? options.slice(0, limitPerColumn) : options const normalized = showCounts ? limited : limited.map(opt => ({ ...opt, count: undefined })) return [columnId, normalized] })
return Object.fromEntries(entries) as Record<string, Option[]> }, [ generatedOptionsMap, includeColumns, excludeColumns, limitPerColumn, showCounts, ])
// Data source selection (dynamicCounts/limitToFilteredRows) now lives in the // provider-level batch computation, so these props are intentionally read-only. void dynamicCounts void limitToFilteredRows
/** * BUG: stale counts on filter changes * * WHY: We mutate `column.columnDef.meta.options` to inject counts. After * the first augment pass, every option already carries `count`. On the * next render we'd read those (now-stale) counts back, so a `count: 5` * pinned at first render would survive even after a filter narrowed the * matching rows to 0. * * IMPACT: Cross-filter narrowing (count-0 hide rule) couldn't fire because * counts were frozen at their first-render value. * * WHAT: Capture each column's caller-supplied options ONCE in a ref and * rebuild `meta.options` from that pristine source on every augment pass. * Counts always come from the fresh `countMap`, with `0` filled in for * values absent from the cross-filtered row set so the count-0 hide rule * has something to act on. */ React.useMemo(() => { if (!autoOptions) return table.getAllColumns().forEach(column => { const meta = (column.columnDef.meta ||= {}) const variant = String(meta.variant ?? FILTER_VARIANTS.TEXT) const isSelectVariant = variant === FILTER_VARIANTS.SELECT const isMultiSelectVariant = variant === FILTER_VARIANTS.MULTI_SELECT
if (!isSelectVariant && !isMultiSelectVariant) return const gen = generatedOptions[column.id] if (!gen || gen.length === 0) return
if (!meta.options) { meta.options = gen return }
if (mergeStrategy === "replace") { meta.options = gen return }
if (mergeStrategy === "augment") { // Stash the caller's pristine options on the meta object itself // (private prop) the first time we see this column — subsequent // augments rebuild from this stash so counts can refresh instead // of being pinned at first-render values. const metaWithStash = meta as typeof meta & { __nikoOriginalOptions?: Option[] } if (!metaWithStash.__nikoOriginalOptions) { metaWithStash.__nikoOriginalOptions = meta.options } const original = metaWithStash.__nikoOriginalOptions const countMap = new Map(gen.map(o => [o.value, o.count])) meta.options = original.map((opt: Option) => ({ ...opt, count: showCounts ? (countMap.get(opt.value) ?? 0) : undefined, })) } // preserve: do nothing }) }, [autoOptions, generatedOptions, mergeStrategy, showCounts, table])
return ( <TableFilterMenu<TData> table={table} precomputedOptions={generatedOptions} {...props} /> )}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */DataTableFilterMenu.displayName = "DataTableFilterMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */// Filter menu module: utilities, hooks (useInitialFilters,// useSyncFiltersWithTable), filter input components, sub-components, and the// `TableFilterMenu` popover.
import type { Column, Table } from "@tanstack/react-table"import { CalendarIcon, Check, ChevronsUpDown, Grip, ListFilter, Trash2,} from "lucide-react"import * as React from "react"
import { TableRangeFilter } from "./table-range-filter"import { Badge } from "@/components/ui/badge"import { Button } from "@/components/ui/button"import { Calendar } from "@/components/ui/calendar"import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,} from "@/components/ui/command"import { Input } from "@/components/ui/input"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"import { Sortable, SortableContent, SortableItem, SortableItemHandle, SortableOverlay,} from "@/components/ui/sortable"import { dataTableConfig } from "../config/data-table"import { getDefaultFilterOperator, getFilterOperators, processFiltersForLogic,} from "../lib/data-table"import { formatDate } from "../lib/format"import { useKeyboardShortcut } from "../hooks/use-keyboard-shortcut"import { cn } from "@/lib/utils"import { FILTER_OPERATORS, FILTER_VARIANTS, JOIN_OPERATORS, ERROR_MESSAGES, KEYBOARD_SHORTCUTS,} from "../lib/constants"import { useGeneratedOptionsForColumn } from "../hooks/use-generated-options"import type { ExtendedColumnFilter, FilterOperator, JoinOperator, Option,} from "../types"
/* ---------- Precomputed options context (avoids per-column row walks) ---------- */const PrecomputedOptionsContext = React.createContext< Record<string, Option[]> | undefined>(undefined)
/* --------------------------------- Utilities -------------------------------- */
/** * Create a deterministic filter ID based on filter properties * This ensures filters can be shared via URL and will have consistent IDs */function createFilterId<TData>( filter: Omit<ExtendedColumnFilter<TData>, "filterId">, index?: number,): string { // Create a deterministic ID based on filter properties // Using a combination that should be unique for each filter configuration const valueStr = typeof filter.value === "string" ? filter.value : JSON.stringify(filter.value)
// Include index as a fallback to ensure uniqueness for URL sharing const indexSuffix = typeof index === "number" ? `-${index}` : ""
return `${filter.id}-${filter.operator}-${filter.variant}-${valueStr}${indexSuffix}` .toLowerCase() .replace(/[^a-z0-9-]/g, "-") .replace(/-+/g, "-") .substring(0, 100) // Limit length to avoid extremely long IDs}
/** * Create a unique key for a filter based on its properties (not filterId) * This allows matching filters even if filterId is changed in the URL */function getFilterKey<TData>(filter: ExtendedColumnFilter<TData>): string { const valueStr = typeof filter.value === "string" ? filter.value : Array.isArray(filter.value) ? filter.value.join(",") : JSON.stringify(filter.value) return `${filter.id}-${filter.operator}-${filter.variant}-${valueStr}`}
/** * Type for filters without filterId (for URL serialization) */type FilterWithoutId<TData> = Omit<ExtendedColumnFilter<TData>, "filterId">
/** * Normalize filters loaded from URL by ensuring they have filterId * If filterId is missing, generate it deterministically * * This allows filters to be stored in URL without filterId, making URLs shorter * and more robust. The filterId is auto-generated when filters are loaded. * * @param filters - Filters that may or may not have filterId * @returns Filters with guaranteed filterId values */function normalizeFiltersFromUrl<TData>( filters: (FilterWithoutId<TData> | ExtendedColumnFilter<TData>)[],): ExtendedColumnFilter<TData>[] { // Quick check: if all filters already have filterIds, return as-is // This preserves object and array references const hasAllIds = filters.every( (f): f is ExtendedColumnFilter<TData> => "filterId" in f && !!f.filterId, ) if (hasAllIds) { return filters as ExtendedColumnFilter<TData>[] }
return filters.map((filter, index) => { // If filterId is missing, generate it if (!("filterId" in filter) || !filter.filterId) { return { ...filter, filterId: createFilterId(filter, index), } as ExtendedColumnFilter<TData> } return filter as ExtendedColumnFilter<TData> })}
/** * Serialize filters for URL (excludes filterId to make URLs shorter) * * OPTIONAL: Use this function when serializing filters to URL to exclude filterId. * The filterId will be auto-generated when filters are loaded from URL via * normalizeFiltersFromUrl(), so it's safe to exclude it. * * Example usage in URL state management: * ```ts * const urlFilters = serializeFiltersForUrl(filters) * setUrlParams({ filters: urlFilters }) * ``` * * @param filters - Filters with filterId * @returns Filters without filterId (suitable for URL storage) */export function serializeFiltersForUrl<TData>( filters: ExtendedColumnFilter<TData>[],): FilterWithoutId<TData>[] { return filters.map(filter => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { filterId, ...filterWithoutId } = filter return filterWithoutId })}
/* --------------------------------- Faceted Component (Inline) -------------------------------- */
/** * Faceted component for single/multi-select filters * Inlined here so users can copy-paste the entire filter menu without external dependencies */
type FacetedValue<Multiple extends boolean> = Multiple extends true ? string[] : string
interface FacetedContextValue<Multiple extends boolean = boolean> { value?: FacetedValue<Multiple> onItemSelect?: (value: string) => void multiple?: Multiple}
const FacetedContext = React.createContext<FacetedContextValue<boolean> | null>( null,)
function useFacetedContext(name: string) { const context = React.useContext(FacetedContext) if (!context) { throw new Error(`\`${name}\` must be within Faceted`) } return context}
interface FacetedProps< Multiple extends boolean = false,> extends React.ComponentProps<typeof Popover> { value?: FacetedValue<Multiple> onValueChange?: (value: FacetedValue<Multiple> | undefined) => void children?: React.ReactNode multiple?: Multiple}
function Faceted<Multiple extends boolean = false>( props: FacetedProps<Multiple>,) { const { open: openProp, onOpenChange: onOpenChangeProp, value, onValueChange, children, multiple = false, ...facetedProps } = props
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false) const isControlled = openProp !== undefined const open = isControlled ? openProp : uncontrolledOpen
const onOpenChange = React.useCallback( (newOpen: boolean) => { if (!isControlled) { setUncontrolledOpen(newOpen) } onOpenChangeProp?.(newOpen) }, [isControlled, onOpenChangeProp], )
const onItemSelect = React.useCallback( (selectedValue: string) => { if (!onValueChange) return
if (multiple) { const currentValue = (Array.isArray(value) ? value : []) as string[] const newValue = currentValue.includes(selectedValue) ? currentValue.filter(v => v !== selectedValue) : [...currentValue, selectedValue] onValueChange(newValue as FacetedValue<Multiple>) } else { if (value === selectedValue) { onValueChange(undefined) } else { onValueChange(selectedValue as FacetedValue<Multiple>) }
requestAnimationFrame(() => onOpenChange(false)) } }, [multiple, value, onValueChange, onOpenChange], )
const contextValue = React.useMemo<FacetedContextValue<typeof multiple>>( () => ({ value, onItemSelect, multiple }), [value, onItemSelect, multiple], )
return ( <FacetedContext.Provider value={contextValue}> <Popover open={open} onOpenChange={onOpenChange} {...facetedProps}> {children} </Popover> </FacetedContext.Provider> )}
function FacetedTrigger(props: React.ComponentProps<typeof PopoverTrigger>) { const { className, children, ...triggerProps } = props
return ( <PopoverTrigger {...triggerProps} className={cn("justify-between text-left", className)} > {children} </PopoverTrigger> )}
interface FacetedBadgeListProps extends React.ComponentProps<"div"> { options?: { label: string; value: string }[] max?: number badgeClassName?: string placeholder?: string}
function FacetedBadgeList(props: FacetedBadgeListProps) { const { options = [], max = 2, placeholder = "Select options...", className, badgeClassName, ...badgeListProps } = props
const context = useFacetedContext("FacetedBadgeList") const values = Array.isArray(context.value) ? context.value : ([context.value].filter(Boolean) as string[])
const getLabel = React.useCallback( (value: string) => { const option = options.find(opt => opt.value === value) return option?.label ?? value }, [options], )
if (!values || values.length === 0) { return ( <div {...badgeListProps} className="flex w-full items-center gap-1 text-muted-foreground" > {placeholder} <ChevronsUpDown className="ml-auto size-4 shrink-0 opacity-50" /> </div> ) }
return ( <div {...badgeListProps} className={cn("flex flex-wrap items-center gap-1", className)} > {values.length > max ? ( <Badge variant="secondary" className={cn("rounded-sm px-1 font-normal", badgeClassName)} > {values.length} selected </Badge> ) : ( values.map(value => ( <Badge key={value} variant="secondary" className={cn("rounded-sm px-1 font-normal", badgeClassName)} > <span className="truncate">{getLabel(value)}</span> </Badge> )) )} </div> )}
function FacetedContent(props: React.ComponentProps<typeof PopoverContent>) { const { className, children, ...contentProps } = props
return ( <PopoverContent {...contentProps} align="start" className={cn( "w-[200px] origin-(--radix-popover-content-transform-origin) p-0", className, )} > <Command>{children}</Command> </PopoverContent> )}
const FacetedInput = CommandInput
const FacetedList = CommandList
const FacetedEmpty = CommandEmpty
const FacetedGroup = CommandGroup
interface FacetedItemProps extends React.ComponentProps<typeof CommandItem> { value: string}
function FacetedItem(props: FacetedItemProps) { const { value, onSelect, className, children, ...itemProps } = props const context = useFacetedContext("FacetedItem")
const isSelected = context.multiple ? Array.isArray(context.value) && context.value.includes(value) : context.value === value
const onItemSelect = React.useCallback( (currentValue: string) => { if (onSelect) { onSelect(currentValue) } else if (context.onItemSelect) { context.onItemSelect(currentValue) } }, [onSelect, context], )
return ( <CommandItem aria-selected={isSelected} data-selected={isSelected} className={cn("gap-2", className)} onSelect={() => onItemSelect(value)} {...itemProps} > <span className={cn( "flex size-4 items-center justify-center rounded-sm border border-primary", isSelected ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible", )} > <Check className="size-4" /> </span> {children} </CommandItem> )}
/** * Normalize join operators after a reorder so the logical relationships * (AND/OR) on adjacent filters survive the move. Matches by filter properties * (not just filterId) so URL-driven filterId changes still work. * * @param originalFilters - Filters in their original order * @param reorderedFilters - Filters in their new order * @returns Normalized filters with correct joinOperator values */function normalizeFilterJoinOperators<TData>( originalFilters: ExtendedColumnFilter<TData>[], reorderedFilters: ExtendedColumnFilter<TData>[],): ExtendedColumnFilter<TData>[] { // If filters are the same or empty, return as-is if ( originalFilters.length === 0 || reorderedFilters.length === 0 || originalFilters.length !== reorderedFilters.length ) { return reorderedFilters }
// Check if order actually changed (using filterId first, then fallback to properties) const orderChangedById = reorderedFilters.some( (filter, index) => filter.filterId !== originalFilters[index]?.filterId, )
// Also check if order changed by comparing filter properties const orderChangedByProps = reorderedFilters.some((filter, index) => { const original = originalFilters[index] if (!original) return true return getFilterKey(filter) !== getFilterKey(original) })
if (!orderChangedById && !orderChangedByProps) { return reorderedFilters }
// Create maps using filterId (primary) and filter properties (fallback) // This allows matching even if filterId is changed in URL const originalIndexMapById = new Map<string, number>() const originalIndexMapByKey = new Map<string, number>()
originalFilters.forEach((filter, index) => { originalIndexMapById.set(filter.filterId, index) originalIndexMapByKey.set(getFilterKey(filter), index) })
// Normalize the reordered filters return reorderedFilters.map((filter, newIndex) => { // First filter always has "and" (it's ignored in evaluation anyway) if (newIndex === 0) { return { ...filter, joinOperator: JOIN_OPERATORS.AND, } }
// Get the previous filter in the new order const previousFilter = reorderedFilters[newIndex - 1]
// Try to find original index using filterId first, then fallback to properties let currentOriginalIndex = originalIndexMapById.get(filter.filterId) ?? -1 let previousOriginalIndex = originalIndexMapById.get(previousFilter.filterId) ?? -1
// If not found by filterId, try matching by properties // This handles the case where filterId was changed in the URL if (currentOriginalIndex === -1) { currentOriginalIndex = originalIndexMapByKey.get(getFilterKey(filter)) ?? -1 } if (previousOriginalIndex === -1) { previousOriginalIndex = originalIndexMapByKey.get(getFilterKey(previousFilter)) ?? -1 }
// If either filter wasn't in original, default to AND // This can happen if filters were added/removed or properties changed if (currentOriginalIndex === -1 || previousOriginalIndex === -1) { return { ...filter, joinOperator: JOIN_OPERATORS.AND, } }
// If filters were adjacent in original order if (Math.abs(currentOriginalIndex - previousOriginalIndex) === 1) { // They were adjacent - use the joinOperator from the filter that came // after the earlier one in original order if (currentOriginalIndex > previousOriginalIndex) { // Current came after previous in original - use current's original joinOperator return { ...filter, joinOperator: originalFilters[currentOriginalIndex].joinOperator, } } else { // Current came before previous in original - use previous's original joinOperator // (which determines how it joins with what was before it) return { ...filter, joinOperator: originalFilters[previousOriginalIndex].joinOperator, } } }
// Filters were not adjacent in original order // Determine relationship by checking if there's an OR operator in the path const startIndex = Math.min(currentOriginalIndex, previousOriginalIndex) const endIndex = Math.max(currentOriginalIndex, previousOriginalIndex)
// Check if any filter between them (or the one after start) has OR const hasOrInPath = originalFilters .slice(startIndex, endIndex + 1) .some((f, idx) => { // Check joinOperator of filters after startIndex return idx > 0 && f.joinOperator === JOIN_OPERATORS.OR })
return { ...filter, joinOperator: hasOrInPath ? JOIN_OPERATORS.OR : JOIN_OPERATORS.AND, } })}
/** * Hook to initialize filters from table state (for URL restoration) * Replaces the initialization useEffect with derived state * * @description This hook runs ONCE on mount to extract initial filter state from: * 1. Controlled filters (if provided via props) * 2. Table's globalFilter (for OR logic filters) * 3. Table's columnFilters (for AND logic filters) * * @debug Check React DevTools > Components > useInitialFilters to see returned value */function useInitialFilters<TData>( table: Table<TData>, controlledFilters?: ExtendedColumnFilter<TData>[],): ExtendedColumnFilter<TData>[] { // Derive initial filters from table state only once on mount const initialFilters = React.useMemo(() => { // If controlled, use controlled filters (normalize to ensure filterId exists) if (controlledFilters) { const normalized = normalizeFiltersFromUrl(controlledFilters) if (process.env.NODE_ENV === "development") { console.log("[useInitialFilters] Using controlled filters:", normalized) } return normalized }
// Check if table has globalFilter with filters object (OR filters) const globalFilter = table.getState().globalFilter if ( globalFilter && typeof globalFilter === "object" && "filters" in globalFilter ) { const filterObj = globalFilter as { filters: (FilterWithoutId<TData> | ExtendedColumnFilter<TData>)[] } const normalized = normalizeFiltersFromUrl(filterObj.filters) if (process.env.NODE_ENV === "development") { console.log( "[useInitialFilters] Extracted from globalFilter:", normalized, ) } return normalized }
// Otherwise check columnFilters (AND filters) const columnFilters = table.getState().columnFilters if (columnFilters && columnFilters.length > 0) { const extractedFilters = columnFilters .map(cf => cf.value) .filter( (v): v is FilterWithoutId<TData> | ExtendedColumnFilter<TData> => v !== null && typeof v === "object" && "id" in v, ) if (extractedFilters.length > 0) { const normalized = normalizeFiltersFromUrl(extractedFilters) if (process.env.NODE_ENV === "development") { console.log( "[useInitialFilters] Extracted from columnFilters:", normalized, ) } return normalized } }
if (process.env.NODE_ENV === "development") { console.log("[useInitialFilters] No initial filters found") } return [] // Only run once on mount - we don't want to reset when table state changes // eslint-disable-next-line react-hooks/exhaustive-deps }, [])
return initialFilters}
// columnFilters-only sync (globalFilter stays free for other uses). OR/MIXED// logic is encoded by writing `joinOperator` into `table.options.meta` and// reading it from a custom pre-filter, since TanStack combines cross-column// filters with AND by default.function useSyncFiltersWithTable<TData>( table: Table<TData>, filters: ExtendedColumnFilter<TData>[], isControlled: boolean,) { // Track if we've done initial sync const hasSyncedRef = React.useRef(false)
// Use core utility to process filters and determine logic const filterLogic = React.useMemo( () => processFiltersForLogic(filters), [filters], )
// Update table meta immediately (no effect needed, happens during render) // This is safe because we're only mutating table.options.meta, not triggering re-renders // Custom filter logic can read this meta to apply correct join operators if (table.options.meta) { // eslint-disable-next-line react-hooks/immutability table.options.meta.hasIndividualJoinOperators = true // eslint-disable-next-line react-hooks/immutability table.options.meta.joinOperator = filterLogic.joinOperator }
// Sync with table state only when filters change (and not in controlled mode) React.useEffect(() => { // Skip if controlled - parent handles table state if (isControlled) { if (process.env.NODE_ENV === "development") { console.log( "[useSyncFiltersWithTable] Controlled mode - skipping table sync", ) } return }
// Mark that we've synced at least once hasSyncedRef.current = true
if (process.env.NODE_ENV === "development") { console.log("[useSyncFiltersWithTable] Syncing filters:", { filterCount: filters.length, hasOrFilters: filterLogic.hasOrFilters, hasSameColumnFilters: filterLogic.hasSameColumnFilters, joinOperator: filterLogic.joinOperator, filters: filters.map(f => ({ id: f.id, operator: f.operator, joinOp: f.joinOperator, value: f.value, })), }) }
// Use core utility to determine routing if (filterLogic.shouldUseGlobalFilter) { table.resetColumnFilters()
table.setGlobalFilter({ filters: filterLogic.processedFilters, joinOperator: filterLogic.joinOperator, })
if (process.env.NODE_ENV === "development") { console.log( "[useSyncFiltersWithTable] Set globalFilter (OR/MIXED logic)", { hasOrFilters: filterLogic.hasOrFilters, hasSameColumnFilters: filterLogic.hasSameColumnFilters, }, ) } } else { // BUILD COLUMN FILTERS ARRAY // Each filter becomes a separate columnFilter entry // TanStack Table will AND them together by default, but we can override with custom logic const columnFilters = filterLogic.processedFilters.map(filter => ({ id: filter.id, value: { operator: filter.operator, value: filter.value, id: filter.id, filterId: filter.filterId, joinOperator: filter.joinOperator, }, }))
table.setColumnFilters(columnFilters)
if (process.env.NODE_ENV === "development") { console.log( "[useSyncFiltersWithTable] Set columnFilters (columnFilters-only architecture)", "- pure AND logic", ) } } }, [filters, filterLogic, table, isControlled])}
interface TableFilterMenuProps<TData> extends React.ComponentProps< typeof PopoverContent> { table: Table<TData> filters?: ExtendedColumnFilter<TData>[] onFiltersChange?: (filters: ExtendedColumnFilter<TData>[] | null) => void joinOperator?: JoinOperator onJoinOperatorChange?: (operator: JoinOperator) => void /** * Precomputed options map from batch generation. When provided, * faceted selects skip per-column row scans. */ precomputedOptions?: Record<string, Option[]>}
export function TableFilterMenu<TData>({ table, filters: controlledFilters, onFiltersChange: controlledOnFiltersChange, precomputedOptions, // Legacy properties ignored: joinOperator, onJoinOperatorChange - now uses individual joinOperators ...props}: Omit< TableFilterMenuProps<TData>, "joinOperator" | "onJoinOperatorChange"> & { joinOperator?: JoinOperator onJoinOperatorChange?: (operator: JoinOperator) => void}) { const id = React.useId() const labelId = React.useId() const descriptionId = React.useId() const [open, setOpen] = React.useState(false) const addButtonRef = React.useRef<HTMLButtonElement>(null)
// Initialize filters from table state (replaces initialization useEffect) const initialFilters = useInitialFilters(table, controlledFilters) const [internalFilters, setInternalFilters] = React.useState(initialFilters)
// Use controlled values if provided, otherwise use internal state const filters = controlledFilters ?? internalFilters const isControlled = Boolean(controlledFilters)
// Handler that works with both controlled and internal state const onFiltersChange = React.useCallback( (newFilters: ExtendedColumnFilter<TData>[] | null) => { if (controlledOnFiltersChange) { controlledOnFiltersChange(newFilters) } else { setInternalFilters(newFilters ?? []) } }, [controlledOnFiltersChange], )
// Sync filters with table state (replaces sync useEffect) useSyncFiltersWithTable(table, filters, isControlled)
// Legacy global join operator - replaced with individual join operators per filter const onJoinOperatorChange = React.useCallback(() => { // No-op: Individual join operators handle this functionality console.warn(ERROR_MESSAGES.DEPRECATED_GLOBAL_JOIN_OPERATOR) }, [])
const columns = React.useMemo(() => { return table .getAllColumns() .filter(column => column.columnDef.enableColumnFilter) // Depend on the column set, not just the (stable) table ref. // eslint-disable-next-line react-hooks/exhaustive-deps }, [table, table.options.columns])
const onFilterAdd = React.useCallback(() => { const column = columns[0]
if (!column) return
const filterWithoutId = { id: column.id as Extract<keyof TData, string>, value: "", variant: column.columnDef.meta?.variant ?? FILTER_VARIANTS.TEXT, operator: getDefaultFilterOperator( column.columnDef.meta?.variant ?? FILTER_VARIANTS.TEXT, ), joinOperator: JOIN_OPERATORS.AND, // Default to AND for new filters }
// Use current filter length as index to ensure unique IDs const newFilterIndex = filters.length
onFiltersChange([ ...filters, { ...filterWithoutId, filterId: createFilterId(filterWithoutId, newFilterIndex), }, ]) }, [columns, filters, onFiltersChange])
const onFilterUpdate = React.useCallback( ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => { const updatedFilters = filters.map(filter => { if (filter.filterId === filterId) { return { ...filter, ...updates } as ExtendedColumnFilter<TData> } return filter }) onFiltersChange(updatedFilters) }, [filters, onFiltersChange], )
const onFilterRemove = React.useCallback( (filterId: string) => { const updatedFilters = filters.filter( filter => filter.filterId !== filterId, ) onFiltersChange(updatedFilters) requestAnimationFrame(() => { addButtonRef.current?.focus() }) }, [filters, onFiltersChange], )
const onFiltersReset = React.useCallback(() => { onFiltersChange(null) onJoinOperatorChange?.() // Legacy - individual filters handle their own join operators }, [onFiltersChange, onJoinOperatorChange])
// Toggle filter menu with 'F' key useKeyboardShortcut({ key: KEYBOARD_SHORTCUTS.FILTER_TOGGLE, onTrigger: () => setOpen(prev => !prev), })
// Remove last filter with Shift+F useKeyboardShortcut({ key: KEYBOARD_SHORTCUTS.FILTER_REMOVE, requireShift: true, onTrigger: () => { if (filters.length > 0) { onFilterRemove(filters[filters.length - 1]?.filterId ?? "") } }, condition: () => filters.length > 0, })
// Handle filter reordering with join operator normalization const handleFiltersReorder = React.useCallback( (reorderedFilters: ExtendedColumnFilter<TData>[]) => { // Normalize join operators when filters are reordered const normalizedFilters = normalizeFilterJoinOperators( filters, reorderedFilters, ) onFiltersChange(normalizedFilters) }, [filters, onFiltersChange], )
return ( <PrecomputedOptionsContext.Provider value={precomputedOptions}> <Sortable value={filters} onValueChange={handleFiltersReorder} getItemValue={item => item.filterId} > <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> <Button variant="outline" size="sm" title="Open filter menu (F)"> <ListFilter /> Filter {filters.length > 0 && ( <Badge variant="secondary" className="h-[18.24px] rounded-[3.2px] px-[5.12px] font-mono text-[10.4px] font-normal" > {filters.length} </Badge> )} </Button> </PopoverTrigger> <PopoverContent aria-describedby={descriptionId} aria-labelledby={labelId} className="flex w-full max-w-(--radix-popover-content-available-width) origin-(--radix-popover-content-transform-origin) flex-col gap-3.5 p-4 sm:min-w-[380px]" {...props} > <div className="flex flex-col gap-1"> <h4 id={labelId} className="leading-none font-medium"> {filters.length > 0 ? "Filters" : "No filters applied"} </h4> <p id={descriptionId} className={cn( "text-sm text-muted-foreground", filters.length > 0 && "sr-only", )} > {filters.length > 0 ? "Modify filters to refine your rows." : "Add filters to refine your rows."} </p> </div> {filters.length > 0 ? ( <SortableContent asChild> <ul className="flex max-h-[300px] flex-col gap-2 overflow-y-auto p-1"> {filters.map((filter, index) => ( <TableFilterItem<TData> key={filter.filterId} filter={filter} index={index} filterItemId={`${id}-filter-${filter.filterId}`} table={table} columns={columns} onFilterUpdate={onFilterUpdate} onFilterRemove={onFilterRemove} /> ))} </ul> </SortableContent> ) : null} <div className="flex w-full items-center gap-2"> <Button size="sm" className="rounded" ref={addButtonRef} onClick={onFilterAdd} title="Add a new filter" > Add filter </Button> {filters.length > 0 ? ( <Button variant="outline" size="sm" className="rounded" onClick={onFiltersReset} title="Clear all filters" > Reset filters </Button> ) : null} </div> </PopoverContent> </Popover> <SortableOverlay> <div className="flex items-center gap-2"> <div className="h-8 min-w-[72px] rounded-sm bg-primary/10" /> <div className="h-8 w-32 rounded-sm bg-primary/10" /> <div className="h-8 w-32 rounded-sm bg-primary/10" /> <div className="h-8 min-w-36 flex-1 rounded-sm bg-primary/10" /> <div className="size-8 shrink-0 rounded-sm bg-primary/10" /> <div className="size-8 shrink-0 rounded-sm bg-primary/10" /> </div> </SortableOverlay> </Sortable> </PrecomputedOptionsContext.Provider> )}
interface TableFilterItemProps<TData> { filter: ExtendedColumnFilter<TData> index: number filterItemId: string table: Table<TData> columns: Column<TData>[] onFilterUpdate: ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => void onFilterRemove: (filterId: string) => void}
function TableFilterItem<TData>({ filter, index, filterItemId, table, columns, onFilterUpdate, onFilterRemove,}: TableFilterItemProps<TData>) { const [showFieldSelector, setShowFieldSelector] = React.useState(false) const [showOperatorSelector, setShowOperatorSelector] = React.useState(false) const [showValueSelector, setShowValueSelector] = React.useState(false)
const column = columns.find(column => column.id === filter.id) const inputId = `${filterItemId}-input` const columnMeta = column?.columnDef.meta
// Handle keyboard shortcuts for removing filters const onItemKeyDown = React.useCallback( (event: React.KeyboardEvent<HTMLLIElement>) => { if ( event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement ) { return }
if (showFieldSelector || showOperatorSelector || showValueSelector) { return }
const key = event.key.toLowerCase() if ( key === KEYBOARD_SHORTCUTS.BACKSPACE || key === KEYBOARD_SHORTCUTS.DELETE ) { event.preventDefault() onFilterRemove(filter.filterId) } }, [ filter.filterId, showFieldSelector, showOperatorSelector, showValueSelector, onFilterRemove, ], )
if (!column) return null
return ( <SortableItem value={filter.filterId} asChild> <li id={filterItemId} tabIndex={-1} className="flex items-center gap-2" onKeyDown={onItemKeyDown} > {/* Join operator (AND/OR) or "Where" for first filter */} <FilterJoinOperator filter={filter} index={index} filterItemId={filterItemId} onFilterUpdate={onFilterUpdate} />
{/* Field selector */} <FilterFieldSelector filter={filter} filterItemId={filterItemId} columns={columns} onFilterUpdate={onFilterUpdate} showFieldSelector={showFieldSelector} setShowFieldSelector={setShowFieldSelector} />
{/* Operator selector (equals, contains, etc.) */} <FilterOperatorSelector filter={filter} filterItemId={filterItemId} onFilterUpdate={onFilterUpdate} showOperatorSelector={showOperatorSelector} setShowOperatorSelector={setShowOperatorSelector} />
{/* Value input (text, number, select, date, etc.) */} <div className="min-w-36 flex-1"> <FilterValueInput filter={filter} inputId={inputId} table={table} column={column} columnMeta={columnMeta} onFilterUpdate={onFilterUpdate} showValueSelector={showValueSelector} setShowValueSelector={setShowValueSelector} /> </div>
{/* Remove button */} <Button aria-controls={filterItemId} variant="outline" size="icon" className="size-8 rounded" onClick={() => onFilterRemove(filter.filterId)} title="Remove filter" > <Trash2 /> </Button>
{/* Drag handle */} <SortableItemHandle asChild> <Button variant="outline" size="icon" className="size-8 rounded" title="Drag to reorder filters" > <Grip /> </Button> </SortableItemHandle> </li> </SortableItem> )}
/* ----------------------------- Filter Input Components ---------------------------- */
interface FilterInputProps<TData> { filter: ExtendedColumnFilter<TData> inputId: string table: Table<TData> column: Column<TData> columnMeta?: Column<TData>["columnDef"]["meta"] onFilterUpdate: ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => void showValueSelector: boolean setShowValueSelector: (value: boolean) => void}
/** * Empty state filter input for isEmpty/isNotEmpty operators */function FilterEmptyInput<TData>({ inputId, columnMeta, filter,}: Pick<FilterInputProps<TData>, "inputId" | "columnMeta" | "filter">) { return ( <div id={inputId} role="status" aria-label={`${columnMeta?.label} filter is ${ filter.operator === FILTER_OPERATORS.EMPTY ? "empty" : "not empty" }`} aria-live="polite" className="h-8 w-full rounded border bg-transparent dark:bg-input/30" /> )}FilterEmptyInput.displayName = "FilterEmptyInput"
/** * Text or number input for text/number/range variants */function FilterTextNumberInput<TData>({ filter, inputId, columnMeta, onFilterUpdate,}: Pick< FilterInputProps<TData>, "filter" | "inputId" | "columnMeta" | "onFilterUpdate">) { const isNumber = filter.variant === FILTER_VARIANTS.NUMBER || filter.variant === FILTER_VARIANTS.RANGE
return ( <Input id={inputId} type={isNumber ? FILTER_VARIANTS.NUMBER : FILTER_VARIANTS.TEXT} aria-label={`${columnMeta?.label} filter value`} aria-describedby={`${inputId}-description`} inputMode={isNumber ? "numeric" : undefined} placeholder={columnMeta?.placeholder ?? "Enter a value..."} className="h-8 w-full rounded" value={typeof filter.value === "string" ? filter.value : ""} onChange={event => onFilterUpdate(filter.filterId, { value: String(event.target.value), }) } /> )}FilterTextNumberInput.displayName = "FilterTextNumberInput"
/** * Boolean select input */function FilterBooleanSelect<TData>({ filter, inputId, columnMeta, onFilterUpdate, showValueSelector, setShowValueSelector,}: FilterInputProps<TData>) { if (Array.isArray(filter.value)) return null
const inputListboxId = `${inputId}-listbox`
return ( <Select open={showValueSelector} onOpenChange={setShowValueSelector} value={filter.value} onValueChange={value => onFilterUpdate(filter.filterId, { value, }) } > <SelectTrigger id={inputId} aria-controls={inputListboxId} aria-label={`${columnMeta?.label} boolean filter`} size="sm" className="w-full rounded" > <SelectValue placeholder={filter.value ? "True" : "False"} /> </SelectTrigger> <SelectContent id={inputListboxId}> <SelectItem value="true">True</SelectItem> <SelectItem value="false">False</SelectItem> </SelectContent> </Select> )}FilterBooleanSelect.displayName = "FilterBooleanSelect"
/** * Select/multi-select faceted input */function FilterFacetedSelect<TData>({ filter, inputId, table, column, columnMeta, onFilterUpdate, showValueSelector, setShowValueSelector,}: FilterInputProps<TData>) { const inputListboxId = `${inputId}-listbox` const multiple = filter.variant === FILTER_VARIANTS.MULTI_SELECT const selectedValues = multiple ? Array.isArray(filter.value) ? filter.value : [] : typeof filter.value === "string" ? filter.value : undefined
// Resolve options: prefer static meta.options, then precomputed batch, // and only then fall back to per-column generation. const precomputedOptions = React.useContext(PrecomputedOptionsContext) const needsPerColumnGeneration = !precomputedOptions?.[column.id] && !columnMeta?.options?.length const perColumnGenerated = useGeneratedOptionsForColumn( table, needsPerColumnGeneration ? column.id : "__noop__", ) const generatedOptions = precomputedOptions?.[column.id] ?? perColumnGenerated const options = columnMeta?.options?.length ? columnMeta.options : generatedOptions
return ( <Faceted open={showValueSelector} onOpenChange={setShowValueSelector} value={selectedValues} onValueChange={value => { onFilterUpdate(filter.filterId, { value, }) }} multiple={multiple} > <FacetedTrigger asChild> <Button id={inputId} aria-controls={inputListboxId} aria-label={`${columnMeta?.label} filter value${multiple ? "s" : ""}`} variant="outline" size="sm" className="w-full rounded font-normal" title={`Select ${columnMeta?.label?.toLowerCase() ?? "option"}${multiple ? "s" : ""}`} > <FacetedBadgeList options={options} placeholder={ columnMeta?.placeholder ?? `Select option${multiple ? "s" : ""}...` } /> </Button> </FacetedTrigger> <FacetedContent id={inputListboxId} className="w-[200px] origin-(--radix-popover-content-transform-origin)" > <FacetedInput aria-label={`Search ${columnMeta?.label} options`} placeholder={columnMeta?.placeholder ?? "Search options..."} /> <FacetedList> <FacetedEmpty>No options found.</FacetedEmpty> <FacetedGroup> {/* Cross-filter narrowing: hide options at count 0 (matches the rule used by `TableColumnFacetedFilterMenu`). Pure label-only option lists (no counts) render unchanged. */} {options ?.filter((option: Option) => option.count !== 0) .map((option: Option) => ( <FacetedItem key={option.value} value={option.value}> {option.icon && <option.icon />} <span>{option.label}</span> {option.count && ( <span className="ml-auto font-mono text-xs"> {option.count} </span> )} </FacetedItem> ))} </FacetedGroup> </FacetedList> </FacetedContent> </Faceted> )}
/** * Date picker input for date/dateRange variants */function FilterDatePicker<TData>({ filter, inputId, columnMeta, onFilterUpdate, showValueSelector, setShowValueSelector,}: FilterInputProps<TData>) { const inputListboxId = `${inputId}-listbox`
const dateValue = Array.isArray(filter.value) ? filter.value.filter(Boolean) : [filter.value, filter.value].filter(Boolean)
const displayValue = filter.operator === FILTER_OPERATORS.BETWEEN && dateValue.length === 2 ? `${formatDate(new Date(Number(dateValue[0])))} - ${formatDate( new Date(Number(dateValue[1])), )}` : dateValue[0] ? formatDate(new Date(Number(dateValue[0]))) : "Pick a date"
return ( <Popover open={showValueSelector} onOpenChange={setShowValueSelector}> <PopoverTrigger asChild> <Button id={inputId} aria-controls={inputListboxId} aria-label={`${columnMeta?.label} date filter`} variant="outline" size="sm" className={cn( "w-full justify-start rounded text-left font-normal", !filter.value && "text-muted-foreground", )} title={`Select ${columnMeta?.label?.toLowerCase() ?? FILTER_VARIANTS.DATE}${filter.operator === FILTER_OPERATORS.BETWEEN ? " range" : ""}`} > <CalendarIcon /> <span className="truncate">{displayValue}</span> </Button> </PopoverTrigger> <PopoverContent id={inputListboxId} align="start" className="w-auto origin-(--radix-popover-content-transform-origin) p-0" > {filter.operator === FILTER_OPERATORS.BETWEEN ? ( <Calendar aria-label={`Select ${columnMeta?.label} date range`} mode={FILTER_VARIANTS.RANGE} captionLayout="dropdown" selected={ dateValue.length === 2 ? { from: new Date(Number(dateValue[0])), to: new Date(Number(dateValue[1])), } : { from: new Date(), to: new Date(), } } onSelect={date => { onFilterUpdate(filter.filterId, { value: date ? [ (date.from?.getTime() ?? "").toString(), (date.to?.getTime() ?? "").toString(), ] : [], }) }} /> ) : ( <Calendar aria-label={`Select ${columnMeta?.label} date`} mode="single" captionLayout="dropdown" selected={dateValue[0] ? new Date(Number(dateValue[0])) : undefined} onSelect={date => { onFilterUpdate(filter.filterId, { value: (date?.getTime() ?? "").toString(), }) }} /> )} </PopoverContent> </Popover> )}
/** * Main filter input renderer - delegates to specific input components */function FilterValueInput<TData>(props: FilterInputProps<TData>) { const { filter, column, inputId, onFilterUpdate } = props
// Empty state for isEmpty/isNotEmpty operators if ( filter.operator === FILTER_OPERATORS.EMPTY || filter.operator === FILTER_OPERATORS.NOT_EMPTY ) { return <FilterEmptyInput {...props} /> }
// Variant-specific inputs switch (filter.variant) { case FILTER_VARIANTS.TEXT: case FILTER_VARIANTS.NUMBER: case FILTER_VARIANTS.RANGE: { // Range filter for isBetween operator if ( (filter.variant === FILTER_VARIANTS.RANGE && filter.operator === FILTER_OPERATORS.BETWEEN) || filter.operator === FILTER_OPERATORS.BETWEEN ) { return ( <TableRangeFilter filter={filter} column={column} inputId={inputId} onFilterUpdate={onFilterUpdate} /> ) }
return <FilterTextNumberInput {...props} /> }
case FILTER_VARIANTS.BOOLEAN: return <FilterBooleanSelect {...props} />
case FILTER_VARIANTS.SELECT: case FILTER_VARIANTS.MULTI_SELECT: return <FilterFacetedSelect {...props} />
case FILTER_VARIANTS.DATE: case FILTER_VARIANTS.DATE_RANGE: return <FilterDatePicker {...props} />
default: return null }}FilterValueInput.displayName = "FilterValueInput"FilterFacetedSelect.displayName = "FilterFacetedSelect"FilterDatePicker.displayName = "FilterDatePicker"
/* ----------------------- Filter Item Sub-Components ----------------------- */
/** * Join operator selector (AND/OR) for filters after the first one */function FilterJoinOperator<TData>({ filter, index, filterItemId, onFilterUpdate,}: { filter: ExtendedColumnFilter<TData> index: number filterItemId: string onFilterUpdate: ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => void}) { const joinOperatorListboxId = `${filterItemId}-join-operator-listbox`
if (index === 0) { return ( <div className="min-w-[72px] text-center"> <span className="text-sm text-muted-foreground">Where</span> </div> ) }
return ( <div className="min-w-[72px] text-center"> <Select value={filter.joinOperator || JOIN_OPERATORS.AND} onValueChange={(value: JoinOperator) => onFilterUpdate(filter.filterId, { joinOperator: value }) } > <SelectTrigger aria-label="Select join operator" aria-controls={joinOperatorListboxId} size="sm" className="rounded lowercase" > <SelectValue placeholder={filter.joinOperator || "and"} /> </SelectTrigger> <SelectContent id={joinOperatorListboxId} position="popper" className="min-w-(--radix-select-trigger-width) lowercase" > {dataTableConfig.joinOperators.map(operator => ( <SelectItem key={operator} value={operator}> {operator} </SelectItem> ))} </SelectContent> </Select> </div> )}FilterJoinOperator.displayName = "FilterJoinOperator"
/** * Field selector for choosing which column to filter */function FilterFieldSelector<TData>({ filter, filterItemId, columns, onFilterUpdate, showFieldSelector, setShowFieldSelector,}: { filter: ExtendedColumnFilter<TData> filterItemId: string columns: Column<TData>[] onFilterUpdate: ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => void showFieldSelector: boolean setShowFieldSelector: (value: boolean) => void}) { const fieldListboxId = `${filterItemId}-field-listbox`
return ( <Popover open={showFieldSelector} onOpenChange={setShowFieldSelector}> <PopoverTrigger asChild> <Button aria-controls={fieldListboxId} variant="outline" size="sm" className="w-32 justify-between rounded font-normal" title="Select field to filter" > <span className="truncate"> {columns.find(column => column.id === filter.id)?.columnDef.meta ?.label ?? "Select field"} </span> <ChevronsUpDown className="opacity-50" /> </Button> </PopoverTrigger> <PopoverContent id={fieldListboxId} align="start" className="w-40 origin-(--radix-popover-content-transform-origin) p-0" > <Command> <CommandInput placeholder="Search fields..." /> <CommandList> <CommandEmpty>No fields found.</CommandEmpty> <CommandGroup> {columns.map(column => ( <CommandItem key={column.id} value={column.id} onSelect={value => { onFilterUpdate(filter.filterId, { id: value as Extract<keyof TData, string>, variant: column.columnDef.meta?.variant ?? FILTER_VARIANTS.TEXT, operator: getDefaultFilterOperator( column.columnDef.meta?.variant ?? FILTER_VARIANTS.TEXT, ), value: "", })
setShowFieldSelector(false) }} > <span className="truncate"> {column.columnDef.meta?.label} </span> <Check className={cn( "ml-auto", column.id === filter.id ? "opacity-100" : "opacity-0", )} /> </CommandItem> ))} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> )}FilterFieldSelector.displayName = "FilterFieldSelector"
/** * Operator selector for choosing filter operation (equals, contains, etc.) */function FilterOperatorSelector<TData>({ filter, filterItemId, onFilterUpdate, showOperatorSelector, setShowOperatorSelector,}: { filter: ExtendedColumnFilter<TData> filterItemId: string onFilterUpdate: ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => void showOperatorSelector: boolean setShowOperatorSelector: (value: boolean) => void}) { const operatorListboxId = `${filterItemId}-operator-listbox` const filterOperators = getFilterOperators(filter.variant)
return ( <Select open={showOperatorSelector} onOpenChange={setShowOperatorSelector} value={filter.operator} onValueChange={(value: FilterOperator) => onFilterUpdate(filter.filterId, { operator: value, value: value === FILTER_OPERATORS.EMPTY || value === FILTER_OPERATORS.NOT_EMPTY ? "" : filter.value, }) } > <SelectTrigger aria-controls={operatorListboxId} size="sm" className="w-32 rounded lowercase" > <div className="truncate"> <SelectValue placeholder={filter.operator} /> </div> </SelectTrigger> <SelectContent id={operatorListboxId} className="origin-(--radix-select-content-transform-origin)" > {filterOperators.map(operator => ( <SelectItem key={operator.value} value={operator.value} className="lowercase" > {operator.label} </SelectItem> ))} </SelectContent> </Select> )}FilterOperatorSelector.displayName = "FilterOperatorSelector"
/* ----------------------------- Main Components ---------------------------- */
// Add displayName to DataTableFilterItem for React DevToolsinterface DataTableFilterItemType { <TData>(props: TableFilterItemProps<TData>): React.JSX.Element | null displayName?: string}
;(TableFilterItem as DataTableFilterItemType).displayName = "DataTableFilterItem"
/** * @required displayName is required for auto feature detection * @see src/components/niko-table/config/feature-detection.ts */TableFilterMenu.displayName = "TableFilterMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry *//** * Table range filter component * @description A range filter component for DataTable that allows users to filter data based on numerical ranges. */
import type { Column } from "@tanstack/react-table"import * as React from "react"
import { Input } from "@/components/ui/input"import { cn } from "@/lib/utils"import type { ExtendedColumnFilter } from "../types"
interface TableRangeFilterProps<TData> extends React.ComponentProps<"div"> { filter: ExtendedColumnFilter<TData> column: Column<TData> inputId: string onFilterUpdate: ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => void}
export function TableRangeFilter<TData>({ filter, column, inputId, onFilterUpdate, className, ...props}: TableRangeFilterProps<TData>) { const meta = column.columnDef.meta
// Capture faceted min/max as scalars so the memo refreshes on data change // (column ref alone is stable across faceted-row updates). const metaRange = column.columnDef.meta?.range const facetedValues = column.getFacetedMinMaxValues() const facetedMin = facetedValues?.[0] const facetedMax = facetedValues?.[1] const [min, max] = React.useMemo<[number, number]>(() => { if (Array.isArray(metaRange) && metaRange.length === 2) { const [a, b] = metaRange as [number, number] return [a, b] } if (facetedMin != null && facetedMax != null) { return [Number(facetedMin), Number(facetedMax)] } return [0, 100] }, [metaRange, facetedMin, facetedMax])
// Plain-string formatter — `<input type="number">` requires a parsable // value, so locale-formatted output (commas, NBSPs) breaks the input. const formatValue = React.useCallback( (value: string | number | undefined) => { if (value === undefined || value === "") return "" const numValue = Number(value) return Number.isNaN(numValue) ? "" : String(numValue) }, [], )
const value = React.useMemo(() => { if (Array.isArray(filter.value)) return filter.value.map(formatValue) return [formatValue(filter.value), ""] }, [filter.value, formatValue])
const onRangeValueChange = React.useCallback( (value: string | number, isMin?: boolean) => { const numValue = Number(value) const currentValues = Array.isArray(filter.value) ? filter.value : ["", ""] const otherValue = isMin ? (currentValues[1] ?? "") : (currentValues[0] ?? "")
if ( value === "" || (!Number.isNaN(numValue) && (isMin ? numValue >= min && numValue <= (Number(otherValue) || max) : numValue <= max && numValue >= (Number(otherValue) || min))) ) { onFilterUpdate(filter.filterId, { value: isMin ? [String(value), String(otherValue)] : [String(otherValue), String(value)], }) } }, [filter.filterId, filter.value, min, max, onFilterUpdate], )
return ( <div data-slot="range" className={cn("flex w-full items-center gap-2", className)} {...props} > <Input id={`${inputId}-min`} type="number" aria-label={`${meta?.label} minimum value`} aria-valuemin={min} aria-valuemax={max} data-slot="range-min" inputMode="numeric" placeholder={min.toString()} min={min} max={max} className="h-8 w-full rounded" defaultValue={value[0]} onChange={event => onRangeValueChange(String(event.target.value), true)} /> <span className="sr-only shrink-0 text-muted-foreground">to</span> <Input id={`${inputId}-max`} type="number" aria-label={`${meta?.label} maximum value`} aria-valuemin={min} aria-valuemax={max} data-slot="range-max" inputMode="numeric" placeholder={max.toString()} min={min} max={max} className="h-8 w-full rounded" defaultValue={value[1]} onChange={event => onRangeValueChange(String(event.target.value))} /> </div> )}Update the import paths to match your project setup.
DataTableFacetedFilter:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import * as React from "react"import type { Table } from "@tanstack/react-table"import { TableFacetedFilter, TableFacetedFilterContent, useTableFacetedFilter, type TableFacetedFilterProps,} from "../filters/table-faceted-filter"import { useDataTable } from "../core/data-table-context"import type { Option } from "../types"import { useDerivedColumnTitle } from "../hooks/use-derived-column-title"import { useGeneratedOptionsForColumn } from "../hooks/use-generated-options"import { buildFacetedOptions } from "../lib/build-faceted-options"
type DataTableFacetedFilterProps<TData, TValue> = Omit< TableFacetedFilterProps<TData, TValue>, "column" | "options"> & { /** * The accessor key of the column to filter (matches column definition) */ accessorKey: keyof TData & string /** * Optional title override (if not provided, will use column.meta.label) */ title?: string /** * Static options (if provided, will be used instead of dynamic generation) */ options?: Option[] /** * Whether to show counts for each option * @default true */ showCounts?: boolean /** * Whether to update counts based on other active filters * @default true */ dynamicCounts?: boolean /** * If true, only show options that exist in the currently filtered table rows. * If false, show all options from the entire dataset (useful for multi-select filters * where you want to see all possible options even if they're not in the current filtered results). * @default !multiple (true for single-select, false for multi-select) */ limitToFilteredRows?: boolean}
/** * A faceted filter component that automatically connects to the DataTable context * and dynamically generates options with counts based on the filtered data. * * @example - Auto-detect options from data with dynamic counts * const columns: DataTableColumnDef[] = [{ accessorKey: "category", ..., meta: { label: "Category" } }, ...] * <DataTableFacetedFilter accessorKey="category" /> * * @example - With static options * const categoryOptions: Option[] = [ * { label: "Electronics", value: "electronics" }, * { label: "Clothing", value: "clothing" }, * ] * <DataTableFacetedFilter * accessorKey="category" * title="Category" * options={categoryOptions} * /> * * @example - With dynamic option generation and multiple selection * <DataTableFacetedFilter * accessorKey="brand" * title="Brand" * multiple * dynamicCounts * /> * * @example - Without counts * <DataTableFacetedFilter * accessorKey="status" * showCounts={false} * /> */
interface UseFacetedOptionsArgs<TData> { table: Table<TData> accessorKey: string options?: Option[] showCounts: boolean dynamicCounts: boolean limitToFilteredRows: boolean precomputedOptions?: Record<string, Option[]>}
// Resolves option list in priority order: 1) caller `options`, 2) meta-aware// generator (select/multiSelect), 3) data-derived fallback. Memo gates it so// we don't walk rows twice.function useFacetedOptions<TData>({ table, accessorKey, options, showCounts, dynamicCounts, limitToFilteredRows, precomputedOptions,}: UseFacetedOptionsArgs<TData>): Option[] { const column = table.getColumn(accessorKey)
// The meta-aware generator is authoritative for declared select variants — // it handles augment/preserve strategies and per-column meta overrides. // It returns `[]` for columns that don't match a select variant, which is // how we detect "fall back to data-derived options." const metaGenerated = precomputedOptions?.[accessorKey] ?? [] const needsFallbackGeneration = !precomputedOptions const perColumnGenerated = useGeneratedOptionsForColumn( table, needsFallbackGeneration ? accessorKey : "__noop__", { showCounts, dynamicCounts, limitToFilteredRows, }, ) const resolvedMetaGenerated = needsFallbackGeneration ? perColumnGenerated : metaGenerated
// Pull state slices for memo reactivity. const state = table.getState() const columnFilters = state.columnFilters const globalFilter = state.globalFilter
// Extract `coreRows` so async-data row-array identity drives recompute; // `table` ref is stable and would hold stale (empty) results. const coreRows = table.getCoreRowModel().rows
return React.useMemo((): Option[] => { if (!column) return []
const meta = column.columnDef.meta const autoOptionsFormat = meta?.autoOptionsFormat ?? true
// Priority 1: caller-supplied options — always wins over meta/data. if (options && options.length > 0) { return buildFacetedOptions( table, coreRows, accessorKey, columnFilters, globalFilter, { staticOptions: options, limitToFilteredRows, dynamicCounts, showCounts, autoOptionsFormat, }, ) }
// Priority 2: trust the meta-aware generator when it produced anything. // (Preserved original "non-empty result wins" behavior so auto-generated // columns with valid data don't get clobbered by the fallback.) if (resolvedMetaGenerated.length > 0) return resolvedMetaGenerated
// Priority 3: data-derived fallback for non-select variants (or select // variants that had no rows to work with — empty output either way). return buildFacetedOptions( table, coreRows, accessorKey, columnFilters, globalFilter, { limitToFilteredRows, dynamicCounts, showCounts, autoOptionsFormat, }, ) }, [ column, options, resolvedMetaGenerated, table, coreRows, accessorKey, columnFilters, globalFilter, limitToFilteredRows, dynamicCounts, showCounts, ])}
/** * Shared setup for the two exported wrapper components. Keeps column lookup, * title derivation, and options resolution in one place so the wrappers stay * thin. */function useFacetedFilterSetup<TData>({ accessorKey, options, showCounts, dynamicCounts, limitToFilteredRows, title,}: { accessorKey: string options?: Option[] showCounts: boolean dynamicCounts: boolean limitToFilteredRows: boolean title?: string}) { const { table, generatedOptionsMap } = useDataTable<TData>() const column = table.getColumn(accessorKey)
const derivedTitle = useDerivedColumnTitle(column, accessorKey, title)
const dynamicOptions = useFacetedOptions({ table, accessorKey, options, showCounts, dynamicCounts, limitToFilteredRows, precomputedOptions: generatedOptionsMap, })
return { table, column, derivedTitle, dynamicOptions }}
export function DataTableFacetedFilter<TData, TValue = unknown>({ accessorKey, options, showCounts = true, dynamicCounts = true, limitToFilteredRows, title, multiple, trigger, ...props}: DataTableFacetedFilterProps<TData, TValue>) { // Default: multi-select shows all options, single-select filters to visible rows const resolvedLimitToFilteredRows = limitToFilteredRows ?? !multiple
const { column, derivedTitle, dynamicOptions } = useFacetedFilterSetup<TData>( { accessorKey: accessorKey as string, options, showCounts, dynamicCounts, limitToFilteredRows: resolvedLimitToFilteredRows, title, }, )
// Early return if column not found if (!column) { console.warn( `Column with accessorKey "${accessorKey}" not found in table columns`, ) return null }
return ( <TableFacetedFilter column={column} options={dynamicOptions} title={derivedTitle} multiple={multiple} trigger={trigger} {...props} /> )}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
DataTableFacetedFilter.displayName = "DataTableFacetedFilter"
export function DataTableFacetedFilterContent<TData, TValue = unknown>({ accessorKey, options, showCounts = true, dynamicCounts = true, limitToFilteredRows, title, multiple, onValueChange,}: DataTableFacetedFilterProps<TData, TValue>) { // Default: multi-select shows all options, single-select filters to visible rows const resolvedLimitToFilteredRows = limitToFilteredRows ?? !multiple
const { column, derivedTitle, dynamicOptions } = useFacetedFilterSetup<TData>( { accessorKey: accessorKey as string, options, showCounts, dynamicCounts, limitToFilteredRows: resolvedLimitToFilteredRows, title, }, )
// Use the shared hook for filter logic const { selectedValues, onItemSelect, onReset } = useTableFacetedFilter({ column, onValueChange, multiple, })
if (!column) return null
return ( <TableFacetedFilterContent title={derivedTitle} options={dynamicOptions} selectedValues={selectedValues} onItemSelect={onItemSelect} onReset={onReset} /> )}
DataTableFacetedFilterContent.displayName = "DataTableFacetedFilterContent""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry *//** * Table faceted filter component * @description A faceted filter component for DataTable that allows users to filter data based on multiple selectable options. It supports both single and multiple selection modes. */
import type { Column } from "@tanstack/react-table"import { Check, PlusCircle, XCircle } from "lucide-react"import * as React from "react"
import { Badge } from "@/components/ui/badge"import { Button } from "@/components/ui/button"import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator,} from "@/components/ui/command"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"import { Separator } from "@/components/ui/separator"import { cn } from "@/lib/utils"import type { ExtendedColumnFilter, Option } from "../types"import { FILTER_OPERATORS, FILTER_VARIANTS, JOIN_OPERATORS,} from "../lib/constants"
export interface TableFacetedFilterProps<TData, TValue> { column?: Column<TData, TValue> title?: string options: Option[] multiple?: boolean /** * Callback fired when filter value changes * Useful for server-side filtering or external state management */ onValueChange?: (value: string[] | undefined) => void /** * Optional custom trigger element */ trigger?: React.ReactNode}
export function useTableFacetedFilter<TData>({ column, onValueChange, multiple,}: { column?: Column<TData, unknown> onValueChange?: (value: string[] | undefined) => void multiple?: boolean}) { const columnFilterValue = column?.getFilterValue()
// Handle both ExtendedColumnFilter format (new) and legacy array format const selectedValues = React.useMemo(() => { // Handle ExtendedColumnFilter format (from filter menu or new faceted filter) if ( columnFilterValue && typeof columnFilterValue === "object" && !Array.isArray(columnFilterValue) && "value" in columnFilterValue ) { const filterValue = (columnFilterValue as ExtendedColumnFilter<TData>) .value return new Set( Array.isArray(filterValue) ? filterValue : [String(filterValue)], ) } // Handle legacy array format (backward compatibility) return new Set(Array.isArray(columnFilterValue) ? columnFilterValue : []) }, [columnFilterValue])
const onItemSelect = React.useCallback( (option: Option, isSelected: boolean) => { if (!column) return
if (multiple) { const newSelectedValues = new Set(selectedValues) if (isSelected) { newSelectedValues.delete(option.value) } else { newSelectedValues.add(option.value) } const filterValues = Array.from(newSelectedValues)
if (filterValues.length === 0) { column.setFilterValue(undefined) onValueChange?.(undefined) } else { // Create ExtendedColumnFilter format for interoperability with filter menu // FORCE variant to multiSelect when using IN operator to ensure it shows up in the menu const extendedFilter: ExtendedColumnFilter<TData> = { id: column.id as Extract<keyof TData, string>, value: filterValues, variant: FILTER_VARIANTS.MULTI_SELECT, operator: FILTER_OPERATORS.IN, filterId: `faceted-${column.id}`, joinOperator: JOIN_OPERATORS.AND, } column.setFilterValue(extendedFilter) onValueChange?.(filterValues) } } else { // Single selection if (isSelected) { column.setFilterValue(undefined) onValueChange?.(undefined) } else { // Create ExtendedColumnFilter format for single selection // Use EQUAL operator for single select const extendedFilter: ExtendedColumnFilter<TData> = { id: column.id as Extract<keyof TData, string>, value: option.value, // Single value, not array variant: FILTER_VARIANTS.SELECT, operator: FILTER_OPERATORS.EQ, filterId: `faceted-${column.id}`, joinOperator: JOIN_OPERATORS.AND, } column.setFilterValue(extendedFilter) onValueChange?.([option.value]) } } }, [column, multiple, selectedValues, onValueChange], )
const onReset = React.useCallback( (event?: React.MouseEvent) => { event?.stopPropagation() column?.setFilterValue(undefined) onValueChange?.(undefined) }, [column, onValueChange], )
return { selectedValues, onItemSelect, onReset, }}
export function TableFacetedFilter<TData, TValue>({ column, title, options = [], multiple, onValueChange, trigger,}: TableFacetedFilterProps<TData, TValue>) { const [open, setOpen] = React.useState(false)
const { selectedValues, onItemSelect, onReset } = useTableFacetedFilter({ column, onValueChange, multiple, })
// Wrap onItemSelect to close multiple=false popover const handleItemSelect = React.useCallback( (option: Option, isSelected: boolean) => { onItemSelect(option, isSelected) if (!multiple) { setOpen(false) } }, [onItemSelect, multiple, setOpen], )
return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> {trigger || ( <Button variant="outline" size="sm" className="h-8 border-dashed"> {selectedValues?.size > 0 ? ( <div role="button" aria-label={`Clear ${title} filter`} tabIndex={0} onClick={onReset} onKeyDown={e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault() onReset(e as unknown as React.MouseEvent) } }} className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none" > <XCircle className="size-4" /> </div> ) : ( <PlusCircle className="size-4" /> )} {title} {selectedValues?.size > 0 && ( <> <Separator orientation="vertical" className="mx-2 h-4" /> <Badge variant="secondary" className="rounded-sm px-1 font-normal lg:hidden" > {selectedValues.size} </Badge> <div className="hidden items-center gap-1 lg:flex"> {selectedValues.size > 2 ? ( <Badge variant="secondary" className="rounded-sm px-1 font-normal" > {selectedValues.size} selected </Badge> ) : ( options .filter(option => selectedValues.has(option.value)) .map(option => ( <Badge variant="secondary" key={option.value} className="rounded-sm px-1 font-normal" > {option.label} </Badge> )) )} </div> </> )} </Button> )} </PopoverTrigger> <PopoverContent className="w-52 p-0" align="start"> <TableFacetedFilterContent title={title} options={options} selectedValues={selectedValues} onItemSelect={handleItemSelect} onReset={onReset} /> </PopoverContent> </Popover> )}
export function TableFacetedFilterContent({ title, options, selectedValues, onItemSelect, onReset,}: { title?: string options: Option[] selectedValues: Set<string> onItemSelect: (option: Option, isSelected: boolean) => void onReset: (event?: React.MouseEvent) => void}) { return ( <Command> <CommandInput placeholder={title} className="pl-2" /> <CommandList className="max-h-full"> <CommandEmpty>No results found.</CommandEmpty> <CommandGroup className="max-h-75 overflow-x-hidden overflow-y-auto"> {options.map(option => { const isSelected = selectedValues.has(option.value)
return ( <CommandItem key={option.value} onSelect={() => onItemSelect(option, isSelected)} > <div className={cn( "mr-2 flex size-4 items-center justify-center rounded-sm border border-primary", isSelected ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible", )} > <Check className="size-4" /> </div> {option.icon && <option.icon className="mr-2 size-4" />} <span className="truncate">{option.label}</span> {option.count !== undefined && ( <span className="ml-auto font-mono text-xs"> {option.count} </span> )} </CommandItem> ) })} </CommandGroup> {selectedValues.size > 0 && ( <> <CommandSeparator /> <CommandGroup> <CommandItem onSelect={() => onReset()} className="justify-center text-center" > Clear filters </CommandItem> </CommandGroup> </> )} </CommandList> </Command> )}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
TableFacetedFilter.displayName = "TableFacetedFilter"/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
import type { Row, Table } from "@tanstack/react-table"
import { getFilteredRowsExcludingColumn } from "./filter-rows"import { formatLabel } from "./format"import type { Option } from "../types"
export interface BuildFacetedOptionsConfig { /** * If provided, these options are the source of truth and the returned list * is a subset of them (optionally narrowed by `limitToFilteredRows` and * enriched with live counts). * * If omitted, options are derived from the rows themselves — useful for * columns that are not declared as `select`/`multiSelect` variants but are * still being used with a faceted filter (boolean, text, etc.). */ staticOptions?: Option[] /** * Narrow the returned options to values that exist in rows passing every * *other* active filter (the current column's own filter is excluded). */ limitToFilteredRows: boolean /** * Compute counts against filtered rows (again, excluding the current * column's filter) rather than against the full core row model. */ dynamicCounts: boolean /** * When false, `count` is stripped from the output for a consistent shape. */ showCounts: boolean /** * When deriving options from rows, run labels through `formatLabel` (title * case etc.). Ignored when `staticOptions` is provided — callers' labels * are always preserved as-is. */ autoOptionsFormat: boolean}
/** * Pure builder for the option list surfaced by a faceted filter. Handles * both explicit `staticOptions` (narrow + enrich with counts) and * row-derived options. Plain function (not a hook) so the wrapper can * gate it behind a single `useMemo`. */export function buildFacetedOptions<TData>( table: Table<TData>, coreRows: Row<TData>[], accessorKey: string, columnFilters: Array<{ id: string; value: unknown }>, globalFilter: unknown, config: BuildFacetedOptionsConfig,): Option[] { const { staticOptions, limitToFilteredRows, dynamicCounts, showCounts, autoOptionsFormat, } = config
// Fast path: explicit options, no narrowing, no counts — just normalize the // shape so every return path of this function looks the same to callers. if (staticOptions && !limitToFilteredRows && !showCounts) { return staticOptions.map(opt => ({ ...opt, count: undefined })) }
// Only compute the filtered row subset if at least one flag actually needs // it. This avoids the cost of `getFilteredRowsExcludingColumn` when the // caller has opted out of both narrowing and dynamic counts. const needsFilteredRows = limitToFilteredRows || dynamicCounts const filteredRows = needsFilteredRows ? getFilteredRowsExcludingColumn( table, coreRows, accessorKey, columnFilters, globalFilter, ) : coreRows
const optionRows = limitToFilteredRows ? filteredRows : coreRows const countRows = dynamicCounts ? filteredRows : coreRows
// Collect the set of values present in `optionRows`. Used for narrowing // static options and for producing the auto-derived option list. const availableValues = collectRowValues(optionRows, accessorKey)
// Build the base option list (no counts yet). let baseOptions: Option[] if (staticOptions) { baseOptions = limitToFilteredRows ? staticOptions.filter(opt => availableValues.has(opt.value)) : staticOptions } else { baseOptions = Array.from(availableValues) .map(value => ({ value, label: autoOptionsFormat ? formatLabel(value) : value, })) .sort((a, b) => a.label.localeCompare(b.label)) }
if (!showCounts) { return baseOptions.map(opt => ({ ...opt, count: undefined })) }
// Scope counts to baseOptions values up-front so every returned option // has a count and every count maps to a returned option. const targetValues = new Set(baseOptions.map(opt => opt.value)) const valueCounts = new Map<string, number>() for (const row of countRows) { const raw = row.getValue(accessorKey) as unknown const values: unknown[] = Array.isArray(raw) ? raw : [raw] for (const v of values) { if (v == null) continue const str = String(v) if (!str) continue if (targetValues.has(str)) { valueCounts.set(str, (valueCounts.get(str) ?? 0) + 1) } } }
return baseOptions.map(opt => ({ ...opt, count: valueCounts.get(opt.value) ?? 0, }))}
function collectRowValues<TData>( rows: Row<TData>[], accessorKey: string,): Set<string> { const set = new Set<string>() for (const row of rows) { const raw = row.getValue(accessorKey) as unknown const values: unknown[] = Array.isArray(raw) ? raw : [raw] for (const v of values) { if (v == null) continue const str = String(v) if (str) set.add(str) } } return set}Update the import paths to match your project setup.
DataTableInlineFilter:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import { useDataTable } from "../core/data-table-context"import { TableInline, type TableInlineProps,} from "../filters/table-inline-filter"import { useGeneratedOptions } from "../hooks/use-generated-options"import { FILTER_VARIANTS } from "../lib/constants"import type { Option } from "../types"
type BaseInlineProps<TData> = Omit<TableInlineProps<TData>, "table">
interface AutoOptionProps { autoOptions?: boolean showCounts?: boolean dynamicCounts?: boolean /** * If true, only generate options from filtered rows. If false, generate from all rows. * This controls which rows are used to generate the option list itself. * Note: This is separate from dynamicCounts which controls count calculation. * @default true */ limitToFilteredRows?: boolean includeColumns?: string[] excludeColumns?: string[] limitPerColumn?: number mergeStrategy?: "preserve" | "augment" | "replace"}
type DataTableInlineFilterProps<TData> = BaseInlineProps<TData> & AutoOptionProps
export function DataTableInlineFilter<TData>({ autoOptions = true, showCounts = true, dynamicCounts = true, limitToFilteredRows = true, includeColumns, excludeColumns, limitPerColumn, mergeStrategy = "preserve", ...props}: DataTableInlineFilterProps<TData>) { const { table } = useDataTable<TData>()
const generatedOptions = useGeneratedOptions(table, { showCounts, dynamicCounts, limitToFilteredRows, includeColumns, excludeColumns, limitPerColumn, })
/** * BUG: stale counts on filter changes — see `data-table-filter-menu.tsx` * for full doc. We capture pristine caller options in a ref and rebuild * `meta.options` from that source on every augment so counts refresh * (and zeroes get filled in for the count-0 hide rule). */ React.useMemo(() => { if (!autoOptions) return null table.getAllColumns().forEach(column => { const meta = (column.columnDef.meta ||= {}) const variant = meta.variant ?? FILTER_VARIANTS.TEXT if ( variant !== FILTER_VARIANTS.SELECT && variant !== FILTER_VARIANTS.MULTI_SELECT ) return const gen = generatedOptions[column.id] if (!gen || gen.length === 0) return
if (!meta.options) { meta.options = gen return }
if (mergeStrategy === "replace") { meta.options = gen return }
if (mergeStrategy === "augment") { // See `data-table-filter-menu.tsx` for the staleness rationale. // We stash the pristine options on `meta` so the next augment can // rebuild from them instead of from the previous augment's output. const metaWithStash = meta as typeof meta & { __nikoOriginalOptions?: Option[] } if (!metaWithStash.__nikoOriginalOptions) { metaWithStash.__nikoOriginalOptions = meta.options } const original = metaWithStash.__nikoOriginalOptions const countMap = new Map(gen.map(o => [o.value, o.count])) meta.options = original.map((opt: Option) => ({ ...opt, count: showCounts ? (countMap.get(opt.value) ?? 0) : undefined, })) } }) }, [autoOptions, generatedOptions, mergeStrategy, showCounts, table])
return <TableInline table={table} {...props} />}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
DataTableInlineFilter.displayName = "DataTableInlineFilter""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */// Inline-filter module: utilities, sync hooks, value-input renderers, and the// `TableInline` toolbar.
import type { Column, Table } from "@tanstack/react-table"import { BadgeCheck, CalendarIcon, Check, ListFilter, Text, X,} from "lucide-react"import * as React from "react"
import { Button } from "@/components/ui/button"import { Calendar } from "@/components/ui/calendar"import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,} from "@/components/ui/command"import { Input } from "@/components/ui/input"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"import { getDefaultFilterOperator, getFilterOperators, processFiltersForLogic,} from "../lib/data-table"import { formatDate } from "../lib/format"import { useKeyboardShortcut } from "../hooks/use-keyboard-shortcut"import { cn } from "@/lib/utils"import { FILTER_OPERATORS, FILTER_VARIANTS, JOIN_OPERATORS, KEYBOARD_SHORTCUTS,} from "../lib/constants"import { dataTableConfig } from "../config/data-table"import type { ExtendedColumnFilter, FilterOperator, JoinOperator, Option,} from "../types"import { TableRangeFilter } from "./table-range-filter"
/* --------------------------------- Utilities -------------------------------- */
/** * Create a deterministic filter ID based on filter properties * This ensures filters can be shared via URL and will have consistent IDs */function createFilterId<TData>( filter: Omit<ExtendedColumnFilter<TData>, "filterId">, index?: number,): string { // Create a deterministic ID based on filter properties // Using a combination that should be unique for each filter configuration const valueStr = typeof filter.value === "string" ? filter.value : JSON.stringify(filter.value)
// Include index as a fallback to ensure uniqueness for URL sharing const indexSuffix = typeof index === "number" ? `-${index}` : ""
return `${filter.id}-${filter.operator}-${filter.variant}-${valueStr}${indexSuffix}` .toLowerCase() .replace(/[^a-z0-9-]/g, "-") .replace(/-+/g, "-") .substring(0, 100) // Limit length to avoid extremely long IDs}
/** * Type for filters without filterId (for URL serialization) */type FilterWithoutId<TData> = Omit<ExtendedColumnFilter<TData>, "filterId">
/** * Normalize filters loaded from URL by ensuring they have filterId * If filterId is missing, generate it deterministically * * This allows filters to be stored in URL without filterId, making URLs shorter * and more robust. The filterId is auto-generated when filters are loaded. * * @param filters - Filters that may or may not have filterId * @returns Filters with guaranteed filterId values */function normalizeFiltersFromUrl<TData>( filters: (FilterWithoutId<TData> | ExtendedColumnFilter<TData>)[],): ExtendedColumnFilter<TData>[] { return filters.map((filter, index) => { // If filterId is missing, generate it if (!("filterId" in filter) || !filter.filterId) { return { ...filter, filterId: createFilterId(filter, index), } as ExtendedColumnFilter<TData> } return filter as ExtendedColumnFilter<TData> })}
/* -------------------------------- Custom Hooks ------------------------------ */
/** * Hook to initialize filters from table state (for URL restoration) * Replaces the initialization useEffect with derived state * * @description This hook runs ONCE on mount to extract initial filter state from: * 1. Controlled filters (if provided via props) * 2. Table's globalFilter (for OR logic filters) * 3. Table's columnFilters (for AND logic filters) * * @debug Check React DevTools > Components > useInitialFilters to see returned value */function useInitialFilters<TData>( table: Table<TData>, controlledFilters?: ExtendedColumnFilter<TData>[],): ExtendedColumnFilter<TData>[] { const initialFilters = React.useMemo(() => { if (controlledFilters) { const normalized = normalizeFiltersFromUrl(controlledFilters) if (process.env.NODE_ENV === "development") { console.log( "[TableInline useInitialFilters] Using controlled filters:", normalized, ) } return normalized }
const globalFilter = table.getState().globalFilter if ( globalFilter && typeof globalFilter === "object" && "filters" in globalFilter ) { const filterObj = globalFilter as { filters: (FilterWithoutId<TData> | ExtendedColumnFilter<TData>)[] } const normalized = normalizeFiltersFromUrl(filterObj.filters) if (process.env.NODE_ENV === "development") { console.log( "[TableInline useInitialFilters] Extracted from globalFilter:", normalized, ) } return normalized }
const columnFilters = table.getState().columnFilters if (columnFilters && columnFilters.length > 0) { const extractedFilters = columnFilters .map(cf => cf.value) .filter( (v): v is FilterWithoutId<TData> | ExtendedColumnFilter<TData> => v !== null && typeof v === "object" && "id" in v, ) if (extractedFilters.length > 0) { const normalized = normalizeFiltersFromUrl(extractedFilters) if (process.env.NODE_ENV === "development") { console.log( "[TableInline useInitialFilters] Extracted from columnFilters:", normalized, ) } return normalized } }
if (process.env.NODE_ENV === "development") { console.log("[TableInline useInitialFilters] No initial filters found") } return [] // eslint-disable-next-line react-hooks/exhaustive-deps }, [])
return initialFilters}
/** * Hook to sync filters with table state * Replaces multiple useEffect hooks with a single focused effect * * @description Manages synchronization between filter state and TanStack Table: * - Updates table.meta.joinOperator for the global filter function * - In uncontrolled mode: updates table's globalFilter or columnFilters based on join operators * - In controlled mode: only updates table.meta (parent handles table state) * * @debug * - Check table.getState().globalFilter to see OR filters * - Check table.getState().columnFilters to see AND filters * - Check table.options.meta.joinOperator to see current join logic */function useSyncFiltersWithTable<TData>( table: Table<TData>, filters: ExtendedColumnFilter<TData>[], isControlled: boolean,) { // Use core utility to process filters and determine logic const filterLogic = React.useMemo( () => processFiltersForLogic(filters), [filters], )
// Update table meta (happens during render, safe mutation) if (table.options.meta) { // eslint-disable-next-line react-hooks/immutability table.options.meta.hasIndividualJoinOperators = true // eslint-disable-next-line react-hooks/immutability table.options.meta.joinOperator = filterLogic.joinOperator }
// Sync with table state only when filters change (and not in controlled mode) React.useEffect(() => { if (isControlled) { if (process.env.NODE_ENV === "development") { console.log( "[TableInline useSyncFiltersWithTable] Controlled mode - skipping table sync", ) } return }
if (process.env.NODE_ENV === "development") { console.log("[TableInline useSyncFiltersWithTable] Syncing filters:", { filterCount: filters.length, hasOrFilters: filterLogic.hasOrFilters, hasSameColumnFilters: filterLogic.hasSameColumnFilters, joinOperator: filterLogic.joinOperator, }) }
// Use core utility to determine routing if (filterLogic.shouldUseGlobalFilter) { table.resetColumnFilters()
table.setGlobalFilter({ filters: filterLogic.processedFilters, joinOperator: filterLogic.joinOperator, }) if (process.env.NODE_ENV === "development") { console.log( "[TableInline useSyncFiltersWithTable] Set globalFilter (OR/MIXED logic)", { hasOrFilters: filterLogic.hasOrFilters, hasSameColumnFilters: filterLogic.hasSameColumnFilters, }, ) } } else { table.setGlobalFilter("") const columnFilters = filterLogic.processedFilters.map(filter => ({ id: filter.id, value: { operator: filter.operator, value: filter.value, id: filter.id, filterId: filter.filterId, joinOperator: filter.joinOperator, }, })) table.setColumnFilters(columnFilters) if (process.env.NODE_ENV === "development") { console.log( "[TableInline useSyncFiltersWithTable] Set columnFilters (AND logic)", ) } } }, [filters, filterLogic, table, isControlled])}
export interface TableInlineProps<TData> extends React.ComponentProps<"div"> { table: Table<TData> filters?: ExtendedColumnFilter<TData>[] onFiltersChange?: (filters: ExtendedColumnFilter<TData>[]) => void}
export function TableInline<TData>({ table, filters: controlledFilters, onFiltersChange: controlledOnFiltersChange, children, className, ...props}: TableInlineProps<TData>) { const id = React.useId()
// Check if we're in controlled mode const isControlled = controlledFilters !== undefined
// Get initial filters from table state (for URL restoration) const initialFilters = useInitialFilters(table, controlledFilters)
// Internal state - manages filters when not controlled const [internalFilters, setInternalFilters] = React.useState<ExtendedColumnFilter<TData>[]>(initialFilters)
// Use controlled values if provided, otherwise use internal state const filters = controlledFilters ?? internalFilters
// Sync filters with table state (handles both controlled and uncontrolled) useSyncFiltersWithTable(table, filters, isControlled)
// Handler that works with both controlled and internal state const onFiltersChange = React.useCallback( (newFilters: ExtendedColumnFilter<TData>[]) => { if (controlledOnFiltersChange) { // In controlled mode, just notify parent - don't call table methods // Parent will update URL state, which will flow back to table state via DataTableRoot controlledOnFiltersChange(newFilters) } else { // In uncontrolled mode, update internal state // Table sync happens via useSyncFiltersWithTable hook setInternalFilters(newFilters) } }, [controlledOnFiltersChange], )
const columns = React.useMemo( () => table.getAllColumns().filter(column => column.getCanFilter()), // Depend on the column set, not just the (stable) table ref. // eslint-disable-next-line react-hooks/exhaustive-deps [table, table.options.columns], )
const [open, setOpen] = React.useState(false) const [selectedColumn, setSelectedColumn] = React.useState<Column<TData> | null>(null) const [inputValue, setInputValue] = React.useState("") const triggerRef = React.useRef<HTMLButtonElement>(null) const inputRef = React.useRef<HTMLInputElement>(null)
const onOpenChange = React.useCallback((open: boolean) => { setOpen(open)
if (!open) { setTimeout(() => { setSelectedColumn(null) setInputValue("") }, 100) } }, [])
const onFilterAdd = React.useCallback( (column: Column<TData>, value: string) => { if ( !value.trim() && column.columnDef.meta?.variant !== FILTER_VARIANTS.BOOLEAN ) { return }
const filterValue = column.columnDef.meta?.variant === FILTER_VARIANTS.MULTI_SELECT ? [value] : value
const filterWithoutId = { id: column.id as Extract<keyof TData, string>, value: filterValue, variant: column.columnDef.meta?.variant ?? FILTER_VARIANTS.TEXT, operator: getDefaultFilterOperator( column.columnDef.meta?.variant ?? FILTER_VARIANTS.TEXT, ), joinOperator: JOIN_OPERATORS.AND, // Default to AND for new filters }
// Use current filter length as index to ensure unique IDs const newFilterIndex = filters.length
const newFilter: ExtendedColumnFilter<TData> = { ...filterWithoutId, filterId: createFilterId(filterWithoutId, newFilterIndex), }
onFiltersChange([...filters, newFilter]) setOpen(false)
setTimeout(() => { setSelectedColumn(null) setInputValue("") }, 100) }, [filters, onFiltersChange], )
const onFilterRemove = React.useCallback( (filterId: string) => { const updatedFilters = filters.filter( filter => filter.filterId !== filterId, ) onFiltersChange(updatedFilters) requestAnimationFrame(() => { triggerRef.current?.focus() }) }, [filters, onFiltersChange], )
const onFilterUpdate = React.useCallback( ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => { const updatedFilters = filters.map(filter => { if (filter.filterId === filterId) { return { ...filter, ...updates } as ExtendedColumnFilter<TData> } return filter }) onFiltersChange(updatedFilters) }, [filters, onFiltersChange], )
const onFiltersReset = React.useCallback(() => { onFiltersChange([]) }, [onFiltersChange])
// Toggle filter menu with 'F' key useKeyboardShortcut({ key: KEYBOARD_SHORTCUTS.FILTER_TOGGLE, onTrigger: () => setOpen(prev => !prev), })
// Remove last filter with Shift+F useKeyboardShortcut({ key: KEYBOARD_SHORTCUTS.FILTER_REMOVE, requireShift: true, onTrigger: () => { if (filters.length > 0) { onFilterRemove(filters[filters.length - 1]?.filterId ?? "") } }, condition: () => filters.length > 0, })
const onInputKeyDown = React.useCallback( (event: React.KeyboardEvent<HTMLInputElement>) => { const key = event.key.toLowerCase() if ( (key === KEYBOARD_SHORTCUTS.BACKSPACE || key === KEYBOARD_SHORTCUTS.DELETE) && !inputValue && selectedColumn ) { event.preventDefault() setSelectedColumn(null) } }, [inputValue, selectedColumn], )
const onTriggerKeyDown = React.useCallback( (event: React.KeyboardEvent<HTMLButtonElement>) => { const key = event.key.toLowerCase() if ( (key === KEYBOARD_SHORTCUTS.BACKSPACE || key === KEYBOARD_SHORTCUTS.DELETE) && filters.length > 0 ) { event.preventDefault() onFilterRemove(filters[filters.length - 1]?.filterId ?? "") } }, [filters, onFilterRemove], )
return ( <div role="toolbar" aria-orientation="horizontal" className={cn( "flex w-full items-start justify-between gap-2 p-1", className, )} {...props} > <div className="flex flex-1 flex-wrap items-center gap-2"> {filters.map((filter, index) => ( <React.Fragment key={filter.filterId}> {/* Show join operator selector before filter (except for first filter) */} {index > 0 && ( <Select value={filter.joinOperator || JOIN_OPERATORS.AND} onValueChange={(value: JoinOperator) => onFilterUpdate(filter.filterId, { joinOperator: value }) } > <SelectTrigger size="sm" className="w-20 text-xs font-medium uppercase" > <SelectValue placeholder={filter.joinOperator || "and"} /> </SelectTrigger> <SelectContent> {dataTableConfig.joinOperators.map(operator => ( <SelectItem key={operator} value={operator} className="uppercase" > {operator} </SelectItem> ))} </SelectContent> </Select> )} <TableInlineFilterItem filter={filter} filterItemId={`${id}-filter-${filter.filterId}`} columns={columns} onFilterUpdate={onFilterUpdate} onFilterRemove={onFilterRemove} /> </React.Fragment> ))} {filters.length > 0 && ( <Button aria-label="Clear all filters" title="Clear all filters" variant="outline" size="icon" className="size-8" onClick={onFiltersReset} > <X /> </Button> )} <Popover open={open} onOpenChange={onOpenChange}> <PopoverTrigger asChild> <Button aria-label="Open filter command menu" title="Add filter (Press F)" variant="outline" size="sm" ref={triggerRef} onKeyDown={onTriggerKeyDown} > <ListFilter /> Add filter </Button> </PopoverTrigger> <PopoverContent align="start" className="w-full max-w-(--radix-popover-content-available-width) origin-(--radix-popover-content-transform-origin) p-0" > <Command loop className="[&_[cmdk-input-wrapper]_svg]:hidden"> <CommandInput ref={inputRef} placeholder={ selectedColumn ? (selectedColumn.columnDef.meta?.label ?? selectedColumn.id) : "Search fields..." } value={inputValue} onValueChange={setInputValue} onKeyDown={onInputKeyDown} /> <CommandList> {selectedColumn ? ( <> {selectedColumn.columnDef.meta?.options && ( <CommandEmpty>No options found.</CommandEmpty> )} <FilterValueSelector column={selectedColumn} value={inputValue} onSelect={value => onFilterAdd(selectedColumn, value)} /> </> ) : ( <> <CommandEmpty>No fields found.</CommandEmpty> <CommandGroup> {columns.map(column => ( <CommandItem key={column.id} value={column.id} onSelect={() => { setSelectedColumn(column) setInputValue("") requestAnimationFrame(() => { inputRef.current?.focus() }) }} > {column.columnDef.meta?.icon && ( <column.columnDef.meta.icon /> )} <span className="truncate"> {column.columnDef.meta?.label ?? column.id} </span> </CommandItem> ))} </CommandGroup> </> )} </CommandList> </Command> </PopoverContent> </Popover> </div> <div className="flex items-center gap-2">{children}</div> </div> )}TableInline.displayName = "TableInline"
interface TableInlineFilterItemProps<TData> { filter: ExtendedColumnFilter<TData> filterItemId: string columns: Column<TData>[] onFilterUpdate: ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => void onFilterRemove: (filterId: string) => void}
function TableInlineFilterItem<TData>({ filter, filterItemId, columns, onFilterUpdate, onFilterRemove,}: TableInlineFilterItemProps<TData>) { const [showFieldSelector, setShowFieldSelector] = React.useState(false) const [showOperatorSelector, setShowOperatorSelector] = React.useState(false) const [showValueSelector, setShowValueSelector] = React.useState(false)
const column = columns.find(column => column.id === filter.id)
const operatorListboxId = `${filterItemId}-operator-listbox` const inputId = `${filterItemId}-input`
const columnMeta = column?.columnDef.meta const filterOperators = getFilterOperators(filter.variant)
const onItemKeyDown = React.useCallback( (event: React.KeyboardEvent<HTMLDivElement>) => { if ( event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement ) { return }
if (showFieldSelector || showOperatorSelector || showValueSelector) { return }
const key = event.key.toLowerCase() if ( key === KEYBOARD_SHORTCUTS.BACKSPACE || key === KEYBOARD_SHORTCUTS.DELETE ) { event.preventDefault() onFilterRemove(filter.filterId) } }, [ filter.filterId, showFieldSelector, showOperatorSelector, showValueSelector, onFilterRemove, ], )
if (!column) return null
return ( <div key={filter.filterId} role="listitem" id={filterItemId} className="flex h-8 items-center rounded-md bg-background" onKeyDown={onItemKeyDown} > <Popover open={showFieldSelector} onOpenChange={setShowFieldSelector}> <PopoverTrigger asChild> <Button title="Change field" variant="ghost" size="sm" className="rounded-none rounded-l-md border border-r-0 font-normal dark:bg-input/30" > {columnMeta?.icon && ( <columnMeta.icon className="text-muted-foreground" /> )} {columnMeta?.label ?? column.id} </Button> </PopoverTrigger> <PopoverContent align="start" className="w-48 origin-(--radix-popover-content-transform-origin) p-0" > <Command loop> <CommandInput placeholder="Search fields..." /> <CommandList> <CommandEmpty>No fields found.</CommandEmpty> <CommandGroup> {columns.map(column => ( <CommandItem key={column.id} value={column.id} onSelect={() => { onFilterUpdate(filter.filterId, { id: column.id as Extract<keyof TData, string>, variant: column.columnDef.meta?.variant ?? FILTER_VARIANTS.TEXT, operator: getDefaultFilterOperator( column.columnDef.meta?.variant ?? FILTER_VARIANTS.TEXT, ), value: "", })
setShowFieldSelector(false) }} > {column.columnDef.meta?.icon && ( <column.columnDef.meta.icon /> )} <span className="truncate"> {column.columnDef.meta?.label ?? column.id} </span> <Check className={cn( "ml-auto", column.id === filter.id ? "opacity-100" : "opacity-0", )} /> </CommandItem> ))} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> <Select open={showOperatorSelector} onOpenChange={setShowOperatorSelector} value={filter.operator} onValueChange={(value: FilterOperator) => onFilterUpdate(filter.filterId, { operator: value, value: value === FILTER_OPERATORS.EMPTY || value === FILTER_OPERATORS.NOT_EMPTY ? "" : filter.value, }) } > <SelectTrigger title="Change operator" aria-controls={operatorListboxId} size="sm" className="rounded-none border-r-0 px-2.5 lowercase [&_svg]:hidden" > <SelectValue placeholder={filter.operator} /> </SelectTrigger> <SelectContent id={operatorListboxId} className="origin-(--radix-select-content-transform-origin)" > {filterOperators.map(operator => ( <SelectItem key={operator.value} className="lowercase" value={operator.value} > {operator.label} </SelectItem> ))} </SelectContent> </Select> {onFilterInputRender({ filter, column, inputId, onFilterUpdate, showValueSelector, setShowValueSelector, })} <Button aria-controls={filterItemId} title={`Remove ${columnMeta?.label ?? column.id} filter`} variant="ghost" size="sm" className="h-full rounded-none rounded-r-md border border-l-0 px-1.5 font-normal dark:bg-input/30" onClick={() => onFilterRemove(filter.filterId)} > <X className="size-3.5" /> </Button> </div> )}TableInlineFilterItem.displayName = "TableInlineFilterItem"
interface FilterValueSelectorProps<TData> { column: Column<TData> value: string onSelect: (value: string) => void}
function FilterValueSelector<TData>({ column, value, onSelect,}: FilterValueSelectorProps<TData>) { const variant = column.columnDef.meta?.variant ?? FILTER_VARIANTS.TEXT
switch (variant) { case FILTER_VARIANTS.BOOLEAN: return ( <CommandGroup> <CommandItem value="true" onSelect={() => onSelect("true")}> True </CommandItem> <CommandItem value="false" onSelect={() => onSelect("false")}> False </CommandItem> </CommandGroup> )
case FILTER_VARIANTS.SELECT: case FILTER_VARIANTS.MULTI_SELECT: return ( <CommandGroup> {/* Cross-filter narrowing: hide options at count 0 (matches the rule used by `TableColumnFacetedFilterMenu` and the filter menu). Pure label-only option lists (no counts) render unchanged. */} {column.columnDef.meta?.options ?.filter((option: Option) => option.count !== 0) .map((option: Option) => ( <CommandItem key={option.value} value={option.value} onSelect={() => onSelect(option.value)} > {option.icon && <option.icon />} <span className="truncate">{option.label}</span> {option.count && ( <span className="ml-auto font-mono text-xs"> {option.count} </span> )} </CommandItem> ))} </CommandGroup> )
case FILTER_VARIANTS.DATE: case FILTER_VARIANTS.DATE_RANGE: return ( <Calendar mode="single" captionLayout="dropdown" selected={value ? new Date(value) : undefined} onSelect={date => onSelect(date?.getTime().toString() ?? "")} /> )
default: { const isEmpty = !value.trim()
return ( <CommandGroup> <CommandItem value={value} onSelect={() => onSelect(value)} disabled={isEmpty} > {isEmpty ? ( <> <Text /> <span>Type to add filter...</span> </> ) : ( <> <BadgeCheck /> <span className="truncate">Filter by "{value}"</span> </> )} </CommandItem> </CommandGroup> ) } }}FilterValueSelector.displayName = "FilterValueSelector"
function onFilterInputRender<TData>({ filter, column, inputId, onFilterUpdate, showValueSelector, setShowValueSelector,}: { filter: ExtendedColumnFilter<TData> column: Column<TData> inputId: string onFilterUpdate: ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => void showValueSelector: boolean setShowValueSelector: (value: boolean) => void}) { if ( filter.operator === FILTER_OPERATORS.EMPTY || filter.operator === FILTER_OPERATORS.NOT_EMPTY ) { return ( <div id={inputId} role="status" aria-label={`${column.columnDef.meta?.label} filter is ${ filter.operator === FILTER_OPERATORS.EMPTY ? "empty" : "not empty" }`} aria-live="polite" className="h-full w-16 rounded-none border bg-transparent px-1.5 py-0.5 text-muted-foreground dark:bg-input/30" /> ) }
switch (filter.variant) { case FILTER_VARIANTS.TEXT: case FILTER_VARIANTS.NUMBER: case FILTER_VARIANTS.RANGE: { if ( (filter.variant === FILTER_VARIANTS.RANGE && filter.operator === FILTER_OPERATORS.BETWEEN) || filter.operator === FILTER_OPERATORS.BETWEEN ) { return ( <TableRangeFilter filter={filter} column={column} inputId={inputId} onFilterUpdate={onFilterUpdate} className="size-full max-w-28 gap-0 **:data-[slot='range-min']:border-r-0 [&_input]:rounded-none [&_input]:px-1.5" /> ) }
const isNumber = filter.variant === FILTER_VARIANTS.NUMBER || filter.variant === FILTER_VARIANTS.RANGE
return ( <Input id={inputId} type={isNumber ? FILTER_VARIANTS.NUMBER : FILTER_VARIANTS.TEXT} inputMode={isNumber ? "numeric" : undefined} placeholder={column.columnDef.meta?.placeholder ?? "Enter value..."} className="h-full w-24 rounded-none px-1.5" value={typeof filter.value === "string" ? filter.value : ""} onChange={event => onFilterUpdate(filter.filterId, { value: event.target.value }) } /> ) }
case FILTER_VARIANTS.BOOLEAN: { const inputListboxId = `${inputId}-listbox`
return ( <Select open={showValueSelector} onOpenChange={setShowValueSelector} value={typeof filter.value === "string" ? filter.value : "true"} onValueChange={(value: "true" | "false") => onFilterUpdate(filter.filterId, { value }) } > <SelectTrigger id={inputId} aria-controls={inputListboxId} className="rounded-none bg-transparent px-1.5 py-0.5 [&_svg]:hidden" > <SelectValue placeholder={filter.value ? "True" : "False"} /> </SelectTrigger> <SelectContent id={inputListboxId}> <SelectItem value="true">True</SelectItem> <SelectItem value="false">False</SelectItem> </SelectContent> </Select> ) }
case FILTER_VARIANTS.SELECT: case FILTER_VARIANTS.MULTI_SELECT: { const inputListboxId = `${inputId}-listbox`
const options = column.columnDef.meta?.options ?? [] const selectedValues = Array.isArray(filter.value) ? filter.value : [filter.value]
const selectedOptions = options.filter((option: Option) => selectedValues.includes(option.value), )
return ( <Popover open={showValueSelector} onOpenChange={setShowValueSelector}> <PopoverTrigger asChild> <Button id={inputId} aria-controls={inputListboxId} variant="ghost" size="sm" className="h-full min-w-16 rounded-none border px-1.5 font-normal dark:bg-input/30" > {selectedOptions.length === 0 ? ( filter.variant === FILTER_VARIANTS.MULTI_SELECT ? ( "Select options..." ) : ( "Select option..." ) ) : ( <> <div className="flex items-center -space-x-2 rtl:space-x-reverse"> {selectedOptions.map((selectedOption: Option) => selectedOption.icon ? ( <div key={selectedOption.value} className="rounded-full border bg-background p-0.5" > <selectedOption.icon className="size-3.5" /> </div> ) : null, )} </div> <span className="truncate"> {selectedOptions.length > 1 ? `${selectedOptions.length} selected` : selectedOptions[0]?.label} </span> </> )} </Button> </PopoverTrigger> <PopoverContent id={inputListboxId} align="start" className="w-48 origin-(--radix-popover-content-transform-origin) p-0" > <Command> <CommandInput placeholder="Search options..." /> <CommandList> <CommandEmpty>No options found.</CommandEmpty> <CommandGroup> {options.map((option: Option) => ( <CommandItem key={option.value} value={option.value} onSelect={() => { const value = filter.variant === FILTER_VARIANTS.MULTI_SELECT ? selectedValues.includes(option.value) ? selectedValues.filter(v => v !== option.value) : [...selectedValues, option.value] : option.value onFilterUpdate(filter.filterId, { value }) }} > {option.icon && <option.icon />} <span className="truncate">{option.label}</span> {filter.variant === FILTER_VARIANTS.MULTI_SELECT && ( <Check className={cn( "ml-auto", selectedValues.includes(option.value) ? "opacity-100" : "opacity-0", )} /> )} </CommandItem> ))} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> ) }
case FILTER_VARIANTS.DATE: case FILTER_VARIANTS.DATE_RANGE: { const inputListboxId = `${inputId}-listbox`
const dateValue = Array.isArray(filter.value) ? filter.value.filter(Boolean) : [filter.value, filter.value].filter(Boolean)
const displayValue = filter.operator === FILTER_OPERATORS.BETWEEN && dateValue.length === 2 ? `${formatDate(new Date(Number(dateValue[0])))} - ${formatDate( new Date(Number(dateValue[1])), )}` : dateValue[0] ? formatDate(new Date(Number(dateValue[0]))) : "Pick date..."
return ( <Popover open={showValueSelector} onOpenChange={setShowValueSelector}> <PopoverTrigger asChild> <Button id={inputId} aria-controls={inputListboxId} variant="ghost" size="sm" className={cn( "h-full rounded-none border px-1.5 font-normal dark:bg-input/30", !filter.value && "text-muted-foreground", )} > <CalendarIcon className="size-3.5" /> <span className="truncate">{displayValue}</span> </Button> </PopoverTrigger> <PopoverContent id={inputListboxId} align="start" className="w-auto origin-(--radix-popover-content-transform-origin) p-0" > {filter.operator === FILTER_OPERATORS.BETWEEN ? ( <Calendar mode={FILTER_VARIANTS.RANGE} captionLayout="dropdown" selected={ dateValue.length === 2 ? { from: new Date(Number(dateValue[0])), to: new Date(Number(dateValue[1])), } : { from: new Date(), to: new Date(), } } onSelect={date => { onFilterUpdate(filter.filterId, { value: date ? [ (date.from?.getTime() ?? "").toString(), (date.to?.getTime() ?? "").toString(), ] : [], }) }} /> ) : ( <Calendar mode="single" captionLayout="dropdown" selected={ dateValue[0] ? new Date(Number(dateValue[0])) : undefined } onSelect={date => { onFilterUpdate(filter.filterId, { value: (date?.getTime() ?? "").toString(), }) }} /> )} </PopoverContent> </Popover> ) }
default: return null }}"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry *//** * Table range filter component * @description A range filter component for DataTable that allows users to filter data based on numerical ranges. */
import type { Column } from "@tanstack/react-table"import * as React from "react"
import { Input } from "@/components/ui/input"import { cn } from "@/lib/utils"import type { ExtendedColumnFilter } from "../types"
interface TableRangeFilterProps<TData> extends React.ComponentProps<"div"> { filter: ExtendedColumnFilter<TData> column: Column<TData> inputId: string onFilterUpdate: ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => void}
export function TableRangeFilter<TData>({ filter, column, inputId, onFilterUpdate, className, ...props}: TableRangeFilterProps<TData>) { const meta = column.columnDef.meta
// Capture faceted min/max as scalars so the memo refreshes on data change // (column ref alone is stable across faceted-row updates). const metaRange = column.columnDef.meta?.range const facetedValues = column.getFacetedMinMaxValues() const facetedMin = facetedValues?.[0] const facetedMax = facetedValues?.[1] const [min, max] = React.useMemo<[number, number]>(() => { if (Array.isArray(metaRange) && metaRange.length === 2) { const [a, b] = metaRange as [number, number] return [a, b] } if (facetedMin != null && facetedMax != null) { return [Number(facetedMin), Number(facetedMax)] } return [0, 100] }, [metaRange, facetedMin, facetedMax])
// Plain-string formatter — `<input type="number">` requires a parsable // value, so locale-formatted output (commas, NBSPs) breaks the input. const formatValue = React.useCallback( (value: string | number | undefined) => { if (value === undefined || value === "") return "" const numValue = Number(value) return Number.isNaN(numValue) ? "" : String(numValue) }, [], )
const value = React.useMemo(() => { if (Array.isArray(filter.value)) return filter.value.map(formatValue) return [formatValue(filter.value), ""] }, [filter.value, formatValue])
const onRangeValueChange = React.useCallback( (value: string | number, isMin?: boolean) => { const numValue = Number(value) const currentValues = Array.isArray(filter.value) ? filter.value : ["", ""] const otherValue = isMin ? (currentValues[1] ?? "") : (currentValues[0] ?? "")
if ( value === "" || (!Number.isNaN(numValue) && (isMin ? numValue >= min && numValue <= (Number(otherValue) || max) : numValue <= max && numValue >= (Number(otherValue) || min))) ) { onFilterUpdate(filter.filterId, { value: isMin ? [String(value), String(otherValue)] : [String(otherValue), String(value)], }) } }, [filter.filterId, filter.value, min, max, onFilterUpdate], )
return ( <div data-slot="range" className={cn("flex w-full items-center gap-2", className)} {...props} > <Input id={`${inputId}-min`} type="number" aria-label={`${meta?.label} minimum value`} aria-valuemin={min} aria-valuemax={max} data-slot="range-min" inputMode="numeric" placeholder={min.toString()} min={min} max={max} className="h-8 w-full rounded" defaultValue={value[0]} onChange={event => onRangeValueChange(String(event.target.value), true)} /> <span className="sr-only shrink-0 text-muted-foreground">to</span> <Input id={`${inputId}-max`} type="number" aria-label={`${meta?.label} maximum value`} aria-valuemin={min} aria-valuemax={max} data-slot="range-max" inputMode="numeric" placeholder={max.toString()} min={min} max={max} className="h-8 w-full rounded" defaultValue={value[1]} onChange={event => onRangeValueChange(String(event.target.value))} /> </div> )}Update the import paths to match your project setup.
DataTableSliderFilter:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import * as React from "react"import { useDataTable } from "../core/data-table-context"import { TableSliderFilter, type TableSliderFilterProps,} from "../filters/table-slider-filter"import { useDerivedColumnTitle } from "../hooks/use-derived-column-title"import { FILTER_VARIANTS } from "../lib/constants"
type DataTableSliderFilterProps<TData> = Omit< TableSliderFilterProps<TData>, "column" | "title"> & { /** * The accessor key of the column to filter (matches column definition) */ accessorKey: keyof TData & string /** * Optional title override (if not provided, will use column.meta.label) */ title?: string}
/** * A slider filter component that automatically connects to the DataTable context * and derives the title from column metadata. * * @example - Auto-detect everything from column metadata and data * const columns: DataTableColumnDef[] = [{ accessorKey: "price",..., meta: { label: "Price", unit: "$", range: [0, 1000] } },...] * <DataTableSliderFilter accessorKey="price" /> * * @example - Custom range shorthand with unit * <DataTableSliderFilter * accessorKey="price" * range={[0, 1000]} * unit="$" * /> * * @example - Individual min/max control * <DataTableSliderFilter * accessorKey="rating" * min={1} * max={5} * step={0.5} * /> * * @example - Full manual control with custom title * <DataTableSliderFilter * accessorKey="distance" * title="Distance Range" * range={[0, 100]} * step={5} * unit="km" * /> */
export function DataTableSliderFilter<TData>({ accessorKey, title, ...props}: DataTableSliderFilterProps<TData>) { const { table } = useDataTable<TData>() const column = table.getColumn(accessorKey as string)
const derivedTitle = useDerivedColumnTitle(column, String(accessorKey), title)
// Auto-set variant in column meta if not already set // This allows the auto-filterFn to be applied based on variant React.useMemo(() => { if (!column) return const meta = (column.columnDef.meta ||= {}) // Only set variant if not already set (respects manual configuration) if (!meta.variant) { meta.variant = FILTER_VARIANTS.RANGE } }, [column])
// Early return if column not found if (!column) { console.warn( `Column with accessorKey "${accessorKey}" not found in table columns`, ) return null }
return <TableSliderFilter column={column} title={derivedTitle} {...props} />}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
DataTableSliderFilter.displayName = "DataTableSliderFilter""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry *//** * Table slider filter component * @description A slider filter component for DataTable that allows users to filter numerical data within a specified range. It supports manual configuration of range, min/max values, step size, and unit labels. */
import type { Column } from "@tanstack/react-table"import { PlusCircle, XCircle } from "lucide-react"import * as React from "react"import { Button } from "@/components/ui/button"import { Input } from "@/components/ui/input"import { Label } from "@/components/ui/label"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"import { Separator } from "@/components/ui/separator"import { Slider } from "@/components/ui/slider"import { cn } from "@/lib/utils"
interface Range { min: number max: number}
type RangeValue = [number, number]
function getIsValidRange(value: unknown): value is RangeValue { return ( Array.isArray(value) && value.length === 2 && typeof value[0] === "number" && typeof value[1] === "number" )}
function parseValuesAsNumbers(value: unknown): RangeValue | undefined { if ( Array.isArray(value) && value.length === 2 && value.every( v => (typeof v === "string" || typeof v === "number") && !Number.isNaN(v), ) ) { return [Number(value[0]), Number(value[1])] }
return undefined}
export interface TableSliderFilterProps<TData> { column: Column<TData, unknown> title?: string /** * Manual range [min, max] (overrides min/max props and column.meta.range) */ range?: RangeValue /** * Manual minimum value (overrides column.meta.range and faceted values) */ min?: number /** * Manual maximum value (overrides column.meta.range and faceted values) */ max?: number /** * Manual step value for the slider */ step?: number /** * Unit label to display (e.g., "$", "kg", "km") */ unit?: string onValueChange?: (value: [number, number] | undefined) => void}
export function TableSliderFilter<TData>({ column, title, range: manualRange, min: manualMin, max: manualMax, step: manualStep, unit: manualUnit, onValueChange,}: TableSliderFilterProps<TData>) { const id = React.useId()
const columnFilterValue = parseValuesAsNumbers(column.getFilterValue())
const defaultRange = column.columnDef.meta?.range const unit = manualUnit ?? column.columnDef.meta?.unit const label = title ?? column.columnDef.meta?.label ?? column.id
// Capture faceted min/max as scalars so the memo re-runs when filters/data change. const facetedValues = column.getFacetedMinMaxValues() const facetedMin = facetedValues?.[0] const facetedMax = facetedValues?.[1]
// Compute range values - memoized to avoid recalculation // This is safe because we're not triggering state updates, just reading values const { min, max, step } = React.useMemo<Range & { step: number }>(() => { let minValue = 0 let maxValue = 100
// Priority 1: Manual range prop (highest priority) if (manualRange && getIsValidRange(manualRange)) { minValue = manualRange[0] maxValue = manualRange[1] } // Priority 2: Manual min/max props else if (manualMin != null && manualMax != null) { minValue = manualMin maxValue = manualMax } // Priority 3: Use explicit range from column metadata else if (defaultRange && getIsValidRange(defaultRange)) { minValue = defaultRange[0] maxValue = defaultRange[1] } // Priority 4: Get min/max from faceted values // This is safe in useMemo as long as we're not calling setFilterValue else if (facetedMin != null && facetedMax != null) { minValue = Number(facetedMin) maxValue = Number(facetedMax) }
// Calculate appropriate step size based on range const rangeSize = maxValue - minValue const calculatedStep = rangeSize <= 20 ? 1 : rangeSize <= 100 ? Math.ceil(rangeSize / 20) : Math.ceil(rangeSize / 50)
return { min: minValue, max: maxValue, step: manualStep ?? calculatedStep, } }, [ defaultRange, manualRange, manualMin, manualMax, manualStep, facetedMin, facetedMax, ])
const range = React.useMemo((): RangeValue => { return columnFilterValue ?? [min, max] }, [columnFilterValue, min, max])
const formatValue = React.useCallback((value: number) => { return value.toLocaleString(undefined, { maximumFractionDigits: 0 }) }, [])
const applyFilterValue = React.useCallback( (value: [number, number] | undefined) => { column.setFilterValue(value) onValueChange?.(value) }, [column, onValueChange], )
const onRangeValueChange = React.useCallback( (value: string | number, isMin?: boolean) => { const numValue = Number(value) const currentValues = range
if (value === "") { // Allow empty value, don't update filter return }
if ( !Number.isNaN(numValue) && (isMin ? numValue >= min && numValue <= currentValues[1] : numValue <= max && numValue >= currentValues[0]) ) { applyFilterValue( isMin ? [numValue, currentValues[1]] : [currentValues[0], numValue], ) } }, [min, max, range, applyFilterValue], )
const onSliderValueChange = React.useCallback( (value: RangeValue) => { if (Array.isArray(value) && value.length === 2) { applyFilterValue(value) } }, [applyFilterValue], )
const onReset = React.useCallback( (event: React.MouseEvent) => { // Always stop the bubble — the previous DIV-only check let SVG/icon // clicks reach the popover trigger and re-open it on Clear. event.stopPropagation() applyFilterValue(undefined) }, [applyFilterValue], )
return ( <Popover> <PopoverTrigger asChild> <Button variant="outline" size="sm" className="border-dashed"> {columnFilterValue ? ( <div role="button" aria-label={`Clear ${label} filter`} tabIndex={0} className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none" onClick={onReset} > <XCircle /> </div> ) : ( <PlusCircle /> )} <span>{label}</span> {columnFilterValue ? ( <> <Separator orientation="vertical" className="mx-0.5 data-[orientation=vertical]:h-4" /> {formatValue(columnFilterValue[0])} -{" "} {formatValue(columnFilterValue[1])} {unit ? ` ${unit}` : ""} </> ) : null} </Button> </PopoverTrigger> <PopoverContent align="start" className="flex w-auto flex-col gap-4"> <div className="flex flex-col gap-3"> <p className="leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> {label} </p> <div className="flex items-center gap-4"> <Label htmlFor={`${id}-from`} className="sr-only"> From </Label> <div className="relative"> <Input key={`${id}-from-${range[0]}`} id={`${id}-from`} type="number" aria-label={`${label} minimum value`} aria-valuemin={min} aria-valuemax={max} inputMode="numeric" pattern="[0-9]*" placeholder={min.toString()} min={min} max={max} defaultValue={range[0]} onChange={event => onRangeValueChange(String(event.target.value), true) } className={cn("h-8 w-24", unit && "pr-8")} /> {unit && ( <span className="absolute top-0 right-0 bottom-0 mt-0.5 mr-0.5 flex h-7 items-center rounded-r-md bg-accent px-2 text-sm text-muted-foreground"> {unit} </span> )} </div> <Label htmlFor={`${id}-to`} className="sr-only"> to </Label> <div className="relative"> <Input key={`${id}-to-${range[1]}`} id={`${id}-to`} type="number" aria-label={`${label} maximum value`} aria-valuemin={min} aria-valuemax={max} inputMode="numeric" pattern="[0-9]*" placeholder={max.toString()} min={min} max={max} defaultValue={range[1]} onChange={event => onRangeValueChange(String(event.target.value)) } className={cn("h-8 w-24", unit && "pr-8")} /> {unit && ( <span className="absolute top-0 right-0 bottom-0 mt-0.5 mr-0.5 flex h-7 items-center rounded-r-md bg-accent px-2 text-sm text-muted-foreground"> {unit} </span> )} </div> </div> <Label htmlFor={`${id}-slider`} className="sr-only"> {label} slider </Label> <Slider id={`${id}-slider`} min={min} max={max} step={step} value={range} onValueChange={onSliderValueChange} /> </div> <Button aria-label={`Clear ${label} filter`} variant="outline" size="sm" onClick={onReset} > Clear </Button> </PopoverContent> </Popover> )}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
TableSliderFilter.displayName = "TableSliderFilter"Update the import paths to match your project setup.
DataTableDateFilter:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import { useDataTable } from "../core/data-table-context"import type { TableDateFilterProps } from "../filters/table-date-filter"import { TableDateFilter } from "../filters/table-date-filter"import { useDerivedColumnTitle } from "../hooks/use-derived-column-title"import { FILTER_VARIANTS } from "../lib/constants"
type DataTableDateFilterProps<TData> = Omit< TableDateFilterProps<TData>, "column" | "title"> & { /** * The accessor key of the column to filter (matches column definition) */ accessorKey: keyof TData & string /** * Optional title override (if not provided, will use column.meta.label) */ title?: string}
/** * A date filter component that automatically connects to the DataTable context * and derives the title from column metadata. * * @example - Auto-detect everything from column metadata * const columns: DataTableColumnDef[] = [{ accessorKey: "releaseDate",..., meta: { label: "Release Date" } },...] * <DataTableDateFilter accessorKey="releaseDate" /> * * @example - Date range filter * <DataTableDateFilter * accessorKey="releaseDate" * multiple * /> * * @example - Custom title * <DataTableDateFilter * accessorKey="createdAt" * title="Created Date" * /> * * @example - Single date selection * <DataTableDateFilter * accessorKey="dueDate" * title="Due Date" * multiple={false} * /> */
export function DataTableDateFilter<TData>({ accessorKey, title, multiple, trigger, ...props}: DataTableDateFilterProps<TData>) { const { table } = useDataTable<TData>() const column = table.getColumn(String(accessorKey))
const derivedTitle = useDerivedColumnTitle(column, String(accessorKey), title)
// Auto-set variant in column meta if not already set // This allows the auto-filterFn to be applied based on variant // Runs synchronously during render (not in an effect) so the variant is set // before TableDateFilter receives the column — avoids a deferred render cycle. if (column && !column.columnDef.meta?.variant) { const meta = (column.columnDef.meta ||= {}) type ColumnVariant = NonNullable<(typeof meta)["variant"]> meta.variant = ( multiple ? FILTER_VARIANTS.DATE_RANGE : FILTER_VARIANTS.DATE ) as ColumnVariant }
// Early return if column not found if (!column) { console.warn( `Column with accessorKey "${accessorKey}" not found in table columns`, ) return null }
return ( <TableDateFilter column={column} title={derivedTitle} multiple={multiple} trigger={trigger} {...props} /> )}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
DataTableDateFilter.displayName = "DataTableDateFilter""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
/** * A dropdown menu component that allows users to toggle the visibility of table columns. * It uses a popover to display a list of columns with checkboxes. * Users can search for columns and toggle their visibility. */
import type { Column } from "@tanstack/react-table"import { CalendarIcon, XCircle } from "lucide-react"import * as React from "react"import type { DateRange } from "react-day-picker"
import { Button } from "@/components/ui/button"import { Calendar } from "@/components/ui/calendar"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"import { Separator } from "@/components/ui/separator"import { formatDate } from "../lib/format"
type DateSelection = Date[] | DateRange
function getIsDateRange(value: DateSelection): value is DateRange { return value && typeof value === "object" && !Array.isArray(value)}
function parseAsDate(timestamp: number | string | undefined): Date | undefined { if (!timestamp) return undefined const numericTimestamp = typeof timestamp === "string" ? Number(timestamp) : timestamp const date = new Date(numericTimestamp) return !Number.isNaN(date.getTime()) ? date : undefined}
function parseColumnFilterValue(value: unknown) { if (value === null || value === undefined) { return [] }
if (Array.isArray(value)) { return value.map(item => { if (typeof item === "number" || typeof item === "string") { return item } return undefined }) }
if (typeof value === "string" || typeof value === "number") { return [value] }
return []}
export interface TableDateFilterProps<TData> { column: Column<TData, unknown> title?: string multiple?: boolean trigger?: React.ReactNode}
export function TableDateFilter<TData>({ column, title, multiple, trigger,}: TableDateFilterProps<TData>) { const columnFilterValue = column.getFilterValue()
const selectedDates = React.useMemo<DateSelection>(() => { if (!columnFilterValue) { return multiple ? { from: undefined, to: undefined } : [] }
if (multiple) { const timestamps = parseColumnFilterValue(columnFilterValue) return { from: parseAsDate(timestamps[0]), to: parseAsDate(timestamps[1]), } }
const timestamps = parseColumnFilterValue(columnFilterValue) const date = parseAsDate(timestamps[0]) return date ? [date] : [] }, [columnFilterValue, multiple])
const onSelect = React.useCallback( (date: Date | DateRange | undefined) => { if (!date) { column.setFilterValue(undefined) return }
if (multiple && !("getTime" in date)) { const from = date.from?.getTime() const to = date.to?.getTime() column.setFilterValue(from || to ? [from, to] : undefined) } else if (!multiple && "getTime" in date) { column.setFilterValue(date.getTime()) } }, [column, multiple], )
const onReset = React.useCallback( (event: React.MouseEvent) => { event.stopPropagation() column.setFilterValue(undefined) }, [column], )
const hasValue = React.useMemo(() => { if (multiple) { if (!getIsDateRange(selectedDates)) return false return selectedDates.from || selectedDates.to } if (!Array.isArray(selectedDates)) return false return selectedDates.length > 0 }, [multiple, selectedDates])
const formatDateRange = React.useCallback((range: DateRange) => { if (!range.from && !range.to) return "" if (range.from && range.to) { return `${formatDate(range.from)} - ${formatDate(range.to)}` } return formatDate(range.from ?? range.to) }, [])
const label = React.useMemo(() => { if (multiple) { if (!getIsDateRange(selectedDates)) return null
const hasSelectedDates = selectedDates.from || selectedDates.to const dateText = hasSelectedDates ? formatDateRange(selectedDates) : "Select date range"
return ( <span className="flex items-center gap-2"> <span>{title}</span> {hasSelectedDates && ( <> <Separator orientation="vertical" className="mx-0.5 data-[orientation=vertical]:h-4" /> <span>{dateText}</span> </> )} </span> ) }
if (getIsDateRange(selectedDates)) return null
const hasSelectedDate = selectedDates.length > 0 const dateText = hasSelectedDate ? formatDate(selectedDates[0]) : "Select date"
return ( <span className="flex items-center gap-2"> <span>{title}</span> {hasSelectedDate && ( <> <Separator orientation="vertical" className="mx-0.5 data-[orientation=vertical]:h-4" /> <span>{dateText}</span> </> )} </span> ) }, [selectedDates, multiple, formatDateRange, title])
return ( <Popover> <PopoverTrigger asChild> {trigger || ( <Button variant="outline" size="sm" className="h-8 border-dashed"> {hasValue ? ( <div role="button" aria-label={`Clear ${title} filter`} tabIndex={0} onClick={onReset} className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none" > <XCircle className="size-4" /> </div> ) : ( <CalendarIcon className="size-4" /> )} {label} </Button> )} </PopoverTrigger> <PopoverContent className="w-auto p-0" align="start"> {multiple ? ( <Calendar captionLayout="dropdown" mode="range" selected={ getIsDateRange(selectedDates) ? selectedDates : { from: undefined, to: undefined } } onSelect={onSelect} /> ) : ( <Calendar captionLayout="dropdown" mode="single" selected={ !getIsDateRange(selectedDates) ? selectedDates[0] : undefined } onSelect={onSelect} /> )} </PopoverContent> </Popover> )}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
TableDateFilter.displayName = "TableDateFilter"Update the import paths to match your project setup.
Column Header Components
Section titled “Column Header Components”DataTableColumnSort:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"
import { TableColumnSortOptions, TableColumnSortMenu,} from "../filters/table-column-sort"import { useDataTable } from "../core/data-table-context"import { useColumnHeaderContext } from "./data-table-column-header"
/** * Sorting options for column header menu using context. */export function DataTableColumnSortOptions<TData, TValue>( props: Omit< React.ComponentProps<typeof TableColumnSortOptions>, "column" | "table" >,) { const { column } = useColumnHeaderContext<TData, TValue>(true) const { table } = useDataTable<TData>() return <TableColumnSortOptions column={column} table={table} {...props} />}
DataTableColumnSortOptions.displayName = "DataTableColumnSortOptions"
/** * Sorting menu for column header using context. * * Standalone button variant for inline use outside dropdown menus. */export function DataTableColumnSortMenu<TData, TValue>( props: Omit< React.ComponentProps<typeof TableColumnSortMenu>, "column" | "table" >,) { const { column } = useColumnHeaderContext<TData, TValue>(true) const { table } = useDataTable<TData>() return <TableColumnSortMenu column={column} table={table} {...props} />}
DataTableColumnSortMenu.displayName = "DataTableColumnSortMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import type { Column, Table } from "@tanstack/react-table"import { Check, CircleHelp } from "lucide-react"
import { Button } from "@/components/ui/button"import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"import { cn } from "@/lib/utils"import { useDataTable } from "../core/data-table-context"
import { SORT_ICONS, SORT_LABELS } from "../config/data-table"import type { SortIconVariant } from "../config/data-table"import { FILTER_VARIANTS } from "../lib/constants"import type { FilterVariant } from "../lib/constants"
/** * Sort options menu items for composition inside TableColumnActions. * * @example * ```tsx * <TableColumnActions> * <TableColumnSortOptions column={column} /> * </TableColumnActions> * ``` */export function TableColumnSortOptions<TData, TValue>({ column, table: propTable, variant: propVariant, withSeparator = true,}: { column: Column<TData, TValue> table?: Table<TData> variant?: SortIconVariant /** Whether to render a separator before the options. @default true */ withSeparator?: boolean}) { const context = useDataTable<TData>() const table = propTable || context.table const sortState = column.getIsSorted()
const variant: FilterVariant = propVariant || column.columnDef.meta?.variant || FILTER_VARIANTS.TEXT
const icons = SORT_ICONS[variant] || SORT_ICONS[FILTER_VARIANTS.TEXT] const labels = SORT_LABELS[variant] || SORT_LABELS[FILTER_VARIANTS.TEXT]
const sortIndex = column.getSortIndex() const isMultiSort = table && table.getState().sorting.length > 1 const showSortBadge = isMultiSort && sortIndex !== -1
/** * Use a ref for immediate synchronous access to shift key state. * React state updates are batched and async, which can cause race conditions * when the dropdown closes - the keyup event might reset state before * handleSort reads it. A ref provides synchronous access. */ const isShiftPressedRef = React.useRef(false) const [isShiftPressed, setIsShiftPressed] = React.useState(false)
React.useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === "Shift") { const isDown = e.type === "keydown" isShiftPressedRef.current = isDown setIsShiftPressed(isDown) } } window.addEventListener("keydown", handleKey, { capture: true }) window.addEventListener("keyup", handleKey, { capture: true }) return () => { window.removeEventListener("keydown", handleKey, { capture: true }) window.removeEventListener("keyup", handleKey, { capture: true }) } }, [])
const handleSort = ( direction: "asc" | "desc" | false, e: | React.MouseEvent | React.KeyboardEvent | Event | { detail?: { originalEvent?: { shiftKey?: boolean } } shiftKey?: boolean nativeEvent?: { shiftKey?: boolean } }, ) => { // Detect Shift across event sources (ref → state → native event → Radix CustomEvent.detail). const isMulti = isShiftPressedRef.current || isShiftPressed || ("shiftKey" in e && !!e.shiftKey) || (e as { detail?: { originalEvent?: { shiftKey?: boolean } } }).detail ?.originalEvent?.shiftKey || (e as { nativeEvent?: { shiftKey?: boolean } }).nativeEvent?.shiftKey
if (direction === false) { column.clearSorting() } else { const isDesc = direction === "desc" const canMultiSort = column.getCanMultiSort()
/** * @see https://tanstack.com/table/v8/docs/guide/sorting#multi-sorting * When using toggleSorting explicitly, we must manually pass the multi-sort flag. */ column.toggleSorting(isDesc, canMultiSort ? isMulti : false) } }
return ( <> {withSeparator && <DropdownMenuSeparator />} <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <div className="flex items-center gap-2"> <span>Column Sort</span> {showSortBadge && ( <Tooltip> <TooltipTrigger asChild> <span className="flex size-4 cursor-help items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground"> {sortIndex + 1} </span> </TooltipTrigger> <TooltipContent side="right"> Sort priority (order in which columns are sorted) </TooltipContent> </Tooltip> )} </div> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> TIP: Hold 'shift' key to enable multi sort </TooltipContent> </Tooltip> </DropdownMenuLabel> <DropdownMenuItem onSelect={e => handleSort("asc", e)} className={cn( "flex items-center", sortState === "asc" && "bg-accent text-accent-foreground", )} > <icons.asc className="mr-2 size-4 text-muted-foreground/70" /> <span className="flex-1">{labels.asc}</span> {sortState === "asc" && <Check className="ml-2 size-4" />} </DropdownMenuItem> <DropdownMenuItem onSelect={e => handleSort("desc", e)} className={cn( "flex items-center", sortState === "desc" && "bg-accent text-accent-foreground", )} > <icons.desc className="mr-2 size-4 text-muted-foreground/70" /> <span className="flex-1">{labels.desc}</span> {sortState === "desc" && <Check className="ml-2 size-4" />} </DropdownMenuItem> {sortState && ( <DropdownMenuItem onSelect={() => column.clearSorting()}> <icons.unsorted className="mr-2 size-4 text-muted-foreground/70" /> Clear Sort </DropdownMenuItem> )} </> )}
/** * Standalone dropdown menu for sorting. * * For composition inside TableColumnActions, use TableColumnSortOptions instead. * * @example * ```tsx * // Standalone * <TableColumnSortMenu column={column} table={table} /> * * // Composed * <TableColumnActions> * <TableColumnSortOptions column={column} /> * </TableColumnActions> * ``` */export function TableColumnSortMenu<TData, TValue>({ column, table: propTable, variant: propVariant, className,}: { column: Column<TData, TValue> table?: Table<TData> variant?: SortIconVariant className?: string}) { const context = useDataTable<TData>() const table = propTable || context.table const canSort = column.getCanSort() const sortState = column.getIsSorted()
const variant: FilterVariant = propVariant || column.columnDef.meta?.variant || FILTER_VARIANTS.TEXT
const icons = SORT_ICONS[variant] || SORT_ICONS[FILTER_VARIANTS.TEXT]
if (!canSort) return null
const SortIcon = sortState === "asc" ? icons.asc : sortState === "desc" ? icons.desc : icons.unsorted
const sortIndex = column.getSortIndex() const isMultiSort = table && table.getState().sorting.length > 1 const showSortBadge = isMultiSort && sortIndex !== -1
return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon" className={cn( "size-7 transition-opacity dark:text-muted-foreground", sortState && "text-primary", className, )} > <div className="relative flex items-center justify-center"> <SortIcon className="size-4" /> {showSortBadge && ( <span className="absolute -top-1 -right-2 flex size-3 items-center justify-center rounded-full bg-primary text-[9px] text-primary-foreground"> {sortIndex + 1} </span> )} </div> <span className="sr-only">Sort column</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-48"> <TableColumnSortOptions column={column} table={table} variant={variant} withSeparator={false} /> </DropdownMenuContent> </DropdownMenu> )}
TableColumnSortOptions.displayName = "TableColumnSortOptions"TableColumnSortMenu.displayName = "TableColumnSortMenu"Update the import paths to match your project setup.
DataTableColumnHide:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"
import { TableColumnHideOptions, TableColumnHideMenu,} from "../filters/table-column-hide"import { useColumnHeaderContext } from "./data-table-column-header"
/** * Hide options for column header menu using context. */export function DataTableColumnHideOptions<TData, TValue>( props: Omit<React.ComponentProps<typeof TableColumnHideOptions>, "column">,) { const { column } = useColumnHeaderContext<TData, TValue>(true) return <TableColumnHideOptions column={column} {...props} />}
DataTableColumnHideOptions.displayName = "DataTableColumnHideOptions"
/** * Standalone hide menu for column header using context. */export function DataTableColumnHideMenu<TData, TValue>( props: Omit<React.ComponentProps<typeof TableColumnHideMenu>, "column">,) { const { column } = useColumnHeaderContext<TData, TValue>(true) return <TableColumnHideMenu column={column} {...props} />}
DataTableColumnHideMenu.displayName = "DataTableColumnHideMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import type { Column } from "@tanstack/react-table"import { CircleHelp, EyeOff } from "lucide-react"
import { Button } from "@/components/ui/button"import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"import { cn } from "@/lib/utils"
/** * Dropdown menu item for hiding a column. * Use inside a DropdownMenuContent or as a child of TableColumnActions. * * @example * ```tsx * // Inside TableColumnActions * <TableColumnActions column={column}> * <TableColumnHideOptions column={column} /> * </TableColumnActions> * ``` */export function TableColumnHideOptions<TData, TValue>({ column, withSeparator = true,}: { column: Column<TData, TValue> /** Whether to render a separator before the option. Defaults to true. */ withSeparator?: boolean}) { const canHide = column.getCanHide()
if (!canHide) return null
return ( <> {withSeparator && <DropdownMenuSeparator />} <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <span>Column Hide</span> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> Hide this column from view </TooltipContent> </Tooltip> </DropdownMenuLabel> <DropdownMenuItem onSelect={() => column.toggleVisibility(false)}> <EyeOff className="mr-2 size-4 text-muted-foreground/70" /> Hide Column </DropdownMenuItem> </> )}
/** * Standalone dropdown menu for hiding a column. * Shows a hide button that opens a dropdown. * * @example * ```tsx * // Standalone usage * <TableColumnHideMenu column={column} /> * ``` */export function TableColumnHideMenu<TData, TValue>({ column, className,}: { column: Column<TData, TValue> className?: string}) { const canHide = column.getCanHide()
if (!canHide) return null
return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon" className={cn( "size-7 transition-opacity group-hover:opacity-100 dark:text-muted-foreground", !column.getIsVisible() ? "text-primary opacity-100" : "opacity-0", className, )} > <EyeOff className="size-4" /> <span className="sr-only">Hide column</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-48"> <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <span>Column Hide</span> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> Hide this column from view </TooltipContent> </Tooltip> </DropdownMenuLabel> <DropdownMenuItem onSelect={() => column.toggleVisibility(false)}> <EyeOff className="mr-2 size-4 text-muted-foreground/70" /> Hide Column </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> )}
/** @deprecated Use `TableColumnHideMenu` instead */export const TableColumnHide = TableColumnHideMenu
TableColumnHideOptions.displayName = "TableColumnHideOptions"TableColumnHideMenu.displayName = "TableColumnHideMenu"Update the import paths to match your project setup.
DataTableColumnPin:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"
import { TableColumnPinOptions, TableColumnPinMenu,} from "../filters/table-column-pin"import { useColumnHeaderContext } from "./data-table-column-header"
/** * Pinning options for column header menu using context. */export function DataTableColumnPinOptions<TData, TValue>( props: Omit<React.ComponentProps<typeof TableColumnPinOptions>, "column">,) { const { column } = useColumnHeaderContext<TData, TValue>(true) return <TableColumnPinOptions column={column} {...props} />}
DataTableColumnPinOptions.displayName = "DataTableColumnPinOptions"
/** * Standalone pinning menu for column header using context. */export function DataTableColumnPinMenu<TData, TValue>( props: Omit<React.ComponentProps<typeof TableColumnPinMenu>, "column">,) { const { column } = useColumnHeaderContext<TData, TValue>(true) return <TableColumnPinMenu column={column} {...props} />}
DataTableColumnPinMenu.displayName = "DataTableColumnPinMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import type { Column } from "@tanstack/react-table"import { Check, CircleHelp, Pin, PinOff } from "lucide-react"
import { Button } from "@/components/ui/button"import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"import { cn } from "@/lib/utils"
/** * Dropdown menu items for pinning a column. * Use inside a DropdownMenuContent or as a child of TableColumnActions. * * @example * ```tsx * // Inside TableColumnActions * <TableColumnActions column={column}> * <TableColumnPinOptions column={column} /> * </TableColumnActions> * ``` */export function TableColumnPinOptions<TData, TValue>({ column, withSeparator = true,}: { column: Column<TData, TValue> /** Whether to render a separator before the options. Defaults to true. */ withSeparator?: boolean}) { const canPin = column.getCanPin() const isPinned = column.getIsPinned()
if (!canPin) return null
return ( <> {withSeparator && <DropdownMenuSeparator />} <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <span>Column Pin</span> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> Pin column to left or right side </TooltipContent> </Tooltip> </DropdownMenuLabel> <DropdownMenuItem onSelect={() => column.pin("left")} className={cn( "flex items-center", isPinned === "left" && "bg-accent text-accent-foreground", )} > <Pin className="mr-2 size-4 -rotate-45" /> <span className="flex-1">Pin to Left</span> {isPinned === "left" && <Check className="ml-2 size-4" />} </DropdownMenuItem> <DropdownMenuItem onSelect={() => column.pin("right")} className={cn( "flex items-center", isPinned === "right" && "bg-accent text-accent-foreground", )} > <Pin className="mr-2 size-4 rotate-45" /> <span className="flex-1">Pin to Right</span> {isPinned === "right" && <Check className="ml-2 size-4" />} </DropdownMenuItem> {isPinned && ( <DropdownMenuItem onSelect={() => column.pin(false)} className="flex items-center" > <PinOff className="mr-2 size-4" /> <span className="flex-1">Unpin</span> </DropdownMenuItem> )} </> )}
/** * Standalone dropdown menu for pinning a column. * Shows a pin button that opens a dropdown with pin options. * * @example * ```tsx * // Standalone usage * <TableColumnPinMenu column={column} /> * ``` */export function TableColumnPinMenu<TData, TValue>({ column, className,}: { column: Column<TData, TValue> className?: string}) { const canPin = column.getCanPin() const isPinned = column.getIsPinned()
if (!canPin) return null
return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon" className={cn( "size-7 transition-opacity group-hover:opacity-100 dark:text-muted-foreground", isPinned ? "text-primary opacity-100" : "opacity-0", className, )} > <Pin className="size-4" /> <span className="sr-only">Pin column</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-48"> <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <span>Column Pin</span> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> Pin column to left or right side </TooltipContent> </Tooltip> </DropdownMenuLabel> <DropdownMenuItem onSelect={() => column.pin("left")} className={cn( "flex items-center", isPinned === "left" && "bg-accent text-accent-foreground", )} > <Pin className="mr-2 size-4 -rotate-45" /> <span className="flex-1">Pin to Left</span> {isPinned === "left" && <Check className="ml-2 size-4" />} </DropdownMenuItem> <DropdownMenuItem onSelect={() => column.pin("right")} className={cn( "flex items-center", isPinned === "right" && "bg-accent text-accent-foreground", )} > <Pin className="mr-2 size-4 rotate-45" /> <span className="flex-1">Pin to Right</span> {isPinned === "right" && <Check className="ml-2 size-4" />} </DropdownMenuItem> <DropdownMenuItem onSelect={() => column.pin(false)} className="flex items-center" > <PinOff className="mr-2 size-4" /> <span className="flex-1">Unpin</span> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> )}
/** @deprecated Use `TableColumnPinMenu` instead */export const TableColumnPin = TableColumnPinMenu
TableColumnPinOptions.displayName = "TableColumnPinOptions"TableColumnPinMenu.displayName = "TableColumnPinMenu"Update the import paths to match your project setup.
DataTableColumnFacetedFilter:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import type { Column } from "@tanstack/react-table"
import { TableColumnFacetedFilterOptions, TableColumnFacetedFilterMenu,} from "../filters/table-column-faceted-filter"import { useDataTable } from "../core/data-table-context"import { useColumnHeaderContext } from "./data-table-column-header"
/** * Faceted filter options for composing inside DataTableColumnActions using context. */export function DataTableColumnFacetedFilterOptions<TData, TValue>( props: Omit< React.ComponentProps<typeof TableColumnFacetedFilterOptions>, "column" > & { column?: Column<TData, TValue> },) { const context = useColumnHeaderContext<TData, TValue>(false) const column = props.column || context?.column
if (!column) { console.warn( "DataTableColumnFacetedFilterOptions must be used within DataTableColumnHeaderRoot or provided with a column prop", ) return null }
return <TableColumnFacetedFilterOptions column={column} {...props} />}
DataTableColumnFacetedFilterOptions.displayName = "DataTableColumnFacetedFilterOptions"
/** * Standalone faceted filter menu for column header using context. */export function DataTableColumnFacetedFilterMenu<TData, TValue>( props: Omit< React.ComponentProps<typeof TableColumnFacetedFilterMenu>, "column" | "table" > & { column?: Column<TData, TValue> },) { const context = useColumnHeaderContext<TData, TValue>(false) const column = props.column || context?.column const { table, generatedOptionsMap } = useDataTable<TData>()
if (!column) { console.warn( "DataTableColumnFacetedFilterMenu must be used within DataTableColumnHeaderRoot or provided with a column prop", ) return null }
return ( <TableColumnFacetedFilterMenu column={column} table={table} precomputedOptions={ // Skip batch cache when caller supplies custom per-column config // (dynamicCounts / limitToFilteredRows) so those props are honoured. "dynamicCounts" in props || "limitToFilteredRows" in props ? undefined : generatedOptionsMap } {...props} /> )}
DataTableColumnFacetedFilterMenu.displayName = "DataTableColumnFacetedFilterMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import type { Column, Table } from "@tanstack/react-table"import { CircleHelp, Filter, FilterX } from "lucide-react"
import { Button } from "@/components/ui/button"import { DropdownMenuSeparator, DropdownMenuLabel,} from "@/components/ui/dropdown-menu"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"import { cn } from "@/lib/utils"import { TableFacetedFilter, TableFacetedFilterContent, useTableFacetedFilter,} from "./table-faceted-filter"import { useDerivedColumnTitle } from "../hooks/use-derived-column-title"import { useGeneratedOptionsForColumn } from "../hooks/use-generated-options"import { formatLabel } from "../lib/format"import type { Option } from "../types"
/** * A standard filter trigger button (Funnel icon). */export function TableColumnFilterTrigger<TData, TValue>({ column, className, ...props}: { column: Column<TData, TValue>} & React.ComponentProps<typeof Button>) { const isFiltered = column.getIsFiltered()
const Icon = isFiltered ? FilterX : Filter
return ( <Button variant="ghost" size="icon" className={cn( "size-7 transition-opacity dark:text-muted-foreground", isFiltered && "text-primary", className, )} {...props} > <Icon className="size-3.5" /> <span className="sr-only">Filter column</span> </Button> )}
/** * Faceted filter options for composing inside TableColumnActions. * Renders as inline searchable menu with checkboxes. * * @example * ```tsx * // Inside TableColumnActions * <TableColumnActions column={column}> * <TableColumnFacetedFilterOptions * column={column} * options={[{ label: "Active", value: "active" }]} * multiple * /> * </TableColumnActions> * ``` */export function TableColumnFacetedFilterOptions<TData, TValue>({ column, title, options = [], onValueChange, multiple = true, withSeparator = true,}: { column: Column<TData, TValue> title?: string options?: Option[] onValueChange?: (value: string[] | undefined) => void /** Whether to allow multiple selections. Defaults to true. */ multiple?: boolean /** Whether to render a separator before the options. Defaults to true. */ withSeparator?: boolean}) { const { selectedValues, onItemSelect, onReset } = useTableFacetedFilter({ column: column as Column<TData, unknown>, onValueChange, multiple, })
const derivedTitle = useDerivedColumnTitle(column, column.id, title) const labelText = multiple ? "Column Multi Select" : "Column Select" const tooltipText = multiple ? "Select multiple options to filter" : "Select a single option to filter"
return ( <> {withSeparator && <DropdownMenuSeparator />} <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <span>{labelText}</span> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> {tooltipText} {derivedTitle && ` - ${derivedTitle}`} </TooltipContent> </Tooltip> </DropdownMenuLabel> <TableFacetedFilterContent title={derivedTitle} options={options} selectedValues={selectedValues} onItemSelect={onItemSelect} onReset={onReset} /> </> )}
/** * Standalone faceted filter menu for column headers. * Shows a filter button that opens a popover with filter options. * * @example * ```tsx * // Standalone usage * <TableColumnFacetedFilterMenu * column={column} * options={[{ label: "Active", value: "active" }]} * /> * ``` */export function TableColumnFacetedFilterMenu<TData, TValue>({ column, table, title, options, onValueChange, multiple, limitToFilteredRows, dynamicCounts = true, precomputedOptions, ...props}: Omit< React.ComponentProps<typeof TableFacetedFilter>, "column" | "trigger" | "options"> & { column: Column<TData, TValue> table?: Table<TData> title?: string options?: React.ComponentProps<typeof TableFacetedFilter>["options"] /** * If true, only show options that exist in the currently filtered rows. * If false, show all options from the entire dataset. * @default !multiple (true for single-select, false for multi-select) */ limitToFilteredRows?: boolean /** * Whether to update counts based on other active filters. * @default true */ dynamicCounts?: boolean /** * Precomputed options map from DataTableProvider context. When provided, * skips per-column option generation. */ precomputedOptions?: Record<string, Option[]>}) { // Default: multi-select shows all options, single-select filters to visible rows limitToFilteredRows ??= !multiple
const derivedTitle = useDerivedColumnTitle(column, column.id, title)
// Auto-generate options from column meta (works for select/multiSelect variants). // Use precomputed batch when available to avoid per-column row scans. const needsPerColumnGeneration = !precomputedOptions const perColumnOptions = useGeneratedOptionsForColumn( table as Table<TData>, needsPerColumnGeneration ? column.id : "__noop__", { limitToFilteredRows, dynamicCounts }, ) const generatedOptions = precomputedOptions?.[column.id] ?? perColumnOptions
/** * REACTIVITY FIX: Extract row model references outside memos so that when * async data arrives, the new rows array reference triggers memo recomputation. * Without this, `table` reference is stable across data changes and memos * would return stale (empty) results after initial render with no data. */ const coreRows = table?.getCoreRowModel().rows const filteredRows = table?.getFilteredRowModel().rows
// Fallback: generate options from row data for any variant (text, boolean, etc.) const fallbackOptions = React.useMemo((): Option[] => { if (!table || !column) return []
const meta = column.columnDef.meta const autoOptionsFormat = (meta as Record<string, unknown>)?.autoOptionsFormat ?? true const showCounts = (meta as Record<string, unknown>)?.showCounts ?? true
// optionRows used for the list of options const optionRows = limitToFilteredRows ? filteredRows : coreRows
// countRows used for the counts const countRows = dynamicCounts ? filteredRows : coreRows
if (!optionRows || !countRows) return []
const valueCounts = new Map<string, number>()
// Determine the set of available options const availableOptions = new Set<string>() optionRows.forEach(row => { const raw = row.getValue(column.id) as unknown const values: unknown[] = Array.isArray(raw) ? raw : [raw] values.forEach(v => { if (v != null) { const s = String(v) if (s) availableOptions.add(s) } }) })
// Calculate counts for available options countRows.forEach(row => { const raw = row.getValue(column.id) as unknown const values: unknown[] = Array.isArray(raw) ? raw : [raw] values.forEach(v => { if (v != null) { const s = String(v) if (availableOptions.has(s)) { valueCounts.set(s, (valueCounts.get(s) || 0) + 1) } } }) })
// If static options exist in meta with augment strategy, use them with counts const metaOptions = (meta as Record<string, unknown>)?.options as | Option[] | undefined const mergeStrategy = (meta as Record<string, unknown>)?.mergeStrategy as | string | undefined
if (metaOptions && metaOptions.length > 0 && mergeStrategy === "augment") { return metaOptions .filter(opt => !limitToFilteredRows || availableOptions.has(opt.value)) .map(opt => ({ ...opt, count: showCounts ? (valueCounts.get(opt.value) ?? 0) : undefined, })) }
if (metaOptions && metaOptions.length > 0) { return limitToFilteredRows ? metaOptions.filter(opt => availableOptions.has(opt.value)) : metaOptions }
return Array.from(availableOptions) .map(value => ({ label: autoOptionsFormat ? formatLabel(value) : value, value, count: showCounts ? valueCounts.get(value) || 0 : undefined, })) .sort((a, b) => a.label.localeCompare(b.label)) }, [ table, column, limitToFilteredRows, dynamicCounts, coreRows, filteredRows, ])
/** * Enrich caller-supplied `options` with live counts and (optionally) narrow * them to values that exist in the current row set. Mirrors the row-set * split used by `fallbackOptions` so explicit and generated paths stay * consistent — without this, `dynamicCounts` was silently ignored whenever * a caller passed their own options. */ const enrichedCallerOptions = React.useMemo(() => { if (!options) return null // Preserve the original `options ?? ...` semantics: an explicit empty // array still wins over generated/fallback options. if (options.length === 0 || !table || !column) return options
const showCounts = (column.columnDef.meta as Record<string, unknown>)?.showCounts ?? true
// Caller-supplied options are never narrowed — the caller is the source // of truth for which values can ever appear (e.g. server-side tables // pass the full static option list every render). Narrowing here would // hide cross-filter pivots. if (!showCounts) { return options.map(opt => ({ ...opt, count: undefined })) }
const countRows = dynamicCounts ? filteredRows : coreRows const valueCounts = new Map<string, number>() if (countRows) { countRows.forEach(row => { const raw = row.getValue(column.id) as unknown const values: unknown[] = Array.isArray(raw) ? raw : [raw] values.forEach(v => { if (v != null) { const s = String(v) valueCounts.set(s, (valueCounts.get(s) || 0) + 1) } }) }) }
/** * Cross-filter narrowing default — caller-supplied `count` wins (server- * side tables compute true cross-filter counts on the server; client- * derived `valueCounts` only sees the current page). After merging, * options with explicit `count: 0` are hidden so server-side tables get * automatic cross-filter narrowing without each caller writing a helper. * * Opt-out: pass `count: undefined` (or omit it) on options you want * visible regardless. Pure label-only callers (no counts anywhere) are * unaffected — `valueCounts` falls back to client rows. */ return options .map(opt => ({ ...opt, count: opt.count ?? valueCounts.get(opt.value) ?? 0, })) .filter(opt => opt.count !== 0) }, [options, table, column, dynamicCounts, coreRows, filteredRows])
const resolvedOptions = enrichedCallerOptions ?? (generatedOptions.length > 0 ? generatedOptions : fallbackOptions)
return ( <TableFacetedFilter column={column} title={derivedTitle} options={resolvedOptions} multiple={multiple} onValueChange={onValueChange} trigger={<TableColumnFilterTrigger column={column} />} {...props} /> )}
TableColumnFacetedFilterOptions.displayName = "TableColumnFacetedFilterOptions"TableColumnFacetedFilterMenu.displayName = "TableColumnFacetedFilterMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"
import { TableColumnFilterTrigger } from "../filters/table-column-faceted-filter"import { useColumnHeaderContext } from "./data-table-column-header"
/** * A standard filter trigger button (Funnel icon) using context. */export function DataTableColumnFilterTrigger<TData, TValue>( props: Omit<React.ComponentProps<typeof TableColumnFilterTrigger>, "column">,) { const { column } = useColumnHeaderContext<TData, TValue>(true) return <TableColumnFilterTrigger column={column} {...props} />}
DataTableColumnFilterTrigger.displayName = "DataTableColumnFilterTrigger"Update the import paths to match your project setup.
DataTableColumnSliderFilter:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import type { Column } from "@tanstack/react-table"
import { TableColumnSliderFilterOptions, TableColumnSliderFilterMenu,} from "../filters/table-column-slider-filter"import { useColumnHeaderContext } from "./data-table-column-header"
/** * Slider filter options for composing inside DataTableColumnActions using context. */export function DataTableColumnSliderFilterOptions<TData, TValue>( props: Omit< React.ComponentProps<typeof TableColumnSliderFilterOptions>, "column" > & { column?: Column<TData, TValue> },) { const context = useColumnHeaderContext<TData, TValue>(false) const column = props.column || context?.column
if (!column) { console.warn( "DataTableColumnSliderFilterOptions must be used within DataTableColumnHeaderRoot or provided with a column prop", ) return null }
return <TableColumnSliderFilterOptions column={column} {...props} />}
DataTableColumnSliderFilterOptions.displayName = "DataTableColumnSliderFilterOptions"
/** * Standalone slider filter menu for column header using context. */export function DataTableColumnSliderFilterMenu<TData, TValue>( props: Omit< React.ComponentProps<typeof TableColumnSliderFilterMenu>, "column" > & { column?: Column<TData, TValue> },) { const context = useColumnHeaderContext<TData, TValue>(false) const column = props.column || context?.column
if (!column) { console.warn( "DataTableColumnSliderFilterMenu must be used within DataTableColumnHeaderRoot or provided with a column prop", ) return null }
return <TableColumnSliderFilterMenu column={column} {...props} />}
DataTableColumnSliderFilterMenu.displayName = "DataTableColumnSliderFilterMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import type { Column } from "@tanstack/react-table"import { CircleHelp, SlidersHorizontal } from "lucide-react"
import { DropdownMenuSeparator, DropdownMenuLabel,} from "@/components/ui/dropdown-menu"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"import { Input } from "@/components/ui/input"import { Label } from "@/components/ui/label"import { Button } from "@/components/ui/button"import { Slider } from "@/components/ui/slider"import { cn } from "@/lib/utils"import { useDerivedColumnTitle } from "../hooks/use-derived-column-title"
type RangeValue = [number, number]
function parseValuesAsNumbers(value: unknown): RangeValue | undefined { if ( Array.isArray(value) && value.length === 2 && value.every( v => (typeof v === "string" || typeof v === "number") && !Number.isNaN(v), ) ) { return [Number(value[0]), Number(value[1])] }
return undefined}
function getIsValidRange(value: unknown): value is RangeValue { return ( Array.isArray(value) && value.length === 2 && typeof value[0] === "number" && typeof value[1] === "number" )}
/** * Slider filter options for composing inside TableColumnActions. * Renders as inline slider with min/max inputs. * * @example * ```tsx * // Inside TableColumnActions * <TableColumnActions column={column}> * <TableColumnSliderFilterOptions * column={column} * min={0} * max={1000} * /> * </TableColumnActions> * ``` */export function TableColumnSliderFilterOptions<TData, TValue>({ column, title, range: manualRange, min: manualMin, max: manualMax, step: manualStep, unit: manualUnit, onValueChange, withSeparator = true,}: { column: Column<TData, TValue> title?: string /** * Manual range [min, max] (overrides min/max props and column.meta.range) */ range?: RangeValue /** * Manual minimum value (overrides column.meta.range and faceted values) */ min?: number /** * Manual maximum value (overrides column.meta.range and faceted values) */ max?: number /** * Manual step value for the slider */ step?: number /** * Unit label to display (e.g., "$", "kg", "km") */ unit?: string onValueChange?: (value: [number, number] | undefined) => void /** Whether to render a separator before the options. Defaults to true. */ withSeparator?: boolean}) { const id = React.useId()
const columnFilterValue = parseValuesAsNumbers(column.getFilterValue())
const defaultRange = column.columnDef.meta?.range const unit = manualUnit ?? column.columnDef.meta?.unit
// Capture faceted min/max as scalars so the memo re-runs when filters/data change. const facetedValues = column.getFacetedMinMaxValues() const facetedMin = facetedValues?.[0] const facetedMax = facetedValues?.[1]
// Compute range values - memoized to avoid recalculation const { min, max, step } = React.useMemo<{ min: number max: number step: number }>(() => { let minValue = 0 let maxValue = 100
// Priority 1: Manual range prop (highest priority) if (manualRange && getIsValidRange(manualRange)) { minValue = manualRange[0] maxValue = manualRange[1] } // Priority 2: Manual min/max props else if (manualMin != null && manualMax != null) { minValue = manualMin maxValue = manualMax } // Priority 3: Use explicit range from column metadata else if (defaultRange && getIsValidRange(defaultRange)) { minValue = defaultRange[0] maxValue = defaultRange[1] } // Priority 4: Get min/max from faceted values else if (facetedMin != null && facetedMax != null) { minValue = Number(facetedMin) maxValue = Number(facetedMax) }
// Calculate appropriate step size based on range const rangeSize = maxValue - minValue const calculatedStep = rangeSize <= 20 ? 1 : rangeSize <= 100 ? Math.ceil(rangeSize / 20) : Math.ceil(rangeSize / 50)
return { min: minValue, max: maxValue, step: manualStep ?? calculatedStep, } }, [ defaultRange, manualRange, manualMin, manualMax, manualStep, facetedMin, facetedMax, ])
const range = React.useMemo((): RangeValue => { return columnFilterValue ?? [min, max] }, [columnFilterValue, min, max])
const derivedTitle = useDerivedColumnTitle(column, column.id, title) const labelText = "Range Filter" const tooltipText = "Set a range to filter values"
const applyFilterValue = React.useCallback( (value: [number, number] | undefined) => { column.setFilterValue(value) onValueChange?.(value) }, [column, onValueChange], )
const onRangeValueChange = React.useCallback( (value: string | number, isMin?: boolean) => { const numValue = Number(value) const currentValues = range
if (value === "") { // Allow empty value, don't update filter return }
if ( !Number.isNaN(numValue) && (isMin ? numValue >= min && numValue <= currentValues[1] : numValue <= max && numValue >= currentValues[0]) ) { applyFilterValue( isMin ? [numValue, currentValues[1]] : [currentValues[0], numValue], ) } }, [min, max, range, applyFilterValue], )
const onSliderValueChange = React.useCallback( (value: RangeValue) => { if (Array.isArray(value) && value.length === 2) { applyFilterValue(value) } }, [applyFilterValue], )
const onReset = React.useCallback(() => { applyFilterValue(undefined) }, [applyFilterValue])
return ( <> {withSeparator && <DropdownMenuSeparator />} <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <span>{labelText}</span> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> {tooltipText} {derivedTitle && ` - ${derivedTitle}`} </TooltipContent> </Tooltip> </DropdownMenuLabel> <div className="px-2 py-2"> <div className="flex flex-col gap-3"> <div className="flex items-center gap-2"> <Label htmlFor={`${id}-from`} className="sr-only"> From </Label> <div className="relative flex-1"> <Input key={`${id}-from-${range[0]}`} id={`${id}-from`} type="number" aria-label={`${derivedTitle} minimum value`} aria-valuemin={min} aria-valuemax={max} inputMode="numeric" pattern="[0-9]*" placeholder={min.toString()} min={min} max={max} defaultValue={range[0]} onChange={event => onRangeValueChange(String(event.target.value), true) } className={cn("h-8 w-full", unit && "pr-8")} /> {unit && ( <span className="absolute top-0 right-0 bottom-0 mt-0.5 mr-0.5 flex h-7 items-center rounded-r-md bg-accent px-2 text-sm text-muted-foreground"> {unit} </span> )} </div> <Label htmlFor={`${id}-to`} className="sr-only"> to </Label> <div className="relative flex-1"> <Input key={`${id}-to-${range[1]}`} id={`${id}-to`} type="number" aria-label={`${derivedTitle} maximum value`} aria-valuemin={min} aria-valuemax={max} inputMode="numeric" pattern="[0-9]*" placeholder={max.toString()} min={min} max={max} defaultValue={range[1]} onChange={event => onRangeValueChange(String(event.target.value)) } className={cn("h-8 w-full", unit && "pr-8")} /> {unit && ( <span className="absolute top-0 right-0 bottom-0 mt-0.5 mr-0.5 flex h-7 items-center rounded-r-md bg-accent px-2 text-sm text-muted-foreground"> {unit} </span> )} </div> </div> <Label htmlFor={`${id}-slider`} className="sr-only"> {derivedTitle} slider </Label> <Slider id={`${id}-slider`} min={min} max={max} step={step} value={range} onValueChange={onSliderValueChange} className="w-full" /> <Button aria-label={`Clear ${derivedTitle} filter`} variant="outline" size="sm" onClick={onReset} className="w-full" > Clear </Button> </div> </div> </> )}
TableColumnSliderFilterOptions.displayName = "TableColumnSliderFilterOptions"
/** * Standalone slider filter menu for column headers. * Shows a filter button that opens a popover with a range slider. * * @example * ```tsx * // Standalone usage * <TableColumnSliderFilterMenu * column={column} * min={0} * max={1000} * /> * ``` */export function TableColumnSliderFilterMenu<TData, TValue>({ column, title, className, ...props}: Omit< React.ComponentProps<typeof TableColumnSliderFilterOptions>, "withSeparator" | "column"> & { column: Column<TData, TValue> className?: string}) { return ( <Popover> <PopoverTrigger asChild> <Button variant="ghost" size="icon" className={cn( "size-7 transition-opacity dark:text-muted-foreground", column.getIsFiltered() && "text-primary", className, )} > <SlidersHorizontal className="size-3.5" /> <span className="sr-only">Filter by range</span> </Button> </PopoverTrigger> <PopoverContent align="end" className="w-52 p-0"> <TableColumnSliderFilterOptions column={column} title={title} withSeparator={false} {...props} /> </PopoverContent> </Popover> )}
TableColumnSliderFilterMenu.displayName = "TableColumnSliderFilterMenu"Update the import paths to match your project setup.
DataTableColumnDateFilter:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import type { Column } from "@tanstack/react-table"
import { TableColumnDateFilterOptions, TableColumnDateFilterMenu,} from "../filters/table-column-date-filter"import { useColumnHeaderContext } from "./data-table-column-header"
/** * Date filter options for composing inside DataTableColumnActions using context. */export function DataTableColumnDateFilterOptions<TData, TValue>( props: Omit< React.ComponentProps<typeof TableColumnDateFilterOptions>, "column" > & { column?: Column<TData, TValue> },) { const context = useColumnHeaderContext<TData, TValue>(false) const column = props.column || context?.column
if (!column) { console.warn( "DataTableColumnDateFilterOptions must be used within DataTableColumnHeaderRoot or provided with a column prop", ) return null }
return <TableColumnDateFilterOptions column={column} {...props} />}
DataTableColumnDateFilterOptions.displayName = "DataTableColumnDateFilterOptions"
/** * Standalone date filter menu for column header using context. */export function DataTableColumnDateFilterMenu<TData, TValue>( props: Omit< React.ComponentProps<typeof TableColumnDateFilterMenu>, "column" > & { column?: Column<TData, TValue> },) { const context = useColumnHeaderContext<TData, TValue>(false) const column = props.column || context?.column
if (!column) { console.warn( "DataTableColumnDateFilterMenu must be used within DataTableColumnHeaderRoot or provided with a column prop", ) return null }
return <TableColumnDateFilterMenu column={column} {...props} />}
DataTableColumnDateFilterMenu.displayName = "DataTableColumnDateFilterMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import type { Column } from "@tanstack/react-table"import type { DateRange } from "react-day-picker"import { CircleHelp, CalendarIcon, CalendarX2 } from "lucide-react"
import { DropdownMenuSeparator, DropdownMenuLabel,} from "@/components/ui/dropdown-menu"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"import { Calendar } from "@/components/ui/calendar"import { Button } from "@/components/ui/button"import { useDerivedColumnTitle } from "../hooks/use-derived-column-title"import { formatDate } from "../lib/format"import { cn } from "@/lib/utils"
type DateSelection = Date[] | DateRange
function parseAsDate(timestamp: number | string | undefined): Date | undefined { if (!timestamp) return undefined const numericTimestamp = typeof timestamp === "string" ? Number(timestamp) : timestamp const date = new Date(numericTimestamp) return !Number.isNaN(date.getTime()) ? date : undefined}
function parseColumnFilterValue(value: unknown) { if (value === null || value === undefined) { return [] }
if (Array.isArray(value)) { return value.map(item => { if (typeof item === "number" || typeof item === "string") { return item } return undefined }) }
if (typeof value === "string" || typeof value === "number") { return [value] }
return []}
/** * Date filter options for composing inside TableColumnActions. * Renders as button that opens a popover with calendar picker - matches FilterDatePicker from table-filter-menu. * * @example * ```tsx * // Inside TableColumnActions * <TableColumnActions column={column}> * <TableColumnDateFilterOptions * column={column} * multiple * /> * </TableColumnActions> * ``` */export function TableColumnDateFilterOptions<TData, TValue>({ column, title, multiple = true, withSeparator = true,}: { column: Column<TData, TValue> title?: string /** Whether to allow date range selection. Defaults to true. */ multiple?: boolean /** Whether to render a separator before the options. Defaults to true. */ withSeparator?: boolean}) { const [showValueSelector, setShowValueSelector] = React.useState(false) const columnFilterValue = column.getFilterValue()
const selectedDates = React.useMemo<DateSelection>(() => { if (!columnFilterValue) { return multiple ? { from: undefined, to: undefined } : [] }
if (multiple) { const timestamps = parseColumnFilterValue(columnFilterValue) return { from: parseAsDate(timestamps[0]), to: parseAsDate(timestamps[1]), } }
const timestamps = parseColumnFilterValue(columnFilterValue) const date = parseAsDate(timestamps[0]) return date ? [date] : [] }, [columnFilterValue, multiple])
const derivedTitle = useDerivedColumnTitle(column, column.id, title) const labelText = multiple ? "Date Range Filter" : "Date Filter" const tooltipText = multiple ? "Select a date range to filter" : "Select a date to filter"
const dateValue = Array.isArray(selectedDates) ? selectedDates.filter(Boolean) : [selectedDates.from, selectedDates.to].filter(Boolean)
const displayValue = multiple && dateValue.length === 2 ? `${formatDate(dateValue[0] as Date)} - ${formatDate(dateValue[1] as Date)}` : dateValue[0] ? formatDate(dateValue[0] as Date) : "Pick a date"
const onSelect = React.useCallback( (date: Date | DateRange | undefined) => { if (!date) { column.setFilterValue(undefined) return }
if (multiple && !("getTime" in date)) { const from = date.from?.getTime() const to = date.to?.getTime()
if (from && to) { column.setFilterValue([from, to]) } else if (from) { column.setFilterValue([from]) } else { column.setFilterValue(undefined) } } else if (!multiple && "getTime" in date) { column.setFilterValue([date.getTime()]) } }, [column, multiple], )
const onReset = React.useCallback(() => { column.setFilterValue(undefined) }, [column])
return ( <> {withSeparator && <DropdownMenuSeparator />} <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <span>{labelText}</span> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> {tooltipText} {derivedTitle && ` - ${derivedTitle}`} </TooltipContent> </Tooltip> </DropdownMenuLabel> <div className="px-2 py-2"> <Popover open={showValueSelector} onOpenChange={setShowValueSelector}> <PopoverTrigger asChild> <Button variant="outline" size="sm" className={cn( "w-full justify-start rounded text-left font-normal", !columnFilterValue && "text-muted-foreground", )} > <CalendarIcon /> <span className="truncate">{displayValue}</span> </Button> </PopoverTrigger> <PopoverContent align="start" className="w-auto p-0"> {multiple ? ( <Calendar mode="range" captionLayout="dropdown" selected={selectedDates as DateRange} onSelect={onSelect as (date: DateRange | undefined) => void} /> ) : ( <Calendar mode="single" captionLayout="dropdown" selected={(selectedDates as Date[])[0]} onSelect={onSelect as (date: Date | undefined) => void} /> )} </PopoverContent> </Popover> <Button variant="outline" size="sm" onClick={onReset} className="mt-2 w-full" > Clear </Button> </div> </> )}
TableColumnDateFilterOptions.displayName = "TableColumnDateFilterOptions"
/** * Standalone date filter menu for column headers. * Shows a filter button that opens a popover with a calendar picker. * * @example * ```tsx * // Standalone usage * <TableColumnDateFilterMenu * column={column} * multiple * /> * ``` */export function TableColumnDateFilterMenu<TData, TValue>({ column, title, className, ...props}: Omit< React.ComponentProps<typeof TableColumnDateFilterOptions>, "withSeparator" | "column"> & { column: Column<TData, TValue> className?: string}) { return ( <Popover> <PopoverTrigger asChild> <Button variant="ghost" size="icon" className={cn( "size-7 transition-opacity dark:text-muted-foreground", column.getIsFiltered() && "text-primary", className, )} > {column.getIsFiltered() ? ( <CalendarX2 className="size-3.5" /> ) : ( <CalendarIcon className="size-3.5" /> )} <span className="sr-only">Filter by date</span> </Button> </PopoverTrigger> <PopoverContent align="end" className="w-auto p-0"> <TableColumnDateFilterOptions column={column} title={title} withSeparator={false} {...props} /> </PopoverContent> </Popover> )}
TableColumnDateFilterMenu.displayName = "TableColumnDateFilterMenu"Update the import paths to match your project setup.
Layout Components
Section titled “Layout Components”DataTableVirtualized:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import { useVirtualizer } from "@tanstack/react-virtual"import { flexRender, type Row } from "@tanstack/react-table"import { cn } from "@/lib/utils"import { useDataTable } from "./data-table-context"import { TableHeader, TableRow, TableHead, TableBody, TableCell,} from "@/components/ui/table"import { Skeleton } from "@/components/ui/skeleton"import { DataTableEmptyState } from "../components/data-table-empty-state"import { DataTableColumnHeaderRoot } from "../components/data-table-column-header"import { createScrollHandler } from "../lib/create-scroll-handler"import { isInteractiveClickTarget } from "../lib/row-click"import { getCommonPinningStyles } from "../lib/styles"
// ============================================================================// Stable measureElement — computed once at module level// ============================================================================
// Sums base + expanded sibling height (ResizeObserver only sees the base row).// Disabled in Firefox where stale `getBoundingClientRect` causes measure loops.//// Prefers `ResizeObserverEntry.borderBoxSize` for the base row when TanStack// Virtual passes the entry through — that height is computed off the same// observation that fired the callback, so we skip a forced layout read.// Falls back to `getBoundingClientRect` for the initial measure (no entry)// and for the expanded sibling (not observed by the virtualizer).const measureElement: | ((element: Element, entry?: ResizeObserverEntry | undefined) => number) | undefined = typeof window !== "undefined" && navigator.userAgent.indexOf("Firefox") === -1 ? (element, entry) => { const baseHeight = entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height const next = element.nextElementSibling if ( next && next.getAttribute("data-slot") === "datatable-expanded-row" ) { return baseHeight + next.getBoundingClientRect().height } return baseHeight } : undefined
// ============================================================================// ScrollEvent Type// ============================================================================
export interface ScrollEvent { scrollTop: number scrollHeight: number clientHeight: number isTop: boolean isBottom: boolean percentage: number}
// ============================================================================// DataTableVirtualizedHeader// ============================================================================
export interface DataTableVirtualizedHeaderProps { className?: string /** * Makes the header sticky at the top when scrolling. * @default true */ sticky?: boolean}
export const DataTableVirtualizedHeader = React.memo( function DataTableVirtualizedHeader({ className, sticky = true, }: DataTableVirtualizedHeaderProps) { const { table } = useDataTable()
const headerGroups = table?.getHeaderGroups() ?? []
if (headerGroups.length === 0) { return null }
return ( <TableHeader className={cn(sticky && "sticky top-0 z-30 bg-background", className)} > {headerGroups.map(headerGroup => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map(header => ( <TableHead key={header.id} className={cn(header.column.getIsPinned() && "bg-background")} style={getCommonPinningStyles(header.column, true)} > {header.isPlaceholder ? null : ( <DataTableColumnHeaderRoot column={header.column}> {flexRender( header.column.columnDef.header, header.getContext(), )} </DataTableColumnHeaderRoot> )} </TableHead> ))} </TableRow> ))} </TableHeader> ) },)
DataTableVirtualizedHeader.displayName = "DataTableVirtualizedHeader"
// ============================================================================// DataTableVirtualizedFlexHeader// ============================================================================
export interface DataTableVirtualizedFlexHeaderProps { className?: string /** * Makes the header sticky at the top when scrolling. * @default true */ sticky?: boolean}
/** * Flex-layout header — pairs with `DataTableVirtualizedDndBody` (row-DnD). * Mirrors the body's cell sizing so columns stay aligned. * * Use `DataTableVirtualizedHeader` for plain tables and * `DataTableVirtualizedDndHeader` for column-DnD tables. * * @example * <DataTableRowDndProvider data={data} onReorder={setData}> * <DataTable height={500}> * <DataTableVirtualizedFlexHeader /> * <DataTableVirtualizedDndBody /> * </DataTable> * </DataTableRowDndProvider> */export const DataTableVirtualizedFlexHeader = React.memo( function DataTableVirtualizedFlexHeader({ className, sticky = true, }: DataTableVirtualizedFlexHeaderProps) { const { table } = useDataTable()
const headerGroups = table?.getHeaderGroups() ?? []
if (headerGroups.length === 0) { return null }
return ( <TableHeader className={cn( "block", sticky && "sticky top-0 z-30 bg-background", className, )} > {headerGroups.map(headerGroup => ( <TableRow key={headerGroup.id} className="flex w-full border-b"> {headerGroup.headers.map(header => { const size = header.column.columnDef.size return ( <TableHead key={header.id} className={cn( size ? "shrink-0" : "min-w-0 flex-1", "flex items-center", header.column.getIsPinned() && "bg-background", )} style={{ width: size ? `${size}px` : undefined, ...getCommonPinningStyles(header.column, true), }} > {header.isPlaceholder ? null : ( <DataTableColumnHeaderRoot column={header.column}> {flexRender( header.column.columnDef.header, header.getContext(), )} </DataTableColumnHeaderRoot> )} </TableHead> ) })} </TableRow> ))} </TableHeader> ) },)
DataTableVirtualizedFlexHeader.displayName = "DataTableVirtualizedFlexHeader"
// ============================================================================// VirtualizedBodyRow — memoized to keep selection / expansion / column-vis// changes from cascading into all visible rows// ============================================================================
/** * Per-row component for `DataTableVirtualizedBody`. Wrapped with * `React.memo` so single-row state changes (selection, expansion) don't * reconcile every other visible row. * * The measure ref is wrapped in a stable callback by the parent so it * doesn't invalidate the memo on every parent render. Composite key * (`${row.id}-${isExpanded}`) stays on the parent so the row remounts * on expansion toggle and `ResizeObserver` re-attaches. */interface VirtualizedBodyRowProps<TData> { row: Row<TData> virtualIndex: number expandColumnId: string | undefined isExpanded: boolean isSelected: boolean isClickable: boolean measureRef: ((node: HTMLTableRowElement | null) => void) | undefined onClick: (event: React.MouseEvent<HTMLElement>) => void /** Column layout signature — invalidates React.memo on visibility/order/pinning change. */ columnLayoutSignature: string /** * Per-row memo key. Change this string to force React.memo to re-render a * specific row when row-level state changes outside of TanStack Table's * tracked props (e.g. inline edit mode, optimistic state). */ rowMemoKey: string}
const VirtualizedBodyRowInner = function VirtualizedBodyRow<TData>({ row, virtualIndex, expandColumnId, isExpanded, isSelected, isClickable, measureRef, onClick, columnLayoutSignature, rowMemoKey,}: VirtualizedBodyRowProps<TData>) { const expandCell = isExpanded && expandColumnId ? row.getAllCells().find(c => c.column.id === expandColumnId) : undefined
const visibleCells = row.getVisibleCells()
// Cache the base-row DOM node so we can re-trigger measureRef when column // layout changes while the row is expanded (no remount = no automatic re-measure). const elementRef = React.useRef<HTMLTableRowElement | null>(null) const setRef = React.useCallback( (node: HTMLTableRowElement | null) => { elementRef.current = node if (measureRef) measureRef(node) }, [measureRef], )
// Re-measure when column layout changes while expanded so the virtualizer // picks up the updated combined base + expanded-pane height. React.useEffect(() => { if (isExpanded && measureRef && elementRef.current) { measureRef(elementRef.current) } }, [isExpanded, rowMemoKey, columnLayoutSignature, measureRef])
return ( <> <TableRow ref={setRef} data-index={virtualIndex} data-row-id={row.id} data-state={isSelected ? "selected" : undefined} onClick={onClick} className={cn("group", isClickable && "cursor-pointer")} > {visibleCells.map(cell => { const size = cell.column.columnDef.size const cellStyle = { width: size ? `${size}px` : undefined, ...getCommonPinningStyles(cell.column, false), }
return ( <TableCell key={cell.id} className={cn( "overflow-hidden", cell.column.getIsPinned() && "bg-background group-hover:bg-muted/50 group-data-[state=selected]:bg-muted", )} style={cellStyle} > {flexRender(cell.column.columnDef.cell, cell.getContext())} </TableCell> ) })} </TableRow>
{isExpanded && expandCell && ( <TableRow data-slot="datatable-expanded-row"> <TableCell colSpan={visibleCells.length} className="p-0"> {expandCell.column.columnDef.meta?.expandedContent?.(row.original)} </TableCell> </TableRow> )} </> )}
// React.memo strips generics; cast back so call sites stay typed.const VirtualizedBodyRow = React.memo( VirtualizedBodyRowInner,) as typeof VirtualizedBodyRowInner
// ============================================================================// DataTableVirtualizedBody// ============================================================================
export interface DataTableVirtualizedBodyProps<TData> { children?: React.ReactNode estimateSize?: number overscan?: number className?: string onScroll?: (event: ScrollEvent) => void onScrolledTop?: () => void onScrolledBottom?: () => void scrollThreshold?: number /** * Fires when the last rendered virtual row is within * `prefetchThreshold` rows of the end of the dataset. Intended * as a prefetch trigger for infinite-scroll — pair it with * `fetchNextPage()` so the next page starts loading *before* the * user reaches the bottom. Called at most once per transition into * the near-end zone (not every frame) so consumers can wire it * directly without worrying about double-fires. * * Strictly better than `onScrolledBottom` for virtualized infinite * scroll because it's virtualizer-index-driven (not scroll-event- * driven), so it also catches: fast scrolls via scrollbar drag, * programmatic `scrollToIndex()` jumps, and initial renders where * the table isn't tall enough to require scrolling. */ onNearEnd?: () => void /** * How many rows from the end of the dataset to trigger * `onNearEnd`. Default `10` — fires when the user is rendering * the last 10 loaded rows. Tune higher for more aggressive * prefetching (pre-fetch earlier), lower for more conservative. */ prefetchThreshold?: number /** * Click is dispatched per-row from each row's `onClick`. Typed as * `React.MouseEvent<HTMLElement>` to match the other body * variants (`DataTableBody`, `DataTableDndBody`, * `DataTableVirtualizedDndBody`, etc.) so a single handler can be * passed through wrappers that switch between bodies. Consumers * needing the row element can `event.target.closest("tr[data-row-id]")`. */ onRowClick?: (row: TData, event: React.MouseEvent<HTMLElement>) => void /** * Return a per-row memo invalidation key. When the returned string changes * for a specific row, React.memo re-renders that row even if TanStack Table * props (selection, expansion, column layout) are unchanged. Use this for * row-level external state that cell renderers depend on — e.g. inline edit * mode, optimistic overlays, or any closure-captured state in column * definitions that changes independently of the table's own state. * * @example * // Trigger re-render on inline edit toggle (only the edited row re-renders) * getRowMemoKey={(row) => (isEditing(row.id) ? "editing" : "")} */ getRowMemoKey?: (row: TData) => string}
export function DataTableVirtualizedBody<TData>({ children, estimateSize = 34, overscan = 20, className, onScroll, onRowClick, onScrolledTop, onScrolledBottom, scrollThreshold = 50, onNearEnd, prefetchThreshold = 10, getRowMemoKey,}: DataTableVirtualizedBodyProps<TData>) { const { table, columns } = useDataTable() const { rows } = table.getRowModel()
// Hoist expand-column lookup above the virtualizer loop (was O(virtual_rows × cols) per frame). const expandColumnId = React.useMemo( () => table.getAllColumns().find(col => col.columnDef.meta?.expandedContent) ?.id, [table, columns], )
const { columnVisibility, columnOrder, columnPinning } = table.getState() // Encodes visible column ids + pinning so memoized rows re-render on layout changes. const columnLayoutSignature = React.useMemo( () => table .getVisibleLeafColumns() .map(c => { const pinned = c.getIsPinned() return pinned ? `${c.id}:${pinned}` : c.id }) .join(","), [table, columnVisibility, columnOrder, columnPinning], )
const [scrollElement, setScrollElement] = React.useState<HTMLDivElement | null>(null) const tbodyRef = React.useRef<HTMLTableSectionElement | null>(null)
const parentRef = React.useCallback( (node: HTMLTableSectionElement | null) => { tbodyRef.current = node if (node !== null) { const container = node.closest( '[data-slot="table-container"]', ) as HTMLDivElement | null setScrollElement(container) } }, [], )
// Lock column widths post auto-size: measure each <th>, enforce explicit // `size` as a minimum, scale to container, then switch to `table-layout: fixed`. // Without this, auto-layout shifts headers during virtual scroll as visible // content changes. useLayoutEffect avoids the auto→fixed flash. const columnLockRef = React.useRef(false) const lockedColumnCountRef = React.useRef(0) // Mirrored as state so row-render can gate `measureElement` on it. Attaching // the ResizeObserver before the lock would read inflated wrapped-text heights // and bake huge spacer gaps into the virtualizer. const [columnsLocked, setColumnsLocked] = React.useState(false)
React.useLayoutEffect(() => { const leafColumns = table.getVisibleLeafColumns() const currentColCount = leafColumns.length
// Reset lock when column visibility changes (toggle columns on/off) if ( columnLockRef.current && lockedColumnCountRef.current !== currentColCount ) { columnLockRef.current = false setColumnsLocked(false) const tbody = tbodyRef.current const tableEl = tbody?.closest<HTMLTableElement>('[data-slot="table"]') if (tableEl) { tableEl.style.tableLayout = "" tableEl.style.minWidth = "" tableEl .querySelectorAll<HTMLTableCellElement>( "thead [data-slot='table-head']", ) .forEach(th => { th.style.width = "" }) } // Bail so React commits the unlocked render before re-locking — // batching both updates would let `ResizeObserver` capture // inflated heights during the auto-layout pass. return }
// Fast path — already locked, skip all DOM queries if (columnLockRef.current) return
const tbody = tbodyRef.current if (!tbody || rows.length === 0 || !scrollElement) return
// Verify data cells are rendered — the virtualizer may need an // extra render cycle after observing the scroll container before // it produces virtual items. Without this check we'd measure // header-only widths which are far too narrow. if (!tbody.querySelector("[data-slot='table-cell']")) return
const tableEl = tbody.closest<HTMLTableElement>('[data-slot="table"]') if (!tableEl) return
const ths = tableEl.querySelectorAll<HTMLTableCellElement>( "thead [data-slot='table-head']", ) if (ths.length === 0) return
// Force the table to be at least as wide as the sum of all column // sizes before measuring. Without this, `w-full` on <TableComponent> // constrains the table to the scroll container's width and the // auto-layout distributes compressed widths that then get locked in. const totalDesiredWidth = leafColumns.reduce( (sum, col) => sum + col.getSize(), 0, ) tableEl.style.minWidth = `${totalDesiredWidth}px`
// Measure auto-computed widths. Auto-layout naturally gives content-heavy // columns more space than narrow columns — keep that behavior but enforce // each column's explicit `size` as a minimum so a column with `size: 180` // is never locked narrower than 180px even when its visible content is short. const rawWidths: number[] = [] ths.forEach(th => rawWidths.push(th.getBoundingClientRect().width))
const effectiveWidths = rawWidths.map((raw, i) => { const explicitSize = leafColumns[i]?.columnDef.size return explicitSize !== undefined ? Math.max(raw, explicitSize) : raw })
// Scale proportionally so widths sum to exactly the container width — // eliminates the subpixel rounding gap that `table-layout: fixed` + // raw pixel widths leaves. const effectiveSum = effectiveWidths.reduce((a, b) => a + b, 0) const containerWidth = tableEl.getBoundingClientRect().width const scale = containerWidth > 0 && effectiveSum > 0 ? containerWidth / effectiveSum : 1
ths.forEach((th, i) => { th.style.width = `${(effectiveWidths[i] ?? 0) * scale}px` }) tableEl.style.tableLayout = "fixed"
columnLockRef.current = true lockedColumnCountRef.current = currentColCount setColumnsLocked(true) })
const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => scrollElement, estimateSize: () => estimateSize, overscan, enabled: !!scrollElement, measureElement, })
// Passive scroll listener — shared `createScrollHandler` across all body variants. React.useEffect(() => { if (!scrollElement) return if (!onScroll && !onScrolledTop && !onScrolledBottom) return
const handleScroll = createScrollHandler({ onScroll, onScrolledTop, onScrolledBottom, scrollThreshold, }) scrollElement.addEventListener("scroll", handleScroll, { passive: true }) return () => scrollElement.removeEventListener("scroll", handleScroll) }, [ scrollElement, onScroll, onScrolledTop, onScrolledBottom, scrollThreshold, ])
const virtualItems = rowVirtualizer.getVirtualItems() const hasVirtualItems = virtualItems.length > 0
// Calculate spacer heights for virtual scrolling const topSpacerHeight = hasVirtualItems ? virtualItems[0].start : 0 const lastItem = hasVirtualItems ? virtualItems[virtualItems.length - 1] : null const bottomSpacerHeight = lastItem ? rowVirtualizer.getTotalSize() - lastItem.end : 0
// Virtualizer-index-driven prefetch: fires once on false→true transition, // catching fast scrolls, scrollbar drags, and short initial pages that // scroll-event-based triggers miss. const isNearEnd = onNearEnd !== undefined && rows.length > 0 && lastItem !== null && lastItem.index >= rows.length - 1 - prefetchThreshold
const wasNearEndRef = React.useRef(false) React.useEffect(() => { if (isNearEnd && !wasNearEndRef.current) { onNearEnd?.() } wasNearEndRef.current = isNearEnd }, [isNearEnd, onNearEnd])
// One stable handler vs N inline closures — at 20 rows × 60fps that's // hundreds of allocations/sec saved during scroll. const handleRowClick = React.useCallback( (event: React.MouseEvent<HTMLElement>) => { if (!onRowClick) return if (isInteractiveClickTarget(event.target as HTMLElement)) return
// Resolve via stable `row.id` rather than a positional index — // sort/filter/reorder leave indices unstable but ids are // canonical. `table.getRow` is a Map lookup internally so this // stays O(1). const rowId = event.currentTarget.dataset.rowId if (rowId == null) return const row = table.getRow(rowId) if (!row) return onRowClick(row.original as TData, event) }, [onRowClick, table], )
// Stable wrapper around the virtualizer's measure callback. The // virtualizer recreates its `measureElement` on every render, which // would invalidate `React.memo` on the row component if passed // directly. The latest-ref pattern keeps the prop reference stable // while still calling through to the current measurer. const measureElementRef = React.useRef(rowVirtualizer.measureElement) measureElementRef.current = rowVirtualizer.measureElement const stableMeasureElement = React.useCallback( (node: HTMLTableRowElement | null) => { measureElementRef.current(node) }, [], )
const isClickable = !!onRowClick const visibleColumnCount = table.getVisibleLeafColumns().length
return ( <TableBody ref={parentRef} className={cn(className)}> {/* Top spacer — colSpan keeps it within native table layout */} {topSpacerHeight > 0 && ( <tr aria-hidden> <td colSpan={visibleColumnCount} style={{ height: `${topSpacerHeight}px`, padding: 0, border: "none", }} /> </tr> )}
{/* Render visible rows */} {virtualItems.map(virtualRow => { const row = rows[virtualRow.index] if (!row) return null const isExpanded = row.getIsExpanded()
// Composite key forces a remount on expansion toggle so // `ResizeObserver` re-attaches and re-reads the combined height. // DnD bodies use a stable key (preserving `useSortable`) and // re-measure imperatively instead. return ( <VirtualizedBodyRow key={`${row.id}-${isExpanded}`} row={row as Row<TData>} virtualIndex={virtualRow.index} expandColumnId={expandColumnId} isExpanded={isExpanded} isSelected={row.getIsSelected()} isClickable={isClickable} measureRef={columnsLocked ? stableMeasureElement : undefined} onClick={handleRowClick} columnLayoutSignature={columnLayoutSignature} rowMemoKey={ getRowMemoKey ? getRowMemoKey(row.original as TData) : "" } /> ) })}
{/* Bottom spacer */} {bottomSpacerHeight > 0 && ( <tr aria-hidden> <td colSpan={visibleColumnCount} style={{ height: `${bottomSpacerHeight}px`, padding: 0, border: "none", }} /> </tr> )}
{/* Composable children — Skeleton, EmptyBody, LoadingMore, and any other data-table body states are rendered here. Each self-gates on its own visibility, so the consumer just drops them in without needing conditional JSX. */} {children} </TableBody> )}
DataTableVirtualizedBody.displayName = "DataTableVirtualizedBody"
// ============================================================================// DataTableVirtualizedEmptyBody// ============================================================================
export interface DataTableVirtualizedEmptyBodyProps { children?: React.ReactNode colSpan?: number className?: string}
/** * Empty state component specifically for virtualized tables. * Use composition pattern with DataTableEmpty* components for full customization. * * @example * <DataTableVirtualizedEmptyBody> * <DataTableEmptyIcon> * <PackageOpen className="size-12" /> * </DataTableEmptyIcon> * <DataTableEmptyMessage> * <DataTableEmptyTitle>No products found</DataTableEmptyTitle> * <DataTableEmptyDescription> * Get started by adding your first product * </DataTableEmptyDescription> * </DataTableEmptyMessage> * <DataTableEmptyFilteredMessage> * No matches found * </DataTableEmptyFilteredMessage> * <DataTableEmptyActions> * <Button onClick={handleAdd}>Add Product</Button> * </DataTableEmptyActions> * </DataTableVirtualizedEmptyBody> */export function DataTableVirtualizedEmptyBody({ children, colSpan, className,}: DataTableVirtualizedEmptyBodyProps) { const { table, columns, isLoading } = useDataTable()
// Hooks first (rules-of-hooks), then early-return below skips work when // the table has rows. const tableState = table.getState() const isFiltered = React.useMemo( () => (tableState.globalFilter && tableState.globalFilter.length > 0) || (tableState.columnFilters && tableState.columnFilters.length > 0), [tableState.globalFilter, tableState.columnFilters], )
// Early return after hooks - this prevents rendering when not needed const rowCount = table.getRowModel().rows.length if (isLoading || rowCount > 0) return null
return ( <TableRow> <TableCell colSpan={colSpan ?? columns.length} className={cn("text-center", className)} > <DataTableEmptyState isFiltered={isFiltered}> {children} </DataTableEmptyState> </TableCell> </TableRow> )}
DataTableVirtualizedEmptyBody.displayName = "DataTableVirtualizedEmptyBody"
// ============================================================================// DataTableVirtualizedSkeleton// ============================================================================
export interface DataTableVirtualizedSkeletonProps { children?: React.ReactNode /** * Number of skeleton rows to display. * @default 5 * @recommendation Set this to match your visible viewport for better UX */ rows?: number /** * Estimated row height in pixels. Should match the `estimateSize` prop on * `DataTableVirtualizedBody` so skeleton rows are the same height as real * rows and the layout doesn't shift when data arrives. * @default 34 */ estimateSize?: number className?: string cellClassName?: string skeletonClassName?: string}
export function DataTableVirtualizedSkeleton({ children, rows = 5, estimateSize = 34, className, cellClassName, skeletonClassName,}: DataTableVirtualizedSkeletonProps) { const { table, isLoading } = useDataTable()
// Show skeleton only when loading if (!isLoading) return null
// Get visible columns from table const visibleColumns = table.getVisibleLeafColumns()
// If custom children provided, show single row with custom content if (children) { return ( <TableRow> <TableCell colSpan={visibleColumns.length} className={cn("h-24 text-center", className)} > {children} </TableCell> </TableRow> ) }
// Show skeleton rows that mimic the virtualized table structure return ( <> {Array.from({ length: rows }).map((_, rowIndex) => ( <TableRow key={rowIndex} style={{ height: `${estimateSize}px` }}> {visibleColumns.map((column, colIndex) => { const size = column.columnDef.size const cellStyle = size ? { width: `${size}px` } : undefined
return ( <TableCell key={colIndex} className={cn(cellClassName)} style={cellStyle} > <Skeleton className={cn("h-4 w-full", skeletonClassName)} /> </TableCell> ) })} </TableRow> ))} </> )}
DataTableVirtualizedSkeleton.displayName = "DataTableVirtualizedSkeleton"
// ============================================================================// DataTableVirtualizedLoading// ============================================================================
export interface DataTableVirtualizedLoadingProps { children?: React.ReactNode colSpan?: number className?: string}
/** * Loading state component specifically for virtualized tables. */export function DataTableVirtualizedLoading({ children, colSpan, className,}: DataTableVirtualizedLoadingProps) { const { columns, isLoading } = useDataTable()
// Show loading only when loading if (!isLoading) return null
return ( <TableRow> <TableCell colSpan={colSpan ?? columns.length} className={className ?? "h-24 text-center"} > {children ?? ( <div className="flex items-center justify-center gap-2"> <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" /> <span className="text-sm text-muted-foreground">Loading...</span> </div> )} </TableCell> </TableRow> )}
DataTableVirtualizedLoading.displayName = "DataTableVirtualizedLoading"
// ============================================================================// DataTableVirtualizedLoadingMore// ============================================================================
export interface DataTableVirtualizedLoadingMoreProps { /** * Whether a next-page fetch is currently in flight. Typically wired * to a library state like TanStack Query's `isFetchingNextPage`, * SWR's `isValidating`, or a plain `useState` flag. When false, this * component renders nothing. */ isFetching: boolean /** * Optional custom content. Defaults to a spinner + "Loading more..." * label. Pass children to customize per-table (e.g. "Loading more * products..."). */ children?: React.ReactNode colSpan?: number className?: string}
/** * Virtualized variant of `DataTableLoadingMore`. Composable "loading * more" row for infinite-scroll virtualized tables. Renders at the end * of the body when `isFetching` is true, and nothing when false. * * Sits OUTSIDE the virtualizer's row count (it's a plain child of * `TableBody`, not a virtual row), so it does not affect `estimateSize` * math. Designed to be dropped as a child of * `DataTableVirtualizedBody` alongside `DataTableVirtualizedSkeleton` * and `DataTableVirtualizedEmptyBody`. * * @example * <DataTableVirtualizedBody * onScrolledBottom={() => { * if (hasMore && !isFetching) void loadMore() * }} * > * <DataTableVirtualizedSkeleton rows={5} /> * <DataTableVirtualizedEmptyBody>No results</DataTableVirtualizedEmptyBody> * <DataTableVirtualizedLoadingMore isFetching={isFetching}> * Loading more products... * </DataTableVirtualizedLoadingMore> * </DataTableVirtualizedBody> */export function DataTableVirtualizedLoadingMore({ isFetching, children, colSpan, className,}: DataTableVirtualizedLoadingMoreProps) { const { columns } = useDataTable()
// Self-gating — nothing to render when no fetch is in flight. if (!isFetching) return null
return ( <TableRow data-slot="datatable-loading-more-row"> <TableCell colSpan={colSpan ?? columns.length} className={cn( "py-3 text-center text-xs text-muted-foreground", className, )} > <span className="inline-flex items-center justify-center gap-2"> <span className="h-3.5 w-3.5 animate-spin rounded-full border-2 border-primary border-t-transparent" aria-hidden="true" /> <span>{children ?? "Loading more..."}</span> </span> </TableCell> </TableRow> )}
DataTableVirtualizedLoadingMore.displayName = "DataTableVirtualizedLoadingMore"Update the import paths to match your project setup.
DataTableAside:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import * as React from "react"import { XIcon } from "lucide-react"import { cn } from "@/lib/utils"
interface DataTableAsideContextValue { open: boolean onOpenChange: (open: boolean) => void side: "left" | "right"}
const DataTableAsideContext = React.createContext< DataTableAsideContextValue | undefined>(undefined)
function useDataTableAside() { const context = React.useContext(DataTableAsideContext) if (!context) { throw new Error( "DataTableAside components must be used within DataTableAside", ) } return context}
interface DataTableAsideProps { children: React.ReactNode side?: "left" | "right" open?: boolean onOpenChange?: (open: boolean) => void defaultOpen?: boolean}
function DataTableAside({ children, side = "right", open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false,}: DataTableAsideProps) { const [internalOpen, setInternalOpen] = React.useState(defaultOpen)
const open = controlledOpen ?? internalOpen const onOpenChange = controlledOnOpenChange ?? setInternalOpen
/** * PERFORMANCE: Memoize context value to prevent unnecessary consumer re-renders * * WHY: Without memoization, a new context value object is created on every render. * React Context uses Object.is() to compare values - new object = all consumers re-render. * * IMPACT: Prevents unnecessary re-renders of DataTableAsideTrigger, DataTableAsideContent, etc. * when aside state hasn't changed. * * WHAT: Only creates new context value when open, onOpenChange, or side actually change. */ const contextValue = React.useMemo<DataTableAsideContextValue>( () => ({ open, onOpenChange, side, }), [open, onOpenChange, side], )
return ( <DataTableAsideContext.Provider value={contextValue}> {children} </DataTableAsideContext.Provider> )}
DataTableAside.displayName = "DataTableAside"
interface DataTableAsideTriggerProps extends React.ComponentPropsWithoutRef<"button"> { asChild?: boolean children?: React.ReactNode}
function DataTableAsideTrigger({ className, asChild = false, children, ...props}: DataTableAsideTriggerProps) { const { open, onOpenChange } = useDataTableAside()
const handleToggle = React.useCallback(() => { onOpenChange(!open) }, [onOpenChange, open])
if (asChild && React.isValidElement(children)) { const childProps = children.props as { onClick?: (e: React.MouseEvent) => void } return React.cloneElement(children, { onClick: (e: React.MouseEvent) => { handleToggle() childProps.onClick?.(e) }, } as Partial<unknown> & React.Attributes) }
return ( <button data-slot="aside-trigger" type="button" className={className} onClick={handleToggle} {...props} > {children} </button> )}
DataTableAsideTrigger.displayName = "DataTableAsideTrigger"
interface DataTableAsideContentProps extends React.ComponentPropsWithoutRef<"aside"> { width?: string sticky?: boolean}
function DataTableAsideContent({ children, className, width = "w-1/2", sticky = false, ...props}: DataTableAsideContentProps) { const { open, side } = useDataTableAside()
if (!open) return null
const slideAnimation = side === "left" ? "slide-in-from-left" : "slide-in-from-right"
return ( <aside data-slot="aside-content" className={cn( "shrink-0 animate-in", width, slideAnimation, sticky && "sticky top-0", className, )} {...props} > {children} </aside> )}
DataTableAsideContent.displayName = "DataTableAsideContent"
function DataTableAsideHeader({ className, ...props}: React.ComponentPropsWithoutRef<"div">) { return ( <div data-slot="aside-header" className={cn("flex flex-col gap-2", className)} {...props} /> )}
DataTableAsideHeader.displayName = "DataTableAsideHeader"
function DataTableAsideTitle({ className, ...props}: React.ComponentPropsWithoutRef<"h3">) { return ( <h3 data-slot="aside-title" className={cn("text-lg leading-none font-semibold", className)} {...props} /> )}
DataTableAsideTitle.displayName = "DataTableAsideTitle"
function DataTableAsideDescription({ className, ...props}: React.ComponentPropsWithoutRef<"p">) { return ( <p data-slot="aside-description" className={cn("text-sm text-muted-foreground", className)} {...props} /> )}
DataTableAsideDescription.displayName = "DataTableAsideDescription"
interface DataTableAsideCloseProps extends React.ComponentPropsWithoutRef<"button"> { showIcon?: boolean}
function DataTableAsideClose({ className, showIcon = true, children, ...props}: DataTableAsideCloseProps) { const { onOpenChange } = useDataTableAside()
const handleClose = React.useCallback(() => { onOpenChange(false) }, [onOpenChange])
return ( <button data-slot="aside-close" type="button" className={cn( "rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none", className, )} onClick={handleClose} {...props} > {showIcon && <XIcon className="size-4" />} {children} {showIcon && !children && <span className="sr-only">Close</span>} </button> )}
DataTableAsideClose.displayName = "DataTableAsideClose"
export { DataTableAside, DataTableAsideTrigger, DataTableAsideContent, DataTableAsideHeader, DataTableAsideTitle, DataTableAsideDescription, DataTableAsideClose,}Update the import paths to match your project setup.
DataTableSelectionBar:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Copy and paste the following code into your project.
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
import * as React from "react"import { Button } from "@/components/ui/button"import { Badge } from "@/components/ui/badge"
interface DataTableSelectionBarProps { selectedCount: number onClear?: () => void children?: React.ReactNode className?: string}
/** * Reusable selection bar. Memoized so table-state changes don't re-render * unchanged selection state. Pass children to add custom action buttons. */export const DataTableSelectionBar = React.memo(function DataTableSelectionBar({ selectedCount, onClear, children, className,}: DataTableSelectionBarProps) { if (selectedCount === 0) return null
return ( <div className={className}> <div className="flex items-center justify-between rounded-lg border border-border bg-muted/50 px-4 py-3"> <div className="flex items-center gap-2"> <Badge variant="secondary">{selectedCount}</Badge> <span className="text-sm text-muted-foreground"> {selectedCount === 1 ? "row selected" : "rows selected"} </span> {onClear && ( <Button variant="ghost" size="sm" onClick={onClear} className="h-7 px-2 text-xs" > Clear </Button> )} </div> {children && <div className="flex items-center gap-2">{children}</div>} </div> </div> )})Update the import paths to match your project setup.
Manual Installation
Section titled “Manual Installation”Prefer to copy and paste all components manually? Check out our Manual Installation Guide page where you can copy all the component files at once.
Your First Table
Section titled “Your First Table”Let’s build your first table. We’ll start with a simple table and progressively add features.
-
Start by defining your data
The following data represents a list of users with their names and emails.
components/example-table.tsx type User = {id: stringname: stringemail: stringstatus: "active" | "inactive"}const data: User[] = [] -
Define your columns
Columns define how your data is displayed in the table.
components/example-table.tsx import type { DataTableColumnDef } from "@/components/niko-table/types"const columns: DataTableColumnDef<User>[] = [{accessorKey: "name",header: "Name",},{accessorKey: "email",header: "Email",},{accessorKey: "status",header: "Status",cell: ({ row }) => (<spanclassName={row.original.status === "active"? "text-green-600": "text-gray-400"}>{row.original.status}</span>),},] -
Build your table
You can now build your table using DataTable components.
components/example-table.tsx import { DataTableRoot } from "@/components/niko-table/core/data-table-root"import { DataTable } from "@/components/niko-table/core/data-table"import {DataTableHeader,DataTableBody,} from "@/components/niko-table/core/data-table-structure"export function ExampleTable() {return (<DataTableRoot data={data} columns={columns}><DataTable><DataTableHeader /><DataTableBody /></DataTable></DataTableRoot>)}
Dependencies by Example
Section titled “Dependencies by Example”Different examples require different dependencies. Here’s what you need for each:
Simple Table
Section titled “Simple Table”@tanstack/react-table- Shadcn:
table
Basic Table
Section titled “Basic Table”@tanstack/react-table- Shadcn:
table,button,dropdown-menu
Search Table
Section titled “Search Table”@tanstack/react-table- Shadcn:
table,button,input,dropdown-menu
Faceted Filter Table
Section titled “Faceted Filter Table”@tanstack/react-table- Shadcn:
table,button,input,dropdown-menu,popover,command,checkbox,select
Row Selection Table
Section titled “Row Selection Table”@tanstack/react-table- Shadcn:
table,button,checkbox,dropdown-menu
Row Expansion Table
Section titled “Row Expansion Table”@tanstack/react-table- Shadcn:
table,button,input,dropdown-menu
Tree Table
Section titled “Tree Table”@tanstack/react-table- Shadcn:
table,button,input,dropdown-menu
Virtualization Table
Section titled “Virtualization Table”@tanstack/react-table@tanstack/react-virtual- Shadcn:
table,button,input,dropdown-menu,scroll-area
Advanced Table (All Features)
Section titled “Advanced Table (All Features)”@tanstack/react-table- Shadcn:
table,button,input,dropdown-menu,popover,command,checkbox,select,scroll-area,separator,skeleton,tooltip - Inline Filters: Included in DataTable components (no additional dependencies)
- Sortable Rows (optional): DiceUI Sortable -
@dnd-kit/core,@dnd-kit/modifiers,@dnd-kit/sortable,@dnd-kit/utilities,@radix-ui/react-slot
Advanced Table with URL State (nuqs)
Section titled “Advanced Table with URL State (nuqs)”@tanstack/react-tablenuqs(nuqs.dev) - Type-safe search params state manager for URL state management- Shadcn: All components from Advanced Table
- Inline Filters: Included in DataTable components (no additional dependencies)
- Sortable Rows (optional): DiceUI Sortable -
@dnd-kit/core,@dnd-kit/modifiers,@dnd-kit/sortable,@dnd-kit/utilities,@radix-ui/react-slot
Optional Dependencies
Section titled “Optional Dependencies”Some advanced features may require additional dependencies:
URL State Management (nuqs)
Section titled “URL State Management (nuqs)”For URL state persistence in tables, install nuqs:
npm install nuqsnuqs provides type-safe search params state management, perfect for syncing table filters, pagination, and sorting with the URL.
Sortable/Draggable Rows
Section titled “Sortable/Draggable Rows”For drag-and-drop row reordering, follow the DiceUI Sortable installation guide.
Quick Install All Dependencies
Section titled “Quick Install All Dependencies”If you want to install everything upfront — core, all add-ons, and optional npm dependencies:
For optional dependencies used by specific examples (not required for all projects):
Project Structure
Section titled “Project Structure”After installation, your project structure should look like:
src/├── components/│ ├── ui/ # Shadcn UI components│ └── niko-table/ # DataTable components│ ├── core/│ ├── types/│ ├── hooks/│ ├── components/│ ├── filters/│ ├── config/│ └── lib/└── lib/ └── utils.tsVerify Installation
Section titled “Verify Installation”Create a simple test to verify everything is working:
import { DataTableRoot } from "@/components/niko-table/core/data-table-root"import { DataTable } from "@/components/niko-table/core/data-table"import { DataTableHeader, DataTableBody,} from "@/components/niko-table/core/data-table-structure"
const columns = [ { accessorKey: "name", header: "Name" }, { accessorKey: "email", header: "Email" },]
const data = []
export function TestTable() { return ( <DataTableRoot data={data} columns={columns}> <DataTable> <DataTableHeader /> <DataTableBody /> </DataTable> </DataTableRoot> )}Next Steps
Section titled “Next Steps”Now that you have the DataTable installed, check out the examples:
- Simple Table - Basic table with no pagination
- Basic Table - Add pagination and sorting
- Search Table - Add search functionality
- Faceted Filter Table - Add advanced filtering
- Advanced Table - All features combined