Installation
Install Niko Table with the shadcn CLI—add @niko-table/data-table and optional registry components to your React + TanStack Table 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) — both the Radix (
new-york) and Base UI (base-nova) generations are supported - TailwindCSS configured
- TypeScript (recommended)
Works with both shadcn generations. Niko Table installs cleanly whether your
components.jsonstyle is the classic Radix generation ("new-york") or the newer Base UI generation ("base-nova", the currentshadcn initdefault). The source is written to typecheck against both: the shadcn CLI adapts Radix idioms (asChild→render) during install, and Niko Table’s callbacks and props are dual-generation safe. If you hit a type error after install, make sure you’re on a recentshadcnCLI and re-add the block with--overwrite.
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"
import { useColumnResizeInfo, useDataTable } from "./data-table-context"
/** * 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> *//** * A single vertical guide line that follows the cursor while a column is being * resized. Resizing runs in `onEnd` mode, so the columns themselves don't move * until the drag ends (that's what keeps heavy tables smooth); this line gives * the live "where the edge will land" feedback in the meantime. * * It subscribes to the dedicated resize-info context, so it — and nothing else * in the table — re-renders per pointer move. Positioned in the scroll * container's content space so it tracks correctly through horizontal scroll. */function ColumnResizePreviewLine() { const { resizingColumnId, deltaOffset } = useColumnResizeInfo() const { scrollContainer } = useDataTable()
if (!resizingColumnId || !scrollContainer) return null
const escapedId = typeof CSS !== "undefined" && CSS.escape ? CSS.escape(resizingColumnId) : resizingColumnId const cell = scrollContainer.querySelector<HTMLElement>( `thead [data-col-id="${escapedId}"]`, ) if (!cell) return null
const containerRect = scrollContainer.getBoundingClientRect() const cellRect = cell.getBoundingClientRect() // Right edge of the dragged column in the container's scrollable content // space, shifted by the live drag delta. const left = cellRect.right - containerRect.left + scrollContainer.scrollLeft + deltaOffset
return ( <div aria-hidden data-slot="column-resize-preview" className="bg-primary pointer-events-none absolute top-0 z-40 w-px" style={{ left, height: scrollContainer.scrollHeight }} /> )}
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
// Register the scroll container so opt-in features (e.g. // `<DataTableColumnAutoFit />`) can measure/observe the viewport width. const { registerScrollContainer } = useDataTable()
return ( <div ref={registerScrollContainer} 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-thumb-border/40 scrollbar-track-transparent", "hover:scrollbar-thumb-border", parsed.safeClassName, )} style={{ height: finalHeight, maxHeight: finalMaxHeight, }} > <TableComponent>{children}</TableComponent> <ColumnResizePreviewLine /> </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 { getCoreRowModel, getExpandedRowModel, getFacetedMinMaxValues, getFacetedRowModel, getFacetedUniqueValues, getFilteredRowModel, getPaginationRowModel, getSortedRowModel, useReactTable, type ColumnFiltersState, type ColumnOrderState, type ColumnSizingState, type ExpandedState, type FilterFn, type FilterFnOption, type PaginationState, type RowSelectionState, type SortingState, type Table, type TableOptions, type Updater, type VisibilityState,} from "@tanstack/react-table"import { TooltipProvider } from "@/components/ui/tooltip"import { cn } from "@/lib/utils"import React from "react"import { detectFeaturesFromChildren } from "../config/feature-detection"import { DEFAULT_MIN_COLUMN_SIZE, FILTER_VARIANTS, SYSTEM_COLUMN_IDS, SYSTEM_COLUMN_ID_LIST,} from "../lib/constants"import { dateRangeFilter, extendedFilter, globalFilter as globalFilterFn, numberRangeFilter,} from "../lib/filter-functions"import { type DataTableColumnDef, type GlobalFilter } from "../types"import { DataTableProvider } from "./data-table-context"
/** * Delay (ms) before a tooltip inside a data table opens. Deliberately long so * header help/sort tooltips don't pop while the assigner is scanning or * clicking through sorts — they only appear on a considered hover. Scopes to * the table via a nested `TooltipProvider`, so action-button tooltips outside * the table keep their own (faster) provider delay. */const TABLE_TOOLTIP_DELAY_MS = 1000
/** * Dual-generation tooltip delay: Radix's provider reads `delayDuration`, * Base UI's reads `delay`, and each ignores the other prop. Spread as an * object (not literal attributes) so it typechecks against both shadcn * generations and the CLI's Base UI codemod leaves it alone. */const tooltipProviderDelay = { delayDuration: TABLE_TOOLTIP_DELAY_MS, delay: TABLE_TOOLTIP_DELAY_MS,}
export interface DataTableConfig { // Feature toggles enablePagination?: boolean enableFilters?: boolean enableSorting?: boolean enableRowSelection?: boolean enableMultiSort?: boolean enableGrouping?: boolean enableExpanding?: boolean /** * Enable drag-to-resize column widths (opt-in; off by default so existing * tables are unaffected). When on, columns render at `column.getSize()` and a * resize handle appears on each resizable header's right edge. Drop * `<DataTableColumnResize />` inside the root to enable via feature detection. * Double-click a grip to autosize; columns opt out with `enableResizing: false`. */ enableColumnResizing?: 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, onColumnSizingChange, 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, enableColumnResizing: config?.enableColumnResizing, 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, config?.enableColumnResizing, 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 ?? false, enableMultiSort: finalConfig.enableMultiSort ?? detectedFeatures.enableMultiSort ?? true, enableGrouping: finalConfig.enableGrouping ?? detectedFeatures.enableGrouping ?? true, enableExpanding: finalConfig.enableExpanding ?? detectedFeatures.enableExpanding ?? false, enableColumnResizing: finalConfig.enableColumnResizing ?? detectedFeatures.enableColumnResizing ?? 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 [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>( restInitialState?.columnSizing ?? {}, ) 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) } }, [], )
/** * 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<{ left?: string[]; right?: string[] }>) => { 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 handleColumnSizingChange = React.useCallback( (u: Updater<ColumnSizingState>) => { if (isMountedRef.current) setColumnSizing(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) }, [], )
// 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])
/** * 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>>>( () => ({ // Follow table-level sorting detection — don't opt every column into // sortable chrome when sorting is off. enableSorting: detectFeatures.enableSorting ?? false, 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, // Align mouse-drag floor with the resize handle's keyboard/autosize // clamp (TanStack's built-in default is 20 — too tight for padded cells). ...(detectFeatures.enableColumnResizing ? { minSize: DEFAULT_MIN_COLUMN_SIZE } : {}), }), [detectFeatures.enableColumnResizing, detectFeatures.enableSorting], )
// 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 controlledColumnSizing = restState?.columnSizing ?? columnSizing 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, columnSizing: controlledColumnSizing, rowSelection: controlledRowSelection, columnFilters: controlledColumnFilters, globalFilter: controlledGlobalFilter, expanded: controlledExpanded, pagination: controlledPagination, }, enableColumnResizing: detectFeatures.enableColumnResizing, // `onEnd`, not `onChange`: apply the new width once, on pointer release. // In `onChange` every mousemove writes `columnSizing`, which invalidates // the memoized header + every visible (avatar/badge-heavy) body row ~60x // a second — the source of resize lag. `onEnd` keeps widths stable during // the drag; `<ColumnResizePreviewLine>` shows a live guide line instead. columnResizeMode: "onEnd", onColumnSizingChange: onColumnSizingChange ?? handleColumnSizingChange, 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, onColumnSizingChange, handleColumnSizingChange, onExpandedChange, handleExpandedChange, onPaginationChange, handlePaginationChange, getRowId, // Use controlled state values - these update when either external or local state changes controlledSorting, controlledColumnVisibility, controlledRowSelection, controlledColumnFilters, controlledGlobalFilter, controlledColumnOrder, controlledColumnSizing, 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} > <TooltipProvider {...tooltipProviderDelay}> <div className={cn("w-full min-w-0 space-y-4", className)}> {children} </div> </TooltipProvider> </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} > <TooltipProvider {...tooltipProviderDelay}> <div className={cn("w-full min-w-0 space-y-4", className)}> {children} </div> </TooltipProvider> </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, useReducer,} from "react"import { useGeneratedOptions } from "../hooks/use-generated-options"import { useHeaderMinWidths } from "../lib/use-header-min-widths"import type { DataTableColumnDef, DataTableInstance, Option } from "../types"
export type DataTableContextState = { isLoading: boolean}
/** Alignment for `scrollRowIntoView`, mirroring TanStack Virtual's `align`. */export type ScrollAlign = "auto" | "start" | "center" | "end"
/** Scroll a row into view by its position in the current row model. */export type ScrollRowIntoView = ( index: number, opts?: { align?: ScrollAlign },) => void
/** * The active-selection SPAN for a grid-style cross-highlight — EVERY column * and row in the current selection lights up (header + gutter), not just the * focused cell. Grid-agnostic: the editable grid sets it from its selection * rectangle. `null` disables the highlight (the default for read-only tables). * * Columns are addressed by id (there are few and all render, so a Set is * cheap); rows by an inclusive row-model index range (rows are virtualized, so * a Set of ids would be unbounded for a large selection — the body tests each * visible row's `index` against the range in O(1)). */export interface ActiveSelection { /** Ids of columns whose header should highlight. */ columnIds: ReadonlySet<string> /** Inclusive row-model index range whose rows/gutter should highlight. */ rowRange: { min: number; max: number }}export type ActiveCell = ActiveSelection | null
export interface FlashRowsOptions { /** How long the highlight lasts, ms. Default 1400. */ durationMs?: number /** Scroll the first flashed row into view first. Default true. */ scrollIntoView?: boolean}
/** * Briefly highlight rows (by id) so users see what just changed — e.g. after a * mutation ("which row did adding a member update?"). Scrolls the row into view * (reusing the virtualizer bridge) then plays a soft fade pulse. A row flash * lights every cell in the row (works even under pinned columns). */export type FlashRows = (ids: string[], opts?: FlashRowsOptions) => void
/** A single cell target for `flashCells`. */export interface FlashCellRef { rowId: string columnId: string}
/** * Briefly highlight individual cells (cell-level, for value-level changes — * e.g. an inline edit or paste). Same fade pulse as `flashRows`, scoped to cells. */export type FlashCells = ( cells: FlashCellRef[], opts?: FlashRowsOptions,) => void
/** Internal key for a flashing cell (row id + column id). */export function flashCellKey(rowId: string, columnId: string): string { return `${rowId}:${columnId}`}
type DataTableContextProps<TData> = DataTableContextState & { table: DataTableInstance<TData> columns: DataTableColumnDef<TData>[] generatedOptionsMap: Record<string, Option[]> setIsLoading: (isLoading: boolean) => void /** Ids of rows currently flashing (used by the body to animate them). */ flashingRowIds: ReadonlySet<string> /** Keys (`flashCellKey`) of cells currently flashing. */ flashingCellKeys: ReadonlySet<string> /** Flash rows by id — scroll into view + soft fade pulse. */ flashRows: FlashRows /** Flash individual cells (value-level changes). */ flashCells: FlashCells /** * Scroll a row into view. Works on virtualized bodies (via the virtualizer, * which registers itself) and plain bodies (DOM `scrollIntoView` fallback). * A stable, virtualizer-agnostic handle so consumers never touch the * virtualizer directly — the registry owner can change virtualization * internals without breaking consumers. */ scrollRowIntoView: ScrollRowIntoView /** * Internal: the virtualized body registers its scroll function here on mount * and clears it (null) on unmount. Non-virtualized bodies never register, so * `scrollRowIntoView` falls back to a DOM query. */ registerRowScroller: (fn: ScrollRowIntoView | null) => void /** * The table's scroll container (`<DataTable>`'s `[data-slot="table-container"]` * viewport). `<DataTable>` registers it on mount; opt-in features like * `<DataTableColumnAutoFit />` read it to measure/observe available width. * `null` until the container mounts (or when no `<DataTable>` is used). */ scrollContainer: HTMLElement | null /** Internal: `<DataTable>` registers its scroll container element here. */ registerScrollContainer: (el: HTMLElement | null) => void /** * Header-fit floors: `columnId -> minimum width` so an un-resized column is * never rendered narrower than its own header label (measured, never written * to `columnSizing`). Empty when resizing is off. Read by the header, body, * and the min-width lock so they agree on widths. */ headerMinWidths: ReadonlyMap<string, number> /** * Drive the resize preview line for a flex-column drag (which resizes outside * TanStack). Pass `{ resizingColumnId, deltaOffset }` while dragging and * `null` on release. Normal (TanStack) resizes ignore this. */ setColumnResizePreview: (info: ColumnResizeInfo | null) => void /** * Toggle a row's selection with standard Shift-range support: a plain * click toggles one row and sets the anchor; a Shift+click selects every row * between the anchor and this one (in current display order). Wire a select * checkbox to this instead of `row.toggleSelected()` to get range selection. */ toggleRowSelection: (rowId: string, shiftKey: boolean) => void}
// ---------------------------------------------------------------------------// Active-cell cross-highlight — its OWN context pair, deliberately outside the// main context value. The active cell changes on every keystroke in an// editable grid; if it lived in the main value, every `useDataTable` consumer// (toolbar, filters, header, body, empty states) would re-render per arrow// key. Split out, only the header + body — the two subscribers — re-render,// and read-only tables (where it stays `null`) never re-render at all.// ---------------------------------------------------------------------------
const ActiveCellValueContext = createContext<ActiveCell>(null)const ActiveCellSetterContext = createContext< ((cell: ActiveCell) => void) | null>(null)
/** * The active-selection span for the grid-style cross-highlight (`null` when * inactive). In a header, `activeCell.columnIds.has(column.id)` lights the * active columns; in a body/gutter, the row's **display** index (position in * `getRowModel().rows` / the virtualizer index) within `activeCell.rowRange` * lights the active rows — not TanStack's source-data `row.index`. */export function useDataTableActiveCell(): ActiveCell { return useContext(ActiveCellValueContext)}
/** * Set (or clear, with `null`) the active-cell cross-highlight. The setter is * stable, so subscribing to it never causes re-renders. Used by the grid's * `<DataGridCrossHighlight>` opt-in. */export function useDataTableActiveCellSetter(): (cell: ActiveCell) => void { const setter = useContext(ActiveCellSetterContext) if (setter === null) { throw new Error( "useDataTableActiveCellSetter must be used within DataTableRoot", ) } return setter}
// ---------------------------------------------------------------------------// Column-resize preview context// ---------------------------------------------------------------------------// Resizing runs in `onEnd` mode: columns don't change width until the drag// ends, so the memoized header/body never re-render mid-drag — that's what// keeps resizing smooth on heavy (avatar/badge) tables. To still show a live// guide line that follows the cursor, the provider publishes the in-flight// resize offset here. Only `<ColumnResizePreviewLine>` subscribes, so nothing// else in the table re-renders per pointer move.// ---------------------------------------------------------------------------
export interface ColumnResizeInfo { /** Id of the column being dragged, or `null` when no resize is active. */ resizingColumnId: string | null /** Pixel delta from the drag start (add to the column's right edge). */ deltaOffset: number}
const ColumnResizeInfoContext = createContext<ColumnResizeInfo>({ resizingColumnId: null, deltaOffset: 0,})
/** * Live column-resize offset for the preview guide line. Returns the idle value * (`{ resizingColumnId: null, deltaOffset: 0 }`) when no drag is in flight (or * outside a provider), so it's safe to call unconditionally. */export function useColumnResizeInfo(): ColumnResizeInfo { return useContext(ColumnResizeInfoContext)}
// 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}
/** * A set of currently-"flashing" string keys with per-key auto-expiry timers. * `flash(keys, durationMs)` adds them and removes each after its duration. * Shared by row flash (keys = row ids) and cell flash (keys = `flashCellKey`). */function useFlashSet(): [ ReadonlySet<string>, (keys: string[], durationMs: number) => void,] { const [set, setSet] = React.useState<Set<string>>(() => new Set()) const timers = React.useRef<Map<string, ReturnType<typeof setTimeout>>>( new Map(), ) React.useEffect(() => { const t = timers.current return () => { t.forEach(timer => clearTimeout(timer)) t.clear() } }, []) const flash = useCallback((keys: string[], durationMs: number) => { if (keys.length === 0) return setSet(prev => { const next = new Set(prev) for (const k of keys) next.add(k) return next }) for (const k of keys) { const existing = timers.current.get(k) if (existing) clearTimeout(existing) timers.current.set( k, setTimeout(() => { setSet(prev => { if (!prev.has(k)) return prev const next = new Set(prev) next.delete(k) return next }) timers.current.delete(k) }, durationMs), ) } }, []) return [set, flash]}
export function DataTableProvider<TData>({ children, table, columns, isLoading: externalIsLoading,}: DataTableProviderProps<TData>) { const [state, dispatch] = useReducer( dataTableReducer, deriveInitialState(externalIsLoading), )
const setIsLoading = useCallback((value: boolean) => { dispatch({ type: DataTableActions.SET_IS_LOADING, value, }) }, [])
// Narrow, virtualizer-agnostic scroll bridge. The virtualized body registers // its `scrollToIndex` here; `scrollRowIntoView` calls through it, or falls // back to a DOM query for non-virtualized bodies. Both callbacks are stable. const rowScrollerRef = React.useRef<ScrollRowIntoView | null>(null) const registerRowScroller = useCallback((fn: ScrollRowIntoView | null) => { rowScrollerRef.current = fn }, [])
// The scroll container is state (not a ref) so opt-in consumers like // `<DataTableColumnAutoFit />` re-run once it mounts. Set once on mount and // cleared on unmount by `<DataTable>`'s ref callback. const [scrollContainer, setScrollContainer] = React.useState<HTMLElement | null>(null) const registerScrollContainer = useCallback( (el: HTMLElement | null) => setScrollContainer(el), [], ) const scrollRowIntoView = useCallback<ScrollRowIntoView>((index, opts) => { if (rowScrollerRef.current) { rowScrollerRef.current(index, opts) return } if (typeof document !== "undefined") { // Virtualized rows carry `data-index`; plain body rows carry // `data-row-index` — match either so the DOM fallback works for both. // Map the virtualizer-style `align` onto scrollIntoView's `block`. const block: ScrollLogicalPosition = opts?.align === undefined || opts.align === "auto" ? "nearest" : opts.align document .querySelector(`[data-index="${index}"], [data-row-index="${index}"]`) ?.scrollIntoView({ block }) } }, [])
// Row / cell flash — highlight-what-changed. Two identity sets (rows by id, // cells by `flashCellKey`) with per-key timers so overlapping flashes each get // their full duration; the body plays a fade pulse on matching cells. const [flashingRowIds, flashRowKeys] = useFlashSet() const [flashingCellKeys, flashCellKeys] = useFlashSet()
// Active-cell cross-highlight (grid-style) — provided through its own // context pair (see above) so per-keystroke updates in an editable grid // don't churn the main context value. `setActiveCell` is stable. const [activeCell, setActiveCell] = React.useState<ActiveCell>(null)
// Anchor for Shift-range row selection. Ref, not state — it's // read/written only inside the click handler, never rendered. const rowSelectionAnchorRef = React.useRef<string | null>(null) const toggleRowSelection = useCallback( (rowId: string, shiftKey: boolean) => { const model = table.getRowModel() const row = model.rowsById[rowId] if (!row) return const anchorId = rowSelectionAnchorRef.current if (shiftKey && anchorId != null && model.rowsById[anchorId]) { // Select every row between the anchor and this one in DISPLAY order. // Use positions in `model.rows` — TanStack's `row.index` is the // source-data index and diverges after sort/filter. const a = model.rows.findIndex(r => r.id === anchorId) const b = model.rows.findIndex(r => r.id === rowId) if (a === -1 || b === -1) return const next = { ...table.getState().rowSelection } for (let i = Math.min(a, b); i <= Math.max(a, b); i++) { const r = model.rows[i] if (r?.getCanSelect()) next[r.id] = true } table.setRowSelection(next) return } row.toggleSelected() rowSelectionAnchorRef.current = rowId }, [table], )
const scrollFirstIdIntoView = useCallback( (ids: string[]) => { const model = table.getRowModel() for (const id of ids) { // Display index — `scrollRowIntoView` / the virtualizer speak display // space, not TanStack's source-data `row.index`. const idx = model.rows.findIndex(r => r.id === id) if (idx !== -1) { scrollRowIntoView(idx, { align: "center" }) return } } }, [table, scrollRowIntoView], )
const flashRows = useCallback<FlashRows>( (ids, opts) => { if (ids.length === 0) return if (opts?.scrollIntoView ?? true) scrollFirstIdIntoView(ids) flashRowKeys(ids, opts?.durationMs ?? 1400) }, [flashRowKeys, scrollFirstIdIntoView], )
const flashCells = useCallback<FlashCells>( (cells, opts) => { if (cells.length === 0) return if (opts?.scrollIntoView ?? true) scrollFirstIdIntoView(cells.map(c => c.rowId)) flashCellKeys( cells.map(c => flashCellKey(c.rowId, c.columnId)), opts?.durationMs ?? 1400, ) }, [flashCellKeys, scrollFirstIdIntoView], )
/** * Derive `isLoading` at READ time, not via a sync useEffect. * * WHY: The previous pattern — `useEffect(() => setIsLoading(externalIsLoading))` * — is a "copy prop into state" anti-pattern. Under React 19 + Next.js 16 * (Turbopack), it can schedule a reducer dispatch that lands on an unmounted * component instance during HMR or Strict Mode double-mount, producing: * * "Can't perform a React state update on a component that hasn't mounted * yet. This indicates that you have a side-effect in your render function * that asynchronously tries to update the component." * * FIX: When `externalIsLoading` is provided it wins (matches the original * effect's intent); otherwise fall back to the reducer-managed state. * No cross-commit dispatch, no race, no unmount-target warning. * * BEHAVIOR NOTE: If a caller both passes `isLoading` as a prop AND calls * `setIsLoading()` from inside the tree, the prop wins and the internal * call becomes a no-op. This matches the documented contract of a * controlled prop and was already the intended behavior of the effect. */ const effectiveIsLoading = externalIsLoading ?? state.isLoading
// 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 const columnSizing = tableState.columnSizing
// 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), // Column widths — so a resize re-runs the context value memo and the // memoized header/body pick up the new `column.getSize()`. columnSizingHash: JSON.stringify(columnSizing), } }, [ globalFilter, sorting, columnFilters, columnVisibility, expanded, rowSelection, pagination, columnPinning, columnOrder, columnSizing, ])
// 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)
// Header-fit: measure each header's natural width (once, off-DOM canvas) so // un-resized columns are floored at their header width and never truncate // their label on load. Only meaningful when resizing is on. const headerMinWidths = useHeaderMinWidths( table, scrollContainer, table.options.enableColumnResizing ?? false, )
// Publish the in-flight resize offset for the preview guide line (see // ColumnResizeInfoContext). Normal columns resize through TanStack, so the // offset comes from `columnSizingInfo` (mutates every pointer move; this // provider re-renders with it). Flex columns resize through a custom drag // (TanStack can't size a width-less column) that publishes its offset via // `setColumnResizePreview` — that manual value wins while a flex drag is live. const [manualResizePreview, setManualResizePreview] = React.useState<ColumnResizeInfo | null>(null) const columnSizingInfo = table.getState().columnSizingInfo const resizingColumnId = columnSizingInfo.isResizingColumn || null const resizeDeltaOffset = columnSizingInfo.deltaOffset ?? 0 const columnResizeInfo = React.useMemo<ColumnResizeInfo>( () => manualResizePreview ?? { resizingColumnId, deltaOffset: resizeDeltaOffset, }, [manualResizePreview, resizingColumnId, resizeDeltaOffset], )
// 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: effectiveIsLoading, generatedOptionsMap, setIsLoading, scrollRowIntoView, registerRowScroller, scrollContainer, registerScrollContainer, headerMinWidths, setColumnResizePreview: setManualResizePreview, flashingRowIds, flashingCellKeys, flashRows, flashCells, toggleRowSelection, }) as DataTableContextProps<TData>, // eslint-disable-next-line react-hooks/exhaustive-deps -- tableStateKey is load-bearing for reactivity (see block comment above) [ table, columns, effectiveIsLoading, generatedOptionsMap, setIsLoading, scrollRowIntoView, registerRowScroller, scrollContainer, registerScrollContainer, headerMinWidths, setManualResizePreview, flashingRowIds, flashingCellKeys, flashRows, flashCells, toggleRowSelection, tableStateKey, ], )
return ( <DataTableContext.Provider value={value}> <ActiveCellSetterContext.Provider value={setActiveCell}> <ActiveCellValueContext.Provider value={activeCell}> <ColumnResizeInfoContext.Provider value={columnResizeInfo}> <style href="niko-row-flash" precedence="default"> {ROW_FLASH_KEYFRAMES} </style> {children} </ColumnResizeInfoContext.Provider> </ActiveCellValueContext.Provider> </ActiveCellSetterContext.Provider> </DataTableContext.Provider> )}
/** * Self-contained flash animation (no dependency on app globals). Soft fade * pulse: a primary-tinted background fades to transparent once, ~1.2s. */const ROW_FLASH_KEYFRAMES = `@keyframes niko-row-flash { 0% { background-color: color-mix(in oklab, var(--primary) 24%, transparent); } 100% { background-color: transparent; }}`
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 { flexRender, type Row } from "@tanstack/react-table"import { Skeleton } from "@/components/ui/skeleton"import { TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"import { cn } from "@/lib/utils"import React from "react"import { DataTableColumnHeaderRoot } from "../components/data-table-column-header"import { DataTableEmptyState } from "../components/data-table-empty-state"import { DataTableRowContextMenu } from "../components/data-table-row-context-menu"import { useResolvedRowContextMenuRenderer } from "../components/data-table-row-context-menu-slot"import { DataTableColumnResizeHandle } from "../lib/column-resize-handle"import { createScrollHandler } from "../lib/create-scroll-handler"import { resolveColumnWidth, resolveFlexColumnIds } from "../lib/flex-columns"import { resolveRowFromClick } from "../lib/row-click"import { getCommonPinningStyles } from "../lib/styles"import { flashCellKey, useDataTable } from "./data-table-context"
// ============================================================================// 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, headerMinWidths, setColumnResizePreview } = useDataTable() const resizing = table?.options.enableColumnResizing ?? false
const headerGroups = table?.getHeaderGroups() ?? []
if (headerGroups.length === 0) { return null }
// Flex fill is on by default: resolve which column soaks up the leftover // width (explicit `meta.flex`, else the first non-pinned data column). const flexColumnIds = resolveFlexColumnIds(table) const columnSizing = table.getState().columnSizing
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 => { // A flex column has no explicit width — under `table-layout: fixed` // it soaks up the leftover row width. Not drag-resizable. const isFlex = flexColumnIds.has(header.column.id) const headerStyle = { width: resolveColumnWidth(header.column, { resizing, isFlex, columnSizing, headerMinWidths, }), ...getCommonPinningStyles(header.column, true), }
return ( <TableHead key={header.id} data-col-id={header.column.id} style={headerStyle} className={cn( header.column.getIsPinned() && "bg-background", // Anchor the absolute resize handle to the cell's right edge. resizing && "relative overflow-hidden", )} > {header.isPlaceholder ? null : ( <DataTableColumnHeaderRoot column={header.column}> {resizing && typeof header.column.columnDef.header === "string" ? ( <span className="inline-block max-w-full truncate"> {header.column.columnDef.header} </span> ) : ( flexRender( header.column.columnDef.header, header.getContext(), ) )} </DataTableColumnHeaderRoot> )} {resizing && header.column.getCanResize() && ( <DataTableColumnResizeHandle header={header} isFlex={isFlex} setResizePreview={setColumnResizePreview} /> )} </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> /** Position in the current display model (post sort/filter) — for scroll/flash. */ displayIndex: number expandColumnId: string | undefined isClickable: boolean isExpanded: boolean isSelected: boolean /** * Precomputed `columnId -> width` for every visible column (flex → undefined, * header-fit floor applied). Built once per body render so cells do an O(1) * lookup instead of recomputing width each. Stable identity, so `React.memo` * holds; the signature below invalidates it when a width changes. */ columnWidths: ReadonlyMap<string, number | string | undefined> /** Column layout signature — invalidates React.memo on visibility/order/pinning/resize 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 /** Whole row is flashing (highlight-what-changed). */ isRowFlashing: boolean /** Keys of individual flashing cells (`flashCellKey`). */ flashingCellKeys: ReadonlySet<string> /** * Right-click menu items for this row. Must be a stable callback (wrap in * `useCallback`) so `React.memo` keeps holding. Return `null` to opt a * specific row out of having a menu. */ renderRowContextMenu?: (row: unknown) => React.ReactNode}
/** Fade-pulse animation applied to a flashing cell (keyframe in the provider). */const FLASH_ANIMATION = "niko-row-flash 1.2s ease-out"
const BodyRow = React.memo(function BodyRow({ row, displayIndex, expandColumnId, isClickable, isExpanded, isSelected, columnWidths, isRowFlashing, flashingCellKeys, renderRowContextMenu,}: BodyRowProps) { const expandCell = isExpanded && expandColumnId ? row.getAllCells().find(c => c.column.id === expandColumnId) : undefined
const visibleCells = row.getVisibleCells()
const rowElement = ( <TableRow data-row-index={displayIndex} data-row-id={row.id} data-row-type={ (row.original as { rowType?: string } | undefined)?.rowType } data-parity={displayIndex % 2 === 0 ? "even" : "odd"} data-expanded={isExpanded ? "true" : undefined} data-state={isSelected ? "selected" : undefined} className={cn( isClickable && "cursor-pointer", "group data-[context-menu-open]:bg-muted/50", )} > {visibleCells.map(cell => { const flashing = isRowFlashing || flashingCellKeys.has(flashCellKey(row.id, cell.column.id)) const cellStyle = { // Precomputed by the body: flex → undefined (fills), otherwise the // header-fit-floored width (or the declared size when resizing off). width: columnWidths.get(cell.column.id), ...getCommonPinningStyles(cell.column, false), ...(flashing ? { animation: FLASH_ANIMATION } : {}), }
return ( <TableCell key={cell.id} data-flash={flashing ? "true" : undefined} data-col-id={cell.column.id} style={cellStyle} className={cn( // Match virtualized/grid: clip overflow. `truncate` adds ellipsis // on top of shadcn TableCell's `whitespace-nowrap` so shrink-via- // resize doesn't paint into neighboring columns. "truncate", cell.column.getIsPinned() && "bg-background group-hover:bg-muted/50 group-data-[context-menu-open]:bg-muted/50 group-data-[state=selected]:bg-muted", )} > {flexRender(cell.column.columnDef.cell, cell.getContext())} </TableCell> ) })} </TableRow> )
// Only stand up the context-menu shell when the consumer supplies items // for this row — a `null` return keeps the plain row (and zero portal cost). const menuItems = renderRowContextMenu?.(row.original)
return ( <> {menuItems ? ( <DataTableRowContextMenu row={row.original} trigger={rowElement}> {menuItems} </DataTableRowContextMenu> ) : ( rowElement )}
{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 /** * Attach a native right-click context menu to each row. Return the menu * items (`ContextMenuItem`, `ContextMenuSeparator`, `ContextMenuSub`, …) * for the given row, or `null` to give that row no menu. The popup shell * and portalling are handled internally. * * Wrap the callback in `useCallback` so memoized rows don't re-render. */ renderRowContextMenu?: (row: TData) => React.ReactNode}
export function DataTableBody<TData>({ children, className, onScroll, onScrolledTop, onScrolledBottom, scrollThreshold = 50, onRowClick, getRowMemoKey, renderRowContextMenu,}: DataTableBodyProps<TData>) { const { table, columns, isLoading, flashingRowIds, flashingCellKeys, registerRowScroller, headerMinWidths, } = useDataTable<TData>() const { rows } = table.getRowModel() const containerRef = React.useRef<HTMLTableSectionElement>(null)
// Register a scroll handle SCOPED to this table's own container, so // `scrollRowIntoView` resolves the row within this table (a plain body has no // virtualizer to register otherwise, and the context's document-wide fallback // could match a same-index row in a different table on the page). React.useEffect(() => { registerRowScroller((index, opts) => { const container = containerRef.current?.closest( '[data-slot="table-container"]', ) const row = container?.querySelector(`[data-row-index="${index}"]`) const block: ScrollLogicalPosition = opts?.align === undefined || opts.align === "auto" ? "nearest" : opts.align row?.scrollIntoView({ block }) }) return () => registerRowScroller(null) }, [registerRowScroller])
// 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])
// Single delegated click handler on <tbody> — matches the DnD bodies and // removes one listener per row. const handleBodyClick = React.useCallback( (event: React.MouseEvent<HTMLTableSectionElement>) => { if (!onRowClick) return const row = resolveRowFromClick(event.target as HTMLElement, table) if (!row) return onRowClick( row.original, event as unknown as React.MouseEvent<HTMLElement>, ) }, [onRowClick, table], )
// 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, // eslint-disable-next-line react-hooks/exhaustive-deps -- `columns` is an intentional invalidation key; the TanStack table instance is stable across column swaps [table, columns], )
const { columnVisibility, columnOrder, columnPinning, columnSizing } = table.getState() const resizing = table.options.enableColumnResizing ?? false
// Flex fill is on by default: which column soaks up the leftover row width. // `columnSizing` is a dep — resizing a flex column pins it and shifts the // fill to the next column. const flexColumnIds = React.useMemo( () => resolveFlexColumnIds(table), // eslint-disable-next-line react-hooks/exhaustive-deps [ table, columnVisibility, columnOrder, columnPinning, columnSizing, resizing, ], )
// Precompute every column's rendered width once (flex + header-fit rules), // so each cell is an O(1) lookup and header/body/lock all agree. const columnWidths = React.useMemo(() => { const widths = new Map<string, number | string | undefined>() for (const col of table.getVisibleLeafColumns()) { widths.set( col.id, resolveColumnWidth(col, { resizing, isFlex: flexColumnIds.has(col.id), columnSizing, headerMinWidths, }), ) } return widths // eslint-disable-next-line react-hooks/exhaustive-deps }, [ table, flexColumnIds, columnSizing, columnVisibility, columnOrder, headerMinWidths, resizing, ])
// String signature of the visible column layout. Memoized rows compare it // to invalidate on column add/remove / toggle / reorder / pin / width change // (resize OR header-fit/flex). `columns` must be included — add/remove does // not change visibility/order/pinning. For external row state (inline edits, // optimistic overlays), pass `getRowMemoKey`. const columnLayoutSignature = React.useMemo( () => table .getVisibleLeafColumns() .map(c => { const pinned = c.getIsPinned() const base = pinned ? `${c.id}:${pinned}` : c.id return resizing ? `${base}:${columnWidths.get(c.id) ?? "flex"}` : base }) .join(","), // eslint-disable-next-line react-hooks/exhaustive-deps [table, columns, columnWidths, resizing], )
const isClickable = !!onRowClick
// Composable path: the per-row menu may come from the `renderRowContextMenu` // prop OR a nested `<DataTableRowContextMenuSlot>` child (prop wins). const resolvedRenderRowContextMenu = useResolvedRowContextMenuRenderer( renderRowContextMenu, children, )
// The table's own inline sizing captured before resizing overrides it, so a // consumer that sets inline `table-layout` / `width` / `min-width` gets those // exact values back when resizing turns off — instead of them being blanked. const tableStyleSnapshotRef = React.useRef<{ tableLayout: string width: string minWidth: string } | null>(null)
// When resizing is on, lock `table-layout: fixed` and an explicit pixel width // (= sum of `getSize()`) so Tailwind `w-full` can't compress columns. Sticky // pin offsets use the same sizes — a compressed table would leave the left // pin overlaying the first data column. React.useLayoutEffect(() => { const tableEl = containerRef.current?.closest<HTMLTableElement>( '[data-slot="table"]', ) if (!tableEl) return
const restore = () => { const snap = tableStyleSnapshotRef.current if (!snap) return tableEl.style.tableLayout = snap.tableLayout tableEl.style.width = snap.width tableEl.style.minWidth = snap.minWidth tableStyleSnapshotRef.current = null }
if (!resizing) { restore() return }
// Capture the pre-override values once. Cleanup nulls the snapshot, so each // resizing pass re-captures the restored (clean) values before overriding. if (!tableStyleSnapshotRef.current) { tableStyleSnapshotRef.current = { tableLayout: tableEl.style.tableLayout, width: tableEl.style.width, minWidth: tableEl.style.minWidth, } } const leafColumns = table.getVisibleLeafColumns() // Min-width = sum of the rendered widths (header-fit floors included; a // flex column contributes its natural `getSize()` as its floor). const totalDesiredWidth = leafColumns.reduce((sum, col) => { const width = columnWidths.get(col.id) return sum + (typeof width === "number" ? width : col.getSize()) }, 0) // A flex column has no fixed width, so the table must stretch to the // container (width 100%) and let that column soak up the surplus; the sized // columns still can't compress below their sum (minWidth). const hasFlex = flexColumnIds.size > 0 tableEl.style.tableLayout = "fixed" tableEl.style.width = hasFlex ? "100%" : `${totalDesiredWidth}px` tableEl.style.minWidth = `${totalDesiredWidth}px` return restore }, [ resizing, table, columnWidths, flexColumnIds, columnVisibility, columnOrder, columnPinning, ])
return ( <TableBody ref={containerRef} className={className} onClick={onRowClick ? handleBodyClick : undefined} > {/* Only show rows when not loading */} {!isLoading && rows?.length ? rows.map((row, displayIndex) => ( <BodyRow key={row.id} row={row as Row<unknown>} displayIndex={displayIndex} expandColumnId={expandColumnId} isClickable={isClickable} isExpanded={row.getIsExpanded()} isSelected={row.getIsSelected()} columnWidths={columnWidths} columnLayoutSignature={columnLayoutSignature} rowMemoKey={ getRowMemoKey ? getRowMemoKey(row.original as TData) : "" } isRowFlashing={flashingRowIds.has(row.id)} flashingCellKeys={flashingCellKeys} renderRowContextMenu={ resolvedRenderRowContextMenu as ((row: unknown) => React.ReactNode) | undefined } /> )) : 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
const visibleCount = table.getVisibleLeafColumns().length
return ( <TableRow> <TableCell colSpan={colSpan ?? (visibleCount || 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 ?? (visibleColumns.length || 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 { table, columns, isLoading } = useDataTable()
// Self-gate on `isLoading` to match peer composables — otherwise the row // stays visible after data resolves. if (!isLoading) return null
const visibleCount = table.getVisibleLeafColumns().length
return ( <TableRow> <TableCell colSpan={colSpan ?? (visibleCount || 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 tRPC's `useInfiniteQuery.isFetchingNextPage`. When * false, this component renders nothing. */ isFetching: boolean /** * Optional custom content inside the loading-more row. Defaults * to a spinner + "Loading more…" label. Pass children to * customize the label per-table (e.g. "Loading more venues…"). */ children?: React.ReactNode colSpan?: number className?: string}
/** * Composable "loading more" row for infinite-scroll tables. Renders * a spinner + label row 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`. * * Mirror of `DataTableVirtualizedLoadingMore` for non-virtualized * tables — same API, same styling, same self-gating behavior. * * @example * const query = api.thing.list.useInfiniteQuery(...); * <DataTableBody * onScrolledBottom={() => { * if (query.hasNextPage && !query.isFetchingNextPage) { * void query.fetchNextPage(); * } * }} * > * <DataTableSkeleton rows={5} /> * <DataTableEmptyBody>No results</DataTableEmptyBody> * <DataTableLoadingMore isFetching={query.isFetchingNextPage}> * Loading more things… * </DataTableLoadingMore> * </DataTableBody> */export function DataTableLoadingMore({ isFetching, children, colSpan, className,}: DataTableLoadingMoreProps) { const { table, columns } = useDataTable()
// Self-gating — nothing to render when no fetch is in flight. if (!isFetching) return null
const visibleCount = table.getVisibleLeafColumns().length
return ( <TableRow data-slot="datatable-loading-more-row"> <TableCell colSpan={colSpan ?? (visibleCount || columns.length)} className={cn("text-center text-xs text-muted-foreground", className)} > <span className="inline-flex items-center justify-center gap-2 py-3"> <span className="inline-block size-3.5 animate-spin rounded-full border-2 border-muted-foreground/60 border-t-transparent" /> {children ?? "Loading more…"} </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 */
/** * Drag-to-resize / double-click-to-autosize grip used by DataTableHeader and * DataTableVirtualizedHeader when `enableColumnResizing` is on. Lives in core * so both bodies can import it without pulling the opt-in marker package. */import { type Header } from "@tanstack/react-table"import * as React from "react"import { cn } from "@/lib/utils"import { DEFAULT_MAX_COLUMN_SIZE, DEFAULT_MIN_COLUMN_SIZE } from "./constants"
/** Room for cell chrome the text measurement doesn't cover. */const AUTOSIZE_CELL_PADDING = 24 // horizontal padding + bufferconst AUTOSIZE_HEADER_CHROME = 52 // sort/menu trigger + resize grip
/** Re-export for consumers that imported sizes from this module. */export { DEFAULT_MAX_COLUMN_SIZE, DEFAULT_MIN_COLUMN_SIZE }
/** Keyboard resize step (px); the larger step applies while Shift is held. */const KEYBOARD_RESIZE_STEP = 8const KEYBOARD_RESIZE_STEP_LARGE = 40
/** * Tightest width that fits every TEXT node in an element. Uses a DOM Range * so `w-full` + `truncate` wrappers — whose `scrollWidth` echoes the current * cell width — don't hide the natural content width. Autosize can grow AND * shrink to fit. */function widestTextWidth(el: Element): number { const range = document.createRange() const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT) let max = 0 for (let n = walker.nextNode(); n; n = walker.nextNode()) { if (!n.textContent || n.textContent.trim() === "") continue range.selectNodeContents(n) max = Math.max(max, range.getBoundingClientRect().width) } return max}
/** * Autosize a column to fit its content — measures the widest rendered * cell/header text (mounted cells only — for virtualized tables, what's on * screen) and sets that column's size. */function autosizeColumn<TData>( header: Header<TData, unknown>, handle: Element,) { const root = handle.closest('[data-slot="table"]') if (!root) return const cells = root.querySelectorAll<HTMLElement>( `[data-col-id="${CSS.escape(header.column.id)}"]`, ) let content = 0 cells.forEach(c => { const isHeader = c.getAttribute("data-slot") === "table-head" const chrome = isHeader ? AUTOSIZE_HEADER_CHROME : AUTOSIZE_CELL_PADDING content = Math.max(content, widestTextWidth(c) + chrome) }) if (content <= 0) return const def = header.column.columnDef const width = Math.min( Math.max(Math.ceil(content), def.minSize ?? DEFAULT_MIN_COLUMN_SIZE), def.maxSize ?? DEFAULT_MAX_COLUMN_SIZE, ) header .getContext() .table.setColumnSizing(prev => ({ ...prev, [header.column.id]: width }))}
/** * Drag-to-resize / double-click-to-autosize grip on a header's right edge. * Keyboard-operable (WAI-ARIA window-splitter): focusable, Arrow Left/Right * nudge the width (Shift = larger step), Enter autosizes; exposes * `aria-value*`. */export interface DataTableColumnResizeHandleProps<TData> { header: Header<TData, unknown> /** * This column is currently the flex (fill) column. Flex columns have no width * TanStack can drive, so they resize through a custom pointer drag that starts * from the rendered width (no jump) and, on release, writes an explicit width * — which pins the column and hands the fill to the next eligible column. */ isFlex?: boolean /** * Drive the resize preview line during a flex drag (from the DataTable * context). Passed `{ resizingColumnId, deltaOffset }` while dragging, `null` * on release. */ setResizePreview?: ( info: { resizingColumnId: string | null; deltaOffset: number } | null, ) => void}
export function DataTableColumnResizeHandle<TData>({ header, isFlex = false, setResizePreview,}: DataTableColumnResizeHandleProps<TData>) { const isResizing = header.column.getIsResizing() const resize = header.getResizeHandler() const def = header.column.columnDef const min = def.minSize ?? DEFAULT_MIN_COLUMN_SIZE const max = def.maxSize ?? DEFAULT_MAX_COLUMN_SIZE
// ARIA value: `getSize()` is wrong for a column without an explicit // `columnSizing` entry — a flex column renders at whatever fills the // container, and header-fit can floor an un-resized column wider than its // declared size. Measure the rendered width when the handle gains focus so // screen readers hear the real width; once an entry exists, `getSize()` is // the truth again (user choice wins, render and ARIA agree). const [focusedWidth, setFocusedWidth] = React.useState<number | null>(null) const hasExplicitWidth = header.getContext().table.getState().columnSizing[header.column.id] != null const ariaValueNow = Math.round( hasExplicitWidth ? header.column.getSize() : (focusedWidth ?? header.column.getSize()), )
// `fallbackWidth` seeds the first nudge for a flex column (no `columnSizing` // entry yet) from its rendered width so the arrow key doesn't jump it to the // declared `size`. Non-flex columns fall back to `getSize()` as before. const nudge = (delta: number, fallbackWidth?: number) => { header.getContext().table.setColumnSizing(prev => { const current = prev[header.column.id] ?? fallbackWidth ?? header.column.getSize() const next = Math.min(Math.max(current + delta, min), max) return { ...prev, [header.column.id]: next } }) }
// Tear down an in-flight flex drag if the handle unmounts mid-drag (route // change, dialog close): the window listeners must not linger, and the // eventual pointerup must not commit a width to a table that no longer // exists (a consumer's `onColumnSizingChange` could persist it). const flexDragTeardownRef = React.useRef<(() => void) | null>(null) React.useEffect(() => () => flexDragTeardownRef.current?.(), [])
// Custom drag for the flex column: measure its rendered (filled) width as the // start so there's no jump, follow the cursor via the preview line, and write // an explicit width on release (which releases it from flex). const startFlexResize = ( startClientX: number, handleEl: HTMLElement | null, ) => { const thEl = handleEl?.closest<HTMLElement>("[data-col-id]") if (!thEl || !setResizePreview) return const startWidth = thEl.getBoundingClientRect().width const id = header.column.id setResizePreview({ resizingColumnId: id, deltaOffset: 0 }) const onMove = (ev: PointerEvent) => { setResizePreview({ resizingColumnId: id, deltaOffset: ev.clientX - startClientX, }) } const teardown = () => { flexDragTeardownRef.current = null window.removeEventListener("pointermove", onMove) window.removeEventListener("pointerup", onUp) window.removeEventListener("pointercancel", onCancel) setResizePreview(null) } flexDragTeardownRef.current = teardown // Commit the new width only on a real release. A cancelled pointer (e.g. the // browser stealing the gesture) just tears down and keeps the column flexing. const onUp = (ev: PointerEvent) => { const next = Math.min( Math.max(startWidth + (ev.clientX - startClientX), min), max, ) header .getContext() .table.setColumnSizing(prev => ({ ...prev, [id]: next })) teardown() } const onCancel = () => teardown() window.addEventListener("pointermove", onMove) window.addEventListener("pointerup", onUp) window.addEventListener("pointercancel", onCancel) }
return ( <div data-slot="column-resize-handle" role="separator" aria-orientation="vertical" aria-label="Resize column (arrow keys, or double-click to fit)" aria-valuenow={ariaValueNow} aria-valuemin={min} // A flex column can legitimately render wider than the declared // `maxSize` (fill ignores it), so keep the ARIA range valid. aria-valuemax={Math.max(max, ariaValueNow)} tabIndex={0} onFocus={e => { const width = e.currentTarget .closest("[data-col-id]") ?.getBoundingClientRect().width setFocusedWidth(width ?? null) }} onBlur={() => setFocusedWidth(null)} onMouseDown={e => { // Keep column-DnD header listeners from treating a resize drag as a // reorder start (handle lives inside the sortable `<th>`). e.stopPropagation() // Flex columns resize via the pointer-based custom drag below. if (!isFlex) resize(e) }} onTouchStart={e => { e.stopPropagation() if (!isFlex) resize(e) }} onDoubleClick={e => autosizeColumn(header, e.currentTarget)} onClick={e => e.stopPropagation()} onPointerDown={e => { e.stopPropagation() if (isFlex) { e.preventDefault() startFlexResize(e.clientX, e.currentTarget) } }} onKeyDown={e => { const step = e.shiftKey ? KEYBOARD_RESIZE_STEP_LARGE : KEYBOARD_RESIZE_STEP // Flex columns have no `columnSizing` entry, so seed the first nudge // from the rendered width to avoid jumping to the declared `size`. const fallbackWidth = isFlex ? e.currentTarget.closest("[data-col-id]")?.getBoundingClientRect() .width : undefined if (e.key === "ArrowLeft") { e.preventDefault() nudge(-step, fallbackWidth) } else if (e.key === "ArrowRight") { e.preventDefault() nudge(step, fallbackWidth) } else if (e.key === "Enter") { e.preventDefault() autosizeColumn(header, e.currentTarget) } }} className="group/resize absolute top-0 right-0 z-10 flex h-full w-2 cursor-col-resize touch-none justify-end outline-none select-none" > {/* Idle: invisible — an always-on grip bar reads as a header divider and drifts 1–2px from real cell `border-r`. Show on hover/focus/drag. */} <div className={cn( "my-1.5 w-px rounded bg-border opacity-0 transition-all", "group-hover/resize:my-0 group-hover/resize:w-0.5 group-hover/resize:bg-primary group-hover/resize:opacity-100", "group-focus-visible/resize:my-0 group-focus-visible/resize:w-0.5 group-focus-visible/resize:bg-primary group-focus-visible/resize:opacity-100", isResizing && "my-0 w-0.5 bg-primary opacity-100", )} /> </div> )}/** * 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, ColumnSizingState, Table } from "@tanstack/react-table"
/** * Resolve which column(s) should flex to fill the leftover row width. * * Flex fill is ON BY DEFAULT for resizable tables. A flex column renders with * no explicit width, so under `table-layout: fixed` it soaks up the surplus: * the table fills its container and a trailing actions column pins to the right * edge instead of leaving dead space. It's pure layout — never writes * `columnSizing`, so no persistence side effects. * * Selection: when no column opts in explicitly, the DEFAULT is the first * non-pinned, resizable column (the primary text column). Selection / expand / * actions columns are non-resizable (`enableResizing: false`), so they're * skipped automatically; the default lands on the first real data column. * * Overrides: * - `meta: { flex: true }` on a column flexes THAT column instead of the * default — use it when the primary column isn't the first one (e.g. a table * whose first columns are narrow codes/dates and a later column should grow). * - `meta: { flex: false }` opts a column out of ever being auto-picked. * - `meta: { disableFlexFill: true }` on the table (TableMeta) turns fill off * entirely — for a wide table meant to scroll horizontally rather than fill. * * Returns the set of column ids to render widthless (empty when fill is off or * no eligible column exists — the table then sizes to its columns as before). */export function resolveFlexColumnIds<TData>( table: Table<TData>,): ReadonlySet<string> { const ids = new Set<string>() // Flex only applies under the fixed layout that resizing turns on. if (!table.options.enableColumnResizing) return ids if (table.options.meta?.disableFlexFill) return ids
const leafColumns = table.getVisibleLeafColumns() // A column the user has explicitly resized is fixed at their width and never // flexes — so dragging a flex column pins it and hands the fill to the next // eligible column, keeping the table full. const columnSizing = table.getState().columnSizing
// Explicit opt-in wins: flex the marked column(s) unless the user resized it. // A pinned column can't flex (sticky offsets need a real width), so skip it // and fall through to the auto-pick — a marked-but-pinned column shouldn't // strand the table without a fill column. let hasExplicit = false for (const column of leafColumns) { if ( column.columnDef.meta?.flex === true && !column.getIsPinned() && columnSizing[column.id] == null ) { ids.add(column.id) hasExplicit = true } } if (hasExplicit) return ids
// Default: the first non-pinned, resizable, un-resized, non-opted-out column. const auto = leafColumns.find( column => column.getCanResize() && !column.getIsPinned() && column.columnDef.meta?.flex !== false && columnSizing[column.id] == null, ) if (auto) ids.add(auto.id) return ids}
/** * The width to render for a single column. Centralizes the flex + header-fit * rules so the header, body cells, and the min-width lock all agree. * * - Off (no resizing): the declared `size` (or auto when unset). * - Flex column: no width — it fills the leftover row width. * - Resized by the user: exactly `getSize()` — their choice wins, even if it's * narrower than the header (matches how spreadsheet grids let you shrink). * - Otherwise: floored at the header's natural width (`headerMinWidths`) so the * label never truncates on load. Pure layout — `headerMinWidths` is measured, * never written back into `columnSizing`, so nothing persists or goes stale. */export function resolveColumnWidth<TData>( column: Column<TData>, params: { resizing: boolean isFlex: boolean columnSizing: ColumnSizingState headerMinWidths: ReadonlyMap<string, number> },): number | string | undefined { const size = column.columnDef.size // Off (no resizing): the declared `size`. Flex only means "fill the surplus" // under the fixed layout that resizing turns on, so a flex column keeps its // declared size here — check resizing before flex. if (!params.resizing) return size ? `${size}px` : undefined if (params.isFlex) return undefined const base = column.getSize() if (params.columnSizing[column.id] != null) return base const headerMin = params.headerMinWidths.get(column.id) ?? 0 return headerMin > base ? headerMin : base}"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, Table } from "@tanstack/react-table"import * as React from "react"
import { formatLabel } from "./format"
// Non-label chrome inside a header cell: horizontal padding + the resize// handle + a little slack. Added to the measured label width so the floor// leaves the label breathing room, not a pixel-tight fit.const BASE_HEADER_CHROME_PX = 28// A sortable column also renders a sort trigger next to the label.const SORT_AFFORDANCE_PX = 24
const EMPTY_MIN_WIDTHS: ReadonlyMap<string, number> = new Map()
// One reusable off-DOM canvas: `measureText` never touches layout, so measuring// every header costs no reflow (unlike reading `scrollWidth` per cell).let measureCanvas: HTMLCanvasElement | null = nullfunction getMeasureContext(): CanvasRenderingContext2D | null { if (typeof document === "undefined") return null if (!measureCanvas) measureCanvas = document.createElement("canvas") return measureCanvas.getContext("2d")}
/** * The label text a column renders in its header. Mirrors the composable * title's precedence (`useDerivedColumnTitle`): `meta.label`, else a plain * string `header`, else the formatted column id — the same fallback * `DataTableColumnTitle` renders when nothing else is set. One gap this can't * see: a JSX `<DataTableColumnTitle title="..." />` override lives in rendered * output, not the column def — mirror such overrides into `meta.label` so * header-fit measures the right string. */function headerLabel<TData>(column: Column<TData, unknown>): string | null { const columnDef = column.columnDef if (columnDef.meta?.label) return columnDef.meta.label if (typeof columnDef.header === "string") return columnDef.header return formatLabel(column.id)}
/** Content equality so a re-measure with identical results keeps identity. */function minWidthsEqual( a: ReadonlyMap<string, number>, b: ReadonlyMap<string, number>,): boolean { if (a.size !== b.size) return false for (const [id, width] of b) { if (a.get(id) !== width) return false } return true}
/** * Measure the natural width each column's header needs so a column is never * rendered so narrow that its label truncates on load (header-fit). * * Returns `columnId -> minimum width in px` (label width + header chrome). The * caller floors each un-resized column at this width; a column the user has * explicitly resized is left alone. Pure measurement — nothing is written to * `columnSizing`, so it never persists or goes stale. * * Recomputes only when the visible columns, their labels, or the header font * change — measurement itself is O(columns) with zero reflow. */export function useHeaderMinWidths<TData>( table: Table<TData>, scrollElement: HTMLElement | null, enabled: boolean,): ReadonlyMap<string, number> { const [minWidths, setMinWidths] = React.useState<ReadonlyMap<string, number>>(EMPTY_MIN_WIDTHS)
const leafColumns = table.getVisibleLeafColumns() // Signature: recompute when the visible set, a label, or sortability changes. const signature = leafColumns .map(c => `${c.id}:${headerLabel(c) ?? ""}:${c.getCanSort() ? 1 : 0}`) .join("|")
// Measure-then-store is the canonical layout-measurement pattern (same as // `useColumnAutoFit`): it needs the rendered DOM (header font), so it can't // run during render. The one extra commit it triggers is intended. React.useLayoutEffect(() => { // Content-equal results keep the previous Map identity — downstream // `columnWidths` memos and memoized body rows only re-render when a floor // actually changed, not on every re-measure. const store = (next: ReadonlyMap<string, number>) => setMinWidths(prev => (minWidthsEqual(prev, next) ? prev : next))
if (!enabled || !scrollElement) { store(EMPTY_MIN_WIDTHS) return }
const ctx = getMeasureContext() // Read the real header font (family/size/weight) so measurement matches the // rendered label. Prefer the composable title element (semibold); fall back // to the header cell itself so raw string headers are still measurable. const fontEl = scrollElement.querySelector<HTMLElement>( 'thead [data-slot="column-title"]', ) ?? scrollElement.querySelector<HTMLElement>("thead th[data-col-id]") if (!ctx || !fontEl) { // No header rendered yet / nothing to measure — drop any stale floors. store(EMPTY_MIN_WIDTHS) return } const cs = getComputedStyle(fontEl) ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`
const next = new Map<string, number>() for (const column of leafColumns) { // Floors apply only to resizable columns. Fixed utility columns // (selection, actions, gutters — `enableResizing: false`) keep their // declared size: they often have no visible label, and the formatted-id // fallback would otherwise invent a phantom floor for them. if (!column.getCanResize()) continue const label = headerLabel(column) if (!label) continue const textWidth = ctx.measureText(label).width const chrome = BASE_HEADER_CHROME_PX + (column.getCanSort() ? SORT_AFFORDANCE_PX : 0) next.set(column.id, Math.ceil(textWidth) + chrome) } store(next) // `leafColumns` identity churns each render; `signature` is the stable key. // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, scrollElement, signature])
return minWidths}"use client"
import * as React from "react"
import { ContextMenu, ContextMenuContent, ContextMenuTrigger,} from "@/components/ui/context-menu"
import { DataTableRowMenuScope } from "./data-table-row-menu"
export interface DataTableRowContextMenuProps { /** * The row's `original` data. Provided to the menu children via * `DataTableRowMenuScope` so a declarative `<XRowMenu>` can read it with * `useDataTableRow()` and render the same items as the kebab dropdown. */ row: unknown /** * The `<TableRow>` element the menu anchors to. Radix's `asChild` merges * the right-click listener onto this element (via Slot), so no wrapper * node is introduced and table markup (`<tbody> > <tr>`) stays valid. */ trigger: React.ReactElement /** * The menu items to show on right-click — compose `ContextMenuItem`, * `ContextMenuSeparator`, `ContextMenuSub`, etc. The portal + surface * chrome is owned here so callers only supply the actions. * * Tip: reuse the same items your row's "…" actions column renders so * right-click and the kebab menu stay in sync (write once). */ children: React.ReactNode /** Extra className for the popup surface. */ className?: string}
/** * Attaches a right-click context menu to a single data-table row. * * Low-level primitive used internally by the bodies (and directly by card * views): pass the row's data as `row`, the row's `<TableRow>` element as * `trigger`, and the menu as `children`. Children are wrapped in a * `DataTableRowMenuScope surface="context"`, so a declarative `<XRowMenu>` * built from `RowMenuItem`/… (see `data-table-row-menu`) works here — as do * raw `ContextMenuItem`s. The popup is portalled and only mounts on open. * * @example * <DataTableRowContextMenu * row={row.original} * trigger={<TableRow data-row-id={row.id}>{cells}</TableRow>} * > * <PlayerRowMenu onView={view} onRemove={remove} /> * </DataTableRowContextMenu> */export function DataTableRowContextMenu({ row, trigger, children, className,}: DataTableRowContextMenuProps) { // Keep the anchored row visually highlighted while its menu is open, so the // user can see which row they're acting on. We stamp `data-context-menu-open` // on the row element; the table's row/cell styles react to it the same way // they react to `data-state="selected"`. const [open, setOpen] = React.useState(false) const anchoredTrigger = open ? React.cloneElement( trigger as React.ReactElement<{ "data-context-menu-open"?: string }>, { "data-context-menu-open": "" }, ) : trigger
return ( <ContextMenu onOpenChange={setOpen}> <ContextMenuTrigger asChild>{anchoredTrigger}</ContextMenuTrigger> <ContextMenuContent className={className}> <DataTableRowMenuScope row={row} surface="context"> {children} </DataTableRowMenuScope> </ContextMenuContent> </ContextMenu> )}
DataTableRowContextMenu.displayName = "DataTableRowContextMenu""use client"
import * as React from "react"
export interface DataTableRowContextMenuSlotProps<TData> { /** * The row menu. Preferred (shadcn/composable) form is a declarative * component that reads the row via `useDataTableRow()`: * * <DataTableRowContextMenuSlot> * <TeamRowMenu onEdit={…} onDelete={…} /> * </DataTableRowContextMenuSlot> * * A `(row) => menu` function is also accepted for cases that would rather * close over the row explicitly. */ children: React.ReactNode | ((row: TData) => React.ReactNode) /** * Optional per-row predicate — return `false` to give a specific row no * menu (e.g. locked/browse-only rows). Table-level gating (e.g. "no manage * permission") is better done by simply not rendering the slot at all. */ enabledFor?: (row: TData) => boolean}
/** * Declarative, composable row context menu for niko-table bodies. * * Nest it inside a `DataTableBody` / `DataTableVirtualizedBody` — or any of * the four DnD bodies (`DataTableDndBody`, `DataTableDndColumnBody`, * `DataTableVirtualizedDndBody`, `DataTableVirtualizedDndColumnBody`) — so * the per-row menu reads as part of the table's JSX tree. It renders nothing * itself — the body detects it among its children and renders its menu for * every (enabled) row inside a `DataTableRowMenuScope`, so a declarative * `<XRowMenu>` composed from `RowMenuItem` / `RowMenuSub` / … resolves the * row and surface from context. The body's `renderRowContextMenu` prop still * works and wins when both are present. * * @example * <DataTableVirtualizedBody estimateSize={44}> * <DataTableRowContextMenuSlot enabledFor={(m) => !m.isLocked}> * <PoolRowMenu onOffer={…} onDirectAssign={…} /> * </DataTableRowContextMenuSlot> * <DataTableVirtualizedSkeleton rows={8} /> * </DataTableVirtualizedBody> */export function DataTableRowContextMenuSlot<TData>( _props: DataTableRowContextMenuSlotProps<TData>,): null { return null}DataTableRowContextMenuSlot.displayName = "DataTableRowContextMenuSlot"
/** * Resolve the effective per-row menu renderer for a body: the explicit * `renderRowContextMenu` prop wins; otherwise a nested * `<DataTableRowContextMenuSlot>` child (declarative node or function), * honouring its optional `enabledFor` predicate. */export function resolveRowContextMenuRenderer<TData>( prop: ((row: TData) => React.ReactNode) | undefined, children: React.ReactNode,): ((row: TData) => React.ReactNode) | undefined { if (prop) return prop
let slotProps: DataTableRowContextMenuSlotProps<TData> | undefined React.Children.forEach(children, child => { if (!React.isValidElement(child)) return // Match by displayName — reference equality breaks across re-exports / HMR. const type = child.type as { displayName?: string } if ( child.type === DataTableRowContextMenuSlot || type.displayName === "DataTableRowContextMenuSlot" ) { slotProps = child.props as DataTableRowContextMenuSlotProps<TData> } }) if (!slotProps) return undefined
const { children: menu, enabledFor } = slotProps return (row: TData) => { if (enabledFor && !enabledFor(row)) return null return typeof menu === "function" ? (menu as (row: TData) => React.ReactNode)(row) : menu }}
/** * Body-side hook: resolve the per-row menu renderer and return a **stable** * callback identity, so memoized rows (`BodyRow`) don't re-render just because * the body re-rendered. `resolveRowContextMenuRenderer` returns a fresh * function each render (it closes over `children`); we keep the latest in a ref * and hand out a stable wrapper that reads it. The wrapper identity only * changes when the presence of a menu changes. */export function useResolvedRowContextMenuRenderer<TData>( prop: ((row: TData) => React.ReactNode) | undefined, children: React.ReactNode,): ((row: TData) => React.ReactNode) | undefined { const resolved = resolveRowContextMenuRenderer(prop, children) const latestRef = React.useRef(resolved)
latestRef.current = resolved const hasMenu = !!resolved return React.useMemo( () => (hasMenu ? (row: TData) => latestRef.current?.(row) : undefined), [hasMenu], )}"use client"
import * as React from "react"
import { ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger,} from "@/components/ui/context-menu"import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger,} from "@/components/ui/dropdown-menu"
/** * Composable row menu for niko-table — the shadcn-style, write-once way to give * a table's "…" dropdown and its right-click context menu the *same* actions. * * Define the actions ONCE as a component using the polymorphic `RowMenuItem` / * `RowMenuSeparator` / `RowMenuSub` … pieces and `useDataTableRow()`: * * @example * function TeamRowMenu({ onEdit, onDelete }: TeamRowMenuProps) { * const team = useDataTableRow<TeamRow>() * return ( * <> * <RowMenuItem onClick={() => onEdit(team)}>Edit</RowMenuItem> * <RowMenuSeparator /> * <RowMenuItem variant="destructive" onClick={() => onDelete(team.id)}> * Delete * </RowMenuItem> * </> * ) * } * * Then drop `<TeamRowMenu … />` into the kebab cell (inside a * `DataTableRowMenuScope surface="dropdown"`) and into the body's * `<DataTableRowContextMenuSlot>` — the same component renders as a dropdown * menu or a context menu depending on the surface it finds in context. */
type RowMenuSurface = "dropdown" | "context"
const RowMenuSurfaceContext = React.createContext<RowMenuSurface | null>(null)
/** Sentinel so `useDataTableRow` can tell "no provider" from a row of `undefined`. */const NO_ROW = Symbol("niko-table.no-row")const DataTableRowValueContext = React.createContext<unknown>(NO_ROW)
/** * Provides the current row and the menu surface to the row-menu pieces nested * inside. The context-menu body sets this up automatically; kebab cells wrap * their `DropdownMenuContent` children with `surface="dropdown"`. */export function DataTableRowMenuScope({ row, surface, children,}: { row: unknown surface: RowMenuSurface children: React.ReactNode}) { return ( <RowMenuSurfaceContext.Provider value={surface}> <DataTableRowValueContext.Provider value={row}> {children} </DataTableRowValueContext.Provider> </RowMenuSurfaceContext.Provider> )}
/** Read the row a row-menu is rendering for. Throws outside a scope. */export function useDataTableRow<TData>(): TData { const row = React.useContext(DataTableRowValueContext) if (row === NO_ROW) { throw new Error( 'useDataTableRow must be used inside a DataTableRowMenuScope — i.e. a table row context menu, or a kebab DropdownMenuContent wrapped with surface="dropdown".', ) } return row as TData}
function useRowMenuSurface(): RowMenuSurface { const surface = React.useContext(RowMenuSurfaceContext) if (!surface) { throw new Error( 'Row menu pieces (RowMenuItem, RowMenuSub, …) must be rendered inside a DataTableRowMenuScope — i.e. a table row context menu or a kebab DropdownMenuContent wrapped with surface="dropdown".', ) } return surface}
// ---------------------------------------------------------------------------// Polymorphic pieces — render the dropdown or context primitive by surface.// ---------------------------------------------------------------------------
export function RowMenuItem( props: React.ComponentProps<typeof DropdownMenuItem>,) { const Item = useRowMenuSurface() === "context" ? ContextMenuItem : DropdownMenuItem return <Item {...props} />}
export function RowMenuSeparator( props: React.ComponentProps<typeof DropdownMenuSeparator>,) { const Separator = useRowMenuSurface() === "context" ? ContextMenuSeparator : DropdownMenuSeparator return <Separator {...props} />}
export function RowMenuGroup( props: React.ComponentProps<typeof DropdownMenuGroup>,) { const Group = useRowMenuSurface() === "context" ? ContextMenuGroup : DropdownMenuGroup return <Group {...props} />}
export function RowMenuLabel( props: React.ComponentProps<typeof DropdownMenuLabel>,) { const Label = useRowMenuSurface() === "context" ? ContextMenuLabel : DropdownMenuLabel return <Label {...props} />}
export function RowMenuSub( props: React.ComponentProps<typeof DropdownMenuSub>,) { const Sub = useRowMenuSurface() === "context" ? ContextMenuSub : DropdownMenuSub return <Sub {...props} />}
export function RowMenuSubTrigger( props: React.ComponentProps<typeof DropdownMenuSubTrigger>,) { const SubTrigger = useRowMenuSurface() === "context" ? ContextMenuSubTrigger : DropdownMenuSubTrigger return <SubTrigger {...props} />}
export function RowMenuSubContent( props: React.ComponentProps<typeof DropdownMenuSubContent>,) { const SubContent = useRowMenuSurface() === "context" ? ContextMenuSubContent : DropdownMenuSubContent return <SubContent {...props} />}"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( // `min-w-0` lets the header shrink below its content so the title's // `truncate` engages. Without it, a narrow (resized/auto-fit) column's // label overflows into the next header cell instead of ellipsizing. "group flex w-full min-w-0 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 data-slot="column-title" className={cn( // `min-w-0` so `truncate` can shrink this flex item below its text // width (a nowrap flex child otherwise keeps full content width and // spills into the neighbouring header cell on narrow columns). "min-w-0 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 /** * Default merge strategy when a column does not provide meta.mergeStrategy. * Column-level mergeStrategy still takes precedence. */ mergeStrategy?: "preserve" | "augment" | "replace"}
/** * 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, mergeStrategy, } = 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 ?? mergeStrategy const colAutoOptionsFormat = meta.autoOptionsFormat ?? true const colFormatOptionLabel = meta.formatOptionLabel
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: colFormatOptionLabel ? colFormatOptionLabel(value) : 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, mergeStrategy, 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
/** * Default column width bounds when a column omits `minSize` / `maxSize`. * Shared by `DataTableRoot` (defaultColumn) and the resize handle clamp — * keep these in constants so root does not import the resize-handle module. */export const DEFAULT_MIN_COLUMN_SIZE = 40export const DEFAULT_MAX_COLUMN_SIZE = 1000
/** 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 ) })}
/** * Operators whose same-column repetitions are equivalent to a single `IN` * (e.g. "brand is apple OR brand is samsung" ≡ "brand IN (apple, samsung)"). * Only these are collapsed into a faceted multi-select entry. */const EQUALITY_OPERATORS = new Set<FilterOperator>([ FILTER_OPERATORS.EQ, FILTER_OPERATORS.IN,])
/** * A pending menu row: an equality filter whose value hasn't been chosen yet. * These are inert (no query effect) and must neither pollute a merged `IN` * nor trigger OR routing while the user is still picking a value. */function isPendingEqualityFilter<TData>( filter: ExtendedColumnFilter<TData>,): boolean { return ( EQUALITY_OPERATORS.has(filter.operator) && (filter.value === "" || filter.value == null || (Array.isArray(filter.value) && filter.value.length === 0)) )}
/** * Collapse repeated equality filters on the same column into one `IN` * multi-select filter, kept in first-occurrence position. * * @description The advanced filter menu and the faceted-filter dropdown must * stay in sync, but they read different state channels — the dropdown reads a * column's value from `columnFilters`, while OR logic is routed to * `globalFilter`. Two "brand is X" menu rows are semantically the faceted * multi-select's own `{ operator: "in", value: [...] }` shape, so collapsing * them yields a single `columnFilters` entry both surfaces read and write. * * A column is only collapsed when EVERY filter on it is an equality operator; * "brand is X OR brand contains Y" can't be a multi-select, so it is left * untouched (and continues to route through `globalFilter`). */function collapseSameColumnEqualityFilters<TData>( filters: ExtendedColumnFilter<TData>[],): ExtendedColumnFilter<TData>[] { const groups = new Map<string, ExtendedColumnFilter<TData>[]>() const indicesById = new Map<string, number[]>() filters.forEach((filter, index) => { const group = groups.get(filter.id) ?? [] group.push(filter) groups.set(filter.id, group) const indices = indicesById.get(filter.id) ?? [] indices.push(index) indicesById.set(filter.id, indices) })
const emitted = new Set<string>() const result: ExtendedColumnFilter<TData>[] = []
for (const filter of filters) { const group = groups.get(filter.id) ?? [] // Pending rows (no value yet) pass through untouched — merging them would // leak "" into the IN values or make the row vanish from the menu. const mergeable = group.filter(member => !isPendingEqualityFilter(member)) // Only collapse a contiguous run of the column's filters — nothing from // another column interleaved. Merging across an interleaved filter would // cross an AND/OR clause boundary and change the boolean grouping the // mixed-filter evaluator relies on: e.g. "brand=A AND category=C OR // brand=B" ((A ∧ C) ∨ B) must not become "brand IN (A,B) AND category=C" // ((A ∨ B) ∧ C). const indices = indicesById.get(filter.id) ?? [] const firstIndex = indices[0] const lastIndex = indices[indices.length - 1] const contiguous = indices.length > 0 && firstIndex !== undefined && lastIndex !== undefined && lastIndex - firstIndex + 1 === indices.length const collapsible = contiguous && mergeable.length > 1 && group.every(member => EQUALITY_OPERATORS.has(member.operator))
if (!collapsible || isPendingEqualityFilter(filter)) { result.push(filter) continue }
// Emit the merged filter once, at the first occurrence of the column. if (emitted.has(filter.id)) continue emitted.add(filter.id)
const values: string[] = [] for (const member of mergeable) { const memberValues = Array.isArray(member.value) ? member.value : [member.value] for (const value of memberValues) { if (!values.includes(value)) values.push(value) } }
result.push({ // collapsible guarantees mergeable.length > 1, so mergeable[0] exists. ...mergeable[0]!, // preserve id, filterId and the group's leading joinOperator value: values, variant: FILTER_VARIANTS.MULTI_SELECT, operator: FILTER_OPERATORS.IN, }) }
return result}
/** * Inverse of {@link collapseSameColumnEqualityFilters}, for menu display: * expand a multi-value `IN` filter into one simple "is" row per value, * OR-joined after the first row. * * @description The canonical stored state keeps multi-value equality as a * single `IN` entry in `columnFilters` (that's what the faceted dropdown * reads), but the filter menu should present it as plain per-value rows — * "Brand is Samsung / or Brand is Adidas" — not a "has any of" multi-select * row. Row `filterId`s derive from column + value (`brand-in-samsung`) so * identities stay stable across edit → collapse → expand cycles and rows * don't remount. `NOT_IN` ("has none of") has no per-row equivalent and is * left untouched. Returns the input array unchanged (same reference) when * nothing is expandable. */export function expandMergedEqualityFilters<TData>( filters: ExtendedColumnFilter<TData>[],): ExtendedColumnFilter<TData>[] { const isExpandable = (filter: ExtendedColumnFilter<TData>) => filter.operator === FILTER_OPERATORS.IN && Array.isArray(filter.value) && filter.value.length > 0
if (!filters.some(isExpandable)) return filters
const result: ExtendedColumnFilter<TData>[] = [] for (const filter of filters) { if (!isExpandable(filter)) { result.push(filter) continue } const values = filter.value as string[] values.forEach((value, index) => { result.push({ ...filter, value, variant: FILTER_VARIANTS.SELECT, operator: FILTER_OPERATORS.EQ, filterId: `${filter.id}-in-${value}`, joinOperator: index === 0 ? (filter.joinOperator ?? JOIN_OPERATORS.AND) : JOIN_OPERATORS.OR, }) }) } return result}
/** * 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), and collapses repeated same-column equality filters into a * single `IN` multi-select entry so the faceted dropdown and the advanced * filter menu stay in sync (see {@link collapseSameColumnEqualityFilters}). * * @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>( inputFilters: ExtendedColumnFilter<TData>[],): { processedFilters: ExtendedColumnFilter<TData>[] hasOrFilters: boolean hasSameColumnFilters: boolean shouldUseGlobalFilter: boolean joinOperator: typeof JOIN_OPERATORS.MIXED | typeof JOIN_OPERATORS.AND} { // Merge repeated same-column equality filters first, so a "brand is X / // or brand is Y" menu pair becomes one faceted-readable IN entry rather than // two rows routed to globalFilter (where the dropdown can't see them). const filters = collapseSameColumnEqualityFilters(inputFilters)
// Pending equality rows (no value yet) are inert and excluded from routing // decisions — a merged IN entry plus a just-added empty row must not // re-route the set to globalFilter (which would blank the faceted dropdown // mid-edit). const activeFilters = filters.filter(f => !isPendingEqualityFilter(f))
// Check for explicit OR operators const hasOrFilters = activeFilters.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 = activeFilters.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 }
// Set of real column ids (leaf columns, including hidden accessor columns). // `columnFilters` can carry filter ids that have no matching client column // (for example a filter resolved entirely server-side). Those are skipped // below — checking membership here avoids TanStack's `table.getColumn` dev // warning ("Column with id 'x' does not exist") that fires before the // `!column` guard would catch it. const columnIds = new Set(table.getAllLeafColumns().map(c => c.id))
// 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) { if (!columnIds.has(filter.id)) continue
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 */
import type { ScrollEvent } from "../core/data-table-virtualized-structure"
/** * Builds the body-scroll listener used by every data-table body * (regular, virtualized, virtualized-DnD ×2). Reads the scroll * container's metrics on each event and dispatches: * * - `onScroll` with a `ScrollEvent` snapshot * - `onScrolledTop` when at the top * - `onScrolledBottom` when within `scrollThreshold` of the bottom * * The body shipped four byte-for-byte copies of this — extracted so * scroll-math changes propagate to all four bodies and so consumers * get one canonical attach point. The returned listener is intended * to be passed to `addEventListener("scroll", handler, { passive: true })`. * * Why a factory instead of `useCallback` per body: the closure deps * are stable across renders (the consumer-supplied callbacks + * threshold are passed as args once), and bodies that wrap this in * a `useEffect` already key on those callbacks via their own deps. */export function createScrollHandler({ onScroll, onScrolledTop, onScrolledBottom, scrollThreshold = 50,}: { onScroll?: (event: ScrollEvent) => void onScrolledTop?: () => void onScrolledBottom?: () => void scrollThreshold?: number}): (event: Event) => void { /** * Edge-transition gating only — no rAF coalescing. * * Edge gating: `onScrolledTop` / `onScrolledBottom` previously fired on * EVERY scroll event while sitting at the edge — pinning at the bottom * while data streamed in re-fired `onScrolledBottom` dozens of times per * second. Tracking previous edge state limits firing to the leading edge * (false→true) and is the actual source of the perf win. * * No rAF: iOS Safari pauses `requestAnimationFrame` during momentum * scroll, which delayed edge callbacks until the finger lifted and * momentum settled — broke infinite-scroll triggering on touch. * Dispatch synchronously instead; the scroll math is cheap and edge * gating already prevents redundant consumer renders. rAF is better * reserved for DOM writes (row virtualization translateY), not * consumer callbacks. */ let prevAtTop = false let prevAtBottom = false
return (event: Event) => { const element = event.currentTarget as HTMLDivElement | null if (!element) return
const { scrollHeight, scrollTop, clientHeight } = element
const isTop = scrollTop === 0 const isBottom = scrollHeight - scrollTop - clientHeight < scrollThreshold const percentage = scrollHeight - clientHeight > 0 ? (scrollTop / (scrollHeight - clientHeight)) * 100 : 0
onScroll?.({ scrollTop, scrollHeight, clientHeight, isTop, isBottom, percentage, })
if (isTop && !prevAtTop) onScrolledTop?.() if (isBottom && !prevAtBottom) onScrolledBottom?.()
prevAtTop = isTop prevAtBottom = isBottom }}/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Shared row-click guard. Centralized so the interactive-element list * stays in sync across all body variants. */import type { Table } from "@tanstack/react-table"
/** * Returns `true` when the click landed on (or inside) an interactive * element that should suppress `onRowClick`. * * Also suppresses when the user has an active text selection — accidental * row clicks while drag-selecting cell text are a common annoyance, * especially on rows that navigate to a detail view on click. */export function isInteractiveClickTarget(target: HTMLElement): boolean { // Drag-selected text: a click that ends a selection should not navigate. // `window.getSelection` may be undefined in some environments (jsdom // edge cases / non-browser SSR), so guard both shapes. const selection = typeof window !== "undefined" ? window.getSelection?.() : null if (selection && !selection.isCollapsed && selection.toString().length > 0) { return true }
return Boolean( target.closest("button") || target.closest("input") || target.closest("textarea") || target.closest("select") || target.closest("a") || target.closest("label") || target.closest("[contenteditable]") || target.closest('[role="button"]') || target.closest('[role="checkbox"]') || target.closest('[role="combobox"]') || target.closest('[role="menuitem"]') || target.closest('[role="textbox"]') || target.closest("[data-radix-collection-item]") || target.closest('[data-slot="checkbox"]') || target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.tagName === "SELECT" || target.tagName === "BUTTON" || target.tagName === "A", )}
/** * For delegated `<tbody>` handlers. Returns the matched row, or `null` * if the click should be ignored (interactive target or no row found). */export function resolveRowFromClick<TData>( target: HTMLElement, table: Table<TData>,) { if (isInteractiveClickTarget(target)) return null const rowEl = target.closest("tr[data-row-id]") if (!rowEl) return null const rowId = rowEl.getAttribute("data-row-id") if (rowId === null) return null return table.getRow(rowId) ?? null}/** * 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 a fixed epoch by subtracting days. * Deterministic — safe for mock data / SSR hydration (no `Date.now()`). * * @param days - Number of days to subtract from the mock epoch * @returns Date object representing the date N days before the epoch * * @example * daysAgo(7) // 7 days before 2026-07-15 * daysAgo(30) // 30 days before 2026-07-15 */const MOCK_EPOCH = new Date("2026-07-15T12:00:00.000Z")
export function daysAgo(days: number): Date { const date = new Date(MOCK_EPOCH) date.setUTCDate(date.getUTCDate() - 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 // Directional separator on the pinned side — a real inner border (right for // left-pinned, left for right-pinned) plus a soft shadow so the frozen // column reads as distinct from scrolling content, even in a grid where // every cell already has borders. `border-box` keeps it from shifting width. // Pinned-column separator. Drawn as an INSET box-shadow (part of the sticky // cell's own paint) rather than a plain border: a border on a sticky cell // gets covered by scrolled-under content in some browsers, so it vanishes // once you scroll horizontally. An inset shadow stays put. A soft OUTER // shadow adds depth. `--border` alone is intentionally very faint (10% white // in dark mode), so the line is a stronger foreground mix to read clearly. ...(isLeft ? { boxShadow: "inset -2px 0 0 0 color-mix(in oklab, var(--foreground) 40%, transparent), 4px 0 8px -2px color-mix(in oklab, var(--foreground) 14%, transparent)", } : { boxShadow: "inset 2px 0 0 0 color-mix(in oklab, var(--foreground) 40%, transparent), -4px 0 8px -2px color-mix(in oklab, var(--foreground) 14%, transparent)", }), }}/** * 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 ComponentType, type PropsWithChildren, type ReactNode,} 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 enableColumnResizing?: 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 }, DataTableViewDndMenu: { enableFilters: true }, TableViewDndMenu: { enableFilters: true },
// Column resize — a marker component; its presence turns on resizable columns. DataTableColumnResize: { enableColumnResizing: 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 /** * Which column absorbs the leftover row width when column resizing is on * (needs `<DataTableColumnResize />`). Filling is ON BY DEFAULT — the first * non-pinned data column flexes automatically — so you rarely set this. * * - `true` overrides the default to flex THIS column instead (use when the * first column isn't the one that should grow). * - `false` opts this column out of being auto-picked. * * The flex column renders with no width and absorbs the surplus, so every * other column keeps its `size` and a trailing actions/menu column pins to * the right edge. Pure layout — never touches `columnSizing`, so no * persistence side effects. Turn filling off for a whole table with * `TableMeta.disableFlexFill`. */ flex?: boolean
// 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 label formatter applied when options are auto-derived from * row data. Receives the stringified row value and returns the display * label. Wins over `autoOptionsFormat` when present. Ignored when the * caller passes explicit `options`. */ formatOptionLabel?: (value: string) => string /** 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 /** * Turn off default flex fill for the whole table, so columns size to their * own widths and the table scrolls horizontally instead of stretching to * fill. Use for wide, many-column tables meant to scroll. * * Set it statically via `<DataTableRoot meta={{ disableFlexFill: true }}>` * — it's read when table options are built, so toggling it at runtime * alone won't re-apply until another table state change rebuilds them. */ disableFlexFill?: 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]"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 { ScrollRowIntoView } from "../core/data-table-context"import { useDataTable } from "../core/data-table-context"
/** * Scroll a row into view by its index in the current row model. Works on * virtualized bodies (the virtualizer registers itself) and plain bodies (DOM * `scrollIntoView` fallback), so consumers never branch on body type. * * Throws outside a `DataTableRoot` (inherited from `useDataTable`). */export function useDataTableScroll(): { scrollRowIntoView: ScrollRowIntoView} { const { scrollRowIntoView } = useDataTable() return { scrollRowIntoView }}"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 { FlashCells, FlashRows } from "../core/data-table-context"import { useDataTable } from "../core/data-table-context"
/** * Briefly highlight what just changed. `flashRows([id])` for a record-level * change (record-level — e.g. a member added to a game); `flashCells([{rowId, * columnId}])` for value-level changes (cell-level — e.g. a grid edit/paste). * Both scroll the first target into view (on virtualized bodies) then play a * soft fade pulse. Works on read tables and the editable grid alike. * * Throws outside a `DataTableRoot` (inherited from `useDataTable`). * * @example * const { flashRows, flashCells } = useDataTableFlash(); * flashRows([updatedGameId]); // whole record changed * flashCells([{ rowId, columnId: "email" }]); // one value changed */export function useDataTableFlash(): { flashRows: FlashRows flashCells: FlashCells} { const { flashRows, flashCells } = useDataTable() return { flashRows, flashCells }}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 every registry item at once? List them explicitly — the shadcn CLI does not support wildcards like @niko-table/**. Prefer installing only what you need (see Components); reach for this when you want the full set.
You can also browse / filter the registry with:
npx shadcn@latest search @niko-tableInstall everything (via URLs)
Section titled “Install everything (via URLs)”If you prefer not to configure the registry, pass full URLs instead:
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( // Base UI selects pass null on clear; Radix never does (value: string | null) => { if (!value) return 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, 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}
/** * Spread (not a literal `asChild` attribute) so the shadcn CLI's Base UI * codemod doesn't rewrite it to `render` — the sortable component keeps the * `asChild` API in both the Radix and Base UI shadcn generations. */const sortableAsChild = { asChild: true }
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} {...sortableAsChild}> <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" 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: string | null) => // Base UI selects pass null on clear; Radix never does value && 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 {...sortableAsChild}> <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 // Hide "Add sort" when adding a second entry would silently replace the // first (`enableMultiSort: false`). The first sort can still be added // from the menu when no sort exists yet. const canShowAddSort = table.options.enableMultiSort !== false || sorting.length === 0
// ============================================================================ // 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 {...sortableAsChild}> <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"> {canShowAddSort && ( <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. * * Two opt-in extensions: * - `lockedColumnIds`: include columns marked `enableHiding: false` in the * list, but render them disabled (always-on, can't toggle). * - `onReset` + `resetLabel`: render a Reset button below a separator. * * Tuned for large column counts (200+): rows are a memoized component, the * search filter runs at this layer (so non-matching rows skip rendering * entirely), and `lockedColumnIds` is consulted via a `Set` for O(1) lookups. * * For drag-to-reorder, see `TableViewDndMenu` — it lives in a separate file * so consumers who don't need DnD don't pay the `@dnd-kit/*` bundle cost. */
import type { Column, Table } from "@tanstack/react-table"import { Check, ChevronsUpDown, RotateCcw, 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"
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 /** * Column ids that should appear in the menu but cannot be toggled off. * Useful for columns the table marks `enableHiding: false` but the * consumer still wants visible in the column list (typically with a * Reset to Defaults affordance below). */ lockedColumnIds?: string[] /** * When provided, renders a Reset button at the bottom of the menu. * Useful when paired with persisted column preferences so users can * revert to defaults. */ onReset?: () => void /** Label for the reset button. Defaults to "Reset to defaults". */ resetLabel?: string}
interface MenuRowProps<TData> { column: Column<TData, unknown> isLocked: boolean isVisible: boolean onToggle: (columnId: string) => void}
const MenuRow = React.memo(function MenuRow<TData>({ column, isLocked, isVisible, onToggle,}: MenuRowProps<TData>) { return ( <CommandItem data-disabled={isLocked ? "" : undefined} onSelect={() => { if (isLocked) return onToggle(column.id) }} > <span className={cn("truncate", isLocked && "text-muted-foreground")}> {getColumnTitle(column)} </span> <Check className={cn( "ml-auto size-4 shrink-0", isLocked ? "opacity-50" : isVisible ? "opacity-100" : "opacity-0", )} /> </CommandItem> )}) as <TData>(props: MenuRowProps<TData>) => React.ReactElement
export function TableViewMenu<TData>({ table, onColumnVisibilityChange, lockedColumnIds, onReset, resetLabel,}: TableViewMenuProps<TData>) { // Controlled search. cmdk's built-in filter hides non-matching `CommandItem`s // but still renders all of them — at 200+ columns that's the bottleneck. // Filtering at this layer means non-matching rows skip rendering entirely. const [search, setSearch] = React.useState("")
// O(1) lookups instead of O(m) `.includes()` per row. const lockedSet = React.useMemo( () => new Set(lockedColumnIds ?? []), [lockedColumnIds], )
const columns = React.useMemo( () => table .getAllColumns() .filter( column => typeof column.accessorFn !== "undefined" && (column.getCanHide() || lockedSet.has(column.id)), ), // Depend on the column set, not just the (stable) table ref. // eslint-disable-next-line react-hooks/exhaustive-deps [table, table.options.columns, lockedSet], )
const visibleColumns = React.useMemo(() => { const q = search.trim().toLowerCase() if (!q) return columns return columns.filter(c => getColumnTitle(c).toLowerCase().includes(q)) }, [columns, search])
// Stable callback so memoized rows skip re-render on keystrokes. const onToggle = React.useCallback( (columnId: string) => { const column = table.getColumn(columnId) if (!column) return const newVisibility = !column.getIsVisible() column.toggleVisibility(newVisibility) onColumnVisibilityChange?.(columnId, newVisibility) }, [table, onColumnVisibilityChange], )
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 shouldFilter={false}> <CommandInput placeholder="Search columns..." value={search} onValueChange={setSearch} /> <CommandList> <CommandEmpty>No columns found.</CommandEmpty> <CommandGroup> {visibleColumns.map(column => ( <MenuRow key={column.id} column={column} isLocked={lockedSet.has(column.id)} isVisible={column.getIsVisible()} onToggle={onToggle} /> ))} </CommandGroup> </CommandList> {onReset ? ( <> <div className="border-t" /> <Button variant="ghost" size="sm" className="w-full justify-start gap-2 rounded-none text-muted-foreground" onClick={onReset} > <RotateCcw className="size-4" /> {resetLabel ?? "Reset to defaults"} </Button> </> ) : null} </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.
DataTableViewDndMenu (column visibility + drag-to-reorder):
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 { TableViewDndMenu, type TableViewDndMenuProps,} from "../filters/table-view-dnd-menu"
type DataTableViewDndMenuProps<TData> = Omit< TableViewDndMenuProps<TData>, "table">
export function DataTableViewDndMenu<TData>( props: DataTableViewDndMenuProps<TData>,) { const { table } = useDataTable<TData>() return <TableViewDndMenu table={table} {...props} />}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
DataTableViewDndMenu.displayName = "DataTableViewDndMenu""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 */
/** * Drag-to-reorder variant of `TableViewMenu`. Each row gets a `GripVertical` * handle and the list becomes vertically sortable via `@dnd-kit`. The same * `columnOrder` state the table consumes drives the menu's display order, * so dropping a row updates both surfaces in lockstep. * * Lives in a separate file so consumers who don't need DnD use `TableViewMenu` * without pulling in `@dnd-kit/core`, `@dnd-kit/modifiers`, or * `@dnd-kit/sortable`. Also supports `lockedColumnIds` + `onReset`/ * `resetLabel` for parity with the plain variant. */
import { closestCenter, DndContext, KeyboardSensor, MouseSensor, TouchSensor, useSensor, useSensors, type DragEndEvent,} from "@dnd-kit/core"import { restrictToVerticalAxis } from "@dnd-kit/modifiers"import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy,} from "@dnd-kit/sortable"import { CSS } from "@dnd-kit/utilities"import type { Column, Table } from "@tanstack/react-table"import { Check, ChevronsUpDown, GripVertical, RotateCcw, 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"
function getColumnTitle<TData>(column: Column<TData, unknown>): string { return column.columnDef.meta?.label ?? formatLabel(column.id)}
export interface TableViewDndMenuProps<TData> { table: Table<TData> className?: string onColumnVisibilityChange?: (columnId: string, isVisible: boolean) => void /** Controlled column order. The menu displays rows in this order. */ columnOrder: string[] /** Called when the user drops a row in a new position. */ onColumnOrderChange: (next: string[]) => void /** * Column ids that should appear in the menu but cannot be toggled off. * Useful for columns the table marks `enableHiding: false` but the * consumer still wants visible in the column list. */ lockedColumnIds?: string[] /** * When provided, renders a Reset button at the bottom of the menu. * Useful when paired with persisted column preferences so users can * revert to defaults. */ onReset?: () => void /** Label for the reset button. Defaults to "Reset to defaults". */ resetLabel?: string}
function SortableMenuRow({ id, disabled = false, children,}: { id: string disabled?: boolean children: React.ReactNode}) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id }) const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, position: "relative", zIndex: isDragging ? 1 : 0, } return ( <div ref={setNodeRef} style={style} className="flex items-center"> <button type="button" disabled={disabled} aria-label="Reorder column" {...attributes} {...listeners} className="flex cursor-grab items-center rounded-sm px-2 text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:outline-none" > <GripVertical className="size-4" /> </button> <div className="flex-1">{children}</div> </div> )}
interface MenuItemProps<TData> { column: Column<TData, unknown> isLocked: boolean isVisible: boolean onToggle: (columnId: string) => void}
const MenuItem = React.memo(function MenuItem<TData>({ column, isLocked, isVisible, onToggle,}: MenuItemProps<TData>) { return ( <CommandItem data-disabled={isLocked ? "" : undefined} onSelect={() => { if (isLocked) return onToggle(column.id) }} > <span className={cn("truncate", isLocked && "text-muted-foreground")}> {getColumnTitle(column)} </span> <Check className={cn( "ml-auto size-4 shrink-0", isLocked ? "opacity-50" : isVisible ? "opacity-100" : "opacity-0", )} /> </CommandItem> )}) as <TData>(props: MenuItemProps<TData>) => React.ReactElement
export function TableViewDndMenu<TData>({ table, onColumnVisibilityChange, columnOrder, onColumnOrderChange, lockedColumnIds, onReset, resetLabel,}: TableViewDndMenuProps<TData>) { // Stable across SSR + hydration — see TableColumnDndProvider. const dndContextId = React.useId()
// Controlled search. cmdk's built-in filter only hides the inner // CommandItem, which would leave SortableMenuRow's grip handle visible // as an orphan. Filtering at this layer means non-matching rows don't // render at all, wrapper and all. const [search, setSearch] = React.useState("")
// O(1) lookups instead of O(m) `.includes()` per row — matters at 200+ columns. const lockedSet = React.useMemo( () => new Set(lockedColumnIds ?? []), [lockedColumnIds], )
const columns = React.useMemo( () => table .getAllColumns() .filter( column => typeof column.accessorFn !== "undefined" && (column.getCanHide() || lockedSet.has(column.id)), ), // Depend on the column set, not just the (stable) table ref. // eslint-disable-next-line react-hooks/exhaustive-deps [table, table.options.columns, lockedSet], )
/** * Sort the menu rows by the controlled `columnOrder` so drag end * yields visually-consistent positions. */ const orderedColumns = React.useMemo(() => { const orderIndex = new Map(columnOrder.map((id, i) => [id, i])) return [...columns].sort( (a, b) => (orderIndex.get(a.id) ?? Infinity) - (orderIndex.get(b.id) ?? Infinity), ) }, [columns, columnOrder])
// Apply the controlled search filter here so SortableMenuRow wrappers // skip entirely for non-matching rows (no orphan handles). const visibleColumns = React.useMemo(() => { const q = search.trim().toLowerCase() if (!q) return orderedColumns return orderedColumns.filter(c => getColumnTitle(c).toLowerCase().includes(q), ) }, [orderedColumns, search])
/** * Partial `columnOrder` lists are common — consumers may control sort * for only a subset of columns. Restrict drag affordances to ids that * actually appear in `columnOrder`; rows omitted from it stay visible * but render without a handle so users aren't offered a no-op drag. * Returned as a Set so the per-row check in the render loop is O(1). */ const draggableIdSet = React.useMemo(() => { const visibleIds = new Set(columns.map(c => c.id)) return new Set(columnOrder.filter(id => visibleIds.has(id))) }, [columns, columnOrder])
// `SortableContext` needs the ordered id list; derive once from the Set. const draggableIds = React.useMemo( () => Array.from(draggableIdSet), [draggableIdSet], )
// 8px drag threshold so clicks on the row chrome land as clicks, not // drag starts. Matches the column-header DnD primitive convention. const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), useSensor(TouchSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates, }), )
const handleDragEnd = React.useCallback( (event: DragEndEvent) => { const { active, over } = event if (!over || active.id === over.id) return const oldIndex = columnOrder.indexOf(String(active.id)) const newIndex = columnOrder.indexOf(String(over.id)) if (oldIndex === -1 || newIndex === -1) return onColumnOrderChange(arrayMove(columnOrder, oldIndex, newIndex)) }, [columnOrder, onColumnOrderChange], )
// Stable callback so memoized rows skip re-render on keystrokes. const onToggle = React.useCallback( (columnId: string) => { const column = table.getColumn(columnId) if (!column) return const newVisibility = !column.getIsVisible() column.toggleVisibility(newVisibility) onColumnVisibilityChange?.(columnId, newVisibility) }, [table, onColumnVisibilityChange], )
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 shouldFilter={false}> <CommandInput placeholder="Search columns..." value={search} onValueChange={setSearch} /> <CommandList> {visibleColumns.length === 0 ? ( <CommandEmpty>No columns found.</CommandEmpty> ) : ( <CommandGroup> <DndContext id={dndContextId} collisionDetection={closestCenter} modifiers={[restrictToVerticalAxis]} sensors={sensors} onDragEnd={handleDragEnd} > <SortableContext items={draggableIds} strategy={verticalListSortingStrategy} > {visibleColumns.map(column => { const item = ( <MenuItem column={column} isLocked={lockedSet.has(column.id)} isVisible={column.getIsVisible()} onToggle={onToggle} /> ) return draggableIdSet.has(column.id) ? ( <SortableMenuRow key={column.id} id={column.id}> {item} </SortableMenuRow> ) : ( <React.Fragment key={column.id}>{item}</React.Fragment> ) })} </SortableContext> </DndContext> </CommandGroup> )} </CommandList> {onReset ? ( <> <div className="border-t" /> <Button variant="ghost" size="sm" className="w-full justify-start gap-2 rounded-none text-muted-foreground" onClick={onReset} > <RotateCcw className="size-4" /> {resetLabel ?? "Reset to defaults"} </Button> </> ) : null} </Command> </PopoverContent> </Popover> )}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
TableViewDndMenu.displayName = "TableViewDndMenu"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 { expandMergedEqualityFilters, 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 */export 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}
/** * Spread (not a literal `asChild` attribute) so the shadcn CLI's Base UI * codemod doesn't rewrite it to `render` — the sortable component keeps the * `asChild` API in both the Radix and Base UI shadcn generations. */const sortableAsChild = { asChild: true }
interface FacetedProps< Multiple extends boolean = false, // Base UI's Popover types onOpenChange as (open, eventDetails) with both // params required; declare our own single-param callback so calling it // with just `open` typechecks in both shadcn generations> extends Omit<React.ComponentProps<typeof Popover>, "onOpenChange"> { onOpenChange?: (open: boolean) => void 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) { table.options.meta.hasIndividualJoinOperators = true
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. // Display expands merged multi-value IN entries (the canonical columnFilters // shape the faceted dropdown reads) back into one simple "is" row per value; // edits re-collapse on sync via processFiltersForLogic. const rawFilters = controlledFilters ?? internalFilters const filters = React.useMemo( () => expandMergedEqualityFilters(rawFilters), [rawFilters], ) 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 {...sortableAsChild}> <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} {...sortableAsChild}> <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 {...sortableAsChild}> <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={typeof filter.value === "string" ? filter.value : undefined} onValueChange={value => // Base UI selects pass null on clear; Radix never does value != null && 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`). A currently selected value is always kept so it can still be un-checked. Pure label-only option lists (no counts) render unchanged. */} {options ?.filter( (option: Option) => option.count !== 0 || // Keep the active selection visible so it stays un-checkable — // multi-select holds an array, single-select a bare string. (Array.isArray(selectedValues) ? selectedValues.includes(option.value) : selectedValues === option.value), ) .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: string | null) => // Base UI selects pass null on clear; Radix never does value && onFilterUpdate(filter.filterId, { joinOperator: value as JoinOperator, }) } > <SelectTrigger aria-label="Select join operator" aria-controls={joinOperatorListboxId} size="sm" className="rounded lowercase" > <SelectValue placeholder={filter.joinOperator || "and"} /> </SelectTrigger> <SelectContent id={joinOperatorListboxId} 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: string | null) => { // Base UI selects pass null on clear; Radix never does if (!value) return const operator = value as FilterOperator onFilterUpdate(filter.filterId, { operator, value: operator === FILTER_OPERATORS.EMPTY || operator === 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 const formatOptionLabel = meta?.formatOptionLabel
// 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, formatOptionLabel, }, ) }
// 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, formatOptionLabel, }, ) }, [ 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,