Installation
Install DataTable from the niko-table registry with the shadcn CLI—TanStack Table v9, optional pagination, filters, virtualization, and more.
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)
-
TanStack Table v9 — this registry targets
@tanstack/react-table@^9. The CLI pins that range when you install registry items; for a manual add use:
Works with both shadcn generations. The registry 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 DataTable 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 registry
Section titled “Configure the 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 owner once —
@niko-table/data-tablecopiesDataTableRoot, chrome, and (transitively)data-table-core+data-table-uiinto 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
data-table-core, so the CLI installs or reuses the shared floor without pulling Root or overwritingtable.tsx.
Now install the owner:
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 { 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 { useTable, type ColumnDef, type ColumnFiltersState, type ColumnOrderState, type ColumnPinningState, type ColumnSizingState, type ColumnVisibilityState, type ExpandedState, type FilterFn, type FilterFnOption, type GroupingState, type PaginationState, type ReactTable, type RowData, type RowSelectionState, type SortingState, type TableOptions, type Updater,} 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 { features, type DataTableFeatures } from "../lib/data-table-features"import { globalFilter as globalFilterFn } from "../lib/filter-functions"import { type DataTableColumnDef, type DataTableColumns, 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 /** * When grouping is active, how grouped columns are placed in the column flow. * TanStack default: `'reorder'` (move grouped columns to the start). * `'remove'` hides them; `false` leaves them in place. */ groupedColumnMode?: false | "reorder" | "remove"
// 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 extends RowData> extends Omit< Partial<TableOptions<DataTableFeatures, TData>>, // `key` is dropped so React's reserved `key` prop keeps its own semantics // — TanStack v9 added a `key` table option that would otherwise force // `<DataTableRoot key={…}>` to be a string. "columns" | "data" | "getRowId" | "key"> { // Option 1: Pass a pre-configured table instance table?: ReactTable<DataTableFeatures, TData>
// Option 2: Let DataTableRoot create its own table columns?: DataTableColumns<TData> 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<ColumnVisibilityState>) => void onColumnFiltersChange?: (updater: Updater<ColumnFiltersState>) => void onRowSelectionChange?: (updater: Updater<RowSelectionState>) => void onExpandedChange?: (updater: Updater<ExpandedState>) => void onGroupingChange?: (updater: Updater<GroupingState>) => void onColumnOrderChange?: (updater: Updater<ColumnOrderState>) => void onRowSelection?: (selectedRows: TData[]) => void}
// Internal component that handles hooks for direct props modefunction DataTableRootInternal<TData extends RowData>({ columns, data, children, className, config, getRowId, isLoading, onGlobalFilterChange, onPaginationChange, onSortingChange, onColumnVisibilityChange, onColumnFiltersChange, onRowSelectionChange, onExpandedChange, onGroupingChange, 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, // Lifted out of the passthrough bag: these are emitted after the spread in // `tableOptions`, so a consumer-supplied value (server-driven grouping / // expansion) must win over the feature-derived default. manualGrouping: manualGroupingProp, manualExpanding: manualExpandingProp, // 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>, "table"> & { columns: DataTableColumns<TData> 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, groupedColumnMode: config?.groupedColumnMode, 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, config?.groupedColumnMode, 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 ?? false, enableExpanding: finalConfig.enableExpanding ?? detectedFeatures.enableExpanding ?? finalConfig.enableGrouping ?? detectedFeatures.enableGrouping ?? 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<ColumnVisibilityState>( 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 [grouping, setGrouping] = React.useState<GroupingState>( restInitialState?.grouping ?? [], ) const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({ start: restInitialState?.columnPinning?.start ?? [], end: restInitialState?.columnPinning?.end ?? [], }) 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<ColumnVisibilityState>) => { if (isMountedRef.current) setColumnVisibility(u) }, [], )
const handleColumnPinningChange = React.useCallback( (updater: Updater<ColumnPinningState>) => { if (!isMountedRef.current) return setColumnPinning(prev => { const next = typeof updater === "function" ? updater(prev) : updater return { start: next.start ?? [], end: next.end ?? [], } }) }, [], )
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 handleGroupingChange = React.useCallback( (u: Updater<GroupingState>) => { if (isMountedRef.current) setGrouping(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<DataTableFeatures, 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<DataTableFeatures, 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<DataTableFeatures, 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<DataTableFeatures, 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 controlledGrouping = restState?.grouping ?? grouping 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>, ): 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 isPinnedStart = controlledColumnPinning.start?.includes(firstDataColId) const isPinnedEnd = controlledColumnPinning.end?.includes(firstDataColId)
// If not fixed to either side, return default (system cols float naturally) if (!isPinnedStart && !isPinnedEnd) { return controlledColumnPinning }
const start = [...(controlledColumnPinning.start ?? [])] const end = [...(controlledColumnPinning.end ?? [])]
// 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 cleanStart = start.filter(id => !SYSTEM_COLUMN_ID_LIST.includes(id)) const cleanEnd = end.filter(id => !SYSTEM_COLUMN_ID_LIST.includes(id))
// 5. Construct new pinning state if (isPinnedStart) { // Pin start (LTR left): [System, ...Others] return { start: [...systemColsPresent, ...cleanStart], end: cleanEnd, } }
if (isPinnedEnd) { // Pin end (LTR right): [System, ...Others] // We place system cols *before* others in the end group so they appear // to the immediate left of the end-pinned data columns. return { start: cleanStart, end: [...systemColsPresent, ...cleanEnd], } }
return controlledColumnPinning }, [controlledColumnPinning, columns, hasSelectColumn, hasExpandColumn])
// Critical: stable options reference. New object → useTable recreates // the instance → state resets and sorting/filter/expand break. const tableOptions = React.useMemo<TableOptions<DataTableFeatures, TData>>( () => ({ ...passthroughTableOptions, features, data, columns: processedColumns as ColumnDef< DataTableFeatures, TData, unknown >[], 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, grouping: controlledGrouping, 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 || detectFeatures.enableGrouping, groupedColumnMode: finalConfig.groupedColumnMode ?? "reorder", // When a feature is off, skip its client row model (v8 omitted get*RowModel). // `manual*` still wins for server-side modes when the feature is on. manualSorting: detectFeatures.manualSorting || !detectFeatures.enableSorting, manualPagination: detectFeatures.manualPagination || !detectFeatures.enablePagination, manualFiltering: detectFeatures.manualFiltering || !detectFeatures.enableFilters, manualGrouping: manualGroupingProp ?? !detectFeatures.enableGrouping, manualExpanding: manualExpandingProp ?? !(detectFeatures.enableExpanding || detectFeatures.enableGrouping), // 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, onGroupingChange: onGroupingChange ?? handleGroupingChange, onPaginationChange: onPaginationChange ?? handlePaginationChange, // filterFns live on `features` (data-table-features.ts) — do not re-register here. // Allow globalFilterFn to be overridden via rest props, otherwise use default globalFilterFn: (restGlobalFilterFn as FilterFn<DataTableFeatures, TData>) ?? (globalFilterFn as unknown as FilterFn<DataTableFeatures, 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, onGroupingChange, handleGroupingChange, onPaginationChange, handlePaginationChange, getRowId, manualGroupingProp, manualExpandingProp, // Use controlled state values - these update when either external or local state changes controlledSorting, controlledColumnVisibility, controlledRowSelection, controlledColumnFilters, controlledGlobalFilter, controlledColumnOrder, controlledColumnSizing, controlledExpanded, controlledGrouping, 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 = useTable<DataTableFeatures, 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 extends RowData>({ table: externalTable, columns, data, children, className, isLoading, ...rest}: TableRootProps<TData>) { // 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 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 type { RowData } from "@tanstack/react-table"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 extends RowData, 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 type { RowData } from "@tanstack/react-table"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 extends RowData, 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 { 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 { cn } from "@/lib/utils"import { useDerivedColumnTitle } from "../hooks/use-derived-column-title"
import type { DataTableColumn } from "../types"import type { RowData } from "@tanstack/react-table"/** * Renders the column title. */export function TableColumnTitle<TData extends RowData, TValue>({ column, title, className, children,}: { column: DataTableColumn<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 { RowData } from "@tanstack/react-table"import * as React from "react"import { formatLabel } from "../lib/format"
import type { DataTableColumn } from "../types"/** * 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 extends RowData, TValue = unknown>( column: DataTableColumn<TData, TValue> | 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])}/** * 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 */
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 { 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 },
// Grouping — also needs expanding so group rows can collapse/expand // // `DataTableGroupedRows` is the body-side marker: composing it is the whole // opt-in, so a table that renders group rows never has to set a config flag. DataTableGroupedRows: { enableGrouping: true, enableExpanding: true }, TableGroupedRows: { enableGrouping: true, enableExpanding: true }, TableColumnGroupOptions: { enableGrouping: true, enableExpanding: true }, DataTableColumnGroupOptions: { enableGrouping: true, enableExpanding: true }, TableColumnGroupMenu: { enableGrouping: true, enableExpanding: true }, DataTableColumnGroupMenu: { enableGrouping: true, enableExpanding: 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: () => {}, getCanGroup: () => true, getIsGrouped: () => false, toggleGrouping: () => {}, 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 }}"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 the full table owner (data-table + data-table-core + data-table-ui). That includes lib/data-table-features.ts — TanStack Table v9’s feature registration that DataTableRoot passes to useTable. Trim it after install the same way you omit registry items you never mount. See Table Features.
For pagination, filters, DnD, virtualization, aside, and other features, add the optional blocks below (one by one) or use Install Everything. If you own the TanStack instance yourself and only need controls + structure, skip data-table and install the control items directly — see Components.
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 type { RowData } from "@tanstack/react-table"import { useDataTable } from "../core/data-table-context"import { TablePagination, type TablePaginationProps,} from "../filters/table-pagination"
type DataTablePaginationProps<TData extends RowData> = Omit< TablePaginationProps<TData>, "table" | "isLoading"> & { /** * Override the loading state from context */ isLoading?: boolean}
export function DataTablePagination<TData extends RowData>({ 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 { 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"
import type { DataTableInstance } from "../types"import type { RowData } from "@tanstack/react-table"export interface TablePaginationProps<TData extends RowData> { table: DataTableInstance<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 extends RowData>({ table, pageSizeOptions = [10, 25, 50, 100], defaultPageSize = pageSizeOptions[0] ?? 10, isLoading, isFetching, disableNextPage, disablePreviousPage, totalCount, onPageSizeChange, onPageChange, onNextPage, onPreviousPage, onPaginationReady,}: TablePaginationProps<TData>) { const { pageIndex, pageSize } = table.state.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 type { RowData } from "@tanstack/react-table"import { useDataTable } from "../core/data-table-context"import { TableSearchFilter, type TableSearchFilterProps,} from "../filters/table-search-filter"
type DataTableSearchFilterProps<TData extends RowData> = 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 extends RowData>( 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 * 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"
import type { DataTableInstance } from "../types"import type { RowData } from "@tanstack/react-table"export interface TableSearchFilterProps<TData extends RowData> { table: DataTableInstance<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 extends RowData>({ 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.state 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 type { RowData } from "@tanstack/react-table"import { useDataTable } from "../core/data-table-context"import { TableSortMenu, type TableSortMenuProps,} from "../filters/table-sort-menu"
type DataTableSortMenuProps<TData extends RowData> = 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 extends RowData>( 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, RowData } 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"
import type { DataTableInstance } from "../types"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-[var(--radix-popover-trigger-width,var(--anchor-width))] origin-[var(--radix-popover-content-transform-origin,var(--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"} // Spread so it type-checks against Radix too: Base UI reads the // closed trigger's label only from `items`, Radix ignores the prop. {...{ items: labels }} 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-[var(--radix-select-trigger-width,var(--anchor-width))] origin-[var(--radix-select-content-transform-origin,var(--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 RowData,> extends React.ComponentProps<typeof PopoverContent> { table: DataTableInstance<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 extends RowData>({ 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.state.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.state.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-[var(--radix-popover-content-available-width,var(--available-width))] origin-[var(--radix-popover-content-transform-origin,var(--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 type { RowData } from "@tanstack/react-table"import { useDataTable } from "../core/data-table-context"import { TableViewMenu, type TableViewMenuProps,} from "../filters/table-view-menu"
type DataTableViewMenuProps<TData extends RowData> = Omit< TableViewMenuProps<TData>, "table">
export function DataTableViewMenu<TData extends RowData>( 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 { RowData } 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"
import type { DataTableColumn, DataTableInstance } from "../types"function getColumnTitle<TData extends RowData>( column: DataTableColumn<TData, unknown>,): string { return column.columnDef.meta?.label ?? formatLabel(column.id)}
export interface TableViewMenuProps<TData extends RowData> { table: DataTableInstance<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 /** * Replaces the default toolbar button. * * Same `trigger` contract the sibling filters already expose * (`TableColumnActions`, `TableDateFilter`, `TableFacetedFilter`), so a * consumer that needs a different trigger shape — a header-sized bare icon * beside a column title, for instance — composes one instead of waiting for * a boolean per shape. */ trigger?: React.ReactNode}
interface MenuRowProps<TData extends RowData> { column: DataTableColumn<TData, unknown> isLocked: boolean isVisible: boolean onToggle: (columnId: string) => void}
const MenuRow = React.memo(function MenuRow<TData extends RowData>({ 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 extends RowData>(props: MenuRowProps<TData>) => React.ReactElement
export function TableViewMenu<TData extends RowData>({ table, onColumnVisibilityChange, lockedColumnIds, onReset,
trigger, 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> {trigger ?? ( <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 type { RowData } from "@tanstack/react-table"import { useDataTable } from "../core/data-table-context"import { TableViewDndMenu, type TableViewDndMenuProps,} from "../filters/table-view-dnd-menu"
type DataTableViewDndMenuProps<TData extends RowData> = Omit< TableViewDndMenuProps<TData>, "table">
export function DataTableViewDndMenu<TData extends RowData>( 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 { RowData } 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"
import type { DataTableColumn, DataTableInstance } from "../types"function getColumnTitle<TData extends RowData>( column: DataTableColumn<TData, unknown>,): string { return column.columnDef.meta?.label ?? formatLabel(column.id)}
export interface TableViewDndMenuProps<TData extends RowData> { table: DataTableInstance<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 /** * Replaces the default toolbar button. * * Same `trigger` contract the sibling filters already expose * (`TableColumnActions`, `TableDateFilter`, `TableFacetedFilter`), so a * consumer that needs a different trigger shape — a header-sized bare icon * beside a column title, for instance — composes one instead of waiting for * a boolean per shape. */ trigger?: React.ReactNode}
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 extends RowData> { column: DataTableColumn<TData, unknown> isLocked: boolean isVisible: boolean onToggle: (columnId: string) => void}
const MenuItem = React.memo(function MenuItem<TData extends RowData>({ 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 extends RowData>(props: MenuItemProps<TData>) => React.ReactElement
export function TableViewDndMenu<TData extends RowData>({ table, onColumnVisibilityChange, columnOrder, onColumnOrderChange, lockedColumnIds, onReset,
trigger, 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> {trigger ?? ( <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 type { RowData } from "@tanstack/react-table"import { useDataTable } from "../core/data-table-context"import { TableClearFilter, type TableClearFilterProps,} from "../filters/table-clear-filter"
type DataTableClearFilterProps<TData extends RowData> = 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 extends RowData>( 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 { Button } from "@/components/ui/button"import { cn } from "@/lib/utils"import { X } from "lucide-react"
import type { DataTableInstance } from "../types"import type { RowData } from "@tanstack/react-table"
/** Stable empty default — `excludeColumnIds` is a `useCallback` dependency. */const EMPTY_EXCLUDED_COLUMN_IDS: readonly string[] = []
export interface TableClearFilterProps<TData extends RowData> { table: DataTableInstance<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 /** * Column ids that are NOT user filters, even though they live in * `columnFilters` — a navigational tab or scope selector the table stores * there so it round-trips through the URL like everything else. * * They neither make the button appear nor get cleared by it. Without this, * selecting such a tab pops a "Reset" the user never asked for, and pressing * it silently navigates them back to the default tab. * * @default [] — every column filter is treated as a user filter */ excludeColumnIds?: readonly string[]}
/** * 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 = useTable({ features, ... }) * <TableClearFilter table={table} /> * ``` */export function TableClearFilter<TData extends RowData>({ table, className, variant = "outline", size = "sm", showIcon = true, children, enableResetColumnFilters = true, enableResetGlobalFilter = true, enableResetSorting = true, excludeColumnIds = EMPTY_EXCLUDED_COLUMN_IDS,}: TableClearFilterProps<TData>) { // Read state directly - should be reactive via table re-renders const state = table.state const hasActiveFilters = state.columnFilters.some( filter => !excludeColumnIds.includes(filter.id), ) // A whitespace-only search is invisible in the input, so counting it as // active pops a Reset the reader cannot account for — they see a button // offering to clear something, and nothing on screen that needs clearing. // Objects (the advanced OR/MIXED filter payload) always count. const hasGlobalFilter = typeof state.globalFilter === "string" ? state.globalFilter.trim().length > 0 : 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) { if (excludeColumnIds.length > 0) { // Keep the excluded entries exactly as they are — resetColumnFilters() // would drop the tab the user is standing on along with their filters. table.setColumnFilters(prev => prev.filter(filter => excludeColumnIds.includes(filter.id)), ) } else { table.resetColumnFilters() } } if (enableResetGlobalFilter) { table.setGlobalFilter("") } if (enableResetSorting) { table.resetSorting() } }, [ table, enableResetColumnFilters, enableResetGlobalFilter, enableResetSorting, excludeColumnIds, ])
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"
import type { RowData } from "@tanstack/react-table"export type DataTableExportButtonProps<TData extends RowData> = 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 extends RowData>({ ...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 { RowData } from "@tanstack/react-table"import { Button } from "@/components/ui/button"import { Download } from "lucide-react"
import type { DataTableInstance } from "../types"/** * Escape a cell value for CSV output. * Handles strings, numbers, booleans, dates, arrays, null, and undefined. *//** * Characters that make a spreadsheet treat a cell as a live formula. * * Excel, LibreOffice Calc and Google Sheets all evaluate a cell beginning with * one of these, so an exported value like `=cmd|'/c calc'!A1` becomes * executable content when the file is opened. Table rows are arbitrary * application data, so anyone who can write a record can reach the export. * * `-` is handled separately: see {@link neutralizeFormula}. */const FORMULA_TRIGGER_PATTERN = /^[=+@\t\r]/
/** * Whether a string is just a number, commas and surrounding space aside. * * `Number` rejects anything with an operator in it, so `-2+3` is not numeric * while `-1,234.56` is. */function isNumericLiteral(str: string): boolean { const cleaned = str.replace(/[,\s]/g, "") return cleaned !== "" && Number.isFinite(Number(cleaned))}
/** * Prefix a value a spreadsheet would evaluate with an apostrophe, which every * major spreadsheet reads as "treat the rest as literal text" and does not * render in the cell. Applied before quoting so the apostrophe is inside the * quoted field. * * A leading `-` is only neutralised when the value is NOT a number. Treating * every `-` as hostile is the common advice, and it is wrong for tabular data: * negative amounts are ordinary here, and prefixing them writes `'-5.00` into * the file. That is visibly wrong to a reader and, worse, silently wrong to any * importer reading the export back — a column of negatives returns as text. * `-2+3+cmd|'/c calc'!A1` is not a number and is still neutralised, so the * carve-out costs nothing in safety. */function neutralizeFormula(str: string): string { if (FORMULA_TRIGGER_PATTERN.test(str)) return `'${str}` if (str.startsWith("-") && !isNumericLiteral(str)) return `'${str}` return str}
/** * Encode one value as a CSV field: formula-neutralised, quoted when it carries * a separator, quote or newline. Exported so the rules can be tested and * reused; `exportTableToCSV` applies it to every header and cell. */export function escapeCsvValue(value: unknown): string { if (value === null || value === undefined) return ""
if (value instanceof Date) { return `"${value.toISOString()}"` }
if (Array.isArray(value)) { // Neutralised too: quoting is for CSV parsing, not formula prevention — // a spreadsheet still evaluates a quoted field that opens with `=`. const joined = neutralizeFormula(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, neutralise formulas, then escape quotes const str = neutralizeFormula(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 extends RowData> { /** 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 extends RowData>( table: DataTableInstance<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 extends RowData> { /** * The table instance from TanStack Table */ table: DataTableInstance<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 = useTable({ features, ... }) * <TableExportButton table={table} filename="products" /> * ``` */export function TableExportButton<TData extends RowData>({ 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 type { RowData } from "@tanstack/react-table"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 extends RowData> = 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 extends RowData> = 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 extends RowData>({ 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 { RowData } 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 { DataTableColumn, DataTableInstance, 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 extends RowData>( 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 extends RowData>( 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 extends RowData> = 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 extends RowData>( 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 extends RowData>( 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-[var(--radix-popover-content-transform-origin,var(--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 extends RowData>( 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 = (previousFilter ? originalIndexMapById.get(previousFilter.filterId) : undefined) ?? -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 = (previousFilter ? originalIndexMapByKey.get(getFilterKey(previousFilter)) : undefined) ?? -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 extends RowData>( table: DataTableInstance<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.state.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.state.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 extends RowData>( table: DataTableInstance<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 RowData,> extends React.ComponentProps<typeof PopoverContent> { table: DataTableInstance<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 extends RowData>({ 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-[var(--radix-popover-content-available-width,var(--available-width))] origin-[var(--radix-popover-content-transform-origin,var(--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 extends RowData> { filter: ExtendedColumnFilter<TData> index: number filterItemId: string table: DataTableInstance<TData> columns: DataTableColumn<TData>[] onFilterUpdate: ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => void onFilterRemove: (filterId: string) => void}
function TableFilterItem<TData extends RowData>({ 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 extends RowData> { filter: ExtendedColumnFilter<TData> inputId: string table: DataTableInstance<TData> column: DataTableColumn<TData> columnMeta?: DataTableColumn<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 extends RowData>({ 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 extends RowData>({ 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 extends RowData>({ 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} // Spread so it type-checks against Radix too: Base UI reads the closed // trigger's label only from `items`, Radix ignores the prop. {...{ items: dataTableConfig.booleanValues }} 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}> {dataTableConfig.booleanValues.map(option => ( <SelectItem key={option.value} value={option.value}> {option.label} </SelectItem> ))} </SelectContent> </Select> )}FilterBooleanSelect.displayName = "FilterBooleanSelect"
/** * Select/multi-select faceted input */function FilterFacetedSelect<TData extends RowData>({ 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-[var(--radix-popover-content-transform-origin,var(--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 extends RowData>({ 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-[var(--radix-popover-content-transform-origin,var(--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 extends RowData>( 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 extends RowData>({ 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-[var(--radix-select-trigger-width,var(--anchor-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 extends RowData>({ filter, filterItemId, columns, onFilterUpdate, showFieldSelector, setShowFieldSelector,}: { filter: ExtendedColumnFilter<TData> filterItemId: string columns: DataTableColumn<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-[var(--radix-popover-content-transform-origin,var(--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 extends RowData>({ 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} // Spread so it type-checks against Radix too: Base UI reads the closed // trigger's label only from `items`, Radix ignores the prop. {...{ items: filterOperators }} 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-[var(--radix-select-content-transform-origin,var(--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 extends RowData>( 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 * as React from "react"
import { Input } from "@/components/ui/input"import { cn } from "@/lib/utils"import type { DataTableColumn, ExtendedColumnFilter } from "../types"
import type { RowData } from "@tanstack/react-table"interface TableRangeFilterProps< TData extends RowData,> extends React.ComponentProps<"div"> { filter: ExtendedColumnFilter<TData> column: DataTableColumn<TData> inputId: string onFilterUpdate: ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => void}
export function TableRangeFilter<TData extends RowData>({ 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 { TableFacetedFilter, TableFacetedFilterContent, useTableFacetedFilter, type TableFacetedFilterProps,} from "../filters/table-faceted-filter"import { useDataTable } from "../core/data-table-context"import type { DataTableInstance, 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"
import type { RowData } from "@tanstack/react-table"type DataTableFacetedFilterProps<TData extends RowData, 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 extends RowData> { table: DataTableInstance<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 extends RowData>({ 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.state 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 extends RowData>({ 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 extends RowData, 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 extends RowData, 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 { Check, PlusCircle, XCircle } from "lucide-react"import * as React from "react"
import { Badge } from "@/components/ui/badge"import { Button } from "@/components/ui/button"import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, CommandSeparator,} from "@/components/ui/command"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"import { Separator } from "@/components/ui/separator"import { cn } from "@/lib/utils"import type { DataTableColumn, ExtendedColumnFilter, Option } from "../types"import { FILTER_OPERATORS, FILTER_VARIANTS, JOIN_OPERATORS,} from "../lib/constants"
import type { RowData } from "@tanstack/react-table"export interface TableFacetedFilterProps<TData extends RowData, TValue> { column?: DataTableColumn<TData, TValue> title?: string options: Option[] multiple?: boolean /** * Callback fired when filter value changes * Useful for server-side filtering or external state management */ onValueChange?: (value: string[] | undefined) => void /** * Optional custom trigger element */ trigger?: React.ReactNode}
export function useTableFacetedFilter<TData extends RowData, TValue = unknown>({ column, onValueChange, multiple,}: { column?: DataTableColumn<TData, TValue> onValueChange?: (value: string[] | undefined) => void multiple?: boolean}) { const columnFilterValue = column?.getFilterValue()
// Handle both ExtendedColumnFilter format (new) and legacy array format const selectedValues = React.useMemo(() => { // Handle ExtendedColumnFilter format (from filter menu or new faceted filter) if ( columnFilterValue && typeof columnFilterValue === "object" && !Array.isArray(columnFilterValue) && "value" in columnFilterValue ) { const filterValue = (columnFilterValue as ExtendedColumnFilter<TData>) .value return new Set( Array.isArray(filterValue) ? filterValue : [String(filterValue)], ) } // Handle legacy array format (backward compatibility) return new Set(Array.isArray(columnFilterValue) ? columnFilterValue : []) }, [columnFilterValue])
const onItemSelect = React.useCallback( (option: Option, isSelected: boolean) => { if (!column) return
if (multiple) { const newSelectedValues = new Set(selectedValues) if (isSelected) { newSelectedValues.delete(option.value) } else { newSelectedValues.add(option.value) } const filterValues = Array.from(newSelectedValues)
if (filterValues.length === 0) { column.setFilterValue(undefined) onValueChange?.(undefined) } else { // Create ExtendedColumnFilter format for interoperability with filter menu // FORCE variant to multiSelect when using IN operator to ensure it shows up in the menu const extendedFilter: ExtendedColumnFilter<TData> = { id: column.id as Extract<keyof TData, string>, value: filterValues, variant: FILTER_VARIANTS.MULTI_SELECT, operator: FILTER_OPERATORS.IN, filterId: `faceted-${column.id}`, joinOperator: JOIN_OPERATORS.AND, } column.setFilterValue(extendedFilter) onValueChange?.(filterValues) } } else { // Single selection if (isSelected) { column.setFilterValue(undefined) onValueChange?.(undefined) } else { // Create ExtendedColumnFilter format for single selection // Use EQUAL operator for single select const extendedFilter: ExtendedColumnFilter<TData> = { id: column.id as Extract<keyof TData, string>, value: option.value, // Single value, not array variant: FILTER_VARIANTS.SELECT, operator: FILTER_OPERATORS.EQ, filterId: `faceted-${column.id}`, joinOperator: JOIN_OPERATORS.AND, } column.setFilterValue(extendedFilter) onValueChange?.([option.value]) } } }, [column, multiple, selectedValues, onValueChange], )
const onReset = React.useCallback( (event?: React.MouseEvent) => { event?.stopPropagation() column?.setFilterValue(undefined) onValueChange?.(undefined) }, [column, onValueChange], )
return { selectedValues, onItemSelect, onReset, }}
export function TableFacetedFilter<TData extends RowData, TValue>({ column, title, options = [], multiple, onValueChange, trigger,}: TableFacetedFilterProps<TData, TValue>) { const [open, setOpen] = React.useState(false)
const { selectedValues, onItemSelect, onReset } = useTableFacetedFilter({ column, onValueChange, multiple, })
// Wrap onItemSelect to close multiple=false popover const handleItemSelect = React.useCallback( (option: Option, isSelected: boolean) => { onItemSelect(option, isSelected) if (!multiple) { setOpen(false) } }, [onItemSelect, multiple, setOpen], )
return ( <Popover open={open} onOpenChange={setOpen}> <PopoverTrigger asChild> {trigger || ( <Button variant="outline" size="sm" className="h-8 border-dashed"> {selectedValues?.size > 0 ? ( <div role="button" aria-label={`Clear ${title} filter`} tabIndex={0} onClick={onReset} onKeyDown={e => { if (e.key === "Enter" || e.key === " ") { e.preventDefault() onReset(e as unknown as React.MouseEvent) } }} className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none" > <XCircle className="size-4" /> </div> ) : ( <PlusCircle className="size-4" /> )} {title} {selectedValues?.size > 0 && ( <> <Separator orientation="vertical" className="mx-2 h-4" /> <Badge variant="secondary" className="rounded-sm px-1 font-normal lg:hidden" > {selectedValues.size} </Badge> <div className="hidden items-center gap-1 lg:flex"> {selectedValues.size > 2 ? ( <Badge variant="secondary" className="rounded-sm px-1 font-normal" > {selectedValues.size} selected </Badge> ) : ( options .filter(option => selectedValues.has(option.value)) .map(option => ( <Badge variant="secondary" key={option.value} className="rounded-sm px-1 font-normal" > {option.label} </Badge> )) )} </div> </> )} </Button> )} </PopoverTrigger> <PopoverContent className="w-52 p-0" align="start"> <TableFacetedFilterContent title={title} options={options} selectedValues={selectedValues} onItemSelect={handleItemSelect} onReset={onReset} /> </PopoverContent> </Popover> )}
export function TableFacetedFilterContent({ title, options, selectedValues, onItemSelect, onReset,}: { title?: string options: Option[] selectedValues: Set<string> onItemSelect: (option: Option, isSelected: boolean) => void onReset: (event?: React.MouseEvent) => void}) { return ( <Command> <CommandInput placeholder={title} className="pl-2" /> <CommandList className="max-h-full"> <CommandEmpty>No results found.</CommandEmpty> <CommandGroup className="max-h-75 overflow-x-hidden overflow-y-auto"> {options.map(option => { const isSelected = selectedValues.has(option.value)
return ( <CommandItem key={option.value} onSelect={() => onItemSelect(option, isSelected)} > <div className={cn( "mr-2 flex size-4 items-center justify-center rounded-sm border border-primary", isSelected ? "bg-primary text-primary-foreground" : "opacity-50 [&_svg]:invisible", )} > <Check className="size-4" /> </div> {option.icon && <option.icon className="mr-2 size-4" />} <span className="truncate">{option.label}</span> {option.count !== undefined && ( <span className="ml-auto font-mono text-xs"> {option.count} </span> )} </CommandItem> ) })} </CommandGroup> {selectedValues.size > 0 && ( <> <CommandSeparator /> <CommandGroup> <CommandItem onSelect={() => onReset()} className="justify-center text-center" > Clear filters </CommandItem> </CommandGroup> </> )} </CommandList> </Command> )}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
TableFacetedFilter.displayName = "TableFacetedFilter"/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
import type { RowData } from "@tanstack/react-table"import type { DataTableInstance, DataTableRow } from "../types"
import { getFilteredRowsExcludingColumn } from "./filter-rows"import { formatLabel } from "./format"import type { Option } from "../types"
export interface BuildFacetedOptionsConfig { /** * If provided, these options are the source of truth and the returned list * is a subset of them (optionally narrowed by `limitToFilteredRows` and * enriched with live counts). * * If omitted, options are derived from the rows themselves — useful for * columns that are not declared as `select`/`multiSelect` variants but are * still being used with a faceted filter (boolean, text, etc.). */ staticOptions?: Option[] /** * Narrow the returned options to values that exist in rows passing every * *other* active filter (the current column's own filter is excluded). */ limitToFilteredRows: boolean /** * Compute counts against filtered rows (again, excluding the current * column's filter) rather than against the full core row model. */ dynamicCounts: boolean /** * When false, `count` is stripped from the output for a consistent shape. */ showCounts: boolean /** * When deriving options from rows, run labels through `formatLabel` (title * case etc.). Ignored when `staticOptions` is provided — callers' labels * are always preserved as-is. */ autoOptionsFormat: boolean /** * Optional per-column label formatter. Wins over `autoOptionsFormat` when * present. Receives the stringified row value, returns the display label. * Ignored when `staticOptions` is provided. */ formatOptionLabel?: (value: string) => string}
/** * Pure builder for the option list surfaced by a faceted filter. Handles * both explicit `staticOptions` (narrow + enrich with counts) and * row-derived options. Plain function (not a hook) so the wrapper can * gate it behind a single `useMemo`. */export function buildFacetedOptions<TData extends RowData>( table: DataTableInstance<TData>, coreRows: DataTableRow<TData>[], accessorKey: string, columnFilters: Array<{ id: string; value: unknown }>, globalFilter: unknown, config: BuildFacetedOptionsConfig,): Option[] { const { staticOptions, limitToFilteredRows, dynamicCounts, showCounts, autoOptionsFormat, formatOptionLabel, } = config
// Fast path: explicit options, no narrowing, no counts — just normalize the // shape so every return path of this function looks the same to callers. if (staticOptions && !limitToFilteredRows && !showCounts) { return staticOptions.map(opt => ({ ...opt, count: undefined })) }
// Only compute the filtered row subset if at least one flag actually needs // it. This avoids the cost of `getFilteredRowsExcludingColumn` when the // caller has opted out of both narrowing and dynamic counts. const needsFilteredRows = limitToFilteredRows || dynamicCounts const filteredRows = needsFilteredRows ? getFilteredRowsExcludingColumn( table, coreRows, accessorKey, columnFilters, globalFilter, ) : coreRows
const optionRows = limitToFilteredRows ? filteredRows : coreRows const countRows = dynamicCounts ? filteredRows : coreRows
// Collect the set of values present in `optionRows`. Used for narrowing // static options and for producing the auto-derived option list. const availableValues = collectRowValues(optionRows, accessorKey)
// The column's own currently-selected values must never be narrowed away: // otherwise a selection that another filter excludes (e.g. Brand=Apple while // Category=Clothing) vanishes from its own facet — the pill disappears and // the value can't be un-checked, even though the filter is still applied. const selectedValues = getSelectedValues(columnFilters, accessorKey) const keepValue = (value: string) => availableValues.has(value) || selectedValues.has(value)
// Build the base option list (no counts yet). let baseOptions: Option[] if (staticOptions) { baseOptions = limitToFilteredRows ? staticOptions.filter(opt => keepValue(opt.value)) : staticOptions } else { const optionValues = limitToFilteredRows ? new Set([...availableValues, ...selectedValues]) : availableValues baseOptions = Array.from(optionValues) .map(value => ({ value, label: formatOptionLabel ? formatOptionLabel(value) : autoOptionsFormat ? formatLabel(value) : value, })) .sort((a, b) => a.label.localeCompare(b.label)) }
if (!showCounts) { return baseOptions.map(opt => ({ ...opt, count: undefined })) }
// Scope counts to baseOptions values up-front so every returned option // has a count and every count maps to a returned option. const targetValues = new Set(baseOptions.map(opt => opt.value)) const valueCounts = new Map<string, number>() for (const row of countRows) { const raw = row.getValue(accessorKey) as unknown const values: unknown[] = Array.isArray(raw) ? raw : [raw] for (const v of values) { if (v == null) continue const str = String(v) if (!str) continue if (targetValues.has(str)) { valueCounts.set(str, (valueCounts.get(str) ?? 0) + 1) } } }
return baseOptions.map(opt => ({ ...opt, // Prefer a caller-supplied count over the row-derived one. On a server-side // table the rows are only the current page, so the caller passes true // server-computed facet counts; keep them. Options without a count fall // back to the row-derived value. Matches the header-funnel path. count: opt.count ?? valueCounts.get(opt.value) ?? 0, }))}
/** * Extract the set of selected values from a single column's filter value, * tolerating every shape it can take: the `ExtendedColumnFilter` object written * by the faceted control and the advanced menu (`{ value: string | string[] }`), * and the legacy bare array / scalar. */export function extractFilterSelectedValues(rawValue: unknown): Set<string> { let raw = rawValue if (raw && typeof raw === "object" && !Array.isArray(raw) && "value" in raw) { raw = (raw as { value: unknown }).value } if (raw == null || raw === "") return new Set()
const values = Array.isArray(raw) ? raw : [raw] return new Set(values.filter(v => v != null && v !== "").map(String))}
function getSelectedValues( columnFilters: Array<{ id: string; value: unknown }>, accessorKey: string,): Set<string> { const entry = columnFilters.find(filter => filter.id === accessorKey) return entry ? extractFilterSelectedValues(entry.value) : new Set()}
function collectRowValues<TData extends RowData>( rows: DataTableRow<TData>[], accessorKey: string,): Set<string> { const set = new Set<string>() for (const row of rows) { const raw = row.getValue(accessorKey) as unknown const values: unknown[] = Array.isArray(raw) ? raw : [raw] for (const v of values) { if (v == null) continue const str = String(v) if (str) set.add(str) } } return set}Update the import paths to match your project setup.
DataTableInlineFilter:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import type { RowData } from "@tanstack/react-table"import React from "react"import { useDataTable } from "../core/data-table-context"import { TableInline, type TableInlineProps,} from "../filters/table-inline-filter"import { useGeneratedOptions } from "../hooks/use-generated-options"import { FILTER_VARIANTS } from "../lib/constants"import type { Option } from "../types"
type BaseInlineProps<TData extends RowData> = Omit< TableInlineProps<TData>, "table">
interface AutoOptionProps { autoOptions?: boolean showCounts?: boolean dynamicCounts?: boolean /** * If true, only generate options from filtered rows. If false, generate from all rows. * This controls which rows are used to generate the option list itself. * Note: This is separate from dynamicCounts which controls count calculation. * @default true */ limitToFilteredRows?: boolean includeColumns?: string[] excludeColumns?: string[] limitPerColumn?: number mergeStrategy?: "preserve" | "augment" | "replace"}
type DataTableInlineFilterProps<TData extends RowData> = BaseInlineProps<TData> & AutoOptionProps
export function DataTableInlineFilter<TData extends RowData>({ autoOptions = true, showCounts = true, dynamicCounts = true, limitToFilteredRows = true, includeColumns, excludeColumns, limitPerColumn, mergeStrategy = "preserve", ...props}: DataTableInlineFilterProps<TData>) { const { table } = useDataTable<TData>()
const generatedOptions = useGeneratedOptions(table, { showCounts, dynamicCounts, limitToFilteredRows, includeColumns, excludeColumns, limitPerColumn, mergeStrategy, })
/** * BUG: stale counts on filter changes — see `data-table-filter-menu.tsx` * for full doc. We capture pristine caller options in a ref and rebuild * `meta.options` from that source on every augment so counts refresh * (and zeroes get filled in for the count-0 hide rule). */ React.useMemo(() => { if (!autoOptions) return null table.getAllColumns().forEach(column => { const meta = (column.columnDef.meta ||= {}) const variant = meta.variant ?? FILTER_VARIANTS.TEXT if ( variant !== FILTER_VARIANTS.SELECT && variant !== FILTER_VARIANTS.MULTI_SELECT ) return const gen = generatedOptions[column.id] if (!gen || gen.length === 0) return
if (!meta.options) { meta.options = gen return }
if (mergeStrategy === "replace") { meta.options = gen return }
if (mergeStrategy === "augment") { // See `data-table-filter-menu.tsx` for the staleness rationale. // We stash the pristine options on `meta` so the next augment can // rebuild from them instead of from the previous augment's output. const metaWithStash = meta as typeof meta & { __nikoOriginalOptions?: Option[] } if (!metaWithStash.__nikoOriginalOptions) { metaWithStash.__nikoOriginalOptions = meta.options } const original = metaWithStash.__nikoOriginalOptions const countMap = new Map(gen.map(o => [o.value, o.count])) meta.options = original.map((opt: Option) => ({ ...opt, count: showCounts ? (countMap.get(opt.value) ?? 0) : undefined, })) } }) }, [autoOptions, generatedOptions, mergeStrategy, showCounts, table])
return <TableInline table={table} {...props} />}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
DataTableInlineFilter.displayName = "DataTableInlineFilter""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */// Inline-filter module: utilities, sync hooks, value-input renderers, and the// `TableInline` toolbar.
import type { RowData } from "@tanstack/react-table"import { BadgeCheck, CalendarIcon, Check, ListFilter, Text, X,} from "lucide-react"import * as React from "react"
import { Button } from "@/components/ui/button"import { Calendar } from "@/components/ui/calendar"import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList,} from "@/components/ui/command"import { Input } from "@/components/ui/input"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from "@/components/ui/select"import { getDefaultFilterOperator, getFilterOperators, processFiltersForLogic,} from "../lib/data-table"import { formatDate } from "../lib/format"import { useKeyboardShortcut } from "../hooks/use-keyboard-shortcut"import { cn } from "@/lib/utils"import { FILTER_OPERATORS, FILTER_VARIANTS, JOIN_OPERATORS, KEYBOARD_SHORTCUTS,} from "../lib/constants"import { dataTableConfig } from "../config/data-table"import type { DataTableColumn, DataTableInstance, ExtendedColumnFilter, FilterOperator, JoinOperator, Option,} from "../types"import { TableRangeFilter } from "./table-range-filter"
/* --------------------------------- Utilities -------------------------------- */
/** * Create a deterministic filter ID based on filter properties * This ensures filters can be shared via URL and will have consistent IDs */function createFilterId<TData extends RowData>( filter: Omit<ExtendedColumnFilter<TData>, "filterId">, index?: number,): string { // Create a deterministic ID based on filter properties // Using a combination that should be unique for each filter configuration const valueStr = typeof filter.value === "string" ? filter.value : JSON.stringify(filter.value)
// Include index as a fallback to ensure uniqueness for URL sharing const indexSuffix = typeof index === "number" ? `-${index}` : ""
return `${filter.id}-${filter.operator}-${filter.variant}-${valueStr}${indexSuffix}` .toLowerCase() .replace(/[^a-z0-9-]/g, "-") .replace(/-+/g, "-") .substring(0, 100) // Limit length to avoid extremely long IDs}
/** * Type for filters without filterId (for URL serialization) */type FilterWithoutId<TData extends RowData> = Omit< ExtendedColumnFilter<TData>, "filterId">
/** * Normalize filters loaded from URL by ensuring they have filterId * If filterId is missing, generate it deterministically * * This allows filters to be stored in URL without filterId, making URLs shorter * and more robust. The filterId is auto-generated when filters are loaded. * * @param filters - Filters that may or may not have filterId * @returns Filters with guaranteed filterId values */function normalizeFiltersFromUrl<TData extends RowData>( filters: (FilterWithoutId<TData> | ExtendedColumnFilter<TData>)[],): ExtendedColumnFilter<TData>[] { return filters.map((filter, index) => { // If filterId is missing, generate it if (!("filterId" in filter) || !filter.filterId) { return { ...filter, filterId: createFilterId(filter, index), } as ExtendedColumnFilter<TData> } return filter as ExtendedColumnFilter<TData> })}
/* -------------------------------- Custom Hooks ------------------------------ */
/** * Hook to initialize filters from table state (for URL restoration) * Replaces the initialization useEffect with derived state * * @description This hook runs ONCE on mount to extract initial filter state from: * 1. Controlled filters (if provided via props) * 2. Table's globalFilter (for OR logic filters) * 3. Table's columnFilters (for AND logic filters) * * @debug Check React DevTools > Components > useInitialFilters to see returned value */function useInitialFilters<TData extends RowData>( table: DataTableInstance<TData>, controlledFilters?: ExtendedColumnFilter<TData>[],): ExtendedColumnFilter<TData>[] { const initialFilters = React.useMemo(() => { if (controlledFilters) { const normalized = normalizeFiltersFromUrl(controlledFilters) if (process.env.NODE_ENV === "development") { console.log( "[TableInline useInitialFilters] Using controlled filters:", normalized, ) } return normalized }
const globalFilter = table.state.globalFilter if ( globalFilter && typeof globalFilter === "object" && "filters" in globalFilter ) { const filterObj = globalFilter as { filters: (FilterWithoutId<TData> | ExtendedColumnFilter<TData>)[] } const normalized = normalizeFiltersFromUrl(filterObj.filters) if (process.env.NODE_ENV === "development") { console.log( "[TableInline useInitialFilters] Extracted from globalFilter:", normalized, ) } return normalized }
const columnFilters = table.state.columnFilters if (columnFilters && columnFilters.length > 0) { const extractedFilters = columnFilters .map(cf => cf.value) .filter( (v): v is FilterWithoutId<TData> | ExtendedColumnFilter<TData> => v !== null && typeof v === "object" && "id" in v, ) if (extractedFilters.length > 0) { const normalized = normalizeFiltersFromUrl(extractedFilters) if (process.env.NODE_ENV === "development") { console.log( "[TableInline useInitialFilters] Extracted from columnFilters:", normalized, ) } return normalized } }
if (process.env.NODE_ENV === "development") { console.log("[TableInline useInitialFilters] No initial filters found") } return [] // eslint-disable-next-line react-hooks/exhaustive-deps }, [])
return initialFilters}
/** * Hook to sync filters with table state * Replaces multiple useEffect hooks with a single focused effect * * @description Manages synchronization between filter state and TanStack Table: * - Updates table.meta.joinOperator for the global filter function * - In uncontrolled mode: updates table's globalFilter or columnFilters based on join operators * - In controlled mode: only updates table.meta (parent handles table state) * * @debug * - Check table.state.globalFilter to see OR filters * - Check table.state.columnFilters to see AND filters * - Check table.options.meta.joinOperator to see current join logic */function useSyncFiltersWithTable<TData extends RowData>( table: DataTableInstance<TData>, filters: ExtendedColumnFilter<TData>[], isControlled: boolean,) { // Use core utility to process filters and determine logic const filterLogic = React.useMemo( () => processFiltersForLogic(filters), [filters], )
// Update table meta (happens during render, safe mutation) if (table.options.meta) { 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(() => { if (isControlled) { if (process.env.NODE_ENV === "development") { console.log( "[TableInline useSyncFiltersWithTable] Controlled mode - skipping table sync", ) } return }
if (process.env.NODE_ENV === "development") { console.log("[TableInline useSyncFiltersWithTable] Syncing filters:", { filterCount: filters.length, hasOrFilters: filterLogic.hasOrFilters, hasSameColumnFilters: filterLogic.hasSameColumnFilters, joinOperator: filterLogic.joinOperator, }) }
// Use core utility to determine routing if (filterLogic.shouldUseGlobalFilter) { table.resetColumnFilters()
table.setGlobalFilter({ filters: filterLogic.processedFilters, joinOperator: filterLogic.joinOperator, }) if (process.env.NODE_ENV === "development") { console.log( "[TableInline useSyncFiltersWithTable] Set globalFilter (OR/MIXED logic)", { hasOrFilters: filterLogic.hasOrFilters, hasSameColumnFilters: filterLogic.hasSameColumnFilters, }, ) } } else { table.setGlobalFilter("") const columnFilters = filterLogic.processedFilters.map(filter => ({ id: filter.id, value: { operator: filter.operator, value: filter.value, id: filter.id, filterId: filter.filterId, joinOperator: filter.joinOperator, }, })) table.setColumnFilters(columnFilters) if (process.env.NODE_ENV === "development") { console.log( "[TableInline useSyncFiltersWithTable] Set columnFilters (AND logic)", ) } } }, [filters, filterLogic, table, isControlled])}
export interface TableInlineProps< TData extends RowData,> extends React.ComponentProps<"div"> { table: DataTableInstance<TData> filters?: ExtendedColumnFilter<TData>[] onFiltersChange?: (filters: ExtendedColumnFilter<TData>[]) => void}
export function TableInline<TData extends RowData>({ table, filters: controlledFilters, onFiltersChange: controlledOnFiltersChange, children, className, ...props}: TableInlineProps<TData>) { const id = React.useId()
// Check if we're in controlled mode const isControlled = controlledFilters !== undefined
// Get initial filters from table state (for URL restoration) const initialFilters = useInitialFilters(table, controlledFilters)
// Internal state - manages filters when not controlled const [internalFilters, setInternalFilters] = React.useState<ExtendedColumnFilter<TData>[]>(initialFilters)
// Use controlled values if provided, otherwise use internal state const filters = controlledFilters ?? internalFilters
// Sync filters with table state (handles both controlled and uncontrolled) useSyncFiltersWithTable(table, filters, isControlled)
// Handler that works with both controlled and internal state const onFiltersChange = React.useCallback( (newFilters: ExtendedColumnFilter<TData>[]) => { if (controlledOnFiltersChange) { // In controlled mode, just notify parent - don't call table methods // Parent will update URL state, which will flow back to table state via DataTableRoot controlledOnFiltersChange(newFilters) } else { // In uncontrolled mode, update internal state // Table sync happens via useSyncFiltersWithTable hook setInternalFilters(newFilters) } }, [controlledOnFiltersChange], )
const columns = React.useMemo( () => table.getAllColumns().filter(column => column.getCanFilter()), // Depend on the column set, not just the (stable) table ref. // eslint-disable-next-line react-hooks/exhaustive-deps [table, table.options.columns], )
const [open, setOpen] = React.useState(false) const [selectedColumn, setSelectedColumn] = React.useState<DataTableColumn<TData> | null>(null) const [inputValue, setInputValue] = React.useState("") const triggerRef = React.useRef<HTMLButtonElement>(null) const inputRef = React.useRef<HTMLInputElement>(null)
const onOpenChange = React.useCallback((open: boolean) => { setOpen(open)
if (!open) { setTimeout(() => { setSelectedColumn(null) setInputValue("") }, 100) } }, [])
const onFilterAdd = React.useCallback( (column: DataTableColumn<TData>, value: string) => { if ( !value.trim() && column.columnDef.meta?.variant !== FILTER_VARIANTS.BOOLEAN ) { return }
const filterValue = column.columnDef.meta?.variant === FILTER_VARIANTS.MULTI_SELECT ? [value] : value
const filterWithoutId = { id: column.id as Extract<keyof TData, string>, value: filterValue, variant: column.columnDef.meta?.variant ?? FILTER_VARIANTS.TEXT, operator: getDefaultFilterOperator( column.columnDef.meta?.variant ?? FILTER_VARIANTS.TEXT, ), joinOperator: JOIN_OPERATORS.AND, // Default to AND for new filters }
// Use current filter length as index to ensure unique IDs const newFilterIndex = filters.length
const newFilter: ExtendedColumnFilter<TData> = { ...filterWithoutId, filterId: createFilterId(filterWithoutId, newFilterIndex), }
onFiltersChange([...filters, newFilter]) setOpen(false)
setTimeout(() => { setSelectedColumn(null) setInputValue("") }, 100) }, [filters, onFiltersChange], )
const onFilterRemove = React.useCallback( (filterId: string) => { const updatedFilters = filters.filter( filter => filter.filterId !== filterId, ) onFiltersChange(updatedFilters) requestAnimationFrame(() => { triggerRef.current?.focus() }) }, [filters, onFiltersChange], )
const onFilterUpdate = React.useCallback( ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => { const updatedFilters = filters.map(filter => { if (filter.filterId === filterId) { return { ...filter, ...updates } as ExtendedColumnFilter<TData> } return filter }) onFiltersChange(updatedFilters) }, [filters, onFiltersChange], )
const onFiltersReset = React.useCallback(() => { onFiltersChange([]) }, [onFiltersChange])
// Toggle filter menu with 'F' key useKeyboardShortcut({ key: KEYBOARD_SHORTCUTS.FILTER_TOGGLE, onTrigger: () => setOpen(prev => !prev), })
// Remove last filter with Shift+F useKeyboardShortcut({ key: KEYBOARD_SHORTCUTS.FILTER_REMOVE, requireShift: true, onTrigger: () => { if (filters.length > 0) { onFilterRemove(filters[filters.length - 1]?.filterId ?? "") } }, condition: () => filters.length > 0, })
const onInputKeyDown = React.useCallback( (event: React.KeyboardEvent<HTMLInputElement>) => { const key = event.key.toLowerCase() if ( (key === KEYBOARD_SHORTCUTS.BACKSPACE || key === KEYBOARD_SHORTCUTS.DELETE) && !inputValue && selectedColumn ) { event.preventDefault() setSelectedColumn(null) } }, [inputValue, selectedColumn], )
const onTriggerKeyDown = React.useCallback( (event: React.KeyboardEvent<HTMLButtonElement>) => { const key = event.key.toLowerCase() if ( (key === KEYBOARD_SHORTCUTS.BACKSPACE || key === KEYBOARD_SHORTCUTS.DELETE) && filters.length > 0 ) { event.preventDefault() onFilterRemove(filters[filters.length - 1]?.filterId ?? "") } }, [filters, onFilterRemove], )
return ( <div role="toolbar" aria-orientation="horizontal" className={cn( "flex w-full items-start justify-between gap-2 p-1", className, )} {...props} > <div className="flex flex-1 flex-wrap items-center gap-2"> {filters.map((filter, index) => ( <React.Fragment key={filter.filterId}> {/* Show join operator selector before filter (except for first filter) */} {index > 0 && ( <Select value={filter.joinOperator || JOIN_OPERATORS.AND} onValueChange={(value: string | null) => // Base UI selects pass null on clear; Radix never does value && onFilterUpdate(filter.filterId, { joinOperator: value as JoinOperator, }) } > <SelectTrigger size="sm" className="w-20 text-xs font-medium uppercase" > <SelectValue placeholder={filter.joinOperator || "and"} /> </SelectTrigger> <SelectContent> {dataTableConfig.joinOperators.map(operator => ( <SelectItem key={operator} value={operator} className="uppercase" > {operator} </SelectItem> ))} </SelectContent> </Select> )} <TableInlineFilterItem filter={filter} filterItemId={`${id}-filter-${filter.filterId}`} columns={columns} onFilterUpdate={onFilterUpdate} onFilterRemove={onFilterRemove} /> </React.Fragment> ))} {filters.length > 0 && ( <Button aria-label="Clear all filters" title="Clear all filters" variant="outline" size="icon" className="size-8" onClick={onFiltersReset} > <X /> </Button> )} <Popover open={open} onOpenChange={onOpenChange}> <PopoverTrigger asChild> <Button aria-label="Open filter command menu" title="Add filter (Press F)" variant="outline" size="sm" ref={triggerRef} onKeyDown={onTriggerKeyDown} > <ListFilter /> Add filter </Button> </PopoverTrigger> <PopoverContent align="start" className="w-full max-w-[var(--radix-popover-content-available-width,var(--available-width))] origin-[var(--radix-popover-content-transform-origin,var(--transform-origin))] p-0" > <Command loop className="[&_[cmdk-input-wrapper]_svg]:hidden"> <CommandInput ref={inputRef} placeholder={ selectedColumn ? (selectedColumn.columnDef.meta?.label ?? selectedColumn.id) : "Search fields..." } value={inputValue} onValueChange={setInputValue} onKeyDown={onInputKeyDown} /> <CommandList> {selectedColumn ? ( <> {selectedColumn.columnDef.meta?.options && ( <CommandEmpty>No options found.</CommandEmpty> )} <FilterValueSelector column={selectedColumn} value={inputValue} onSelect={value => onFilterAdd(selectedColumn, value)} /> </> ) : ( <> <CommandEmpty>No fields found.</CommandEmpty> <CommandGroup> {columns.map(column => ( <CommandItem key={column.id} value={column.id} onSelect={() => { setSelectedColumn(column) setInputValue("") requestAnimationFrame(() => { inputRef.current?.focus() }) }} > {column.columnDef.meta?.icon && ( <column.columnDef.meta.icon /> )} <span className="truncate"> {column.columnDef.meta?.label ?? column.id} </span> </CommandItem> ))} </CommandGroup> </> )} </CommandList> </Command> </PopoverContent> </Popover> </div> <div className="flex items-center gap-2">{children}</div> </div> )}TableInline.displayName = "TableInline"
interface TableInlineFilterItemProps<TData extends RowData> { filter: ExtendedColumnFilter<TData> filterItemId: string columns: DataTableColumn<TData>[] onFilterUpdate: ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => void onFilterRemove: (filterId: string) => void}
function TableInlineFilterItem<TData extends RowData>({ filter, filterItemId, columns, onFilterUpdate, onFilterRemove,}: TableInlineFilterItemProps<TData>) { const [showFieldSelector, setShowFieldSelector] = React.useState(false) const [showOperatorSelector, setShowOperatorSelector] = React.useState(false) const [showValueSelector, setShowValueSelector] = React.useState(false)
const column = columns.find(column => column.id === filter.id)
const operatorListboxId = `${filterItemId}-operator-listbox` const inputId = `${filterItemId}-input`
const columnMeta = column?.columnDef.meta const filterOperators = getFilterOperators(filter.variant)
const onItemKeyDown = React.useCallback( (event: React.KeyboardEvent<HTMLDivElement>) => { if ( event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement ) { return }
if (showFieldSelector || showOperatorSelector || showValueSelector) { return }
const key = event.key.toLowerCase() if ( key === KEYBOARD_SHORTCUTS.BACKSPACE || key === KEYBOARD_SHORTCUTS.DELETE ) { event.preventDefault() onFilterRemove(filter.filterId) } }, [ filter.filterId, showFieldSelector, showOperatorSelector, showValueSelector, onFilterRemove, ], )
if (!column) return null
return ( <div key={filter.filterId} role="listitem" id={filterItemId} className="flex h-8 items-center rounded-md bg-background" onKeyDown={onItemKeyDown} > <Popover open={showFieldSelector} onOpenChange={setShowFieldSelector}> <PopoverTrigger asChild> <Button title="Change field" variant="ghost" size="sm" className="rounded-none rounded-l-md border border-r-0 font-normal dark:bg-input/30" > {columnMeta?.icon && ( <columnMeta.icon className="text-muted-foreground" /> )} {columnMeta?.label ?? column.id} </Button> </PopoverTrigger> <PopoverContent align="start" className="w-48 origin-[var(--radix-popover-content-transform-origin,var(--transform-origin))] p-0" > <Command loop> <CommandInput placeholder="Search fields..." /> <CommandList> <CommandEmpty>No fields found.</CommandEmpty> <CommandGroup> {columns.map(column => ( <CommandItem key={column.id} value={column.id} onSelect={() => { onFilterUpdate(filter.filterId, { id: column.id as Extract<keyof TData, string>, variant: column.columnDef.meta?.variant ?? FILTER_VARIANTS.TEXT, operator: getDefaultFilterOperator( column.columnDef.meta?.variant ?? FILTER_VARIANTS.TEXT, ), value: "", })
setShowFieldSelector(false) }} > {column.columnDef.meta?.icon && ( <column.columnDef.meta.icon /> )} <span className="truncate"> {column.columnDef.meta?.label ?? column.id} </span> <Check className={cn( "ml-auto", column.id === filter.id ? "opacity-100" : "opacity-0", )} /> </CommandItem> ))} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> <Select open={showOperatorSelector} onOpenChange={setShowOperatorSelector} value={filter.operator} // Spread so it type-checks against Radix too: Base UI reads the closed // trigger's label only from `items`, Radix ignores the prop. {...{ items: filterOperators }} 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 title="Change operator" aria-controls={operatorListboxId} size="sm" className="rounded-none border-r-0 px-2.5 lowercase [&_svg]:hidden" > <SelectValue placeholder={filter.operator} /> </SelectTrigger> <SelectContent id={operatorListboxId} className="origin-[var(--radix-select-content-transform-origin,var(--transform-origin))]" > {filterOperators.map(operator => ( <SelectItem key={operator.value} className="lowercase" value={operator.value} > {operator.label} </SelectItem> ))} </SelectContent> </Select> {onFilterInputRender({ filter, column, inputId, onFilterUpdate, showValueSelector, setShowValueSelector, })} <Button aria-controls={filterItemId} title={`Remove ${columnMeta?.label ?? column.id} filter`} variant="ghost" size="sm" className="h-full rounded-none rounded-r-md border border-l-0 px-1.5 font-normal dark:bg-input/30" onClick={() => onFilterRemove(filter.filterId)} > <X className="size-3.5" /> </Button> </div> )}TableInlineFilterItem.displayName = "TableInlineFilterItem"
interface FilterValueSelectorProps<TData extends RowData> { column: DataTableColumn<TData> value: string onSelect: (value: string) => void}
function FilterValueSelector<TData extends RowData>({ column, value, onSelect,}: FilterValueSelectorProps<TData>) { const variant = column.columnDef.meta?.variant ?? FILTER_VARIANTS.TEXT
switch (variant) { case FILTER_VARIANTS.BOOLEAN: return ( <CommandGroup> <CommandItem value="true" onSelect={() => onSelect("true")}> True </CommandItem> <CommandItem value="false" onSelect={() => onSelect("false")}> False </CommandItem> </CommandGroup> )
case FILTER_VARIANTS.SELECT: case FILTER_VARIANTS.MULTI_SELECT: return ( <CommandGroup> {/* Cross-filter narrowing: hide options at count 0 (matches the rule used by `TableColumnFacetedFilterMenu` and the filter menu). Pure label-only option lists (no counts) render unchanged. */} {column.columnDef.meta?.options ?.filter((option: Option) => option.count !== 0) .map((option: Option) => ( <CommandItem key={option.value} value={option.value} onSelect={() => onSelect(option.value)} > {option.icon && <option.icon />} <span className="truncate">{option.label}</span> {option.count && ( <span className="ml-auto font-mono text-xs"> {option.count} </span> )} </CommandItem> ))} </CommandGroup> )
case FILTER_VARIANTS.DATE: case FILTER_VARIANTS.DATE_RANGE: return ( <Calendar mode="single" captionLayout="dropdown" selected={value ? new Date(value) : undefined} onSelect={date => onSelect(date?.getTime().toString() ?? "")} /> )
default: { const isEmpty = !value.trim()
return ( <CommandGroup> <CommandItem value={value} onSelect={() => onSelect(value)} disabled={isEmpty} > {isEmpty ? ( <> <Text /> <span>Type to add filter...</span> </> ) : ( <> <BadgeCheck /> <span className="truncate">Filter by "{value}"</span> </> )} </CommandItem> </CommandGroup> ) } }}FilterValueSelector.displayName = "FilterValueSelector"
function onFilterInputRender<TData extends RowData>({ filter, column, inputId, onFilterUpdate, showValueSelector, setShowValueSelector,}: { filter: ExtendedColumnFilter<TData> column: DataTableColumn<TData> inputId: string onFilterUpdate: ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => void showValueSelector: boolean setShowValueSelector: (value: boolean) => void}) { if ( filter.operator === FILTER_OPERATORS.EMPTY || filter.operator === FILTER_OPERATORS.NOT_EMPTY ) { return ( <div id={inputId} role="status" aria-label={`${column.columnDef.meta?.label} filter is ${ filter.operator === FILTER_OPERATORS.EMPTY ? "empty" : "not empty" }`} aria-live="polite" className="h-full w-16 rounded-none border bg-transparent px-1.5 py-0.5 text-muted-foreground dark:bg-input/30" /> ) }
switch (filter.variant) { case FILTER_VARIANTS.TEXT: case FILTER_VARIANTS.NUMBER: case FILTER_VARIANTS.RANGE: { if ( (filter.variant === FILTER_VARIANTS.RANGE && filter.operator === FILTER_OPERATORS.BETWEEN) || filter.operator === FILTER_OPERATORS.BETWEEN ) { return ( <TableRangeFilter filter={filter} column={column} inputId={inputId} onFilterUpdate={onFilterUpdate} className="size-full max-w-28 gap-0 **:data-[slot='range-min']:border-r-0 [&_input]:rounded-none [&_input]:px-1.5" /> ) }
const isNumber = filter.variant === FILTER_VARIANTS.NUMBER || filter.variant === FILTER_VARIANTS.RANGE
return ( <Input id={inputId} type={isNumber ? FILTER_VARIANTS.NUMBER : FILTER_VARIANTS.TEXT} inputMode={isNumber ? "numeric" : undefined} placeholder={column.columnDef.meta?.placeholder ?? "Enter value..."} className="h-full w-24 rounded-none px-1.5" value={typeof filter.value === "string" ? filter.value : ""} onChange={event => onFilterUpdate(filter.filterId, { value: event.target.value }) } /> ) }
case FILTER_VARIANTS.BOOLEAN: { const inputListboxId = `${inputId}-listbox`
return ( <Select open={showValueSelector} onOpenChange={setShowValueSelector} value={typeof filter.value === "string" ? filter.value : "true"} onValueChange={(value: string | null) => // Base UI selects pass null on clear; Radix never does value && onFilterUpdate(filter.filterId, { value }) } // Spread so it type-checks against Radix too: Base UI reads the // closed trigger's label only from `items`, Radix ignores the prop. {...{ items: dataTableConfig.booleanValues }} > <SelectTrigger id={inputId} aria-controls={inputListboxId} className="rounded-none bg-transparent px-1.5 py-0.5 [&_svg]:hidden" > <SelectValue placeholder={filter.value ? "True" : "False"} /> </SelectTrigger> <SelectContent id={inputListboxId}> {dataTableConfig.booleanValues.map(option => ( <SelectItem key={option.value} value={option.value}> {option.label} </SelectItem> ))} </SelectContent> </Select> ) }
case FILTER_VARIANTS.SELECT: case FILTER_VARIANTS.MULTI_SELECT: { const inputListboxId = `${inputId}-listbox`
const options = column.columnDef.meta?.options ?? [] const selectedValues = Array.isArray(filter.value) ? filter.value : [filter.value]
const selectedOptions = options.filter((option: Option) => selectedValues.includes(option.value), )
return ( <Popover open={showValueSelector} onOpenChange={setShowValueSelector}> <PopoverTrigger asChild> <Button id={inputId} aria-controls={inputListboxId} variant="ghost" size="sm" className="h-full min-w-16 rounded-none border px-1.5 font-normal dark:bg-input/30" > {selectedOptions.length === 0 ? ( filter.variant === FILTER_VARIANTS.MULTI_SELECT ? ( "Select options..." ) : ( "Select option..." ) ) : ( <> <div className="flex items-center -space-x-2 rtl:space-x-reverse"> {selectedOptions.map((selectedOption: Option) => selectedOption.icon ? ( <div key={selectedOption.value} className="rounded-full border bg-background p-0.5" > <selectedOption.icon className="size-3.5" /> </div> ) : null, )} </div> <span className="truncate"> {selectedOptions.length > 1 ? `${selectedOptions.length} selected` : selectedOptions[0]?.label} </span> </> )} </Button> </PopoverTrigger> <PopoverContent id={inputListboxId} align="start" className="w-48 origin-[var(--radix-popover-content-transform-origin,var(--transform-origin))] p-0" > <Command> <CommandInput placeholder="Search options..." /> <CommandList> <CommandEmpty>No options found.</CommandEmpty> <CommandGroup> {options.map((option: Option) => ( <CommandItem key={option.value} value={option.value} onSelect={() => { const value = filter.variant === FILTER_VARIANTS.MULTI_SELECT ? selectedValues.includes(option.value) ? selectedValues.filter(v => v !== option.value) : [...selectedValues, option.value] : option.value onFilterUpdate(filter.filterId, { value }) }} > {option.icon && <option.icon />} <span className="truncate">{option.label}</span> {filter.variant === FILTER_VARIANTS.MULTI_SELECT && ( <Check className={cn( "ml-auto", selectedValues.includes(option.value) ? "opacity-100" : "opacity-0", )} /> )} </CommandItem> ))} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> ) }
case FILTER_VARIANTS.DATE: case FILTER_VARIANTS.DATE_RANGE: { const inputListboxId = `${inputId}-listbox`
const dateValue = Array.isArray(filter.value) ? filter.value.filter(Boolean) : [filter.value, filter.value].filter(Boolean)
const displayValue = filter.operator === FILTER_OPERATORS.BETWEEN && dateValue.length === 2 ? `${formatDate(new Date(Number(dateValue[0])))} - ${formatDate( new Date(Number(dateValue[1])), )}` : dateValue[0] ? formatDate(new Date(Number(dateValue[0]))) : "Pick date..."
return ( <Popover open={showValueSelector} onOpenChange={setShowValueSelector}> <PopoverTrigger asChild> <Button id={inputId} aria-controls={inputListboxId} variant="ghost" size="sm" className={cn( "h-full rounded-none border px-1.5 font-normal dark:bg-input/30", !filter.value && "text-muted-foreground", )} > <CalendarIcon className="size-3.5" /> <span className="truncate">{displayValue}</span> </Button> </PopoverTrigger> <PopoverContent id={inputListboxId} align="start" className="w-auto origin-[var(--radix-popover-content-transform-origin,var(--transform-origin))] p-0" > {filter.operator === FILTER_OPERATORS.BETWEEN ? ( <Calendar mode={FILTER_VARIANTS.RANGE} captionLayout="dropdown" selected={ dateValue.length === 2 ? { from: new Date(Number(dateValue[0])), to: new Date(Number(dateValue[1])), } : { from: new Date(), to: new Date(), } } onSelect={date => { onFilterUpdate(filter.filterId, { value: date ? [ (date.from?.getTime() ?? "").toString(), (date.to?.getTime() ?? "").toString(), ] : [], }) }} /> ) : ( <Calendar mode="single" captionLayout="dropdown" selected={ dateValue[0] ? new Date(Number(dateValue[0])) : undefined } onSelect={date => { onFilterUpdate(filter.filterId, { value: (date?.getTime() ?? "").toString(), }) }} /> )} </PopoverContent> </Popover> ) }
default: return null }}"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry *//** * Table range filter component * @description A range filter component for DataTable that allows users to filter data based on numerical ranges. */
import * as React from "react"
import { Input } from "@/components/ui/input"import { cn } from "@/lib/utils"import type { DataTableColumn, ExtendedColumnFilter } from "../types"
import type { RowData } from "@tanstack/react-table"interface TableRangeFilterProps< TData extends RowData,> extends React.ComponentProps<"div"> { filter: ExtendedColumnFilter<TData> column: DataTableColumn<TData> inputId: string onFilterUpdate: ( filterId: string, updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>, ) => void}
export function TableRangeFilter<TData extends RowData>({ filter, column, inputId, onFilterUpdate, className, ...props}: TableRangeFilterProps<TData>) { const meta = column.columnDef.meta
// Capture faceted min/max as scalars so the memo refreshes on data change // (column ref alone is stable across faceted-row updates). const metaRange = column.columnDef.meta?.range const facetedValues = column.getFacetedMinMaxValues() const facetedMin = facetedValues?.[0] const facetedMax = facetedValues?.[1] const [min, max] = React.useMemo<[number, number]>(() => { if (Array.isArray(metaRange) && metaRange.length === 2) { const [a, b] = metaRange as [number, number] return [a, b] } if (facetedMin != null && facetedMax != null) { return [Number(facetedMin), Number(facetedMax)] } return [0, 100] }, [metaRange, facetedMin, facetedMax])
// Plain-string formatter — `<input type="number">` requires a parsable // value, so locale-formatted output (commas, NBSPs) breaks the input. const formatValue = React.useCallback( (value: string | number | undefined) => { if (value === undefined || value === "") return "" const numValue = Number(value) return Number.isNaN(numValue) ? "" : String(numValue) }, [], )
const value = React.useMemo(() => { if (Array.isArray(filter.value)) return filter.value.map(formatValue) return [formatValue(filter.value), ""] }, [filter.value, formatValue])
const onRangeValueChange = React.useCallback( (value: string | number, isMin?: boolean) => { const numValue = Number(value) const currentValues = Array.isArray(filter.value) ? filter.value : ["", ""] const otherValue = isMin ? (currentValues[1] ?? "") : (currentValues[0] ?? "")
if ( value === "" || (!Number.isNaN(numValue) && (isMin ? numValue >= min && numValue <= (Number(otherValue) || max) : numValue <= max && numValue >= (Number(otherValue) || min))) ) { onFilterUpdate(filter.filterId, { value: isMin ? [String(value), String(otherValue)] : [String(otherValue), String(value)], }) } }, [filter.filterId, filter.value, min, max, onFilterUpdate], )
return ( <div data-slot="range" className={cn("flex w-full items-center gap-2", className)} {...props} > <Input id={`${inputId}-min`} type="number" aria-label={`${meta?.label} minimum value`} aria-valuemin={min} aria-valuemax={max} data-slot="range-min" inputMode="numeric" placeholder={min.toString()} min={min} max={max} className="h-8 w-full rounded" defaultValue={value[0]} onChange={event => onRangeValueChange(String(event.target.value), true)} /> <span className="sr-only shrink-0 text-muted-foreground">to</span> <Input id={`${inputId}-max`} type="number" aria-label={`${meta?.label} maximum value`} aria-valuemin={min} aria-valuemax={max} data-slot="range-max" inputMode="numeric" placeholder={max.toString()} min={min} max={max} className="h-8 w-full rounded" defaultValue={value[1]} onChange={event => onRangeValueChange(String(event.target.value))} /> </div> )}Update the import paths to match your project setup.
DataTableSliderFilter:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import type { RowData } from "@tanstack/react-table"import * as React from "react"import { useDataTable } from "../core/data-table-context"import { TableSliderFilter, type TableSliderFilterProps,} from "../filters/table-slider-filter"import { useDerivedColumnTitle } from "../hooks/use-derived-column-title"import { FILTER_VARIANTS } from "../lib/constants"
type DataTableSliderFilterProps<TData extends RowData> = Omit< TableSliderFilterProps<TData>, "column" | "title"> & { /** * The accessor key of the column to filter (matches column definition) */ accessorKey: keyof TData & string /** * Optional title override (if not provided, will use column.meta.label) */ title?: string}
/** * A slider filter component that automatically connects to the DataTable context * and derives the title from column metadata. * * @example - Auto-detect everything from column metadata and data * const columns: DataTableColumnDef[] = [{ accessorKey: "price",..., meta: { label: "Price", unit: "$", range: [0, 1000] } },...] * <DataTableSliderFilter accessorKey="price" /> * * @example - Custom range shorthand with unit * <DataTableSliderFilter * accessorKey="price" * range={[0, 1000]} * unit="$" * /> * * @example - Individual min/max control * <DataTableSliderFilter * accessorKey="rating" * min={1} * max={5} * step={0.5} * /> * * @example - Full manual control with custom title * <DataTableSliderFilter * accessorKey="distance" * title="Distance Range" * range={[0, 100]} * step={5} * unit="km" * /> */
export function DataTableSliderFilter<TData extends RowData>({ accessorKey, title, ...props}: DataTableSliderFilterProps<TData>) { const { table } = useDataTable<TData>() const column = table.getColumn(accessorKey as string)
const derivedTitle = useDerivedColumnTitle(column, String(accessorKey), title)
// Auto-set variant in column meta if not already set // This allows the auto-filterFn to be applied based on variant React.useMemo(() => { if (!column) return const meta = (column.columnDef.meta ||= {}) // Only set variant if not already set (respects manual configuration) if (!meta.variant) { meta.variant = FILTER_VARIANTS.RANGE } }, [column])
// Early return if column not found if (!column) { console.warn( `Column with accessorKey "${accessorKey}" not found in table columns`, ) return null }
return <TableSliderFilter column={column} title={derivedTitle} {...props} />}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
DataTableSliderFilter.displayName = "DataTableSliderFilter""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry *//** * Table slider filter component * @description A slider filter component for DataTable that allows users to filter numerical data within a specified range. It supports manual configuration of range, min/max values, step size, and unit labels. */
import { PlusCircle, XCircle } from "lucide-react"import * as React from "react"import { Button } from "@/components/ui/button"import { Input } from "@/components/ui/input"import { Label } from "@/components/ui/label"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"import { Separator } from "@/components/ui/separator"import { Slider } from "@/components/ui/slider"import { cn } from "@/lib/utils"
import type { DataTableColumn } from "../types"import type { RowData } from "@tanstack/react-table"interface Range { min: number max: number}
type RangeValue = [number, number]
function getIsValidRange(value: unknown): value is RangeValue { return ( Array.isArray(value) && value.length === 2 && typeof value[0] === "number" && typeof value[1] === "number" )}
function parseValuesAsNumbers(value: unknown): RangeValue | undefined { if ( Array.isArray(value) && value.length === 2 && value.every( v => (typeof v === "string" || typeof v === "number") && !Number.isNaN(v), ) ) { return [Number(value[0]), Number(value[1])] }
return undefined}
export interface TableSliderFilterProps<TData extends RowData> { column: DataTableColumn<TData, unknown> title?: string /** * Manual range [min, max] (overrides min/max props and column.meta.range) */ range?: RangeValue /** * Manual minimum value (overrides column.meta.range and faceted values) */ min?: number /** * Manual maximum value (overrides column.meta.range and faceted values) */ max?: number /** * Manual step value for the slider */ step?: number /** * Unit label to display (e.g., "$", "kg", "km") */ unit?: string onValueChange?: (value: [number, number] | undefined) => void}
export function TableSliderFilter<TData extends RowData>({ column, title, range: manualRange, min: manualMin, max: manualMax, step: manualStep, unit: manualUnit, onValueChange,}: TableSliderFilterProps<TData>) { const id = React.useId()
const columnFilterValue = parseValuesAsNumbers(column.getFilterValue())
const defaultRange = column.columnDef.meta?.range const unit = manualUnit ?? column.columnDef.meta?.unit const label = title ?? column.columnDef.meta?.label ?? column.id
// Capture faceted min/max as scalars so the memo re-runs when filters/data change. const facetedValues = column.getFacetedMinMaxValues() const facetedMin = facetedValues?.[0] const facetedMax = facetedValues?.[1]
// Compute range values - memoized to avoid recalculation // This is safe because we're not triggering state updates, just reading values const { min, max, step } = React.useMemo<Range & { step: number }>(() => { let minValue = 0 let maxValue = 100
// Priority 1: Manual range prop (highest priority) if (manualRange && getIsValidRange(manualRange)) { minValue = manualRange[0] maxValue = manualRange[1] } // Priority 2: Manual min/max props else if (manualMin != null && manualMax != null) { minValue = manualMin maxValue = manualMax } // Priority 3: Use explicit range from column metadata else if (defaultRange && getIsValidRange(defaultRange)) { minValue = defaultRange[0] maxValue = defaultRange[1] } // Priority 4: Get min/max from faceted values // This is safe in useMemo as long as we're not calling setFilterValue else if (facetedMin != null && facetedMax != null) { minValue = Number(facetedMin) maxValue = Number(facetedMax) }
// Calculate appropriate step size based on range const rangeSize = maxValue - minValue const calculatedStep = rangeSize <= 20 ? 1 : rangeSize <= 100 ? Math.ceil(rangeSize / 20) : Math.ceil(rangeSize / 50)
return { min: minValue, max: maxValue, step: manualStep ?? calculatedStep, } }, [ defaultRange, manualRange, manualMin, manualMax, manualStep, facetedMin, facetedMax, ])
const range = React.useMemo((): RangeValue => { return columnFilterValue ?? [min, max] }, [columnFilterValue, min, max])
const formatValue = React.useCallback((value: number) => { return value.toLocaleString(undefined, { maximumFractionDigits: 0 }) }, [])
// Match count: rows that fall inside the currently-selected range, // sourced from the faceted row model so the number reflects "matches // before this filter is applied" within the current cross-filter // context. Renders inside the popover next to the label so the user // sees the hit count for the active range as they drag the slider. const facetedRows = column.getFacetedRowModel().rows const matchCount = React.useMemo(() => { const [filterMin, filterMax] = range let count = 0 for (const row of facetedRows) { const raw = row.getValue(column.id) const value = typeof raw === "number" ? raw : Number(raw) if (!Number.isFinite(value)) continue if (value >= filterMin && value <= filterMax) count += 1 } return count }, [range, facetedRows, column.id])
const applyFilterValue = React.useCallback( (value: [number, number] | undefined) => { column.setFilterValue(value) onValueChange?.(value) }, [column, onValueChange], )
const onRangeValueChange = React.useCallback( (value: string | number, isMin?: boolean) => { const numValue = Number(value) const currentValues = range
if (value === "") { // Allow empty value, don't update filter return }
if ( !Number.isNaN(numValue) && (isMin ? numValue >= min && numValue <= currentValues[1] : numValue <= max && numValue >= currentValues[0]) ) { applyFilterValue( isMin ? [numValue, currentValues[1]] : [currentValues[0], numValue], ) } }, [min, max, range, applyFilterValue], )
const onSliderValueChange = React.useCallback( // Radix sliders emit number[]; Base UI emits number | readonly number[] (value: RangeValue | number | readonly number[]) => { if (Array.isArray(value) && value.length === 2) { applyFilterValue([value[0], value[1]]) } }, [applyFilterValue], )
const onReset = React.useCallback( (event: React.MouseEvent) => { // Always stop the bubble — the previous DIV-only check let SVG/icon // clicks reach the popover trigger and re-open it on Clear. event.stopPropagation() applyFilterValue(undefined) }, [applyFilterValue], )
return ( <Popover> <PopoverTrigger asChild> <Button variant="outline" size="sm" className="border-dashed"> {columnFilterValue ? ( <div role="button" aria-label={`Clear ${label} filter`} tabIndex={0} className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none" onClick={onReset} > <XCircle /> </div> ) : ( <PlusCircle /> )} <span>{label}</span> {columnFilterValue ? ( <> <Separator orientation="vertical" className="mx-0.5 data-[orientation=vertical]:h-4" /> {formatValue(columnFilterValue[0])} -{" "} {formatValue(columnFilterValue[1])} {unit ? ` ${unit}` : ""} </> ) : null} </Button> </PopoverTrigger> <PopoverContent align="start" className="flex w-auto flex-col gap-4"> <div className="flex flex-col gap-3"> <div className="flex h-5 items-center justify-between gap-2"> <p className="leading-5 font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70"> {label} </p> <span className="inline-flex h-5 items-center justify-center rounded-sm bg-secondary px-1.5 text-xs leading-none font-normal text-secondary-foreground tabular-nums"> {matchCount} </span> </div> <div className="flex items-center gap-2"> <Label htmlFor={`${id}-from`} className="sr-only"> From </Label> <div className="relative flex-1"> <Input key={`${id}-from-${range[0]}`} id={`${id}-from`} type="number" aria-label={`${label} minimum value`} aria-valuemin={min} aria-valuemax={max} inputMode="numeric" pattern="[0-9]*" placeholder={min.toString()} min={min} max={max} defaultValue={range[0]} onChange={event => onRangeValueChange(String(event.target.value), true) } className={cn("h-8 w-full", unit && "pr-8")} /> {unit && ( <span className="absolute top-0 right-0 bottom-0 mt-0.5 mr-0.5 flex h-7 items-center rounded-r-md bg-accent px-2 text-sm text-muted-foreground"> {unit} </span> )} </div> <Label htmlFor={`${id}-to`} className="sr-only"> to </Label> <div className="relative flex-1"> <Input key={`${id}-to-${range[1]}`} id={`${id}-to`} type="number" aria-label={`${label} maximum value`} aria-valuemin={min} aria-valuemax={max} inputMode="numeric" pattern="[0-9]*" placeholder={max.toString()} min={min} max={max} defaultValue={range[1]} onChange={event => onRangeValueChange(String(event.target.value)) } className={cn("h-8 w-full", unit && "pr-8")} /> {unit && ( <span className="absolute top-0 right-0 bottom-0 mt-0.5 mr-0.5 flex h-7 items-center rounded-r-md bg-accent px-2 text-sm text-muted-foreground"> {unit} </span> )} </div> </div> <Label htmlFor={`${id}-slider`} className="sr-only"> {label} slider </Label> <Slider id={`${id}-slider`} min={min} max={max} step={step} value={range} onValueChange={onSliderValueChange} /> </div> <Button aria-label={`Clear ${label} filter`} variant="outline" size="sm" onClick={onReset} > Clear </Button> </PopoverContent> </Popover> )}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
TableSliderFilter.displayName = "TableSliderFilter"Update the import paths to match your project setup.
DataTableDateFilter:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import type { RowData } from "@tanstack/react-table"import { useDataTable } from "../core/data-table-context"import type { TableDateFilterProps } from "../filters/table-date-filter"import { TableDateFilter } from "../filters/table-date-filter"import { useDerivedColumnTitle } from "../hooks/use-derived-column-title"import { FILTER_VARIANTS } from "../lib/constants"
type DataTableDateFilterProps<TData extends RowData> = Omit< TableDateFilterProps<TData>, "column" | "title"> & { /** * The accessor key of the column to filter (matches column definition) */ accessorKey: keyof TData & string /** * Optional title override (if not provided, will use column.meta.label) */ title?: string}
/** * A date filter component that automatically connects to the DataTable context * and derives the title from column metadata. * * @example - Auto-detect everything from column metadata * const columns: DataTableColumnDef[] = [{ accessorKey: "releaseDate",..., meta: { label: "Release Date" } },...] * <DataTableDateFilter accessorKey="releaseDate" /> * * @example - Date range filter * <DataTableDateFilter * accessorKey="releaseDate" * multiple * /> * * @example - Custom title * <DataTableDateFilter * accessorKey="createdAt" * title="Created Date" * /> * * @example - Single date selection * <DataTableDateFilter * accessorKey="dueDate" * title="Due Date" * multiple={false} * /> */
export function DataTableDateFilter<TData extends RowData>({ accessorKey, title, multiple, trigger, ...props}: DataTableDateFilterProps<TData>) { const { table } = useDataTable<TData>() const column = table.getColumn(String(accessorKey))
const derivedTitle = useDerivedColumnTitle(column, String(accessorKey), title)
// Auto-set variant in column meta if not already set // This allows the auto-filterFn to be applied based on variant // Runs synchronously during render (not in an effect) so the variant is set // before TableDateFilter receives the column — avoids a deferred render cycle. if (column && !column.columnDef.meta?.variant) { const meta = (column.columnDef.meta ||= {}) type ColumnVariant = NonNullable<(typeof meta)["variant"]> meta.variant = ( multiple ? FILTER_VARIANTS.DATE_RANGE : FILTER_VARIANTS.DATE ) as ColumnVariant }
// Early return if column not found if (!column) { console.warn( `Column with accessorKey "${accessorKey}" not found in table columns`, ) return null }
return ( <TableDateFilter column={column} title={derivedTitle} multiple={multiple} trigger={trigger} {...props} /> )}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
DataTableDateFilter.displayName = "DataTableDateFilter""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
/** * A dropdown menu component that allows users to toggle the visibility of table columns. * It uses a popover to display a list of columns with checkboxes. * Users can search for columns and toggle their visibility. */
import { CalendarIcon, XCircle } from "lucide-react"import * as React from "react"import type { DateRange } from "react-day-picker"
import { Button } from "@/components/ui/button"import { Calendar } from "@/components/ui/calendar"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"import { Separator } from "@/components/ui/separator"import { formatDate } from "../lib/format"
import type { DataTableColumn } from "../types"import type { RowData } from "@tanstack/react-table"type DateSelection = Date[] | DateRange
function getIsDateRange(value: DateSelection): value is DateRange { return value && typeof value === "object" && !Array.isArray(value)}
function parseAsDate(timestamp: number | string | undefined): Date | undefined { if (!timestamp) return undefined const numericTimestamp = typeof timestamp === "string" ? Number(timestamp) : timestamp const date = new Date(numericTimestamp) return !Number.isNaN(date.getTime()) ? date : undefined}
function parseColumnFilterValue(value: unknown) { if (value === null || value === undefined) { return [] }
if (Array.isArray(value)) { return value.map(item => { if (typeof item === "number" || typeof item === "string") { return item } return undefined }) }
if (typeof value === "string" || typeof value === "number") { return [value] }
return []}
export interface TableDateFilterProps<TData extends RowData> { column: DataTableColumn<TData, unknown> title?: string multiple?: boolean trigger?: React.ReactNode}
export function TableDateFilter<TData extends RowData>({ column, title, multiple, trigger,}: TableDateFilterProps<TData>) { const columnFilterValue = column.getFilterValue()
const selectedDates = React.useMemo<DateSelection>(() => { if (!columnFilterValue) { return multiple ? { from: undefined, to: undefined } : [] }
if (multiple) { const timestamps = parseColumnFilterValue(columnFilterValue) return { from: parseAsDate(timestamps[0]), to: parseAsDate(timestamps[1]), } }
const timestamps = parseColumnFilterValue(columnFilterValue) const date = parseAsDate(timestamps[0]) return date ? [date] : [] }, [columnFilterValue, multiple])
const onSelect = React.useCallback( (date: Date | DateRange | undefined) => { if (!date) { column.setFilterValue(undefined) return }
if (multiple && !("getTime" in date)) { const from = date.from?.getTime() const to = date.to?.getTime() column.setFilterValue(from || to ? [from, to] : undefined) } else if (!multiple && "getTime" in date) { column.setFilterValue(date.getTime()) } }, [column, multiple], )
const onReset = React.useCallback( (event: React.MouseEvent) => { event.stopPropagation() column.setFilterValue(undefined) }, [column], )
const hasValue = React.useMemo(() => { if (multiple) { if (!getIsDateRange(selectedDates)) return false return selectedDates.from || selectedDates.to } if (!Array.isArray(selectedDates)) return false return selectedDates.length > 0 }, [multiple, selectedDates])
const formatDateRange = React.useCallback((range: DateRange) => { if (!range.from && !range.to) return "" if (range.from && range.to) { return `${formatDate(range.from)} - ${formatDate(range.to)}` } return formatDate(range.from ?? range.to) }, [])
const label = React.useMemo(() => { if (multiple) { if (!getIsDateRange(selectedDates)) return null
const hasSelectedDates = selectedDates.from || selectedDates.to const dateText = hasSelectedDates ? formatDateRange(selectedDates) : "Select date range"
return ( <span className="flex items-center gap-2"> <span>{title}</span> {hasSelectedDates && ( <> <Separator orientation="vertical" className="mx-0.5 data-[orientation=vertical]:h-4" /> <span>{dateText}</span> </> )} </span> ) }
if (getIsDateRange(selectedDates)) return null
const hasSelectedDate = selectedDates.length > 0 const dateText = hasSelectedDate ? formatDate(selectedDates[0]) : "Select date"
return ( <span className="flex items-center gap-2"> <span>{title}</span> {hasSelectedDate && ( <> <Separator orientation="vertical" className="mx-0.5 data-[orientation=vertical]:h-4" /> <span>{dateText}</span> </> )} </span> ) }, [selectedDates, multiple, formatDateRange, title])
return ( <Popover> <PopoverTrigger asChild> {trigger || ( <Button variant="outline" size="sm" className="h-8 border-dashed"> {hasValue ? ( <div role="button" aria-label={`Clear ${title} filter`} tabIndex={0} onClick={onReset} className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none" > <XCircle className="size-4" /> </div> ) : ( <CalendarIcon className="size-4" /> )} {label} </Button> )} </PopoverTrigger> <PopoverContent className="w-auto p-0" align="start"> {multiple ? ( <Calendar captionLayout="dropdown" mode="range" selected={ getIsDateRange(selectedDates) ? selectedDates : { from: undefined, to: undefined } } onSelect={onSelect} /> ) : ( <Calendar captionLayout="dropdown" mode="single" selected={ !getIsDateRange(selectedDates) ? selectedDates[0] : undefined } onSelect={onSelect} /> )} </PopoverContent> </Popover> )}
/** * @required displayName is required for auto feature detection * @see "feature-detection.ts" */
TableDateFilter.displayName = "TableDateFilter"Update the import paths to match your project setup.
Column Header Components
Section titled “Column Header Components”DataTableColumnSort:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import type { RowData } from "@tanstack/react-table"import React from "react"
import { TableColumnSortOptions, TableColumnSortMenu,} from "../filters/table-column-sort"import { useDataTable } from "../core/data-table-context"import { useColumnHeaderContext } from "./data-table-column-header"
/** * Sorting options for column header menu using context. */export function DataTableColumnSortOptions<TData extends RowData, TValue>( props: Omit< React.ComponentProps<typeof TableColumnSortOptions>, "column" | "table" >,) { const { column } = useColumnHeaderContext<TData, TValue>(true) const { table } = useDataTable<TData>() return <TableColumnSortOptions column={column} table={table} {...props} />}
DataTableColumnSortOptions.displayName = "DataTableColumnSortOptions"
/** * Sorting menu for column header using context. * * Standalone button variant for inline use outside dropdown menus. */export function DataTableColumnSortMenu<TData extends RowData, TValue>( props: Omit< React.ComponentProps<typeof TableColumnSortMenu>, "column" | "table" >,) { const { column } = useColumnHeaderContext<TData, TValue>(true) const { table } = useDataTable<TData>() return <TableColumnSortMenu column={column} table={table} {...props} />}
DataTableColumnSortMenu.displayName = "DataTableColumnSortMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import type { RowData } from "@tanstack/react-table"import { Check, CircleHelp } from "lucide-react"
import { Button } from "@/components/ui/button"import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"import { cn } from "@/lib/utils"import { useDataTable } from "../core/data-table-context"
import { SORT_ICONS, SORT_LABELS } from "../config/data-table"import type { SortIconVariant } from "../config/data-table"import { FILTER_VARIANTS } from "../lib/constants"import type { FilterVariant } from "../lib/constants"
import type { DataTableColumn, DataTableInstance } from "../types"/** * Sort options menu items for composition inside TableColumnActions. * * @example * ```tsx * <TableColumnActions> * <TableColumnSortOptions column={column} /> * </TableColumnActions> * ``` */export function TableColumnSortOptions<TData extends RowData, TValue>({ column, table: propTable, variant: propVariant, withSeparator = true,}: { column: DataTableColumn<TData, TValue> table?: DataTableInstance<TData> variant?: SortIconVariant /** Whether to render a separator before the options. @default true */ withSeparator?: boolean}) { const context = useDataTable<TData>() const table = propTable || context.table const sortState = column.getIsSorted()
const variant: FilterVariant = propVariant || column.columnDef.meta?.variant || FILTER_VARIANTS.TEXT
const icons = SORT_ICONS[variant] || SORT_ICONS[FILTER_VARIANTS.TEXT] const labels = SORT_LABELS[variant] || SORT_LABELS[FILTER_VARIANTS.TEXT]
const sortIndex = column.getSortIndex() const isMultiSort = table && table.state.sorting.length > 1 const showSortBadge = isMultiSort && sortIndex !== -1
/** * Use a ref for immediate synchronous access to shift key state. * React state updates are batched and async, which can cause race conditions * when the dropdown closes - the keyup event might reset state before * handleSort reads it. A ref provides synchronous access. */ const isShiftPressedRef = React.useRef(false) const [isShiftPressed, setIsShiftPressed] = React.useState(false)
React.useEffect(() => { const handleKey = (e: KeyboardEvent) => { if (e.key === "Shift") { const isDown = e.type === "keydown" isShiftPressedRef.current = isDown setIsShiftPressed(isDown) } } window.addEventListener("keydown", handleKey, { capture: true }) window.addEventListener("keyup", handleKey, { capture: true }) return () => { window.removeEventListener("keydown", handleKey, { capture: true }) window.removeEventListener("keyup", handleKey, { capture: true }) } }, [])
const handleSort = ( direction: "asc" | "desc" | false, e: React.MouseEvent, ) => { // Keyboard activation synthesizes a click whose `shiftKey` is false, so the // window-level ref is what carries Shift for Enter/Space; the event's own // flag covers a plain shift-click. const isMulti = isShiftPressedRef.current || isShiftPressed || e.shiftKey
if (direction === false) { column.clearSorting() } else { const isDesc = direction === "desc" const canMultiSort = column.getCanMultiSort()
/** * @see https://tanstack.com/table/v8/docs/guide/sorting#multi-sorting * When using toggleSorting explicitly, we must manually pass the multi-sort flag. */ column.toggleSorting(isDesc, canMultiSort ? isMulti : false) } }
return ( <> {withSeparator && <DropdownMenuSeparator />} <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <div className="flex items-center gap-2"> <span>Column Sort</span> {showSortBadge && ( <Tooltip> <TooltipTrigger asChild> <span className="flex size-4 cursor-help items-center justify-center rounded-full bg-primary text-[10px] font-medium text-primary-foreground"> {sortIndex + 1} </span> </TooltipTrigger> <TooltipContent side="right"> Sort priority (order in which columns are sorted) </TooltipContent> </Tooltip> )} </div> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> TIP: Hold 'shift' key to enable multi sort </TooltipContent> </Tooltip> </DropdownMenuLabel> <DropdownMenuItem onClick={e => handleSort("asc", e)} className={cn( "flex items-center", sortState === "asc" && "bg-accent text-accent-foreground", )} > <icons.asc className="mr-2 size-4 text-muted-foreground/70" /> <span className="flex-1">{labels.asc}</span> {sortState === "asc" && <Check className="ml-2 size-4" />} </DropdownMenuItem> <DropdownMenuItem onClick={e => handleSort("desc", e)} className={cn( "flex items-center", sortState === "desc" && "bg-accent text-accent-foreground", )} > <icons.desc className="mr-2 size-4 text-muted-foreground/70" /> <span className="flex-1">{labels.desc}</span> {sortState === "desc" && <Check className="ml-2 size-4" />} </DropdownMenuItem> {sortState && ( <DropdownMenuItem onClick={() => column.clearSorting()}> <icons.unsorted className="mr-2 size-4 text-muted-foreground/70" /> Clear Sort </DropdownMenuItem> )} </> )}
/** * Standalone dropdown menu for sorting. * * For composition inside TableColumnActions, use TableColumnSortOptions instead. * * @example * ```tsx * // Standalone * <TableColumnSortMenu column={column} table={table} /> * * // Composed * <TableColumnActions> * <TableColumnSortOptions column={column} /> * </TableColumnActions> * ``` */export function TableColumnSortMenu<TData extends RowData, TValue>({ column, table: propTable, variant: propVariant, className,}: { column: DataTableColumn<TData, TValue> table?: DataTableInstance<TData> variant?: SortIconVariant className?: string}) { const context = useDataTable<TData>() const table = propTable || context.table const canSort = column.getCanSort() const sortState = column.getIsSorted()
const variant: FilterVariant = propVariant || column.columnDef.meta?.variant || FILTER_VARIANTS.TEXT
const icons = SORT_ICONS[variant] || SORT_ICONS[FILTER_VARIANTS.TEXT]
if (!canSort) return null
const SortIcon = sortState === "asc" ? icons.asc : sortState === "desc" ? icons.desc : icons.unsorted
const sortIndex = column.getSortIndex() const isMultiSort = table && table.state.sorting.length > 1 const showSortBadge = isMultiSort && sortIndex !== -1
return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon" className={cn( "size-7 transition-opacity dark:text-muted-foreground", sortState && "text-primary", className, )} > <div className="relative flex items-center justify-center"> <SortIcon className="size-4" /> {showSortBadge && ( <span className="absolute -top-1 -right-2 flex size-3 items-center justify-center rounded-full bg-primary text-[9px] text-primary-foreground"> {sortIndex + 1} </span> )} </div> <span className="sr-only">Sort column</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-48"> <TableColumnSortOptions column={column} table={table} variant={variant} withSeparator={false} /> </DropdownMenuContent> </DropdownMenu> )}
TableColumnSortOptions.displayName = "TableColumnSortOptions"TableColumnSortMenu.displayName = "TableColumnSortMenu"Update the import paths to match your project setup.
DataTableColumnHide:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import type { RowData } from "@tanstack/react-table"import React from "react"
import { TableColumnHideOptions, TableColumnHideMenu,} from "../filters/table-column-hide"import { useColumnHeaderContext } from "./data-table-column-header"
/** * Hide options for column header menu using context. */export function DataTableColumnHideOptions<TData extends RowData, TValue>( props: Omit<React.ComponentProps<typeof TableColumnHideOptions>, "column">,) { const { column } = useColumnHeaderContext<TData, TValue>(true) return <TableColumnHideOptions column={column} {...props} />}
DataTableColumnHideOptions.displayName = "DataTableColumnHideOptions"
/** * Standalone hide menu for column header using context. */export function DataTableColumnHideMenu<TData extends RowData, TValue>( props: Omit<React.ComponentProps<typeof TableColumnHideMenu>, "column">,) { const { column } = useColumnHeaderContext<TData, TValue>(true) return <TableColumnHideMenu column={column} {...props} />}
DataTableColumnHideMenu.displayName = "DataTableColumnHideMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import type { RowData } from "@tanstack/react-table"import { CircleHelp, EyeOff } from "lucide-react"
import { Button } from "@/components/ui/button"import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"import { cn } from "@/lib/utils"
import type { DataTableColumn } from "../types"/** * Dropdown menu item for hiding a column. * Use inside a DropdownMenuContent or as a child of TableColumnActions. * * @example * ```tsx * // Inside TableColumnActions * <TableColumnActions column={column}> * <TableColumnHideOptions column={column} /> * </TableColumnActions> * ``` */export function TableColumnHideOptions<TData extends RowData, TValue>({ column, withSeparator = true,}: { column: DataTableColumn<TData, TValue> /** Whether to render a separator before the option. Defaults to true. */ withSeparator?: boolean}) { const canHide = column.getCanHide()
if (!canHide) return null
return ( <> {withSeparator && <DropdownMenuSeparator />} <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <span>Column Hide</span> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> Hide this column from view </TooltipContent> </Tooltip> </DropdownMenuLabel> <DropdownMenuItem onClick={() => column.toggleVisibility(false)}> <EyeOff className="mr-2 size-4 text-muted-foreground/70" /> Hide Column </DropdownMenuItem> </> )}
/** * Standalone dropdown menu for hiding a column. * Shows a hide button that opens a dropdown. * * @example * ```tsx * // Standalone usage * <TableColumnHideMenu column={column} /> * ``` */export function TableColumnHideMenu<TData extends RowData, TValue>({ column, className,}: { column: DataTableColumn<TData, TValue> className?: string}) { const canHide = column.getCanHide()
if (!canHide) return null
return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon" className={cn( "size-7 transition-opacity group-hover:opacity-100 dark:text-muted-foreground", !column.getIsVisible() ? "text-primary opacity-100" : "opacity-0", className, )} > <EyeOff className="size-4" /> <span className="sr-only">Hide column</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-48"> <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <span>Column Hide</span> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> Hide this column from view </TooltipContent> </Tooltip> </DropdownMenuLabel> <DropdownMenuItem onClick={() => column.toggleVisibility(false)}> <EyeOff className="mr-2 size-4 text-muted-foreground/70" /> Hide Column </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> )}
/** @deprecated Use `TableColumnHideMenu` instead */export const TableColumnHide = TableColumnHideMenu
TableColumnHideOptions.displayName = "TableColumnHideOptions"TableColumnHideMenu.displayName = "TableColumnHideMenu"Update the import paths to match your project setup.
DataTableColumnPin:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import type { RowData } from "@tanstack/react-table"import React from "react"
import { TableColumnPinOptions, TableColumnPinMenu,} from "../filters/table-column-pin"import { useColumnHeaderContext } from "./data-table-column-header"
/** * Pinning options for column header menu using context. */export function DataTableColumnPinOptions<TData extends RowData, TValue>( props: Omit<React.ComponentProps<typeof TableColumnPinOptions>, "column">,) { const { column } = useColumnHeaderContext<TData, TValue>(true) return <TableColumnPinOptions column={column} {...props} />}
DataTableColumnPinOptions.displayName = "DataTableColumnPinOptions"
/** * Standalone pinning menu for column header using context. */export function DataTableColumnPinMenu<TData extends RowData, TValue>( props: Omit<React.ComponentProps<typeof TableColumnPinMenu>, "column">,) { const { column } = useColumnHeaderContext<TData, TValue>(true) return <TableColumnPinMenu column={column} {...props} />}
DataTableColumnPinMenu.displayName = "DataTableColumnPinMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import type { RowData } from "@tanstack/react-table"import { Check, CircleHelp, Pin, PinOff } from "lucide-react"
import { Button } from "@/components/ui/button"import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"import { cn } from "@/lib/utils"
import type { DataTableColumn } from "../types"/** * Dropdown menu items for pinning a column. * Use inside a DropdownMenuContent or as a child of TableColumnActions. * * @example * ```tsx * // Inside TableColumnActions * <TableColumnActions column={column}> * <TableColumnPinOptions column={column} /> * </TableColumnActions> * ``` */export function TableColumnPinOptions<TData extends RowData, TValue>({ column, withSeparator = true,}: { column: DataTableColumn<TData, TValue> /** Whether to render a separator before the options. Defaults to true. */ withSeparator?: boolean}) { const canPin = column.getCanPin() const isPinned = column.getIsPinned()
if (!canPin) return null
return ( <> {withSeparator && <DropdownMenuSeparator />} <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <span>Column Pin</span> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> Pin column to left or right side </TooltipContent> </Tooltip> </DropdownMenuLabel> <DropdownMenuItem onClick={() => column.pin("start")} className={cn( "flex items-center", isPinned === "start" && "bg-accent text-accent-foreground", )} > <Pin className="mr-2 size-4 -rotate-45" /> <span className="flex-1">Pin to Left</span> {isPinned === "start" && <Check className="ml-2 size-4" />} </DropdownMenuItem> <DropdownMenuItem onClick={() => column.pin("end")} className={cn( "flex items-center", isPinned === "end" && "bg-accent text-accent-foreground", )} > <Pin className="mr-2 size-4 rotate-45" /> <span className="flex-1">Pin to Right</span> {isPinned === "end" && <Check className="ml-2 size-4" />} </DropdownMenuItem> {isPinned && ( <DropdownMenuItem onClick={() => column.pin(false)} className="flex items-center" > <PinOff className="mr-2 size-4" /> <span className="flex-1">Unpin</span> </DropdownMenuItem> )} </> )}
/** * Standalone dropdown menu for pinning a column. * Shows a pin button that opens a dropdown with pin options. * * @example * ```tsx * // Standalone usage * <TableColumnPinMenu column={column} /> * ``` */export function TableColumnPinMenu<TData extends RowData, TValue>({ column, className,}: { column: DataTableColumn<TData, TValue> className?: string}) { const canPin = column.getCanPin() const isPinned = column.getIsPinned()
if (!canPin) return null
return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon" className={cn( "size-7 transition-opacity group-hover:opacity-100 dark:text-muted-foreground", isPinned ? "text-primary opacity-100" : "opacity-0", className, )} > <Pin className="size-4" /> <span className="sr-only">Pin column</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-48"> <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <span>Column Pin</span> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> Pin column to left or right side </TooltipContent> </Tooltip> </DropdownMenuLabel> <DropdownMenuItem onClick={() => column.pin("start")} className={cn( "flex items-center", isPinned === "start" && "bg-accent text-accent-foreground", )} > <Pin className="mr-2 size-4 -rotate-45" /> <span className="flex-1">Pin to Left</span> {isPinned === "start" && <Check className="ml-2 size-4" />} </DropdownMenuItem> <DropdownMenuItem onClick={() => column.pin("end")} className={cn( "flex items-center", isPinned === "end" && "bg-accent text-accent-foreground", )} > <Pin className="mr-2 size-4 rotate-45" /> <span className="flex-1">Pin to Right</span> {isPinned === "end" && <Check className="ml-2 size-4" />} </DropdownMenuItem> <DropdownMenuItem onClick={() => column.pin(false)} className="flex items-center" > <PinOff className="mr-2 size-4" /> <span className="flex-1">Unpin</span> </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> )}
/** @deprecated Use `TableColumnPinMenu` instead */export const TableColumnPin = TableColumnPinMenu
TableColumnPinOptions.displayName = "TableColumnPinOptions"TableColumnPinMenu.displayName = "TableColumnPinMenu"Update the import paths to match your project setup.
DataTableColumnGroup:
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.
/** * 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 { RowData } from "@tanstack/react-table"import React from "react"
import { TableColumnGroupOptions, TableColumnGroupMenu,} from "../filters/table-column-group"import { useColumnHeaderContext } from "./data-table-column-header"
/** * Grouping options for column header menu using context. */export function DataTableColumnGroupOptions<TData extends RowData, TValue>( props: Omit<React.ComponentProps<typeof TableColumnGroupOptions>, "column">,) { const { column } = useColumnHeaderContext<TData, TValue>(true) return <TableColumnGroupOptions column={column} {...props} />}
DataTableColumnGroupOptions.displayName = "DataTableColumnGroupOptions"
/** * Standalone grouping menu for column header using context. */export function DataTableColumnGroupMenu<TData extends RowData, TValue>( props: Omit<React.ComponentProps<typeof TableColumnGroupMenu>, "column">,) { const { column } = useColumnHeaderContext<TData, TValue>(true) return <TableColumnGroupMenu column={column} {...props} />}
DataTableColumnGroupMenu.displayName = "DataTableColumnGroupMenu"/** * 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 { RowData } from "@tanstack/react-table"import { CircleHelp, Group, Ungroup } from "lucide-react"
import { Button } from "@/components/ui/button"import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger,} from "@/components/ui/dropdown-menu"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"import { cn } from "@/lib/utils"
import type { DataTableColumn } from "../types"/** * Dropdown menu items for grouping rows by a column. * Use inside a DropdownMenuContent or as a child of TableColumnActions. * * @example * ```tsx * <TableColumnActions> * <TableColumnGroupOptions column={column} /> * </TableColumnActions> * ``` */export function TableColumnGroupOptions<TData extends RowData, TValue>({ column, withSeparator = true,}: { column: DataTableColumn<TData, TValue> /** Whether to render a separator before the options. Defaults to true. */ withSeparator?: boolean}) { const canGroup = column.getCanGroup() const isGrouped = column.getIsGrouped()
if (!canGroup) return null
return ( <> {withSeparator && <DropdownMenuSeparator />} <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <span>Column Group</span> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> Group rows by this column's values. Nested groups follow the order you group columns. </TooltipContent> </Tooltip> </DropdownMenuLabel> {isGrouped ? ( <DropdownMenuItem onClick={() => column.toggleGrouping()} className="flex items-center" > <Ungroup className="mr-2 size-4" /> <span className="flex-1">Stop grouping by</span> </DropdownMenuItem> ) : ( <DropdownMenuItem onClick={() => column.toggleGrouping()} className="flex items-center" > <Group className="mr-2 size-4" /> <span className="flex-1">Group by</span> </DropdownMenuItem> )} </> )}
/** * Standalone dropdown menu for grouping a column. * * @example * ```tsx * <TableColumnGroupMenu column={column} /> * ``` */export function TableColumnGroupMenu<TData extends RowData, TValue>({ column, className,}: { column: DataTableColumn<TData, TValue> className?: string}) { const canGroup = column.getCanGroup() const isGrouped = column.getIsGrouped()
if (!canGroup) return null
return ( <DropdownMenu> <DropdownMenuTrigger asChild> <Button variant="ghost" size="icon" className={cn( "size-7 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 dark:text-muted-foreground", isGrouped ? "text-primary opacity-100" : "opacity-0", className, )} > <Group className="size-4" /> <span className="sr-only">Group column</span> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-48"> <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <span>Column Group</span> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> Group rows by this column's values. Nested groups follow the order you group columns. </TooltipContent> </Tooltip> </DropdownMenuLabel> {isGrouped ? ( <DropdownMenuItem onClick={() => column.toggleGrouping()} className="flex items-center" > <Ungroup className="mr-2 size-4" /> <span className="flex-1">Stop grouping by</span> </DropdownMenuItem> ) : ( <DropdownMenuItem onClick={() => column.toggleGrouping()} className="flex items-center" > <Group className="mr-2 size-4" /> <span className="flex-1">Group by</span> </DropdownMenuItem> )} </DropdownMenuContent> </DropdownMenu> )}
TableColumnGroupOptions.displayName = "TableColumnGroupOptions"TableColumnGroupMenu.displayName = "TableColumnGroupMenu"Update the import paths to match your project setup.
DataTableColumnResize:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Copy and paste the following code into your project.
"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 */
/** * Opt-in column resizing — mix-and-match, like every other niko-table feature. * DROP THIS anywhere inside `<DataTableRoot>` and columns become drag-resizable * (grip on each header's right edge; double-click a grip to autosize to * content). Leave it out and nothing about the table changes. * * It renders nothing — its presence is detected from the children tree (by * `displayName`, same mechanism as the view/filter menus) and flips the table's * `enableColumnResizing`. Columns opt OUT individually with `enableResizing: * false` on their column def (e.g. a row-number gutter). * * Works with both `DataTableHeader` / `DataTableBody` and the virtualized * header/body variants. The grip UI ships with the data-table core; this * marker only flips the feature flag. * * @example * <DataTableRoot data={data} columns={columns}> * <DataTableColumnResize /> * <DataTable> * <DataTableHeader /> * <DataTableBody /> * </DataTable> * </DataTableRoot> */export function DataTableColumnResize() { return null}
DataTableColumnResize.displayName = "DataTableColumnResize"Update the import paths to match your project setup.
DataTableColumnAutoFit:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Copy and paste the following code into your project.
"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 { useColumnAutoFit } from "../lib/use-column-auto-fit"
/** * Opt-in column auto-fit — mix-and-match, like every other niko-table feature. * Pairs with column resizing (`<DataTableColumnResize />`). DROP THIS anywhere * inside `<DataTableRoot>` and, on load, the resizable columns scale up by an * EQUAL SHARE of the leftover space. Leave it out and this whole feature (and * its code) never ships — the default single-flex-column fill applies instead * (the first non-pinned data column absorbs the surplus; see `meta.flex`). * * Self-contained: it runs the auto-fit logic itself off the table + scroll * container from context, so the body structures carry no auto-fit code. It * renders nothing. Auto-fit re-fits when the container grows and backs off once * the user resizes a column or a saved `columnSizing` is restored, so manual * and persisted widths win. No-ops unless resizing is enabled. * * @example * <DataTableRoot data={data} columns={columns}> * <DataTableColumnResize /> * <DataTableColumnAutoFit /> * <DataTable> * <DataTableHeader /> * <DataTableBody /> * </DataTable> * </DataTableRoot> */export function DataTableColumnAutoFit() { const { table, scrollContainer } = useDataTable() const enabled = table.options.enableColumnResizing ?? false useColumnAutoFit(table, scrollContainer, enabled) return null}
DataTableColumnAutoFit.displayName = "DataTableColumnAutoFit""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 */
/** * Fill the container with resizable columns. * * When column resizing is enabled, cells render at `column.getSize()` (a fixed * pixel width) instead of the flex-fill layout the non-resizable table uses. * If the columns' natural sizes don't add up to the scroll container's width, * that leaves dead space on the right. This hook removes it: on load (and when * the container grows or columns are toggled) it grows the RESIZABLE columns by * an equal share of the leftover space so they fill the available width (wide * and narrow columns end up closer), seeding `columnSizing` so `getSize()` — * the source of truth for both rendering and drag math — stays consistent. * * Rules: * - Only resizable columns are scaled; fixed utility columns (selection, * actions, gutters — `enableResizing: false`) keep their size, and their * width is subtracted from the space the resizable columns fill. * - Once the user manually resizes any column — drag, keyboard nudge, or * double-click autosize — auto-fit stops for the life of the mount, so their * widths are never overwritten. * - When the columns already meet or exceed the container width, it does * nothing and horizontal scrolling takes over. * - With width PERSISTENCE wired in, the fitted widths are saved like any * other `columnSizing` change, and on later visits they count as restored * sizing — so auto-fit runs once per user, not once per session. That's the * deliberate trade-off of "restored widths always win": the user keeps a * stable layout, at the cost of not re-fitting when their viewport grows. * * Idempotent: after a fit the columns sum to the container width, so the next * run finds no surplus and makes no change. */import type { RowData } from "@tanstack/react-table"import type { DataTableInstance } from "../types"import * as React from "react"
import { DEFAULT_MAX_COLUMN_SIZE, DEFAULT_MIN_COLUMN_SIZE } from "./constants"
export function useColumnAutoFit<TData extends RowData>( table: DataTableInstance<TData>, scrollElement: HTMLElement | null, enabled: boolean,): void { // A manual resize permanently exits auto-fit for this mount — otherwise a // container resize would clobber the width the user just set. Drags latch // here via `isResizingColumn`; keyboard nudges, double-click autosize, and // consumer writes don't go through the drag handler, so they latch via the // `lastFitSizingRef` comparison in the fit effect below. const userResizedRef = React.useRef(false) const isResizingColumn = table.state.columnResizing.isResizingColumn React.useEffect(() => { if (isResizingColumn) userResizedRef.current = true }, [isResizingColumn])
// JSON of the sizing this hook last applied. If `columnSizing` is ever // non-empty and NOT what the hook wrote, something else resized a column // (keyboard nudge, double-click autosize, a consumer `setColumnSizing`) — // treat it like a manual drag, or the next fit would redistribute the very // space the user just removed on purpose. const lastFitSizingRef = React.useRef<string | null>(null)
// Sizes already present (restored from persistence, or provided by the // consumer) are respected as-is — auto-fit only fills the unsized first-load // case, so it never overwrites a saved column layout. Captured lazily on the // first *measured* pass (see below), NOT at mount: on a full page load the // first render is SSR/hydration where `columnSizing` is momentarily empty // (localStorage-backed widths arrive a tick later), and latching `false` there // would let auto-fit clobber the persisted widths. `null` = not yet captured. const hadInitialSizingRef = React.useRef<boolean | null>(null)
// Reactively track the container's inner width so a fit re-runs when the // available space changes (window resize, sidebar collapse, panel dock). const [containerWidth, setContainerWidth] = React.useState(0) React.useLayoutEffect(() => { if (!scrollElement) return const measure = () => setContainerWidth(scrollElement.clientWidth) measure() const observer = new ResizeObserver(measure) observer.observe(scrollElement) return () => observer.disconnect() }, [scrollElement])
// Re-derive on any layout-affecting state change. `columnSizing` is included // so the effect converges (after a fit it re-runs, finds no surplus, stops). const { columnSizing, columnVisibility, columnOrder } = table.state
React.useLayoutEffect(() => { // The width guard is load-bearing for the persistence race, not just a // no-op skip: persisted widths land (via the consumer's storage // subscription) at least one commit BEFORE the ResizeObserver's first // measurement can set `containerWidth`, so gating the capture below behind // a real measurement guarantees restored sizing is visible when it runs. // Capturing earlier (or measuring synchronously in render) would reopen // the reload clobber this ordering prevents. if (!enabled || containerWidth <= 0) return
// Capture whether the consumer restored widths on the first measured pass — // by now hydration has settled and localStorage-backed `columnSizing` is in. if (hadInitialSizingRef.current === null) { hadInitialSizingRef.current = Object.keys(table.state.columnSizing).length > 0 } if (userResizedRef.current || hadInitialSizingRef.current) return
// Sizing that this hook didn't write means a manual resize happened // through a path that bypasses `isResizingColumn` (keyboard, autosize, // consumer write) — latch and stop fitting for this mount. if ( Object.keys(columnSizing).length > 0 && JSON.stringify(columnSizing) !== lastFitSizingRef.current ) { userResizedRef.current = true return }
const leafColumns = table.getVisibleLeafColumns() const resizable = leafColumns.filter(c => c.getCanResize()) if (resizable.length === 0) return
const fixedTotal = leafColumns .filter(c => !c.getCanResize()) .reduce((sum, c) => sum + c.getSize(), 0) const resizableTotal = resizable.reduce((sum, c) => sum + c.getSize(), 0) const available = containerWidth - fixedTotal
// Already fills or overflows (allow 1px for subpixel rounding) — let the // horizontal scrollbar handle it rather than shrinking columns to fit. if (resizableTotal <= 0 || resizableTotal >= available - 1) return
// Split the leftover space evenly across the resizable columns (rather than // scaling proportionally), so wide and narrow columns end up closer. const perColumn = (available - resizableTotal) / resizable.length const next: Record<string, number> = {} let changed = false for (const column of resizable) { const min = column.columnDef.minSize ?? DEFAULT_MIN_COLUMN_SIZE const max = column.columnDef.maxSize ?? DEFAULT_MAX_COLUMN_SIZE const size = Math.round( Math.min(Math.max(column.getSize() + perColumn, min), max), ) next[column.id] = size if (size !== column.getSize()) changed = true } if (!changed) return
// Record what we're about to write so the external-change check above can // tell hook-applied sizing apart from a user's keyboard/autosize resize. const merged = { ...columnSizing, ...next } lastFitSizingRef.current = JSON.stringify(merged) table.setColumnSizing(() => merged) // `table` identity is stable; state slices below drive re-fitting. // eslint-disable-next-line react-hooks/exhaustive-deps }, [enabled, containerWidth, columnSizing, columnVisibility, columnOrder])}Update the import paths to match your project setup.
useColumnSizingPersistence (persist column widths):
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Copy and paste the following code into your project.
"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 */
/** * Opt-in `localStorage` persistence for column widths. * * Column resizing is controlled state (`columnSizing`) on `<DataTableRoot>`, so * to make resized widths survive reloads you own that state. This hook does it * for you: wire the returned `columnSizing` + `onColumnSizingChange` into the * table and you're done. * * @example * const { columnSizing, onColumnSizingChange } = * useColumnSizingPersistence(`orders-table:${orgId}`) * * <DataTableRoot * data={rows} * columns={columns} * state={{ columnSizing }} * onColumnSizingChange={onColumnSizingChange} * > * <DataTable> * <DataTableHeader /> * <DataTableBody /> * </DataTable> * <DataTableColumnResize /> * </DataTableRoot> * * Notes: * - Writes immediately. Resizing runs in `onEnd` mode, so this fires once per * drag (not per frame), and an immediate write can't be lost on a hard * refresh the way a debounced one can. * - Syncs across tabs. Widths are a per-user preference, so resizing (or * resetting) the same table in one tab updates the others via the native * `storage` event. Only same-key changes are read, and an unchanged read * keeps state identity, so idle tabs don't re-render. * - SSR note: `localStorage` is only read on the client, so nothing crashes on * the server — but in an SSR framework the client's first (hydration) render * already has the stored widths while the server HTML was rendered with * `{}`, so React may log a hydration-mismatch warning for the width styles * and reconcile. Cosmetic, but if the warning matters to you, load the * widths in an effect instead (accepting a default-width first paint). * - Corrupt / hand-edited storage is ignored (non-object, or non-finite * widths) so a bad value never reaches `column.getSize()`. * - Scope the `storageKey` per table (and per tenant/user if widths shouldn't * be shared), e.g. `"orders-table:${orgId}"`. */import type { ColumnSizingState, OnChangeFn } from "@tanstack/react-table"import * as React from "react"
/** Coerce untrusted storage into a valid `{ [columnId]: number }` map. */function sanitize(value: unknown): ColumnSizingState { if (!value || typeof value !== "object" || Array.isArray(value)) return {} const result: ColumnSizingState = {} for (const [id, width] of Object.entries(value)) { if (typeof width === "number" && Number.isFinite(width) && width > 0) { result[id] = width } } return result}
/** Shallow content equality, so an identical re-read keeps state identity. */function sizingEqual(a: ColumnSizingState, b: ColumnSizingState): boolean { const aKeys = Object.keys(a) if (aKeys.length !== Object.keys(b).length) return false return aKeys.every(id => a[id] === b[id])}
function readStored(key: string): ColumnSizingState { if (typeof window === "undefined") return {} try { const raw = window.localStorage.getItem(key) return raw ? sanitize(JSON.parse(raw)) : {} } catch { return {} }}
function writeStored(key: string, value: ColumnSizingState): void { if (typeof window === "undefined") return try { window.localStorage.setItem(key, JSON.stringify(value)) } catch { // Quota / private mode — silently skip persistence. }}
export interface ColumnSizingPersistence { /** Controlled widths to pass to `<DataTableRoot state={{ columnSizing }} />`. */ columnSizing: ColumnSizingState /** Pass to `<DataTableRoot onColumnSizingChange={...} />`. */ onColumnSizingChange: OnChangeFn<ColumnSizingState> /** Clear the stored widths and reset every column to its declared size. */ resetColumnSizing: () => void}
export function useColumnSizingPersistence( storageKey: string,): ColumnSizingPersistence { const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>( () => readStored(storageKey), )
// Kept current by the setters below, so they read the latest widths without a // render-time ref write and never re-create on unrelated renders. const columnSizingRef = React.useRef(columnSizing)
// Pull whatever is in storage into state. Every sync goes through the // equality check, so an unchanged read keeps the previous identity: no wasted // re-render and no churn for consumers keyed on `columnSizing` (e.g. // auto-fit). A removed key reads as `{}` and clears the widths. React.useEffect(() => { const sync = () => { const next = readStored(storageKey) if (sizingEqual(columnSizingRef.current, next)) return columnSizingRef.current = next setColumnSizing(next) }
// Re-load when the key changes (e.g. switching tenant / scope). The run on // mount is normally a no-op — the initializer already read the same key. sync()
if (typeof window === "undefined") return
// Cross-tab sync: keep widths in step when the same table is open in // another tab. The native `storage` event only fires in OTHER tabs, so this // tab's own writes never echo back — no self-write guard needed. const onStorage = (event: StorageEvent) => { // Ignore sessionStorage and unrelated keys. `key === null` means // `localStorage.clear()`, which must re-read (and clear) like any reset. if (event.storageArea && event.storageArea !== window.localStorage) return if (event.key !== null && event.key !== storageKey) return sync() } window.addEventListener("storage", onStorage) return () => window.removeEventListener("storage", onStorage) }, [storageKey])
const onColumnSizingChange = React.useCallback<OnChangeFn<ColumnSizingState>>( updater => { const next = typeof updater === "function" ? updater(columnSizingRef.current) : updater columnSizingRef.current = next setColumnSizing(next) writeStored(storageKey, next) }, [storageKey], )
const resetColumnSizing = React.useCallback(() => { columnSizingRef.current = {} setColumnSizing({}) if (typeof window !== "undefined") { try { window.localStorage.removeItem(storageKey) } catch { // Ignore. } } }, [storageKey])
return { columnSizing, onColumnSizingChange, resetColumnSizing }}Update the import paths to match your project setup.
DataTableColumnFacetedFilter:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"
import { TableColumnFacetedFilterOptions, TableColumnFacetedFilterMenu,} from "../filters/table-column-faceted-filter"import { useDataTable } from "../core/data-table-context"import { useColumnHeaderContext } from "./data-table-column-header"
import type { DataTableColumn } from "../types"import type { RowData } from "@tanstack/react-table"/** * Faceted filter options for composing inside DataTableColumnActions using context. */export function DataTableColumnFacetedFilterOptions< TData extends RowData, TValue,>( props: Omit< React.ComponentProps<typeof TableColumnFacetedFilterOptions>, "column" > & { column?: DataTableColumn<TData, TValue> },) { const context = useColumnHeaderContext<TData, TValue>(false) const column = props.column || context?.column
if (!column) { console.warn( "DataTableColumnFacetedFilterOptions must be used within DataTableColumnHeaderRoot or provided with a column prop", ) return null }
return <TableColumnFacetedFilterOptions column={column} {...props} />}
DataTableColumnFacetedFilterOptions.displayName = "DataTableColumnFacetedFilterOptions"
/** * Standalone faceted filter menu for column header using context. */export function DataTableColumnFacetedFilterMenu<TData extends RowData, TValue>( props: Omit< React.ComponentProps<typeof TableColumnFacetedFilterMenu>, "column" | "table" > & { column?: DataTableColumn<TData, TValue> },) { const context = useColumnHeaderContext<TData, TValue>(false) const column = props.column || context?.column const { table, generatedOptionsMap } = useDataTable<TData>()
if (!column) { console.warn( "DataTableColumnFacetedFilterMenu must be used within DataTableColumnHeaderRoot or provided with a column prop", ) return null }
return ( <TableColumnFacetedFilterMenu column={column} table={table} precomputedOptions={ // Skip batch cache when caller supplies custom per-column config // (dynamicCounts / limitToFilteredRows) so those props are honoured. "dynamicCounts" in props || "limitToFilteredRows" in props ? undefined : generatedOptionsMap } {...props} /> )}
DataTableColumnFacetedFilterMenu.displayName = "DataTableColumnFacetedFilterMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import type { RowData } from "@tanstack/react-table"import { CircleHelp, Filter, FilterX } from "lucide-react"
import { Button } from "@/components/ui/button"import { DropdownMenuSeparator, DropdownMenuLabel,} from "@/components/ui/dropdown-menu"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"import { cn } from "@/lib/utils"import { TableFacetedFilter, TableFacetedFilterContent, useTableFacetedFilter,} from "./table-faceted-filter"import { useDerivedColumnTitle } from "../hooks/use-derived-column-title"import { useGeneratedOptionsForColumn } from "../hooks/use-generated-options"import { extractFilterSelectedValues } from "../lib/build-faceted-options"import { formatLabel } from "../lib/format"import type { DataTableColumn, DataTableInstance, Option } from "../types"
/** * A standard filter trigger button (Funnel icon). */export function TableColumnFilterTrigger<TData extends RowData, TValue>({ column, className, ...props}: { column: DataTableColumn<TData, TValue>} & React.ComponentProps<typeof Button>) { const isFiltered = column.getIsFiltered()
const Icon = isFiltered ? FilterX : Filter
return ( <Button variant="ghost" size="icon" className={cn( "size-7 transition-opacity dark:text-muted-foreground", isFiltered && "text-primary", className, )} {...props} > <Icon className="size-3.5" /> <span className="sr-only">Filter column</span> </Button> )}
/** * Faceted filter options for composing inside TableColumnActions. * Renders as inline searchable menu with checkboxes. * * @example * ```tsx * // Inside TableColumnActions * <TableColumnActions column={column}> * <TableColumnFacetedFilterOptions * column={column} * options={[{ label: "Active", value: "active" }]} * multiple * /> * </TableColumnActions> * ``` */export function TableColumnFacetedFilterOptions<TData extends RowData, TValue>({ column, title, options = [], onValueChange, multiple = true, withSeparator = true,}: { column: DataTableColumn<TData, TValue> title?: string options?: Option[] onValueChange?: (value: string[] | undefined) => void /** Whether to allow multiple selections. Defaults to true. */ multiple?: boolean /** Whether to render a separator before the options. Defaults to true. */ withSeparator?: boolean}) { const { selectedValues, onItemSelect, onReset } = useTableFacetedFilter({ column: column as DataTableColumn<TData, unknown>, onValueChange, multiple, })
const derivedTitle = useDerivedColumnTitle(column, column.id, title) const labelText = multiple ? "Column Multi Select" : "Column Select" const tooltipText = multiple ? "Select multiple options to filter" : "Select a single option to filter"
return ( <> {withSeparator && <DropdownMenuSeparator />} <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <span>{labelText}</span> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> {tooltipText} {derivedTitle && ` - ${derivedTitle}`} </TooltipContent> </Tooltip> </DropdownMenuLabel> <TableFacetedFilterContent title={derivedTitle} options={options} selectedValues={selectedValues} onItemSelect={onItemSelect} onReset={onReset} /> </> )}
/** * Standalone faceted filter menu for column headers. * Shows a filter button that opens a popover with filter options. * * @example * ```tsx * // Standalone usage * <TableColumnFacetedFilterMenu * column={column} * options={[{ label: "Active", value: "active" }]} * /> * ``` */export function TableColumnFacetedFilterMenu<TData extends RowData, TValue>({ column, table, title, options, onValueChange, multiple, limitToFilteredRows, dynamicCounts = true, precomputedOptions, ...props}: Omit< React.ComponentProps<typeof TableFacetedFilter>, "column" | "trigger" | "options"> & { column: DataTableColumn<TData, TValue> table?: DataTableInstance<TData> title?: string options?: React.ComponentProps<typeof TableFacetedFilter>["options"] /** * If true, only show options that exist in the currently filtered rows. * If false, show all options from the entire dataset. * @default !multiple (true for single-select, false for multi-select) */ limitToFilteredRows?: boolean /** * Whether to update counts based on other active filters. * @default true */ dynamicCounts?: boolean /** * Precomputed options map from DataTableProvider context. When provided, * skips per-column option generation. */ precomputedOptions?: Record<string, Option[]>}) { // Default: multi-select shows all options, single-select filters to visible rows limitToFilteredRows ??= !multiple
// A column's own selected values must never be narrowed or count-0 hidden // away — otherwise a selection excluded by another filter disappears from // its own facet and can't be un-checked. Recomputed on every render so it // tracks the current selection. const selectedValues = extractFilterSelectedValues(column.getFilterValue())
const derivedTitle = useDerivedColumnTitle(column, column.id, title)
// Auto-generate options from column meta (works for select/multiSelect variants). // Use precomputed batch when available to avoid per-column row scans. const needsPerColumnGeneration = !precomputedOptions const perColumnOptions = useGeneratedOptionsForColumn( table as DataTableInstance<TData>, needsPerColumnGeneration ? column.id : "__noop__", { limitToFilteredRows, dynamicCounts }, ) const generatedOptions = precomputedOptions?.[column.id] ?? perColumnOptions
/** * REACTIVITY FIX: Extract row model references outside memos so that when * async data arrives, the new rows array reference triggers memo recomputation. * Without this, `table` reference is stable across data changes and memos * would return stale (empty) results after initial render with no data. */ const coreRows = table?.getCoreRowModel().rows const filteredRows = table?.getFilteredRowModel().rows
// Fallback: generate options from row data for any variant (text, boolean, etc.) const fallbackOptions = React.useMemo((): Option[] => { if (!table || !column) return []
const meta = column.columnDef.meta const autoOptionsFormat = (meta as Record<string, unknown>)?.autoOptionsFormat ?? true const showCounts = (meta as Record<string, unknown>)?.showCounts ?? true
// optionRows used for the list of options const optionRows = limitToFilteredRows ? filteredRows : coreRows
// countRows used for the counts const countRows = dynamicCounts ? filteredRows : coreRows
if (!optionRows || !countRows) return []
const valueCounts = new Map<string, number>()
// Determine the set of available options const availableOptions = new Set<string>() optionRows.forEach(row => { const raw = row.getValue(column.id) as unknown const values: unknown[] = Array.isArray(raw) ? raw : [raw] values.forEach(v => { if (v != null) { const s = String(v) if (s) availableOptions.add(s) } }) })
// Calculate counts for available options countRows.forEach(row => { const raw = row.getValue(column.id) as unknown const values: unknown[] = Array.isArray(raw) ? raw : [raw] values.forEach(v => { if (v != null) { const s = String(v) if (availableOptions.has(s)) { valueCounts.set(s, (valueCounts.get(s) || 0) + 1) } } }) })
// If static options exist in meta with augment strategy, use them with counts const metaOptions = (meta as Record<string, unknown>)?.options as Option[] | undefined const mergeStrategy = (meta as Record<string, unknown>)?.mergeStrategy as string | undefined
if (metaOptions && metaOptions.length > 0 && mergeStrategy === "augment") { return metaOptions .filter( opt => !limitToFilteredRows || availableOptions.has(opt.value) || selectedValues.has(opt.value), ) .map(opt => ({ ...opt, count: showCounts ? (valueCounts.get(opt.value) ?? 0) : undefined, })) }
if (metaOptions && metaOptions.length > 0) { return limitToFilteredRows ? metaOptions.filter( opt => availableOptions.has(opt.value) || selectedValues.has(opt.value), ) : metaOptions }
const fallbackValues = limitToFilteredRows ? new Set([...availableOptions, ...selectedValues]) : availableOptions return Array.from(fallbackValues) .map(value => ({ label: autoOptionsFormat ? formatLabel(value) : value, value, count: showCounts ? valueCounts.get(value) || 0 : undefined, })) .sort((a, b) => a.label.localeCompare(b.label)) }, [ table, column, limitToFilteredRows, dynamicCounts, coreRows, filteredRows, selectedValues, ])
/** * Enrich caller-supplied `options` with live counts and (optionally) narrow * them to values that exist in the current row set. Mirrors the row-set * split used by `fallbackOptions` so explicit and generated paths stay * consistent — without this, `dynamicCounts` was silently ignored whenever * a caller passed their own options. */ const enrichedCallerOptions = React.useMemo(() => { if (!options) return null // Preserve the original `options ?? ...` semantics: an explicit empty // array still wins over generated/fallback options. if (options.length === 0 || !table || !column) return options
const showCounts = (column.columnDef.meta as Record<string, unknown>)?.showCounts ?? true
// Caller-supplied options are never narrowed — the caller is the source // of truth for which values can ever appear (e.g. server-side tables // pass the full static option list every render). Narrowing here would // hide cross-filter pivots. if (!showCounts) { return options.map(opt => ({ ...opt, count: undefined })) }
const countRows = dynamicCounts ? filteredRows : coreRows const valueCounts = new Map<string, number>() if (countRows) { countRows.forEach(row => { const raw = row.getValue(column.id) as unknown const values: unknown[] = Array.isArray(raw) ? raw : [raw] values.forEach(v => { if (v != null) { const s = String(v) valueCounts.set(s, (valueCounts.get(s) || 0) + 1) } }) }) }
/** * Cross-filter narrowing default — caller-supplied `count` wins (server- * side tables compute true cross-filter counts on the server; client- * derived `valueCounts` only sees the current page). After merging, * options with explicit `count: 0` are hidden so server-side tables get * automatic cross-filter narrowing without each caller writing a helper. * * Opt-out: pass `count: undefined` (or omit it) on options you want * visible regardless. Pure label-only callers (no counts anywhere) are * unaffected — `valueCounts` falls back to client rows. */ return options .map(opt => ({ ...opt, count: opt.count ?? valueCounts.get(opt.value) ?? 0, })) .filter(opt => opt.count !== 0 || selectedValues.has(opt.value)) }, [ options, table, column, dynamicCounts, coreRows, filteredRows, selectedValues, ])
const resolvedOptions = enrichedCallerOptions ?? (generatedOptions.length > 0 ? generatedOptions : fallbackOptions)
return ( <TableFacetedFilter column={column} title={derivedTitle} options={resolvedOptions} multiple={multiple} onValueChange={onValueChange} trigger={<TableColumnFilterTrigger column={column} />} {...props} /> )}
TableColumnFacetedFilterOptions.displayName = "TableColumnFacetedFilterOptions"TableColumnFacetedFilterMenu.displayName = "TableColumnFacetedFilterMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import type { RowData } from "@tanstack/react-table"import React from "react"
import { TableColumnFilterTrigger } from "../filters/table-column-faceted-filter"import { useColumnHeaderContext } from "./data-table-column-header"
/** * A standard filter trigger button (Funnel icon) using context. */export function DataTableColumnFilterTrigger<TData extends RowData, TValue>( props: Omit<React.ComponentProps<typeof TableColumnFilterTrigger>, "column">,) { const { column } = useColumnHeaderContext<TData, TValue>(true) return <TableColumnFilterTrigger column={column} {...props} />}
DataTableColumnFilterTrigger.displayName = "DataTableColumnFilterTrigger"Update the import paths to match your project setup.
DataTableColumnSliderFilter:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"
import { TableColumnSliderFilterOptions, TableColumnSliderFilterMenu,} from "../filters/table-column-slider-filter"import { useColumnHeaderContext } from "./data-table-column-header"
import type { DataTableColumn } from "../types"import type { RowData } from "@tanstack/react-table"/** * Slider filter options for composing inside DataTableColumnActions using context. */export function DataTableColumnSliderFilterOptions< TData extends RowData, TValue,>( props: Omit< React.ComponentProps<typeof TableColumnSliderFilterOptions>, "column" > & { column?: DataTableColumn<TData, TValue> },) { const context = useColumnHeaderContext<TData, TValue>(false) const column = props.column || context?.column
if (!column) { console.warn( "DataTableColumnSliderFilterOptions must be used within DataTableColumnHeaderRoot or provided with a column prop", ) return null }
return <TableColumnSliderFilterOptions column={column} {...props} />}
DataTableColumnSliderFilterOptions.displayName = "DataTableColumnSliderFilterOptions"
/** * Standalone slider filter menu for column header using context. */export function DataTableColumnSliderFilterMenu<TData extends RowData, TValue>( props: Omit< React.ComponentProps<typeof TableColumnSliderFilterMenu>, "column" > & { column?: DataTableColumn<TData, TValue> },) { const context = useColumnHeaderContext<TData, TValue>(false) const column = props.column || context?.column
if (!column) { console.warn( "DataTableColumnSliderFilterMenu must be used within DataTableColumnHeaderRoot or provided with a column prop", ) return null }
return <TableColumnSliderFilterMenu column={column} {...props} />}
DataTableColumnSliderFilterMenu.displayName = "DataTableColumnSliderFilterMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import { CircleHelp, SlidersHorizontal } from "lucide-react"
import { DropdownMenuSeparator, DropdownMenuLabel,} from "@/components/ui/dropdown-menu"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"import { Input } from "@/components/ui/input"import { Label } from "@/components/ui/label"import { Button } from "@/components/ui/button"import { Slider } from "@/components/ui/slider"import { cn } from "@/lib/utils"import { useDerivedColumnTitle } from "../hooks/use-derived-column-title"
import type { DataTableColumn } from "../types"import type { RowData } from "@tanstack/react-table"type RangeValue = [number, number]
function parseValuesAsNumbers(value: unknown): RangeValue | undefined { if ( Array.isArray(value) && value.length === 2 && value.every( v => (typeof v === "string" || typeof v === "number") && !Number.isNaN(v), ) ) { return [Number(value[0]), Number(value[1])] }
return undefined}
function getIsValidRange(value: unknown): value is RangeValue { return ( Array.isArray(value) && value.length === 2 && typeof value[0] === "number" && typeof value[1] === "number" )}
/** * Slider filter options for composing inside TableColumnActions. * Renders as inline slider with min/max inputs. * * @example * ```tsx * // Inside TableColumnActions * <TableColumnActions column={column}> * <TableColumnSliderFilterOptions * column={column} * min={0} * max={1000} * /> * </TableColumnActions> * ``` */export function TableColumnSliderFilterOptions<TData extends RowData, TValue>({ column, title, range: manualRange, min: manualMin, max: manualMax, step: manualStep, unit: manualUnit, onValueChange, withSeparator = true,}: { column: DataTableColumn<TData, TValue> title?: string /** * Manual range [min, max] (overrides min/max props and column.meta.range) */ range?: RangeValue /** * Manual minimum value (overrides column.meta.range and faceted values) */ min?: number /** * Manual maximum value (overrides column.meta.range and faceted values) */ max?: number /** * Manual step value for the slider */ step?: number /** * Unit label to display (e.g., "$", "kg", "km") */ unit?: string onValueChange?: (value: [number, number] | undefined) => void /** Whether to render a separator before the options. Defaults to true. */ withSeparator?: boolean}) { const id = React.useId()
const columnFilterValue = parseValuesAsNumbers(column.getFilterValue())
const defaultRange = column.columnDef.meta?.range const unit = manualUnit ?? column.columnDef.meta?.unit
// Capture faceted min/max as scalars so the memo re-runs when filters/data change. const facetedValues = column.getFacetedMinMaxValues() const facetedMin = facetedValues?.[0] const facetedMax = facetedValues?.[1]
// Compute range values - memoized to avoid recalculation const { min, max, step } = React.useMemo<{ min: number max: number step: number }>(() => { let minValue = 0 let maxValue = 100
// Priority 1: Manual range prop (highest priority) if (manualRange && getIsValidRange(manualRange)) { minValue = manualRange[0] maxValue = manualRange[1] } // Priority 2: Manual min/max props else if (manualMin != null && manualMax != null) { minValue = manualMin maxValue = manualMax } // Priority 3: Use explicit range from column metadata else if (defaultRange && getIsValidRange(defaultRange)) { minValue = defaultRange[0] maxValue = defaultRange[1] } // Priority 4: Get min/max from faceted values else if (facetedMin != null && facetedMax != null) { minValue = Number(facetedMin) maxValue = Number(facetedMax) }
// Calculate appropriate step size based on range const rangeSize = maxValue - minValue const calculatedStep = rangeSize <= 20 ? 1 : rangeSize <= 100 ? Math.ceil(rangeSize / 20) : Math.ceil(rangeSize / 50)
return { min: minValue, max: maxValue, step: manualStep ?? calculatedStep, } }, [ defaultRange, manualRange, manualMin, manualMax, manualStep, facetedMin, facetedMax, ])
const range = React.useMemo((): RangeValue => { return columnFilterValue ?? [min, max] }, [columnFilterValue, min, max])
const derivedTitle = useDerivedColumnTitle(column, column.id, title) const labelText = "Range Filter" const tooltipText = "Set a range to filter values"
const applyFilterValue = React.useCallback( (value: [number, number] | undefined) => { column.setFilterValue(value) onValueChange?.(value) }, [column, onValueChange], )
const onRangeValueChange = React.useCallback( (value: string | number, isMin?: boolean) => { const numValue = Number(value) const currentValues = range
if (value === "") { // Allow empty value, don't update filter return }
if ( !Number.isNaN(numValue) && (isMin ? numValue >= min && numValue <= currentValues[1] : numValue <= max && numValue >= currentValues[0]) ) { applyFilterValue( isMin ? [numValue, currentValues[1]] : [currentValues[0], numValue], ) } }, [min, max, range, applyFilterValue], )
const onSliderValueChange = React.useCallback( // Radix sliders emit number[]; Base UI emits number | readonly number[] (value: RangeValue | number | readonly number[]) => { if (Array.isArray(value) && value.length === 2) { applyFilterValue([value[0], value[1]]) } }, [applyFilterValue], )
const onReset = React.useCallback(() => { applyFilterValue(undefined) }, [applyFilterValue])
return ( <> {withSeparator && <DropdownMenuSeparator />} <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <span>{labelText}</span> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> {tooltipText} {derivedTitle && ` - ${derivedTitle}`} </TooltipContent> </Tooltip> </DropdownMenuLabel> <div className="px-2 py-2"> <div className="flex flex-col gap-3"> <div className="flex items-center gap-2"> <Label htmlFor={`${id}-from`} className="sr-only"> From </Label> <div className="relative flex-1"> <Input key={`${id}-from-${range[0]}`} id={`${id}-from`} type="number" aria-label={`${derivedTitle} minimum value`} aria-valuemin={min} aria-valuemax={max} inputMode="numeric" pattern="[0-9]*" placeholder={min.toString()} min={min} max={max} defaultValue={range[0]} onChange={event => onRangeValueChange(String(event.target.value), true) } className={cn("h-8 w-full", unit && "pr-8")} /> {unit && ( <span className="absolute top-0 right-0 bottom-0 mt-0.5 mr-0.5 flex h-7 items-center rounded-r-md bg-accent px-2 text-sm text-muted-foreground"> {unit} </span> )} </div> <Label htmlFor={`${id}-to`} className="sr-only"> to </Label> <div className="relative flex-1"> <Input key={`${id}-to-${range[1]}`} id={`${id}-to`} type="number" aria-label={`${derivedTitle} maximum value`} aria-valuemin={min} aria-valuemax={max} inputMode="numeric" pattern="[0-9]*" placeholder={max.toString()} min={min} max={max} defaultValue={range[1]} onChange={event => onRangeValueChange(String(event.target.value)) } className={cn("h-8 w-full", unit && "pr-8")} /> {unit && ( <span className="absolute top-0 right-0 bottom-0 mt-0.5 mr-0.5 flex h-7 items-center rounded-r-md bg-accent px-2 text-sm text-muted-foreground"> {unit} </span> )} </div> </div> <Label htmlFor={`${id}-slider`} className="sr-only"> {derivedTitle} slider </Label> <Slider id={`${id}-slider`} min={min} max={max} step={step} value={range} onValueChange={onSliderValueChange} className="w-full" /> <Button aria-label={`Clear ${derivedTitle} filter`} variant="outline" size="sm" onClick={onReset} className="w-full" > Clear </Button> </div> </div> </> )}
TableColumnSliderFilterOptions.displayName = "TableColumnSliderFilterOptions"
/** * Standalone slider filter menu for column headers. * Shows a filter button that opens a popover with a range slider. * * @example * ```tsx * // Standalone usage * <TableColumnSliderFilterMenu * column={column} * min={0} * max={1000} * /> * ``` */export function TableColumnSliderFilterMenu<TData extends RowData, TValue>({ column, title, className, ...props}: Omit< React.ComponentProps<typeof TableColumnSliderFilterOptions>, "withSeparator" | "column"> & { column: DataTableColumn<TData, TValue> className?: string}) { return ( <Popover> <PopoverTrigger asChild> <Button variant="ghost" size="icon" className={cn( "size-7 transition-opacity dark:text-muted-foreground", column.getIsFiltered() && "text-primary", className, )} > <SlidersHorizontal className="size-3.5" /> <span className="sr-only">Filter by range</span> </Button> </PopoverTrigger> <PopoverContent align="end" className="w-52 p-0"> <TableColumnSliderFilterOptions column={column} title={title} withSeparator={false} {...props} /> </PopoverContent> </Popover> )}
TableColumnSliderFilterMenu.displayName = "TableColumnSliderFilterMenu"Update the import paths to match your project setup.
DataTableColumnDateFilter:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"
import { TableColumnDateFilterOptions, TableColumnDateFilterMenu,} from "../filters/table-column-date-filter"import { useColumnHeaderContext } from "./data-table-column-header"
import type { DataTableColumn } from "../types"import type { RowData } from "@tanstack/react-table"/** * Date filter options for composing inside DataTableColumnActions using context. */export function DataTableColumnDateFilterOptions<TData extends RowData, TValue>( props: Omit< React.ComponentProps<typeof TableColumnDateFilterOptions>, "column" > & { column?: DataTableColumn<TData, TValue> },) { const context = useColumnHeaderContext<TData, TValue>(false) const column = props.column || context?.column
if (!column) { console.warn( "DataTableColumnDateFilterOptions must be used within DataTableColumnHeaderRoot or provided with a column prop", ) return null }
return <TableColumnDateFilterOptions column={column} {...props} />}
DataTableColumnDateFilterOptions.displayName = "DataTableColumnDateFilterOptions"
/** * Standalone date filter menu for column header using context. */export function DataTableColumnDateFilterMenu<TData extends RowData, TValue>( props: Omit< React.ComponentProps<typeof TableColumnDateFilterMenu>, "column" > & { column?: DataTableColumn<TData, TValue> },) { const context = useColumnHeaderContext<TData, TValue>(false) const column = props.column || context?.column
if (!column) { console.warn( "DataTableColumnDateFilterMenu must be used within DataTableColumnHeaderRoot or provided with a column prop", ) return null }
return <TableColumnDateFilterMenu column={column} {...props} />}
DataTableColumnDateFilterMenu.displayName = "DataTableColumnDateFilterMenu""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import type { DateRange } from "react-day-picker"import { CircleHelp, CalendarIcon, CalendarX2 } from "lucide-react"
import { DropdownMenuSeparator, DropdownMenuLabel,} from "@/components/ui/dropdown-menu"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"import { Calendar } from "@/components/ui/calendar"import { Button } from "@/components/ui/button"import { useDerivedColumnTitle } from "../hooks/use-derived-column-title"import { formatDate } from "../lib/format"import { cn } from "@/lib/utils"
import type { DataTableColumn } from "../types"import type { RowData } from "@tanstack/react-table"type DateSelection = Date[] | DateRange
function parseAsDate(timestamp: number | string | undefined): Date | undefined { if (!timestamp) return undefined const numericTimestamp = typeof timestamp === "string" ? Number(timestamp) : timestamp const date = new Date(numericTimestamp) return !Number.isNaN(date.getTime()) ? date : undefined}
function parseColumnFilterValue(value: unknown) { if (value === null || value === undefined) { return [] }
if (Array.isArray(value)) { return value.map(item => { if (typeof item === "number" || typeof item === "string") { return item } return undefined }) }
if (typeof value === "string" || typeof value === "number") { return [value] }
return []}
/** * Date filter options for composing inside TableColumnActions. * Renders as button that opens a popover with calendar picker - matches FilterDatePicker from table-filter-menu. * * @example * ```tsx * // Inside TableColumnActions * <TableColumnActions column={column}> * <TableColumnDateFilterOptions * column={column} * multiple * /> * </TableColumnActions> * ``` */export function TableColumnDateFilterOptions<TData extends RowData, TValue>({ column, title, multiple = true, withSeparator = true,}: { column: DataTableColumn<TData, TValue> title?: string /** Whether to allow date range selection. Defaults to true. */ multiple?: boolean /** Whether to render a separator before the options. Defaults to true. */ withSeparator?: boolean}) { const [showValueSelector, setShowValueSelector] = React.useState(false) const columnFilterValue = column.getFilterValue()
const selectedDates = React.useMemo<DateSelection>(() => { if (!columnFilterValue) { return multiple ? { from: undefined, to: undefined } : [] }
if (multiple) { const timestamps = parseColumnFilterValue(columnFilterValue) return { from: parseAsDate(timestamps[0]), to: parseAsDate(timestamps[1]), } }
const timestamps = parseColumnFilterValue(columnFilterValue) const date = parseAsDate(timestamps[0]) return date ? [date] : [] }, [columnFilterValue, multiple])
const derivedTitle = useDerivedColumnTitle(column, column.id, title) const labelText = multiple ? "Date Range Filter" : "Date Filter" const tooltipText = multiple ? "Select a date range to filter" : "Select a date to filter"
const dateValue = Array.isArray(selectedDates) ? selectedDates.filter(Boolean) : [selectedDates.from, selectedDates.to].filter(Boolean)
const displayValue = multiple && dateValue.length === 2 ? `${formatDate(dateValue[0] as Date)} - ${formatDate(dateValue[1] as Date)}` : dateValue[0] ? formatDate(dateValue[0] as Date) : "Pick a date"
const onSelect = React.useCallback( (date: Date | DateRange | undefined) => { if (!date) { column.setFilterValue(undefined) return }
if (multiple && !("getTime" in date)) { const from = date.from?.getTime() const to = date.to?.getTime()
if (from && to) { column.setFilterValue([from, to]) } else if (from) { column.setFilterValue([from]) } else { column.setFilterValue(undefined) } } else if (!multiple && "getTime" in date) { column.setFilterValue([date.getTime()]) } }, [column, multiple], )
const onReset = React.useCallback(() => { column.setFilterValue(undefined) }, [column])
return ( <> {withSeparator && <DropdownMenuSeparator />} <DropdownMenuLabel className="flex items-center justify-between text-xs font-normal text-muted-foreground"> <span>{labelText}</span> <Tooltip> <TooltipTrigger asChild> <CircleHelp className="size-3.5 cursor-help" /> </TooltipTrigger> <TooltipContent side="right"> {tooltipText} {derivedTitle && ` - ${derivedTitle}`} </TooltipContent> </Tooltip> </DropdownMenuLabel> <div className="px-2 py-2"> <Popover open={showValueSelector} onOpenChange={setShowValueSelector}> <PopoverTrigger asChild> <Button variant="outline" size="sm" className={cn( "w-full justify-start rounded text-left font-normal", !columnFilterValue && "text-muted-foreground", )} > <CalendarIcon /> <span className="truncate">{displayValue}</span> </Button> </PopoverTrigger> <PopoverContent align="start" className="w-auto p-0"> {multiple ? ( <Calendar mode="range" captionLayout="dropdown" selected={selectedDates as DateRange} onSelect={onSelect as (date: DateRange | undefined) => void} /> ) : ( <Calendar mode="single" captionLayout="dropdown" selected={(selectedDates as Date[])[0]} onSelect={onSelect as (date: Date | undefined) => void} /> )} </PopoverContent> </Popover> <Button variant="outline" size="sm" onClick={onReset} className="mt-2 w-full" > Clear </Button> </div> </> )}
TableColumnDateFilterOptions.displayName = "TableColumnDateFilterOptions"
/** * Standalone date filter menu for column headers. * Shows a filter button that opens a popover with a calendar picker. * * @example * ```tsx * // Standalone usage * <TableColumnDateFilterMenu * column={column} * multiple * /> * ``` */export function TableColumnDateFilterMenu<TData extends RowData, TValue>({ column, title, className, ...props}: Omit< React.ComponentProps<typeof TableColumnDateFilterOptions>, "withSeparator" | "column"> & { column: DataTableColumn<TData, TValue> className?: string}) { return ( <Popover> <PopoverTrigger asChild> <Button variant="ghost" size="icon" className={cn( "size-7 transition-opacity dark:text-muted-foreground", column.getIsFiltered() && "text-primary", className, )} > {column.getIsFiltered() ? ( <CalendarX2 className="size-3.5" /> ) : ( <CalendarIcon className="size-3.5" /> )} <span className="sr-only">Filter by date</span> </Button> </PopoverTrigger> <PopoverContent align="end" className="w-auto p-0"> <TableColumnDateFilterOptions column={column} title={title} withSeparator={false} {...props} /> </PopoverContent> </Popover> )}
TableColumnDateFilterMenu.displayName = "TableColumnDateFilterMenu"Update the import paths to match your project setup.
Layout Components
Section titled “Layout Components”DataTableVirtualized:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import { flexRender } from "@tanstack/react-table"import { useVirtualizer } from "@tanstack/react-virtual"import { Skeleton } from "@/components/ui/skeleton"import { TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/components/ui/table"import { cn } from "@/lib/utils"import { Loader2 } from "lucide-react"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 { resolveGroupedRowsSlot } from "../components/data-table-grouped-rows"import { GroupedBodyRow } from "./data-table-grouped-row"import { DataTableColumnResizeHandle } from "../lib/column-resize-handle"import { isGlobalFilterActive } from "../lib/filter-functions"import { createScrollHandler, type ScrollEvent,} from "../lib/create-scroll-handler"import { renderCellContent } from "../lib/render-cell-content"import { resolveColumnWidth, resolveFlexColumnIds } from "../lib/flex-columns"import { isInteractiveClickTarget } from "../lib/row-click"import { getCommonPinningStyles } from "../lib/styles"import { flashCellKey, useDataTable, useDataTableActiveCell,} from "./data-table-context"
import type { DataTableRow } from "../types"import type { RowData } from "@tanstack/react-table"// ============================================================================// Row measurement helper// ============================================================================
// Sums base + expanded sibling height (ResizeObserver only sees the base row).// Disabled in Firefox where stale `getBoundingClientRect` causes measure loops.//// Prefers `ResizeObserverEntry.borderBoxSize` for the base row when TanStack// Virtual passes the entry through — that height is computed off the same// observation that fired the callback, so we skip a forced layout read.// Falls back to `getBoundingClientRect` for the initial measure (no entry)// and for the expanded sibling (not observed by the virtualizer).const measureRowWithExpansion: | ((element: Element, entry?: ResizeObserverEntry | undefined) => number) | undefined = typeof window !== "undefined" && navigator.userAgent.indexOf("Firefox") === -1 ? (element, entry) => { const baseHeight = entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height const next = element.nextElementSibling if ( next && next.getAttribute("data-slot") === "datatable-expanded-row" ) { return baseHeight + next.getBoundingClientRect().height } return baseHeight } : undefined
export type { ScrollEvent }
// ============================================================================// DataTableVirtualizedHeader// ============================================================================
export interface DataTableVirtualizedHeaderProps { className?: string /** * Makes the header sticky at the top when scrolling. * @default true */ sticky?: boolean}
export const DataTableVirtualizedHeader = React.memo( function DataTableVirtualizedHeader({ className, sticky = true, }: DataTableVirtualizedHeaderProps) { const { table, headerMinWidths, setColumnResizePreview } = useDataTable() // Dedicated context — only this header (and the body) re-render when the // grid's focused cell moves; other table consumers are untouched. const activeCell = useDataTableActiveCell() const resizing = table?.options.enableColumnResizing ?? false
const headerGroups = table?.getHeaderGroups() ?? []
if (headerGroups.length === 0) { return null }
// Flex fill is on by default: which column soaks up the leftover width. const flexColumnIds = resolveFlexColumnIds(table) const columnSizing = table.state.columnSizing
return ( <TableHeader className={cn( sticky && "sticky top-0 z-30 bg-background", // Sticky elements don't paint the row's own border-b reliably — draw // the bottom rule as a pseudo-element so it stays crisp while scrolling. 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 isActiveColumn = activeCell?.columnIds.has(header.column.id) ?? false return ( <TableHead key={header.id} data-active-column={isActiveColumn ? "true" : undefined} data-col-id={header.column.id} // Pinned header cells stick with the body (same // `getCommonPinningStyles`) and paint an opaque bg + right rule // so scrolled columns pass cleanly underneath. Set an explicit // width (mirroring the non-virtualized header) so the sticky // offset math lines up with the body's pinned columns. The // active column (focused cell) lights up grid-style. className={cn( header.column.getIsPinned() && "bg-background", // Trailing `!` so the active tint wins over the base // `bg-accent/30` / `text-accent-foreground` (equal-specificity // utilities are otherwise source-order dependent in Tailwind). isActiveColumn && "bg-primary/15! font-semibold! text-foreground!", // Anchor the absolute resize handle to the cell's right edge. resizing && "relative overflow-hidden", )} style={{ width: resolveColumnWidth(header.column, { resizing, isFlex, columnSizing, headerMinWidths, }), ...getCommonPinningStyles(header.column, true), }} > {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> ) },)
DataTableVirtualizedHeader.displayName = "DataTableVirtualizedHeader"
// ============================================================================// DataTableVirtualizedFlexHeader// ============================================================================
export interface DataTableVirtualizedFlexHeaderProps { className?: string /** * Makes the header sticky at the top when scrolling. * @default true */ sticky?: boolean}
/** * Flex-layout header — pairs with `DataTableVirtualizedDndBody` (row-DnD). * Mirrors the body's cell sizing so columns stay aligned. * * Use `DataTableVirtualizedHeader` for plain tables and * `DataTableVirtualizedDndHeader` for column-DnD tables. * * @example * <DataTableRowDndProvider data={data} onReorder={setData}> * <DataTable height={500}> * <DataTableVirtualizedFlexHeader /> * <DataTableVirtualizedDndBody /> * </DataTable> * </DataTableRowDndProvider> */export const DataTableVirtualizedFlexHeader = React.memo( function DataTableVirtualizedFlexHeader({ className, sticky = true, }: DataTableVirtualizedFlexHeaderProps) { const { table } = useDataTable() const resizing = table?.options.enableColumnResizing ?? false
const headerGroups = table?.getHeaderGroups() ?? []
if (headerGroups.length === 0) { return null }
return ( <TableHeader className={cn( "block", sticky && "sticky top-0 z-30 bg-background", // Sticky elements don't paint the row's own border-b reliably — draw // the bottom rule as a pseudo-element so it stays crisp while scrolling. 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} className="flex w-full border-b"> {headerGroup.headers.map(header => { const size = header.column.columnDef.size const fixedWidth = resizing ? header.getSize() : size ? `${size}px` : undefined return ( <TableHead key={header.id} data-col-id={header.column.id} className={cn( fixedWidth != null ? "shrink-0" : "min-w-0 flex-1", "flex items-center", header.column.getIsPinned() && "bg-background", // Anchor the absolute resize handle to the cell's right edge. resizing && "relative overflow-hidden", )} style={{ width: fixedWidth, ...getCommonPinningStyles(header.column, true), }} > {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} /> )} </TableHead> ) })} </TableRow> ))} </TableHeader> ) },)
DataTableVirtualizedFlexHeader.displayName = "DataTableVirtualizedFlexHeader"
// ============================================================================// VirtualizedBodyRow — memoized to keep selection / expansion / column-vis// changes from cascading into all visible rows// ============================================================================
/** * Per-row component for `DataTableVirtualizedBody`. Wrapped with * `React.memo` so single-row state changes (selection, expansion) don't * reconcile every other visible row. * * The measure ref is wrapped in a stable callback by the parent so it * doesn't invalidate the memo on every parent render. `getRowClassName` * and `getCellClassName` are expected to be stable refs from the * consumer — pass them through `useCallback` to preserve memoization * across parent renders. * * Composite key (`${row.id}-${isExpanded}`) stays on the parent so the * row remounts on expansion toggle and `ResizeObserver` re-attaches. */interface VirtualizedBodyRowProps<TData extends RowData> { row: DataTableRow<TData> virtualIndex: number expandColumnId: string | undefined isExpanded: boolean isSelected: boolean /** This row holds the active cell — exposed as `data-active-row` for the gutter cross-highlight. */ isActiveRow: 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; stable identity so `React.memo` holds, the signature invalidates it. */ columnWidths: ReadonlyMap<string, number | string | undefined> isClickable: boolean measureRef: ((node: HTMLTableRowElement | null) => void) | undefined onClick: (event: React.MouseEvent<HTMLElement>) => void getRowClassName?: (row: TData) => string | undefined getCellClassName?: (row: TData, columnId: string) => string | undefined /** Column layout signature — invalidates React.memo on visibility/order/pinning change. */ columnLayoutSignature: string /** * Per-row memo key. Change this string to force React.memo to re-render a * specific row when row-level state changes outside of TanStack Table's * tracked props (e.g. inline edit mode, optimistic state). */ rowMemoKey: string /** 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 so * `React.memo` keeps holding. Return `null` to opt a specific row out. */ renderRowContextMenu?: (row: TData) => 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 VirtualizedBodyRowInner = function VirtualizedBodyRow< TData extends RowData,>({ row, virtualIndex, expandColumnId, isExpanded, isSelected, isActiveRow, columnWidths, isClickable, measureRef, onClick, getRowClassName, getCellClassName, columnLayoutSignature, rowMemoKey, isRowFlashing, flashingCellKeys, renderRowContextMenu,}: VirtualizedBodyRowProps<TData>) { const expandCell = isExpanded && expandColumnId ? row.getAllCells().find(c => c.column.id === expandColumnId) : undefined
const visibleCells = row.getVisibleCells()
// Cache the base-row DOM node so we can re-trigger measureRef when column // layout changes while the row is expanded (no remount = no automatic re-measure). const elementRef = React.useRef<HTMLTableRowElement | null>(null) const setRef = React.useCallback( (node: HTMLTableRowElement | null) => { elementRef.current = node if (measureRef) measureRef(node) }, [measureRef], )
// Re-measure when column layout changes while expanded so the virtualizer // picks up the updated combined base + expanded-pane height. React.useEffect(() => { if (isExpanded && measureRef && elementRef.current) { measureRef(elementRef.current) } }, [isExpanded, rowMemoKey, columnLayoutSignature, measureRef])
const rowElement = ( <TableRow ref={setRef} data-index={virtualIndex} data-row-id={row.id} data-state={isSelected ? "selected" : undefined} data-active-row={isActiveRow ? "true" : undefined} onClick={onClick} className={cn( "group data-[context-menu-open]:bg-muted/50", isClickable && "cursor-pointer", getRowClassName?.(row.original as TData), )} > {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} className={cn( // Ellipsis on shrink (same as regular body / grid display cells). !cell.getIsGrouped() && "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", getCellClassName?.(row.original as TData, cell.column.id), )} style={cellStyle} > {renderCellContent(cell)} </TableCell> ) })} </TableRow> )
// Stand up the context-menu shell only when the consumer returns items // for this row. Base UI merges its anchor ref with the virtualizer's // `measureRef` via the `render` prop, so row measurement is unaffected. const menuItems = renderRowContextMenu?.(row.original as TData)
return ( <> {menuItems ? ( <DataTableRowContextMenu row={row.original as TData} trigger={rowElement} > {menuItems} </DataTableRowContextMenu> ) : ( rowElement )}
{isExpanded && expandCell && ( <TableRow data-slot="datatable-expanded-row"> <TableCell colSpan={visibleCells.length} className="p-0"> {expandCell.column.columnDef.meta?.expandedContent?.(row.original)} </TableCell> </TableRow> )} </> )}
// React.memo strips generics; cast back so call sites stay typed.const VirtualizedBodyRow = React.memo( VirtualizedBodyRowInner,) as typeof VirtualizedBodyRowInner
// ============================================================================// DataTableVirtualizedBody// ============================================================================
export interface DataTableVirtualizedBodyProps<TData extends RowData> { children?: React.ReactNode estimateSize?: number overscan?: number /** * All rows are EXACTLY `estimateSize` tall — skip per-row `ResizeObserver` * measurement entirely. Every row is positioned at `index * estimateSize` with * zero measure/correct cycles, so scrolling is perfectly smooth (no vertical * jitter, and no re-layout that would jitter column widths). Use for uniform * fixed-height grids (single-line clamped cells). Incompatible with row * expansion / variable-height rows — leave `false` (default) for those. */ fixedRowHeight?: boolean className?: string onScroll?: (event: ScrollEvent) => void onScrolledTop?: () => void onScrolledBottom?: () => void scrollThreshold?: number /** * Click is dispatched per-row from each row's `onClick`. Typed as * `React.MouseEvent<HTMLElement>` to match the other body * variants (`DataTableBody`, `DataTableDndBody`, * `DataTableVirtualizedDndBody`, etc.) so a single handler can be * passed through wrappers that switch between bodies. Consumers * needing the row element can `event.target.closest("tr[data-row-id]")`. */ onRowClick?: (row: TData, event: React.MouseEvent<HTMLElement>) => void /** * Fires when the last rendered virtual row is within * `prefetchThreshold` rows of the end of the dataset. Intended * as a prefetch trigger for infinite-scroll — pair it with tRPC's * `useInfiniteQuery.fetchNextPage()` so the next page is loaded * *before* the user reaches the bottom. Called at most once per * transition into the near-end zone (not every frame) so * consumers can wire it directly without worrying about * double-fires. * * Strictly better than `onScrolledBottom` for virtualized infinite * scroll because it's virtualizer-index-driven (not scroll-event- * driven), so it also catches: fast scrolls via scrollbar drag, * programmatic `scrollToIndex()` jumps, and initial renders where * the table isn't tall enough to require scrolling. */ onNearEnd?: () => void /** * How many rows from the end of the dataset to trigger * `onNearEnd`. Default `10` — fires when the user is rendering * the last 10 loaded rows. Tune higher for more aggressive * prefetching (pre-fetch earlier), lower for more conservative. */ prefetchThreshold?: number /** Return extra className(s) for a specific row. Called per-row during render. */ getRowClassName?: (row: TData) => string | undefined /** Return extra className(s) for a specific cell. Called per-cell during render. */ getCellClassName?: (row: TData, columnId: string) => string | undefined /** * Return a per-row memo invalidation key. When the returned string changes * for a specific row, React.memo re-renders that row even if TanStack Table * props (selection, expansion, column layout) are unchanged. Use this for * row-level external state that cell renderers depend on — e.g. inline edit * mode, optimistic overlays, or any closure-captured state in column * definitions that changes independently of the table's own state. * * @example * // Trigger re-render on inline edit toggle (only the edited row re-renders) * getRowMemoKey={(row) => (isEditing(row.id) ? "editing" : "")} */ getRowMemoKey?: (row: TData) => string /** * Attach a native right-click context menu to each row. Return the menu * items 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 DataTableVirtualizedBody<TData extends RowData>({ children, estimateSize = 34, overscan = 20, fixedRowHeight = false, className, onScroll, onRowClick, onScrolledTop, onScrolledBottom, scrollThreshold = 50, onNearEnd, prefetchThreshold = 10, renderRowContextMenu, getRowClassName, getCellClassName, getRowMemoKey,}: DataTableVirtualizedBodyProps<TData>) { const { table, columns, registerRowScroller, flashingRowIds, flashingCellKeys, headerMinWidths, } = useDataTable() // Dedicated context — see the header note; memoized rows keep the actual // re-render cost to just the rows whose active state changed. const activeCell = useDataTableActiveCell() const { rows } = table.getRowModel() const activeRowRange = activeCell?.rowRange ?? null const resizing = table.options.enableColumnResizing ?? false
// Hoist expand-column lookup above the virtualizer loop (was O(virtual_rows × cols) per frame). const expandColumnId = React.useMemo( () => table.getAllColumns().find(col => col.columnDef.meta?.expandedContent) ?.id, // 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.state
// 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 virtual 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. A width change also drives // expanded-row re-measure. 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 [scrollElement, setScrollElement] = React.useState<HTMLDivElement | null>(null) const tbodyRef = React.useRef<HTMLTableSectionElement | null>(null)
const parentRef = React.useCallback( (node: HTMLTableSectionElement | null) => { tbodyRef.current = node if (node !== null) { const container = node.closest( '[data-slot="table-container"]', ) as HTMLDivElement | null setScrollElement(container) } }, [], )
// Lock column widths post auto-size: measure each <th>, enforce explicit // `size` as a minimum, scale to container, then switch to `table-layout: fixed`. // Without this, auto-layout shifts headers during virtual scroll as visible // content changes. useLayoutEffect avoids the auto→fixed flash. const columnLockRef = React.useRef(false) const lockedColumnCountRef = React.useRef(0) // Tracks the previous resize mode so a true→false transition can undo the // fixed-width lock even when the column count is unchanged. const prevResizingRef = React.useRef(false) // Mirrored as state so row-render can gate `measureElement` on it. Attaching // the ResizeObserver before the lock would read inflated wrapped-text heights // and bake huge spacer gaps into the virtualizer. const [columnsLocked, setColumnsLocked] = React.useState(false)
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentionally runs every render; guarded by refs React.useLayoutEffect(() => { const leafColumns = table.getVisibleLeafColumns() const currentColCount = leafColumns.length
// Column resizing on: widths come from `column.getSize()` (React-controlled) // + `table-layout: fixed`, so the content-measure lock is neither needed nor // wanted (it would fight user resizes). Pinning uses those same sizes for // sticky `left`/`width` — if the table still collapses under Tailwind's // `w-full`, layout columns shrink while sticky cells stay at `getSize()`, // and the left pin overlays the first data column. Keep an explicit pixel // width (= sum of sizes) in sync with columnSizing so sticky and flow match. if (resizing) { const tableEl = tbodyRef.current?.closest<HTMLTableElement>( '[data-slot="table"]', ) if (tableEl) { // Min-width = sum of 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 (no explicit width) absorbs the leftover row width: // let the table be full-width and only enforce `min-width` so columns // don't shrink below their sizes. Without one, pin the table to the // exact sum so sticky offsets and column flow stay in lockstep. const hasFlex = flexColumnIds.size > 0 tableEl.style.tableLayout = "fixed" tableEl.style.width = hasFlex ? "100%" : `${totalDesiredWidth}px` tableEl.style.minWidth = `${totalDesiredWidth}px` } if (!columnLockRef.current) { columnLockRef.current = true lockedColumnCountRef.current = currentColCount setColumnsLocked(true) } prevResizingRef.current = true return }
// Resizing just turned off. The branches below only handle a column-count // change and then the fast path returns early — neither clears the // fixed-width lock applied while resizing was on. Undo it here (and unlock) // so the table falls back to the normal content-measured layout. if (prevResizingRef.current) { prevResizingRef.current = false const tableEl = tbodyRef.current?.closest<HTMLTableElement>( '[data-slot="table"]', ) if (tableEl) { tableEl.style.tableLayout = "" tableEl.style.width = "" tableEl.style.minWidth = "" } if (columnLockRef.current) { columnLockRef.current = false setColumnsLocked(false) } // Fall through to re-measure + re-lock via the normal content path. }
// Reset lock when column visibility changes (toggle columns on/off) if ( columnLockRef.current && lockedColumnCountRef.current !== currentColCount ) { columnLockRef.current = false setColumnsLocked(false) const tbody = tbodyRef.current const tableEl = tbody?.closest<HTMLTableElement>('[data-slot="table"]') if (tableEl) { tableEl.style.tableLayout = "" tableEl.style.width = "" tableEl.style.minWidth = "" tableEl .querySelectorAll<HTMLTableCellElement>( "thead [data-slot='table-head']", ) .forEach(th => { th.style.width = "" }) } // Bail so React commits the unlocked render before re-locking — // batching both updates would let `ResizeObserver` capture // inflated heights during the auto-layout pass. return }
// Fast path — already locked, skip all DOM queries if (columnLockRef.current) return
const tbody = tbodyRef.current if (!tbody || rows.length === 0 || !scrollElement) return
// Verify data cells are rendered — the virtualizer may need an // extra render cycle after observing the scroll container before // it produces virtual items. Without this check we'd measure // header-only widths which are far too narrow. if (!tbody.querySelector("[data-slot='table-cell']")) return
const tableEl = tbody.closest<HTMLTableElement>('[data-slot="table"]') if (!tableEl) return
const ths = tableEl.querySelectorAll<HTMLTableCellElement>( "thead [data-slot='table-head']", ) if (ths.length === 0) return
// Force the table to be at least as wide as the sum of all column // sizes before measuring. Without this, `w-full` on <TableComponent> // constrains the table to the scroll container's width and the // auto-layout distributes compressed widths that then get locked in. const totalDesiredWidth = leafColumns.reduce( (sum, col) => sum + col.getSize(), 0, ) tableEl.style.minWidth = `${totalDesiredWidth}px`
// Measure auto-computed widths. Auto-layout naturally gives content-heavy // columns (e.g. Name with chevron + icon + badge) more space than narrow // columns (e.g. Games showing "0"). We keep that behavior but enforce // each column's explicit `size` as a minimum — so a column with // `size: 180` is never locked narrower than 180px even if its visible // content happens to be short. const rawWidths: number[] = [] ths.forEach(th => rawWidths.push(th.getBoundingClientRect().width))
const effectiveWidths = rawWidths.map((raw, i) => { const explicitSize = leafColumns[i]?.columnDef.size return explicitSize !== undefined ? Math.max(raw, explicitSize) : raw })
// Scale proportionally so widths sum to exactly the container width — // eliminates the subpixel rounding gap that `table-layout: fixed` + // raw pixel widths leaves. const effectiveSum = effectiveWidths.reduce((a, b) => a + b, 0) const containerWidth = tableEl.getBoundingClientRect().width const scale = containerWidth > 0 && effectiveSum > 0 ? containerWidth / effectiveSum : 1
ths.forEach((th, i) => { th.style.width = `${(effectiveWidths[i] ?? 0) * scale}px` }) tableEl.style.tableLayout = "fixed"
columnLockRef.current = true lockedColumnCountRef.current = currentColCount setColumnsLocked(true) })
const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => scrollElement, estimateSize: () => estimateSize, overscan, enabled: !!scrollElement, // Fixed-height mode: no measurement — every row is exactly `estimateSize`, // so the virtualizer never measures/corrects and scroll stays jitter-free. measureElement: fixedRowHeight ? undefined : measureRowWithExpansion, })
// Register this body's scroll capability with the table context so consumers // (e.g. an editable grid's keyboard engine) can scroll a row into view via the // stable `scrollRowIntoView` handle without touching the virtualizer directly. React.useEffect(() => { registerRowScroller((index, opts) => rowVirtualizer.scrollToIndex(index, { align: opts?.align ?? "auto" }), ) return () => registerRowScroller(null) }, [registerRowScroller, rowVirtualizer])
// Passive scroll listener — shared `createScrollHandler` across all body variants. React.useEffect(() => { if (!scrollElement) return if (!onScroll && !onScrolledTop && !onScrolledBottom) return
const handleScroll = createScrollHandler({ onScroll, onScrolledTop, onScrolledBottom, scrollThreshold, }) scrollElement.addEventListener("scroll", handleScroll, { passive: true }) return () => scrollElement.removeEventListener("scroll", handleScroll) }, [ scrollElement, onScroll, onScrolledTop, onScrolledBottom, scrollThreshold, ])
const virtualItems = rowVirtualizer.getVirtualItems() const hasVirtualItems = virtualItems.length > 0
// Calculate spacer heights for virtual scrolling const topSpacerHeight = hasVirtualItems ? virtualItems[0]!.start : 0 const lastItem = hasVirtualItems ? virtualItems[virtualItems.length - 1]! : null const bottomSpacerHeight = lastItem ? rowVirtualizer.getTotalSize() - lastItem.end : 0
// Virtualizer-index-driven prefetch: fires once on false→true transition, // catching fast scrolls, scrollbar drags, and short initial pages that // scroll-event-based triggers miss. const isNearEnd = onNearEnd !== undefined && rows.length > 0 && lastItem !== null && lastItem.index >= rows.length - 1 - prefetchThreshold
const wasNearEndRef = React.useRef(false) React.useEffect(() => { if (isNearEnd && !wasNearEndRef.current) { onNearEnd?.() } wasNearEndRef.current = isNearEnd }, [isNearEnd, onNearEnd])
// One stable handler vs N inline closures — at 20 rows × 60fps that's // hundreds of allocations/sec saved during scroll. const handleRowClick = React.useCallback( (event: React.MouseEvent<HTMLElement>) => { if (!onRowClick) return if (isInteractiveClickTarget(event.target as HTMLElement)) return
// Resolve via stable `row.id` rather than a positional index — // sort/filter/reorder leave indices unstable but ids are // canonical. `table.getRow` is a Map lookup internally so this // stays O(1). const rowId = event.currentTarget.dataset.rowId if (rowId == null) return const row = table.getRow(rowId) if (!row) return onRowClick(row.original as TData, event) }, [onRowClick, table], )
// Stable wrapper around the virtualizer's measure callback. The // virtualizer recreates its `measureElement` on every render, which // would invalidate `React.memo` on the row component if passed // directly. The latest-ref pattern keeps the prop reference stable // while still calling through to the current measurer. const measureElementRef = React.useRef(rowVirtualizer.measureElement) measureElementRef.current = rowVirtualizer.measureElement const stableMeasureElement = React.useCallback( (node: HTMLTableRowElement | null) => { measureElementRef.current(node) }, [], )
const isClickable = !!onRowClick const visibleColumnCount = table.getVisibleLeafColumns().length
// Composable path: the per-row menu may come from the `renderRowContextMenu` // prop OR a nested `<DataTableRowContextMenuSlot>` child (prop wins). const resolvedRenderRowContextMenu = useResolvedRowContextMenuRenderer( renderRowContextMenu, children, )
// Composable path: same marker as the standard body. A band is taller than a // leaf row, so it relies on the virtualizer's dynamic measurement — which is // why `fixedRowHeight` and grouping do not mix (see the grouping docs). const groupedRowsSlot = React.useMemo( () => resolveGroupedRowsSlot(children), [children], ) return ( <TableBody ref={parentRef} className={cn(className)}> {/* Top spacer — colSpan keeps it within native table layout */} {topSpacerHeight > 0 && ( <tr aria-hidden> <td colSpan={visibleColumnCount} style={{ height: `${topSpacerHeight}px`, padding: 0, border: "none", }} /> </tr> )}
{/* Render visible rows */} {virtualItems.map(virtualRow => { const row = rows[virtualRow.index] if (!row) return null const isExpanded = row.getIsExpanded()
// Composite key forces a remount on expansion toggle so // `ResizeObserver` re-attaches and re-reads the combined height. // DnD bodies use a stable key (preserving `useSortable`) and // re-measure imperatively instead. const measure = fixedRowHeight ? undefined : columnsLocked ? stableMeasureElement : undefined
if (groupedRowsSlot && row.getIsGrouped()) { return ( <GroupedBodyRow key={`${row.id}-${isExpanded}`} row={row as unknown as DataTableRow<RowData>} displayIndex={virtualRow.index} isExpanded={isExpanded} columnWidths={columnWidths} columnLayoutSignature={columnLayoutSignature} slot={groupedRowsSlot} measureRef={measure} virtualIndex={virtualRow.index} /> ) }
return ( <VirtualizedBodyRow key={`${row.id}-${isExpanded}`} row={row as DataTableRow<TData>} virtualIndex={virtualRow.index} expandColumnId={expandColumnId} isExpanded={isExpanded} isSelected={row.getIsSelected()} isActiveRow={ activeRowRange !== null && virtualRow.index >= activeRowRange.min && virtualRow.index <= activeRowRange.max } columnWidths={columnWidths} isClickable={isClickable} measureRef={ fixedRowHeight ? undefined : columnsLocked ? stableMeasureElement : undefined } onClick={handleRowClick} getRowClassName={getRowClassName} getCellClassName={getCellClassName} columnLayoutSignature={columnLayoutSignature} rowMemoKey={ getRowMemoKey ? getRowMemoKey(row.original as TData) : "" } isRowFlashing={flashingRowIds.has(row.id)} flashingCellKeys={flashingCellKeys} renderRowContextMenu={resolvedRenderRowContextMenu} /> ) })}
{/* Bottom spacer */} {bottomSpacerHeight > 0 && ( <tr aria-hidden> <td colSpan={visibleColumnCount} style={{ height: `${bottomSpacerHeight}px`, padding: 0, border: "none", }} /> </tr> )}
{/* Composable children — Skeleton, EmptyBody, LoadingMore, and any other data-table body states are rendered here. Each self-gates on its own visibility, so the consumer just drops them in without needing conditional JSX. */} {children} </TableBody> )}
DataTableVirtualizedBody.displayName = "DataTableVirtualizedBody"
// ============================================================================// DataTableVirtualizedEmptyBody// ============================================================================
export interface DataTableVirtualizedEmptyBodyProps { children?: React.ReactNode colSpan?: number className?: string}
/** * Empty state component specifically for virtualized tables. * Uses flex layout to properly center content in virtualized table bodies. * Use composition pattern with DataTableEmpty* components for full customization. * * @example * <DataTableVirtualizedEmptyBody> * <DataTableEmptyIcon> * <PackageOpen className="size-12" /> * </DataTableEmptyIcon> * <DataTableEmptyMessage> * <DataTableEmptyTitle>No products found</DataTableEmptyTitle> * <DataTableEmptyDescription> * Get started by adding your first product * </DataTableEmptyDescription> * </DataTableEmptyMessage> * <DataTableEmptyFilteredMessage> * No matches found * </DataTableEmptyFilteredMessage> * <DataTableEmptyActions> * <Button onClick={handleAdd}>Add Product</Button> * </DataTableEmptyActions> * </DataTableVirtualizedEmptyBody> */export function DataTableVirtualizedEmptyBody({ children, colSpan, className,}: DataTableVirtualizedEmptyBodyProps) { const { table, columns, isLoading } = useDataTable()
// Hooks first (rules-of-hooks), then early-return below skips work when // the table has rows. const tableState = table.state const isFiltered = React.useMemo( () => isGlobalFilterActive(tableState.globalFilter) || (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={cn("text-center", className)} > <DataTableEmptyState isFiltered={isFiltered}> {children} </DataTableEmptyState> </TableCell> </TableRow> )}
DataTableVirtualizedEmptyBody.displayName = "DataTableVirtualizedEmptyBody"
// ============================================================================// DataTableVirtualizedSkeleton// ============================================================================
export interface DataTableVirtualizedSkeletonProps { children?: React.ReactNode /** * Number of skeleton rows to display. * @default 5 * @recommendation Set this to match your visible viewport for better UX */ rows?: number /** * Estimated row height (should match estimateSize prop of DataTableVirtualizedBody). * @default 34 */ estimateSize?: number className?: string cellClassName?: string skeletonClassName?: string}
export function DataTableVirtualizedSkeleton({ children, rows = 5, estimateSize = 34, className, cellClassName, skeletonClassName,}: DataTableVirtualizedSkeletonProps) { const { table, isLoading } = useDataTable()
// Show skeleton only when loading if (!isLoading) return null
// Get visible columns from table const visibleColumns = table.getVisibleLeafColumns()
// If custom children provided, show single row with custom content if (children) { return ( <TableRow> <TableCell colSpan={visibleColumns.length} className={cn("h-24 text-center", className)} > {children} </TableCell> </TableRow> ) }
// Show skeleton rows that mimic the virtualized table structure return ( <> {Array.from({ length: rows }).map((_, rowIndex) => ( <TableRow key={rowIndex} style={{ height: `${estimateSize}px` }}> {visibleColumns.map((column, colIndex) => { const size = column.columnDef.size const cellStyle = size ? { width: `${size}px` } : undefined
return ( <TableCell key={colIndex} className={cn(cellClassName)} style={cellStyle} > <Skeleton className={cn("h-4 w-full", skeletonClassName)} /> </TableCell> ) })} </TableRow> ))} </> )}
DataTableVirtualizedSkeleton.displayName = "DataTableVirtualizedSkeleton"
// ============================================================================// DataTableVirtualizedLoading// ============================================================================
export interface DataTableVirtualizedLoadingProps { children?: React.ReactNode colSpan?: number className?: string}
/** * Loading state component specifically for virtualized tables. * Uses flex layout to properly center content in virtualized table bodies. */export function DataTableVirtualizedLoading({ children, colSpan, className,}: DataTableVirtualizedLoadingProps) { const { table, columns, isLoading } = useDataTable()
// Show loading only when loading 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> )}
DataTableVirtualizedLoading.displayName = "DataTableVirtualizedLoading"
// ============================================================================// DataTableVirtualizedLoadingMore// ============================================================================
export interface DataTableVirtualizedLoadingMoreProps { /** * 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 virtualized * tables. Renders at the end of the body when `isFetching` is true, * and nothing when false — designed to be dropped as a child of * `DataTableVirtualizedBody` alongside `DataTableVirtualizedSkeleton` * and `DataTableVirtualizedEmptyBody`. * * Sits outside the virtualizer row count (it's just appended to the * `TableBody` children), so it doesn't affect `estimateSize` math. * * @example * const query = api.thing.list.useInfiniteQuery(...); * <DataTableVirtualizedBody * onScrolledBottom={() => { * if (query.hasNextPage && !query.isFetchingNextPage) { * void query.fetchNextPage(); * } * }} * > * <DataTableVirtualizedSkeleton rows={5} /> * <DataTableVirtualizedEmptyBody>No results</DataTableVirtualizedEmptyBody> * <DataTableVirtualizedLoadingMore isFetching={query.isFetchingNextPage}> * Loading more things… * </DataTableVirtualizedLoadingMore> * </DataTableVirtualizedBody> */export function DataTableVirtualizedLoadingMore({ isFetching, children, colSpan, className,}: DataTableVirtualizedLoadingMoreProps) { 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( "py-3 text-center text-xs text-muted-foreground", className, )} > <span className="inline-flex items-center justify-center gap-2"> <Loader2 className="size-3.5 animate-spin" aria-hidden="true" /> <span>{children ?? "Loading more…"}</span> </span> </TableCell> </TableRow> )}
DataTableVirtualizedLoadingMore.displayName = "DataTableVirtualizedLoadingMore"Update the import paths to match your project setup.
DataGrid (editable spreadsheet):
Turns the virtualized table into a keyboard-navigable, clipboard-aware editable grid. See the Data Grid docs.
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.
/** * 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 *//** * niko-table grid — cell model. * * Part of the niko-table editable DataGrid (registry item `niko-table/grid`). * Domain-free: no app/entity types, safe to mirror to the registry. * * Each editable cell keeps three things separate so a pasted-but-unmatched * value stays visible (and correctable) rather than being silently dropped: * raw — the literal text the user typed or pasted * value — the resolved id / parsed value, or null when unmatched * status — drives the cell's highlight (invalid = red) */
export type CellStatus = "valid" | "invalid" | "empty"
export interface CellState<T> { raw: string value: T | null status: CellStatus /** Human-readable reason shown in the cell's tooltip when invalid. */ error?: string}
/** * A cell is addressed by its row's stable id (NOT its position), so focus, * selection, and edits stay correct when the display is filtered / sorted / * reordered. The column is addressed by id too. */export interface CellPosition { rowId: string columnId: string}
/** * The copied rectangle (the "marching ants" copy outline), captured by identity so the * outline stays on the same cells across sort/filter. Sets give O(1) membership; * first/last ids mark which edges of a cell sit on the rectangle's perimeter. */export interface CopiedRange { rowIds: Set<string> firstRowId: string lastRowId: string columnIds: Set<string> firstColumnId: string lastColumnId: string}
/** * Inclusive bounds of the selection rectangle, in DISPLAY space: `minRow`/ * `maxRow` are the visible (post filter/sort) row indices, `minColIndex`/ * `maxColIndex` the visible column indices. Computed by the container, which * knows the display order. */export interface SelectionBounds { minRow: number maxRow: number minColIndex: number maxColIndex: number}
/** * A grid row: a client-only id (React key, never a persisted id) plus one * `CellState` per editable column. The index signature is permissive so * consumers can extend rows; the id is always a string. */export interface GridRow { id: string [columnId: string]: CellState<string> | string}
/** A blank, empty-status cell. */export function emptyCell<T>(): CellState<T> { return { raw: "", value: null, status: "empty" }}
/** Inclusive membership test for the selection rectangle. */export function isCellInBounds( bounds: SelectionBounds | null, rowIndex: number, colIndex: number,): boolean { return ( !!bounds && rowIndex >= bounds.minRow && rowIndex <= bounds.maxRow && colIndex >= bounds.minColIndex && colIndex <= bounds.maxColIndex )}"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 */
/** * niko-table grid — keyboard shortcut METADATA (a discoverable reference). * * The grid's actual keyboard is a raw scoped `keydown` state machine (the right * tool for an input surface — nav, type-to-edit, etc. aren't "hotkeys"). This * list is the human-readable mirror of it, for a help dialog or a consumer's own * shortcut UI. Keep it in sync with `<DataGrid>`'s handler when shortcuts change. */
import * as React from "react"
export interface GridShortcut { /** Key tokens. "Mod" renders as ⌘ on macOS, Ctrl elsewhere. */ keys: string[] description: string}
export interface GridShortcutGroup { category: string shortcuts: GridShortcut[]}
export const GRID_SHORTCUTS: GridShortcutGroup[] = [ { category: "Navigation", shortcuts: [ { keys: ["↑", "↓", "←", "→"], description: "Move between cells" }, { keys: ["Tab"], description: "Next cell (appends a row off the end)" }, { keys: ["Shift", "Tab"], description: "Previous cell" }, { keys: ["Mod", "↑↓←→"], description: "Jump to the edge of the data" }, { keys: ["Home"], description: "First column (⌘/Ctrl: first cell)" }, { keys: ["End"], description: "Last column (⌘/Ctrl: last cell)" }, { keys: ["PageUp"], description: "Up one page" }, { keys: ["PageDown"], description: "Down one page" }, ], }, { category: "Editing", shortcuts: [ { keys: ["Enter"], description: "Edit the cell, or commit and move down", }, { keys: ["Type"], description: "Overwrite the cell" }, { keys: ["Delete"], description: "Clear the selected cells" }, { keys: ["Drag ⤡"], description: "Fill handle — drag to fill down/across", }, { keys: ["Double-click ⤡"], description: "Fill handle — auto-fill down" }, { keys: ["Esc"], description: "Cancel edit / clear copy marker" }, ], }, { category: "Selection", shortcuts: [ { keys: ["Drag"], description: "Select a range" }, { keys: ["Drag border"], description: "Move the selection (hold ⌘/Ctrl to copy)", }, { keys: ["Shift", "Click"], description: "Extend selection to a cell" }, { keys: ["Shift", "↑↓←→"], description: "Extend selection" }, { keys: ["Mod", "Shift", "↑↓←→"], description: "Extend to the data edge", }, { keys: ["Mod", "A"], description: "Select all cells" }, ], }, { category: "Clipboard", shortcuts: [ { keys: ["Mod", "C"], description: "Copy" }, { keys: ["Mod", "X"], description: "Cut" }, { keys: ["Mod", "V"], description: "Paste" }, ], }, { category: "History", shortcuts: [ { keys: ["Mod", "Z"], description: "Undo" }, { keys: ["Mod", "Shift", "Z"], description: "Redo" }, ], },]
const subscribeToNothing = () => () => {}
function readIsMac(): boolean { const nav = navigator as Navigator & { userAgentData?: { platform?: string } } const platform = nav.userAgentData?.platform ?? nav.platform ?? nav.userAgent return /Mac|iPhone|iPad|iPod/i.test(platform)}
/** * True on macOS (⌘) vs Ctrl platforms. Hydration-safe: the server snapshot is * `false` and the client value resolves right after hydration, so SSR'd "Ctrl" * markup never mismatches a "⌘" client render. */export function useIsMac(): boolean { return React.useSyncExternalStore(subscribeToNothing, readIsMac, () => false)}
/** Render a key token to its display label for the current platform. */export function formatShortcutKey(token: string, isMac: boolean): string { switch (token) { case "Mod": return isMac ? "⌘" : "Ctrl" case "Shift": return isMac ? "⇧" : "Shift" case "Enter": return isMac ? "⏎" : "Enter" case "Esc": return "Esc" default: return token }}"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 *//** * niko-table grid — headless interaction engine. * * Part of the niko-table editable DataGrid (registry item `niko-table/grid`). * Domain-free. Owns the grid STATE a table model has no concept of: the * focused cell, the selection anchor, the edit lifecycle, the copy-source * marching-ants — all addressed by ROW ID (not position) so they survive * filtering / sorting / reordering. Navigation and selection GEOMETRY live in * the container (`<DataGrid>`), which knows the display order; this hook is pure * state + id-keyed setters. */
import { useCallback, useEffect, useMemo, useReducer, useState } from "react"
import { type CellPosition, type CellState, type GridRow,} from "../types/grid-cell"
/** Max undo/redo depth. Snapshots share unchanged row objects, so memory is * roughly O(changes), not O(rows × history). */const MAX_HISTORY = 100
/** * A general "what changed last commit" signal (see `UseDataGrid.lastCommit`). * `ids` enumerates the touched rows for granular ops (the O(1) hot path); * `bulk`/`reset` don't enumerate. `seq` advances on every real commit (never on * a no-op) so observers can `useEffect([lastCommit?.seq])`. Domain-free — an * analytics hook, a dirty-badge, or `useGridChanges` can all read it. */export type GridCommit = | { kind: "set" | "add" | "remove"; ids: readonly string[]; seq: number } | { kind: "bulk" | "reset"; seq: number }
/** Meta each commit is tagged with so the reducer can surface `lastCommit`. */type GridCommitMeta = | { kind: "set" | "add" | "remove"; ids: readonly string[] } | { kind: "bulk" | "reset" }
interface HistoryState<TRow> { rows: TRow[] past: TRow[][] future: TRow[][] lastCommit: GridCommit | null}
type HistoryAction<TRow> = | { type: "commit"; updater: (rows: TRow[]) => TRow[]; meta: GridCommitMeta } | { type: "settle"; updater: (rows: TRow[]) => TRow[] } | { type: "undo" } | { type: "redo" }
/** * True when `next` is the same row list as `prev` — either by array identity * or by element identity (same length, every `next[i] === prev[i]`). * * Updaters often `rows.map(...)` and return the same row refs for unchanged * rows. Without the element-wise check, a brand-new array of identical refs * would still bump `seq` and re-fire `lastCommit` observers (e.g. validation * settle loops forever after the first edit). */function isSameRowList<TRow>(prev: TRow[], next: TRow[]): boolean { if (next === prev) return true if (next.length !== prev.length) return false for (let i = 0; i < next.length; i++) { if (next[i] !== prev[i]) return false } return true}
function historyReducer<TRow>( state: HistoryState<TRow>, action: HistoryAction<TRow>,): HistoryState<TRow> { const seq = (state.lastCommit?.seq ?? 0) + 1 switch (action.type) { case "commit": { const next = action.updater(state.rows) if (isSameRowList(state.rows, next)) return state // no-op → no history entry, seq unchanged const past = state.past.length >= MAX_HISTORY ? [...state.past.slice(1), state.rows] : [...state.past, state.rows] return { rows: next, past, future: [], lastCommit: { ...action.meta, seq }, } } case "settle": { // A non-undoable row update: re-settling derived state (e.g. validation // status folded onto cells after an edit) must NOT push a history entry // or clear the redo stack, or undo/redo would step through the settle // instead of the user's actual edits. Past/future are left untouched. const next = action.updater(state.rows) if (isSameRowList(state.rows, next)) return state // converged → no change, no seq bump return { ...state, rows: next, lastCommit: { kind: "bulk", seq } } } case "undo": { if (state.past.length === 0) return state const prev = state.past[state.past.length - 1]! return { rows: prev, past: state.past.slice(0, -1), future: [state.rows, ...state.future], lastCommit: { kind: "bulk", seq }, } } case "redo": { if (state.future.length === 0) return state const next = state.future[0]! return { rows: next, past: [...state.past, state.rows], future: state.future.slice(1), lastCommit: { kind: "bulk", seq }, } } default: return state }}
export interface UseDataGridConfig<TRow extends GridRow> { /** Editable column ids in visual order — the focusable/navigable cells. */ columnIds: readonly string[] /** Factory for a fresh, all-empty row. Owns id generation. */ createEmptyRow: (id: string) => TRow /** Hard cap on rows (e.g. a server's bulk-create max). Default 200. */ maxRows?: number /** Rows to seed on mount. Default 5. Ignored when `initialRows` is given. */ initialRowCount?: number /** Seed with these exact rows (e.g. editing existing data) instead of blanks. */ initialRows?: TRow[] /** * Client id generator for runtime inserts (`addRows` / `insertRows`). * Default `crypto.randomUUID()`. Injectable for tests. * * Blank grids seeded via `initialRowCount` (no `initialRows`) use * deterministic `row-0`… ids so SSR and hydration agree — `makeId` is not * used for that path. */ makeId?: () => string}
export interface UseDataGrid<TRow extends GridRow> { rows: TRow[] /** What changed in the last commit — an observe-changes signal for external * layers (e.g. `useGridChanges` for staging/autosave/CRUD). Null until the * first commit; `seq` advances only on a real (non-no-op) commit. */ lastCommit: GridCommit | null /** Active cell (moving corner of the selection + edit target), by id. */ focusedCell: CellPosition | null /** Open cell editor, or null in navigate mode. */ editingCell: CellPosition | null /** Fixed corner of the selection rectangle (null ⇒ single-cell selection). */ selectionAnchor: CellPosition | null /** Char to seed the editor with when editing starts by typing (else null). */ editSeed: string | null /** Select a single cell (collapses any range + closes the editor). */ selectCell: (pos: CellPosition) => void /** Clear the cell selection (focus + anchor + editing). */ deselect: () => void /** Extend the selection so the active corner becomes `pos` (anchor stays). */ extendSelectionTo: (pos: CellPosition) => void /** Open the editor for a cell, optionally seeding it with a typed char. */ startEditing: (pos: CellPosition, seed?: string | null) => void stopEditing: () => void /** Set a single resolved cell by row id (O(1): only that row's ref changes). */ setCell: (rowId: string, columnId: string, next: CellState<string>) => void /** * Replace the whole rows array (paste expand + fill, sort, etc.). Undoable by * default; pass `{ history: false }` to re-settle derived state (validation) * without pushing an undo entry or clearing the redo stack. */ updateRows: ( updater: (rows: TRow[]) => TRow[], opts?: { history?: boolean }, ) => void /** Append `count` empty rows; returns the created rows (with ids). */ addRows: (count: number) => TRow[] /** Insert `count` empty rows above/below a row (by id); returns them. */ insertRows: ( anchorRowId: string, position: "above" | "below", count: number, ) => TRow[] removeRow: (rowId: string) => void removeRows: (rowIds: Iterable<string>) => void /** Replace every row with blanks (count = seed length or `initialRowCount`). */ clearAll: () => void /** Undo the last data mutation (setCell / paste / insert / delete / clear). */ undo: () => void redo: () => void canUndo: boolean canRedo: boolean /** Editable column ids in visual order — for the container's navigation. */ columnIds: readonly string[] maxRows: number}
export function useDataGrid<TRow extends GridRow>( config: UseDataGridConfig<TRow>,): UseDataGrid<TRow> { const { columnIds, createEmptyRow, maxRows = 200, initialRowCount = 5, initialRows, makeId = () => crypto.randomUUID(), } = config
const [history, dispatch] = useReducer( historyReducer<TRow>, null, (): HistoryState<TRow> => ({ // Deterministic ids for the blank seed — `crypto.randomUUID()` here would // mismatch SSR vs client. Runtime inserts still go through `makeId`. rows: initialRows ?? Array.from({ length: initialRowCount }, (_, i) => createEmptyRow(`row-${i}`), ), past: [], future: [], lastCommit: null, }), ) const rows = history.rows const lastCommit = history.lastCommit const canUndo = history.past.length > 0 const canRedo = history.future.length > 0
const [focusedCell, setFocusedCell] = useState<CellPosition | null>(null) const [editingCell, setEditingCell] = useState<CellPosition | null>(null) const [selectionAnchor, setSelectionAnchor] = useState<CellPosition | null>( null, ) const [editSeed, setEditSeed] = useState<string | null>(null)
const selectCell = useCallback((pos: CellPosition) => { setFocusedCell(pos) setSelectionAnchor(pos) setEditingCell(null) setEditSeed(null) }, [])
// Clear the cell selection entirely (click-away, deselect a column/row). const deselect = useCallback(() => { setFocusedCell(null) setSelectionAnchor(null) setEditingCell(null) setEditSeed(null) }, [])
const extendSelectionTo = useCallback( (pos: CellPosition) => { setSelectionAnchor(anchor => anchor ?? focusedCell ?? pos) setFocusedCell(pos) // Match `selectCell`: leave edit mode so a shift-drag can't leave an // open editor committing against a selection the user has already moved. setEditingCell(null) setEditSeed(null) }, [focusedCell], )
const startEditing = useCallback( (pos: CellPosition, seed: string | null = null) => { setFocusedCell(pos) setSelectionAnchor(pos) setEditingCell(pos) setEditSeed(seed) }, [], )
const stopEditing = useCallback(() => { setEditingCell(null) setEditSeed(null) }, [])
// O(1) commit: only the matching row's object identity changes. All row // mutations flow through `dispatch({ type: "commit" })` so each is undoable. const setCell = useCallback( (rowId: string, columnId: string, next: CellState<string>) => { dispatch({ type: "commit", updater: prev => prev.map(row => row.id === rowId ? ({ ...row, [columnId]: next } as TRow) : row, ), meta: { kind: "set", ids: [rowId] }, }) }, [], )
const updateRows = useCallback( (updater: (rows: TRow[]) => TRow[], opts?: { history?: boolean }) => { const boundedUpdater = (prev: TRow[]) => { // Preserve the updater's no-op identity (`prev` back out) so the // reducer's no-op guard holds and no phantom history entry is pushed. const next = updater(prev) if (next === prev) return prev return next.length > maxRows ? next.slice(0, maxRows) : next } // `history: false` re-settles derived state without an undoable entry — // used by validation to fold cell status after an edit (see the reducer's // `settle` case). Everything else stays undoable. dispatch( opts?.history === false ? { type: "settle", updater: boundedUpdater } : { type: "commit", meta: { kind: "bulk" }, updater: boundedUpdater }, ) }, [maxRows], )
const addRows = useCallback( (count: number) => { const room = Math.max(0, maxRows - rows.length) const toAdd = Math.min(count, room) const created = Array.from({ length: toAdd }, () => createEmptyRow(makeId()), ) if (created.length > 0) dispatch({ type: "commit", updater: prev => [...prev, ...created], meta: { kind: "add", ids: created.map(r => r.id) }, }) return created }, [rows.length, createEmptyRow, makeId, maxRows], )
const insertRows = useCallback( (anchorRowId: string, position: "above" | "below", count: number) => { const room = Math.max(0, maxRows - rows.length) const toAdd = Math.min(count, room) const created = Array.from({ length: toAdd }, () => createEmptyRow(makeId()), ) if (created.length > 0) { dispatch({ type: "commit", updater: prev => { const idx = prev.findIndex(r => r.id === anchorRowId) if (idx === -1) return [...prev, ...created] const at = position === "above" ? idx : idx + 1 const next = prev.slice() next.splice(at, 0, ...created) return next }, meta: { kind: "add", ids: created.map(r => r.id) }, }) } return created }, [rows.length, createEmptyRow, makeId, maxRows], )
// Drop any focus/selection/edit state that pointed at removed rows — // otherwise a stale focusedCell leaves the grid cursor-less until the next // click, and a stale anchor produces a bogus selection rectangle. const releaseRemovedRows = useCallback((removed: (id: string) => boolean) => { setFocusedCell(fc => (fc && removed(fc.rowId) ? null : fc)) setSelectionAnchor(a => (a && removed(a.rowId) ? null : a)) setEditingCell(ec => (ec && removed(ec.rowId) ? null : ec)) }, [])
const removeRow = useCallback( (rowId: string) => { // Decide OUTSIDE the reducer updater (React may defer/replay it): when // the last-row guard blocks the delete, focus/edit state on the // surviving row must be kept — releasing it would silently close an // open editor and drop its draft even though nothing was deleted. if (rows.length <= 1 || !rows.some(r => r.id === rowId)) return dispatch({ type: "commit", updater: prev => { if (prev.length <= 1) return prev const next = prev.filter(r => r.id !== rowId) return next.length === prev.length ? prev : next }, meta: { kind: "remove", ids: [rowId] }, }) releaseRemovedRows(id => id === rowId) }, [rows, releaseRemovedRows], )
const removeRows = useCallback( (rowIds: Iterable<string>) => { const ids = new Set(rowIds) if (!rows.some(r => ids.has(r.id))) return dispatch({ type: "commit", updater: prev => { const kept = prev.filter(r => !ids.has(r.id)) if (kept.length === prev.length) return prev return kept.length > 0 ? kept : [createEmptyRow(makeId())] }, meta: { kind: "remove", ids: [...ids] }, }) releaseRemovedRows(id => ids.has(id)) }, [rows, createEmptyRow, makeId, releaseRemovedRows], )
// Replace every row with blanks. Row count matches the seed (`initialRows` // length, else `initialRowCount`) so a 500-row stress demo stays 500-tall. const clearAll = useCallback(() => { const count = initialRows?.length ?? initialRowCount dispatch({ type: "commit", updater: () => Array.from({ length: count }, () => createEmptyRow(makeId())), meta: { kind: "reset" }, }) setFocusedCell(null) setEditingCell(null) setSelectionAnchor(null) setEditSeed(null) }, [createEmptyRow, initialRowCount, initialRows, makeId])
const undo = useCallback(() => dispatch({ type: "undo" }), []) const redo = useCallback(() => dispatch({ type: "redo" }), [])
// Undo/redo can restore or drop rows without going through removeRow — // drop focus/anchor/edit that point at ids no longer in `rows`. useEffect(() => { const present = new Set(rows.map(r => r.id)) releaseRemovedRows(id => !present.has(id)) }, [rows, releaseRemovedRows])
return useMemo( () => ({ rows, lastCommit, focusedCell, editingCell, selectionAnchor, editSeed, selectCell, deselect, extendSelectionTo, startEditing, stopEditing, setCell, updateRows, addRows, insertRows, removeRow, removeRows, clearAll, undo, redo, canUndo, canRedo, columnIds, maxRows, }), [ rows, lastCommit, focusedCell, editingCell, selectionAnchor, editSeed, selectCell, deselect, extendSelectionTo, startEditing, stopEditing, setCell, updateRows, addRows, insertRows, removeRow, removeRows, clearAll, undo, redo, canUndo, canRedo, columnIds, maxRows, ], )}/** * 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 *//** * niko-table grid — clipboard pure helpers. * * Part of the niko-table editable DataGrid (registry item `niko-table/grid`). * Domain-free, unit-testable without React. The copy/cut/paste WIRING lives in * the `<DataGrid>` container (it needs the display order); these helpers do the * TSV serialization/parsing over display-ordered rows. */
import type { CellState, GridRow } from "../types/grid-cell"
/** * Read a row's cell raw text. Rows created before a dynamically added column * have no CellState entry for it — treat that as empty, never crash. */function rawOf(row: GridRow, columnId: string): string { return (row[columnId] as CellState<string> | undefined)?.raw ?? ""}
/** * Quote a cell for TSV when it contains a tab, newline, or quote — the same * convention Excel/Google Sheets use on their clipboard, so multi-line cells * round-trip instead of tearing into extra rows. */function encodeTsvCell(raw: string): string { return /[\t\n\r"]/.test(raw) ? `"${raw.replace(/"/g, '""')}"` : raw}
/** * Split clipboard text into a matrix: rows on newlines, cells on tabs. * Quote-aware: a cell wrapped in double quotes may contain literal tabs and * newlines, and `""` escapes a quote (Excel/Sheets clipboard convention). */export function parseTsv(text: string): string[][] { // Strip only ONE trailing newline (the terminator external editors append) — // NOT all of them, so a selection ending in blank rows round-trips. const normalized = text.replace(/\r\n?/g, "\n").replace(/\n$/, "") const rows: string[][] = [] let row: string[] = [] let cell = "" let inQuotes = false for (let i = 0; i < normalized.length; i++) { const ch = normalized[i]! if (inQuotes) { if (ch === '"') { if (normalized[i + 1] === '"') { cell += '"' i++ } else { inQuotes = false } } else { cell += ch } } else if (ch === '"' && cell === "") { inQuotes = true } else if (ch === "\t") { row.push(cell) cell = "" } else if (ch === "\n") { row.push(cell) rows.push(row) row = [] cell = "" } else { cell += ch } } row.push(cell) rows.push(row) return rows}
/** * Serialize a selection rectangle to TSV (raw cell text). Rows are pulled lazily * by DISPLAY index via `getRow`, so this is O(selection) — it never touches rows * outside the rectangle (important at large row counts). `getRow` typically maps * a display index to the TanStack row's `.original`. */export function serializeSelection( getRow: (displayIndex: number) => GridRow | undefined, columnIds: readonly string[], bounds: { minRow: number maxRow: number minColIndex: number maxColIndex: number },): string { const lines: string[] = [] for (let r = bounds.minRow; r <= bounds.maxRow; r++) { const row = getRow(r) if (!row) continue const cells: string[] = [] for (let c = bounds.minColIndex; c <= bounds.maxColIndex; c++) { const columnId = columnIds[c] cells.push(columnId ? encodeTsvCell(rawOf(row, columnId)) : "") } lines.push(cells.join("\t")) } return lines.join("\n")}"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"
/** GridRow's reserved row-identity key — a column may not shadow it. */const RESERVED_COLUMN_ID = "id"
/** * A dynamic column descriptor. `type` is a consumer-defined tag (e.g. "text", * "select", "date") that the consumer maps to a cell editor + resolver — the * grid itself stays type-agnostic. Extra per-type config (e.g. `options` for a * select) rides along and is passed straight back to the consumer's renderer. */export interface GridColumnSpec { id: string label: string type: string width?: number options?: readonly string[]}
export interface GridColumnsApi { /** Current columns, in display order. Build your `tableColumns` from this. */ columns: GridColumnSpec[] /** Derived id list, in order — feed straight to `useDataGrid({ columnIds })`. */ columnIds: string[] /** * Append (or insert at `atIndex`) a new column and return it. Missing fields * default to a text column labelled "Column N". Existing rows need no change * — a cell with no value for the new column renders empty. */ addColumn: ( spec?: Partial<GridColumnSpec>, atIndex?: number, ) => GridColumnSpec /** Remove a column. Its cell data is left orphaned in the rows (harmless). */ removeColumn: (id: string) => void /** Rename a column's header label. */ renameColumn: (id: string, label: string) => void /** Replace a column's type (and optional per-type config). */ setColumnType: ( id: string, type: string, extra?: Pick<GridColumnSpec, "options">, ) => void /** Move a column one slot left (`-1`) or right (`1`). No-op at the edges. */ moveColumn: (id: string, dir: -1 | 1) => void /** * Reorder columns to match `orderedIds` (e.g. from a drag-reorder view menu's * `onColumnOrderChange`). Ids not listed keep their relative order at the end. */ reorderColumns: (orderedIds: string[]) => void}
export interface UseGridColumnsOptions { initialColumns: GridColumnSpec[] /** Mint a unique id for a new column. Default: an incrementing `newcol_N`. */ makeColumnId?: () => string}
/** * Owns the grid's column definitions as STATE so they can be added, renamed, * retyped, reordered, and removed at runtime. Opt-in: a grid with a fixed * column set never uses this. Pass `.columnIds` to `useDataGrid` and build your * TanStack `tableColumns` from `.columns`; drop `<DataGridColumns value={…}>` * around the table to wire the header menu + add-column button. */export function useGridColumns({ initialColumns, makeColumnId,}: UseGridColumnsOptions): GridColumnsApi { // Drop any column that shadows the reserved `id` key (would corrupt row.id), // and any later duplicate id — every column id must be unique. const [columns, setColumns] = React.useState<GridColumnSpec[]>(() => { const seen = new Set<string>() return initialColumns.filter(c => { if (c.id === RESERVED_COLUMN_ID || seen.has(c.id)) return false seen.add(c.id) return true }) })
const idRef = React.useRef(0) const mintId = React.useCallback( () => makeColumnId?.() ?? `newcol_${idRef.current++}`, [makeColumnId], )
// Live set of active column ids, kept in sync so `addColumn` can guarantee a // unique id: a caller-supplied `spec.id` may collide with an existing column, // and a minted id may too after removes/remounts. Reserving synchronously // covers back-to-back adds in one tick (state updates are async). const idSetRef = React.useRef<Set<string>>(new Set(columns.map(c => c.id))) React.useEffect(() => { idSetRef.current = new Set(columns.map(c => c.id)) }, [columns]) const mintUniqueId = React.useCallback( (preferred?: string) => { const active = idSetRef.current let id = preferred && preferred !== RESERVED_COLUMN_ID ? preferred : mintId() while (id === RESERVED_COLUMN_ID || active.has(id)) id = mintId() active.add(id) return id }, [mintId], ) // Monotonic default-label counter. Reading `columns.length` would be stale // across a batch of synchronous addColumn calls (state updates are async), so // rapid back-to-back adds would collide on the same "Column N"; a ref doesn't. const labelIndexRef = React.useRef(initialColumns.length)
const addColumn = React.useCallback<GridColumnsApi["addColumn"]>( (spec, atIndex) => { // Resolve id + label UP FRONT so the returned column matches exactly what // gets inserted. A spec that shadows the reserved `id` key is remapped. const created: GridColumnSpec = { id: mintUniqueId(spec?.id), label: spec?.label || `Column ${(labelIndexRef.current += 1)}`, type: spec?.type ?? "text", ...(spec?.width !== undefined ? { width: spec.width } : {}), ...(spec?.options !== undefined ? { options: spec.options } : {}), } setColumns(prev => { const at = atIndex === undefined ? prev.length : Math.max(0, Math.min(atIndex, prev.length)) const copy = prev.slice() copy.splice(at, 0, created) return copy }) return created }, [mintUniqueId], )
const removeColumn = React.useCallback((id: string) => { setColumns(prev => prev.length <= 1 ? prev : prev.filter(c => c.id !== id), ) }, [])
const renameColumn = React.useCallback((id: string, label: string) => { setColumns(prev => prev.map(c => (c.id === id ? { ...c, label } : c))) }, [])
const setColumnType = React.useCallback<GridColumnsApi["setColumnType"]>( (id, type, extra) => { setColumns(prev => prev.map(c => c.id === id ? { ...c, type, options: extra?.options } : c, ), ) }, [], )
const moveColumn = React.useCallback((id: string, dir: -1 | 1) => { setColumns(prev => { const i = prev.findIndex(c => c.id === id) const j = i + dir if (i < 0 || j < 0 || j >= prev.length) return prev const copy = prev.slice() const [moved] = copy.splice(i, 1) copy.splice(j, 0, moved!) return copy }) }, [])
const reorderColumns = React.useCallback((orderedIds: string[]) => { setColumns(prev => { const rank = new Map(orderedIds.map((id, i) => [id, i])) // Stable sort by the requested rank; unlisted ids keep their order last. return prev .map((c, i) => ({ c, i })) .sort((a, b) => { const ra = rank.get(a.c.id) const rb = rank.get(b.c.id) if (ra === undefined && rb === undefined) return a.i - b.i if (ra === undefined) return 1 if (rb === undefined) return -1 return ra - rb }) .map(({ c }) => c) }) }, [])
const columnIds = React.useMemo(() => columns.map(c => c.id), [columns])
return React.useMemo( () => ({ columns, columnIds, addColumn, removeColumn, renameColumn, setColumnType, moveColumn, reorderColumns, }), [ columns, columnIds, addColumn, removeColumn, renameColumn, setColumnType, moveColumn, reorderColumns, ], )}"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 { clampGridIndex as clamp, type GridDisplayRow,} from "../core/data-grid-features"import type { CellPosition, CellState, GridRow } from "../types/grid-cell"import type { UseDataGrid } from "./use-data-grid"
/** PageUp/Down sizing fallbacks when no row / scroll container is measurable. */const FALLBACK_ROW_HEIGHT = 36const FALLBACK_VIEWPORT_HEIGHT = 400
interface UseGridNavigationOptions<TRow extends GridRow> { grid: UseDataGrid<TRow> orderedRows: readonly GridDisplayRow[] columnIds: readonly string[] displayIndexOf: (id: string) => number | undefined focusedCell: CellPosition | null wrapperRef: React.RefObject<HTMLDivElement | null>}
export interface GridNavigation { /** Move the active cell by a delta in DISPLAY order (arrows). */ moveFocus: (dRow: number, dCol: number, extend?: boolean) => void /** Tab to the next/previous cell (appends a row off the end). */ tabNext: (reverse: boolean) => void /** Select or extend to an absolute display cell (Home/End variants). */ moveTo: (rowIdx: number, colIdx: number, extend: boolean) => void /** Ctrl+A — select the whole grid. */ selectAll: () => void /** Ctrl+Arrow — jump to the edge of the contiguous data block (the standard jump-to-data-edge behavior). */ moveToEdge: (dRow: number, dCol: number, extend: boolean) => void /** Rows per "page" for PageUp/PageDown (from the scroll viewport). */ pageRows: () => number}
/** * Display-order navigation for the grid core: arrows, Tab, Home/End, * Ctrl+Arrow edge jumps, select-all, and page sizing. */export function useGridNavigation<TRow extends GridRow>({ grid, orderedRows, columnIds, displayIndexOf, focusedCell, wrapperRef,}: UseGridNavigationOptions<TRow>): GridNavigation { const moveFocus = React.useCallback( (dRow: number, dCol: number, extend = false) => { if (orderedRows.length === 0 || columnIds.length === 0) return const base = focusedCell ?? { rowId: orderedRows[0]!.id, columnId: columnIds[0]!, } const curRow = displayIndexOf(base.rowId) ?? 0 const nextRow = clamp(curRow + dRow, 0, orderedRows.length - 1) const curCol = columnIds.indexOf(base.columnId) const nextCol = clamp(curCol + dCol, 0, columnIds.length - 1) const pos: CellPosition = { rowId: orderedRows[nextRow]!.id, columnId: columnIds[nextCol]!, } if (extend) grid.extendSelectionTo(pos) else grid.selectCell(pos) }, [orderedRows, displayIndexOf, focusedCell, columnIds, grid], )
const tabNext = React.useCallback( (reverse: boolean) => { if (orderedRows.length === 0 || columnIds.length === 0) return const lastCol = columnIds.length - 1 const base = focusedCell ?? { rowId: orderedRows[0]!.id, columnId: columnIds[0]!, } const rowDisp = displayIndexOf(base.rowId) ?? 0 const colIdx = columnIds.indexOf(base.columnId)
let nextRowDisp = rowDisp let nextCol: number if (reverse) { if (colIdx > 0) nextCol = colIdx - 1 else { nextCol = lastCol nextRowDisp = Math.max(0, rowDisp - 1) } } else if (colIdx < lastCol) { nextCol = colIdx + 1 } else { nextCol = 0 if (rowDisp + 1 >= orderedRows.length) { // Tab off the last cell → append a row and land on it. const created = grid.addRows(1) if (created.length > 0) { grid.selectCell({ rowId: created[0]!.id, columnId: columnIds[0]!, }) } return } nextRowDisp = rowDisp + 1 } grid.selectCell({ rowId: orderedRows[nextRowDisp]!.id, columnId: columnIds[nextCol]!, }) }, [orderedRows, displayIndexOf, focusedCell, columnIds, grid], )
const moveTo = React.useCallback( (rowIdx: number, colIdx: number, extend: boolean) => { if (orderedRows.length === 0 || columnIds.length === 0) return const r = clamp(rowIdx, 0, orderedRows.length - 1) const c = clamp(colIdx, 0, columnIds.length - 1) const pos: CellPosition = { rowId: orderedRows[r]!.id, columnId: columnIds[c]!, } if (extend) grid.extendSelectionTo(pos) else grid.selectCell(pos) }, [orderedRows, columnIds, grid], )
const selectAll = React.useCallback(() => { if (orderedRows.length === 0 || columnIds.length === 0) return grid.selectCell({ rowId: orderedRows[0]!.id, columnId: columnIds[0]! }) grid.extendSelectionTo({ rowId: orderedRows[orderedRows.length - 1]!.id, columnId: columnIds[columnIds.length - 1]!, }) }, [orderedRows, columnIds, grid])
// Ctrl+Arrow — from a filled cell, land on the last filled cell of the // contiguous block; from an empty/edge cell, skip to the next filled cell // (or the far edge). const moveToEdge = React.useCallback( (dRow: number, dCol: number, extend: boolean) => { if (!focusedCell || orderedRows.length === 0 || columnIds.length === 0) return const rowMax = orderedRows.length - 1 const colMax = columnIds.length - 1 const startRow = displayIndexOf(focusedCell.rowId) ?? 0 const startCol = columnIds.indexOf(focusedCell.columnId) const inBounds = (r: number, c: number) => r >= 0 && r <= rowMax && c >= 0 && c <= colMax const isEmptyAt = (r: number, c: number) => { const row = orderedRows[r]?.original const col = columnIds[c] if (!row || !col) return true // Rows created before a dynamically added column have no entry for it. const cell = row[col] as CellState<string> | undefined return (cell?.raw ?? "") === "" }
let r = startRow let c = startCol if (!inBounds(r + dRow, c + dCol)) { moveTo(r, c, extend) return } if (isEmptyAt(r, c) || isEmptyAt(r + dRow, c + dCol)) { // Skip to the next filled cell (or the far edge). r += dRow c += dCol while (inBounds(r + dRow, c + dCol) && isEmptyAt(r, c)) { r += dRow c += dCol } } else { // Walk to the last filled cell of the contiguous block. while (inBounds(r + dRow, c + dCol) && !isEmptyAt(r + dRow, c + dCol)) { r += dRow c += dCol } } moveTo(r, c, extend) }, [focusedCell, orderedRows, columnIds, displayIndexOf, moveTo], )
const pageRows = React.useCallback(() => { const scrollEl = wrapperRef.current?.querySelector<HTMLElement>( '[data-slot="table-container"]', ) const rowEl = wrapperRef.current?.querySelector<HTMLElement>("[data-index]") const rowH = rowEl?.getBoundingClientRect().height || FALLBACK_ROW_HEIGHT const viewport = scrollEl?.clientHeight || FALLBACK_VIEWPORT_HEIGHT return Math.max(1, Math.floor(viewport / rowH) - 1) }, [wrapperRef])
return React.useMemo( () => ({ moveFocus, tabNext, moveTo, selectAll, moveToEdge, pageRows }), [moveFocus, tabNext, moveTo, selectAll, moveToEdge, pageRows], )}"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 { CellPosition, GridRow } from "../types/grid-cell"import type { UseDataGrid } from "./use-data-grid"import type { GridNavigation } from "./use-grid-navigation"
interface UseGridKeyboardOptions<TRow extends GridRow> { grid: UseDataGrid<TRow> nav: GridNavigation focusedCell: CellPosition | null editingCell: CellPosition | null displayIndexOf: (id: string) => number | undefined rowCount: number columnCount: number clearSelection: () => void /** From the clipboard feature, when mounted — Escape clears the copy marker. */ clearCopiedRange: (() => void) | undefined onRequestShortcuts: (() => void) | undefined}
/** * The grid's scoped keyboard state machine — a raw `keydown` handler, NOT a * hotkey lib, because a grid is an input surface (type-to-edit must catch any * printable char, matching how text and grid editors handle input). Attach to the * grid wrapper's `onKeyDownCapture` (capture phase beats inner comboboxes). */export function useGridKeyboard<TRow extends GridRow>({ grid, nav, focusedCell, editingCell, displayIndexOf, rowCount, columnCount, clearSelection, clearCopiedRange, onRequestShortcuts,}: UseGridKeyboardOptions<TRow>) { const { moveFocus, tabNext, moveTo, selectAll, moveToEdge, pageRows } = nav
return (event: React.KeyboardEvent<HTMLDivElement>) => { if (editingCell) return
// Only treat keys as grid shortcuts when they originate from a cell (or // the grid wrapper itself, so focusing the grid + arrows still works). // Toolbar buttons, add-row footers, and other controls nested inside the // wrapper keep their native keyboard behavior (Enter/Space activation). const target = event.target as HTMLElement if (target !== event.currentTarget && !target.closest("[data-cell]")) { return }
// Undo / redo — Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z, Ctrl+Y. (Cmd+C/X/V fall // through to `<DataGridClipboard>`'s native listeners; while editing, the // input's own undo handles it — gated by the `editingCell` guard above.) const mod = event.metaKey || event.ctrlKey if (mod) { const k = event.key.toLowerCase() if (k === "z") { event.preventDefault() event.stopPropagation() if (event.shiftKey) grid.redo() else grid.undo() return } if (k === "y") { event.preventDefault() event.stopPropagation() grid.redo() return } if (k === "a") { event.preventDefault() event.stopPropagation() selectAll() return } }
switch (event.key) { case "Tab": event.preventDefault() event.stopPropagation() tabNext(event.shiftKey) return // Ctrl/Cmd+Arrow jumps to the data-block edge; plain Arrow moves one cell. case "ArrowUp": event.preventDefault() event.stopPropagation() if (mod) moveToEdge(-1, 0, event.shiftKey) else moveFocus(-1, 0, event.shiftKey) return case "ArrowDown": event.preventDefault() event.stopPropagation() if (mod) moveToEdge(1, 0, event.shiftKey) else moveFocus(1, 0, event.shiftKey) return case "ArrowLeft": event.preventDefault() event.stopPropagation() if (mod) moveToEdge(0, -1, event.shiftKey) else moveFocus(0, -1, event.shiftKey) return case "ArrowRight": event.preventDefault() event.stopPropagation() if (mod) moveToEdge(0, 1, event.shiftKey) else moveFocus(0, 1, event.shiftKey) return case "Home": // Home → first column of the row; Ctrl+Home → first cell of the grid. event.preventDefault() event.stopPropagation() if (mod) moveTo(0, 0, event.shiftKey) else if (focusedCell) moveTo(displayIndexOf(focusedCell.rowId) ?? 0, 0, event.shiftKey) return case "End": // End → last column of the row; Ctrl+End → last cell of the grid. event.preventDefault() event.stopPropagation() if (mod) moveTo(rowCount - 1, columnCount - 1, event.shiftKey) else if (focusedCell) moveTo( displayIndexOf(focusedCell.rowId) ?? 0, columnCount - 1, event.shiftKey, ) return case "PageUp": event.preventDefault() event.stopPropagation() moveFocus(-pageRows(), 0, event.shiftKey) return case "PageDown": event.preventDefault() event.stopPropagation() moveFocus(pageRows(), 0, event.shiftKey) return case "Delete": case "Backspace": // Clear the selected cells (standard behavior). Editing is guarded above. event.preventDefault() event.stopPropagation() clearSelection() return case "Enter": if (focusedCell) { event.preventDefault() event.stopPropagation() grid.startEditing(focusedCell) } return case "Escape": event.preventDefault() event.stopPropagation() clearCopiedRange?.() if (focusedCell) grid.selectCell(focusedCell) return } // `?` opens the shortcuts help (opt-in) instead of type-to-edit. if ( focusedCell && onRequestShortcuts && event.key === "?" && !event.metaKey && !event.ctrlKey ) { event.preventDefault() event.stopPropagation() onRequestShortcuts() return }
if ( focusedCell && !event.metaKey && !event.ctrlKey && !event.altKey && event.key.length === 1 ) { event.preventDefault() event.stopPropagation() grid.startEditing(focusedCell, event.key === " " ? undefined : event.key) } }}"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 { TooltipProvider } from "@/components/ui/tooltip"
import { useDataTable } from "../../core/data-table-context"import { useDataTableScroll } from "../../hooks/use-data-table-scroll"import type { UseDataGrid } from "../hooks/use-data-grid"import { useGridKeyboard } from "../hooks/use-grid-keyboard"import { useGridNavigation } from "../hooks/use-grid-navigation"import { emptyCell, type CellPosition, type CellState, type GridRow, type SelectionBounds,} from "../types/grid-cell"import { DataGridContextProvider } from "./data-grid-context"import { DataGridFeaturesProvider, DataGridInternalsProvider, GRID_EDGE_SPEED, GRID_EDGE_ZONE, type DataGridInternals, type GridEnv, type GridFeatures, type RegisterGridFeature,} from "./data-grid-features"
export interface DataGridProps<TRow extends GridRow> { grid: UseDataGrid<TRow> children: React.ReactNode className?: string /** * Called when the user presses `?` (with a focused cell, not editing) — wire * it to open a keyboard-shortcuts help dialog. When unset, `?` types normally. */ onRequestShortcuts?: () => void}
/** * The DataGrid container — the irreducible core. Place INSIDE `DataTableRoot`, * wrapping the `DataTable`. It reads the table's DISPLAY order (post * filter/sort) and owns the selection rectangle, keyboard navigation, drag * range-select, and the scroll-into-view seam. Cells (addressed by row id) * read their state from `DataGridContext`. * * Everything else is opt-in, composable children (mix and match — an unmounted * feature attaches no listeners and tree-shakes out of the bundle): * * @example * <DataGrid grid={grid}> * <DataGridClipboard resolveCell={resolveCell} /> // copy/cut/paste * <DataGridFillHandle /> // corner-drag fill * <DataGridMove /> // border-drag move/copy * <DataGridCrossHighlight /> // header/gutter highlight * <DataTable>…</DataTable> * </DataGrid> */
// Radix's TooltipProvider reads `delayDuration`, Base UI's reads `delay`;// spread so it typechecks in both shadcn generations.const gridTooltipDelay = { delayDuration: 0, delay: 0 }
export function DataGrid<TRow extends GridRow>({ grid, children, className, onRequestShortcuts,}: DataGridProps<TRow>) { const wrapperRef = React.useRef<HTMLDivElement>(null) const { scrollRowIntoView } = useDataTableScroll() const { table } = useDataTable<TRow>()
// Display order (post filter/sort) — the source of truth for navigation. // `row.index` is the row's SOURCE (data-array) position, which diverges from // its display position once the table is sorted or filtered — so we build the // id→DISPLAY-index map from `rows` array positions. Memoized on the row-model // identity: it rebuilds only when the model actually changes (incl. per edit), // not on unrelated renders. const rowModel = table.getRowModel() const orderedRows = rowModel.rows const displayIndexById = React.useMemo(() => { const map = new Map<string, number>() orderedRows.forEach((r, i) => map.set(r.id, i)) return map }, [orderedRows]) const displayIndexOf = React.useCallback( (id: string): number | undefined => displayIndexById.get(id), [displayIndexById], )
const { focusedCell, editingCell, selectionAnchor } = grid
// Display-space COLUMNS: the engine's editable columns filtered + ordered by // what the table actually renders (visibility, pinning, column order). Keeps // keyboard traversal, selection bounds, and paste targeting aligned with the // mounted cells when the user hides or pins columns via the header menus. const { columnVisibility, columnOrder, columnPinning } = table.state const gridColumnIds = grid.columnIds const columnIds = React.useMemo(() => { const editable = new Set(gridColumnIds) return table .getVisibleLeafColumns() .map(c => c.id) .filter(id => editable.has(id)) // eslint-disable-next-line react-hooks/exhaustive-deps -- visibility/order/pinning are the reactive inputs behind getVisibleLeafColumns() }, [table, gridColumnIds, columnVisibility, columnOrder, columnPinning])
// Selection rectangle in DISPLAY space. const selectionBounds = React.useMemo<SelectionBounds | null>(() => { if (!focusedCell) return null const anchor = selectionAnchor ?? focusedCell const aRow = displayIndexOf(anchor.rowId) const fRow = displayIndexOf(focusedCell.rowId) if (aRow === undefined || fRow === undefined) return null const aCol = columnIds.indexOf(anchor.columnId) const fCol = columnIds.indexOf(focusedCell.columnId) // Hidden / reordered-away columns yield -1 — never treat that as a bound. if (aCol < 0 || fCol < 0) return null return { minRow: Math.min(aRow, fRow), maxRow: Math.max(aRow, fRow), minColIndex: Math.min(aCol, fCol), maxColIndex: Math.max(aCol, fCol), } }, [focusedCell, selectionAnchor, columnIds, displayIndexOf])
// Drop focus when the focused row/column leaves the display model (filter, // hide column). Keeps Enter / type-to-edit from targeting a ghost cell. const deselect = grid.deselect React.useEffect(() => { if (!focusedCell) return const rowGone = displayIndexOf(focusedCell.rowId) === undefined const colGone = columnIds.indexOf(focusedCell.columnId) < 0 if (rowGone || colGone) deselect() }, [focusedCell, displayIndexOf, columnIds, deselect])
// --- opt-in features (registered by mounted children) ------------------- const [features, setFeatures] = React.useState<GridFeatures>({}) const registerFeature = React.useCallback<RegisterGridFeature>( (key, payload) => { setFeatures(prev => prev[key] === payload ? prev : { ...prev, [key]: payload }, ) }, [], )
// --- scroll-into-view seam --------------------------------------------- // Coarse vertical scroll (for far-off rows that aren't rendered yet) goes // through the virtualizer's `scrollToIndex`. Then we fine-tune BOTH axes from // the focused cell's real rect — the virtualizer's `align: "auto"` no-ops when // the target row is already rendered (within overscan) but below the visible // fold, so single-step arrow nav wouldn't scroll. Direct measurement fixes it, // and it mirrors exactly how the horizontal axis already worked. React.useLayoutEffect(() => { if (!focusedCell) return const idx = displayIndexOf(focusedCell.rowId) if (idx !== undefined) scrollRowIntoView(idx, { align: "auto" })
// rAF so the row is mounted (after a virtualized vertical scroll) before we // measure its cell for the fine-grained adjustment. const raf = requestAnimationFrame(() => { const wrapper = wrapperRef.current const scrollEl = wrapper?.querySelector<HTMLElement>( '[data-slot="table-container"]', ) const cellEl = wrapper?.querySelector<HTMLElement>( `[data-cell="${focusedCell.rowId}:${focusedCell.columnId}"]`, ) if (!scrollEl || !cellEl) return const cont = scrollEl.getBoundingClientRect() const cell = cellEl.getBoundingClientRect()
// Horizontal: sticky pins overlap the edges. Only LEFT-pinned cells have // a non-auto `left` — right pins are also sticky but must inset the // right edge (treating every sticky td as left used to yank scrollLeft). const rowEl = cellEl.closest("tr") let leftInset = 0 let rightInset = 0 rowEl?.querySelectorAll<HTMLElement>("td").forEach(td => { // The focused cell itself must not inset the scroll: a pinned focused // cell is already visible, and counting it would yank scrollLeft. if (td === cellEl) return const style = getComputedStyle(td) if (style.position !== "sticky") return const rect = td.getBoundingClientRect() if (style.left !== "auto") { leftInset = Math.max(leftInset, rect.right - cont.left) } if (style.right !== "auto") { rightInset = Math.max(rightInset, cont.right - rect.left) } }) if (cell.left < cont.left + leftInset) { scrollEl.scrollLeft -= cont.left + leftInset - cell.left } else if (cell.right > cont.right - rightInset) { scrollEl.scrollLeft += cell.right - (cont.right - rightInset) }
// Vertical: the sticky header overlaps the top edge — offset past it // (mirror of `leftInset`) so the focused row never hides under the header. const headerEl = scrollEl.querySelector<HTMLElement>("thead") const topInset = headerEl?.getBoundingClientRect().height ?? 0 if (cell.top < cont.top + topInset) { scrollEl.scrollTop -= cont.top + topInset - cell.top } else if (cell.bottom > cont.bottom) { scrollEl.scrollTop += cell.bottom - cont.bottom } }) return () => cancelAnimationFrame(raf) }, [focusedCell, displayIndexOf, scrollRowIntoView])
// --- navigation (display order) ---------------------------------------- const nav = useGridNavigation({ grid, orderedRows, columnIds, displayIndexOf, focusedCell, wrapperRef, }) const { moveFocus, tabNext } = nav
// Display index → the row's data (for lazy TSV serialization). const getDisplayRow = React.useCallback( (i: number): GridRow | undefined => orderedRows[i]?.original as GridRow, [orderedRows], )
// --- selection actions (used by keyboard + GridRowMenu + features) ------ const selectedRowIds = React.useCallback((): string[] => { if (!selectionBounds) return [] const ids: string[] = [] for (let r = selectionBounds.minRow; r <= selectionBounds.maxRow; r++) { const id = orderedRows[r]?.id if (id) ids.push(id) } return ids }, [selectionBounds, orderedRows])
const clearSelection = React.useCallback(() => { if (!selectionBounds) return const ids = new Set(selectedRowIds()) const { minColIndex, maxColIndex } = selectionBounds grid.updateRows(rows => rows.map(row => { if (!ids.has(row.id)) return row const patched = { ...row } as TRow for (let c = minColIndex; c <= maxColIndex; c++) { const col = columnIds[c] if (col) (patched as GridRow)[col] = emptyCell<string>() } return patched }), ) }, [selectionBounds, selectedRowIds, columnIds, grid])
const deleteSelectedRows = React.useCallback(() => { const ids = selectedRowIds() if (ids.length > 0) grid.removeRows(ids) }, [selectedRowIds, grid])
const insertRowsAbove = React.useCallback(() => { if (!selectionBounds) return const topId = orderedRows[selectionBounds.minRow]?.id const count = selectionBounds.maxRow - selectionBounds.minRow + 1 if (topId) grid.insertRows(topId, "above", count) }, [selectionBounds, orderedRows, grid])
const insertRowsBelow = React.useCallback(() => { if (!selectionBounds) return const bottomId = orderedRows[selectionBounds.maxRow]?.id const count = selectionBounds.maxRow - selectionBounds.minRow + 1 if (bottomId) grid.insertRows(bottomId, "below", count) }, [selectionBounds, orderedRows, grid])
// Select an entire column (header click) — top to bottom, that one column. // Clicking a column that's already fully selected deselects it (toggle). const selectColumn = React.useCallback( (columnId: string) => { if (orderedRows.length === 0) return const c = columnIds.indexOf(columnId) const b = selectionBounds const already = b != null && b.minRow === 0 && b.maxRow === orderedRows.length - 1 && b.minColIndex === c && b.maxColIndex === c if (already) { grid.deselect() return } grid.selectCell({ rowId: orderedRows[0]!.id, columnId }) grid.extendSelectionTo({ rowId: orderedRows[orderedRows.length - 1]!.id, columnId, }) }, [orderedRows, columnIds, selectionBounds, grid], )
// Select an entire row (row-number click) — all columns, that one row. // Clicking a row that's already fully selected deselects it (toggle). const selectRow = React.useCallback( (rowId: string) => { if (columnIds.length === 0) return const r = displayIndexOf(rowId) const b = selectionBounds const already = b != null && r != null && b.minRow === r && b.maxRow === r && b.minColIndex === 0 && b.maxColIndex === columnIds.length - 1 if (already) { grid.deselect() return } grid.selectCell({ rowId, columnId: columnIds[0]! }) grid.extendSelectionTo({ rowId, columnId: columnIds[columnIds.length - 1]!, }) }, [columnIds, selectionBounds, displayIndexOf, grid], )
// Fill every selected cell with one value (Ctrl/Cmd+Enter from an editor). const fillSelectionWith = React.useCallback( (cell: CellState<string>) => { if (!selectionBounds) return const ids = new Set(selectedRowIds()) const { minColIndex, maxColIndex } = selectionBounds grid.updateRows(rows => rows.map(row => { if (!ids.has(row.id)) return row const patched = { ...row } as TRow for (let c = minColIndex; c <= maxColIndex; c++) { const col = columnIds[c] if (col) (patched as GridRow)[col] = { ...cell } } return patched }), ) }, [selectionBounds, selectedRowIds, columnIds, grid], )
// Latest values once-subscribed listeners / rAF loops read (shared with // feature components through the internals context). const envRef = React.useRef<GridEnv>({ orderedRows: [], columnIds: [], displayIndexOf, grid: grid as unknown as UseDataGrid<GridRow>, selectionBounds: null, }) envRef.current = { orderedRows, columnIds, displayIndexOf, grid: grid as unknown as UseDataGrid<GridRow>, selectionBounds, }
// Select the whole display-space rectangle (fill/move land here after apply). const selectRange = React.useCallback((bounds: SelectionBounds) => { const env = envRef.current const anchorRowId = env.orderedRows[bounds.minRow]?.id const anchorColId = env.columnIds[bounds.minColIndex] const focusRowId = env.orderedRows[bounds.maxRow]?.id const focusColId = env.columnIds[bounds.maxColIndex] if (!anchorRowId || !anchorColId || !focusRowId || !focusColId) return env.grid.selectCell({ rowId: anchorRowId, columnId: anchorColId }) env.grid.extendSelectionTo({ rowId: focusRowId, columnId: focusColId }) }, [])
// --- drag range-select -------------------------------------------------- const draggingRef = React.useRef(false) const pointerRef = React.useRef({ x: 0, y: 0 }) const rafRef = React.useRef<number | null>(null)
const dragStep = React.useCallback(() => { if (!draggingRef.current) { rafRef.current = null return } const scrollEl = wrapperRef.current?.querySelector<HTMLElement>( '[data-slot="table-container"]', ) if (scrollEl) { const rect = scrollEl.getBoundingClientRect() const { x, y } = pointerRef.current if (y < rect.top + GRID_EDGE_ZONE) scrollEl.scrollTop -= GRID_EDGE_SPEED else if (y > rect.bottom - GRID_EDGE_ZONE) scrollEl.scrollTop += GRID_EDGE_SPEED if (x < rect.left + GRID_EDGE_ZONE) scrollEl.scrollLeft -= GRID_EDGE_SPEED else if (x > rect.right - GRID_EDGE_ZONE) scrollEl.scrollLeft += GRID_EDGE_SPEED
const attr = document .elementFromPoint(x, y) ?.closest("[data-cell]") ?.getAttribute("data-cell") if (attr) { const sep = attr.indexOf(":") grid.extendSelectionTo({ rowId: attr.slice(0, sep), columnId: attr.slice(sep + 1), }) } } rafRef.current = requestAnimationFrame(dragStep) }, [grid])
// Is a cell inside the current selection rectangle (display space)? const isPosInSelection = React.useCallback( (pos: CellPosition): boolean => { if (!selectionBounds) return false const r = displayIndexOf(pos.rowId) if (r === undefined) return false const c = columnIds.indexOf(pos.columnId) return ( r >= selectionBounds.minRow && r <= selectionBounds.maxRow && c >= selectionBounds.minColIndex && c <= selectionBounds.maxColIndex ) }, [selectionBounds, displayIndexOf, columnIds], )
const onCellMouseDown = React.useCallback( (pos: CellPosition, e: React.MouseEvent) => { // A cell editor (date calendar, combobox, etc.) portals its content out of // this cell's DOM, but React events still bubble through the React tree to // this handler. A mousedown INSIDE an open editor must NOT reselect the // cell — `selectCell` clears `editingCell`, unmounting the editor before // its own click / onSelect fires, so picking a value by mouse would // silently no-op. Editors mark their portaled surface with // `data-grid-cell-editor`. if ( (e.target as HTMLElement | null)?.closest?.("[data-grid-cell-editor]") ) return // Right-click: keep the selection if the cell is inside it (so the context // menu acts on the whole range, the standard behavior); otherwise move to the cell. // Never start a drag — let the context menu open. if (e.button === 2) { if (!isPosInSelection(pos)) grid.selectCell(pos) return } if (e.shiftKey) grid.extendSelectionTo(pos) else grid.selectCell(pos) // Seed the pointer from the mousedown itself — the drag rAF may run // before the first mousemove, and a stale pointer would extend the // selection toward wherever the cursor last was. pointerRef.current = { x: e.clientX, y: e.clientY } draggingRef.current = true if (rafRef.current == null) rafRef.current = requestAnimationFrame(dragStep) }, [grid, dragStep, isPosInSelection], )
const onCellMouseEnter = React.useCallback( (pos: CellPosition) => { if (draggingRef.current) grid.extendSelectionTo(pos) }, [grid], )
// Core listeners: track the pointer (shared with features through // `pointerRef`), end a drag range-select, and clear the selection on a // click OUTSIDE the grid (clicking away deselects, the standard behavior). Feature // components (fill, move) attach their own drag-scoped listeners only while // their drag is active. React.useEffect(() => { const onMove = (e: MouseEvent) => { pointerRef.current = { x: e.clientX, y: e.clientY } } const onUp = () => { draggingRef.current = false if (rafRef.current != null) { cancelAnimationFrame(rafRef.current) rafRef.current = null } } const onDocDown = (e: MouseEvent) => { const wrapper = wrapperRef.current const target = e.target as HTMLElement | null if (!wrapper || !target) return // Inside the grid → keep the selection. if (wrapper.contains(target)) return // Inside a portaled grid surface (menu / dialog / popover / cell editor) // → keep it too, so acting on the selection doesn't clear it first. if ( target.closest( '[role="menu"],[role="dialog"],[data-slot*="menu"],[data-slot*="dialog"],[data-slot*="popover"],[data-grid-cell-editor]', ) ) { return } grid.deselect() } window.addEventListener("mousemove", onMove) window.addEventListener("mouseup", onUp) window.addEventListener("mousedown", onDocDown, true) return () => { window.removeEventListener("mousemove", onMove) window.removeEventListener("mouseup", onUp) window.removeEventListener("mousedown", onDocDown, true) if (rafRef.current != null) cancelAnimationFrame(rafRef.current) } // eslint-disable-next-line react-hooks/exhaustive-deps -- grid.deselect is stable; mount-once listeners }, [])
// --- keyboard model ----------------------------------------------------- // The scoped keydown state machine (see `use-grid-keyboard.ts`). Escape also // clears the clipboard feature's copy marker when that feature is mounted. const onKeyDownCapture = useGridKeyboard({ grid, nav, focusedCell, editingCell, displayIndexOf, rowCount: orderedRows.length, columnCount: columnIds.length, clearSelection, clearCopiedRange: features.clipboard?.clearCopiedRange, onRequestShortcuts, })
const contextValue = React.useMemo( () => ({ grid, selectionBounds, columnIds, displayIndexOf, onCellMouseDown, onCellMouseEnter, moveFocus, tabNext, clearSelection, deleteSelectedRows, insertRowsAbove, insertRowsBelow, selectColumn, selectRow, fillSelectionWith, hasSelection: selectionBounds !== null, }), [ grid, selectionBounds, columnIds, displayIndexOf, onCellMouseDown, onCellMouseEnter, moveFocus, tabNext, clearSelection, deleteSelectedRows, insertRowsAbove, insertRowsBelow, selectColumn, selectRow, fillSelectionWith, ], )
const internals = React.useMemo<DataGridInternals>( () => ({ grid: grid as unknown as UseDataGrid<GridRow>, selectionBounds, orderedRows, columnIds, displayIndexOf, getDisplayRow, selectRange, clearSelection, wrapperRef, pointerRef, envRef, }), [ grid, selectionBounds, orderedRows, columnIds, displayIndexOf, getDisplayRow, selectRange, clearSelection, ], )
return ( <TooltipProvider {...gridTooltipDelay}> <DataGridContextProvider value={contextValue}> <DataGridInternalsProvider value={internals}> <DataGridFeaturesProvider features={features} register={registerFeature} > <div ref={wrapperRef} role="grid" tabIndex={0} onKeyDownCapture={onKeyDownCapture} className={className ?? "outline-none"} > {children} </div> </DataGridFeaturesProvider> </DataGridInternalsProvider> </DataGridContextProvider> </TooltipProvider> )}"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 { cn } from "@/lib/utils"import * as React from "react"
import type { CellEditorProps } from "../cells/cell-props"import type { UseDataGrid } from "../hooks/use-data-grid"import { type CellPosition, type CellState, type GridRow, type SelectionBounds, emptyCell, isCellInBounds,} from "../types/grid-cell"import { useDataGridFeatures } from "./data-grid-features"
// ---------------------------------------------------------------------------// Context — the CORE container provides the engine, the display-space// selection rectangle, navigation, and selection actions to cells + menus.// Opt-in capabilities (clipboard, fill, move, cross-highlight) publish through// the separate features context — see `data-grid-features.tsx`.// ---------------------------------------------------------------------------
export interface DataGridContextValue<TRow extends GridRow> { grid: UseDataGrid<TRow> /** Selection rectangle in DISPLAY space (post filter/sort). */ selectionBounds: SelectionBounds | null /** * Editable columns in DISPLAY order — the engine's columns filtered/ordered * by the table's visibility, pinning, and column order. */ columnIds: readonly string[] /** * Map a row id → its index in the current display order (post filter/sort). * Prefer this over TanStack's `row.index`, which is the source-data index. */ displayIndexOf: (rowId: string) => number | undefined /** Mouse-down on a cell: focus (or shift-extend) + begin a drag-select. */ onCellMouseDown: (pos: CellPosition, e: React.MouseEvent) => void /** Mouse-enter on a cell while dragging: extend the selection. */ onCellMouseEnter: (pos: CellPosition) => void /** Move the active cell in DISPLAY order (arrows). */ moveFocus: (deltaRow: number, deltaCol: number, extend?: boolean) => void /** Tab to the next/previous cell in display order (appends a row off the end). */ tabNext: (reverse: boolean) => void // Selection actions (display-order aware — implemented by the container). clearSelection: () => void deleteSelectedRows: () => void insertRowsAbove: () => void insertRowsBelow: () => void /** Select the whole column (top to bottom). */ selectColumn: (columnId: string) => void /** Select the whole row (all columns). */ selectRow: (rowId: string) => void /** Fill every selected cell with one value (Ctrl/Cmd+Enter). */ fillSelectionWith: (next: CellState<string>) => void hasSelection: boolean}
const MISSING = Symbol("no-data-grid-context")const DataGridContext = React.createContext< DataGridContextValue<GridRow> | typeof MISSING>(MISSING)
export function DataGridContextProvider<TRow extends GridRow>({ value, children,}: { value: DataGridContextValue<TRow> children: React.ReactNode}) { return ( <DataGridContext.Provider value={value as unknown as DataGridContextValue<GridRow>} > {children} </DataGridContext.Provider> )}
export function useDataGridContext< TRow extends GridRow,>(): DataGridContextValue<TRow> { const ctx = React.useContext(DataGridContext) if (ctx === MISSING) { throw new Error("useDataGridContext must be used within <DataGrid>") } return ctx as unknown as DataGridContextValue<TRow>}
// ---------------------------------------------------------------------------// useGridCell — derive a cell's editor + wrapper props. Addressed by row id;// selection membership tested against the display-space rectangle. Overlay// geometry for OPT-IN features (copy outline, fill preview, move ghost) is// derived only when the matching feature component is mounted.// ---------------------------------------------------------------------------
/** Which sides of a cell sit on a rectangle's perimeter (else null). */interface CopyEdges { top: boolean right: boolean bottom: boolean left: boolean}
/** The sides of `bounds`' perimeter this display cell sits on, or null. */function edgesOf( bounds: SelectionBounds | null | undefined, displayIndex: number, colIndex: number,): CopyEdges | null { if (!bounds || !isCellInBounds(bounds, displayIndex, colIndex)) return null return { top: displayIndex === bounds.minRow, bottom: displayIndex === bounds.maxRow, left: colIndex === bounds.minColIndex, right: colIndex === bounds.maxColIndex, }}
export interface GridCellRender { editorProps: CellEditorProps wrapperProps: { "data-cell": string onMouseDown: (e: React.MouseEvent) => void onMouseEnter: () => void onDoubleClick: () => void } /** Copy-outline edges for this cell, or null (requires `<DataGridClipboard>`). */ copyEdges: CopyEdges | null /** Bottom-right corner of the selection (requires `<DataGridFillHandle>`). */ isFillCorner: boolean /** Fill-preview edges, or null (requires `<DataGridFillHandle>`). */ fillEdges: CopyEdges | null /** Selection-perimeter grab edges, or null (requires `<DataGridMove>`). */ selectionEdges: CopyEdges | null /** Drag-to-move ghost edges, or null (requires `<DataGridMove>`). */ moveEdges: CopyEdges | null}
export function useGridCell( row: GridRow, columnId: string, displayIndex?: number,): GridCellRender { const { grid, onCellMouseDown, onCellMouseEnter, moveFocus, fillSelectionWith, displayIndexOf, } = useDataGridContext() const resolvedDisplayIndex = displayIndexOf(row.id) ?? displayIndex ?? 0 const state = useGridCellState(row, columnId, resolvedDisplayIndex) const pos: CellPosition = { rowId: row.id, columnId }
const editorProps: CellEditorProps = { cell: state.cell, isFocused: state.isFocused, isSelected: state.isSelected, isEditing: state.isEditing, editSeed: state.editSeed, onEditingChange: editing => editing ? grid.startEditing(pos) : grid.stopEditing(), onCommit: next => grid.setCell(row.id, columnId, next), onFillSelection: fillSelectionWith, onMoveFocus: moveFocus, }
return { editorProps, wrapperProps: { "data-cell": `${row.id}:${columnId}`, onMouseDown: e => onCellMouseDown(pos, e), onMouseEnter: () => onCellMouseEnter(pos), onDoubleClick: () => grid.startEditing(pos), }, copyEdges: state.copyEdges, isFillCorner: state.isFillCorner, fillEdges: state.fillEdges, selectionEdges: state.selectionEdges, moveEdges: state.moveEdges, }}
/** The per-cell visual state — everything the cell chrome derives per render. */interface GridCellState { cell: CellState<string> isFocused: boolean isSelected: boolean isEditing: boolean editSeed: string | null copyEdges: CopyEdges | null isFillCorner: boolean fillEdges: CopyEdges | null selectionEdges: CopyEdges | null moveEdges: CopyEdges | null}
function useGridCellState( row: GridRow, columnId: string, displayIndex: number,): GridCellState { const { grid, selectionBounds, columnIds } = useDataGridContext() const features = useDataGridFeatures()
const isFocused = grid.focusedCell?.rowId === row.id && grid.focusedCell.columnId === columnId const isEditing = grid.editingCell?.rowId === row.id && grid.editingCell.columnId === columnId // DISPLAY-space column index (respects hiding/pinning/reorder). const colIndex = columnIds.indexOf(columnId) const isSelected = isCellInBounds(selectionBounds, displayIndex, colIndex)
// Fill-handle anchor: the selection's bottom-right corner (opt-in). const isFillCorner = !!features.fill && !!selectionBounds && displayIndex === selectionBounds.maxRow && colIndex === selectionBounds.maxColIndex
// Fill-preview perimeter (only while dragging the fill handle). const fillEdges = features.fill ? edgesOf(features.fill.fillBounds, displayIndex, colIndex) : null
// Selection perimeter — the sides a drag-to-move grab zone is drawn on. const selectionEdges = features.move && isSelected ? edgesOf(selectionBounds, displayIndex, colIndex) : null
// Drag-to-move ghost perimeter (where the block will land on release). const moveEdges = features.move ? edgesOf(features.move.moveBounds, displayIndex, colIndex) : null
// Copy outline: a cell in the copied rectangle draws a dashed border on the // sides that lie on the rectangle's perimeter, forming one continuous outline. const copiedRange = features.clipboard?.copiedRange ?? null const copyEdges: CopyEdges | null = copiedRange && copiedRange.rowIds.has(row.id) && copiedRange.columnIds.has(columnId) ? { top: copiedRange.firstRowId === row.id, bottom: copiedRange.lastRowId === row.id, left: copiedRange.firstColumnId === columnId, right: copiedRange.lastColumnId === columnId, } : null
const cell = (row[columnId] as CellState<string> | undefined) ?? emptyCell<string>()
return { cell, isFocused, isSelected, isEditing, editSeed: isEditing ? grid.editSeed : null, copyEdges, isFillCorner, fillEdges, selectionEdges, moveEdges, }}
// ---------------------------------------------------------------------------// DataGridCell — per-cell chrome: data-cell attr, mouse handlers, feature// overlays, and moving DOM focus onto the active cell (fires when the row// mounts, so it works with virtualization). Feature overlays render only when// the matching opt-in component is mounted inside <DataGrid>.//// PERF: the grid context changes on every focus move, which re-runs this// component for EVERY mounted cell (hundreds when virtualized). The state// derivation above is a handful of comparisons — cheap — but reconciling each// cell's editor subtree is not. So the actual DOM lives in a memoized inner// component fed only value-comparable props + identity-stable handlers (the// latest-ref pattern): per keystroke, only the handful of cells whose visual// state changed re-render; the rest stop at the memo.// ---------------------------------------------------------------------------
/** Identity-stable handlers a cell hands to its wrapper + editor. */interface StableCellHandlers { onMouseDown: (e: React.MouseEvent) => void onMouseEnter: () => void onDoubleClick: () => void onEditingChange: (editing: boolean) => void onCommit: (next: CellState<string>) => void onFillSelection: (next: CellState<string>) => void onMoveFocus: (deltaRow: number, deltaCol: number, extend?: boolean) => void onFillHandleMouseDown: (e: React.MouseEvent) => void onFillHandleDoubleClick: () => void onSelectionMoveMouseDown: (e: React.MouseEvent) => void}
function edgesEqual(a: CopyEdges | null, b: CopyEdges | null): boolean { if (a === b) return true if (!a || !b) return false return ( a.top === b.top && a.right === b.right && a.bottom === b.bottom && a.left === b.left )}
interface DataGridCellInnerProps { dataCell: string state: GridCellState /** The fill handle is rendered (feature mounted + corner cell). */ showFillHandle: boolean /** The drag-to-move grab strips are rendered (feature mounted). */ showMoveStrips: boolean handlers: StableCellHandlers children: (props: CellEditorProps) => React.ReactNode}
function cellInnerPropsEqual( prev: DataGridCellInnerProps, next: DataGridCellInnerProps,): boolean { const a = prev.state const b = next.state return ( prev.dataCell === next.dataCell && prev.showFillHandle === next.showFillHandle && prev.showMoveStrips === next.showMoveStrips && prev.handlers === next.handlers && prev.children === next.children && a.cell === b.cell && a.isFocused === b.isFocused && a.isSelected === b.isSelected && a.isEditing === b.isEditing && a.editSeed === b.editSeed && a.isFillCorner === b.isFillCorner && edgesEqual(a.copyEdges, b.copyEdges) && edgesEqual(a.fillEdges, b.fillEdges) && edgesEqual(a.selectionEdges, b.selectionEdges) && edgesEqual(a.moveEdges, b.moveEdges) )}
const DataGridCellInner = React.memo(function DataGridCellInner({ dataCell, state, showFillHandle, showMoveStrips, handlers: h, children,}: DataGridCellInnerProps) { const { cell, isFocused, isSelected, isEditing, editSeed, copyEdges, fillEdges, selectionEdges, moveEdges, } = state const ref = React.useRef<HTMLDivElement>(null)
React.useLayoutEffect(() => { if (!isFocused || isEditing) return const el = ref.current?.querySelector<HTMLElement>( 'input, textarea, select, button, [contenteditable]:not([contenteditable="false"]), [tabindex]:not([tabindex="-1"])', ) if (el && !el.contains(document.activeElement)) { el.focus({ preventScroll: true }) } }, [isFocused, isEditing])
const editorProps: CellEditorProps = { cell, isFocused, isSelected, isEditing, editSeed, onEditingChange: h.onEditingChange, onCommit: h.onCommit, onFillSelection: h.onFillSelection, onMoveFocus: h.onMoveFocus, }
return ( <div ref={ref} className="relative h-full w-full" data-cell={dataCell} onMouseDown={h.onMouseDown} onMouseEnter={h.onMouseEnter} onDoubleClick={h.onDoubleClick} > {copyEdges && ( <div aria-hidden className={cn( "pointer-events-none absolute inset-0 z-[6] border-0 border-dashed border-primary", copyEdges.top && "border-t", copyEdges.right && "border-r", copyEdges.bottom && "border-b", copyEdges.left && "border-l", )} /> )} {/* Fill-drag preview outline (dashed). */} {fillEdges && ( <div aria-hidden className={cn( "pointer-events-none absolute inset-0 z-[6] border-0 border-dashed border-primary", fillEdges.top && "border-t", fillEdges.right && "border-r", fillEdges.bottom && "border-b", fillEdges.left && "border-l", )} /> )} {/* Drag-to-move ghost — where the block lands on release (solid outline + faint tint, distinct from the dashed fill/copy previews). */} {moveEdges && ( <div aria-hidden className={cn( "pointer-events-none absolute inset-0 z-[7] border-0 border-primary bg-primary/5", moveEdges.top && "border-t-2", moveEdges.right && "border-r-2", moveEdges.bottom && "border-b-2", moveEdges.left && "border-l-2", )} /> )} {/* Active-cell (cursor) border — drawn as an overlay on the wrapper, NOT on the focusable control, so it can't be zeroed out by the control's own `:focus-visible` reset. Bold + brand-colored, grid-style. */} {isFocused && ( <div aria-hidden className="pointer-events-none absolute inset-0 z-[7] border-2 border-primary" /> )} {/* Drag-to-move grab zones — thin strips along the selection's outer border. Hovering shows a move cursor; mouse-down drags the whole block to a new location (border-drag). Only on the perimeter so cell interior clicks still select/edit; suppressed while editing. The fill handle (below) sits above these at the bottom-right corner. */} {selectionEdges && showMoveStrips && !isEditing && ( <> {selectionEdges.top && ( <div onMouseDown={h.onSelectionMoveMouseDown} className="absolute top-0 right-0 left-0 z-[8] h-1.5 cursor-move" /> )} {selectionEdges.bottom && ( <div onMouseDown={h.onSelectionMoveMouseDown} className="absolute right-0 bottom-0 left-0 z-[8] h-1.5 cursor-move" /> )} {selectionEdges.left && ( <div onMouseDown={h.onSelectionMoveMouseDown} className="absolute top-0 bottom-0 left-0 z-[8] w-1.5 cursor-move" /> )} {selectionEdges.right && ( <div onMouseDown={h.onSelectionMoveMouseDown} className="absolute top-0 right-0 bottom-0 z-[8] w-1.5 cursor-move" /> )} </> )} {/* Fill handle — the small square at the selection's bottom-right corner. Drag to fill (down or across); double-click to auto-fill down. Sits FLUSH INSIDE the cell corner: the body `<td>` is `overflow-hidden`, so a handle hanging outside (negative offsets) would be clipped and unusable. A larger transparent hit-area wraps the visible 8px square so it's easy to grab on either the bottom edge (fill down) or right edge (fill across). */} {state.isFillCorner && showFillHandle && ( <div onMouseDown={h.onFillHandleMouseDown} onDoubleClick={e => { e.stopPropagation() h.onFillHandleDoubleClick() }} className="group/fill absolute right-0 bottom-0 z-[9] flex size-3.5 cursor-crosshair items-end justify-end" aria-label="Fill handle — drag to fill the selection" > <div className="size-2 rounded-[1px] border border-background bg-primary shadow-sm transition-transform group-hover/fill:scale-125" /> </div> )} {children(editorProps)} </div> )}, cellInnerPropsEqual)
export function DataGridCell({ row, columnId, displayIndex: displayIndexProp, children,}: { row: GridRow columnId: string /** * Optional fallback. Prefer omitting — the cell resolves display index from * row id via the grid's display order, so sort/filter stay correct. Passing * TanStack's `row.index` (source-data index) will be ignored when the row is * still in the current display model. */ displayIndex?: number children: (props: CellEditorProps) => React.ReactNode}) { const { grid, onCellMouseDown, onCellMouseEnter, moveFocus, fillSelectionWith, displayIndexOf, } = useDataGridContext() const features = useDataGridFeatures() const displayIndex = displayIndexOf(row.id) ?? displayIndexProp ?? 0 const state = useGridCellState(row, columnId, displayIndex)
// Latest-ref + one stable handler set per cell instance. The handlers read // through the ref, so their identity never changes and the memoized inner // keeps holding while the grid context churns on every focus move. const latest = React.useRef({ grid, features, onCellMouseDown, onCellMouseEnter, moveFocus, fillSelectionWith, rowId: row.id, columnId, }) latest.current = { grid, features, onCellMouseDown, onCellMouseEnter, moveFocus, fillSelectionWith, rowId: row.id, columnId, }
const handlers = React.useMemo<StableCellHandlers>(() => { const posOf = () => ({ rowId: latest.current.rowId, columnId: latest.current.columnId, }) return { onMouseDown: e => latest.current.onCellMouseDown(posOf(), e), onMouseEnter: () => latest.current.onCellMouseEnter(posOf()), onDoubleClick: () => latest.current.grid.startEditing(posOf()), onEditingChange: editing => editing ? latest.current.grid.startEditing(posOf()) : latest.current.grid.stopEditing(), onCommit: next => latest.current.grid.setCell( latest.current.rowId, latest.current.columnId, next, ), onFillSelection: next => latest.current.fillSelectionWith(next), onMoveFocus: (dRow, dCol, extend) => latest.current.moveFocus(dRow, dCol, extend), onFillHandleMouseDown: e => latest.current.features.fill?.onFillHandleMouseDown(e), onFillHandleDoubleClick: () => latest.current.features.fill?.onFillHandleDoubleClick(), onSelectionMoveMouseDown: e => latest.current.features.move?.onSelectionMoveMouseDown(e, posOf()), } }, [])
return ( <DataGridCellInner dataCell={`${row.id}:${columnId}`} state={state} showFillHandle={!!features.fill} showMoveStrips={!!features.move} handlers={handlers} > {children} </DataGridCellInner> )}"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 { UseDataGrid } from "../hooks/use-data-grid"import type { CellPosition, CopiedRange, GridRow, SelectionBounds,} from "../types/grid-cell"
// ---------------------------------------------------------------------------// Opt-in grid features — the composable seam.//// `<DataGrid>` ships only the irreducible core (selection, keyboard nav, the// scroll seam). Everything else is an opt-in CHILD component that registers// its capability here when mounted — mix and match, niko-table style://// <DataGrid grid={grid}>// <DataGridClipboard resolveCell={...} /> // copy/cut/paste + marching ants// <DataGridFillHandle /> // corner-drag fill// <DataGridMove /> // border-drag move/copy// <DataGridCrossHighlight /> // header + row-number highlight// <DataTable>…</DataTable>// </DataGrid>//// A feature that isn't mounted costs NOTHING: no document/window listeners,// no per-cell overlays, no state churn — and it tree-shakes out of the bundle.// ---------------------------------------------------------------------------
/** Published by `<DataGridClipboard>`. */export interface ClipboardFeature { /** The copied rectangle (the marching-ants copy outline), or null. */ copiedRange: CopiedRange | null copySelection: () => void cutSelection: () => void /** Read the clipboard and paste from the focused cell (menu "Paste"). */ pasteFromClipboard: () => void /** Clear the copy marker (wired to Escape by the core keyboard). */ clearCopiedRange: () => void /** Paste is available (a `resolveCell` was provided). */ canPaste: boolean}
/** Published by `<DataGridFillHandle>`. */export interface FillFeature { /** Fill-drag rectangle (source + extension), display space, or null. */ fillBounds: SelectionBounds | null onFillHandleMouseDown: (e: React.MouseEvent) => void onFillHandleDoubleClick: () => void}
/** Published by `<DataGridMove>`. */export interface MoveFeature { /** Drag-to-move ghost rectangle (target landing zone), or null. */ moveBounds: SelectionBounds | null onSelectionMoveMouseDown: (e: React.MouseEvent, grabPos: CellPosition) => void}
/** Published by `<DataGridRowReorder>`. */export interface RowReorderFeature { /** The row id being dragged (for the handle's grabbing state), or null. */ draggingRowId: string | null /** Start a row-reorder drag from a gutter grab handle. */ onRowReorderMouseDown: (e: React.MouseEvent, rowId: string) => void}
export interface GridFeatures { clipboard?: ClipboardFeature fill?: FillFeature move?: MoveFeature rowReorder?: RowReorderFeature}
export type RegisterGridFeature = <K extends keyof GridFeatures>( key: K, payload: GridFeatures[K] | undefined,) => void
const FeaturesContext = React.createContext<GridFeatures | null>(null)const FeatureRegistryContext = React.createContext<RegisterGridFeature | null>( null,)
export function DataGridFeaturesProvider({ features, register, children,}: { features: GridFeatures register: RegisterGridFeature children: React.ReactNode}) { return ( <FeatureRegistryContext.Provider value={register}> <FeaturesContext.Provider value={features}> {children} </FeaturesContext.Provider> </FeatureRegistryContext.Provider> )}
/** Read the currently-registered opt-in features (cells, menus). */export function useDataGridFeatures(): GridFeatures { const ctx = React.useContext(FeaturesContext) if (ctx === null) { throw new Error("useDataGridFeatures must be used within <DataGrid>") } return ctx}
/** * Publish a feature payload while mounted; unregisters on unmount. Feature * components memoize their payload and call this once. */export function useRegisterGridFeature<K extends keyof GridFeatures>( key: K, payload: NonNullable<GridFeatures[K]>,): void { const register = React.useContext(FeatureRegistryContext) if (register === null) { throw new Error( "Grid feature components (DataGridClipboard, DataGridFillHandle, …) must be placed inside <DataGrid>", ) } React.useEffect(() => { register(key, payload) return () => register(key, undefined) }, [register, key, payload])}
// ---------------------------------------------------------------------------// Internals — the shared machinery feature components build on. Deliberately// exported (registry consumers can write their own features) but NOT part of// the everyday public API.// ---------------------------------------------------------------------------
/** px from a scroll-container edge where drag auto-scroll kicks in. */export const GRID_EDGE_ZONE = 36/** px per frame while auto-scrolling at an edge. */export const GRID_EDGE_SPEED = 14
export function clampGridIndex(v: number, min: number, max: number): number { return Math.max(min, Math.min(max, v))}
/** The display-order row shape features read (a subset of TanStack's `Row`). */export interface GridDisplayRow { readonly id: string readonly original: GridRow}
/** Latest-snapshot env for once-subscribed listeners / rAF loops. */export interface GridEnv { orderedRows: readonly GridDisplayRow[] columnIds: readonly string[] displayIndexOf: (id: string) => number | undefined grid: UseDataGrid<GridRow> selectionBounds: SelectionBounds | null}
export interface DataGridInternals { grid: UseDataGrid<GridRow> /** Selection rectangle in DISPLAY space (post filter/sort). */ selectionBounds: SelectionBounds | null orderedRows: readonly GridDisplayRow[] columnIds: readonly string[] displayIndexOf: (id: string) => number | undefined /** Display index → the row's data (lazy TSV serialization). */ getDisplayRow: (i: number) => GridRow | undefined /** Select the whole display-space rectangle (anchor top-left → bottom-right). */ selectRange: (bounds: SelectionBounds) => void /** Clear the selected cells' values. */ clearSelection: () => void /** The grid wrapper element (query the scroll container from here). */ wrapperRef: React.RefObject<HTMLDivElement | null> /** Latest pointer position (updated by the core's window mousemove). */ pointerRef: React.RefObject<{ x: number; y: number }> /** Latest env snapshot for once-subscribed listeners / rAF loops. */ envRef: React.RefObject<GridEnv>}
const InternalsContext = React.createContext<DataGridInternals | null>(null)
export function DataGridInternalsProvider({ value, children,}: { value: DataGridInternals children: React.ReactNode}) { return ( <InternalsContext.Provider value={value}> {children} </InternalsContext.Provider> )}
/** Access the grid's internal machinery (feature components only). */export function useDataGridInternals(): DataGridInternals { const ctx = React.useContext(InternalsContext) if (ctx === null) { throw new Error("useDataGridInternals must be used within <DataGrid>") } return ctx}"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 { Button } from "@/components/ui/button"import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,} from "@/components/ui/dialog"import { Input } from "@/components/ui/input"import { Separator } from "@/components/ui/separator"import * as React from "react"
import type { GridColumnsApi } from "../hooks/use-grid-columns"import type { CellState, GridRow } from "../types/grid-cell"import { useDataGridInternals } from "./data-grid-features"
/** A selectable column type for the header "Change type" submenu. */export interface GridColumnTypeOption { value: string label: string /** Options applied to a column switched to this type (e.g. select values). */ options?: readonly string[]}
// ---------------------------------------------------------------------------// Dynamic columns — the opt-in seam. `<DataGridColumns value={…}>` publishes// the column API (from `useGridColumns`) to the header menu / add-column button// deep inside the table, and hosts a built-in rename dialog so "Rename" works// from the header dropdown (which unmounts on close). A grid with fixed columns// never mounts this.// ---------------------------------------------------------------------------
interface DataGridColumnsContextValue extends GridColumnsApi { /** The column currently being renamed (dialog open), or null. */ renameTarget: string | null /** Open the rename dialog for a column. */ beginRename: (id: string) => void /** Close the rename dialog. */ endRename: () => void /** Types offered in the header "Change type" submenu (empty = hidden). */ columnTypes: GridColumnTypeOption[] /** Switch a column's type (applies that type's default options). */ changeColumnType: (id: string, type: string) => void}
const DataGridColumnsContext = React.createContext<DataGridColumnsContextValue | null>(null)
/** * Read the dynamic-columns API (mutations + rename state) inside the table. * Throws when used without a `<DataGridColumns>` ancestor. */export function useDataGridColumns(): DataGridColumnsContextValue { const ctx = React.useContext(DataGridColumnsContext) if (ctx === null) { throw new Error( "useDataGridColumns must be used within <DataGridColumns> (dynamic columns are opt-in)", ) } return ctx}
/** * Wire dynamic columns into the grid. Wrap the toolbar + table with it and pass * your `useGridColumns()` result: * * @example * const cols = useGridColumns({ initialColumns }); * const grid = useDataGrid({ columnIds: cols.columnIds, … }); * <DataGrid grid={grid}> * <DataGridColumns value={cols}> * <DataGridToolbar><DataGridAddColumnButton /></DataGridToolbar> * <DataTable>…</DataTable> // headers render <GridColumnMenuOptions/> * </DataGridColumns> * </DataGrid> */export function DataGridColumns({ value, columnTypes, resolveCell, children,}: { value: GridColumnsApi /** Types for the header "Change type" submenu. Omit to hide retype. */ columnTypes?: GridColumnTypeOption[] /** * Re-resolve a cell's raw value when its column's type changes (e.g. text → * select re-validates existing values against the new options). Same resolver * you pass to `<DataGridClipboard>`. Omit and a type change updates the type * only, leaving existing cells until they're next edited. */ resolveCell?: (columnId: string, raw: string) => CellState<string> children: React.ReactNode}) { const [renameTarget, setRenameTarget] = React.useState<string | null>(null) const beginRename = React.useCallback((id: string) => setRenameTarget(id), []) const endRename = React.useCallback(() => setRenameTarget(null), [])
const types = React.useMemo(() => columnTypes ?? [], [columnTypes]) const setColumnType = value.setColumnType const changeColumnType = React.useCallback( (id: string, type: string) => { const opt = types.find(t => t.value === type) setColumnType(id, type, opt?.options ? { options: opt.options } : {}) }, [types, setColumnType], )
const ctx = React.useMemo<DataGridColumnsContextValue>( () => ({ ...value, renameTarget, beginRename, endRename, columnTypes: types, changeColumnType, }), [value, renameTarget, beginRename, endRename, types, changeColumnType], )
return ( <DataGridColumnsContext.Provider value={ctx}> {children} <ColumnRenameDialog /> {/* Only mounts (and touches grid internals) when re-resolve is opted in. */} {resolveCell && <ColumnRetypeReconciler resolveCell={resolveCell} />} </DataGridColumnsContext.Provider> )}
/** * Watches column type/options and re-resolves that column's cells when they * change — so a text → select switch immediately re-validates existing values * (unmatched ones turn red). Runs in a layout effect (before paint, no flash of * mis-typed values) and only mounts when `<DataGridColumns resolveCell>` is set, * so it never touches grid internals for a read-only/no-retype table. */function ColumnRetypeReconciler({ resolveCell,}: { resolveCell: (columnId: string, raw: string) => CellState<string>}) { const { columns } = useDataGridColumns() const { grid } = useDataGridInternals() const prevSig = React.useRef<Map<string, string> | null>(null)
React.useLayoutEffect(() => { const next = new Map<string, string>() const changed: string[] = [] for (const c of columns) { const sig = `${c.type}\u0000${(c.options ?? []).join("\u0001")}` next.set(c.id, sig) const prev = prevSig.current // Skip the first pass (prev === null) — initial data is already resolved. if (prev?.has(c.id) && prev.get(c.id) !== sig) changed.push(c.id) } prevSig.current = next if (changed.length === 0) return
grid.updateRows(rows => { let mutated = false const nextRows = rows.map(row => { let out = row for (const id of changed) { const cell = row[id] as CellState<string> | undefined if (cell === undefined) continue // empty cell — nothing to resolve if (out === row) out = { ...row } as GridRow out[id] = resolveCell(id, cell.raw) mutated = true } return out }) // All affected cells were empty — return the same array so the history // reducer's no-op guard holds (no phantom undo entry). return mutated ? nextRows : rows }) }, [columns, grid, resolveCell])
return null}
/** Built-in rename dialog — opened via `beginRename` from the header menu. */function ColumnRenameDialog() { const { columns, renameColumn, renameTarget, endRename } = useDataGridColumns() const column = columns.find(c => c.id === renameTarget) ?? null
return ( <Dialog open={renameTarget !== null} onOpenChange={open => { if (!open) endRename() }} > <DialogContent className="sm:max-w-sm"> <DialogHeader> <DialogTitle>Rename column</DialogTitle> </DialogHeader> <div className="-mx-4"> <Separator /> </div> {/* Keyed on the column id: remounting re-seeds the draft from the new column's label, so we never setState-in-an-effect to reseed. */} {column && ( <RenameForm key={column.id} initialLabel={column.label} onSave={label => { renameColumn(column.id, label) endRename() }} onCancel={endRename} /> )} </DialogContent> </Dialog> )}
function RenameForm({ initialLabel, onSave, onCancel,}: { initialLabel: string onSave: (label: string) => void onCancel: () => void}) { const [draft, setDraft] = React.useState(initialLabel) const commit = () => onSave(draft.trim() || "Untitled")
return ( <> <Input autoFocus value={draft} onChange={e => setDraft(e.target.value)} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault() commit() } }} placeholder="Column name" aria-label="Column name" /> <div className="-mx-4"> <Separator /> </div> <DialogFooter> <Button variant="outline" onClick={onCancel}> Cancel </Button> <Button onClick={commit}>Save</Button> </DialogFooter> </> )}/** * 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 *//** * niko-table grid — the cell editor contract. * * Any component satisfying `CellEditorProps` can be a grid cell: text input, * searchable dropdown, date picker, number spinner, multi-select. Domain-free. */
import type { CellState } from "../types/grid-cell"
export interface CellEditorProps { cell: CellState<string> /** The active cell (shows the focus ring; the edit target). */ isFocused: boolean /** Within the current selection rectangle (range highlight). */ isSelected: boolean /** Whether this cell's editor is open. */ isEditing: boolean /** Char to seed the editor with when editing started by typing (else null). */ editSeed?: string | null /** Open/close this cell's editor. */ onEditingChange: (editing: boolean) => void onCommit: (next: CellState<string>) => void /** * Commit this value to EVERY selected cell (Ctrl/Cmd+Enter). When absent the * editor falls back to `onCommit` (single cell). */ onFillSelection?: (next: CellState<string>) => void /** Move the active cell after committing (e.g. Enter in a text cell). */ onMoveFocus?: (deltaRow: number, deltaCol: number) => void /** Resolved display label for the cell's value (entity/select cells). */ displayLabel?: string | null}
/** Option shape for combobox/select cells. */export interface GridComboboxOption { label: string value: string}/** * 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 { cn } from "@/lib/utils"
import type { CellStatus } from "../types/grid-cell"
/** Row height for every grid cell (matches the h-9 form-control rhythm). */export const CELL_HEIGHT = 36
/** * Shared cell-trigger look: a borderless control that fills its cell, turns * red when invalid, and shows an inset focus ring. Used by every cell editor * so comboboxes and inputs line up pixel-for-pixel. *//** * Shared cell-trigger look, applied on top of shadcn `Input` / `Button` so we * reuse those primitives (a11y, focus, disabled handling) but strip their * border / rounding / default ring / hover fill so they sit flush in a cell. * tailwind-merge lets these later utilities win over the primitives' base * classes, so we compose rather than fork. */export function cellTriggerClass(opts: { status: CellStatus isFocused: boolean isEmpty: boolean /** Within the current selection rectangle (range highlight). */ isSelected?: boolean}): string { return cn( // Layout + reset the primitive chrome (border/rounding/shadow/ring/hover). "flex h-9 w-full items-center justify-start gap-1 truncate rounded-none border-0 bg-transparent px-2 text-left text-sm font-normal shadow-none transition-colors outline-none", "hover:bg-transparent focus-visible:border-0 focus-visible:ring-0", opts.isEmpty && "text-muted-foreground", // Range highlight for selected cells — but NOT the active cell, which stays // unfilled so it reads as the cursor within the selection. // Skipped on invalid cells so the red error fill always wins. Repeat on // hover: so the shadcn Button's hover state doesn't wash out the tint. opts.isSelected && !opts.isFocused && opts.status !== "invalid" && "bg-primary/10 hover:bg-primary/10", opts.status === "invalid" && "text-destructive bg-destructive/10 hover:bg-destructive/10 aria-invalid:bg-destructive/10", // NOTE: the active-cell (cursor) border is drawn by `DataGridCell` as an // overlay on the cell WRAPPER, not here — a control's own `:focus-visible` // reset would otherwise zero out a ring on the focusable element. )}"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 { Button } from "@/components/ui/button"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"import type { CellStatus } from "../types/grid-cell"
import { cellTriggerClass } from "./cell-styles"
export interface GridCellDisplayProps { status: CellStatus isFocused: boolean isSelected: boolean /** Text alignment — right for numbers, left (default) otherwise. */ align?: "left" | "right" /** * Reason the cell is invalid (from your `resolve`). When `status` is * `"invalid"` and this is set, the cell shows it in a tooltip on hover/focus — * inline, per cell, no summary block. Valid/empty cells render the bare shell. */ error?: string /** The rendered value (label / raw / placeholder) — already resolved by the cell. */ children: React.ReactNode}
/** * The read-only cell shell — a borderless, focusable button that fills the cell * and carries the selection/invalid styling. EVERY cell type renders this while * not editing. * * Deliberately hook-free and dependency-light: a grid shows ~280 cells at once * but edits ONE, so the display path must be cheap. Each cell type's heavy * editor (input, combobox, date picker, …) lives in a SEPARATE component that * the cell mounts only when `isEditing` — so a 279-cell viewport never carries * 279 date-picker subtrees, refs, or effects. New cell types MUST follow this * split: `isEditing ? <XEditor …/> : <GridCellDisplay …>{label}</GridCellDisplay>`. */export function GridCellDisplay({ status, isFocused, isSelected, align = "left", error, children,}: GridCellDisplayProps) { const button = ( <Button type="button" variant="ghost" tabIndex={-1} aria-invalid={status === "invalid"} className={cn( cellTriggerClass({ status, isFocused, isSelected, isEmpty: status === "empty", }), align === "right" && "justify-end", )} > <span className={cn("flex-1 truncate", align === "right" && "text-right")} > {children} </span> </Button> )
// Only invalid cells with a reason get the tooltip wrapper — valid/empty // cells (the overwhelming majority) render the bare shell, keeping the // display path cheap across a 280-cell viewport. if (status !== "invalid" || !error) return button
return ( <Tooltip> <TooltipTrigger asChild>{button}</TooltipTrigger> <TooltipContent>{error}</TooltipContent> </Tooltip> )}"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 *//** * The "marching ants" copy outline for the copied cell — a self-contained, * animated SVG overlay (no global CSS / keyframes). Pinned to a `relative` * parent and non-interactive so it never blocks clicks on the cell beneath. */export function GridMarchingAnts() { return ( <svg aria-hidden className="pointer-events-none absolute inset-0 z-[6] size-full overflow-visible text-primary" > <rect x="0" y="0" width="100%" height="100%" fill="none" stroke="currentColor" strokeWidth="1.5" strokeDasharray="4 4" > <animate attributeName="stroke-dashoffset" from="0" to="-8" dur="0.5s" repeatCount="indefinite" /> </rect> </svg> )}"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 { Input } from "@/components/ui/input"import { cn } from "@/lib/utils"import * as React from "react"
import type { CellState } from "../types/grid-cell"import type { CellEditorProps } from "./cell-props"import { cellTriggerClass } from "./cell-styles"import { GridCellDisplay } from "./grid-cell-display"
export interface GridTextCellProps extends CellEditorProps { placeholder?: string /** Resolve raw text into a validated cell (date / time / number parsers). */ resolve: (raw: string) => CellState<string> /** Text alignment — right for numbers. Default left. */ align?: "left" | "right" /** Virtual-keyboard hint for the editing input (e.g. "decimal" for numbers). */ inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"] /** * Format the DISPLAY value (e.g. `25` → `$25.00`). Applied only while not * editing — the editor always shows the raw text so entry stays precise. * Only called for valid, non-empty cells; invalid/empty fall back to raw. */ format?: (raw: string) => string}
/** * Free-text cell. Two modes, like an editable grid cell: a read-only display shell * while merely selected (single-click select + drag-select work), and a real * `<input>` once editing starts (double-click, Enter, or typing). Typing seeds * the input so it overwrites; the value resolves on blur / Enter and highlights * when unparsable. * * The `<input>` + its draft/commit machinery live in `GridTextEditor`, which * mounts ONLY when this cell edits — so a viewport of ~280 cells carries the * input machinery on one cell, not all of them (see `GridCellDisplay`). */export function GridTextCell(props: GridTextCellProps) { const { cell, placeholder, isFocused, isSelected, isEditing, align, format } = props
if (!isEditing) { // Format only valid, non-empty values; invalid/empty show raw (+ placeholder) // so a user's in-progress or unparsable text is never masked. const display = format && cell.status === "valid" ? format(cell.raw) : cell.raw return ( <GridCellDisplay status={cell.status} error={cell.error} isFocused={isFocused} isSelected={isSelected} align={align} > {display || placeholder} </GridCellDisplay> ) } return <GridTextEditor {...props} />}
/** * Number cell — `GridTextCell` right-aligned with a numeric keyboard. Validity * (is this a number?) is the consumer's `resolve`'s job, so this stays a thin * preset and the grid remains type-agnostic. */export function GridNumberCell( props: Omit<GridTextCellProps, "align" | "inputMode">,) { return <GridTextCell {...props} align="right" inputMode="decimal" />}
/** The editing `<input>` — mounted only while this cell is being edited. */function GridTextEditor({ cell, placeholder, resolve, isSelected, editSeed, align, inputMode, onEditingChange, onCommit, onFillSelection, onMoveFocus,}: GridTextCellProps) { const commit = (value: string) => { // Unchanged value (including leaving an untouched empty cell via // Enter/Tab/blur) is a no-op — committing would push a phantom // undo-history entry for a visit that changed nothing. if (value === cell.raw) return onCommit(resolve(value)) }
// The un-committed draft. The input is uncontrolled (`defaultValue`), and // editing can be torn down EXTERNALLY before blur fires — clicking another // cell runs `selectCell` (which clears `editingCell`) in the same mousedown, // so this component unmounts while the input still holds the draft and its // `onBlur` never dispatches. We mirror the draft into a ref and commit it on // unmount (also covers the row being virtualized away mid-edit). Escape // clears the draft first, so cancel still discards. const draftRef = React.useRef<string | null>(null) const commitRef = React.useRef(commit) commitRef.current = commit
const commitDraft = (value: string) => { draftRef.current = null commit(value) }
// useLayoutEffect cleanup, NOT useEffect: it runs before the browser paints, // so the commit's re-render lands in the same frame. A passive effect would // let the teardown frame paint the OLD value first — a stale-data flash. React.useLayoutEffect( () => () => { if (draftRef.current != null) commitRef.current(draftRef.current) }, [], )
return ( <Input autoFocus type="text" inputMode={inputMode} defaultValue={editSeed ?? cell.raw} placeholder={placeholder} aria-invalid={cell.status === "invalid"} onFocus={e => { // Type-to-edit seeds the input with the typed character — that seed IS // a draft, so a click-away with no further typing must still commit it. // A plain double-click open (no seed) leaves the draft null, so closing // without typing stays a no-op (no phantom history entry). if (editSeed != null && draftRef.current == null) { draftRef.current = e.currentTarget.value } }} onChange={e => { draftRef.current = e.target.value }} onBlur={e => { commitDraft(e.target.value) onEditingChange(false) }} onKeyDown={e => { // Commit the current value, close the editor, and move the focus. const commitAndMove = (dRow: number, dCol: number) => { commitDraft(e.currentTarget.value) onEditingChange(false) onMoveFocus?.(dRow, dCol) } if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && onFillSelection) { // Ctrl/Cmd+Enter: fill the whole selection with this value. e.preventDefault() draftRef.current = null onFillSelection(resolve(e.currentTarget.value)) onEditingChange(false) } else if (e.key === "Enter") { e.preventDefault() commitAndMove(1, 0) } else if (e.key === "Tab") { e.preventDefault() commitAndMove(0, e.shiftKey ? -1 : 1) } else if (e.key === "Escape") { e.preventDefault() draftRef.current = null // cancel discards the draft onEditingChange(false) } }} className={cn( cellTriggerClass({ status: cell.status, isFocused: true, isSelected, isEmpty: false, }), align === "right" && "text-right", )} /> )}"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 { Checkbox } from "@/components/ui/checkbox"import { cn } from "@/lib/utils"import * as React from "react"
import type { CellEditorProps } from "./cell-props"import { cellTriggerClass } from "./cell-styles"
interface GridCheckboxCellProps extends CellEditorProps { /** Truthy raw values (default: "true", "1", "yes"). Comparison is case-insensitive. */ truthy?: readonly string[] /** * Accessible name for the checkbox (pass the column label so a screen reader * announces WHICH field it is). Falls back to the checked-state text. */ "aria-label"?: string}
const DEFAULT_TRUTHY = ["true", "1", "yes"] as const
/** * Boolean cell — a checkbox that's always interactive (no separate edit mode). * Stores `"true"` / `"false"` in the cell's `raw`. Click toggles; and because * the grid routes Enter/Space/type-to-edit through `isEditing`, this cell * interprets that "edit" as a toggle (then exits), so keyboard works too. */export function GridCheckboxCell({ cell, isFocused, isSelected, isEditing, truthy = DEFAULT_TRUTHY, "aria-label": ariaLabel, onCommit, onEditingChange,}: GridCheckboxCellProps) { const set = new Set(truthy.map(t => t.toLowerCase())) const checked = set.has(cell.raw.trim().toLowerCase())
const toggleRef = React.useRef<() => void>(() => {}) toggleRef.current = () => { const next = !checked onCommit({ raw: String(next), value: String(next), status: "valid" }) }
// The grid triggers editing on Enter/Space/type — for a checkbox that means // "toggle". Do it in a layout effect (before paint) then exit edit mode. React.useLayoutEffect(() => { if (!isEditing) return toggleRef.current() onEditingChange(false) }, [isEditing, onEditingChange])
// Reuse the shared cell shell so a checkbox cell fills/highlights EXACTLY like // every other cell: the selection-range tint when selected, and nothing while // it's the active cell (the active-cell border is drawn by the wrapper // overlay, same as text/select cells — so no redundant second outline here). // Centered instead of the default left align since it holds a single control. return ( <div className={cn( cellTriggerClass({ status: cell.status, isFocused, isSelected, isEmpty: false, }), "justify-center", )} > <Checkbox tabIndex={-1} checked={checked} onCheckedChange={() => toggleRef.current()} aria-label={ariaLabel ?? (checked ? "Checked" : "Unchecked")} /> </div> )}"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 { Button } from "@/components/ui/button"import { Calendar } from "@/components/ui/calendar"import { Popover, PopoverContent, PopoverTrigger,} from "@/components/ui/popover"
import type { CellEditorProps } from "./cell-props"import { cellTriggerClass } from "./cell-styles"import { GridCellDisplay } from "./grid-cell-display"
export interface GridDateCellProps extends CellEditorProps { placeholder?: string}
/** Parse an ISO `YYYY-MM-DD` (local, no TZ shift), or undefined. */function parseISODate(raw: string): Date | undefined { const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw.trim()) if (!m) return undefined const y = Number(m[1]) const mo = Number(m[2]) const day = Number(m[3]) const d = new Date(y, mo - 1, day) // Reject calendar-invalid values JS silently rolls over (2026-02-31 → Mar 3). if (d.getFullYear() !== y || d.getMonth() !== mo - 1 || d.getDate() !== day) { return undefined } return d}
function toISODate(d: Date): string { const p = (n: number) => String(n).padStart(2, "0") return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`}
/** * Date cell. Display shows the stored `YYYY-MM-DD`; editing opens a portaled * calendar (double-click / Enter / typing). Picking a day commits the ISO date. * The calendar + popover live in `GridDateEditor`, mounted ONLY while editing. * * Deliberate divergence from the text cell: typing opens the calendar but the * typed character is NOT seeded (`editSeed` is ignored) — a calendar has no * text affordance to seed. Escape / click-away closes without committing. */export function GridDateCell(props: GridDateCellProps) { const { cell, placeholder, isFocused, isSelected, isEditing } = props if (!isEditing) { return ( <GridCellDisplay status={cell.status} error={cell.error} isFocused={isFocused} isSelected={isSelected} > {cell.raw || placeholder} </GridCellDisplay> ) } return <GridDateEditor {...props} />}
/** The open calendar popover — mounted only while this cell is being edited. */function GridDateEditor({ cell, placeholder, isSelected, onCommit, onEditingChange,}: GridDateCellProps) { const selected = parseISODate(cell.raw) return ( <Popover open onOpenChange={open => { if (!open) onEditingChange(false) }} > <PopoverTrigger asChild> <Button type="button" variant="ghost" aria-invalid={cell.status === "invalid"} className={cellTriggerClass({ status: cell.status, isFocused: true, isSelected, isEmpty: cell.status === "empty", })} > <span className="flex-1 truncate">{cell.raw || placeholder}</span> </Button> </PopoverTrigger> <PopoverContent data-grid-cell-editor="" align="start" className="w-auto p-0" > <Calendar mode="single" autoFocus selected={selected} defaultMonth={selected} onSelect={d => { if (d) { const iso = toISODate(d) // Only commit a real change — re-picking the same day would // otherwise push a phantom undo-history entry. if (iso !== cell.raw) { onCommit({ raw: iso, value: iso, status: "valid" }) } } onEditingChange(false) }} /> </PopoverContent> </Popover> )}"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 { 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 * as React from "react"
import type { CellEditorProps, GridComboboxOption } from "./cell-props"import { cellTriggerClass } from "./cell-styles"import { GridCellDisplay } from "./grid-cell-display"
export interface GridComboboxCellProps extends CellEditorProps { options: GridComboboxOption[] placeholder: string searchPlaceholder?: string /** Show the in-dropdown search input. Default true. `GridSelectCell` = false. */ searchable?: boolean}
/** * The cell's shown label. Precedence: explicit displayLabel → invalid raw * (stays visible in red) → the selected option's label (a valid value without a * displayLabel must not fall back to the placeholder) → placeholder. */function triggerTextFor(props: GridComboboxCellProps): string { const { cell, options, placeholder, displayLabel } = props const selectedLabel = cell.value != null ? options.find(o => o.value === cell.value)?.label : undefined return ( displayLabel ?? (cell.status === "invalid" ? cell.raw : (selectedLabel ?? placeholder)) )}
/** * Searchable single-select cell. Two modes: a read-only display shell while * merely selected (single-click select + drag-select work), and an open, * portaled dropdown once editing starts (double-click, Enter, or typing). A * pasted-but-unmatched value stays visible in red so the user can correct it. * * The `Popover` + `Command` dropdown lives in `GridComboboxEditor`, mounted * ONLY when this cell edits — so a viewport of ~280 select cells never * constructs 280 popover/command subtrees. */export function GridComboboxCell(props: GridComboboxCellProps) { const { cell, isFocused, isSelected, isEditing } = props
if (!isEditing) { return ( <GridCellDisplay status={cell.status} error={cell.error} isFocused={isFocused} isSelected={isSelected} > {triggerTextFor(props)} </GridCellDisplay> ) } return <GridComboboxEditor {...props} />}
/** The open combobox dropdown — mounted only while this cell is being edited. */function GridComboboxEditor(props: GridComboboxCellProps) { const { cell, options, searchPlaceholder, searchable = true, isSelected, editSeed, onEditingChange, onCommit, } = props const triggerText = triggerTextFor(props) // Type-to-edit seeds the search box with the first typed character. const [search, setSearch] = React.useState(editSeed ?? "")
const commit = (item: GridComboboxOption | null) => { const next = item ? { raw: item.label, value: item.value, status: "valid" as const } : { raw: "", value: null, status: "empty" as const } // Skip no-op re-selects — otherwise every identical pick clones the row // and pushes a phantom undo entry. if ( next.value === cell.value && next.raw === cell.raw && next.status === cell.status ) { onEditingChange(false) return } onCommit(next) onEditingChange(false) }
return ( <Popover open onOpenChange={open => { if (!open) onEditingChange(false) }} > <PopoverTrigger asChild> <Button type="button" variant="ghost" aria-invalid={cell.status === "invalid"} className={cellTriggerClass({ status: cell.status, isFocused: true, isSelected, isEmpty: cell.status === "empty", })} > <span className="flex-1 truncate">{triggerText}</span> </Button> </PopoverTrigger> <PopoverContent data-grid-cell-editor="" align="start" className="w-[var(--radix-popover-trigger-width,var(--anchor-width))] min-w-48 p-0" > <Command> {searchable && ( <CommandInput autoFocus value={search} onValueChange={setSearch} placeholder={searchPlaceholder ?? "Search…"} /> )} <CommandList> <CommandEmpty>No match found.</CommandEmpty> <CommandGroup> {options.map(item => ( <CommandItem key={item.value} value={item.label} onSelect={() => commit(item)} > <span className="truncate">{item.label}</span> </CommandItem> ))} </CommandGroup> </CommandList> </Command> </PopoverContent> </Popover> )}"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 { CellEditorProps, GridComboboxOption } from "./cell-props"import { GridComboboxCell } from "./grid-combobox-cell"
export interface GridSelectCellProps extends CellEditorProps { options: GridComboboxOption[] placeholder: string}
/** * Non-searchable single-select cell — the `GridComboboxCell` with the in-dropdown * search input hidden. Same `CellEditorProps` contract, so it drops into any * column. Use for short, fixed option lists (status, type) where search adds no * value; use `GridComboboxCell` for long entity lists. */export function GridSelectCell(props: GridSelectCellProps) { return <GridComboboxCell {...props} searchable={false} />}"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 { useDataTableFlash } from "../../hooks/use-data-table-flash"import { useDataGridInternals, useRegisterGridFeature, type ClipboardFeature,} from "../core/data-grid-features"import { parseTsv, serializeSelection } from "../hooks/use-grid-clipboard"import type { CellState, CopiedRange, SelectionBounds,} from "../types/grid-cell"
/** Capture the copied rectangle by identity (for the marching-ants outline). */function buildCopiedRange( bounds: SelectionBounds | null, orderedRows: readonly { id: string }[], columnIds: readonly string[],): CopiedRange | null { if (!bounds) return null const rowIds = new Set<string>() let firstRowId = "" let lastRowId = "" for (let r = bounds.minRow; r <= bounds.maxRow; r++) { const id = orderedRows[r]?.id if (!id) continue rowIds.add(id) if (r === bounds.minRow) firstRowId = id lastRowId = id } const cols = columnIds.slice(bounds.minColIndex, bounds.maxColIndex + 1) return { rowIds, firstRowId, lastRowId, columnIds: new Set(cols), firstColumnId: cols[0] ?? "", lastColumnId: cols[cols.length - 1] ?? "", }}
export interface DataGridClipboardProps { /** * Resolve a pasted string into a CellState for a column. Required for paste * (Ctrl/Cmd+V); without it copy/cut still work. Keeps the grid * validation-agnostic. */ resolveCell?: (columnId: string, raw: string) => CellState<string>}
/** * Opt-in clipboard for the grid — copy / cut / paste via the NATIVE clipboard * events (input-safe, gives real `clipboardData`), a TSV wire format that * round-trips with external tabular editors, and the marching-ants copy outline. Drop it * inside `<DataGrid>`; leave it out and the grid attaches no document * listeners and ships no clipboard code. * * @example * <DataGrid grid={grid}> * <DataGridClipboard resolveCell={resolveCell} /> * <DataTable>…</DataTable> * </DataGrid> */export function DataGridClipboard({ resolveCell }: DataGridClipboardProps) { const { grid, selectionBounds, orderedRows, columnIds, displayIndexOf, getDisplayRow, clearSelection, wrapperRef, } = useDataGridInternals() const { flashCells } = useDataTableFlash() const [copiedRange, setCopiedRange] = React.useState<CopiedRange | null>(null) const { focusedCell, editingCell } = grid
// Await the clipboard write; returns the rectangle it wrote on success (null // on failure or empty selection). It does NOT touch the marching-ants marker — // each caller decides: copy shows the outline, cut removes it. Awaiting the // write means a rejected write (permissions, focus) never destroys data, and // returning the captured bounds means each caller acts on the snapshot it // wrote, not whatever the selection has since become. const writeSelectionToClipboard = React.useCallback(async (): Promise<SelectionBounds | null> => { if (!selectionBounds) return null const snapshot = selectionBounds try { await navigator.clipboard.writeText( serializeSelection(getDisplayRow, columnIds, snapshot), ) } catch { return null } return snapshot }, [selectionBounds, getDisplayRow, columnIds])
const copySelection = React.useCallback(() => { void writeSelectionToClipboard().then(bounds => { if (bounds) { setCopiedRange(buildCopiedRange(bounds, orderedRows, columnIds)) } }) }, [writeSelectionToClipboard, orderedRows, columnIds])
const cutSelection = React.useCallback(() => { void writeSelectionToClipboard().then(bounds => { if (!bounds) return // Match native cut: clear the source and DROP the marching-ants outline — // an outline around now-empty cells reads as "still copyable" and is wrong. clearSelection() setCopiedRange(null) }) }, [writeSelectionToClipboard, clearSelection])
// Apply pasted TSV text (shared by the native paste event and the // context-menu "Paste" action). Anchors at the selection's top-left (or the // focused cell), and TILES the copied block across a larger selection when it // divides evenly — the standard "paste one to many" behavior. const applyPaste = React.useCallback( (text: string) => { if (!resolveCell || !text) return const matrix = parseTsv(text) const pr = matrix.length const pc = matrix.reduce((m, cells) => Math.max(m, cells.length), 0) if (pr === 0 || pc === 0) return
// Anchor at the selection's top-left; a range defines the paste target, // a single cell just anchors a normal (possibly grid-growing) paste. const b = selectionBounds const startRow = b ? b.minRow : focusedCell ? (displayIndexOf(focusedCell.rowId) ?? 0) : 0 const startCol = b ? b.minColIndex : focusedCell ? columnIds.indexOf(focusedCell.columnId) : 0 const selRows = b ? b.maxRow - b.minRow + 1 : 1 const selCols = b ? b.maxColIndex - b.minColIndex + 1 : 1
// Paste-one-to-many: when the selection is LARGER than the copied block // and an exact multiple in both axes, tile the block to fill the whole // selection (copy 1×1 → fill; copy 1×3 into 3×3 → repeat down). Otherwise // paste the block once at the anchor. const tile = (selRows > pr || selCols > pc) && selRows % pr === 0 && selCols % pc === 0 const destRows = tile ? selRows : pr const destCols = tile ? selCols : pc
// Grow the grid when a NON-tiled paste runs past the last row (the grid // grows rather than dropping overflow; tiling is bounded by the existing // selection so it never needs new rows). `addRows` clamps at `maxRows` // and commits its own history entry, so a grown paste undoes in two steps. // Note: `addRows` appends to STORAGE order — under an active column sort // the new rows may not appear at the visual end (same caveat as reorder). const overflow = tile ? 0 : startRow + destRows - orderedRows.length const created = overflow > 0 ? grid.addRows(overflow) : []
const patchById = new Map<string, Record<string, CellState<string>>>() for (let dr = 0; dr < destRows; dr++) { const rowId = orderedRows[startRow + dr]?.id ?? created[startRow + dr - orderedRows.length]?.id if (!rowId) continue const patch = patchById.get(rowId) ?? {} for (let dc = 0; dc < destCols; dc++) { const col = columnIds[startCol + dc] if (!col) continue const raw = matrix[dr % pr]?.[dc % pc] ?? "" patch[col] = resolveCell(col, raw) } patchById.set(rowId, patch) } if (patchById.size === 0) return grid.updateRows(rows => rows.map(row => { const patch = patchById.get(row.id) return patch ? { ...row, ...patch } : row }), ) setCopiedRange(null) // Flash the specific cells that received pasted values (cell-level for // value changes, the standard behavior), not whole rows. No scroll — user is here. const flashed: { rowId: string; columnId: string }[] = [] patchById.forEach((patch, rowId) => { for (const columnId of Object.keys(patch)) flashed.push({ rowId, columnId }) }) flashCells(flashed, { scrollIntoView: false }) }, [ resolveCell, focusedCell, selectionBounds, orderedRows, columnIds, displayIndexOf, grid, flashCells, ], )
const pasteFromClipboard = React.useCallback(() => { void (async () => { try { const text = await navigator.clipboard.readText() if (text) applyPaste(text) } catch { // Clipboard read may be blocked (permissions / focus) — Ctrl/Cmd+V still works. } })() }, [applyPaste])
const clearCopiedRange = React.useCallback(() => setCopiedRange(null), [])
// Native clipboard events — subscribed ONCE while this component is mounted; // handlers read the latest state through a ref instead of re-binding three // document listeners on every render. const clipboardRef = React.useRef({ editingCell, selectionBounds, columnIds, orderedRows, getDisplayRow, resolveCell, clearSelection, applyPaste, }) clipboardRef.current = { editingCell, selectionBounds, columnIds, orderedRows, getDisplayRow, resolveCell, clearSelection, applyPaste, }
React.useEffect(() => { // Only intercept when the grid actually has focus — the listeners live on // `document`, so without this guard a grid with a lingering selection // would hijack copy/paste aimed at other inputs on the page. const gridHasFocus = () => wrapperRef.current?.contains(document.activeElement) ?? false
const onCopy = (e: ClipboardEvent) => { const s = clipboardRef.current if (!gridHasFocus()) return if (s.editingCell || !s.selectionBounds) return e.clipboardData?.setData( "text/plain", serializeSelection(s.getDisplayRow, s.columnIds, s.selectionBounds), ) e.preventDefault() setCopiedRange( buildCopiedRange(s.selectionBounds, s.orderedRows, s.columnIds), ) } const onCut = (e: ClipboardEvent) => { const s = clipboardRef.current if (!gridHasFocus()) return if (s.editingCell || !s.selectionBounds) return e.clipboardData?.setData( "text/plain", serializeSelection(s.getDisplayRow, s.columnIds, s.selectionBounds), ) e.preventDefault() s.clearSelection() setCopiedRange(null) } const onPaste = (e: ClipboardEvent) => { const s = clipboardRef.current if (!gridHasFocus()) return if (s.editingCell || !s.resolveCell) return const text = e.clipboardData?.getData("text/plain") if (!text) return e.preventDefault() s.applyPaste(text) }
document.addEventListener("copy", onCopy) document.addEventListener("cut", onCut) document.addEventListener("paste", onPaste) return () => { document.removeEventListener("copy", onCopy) document.removeEventListener("cut", onCut) document.removeEventListener("paste", onPaste) } }, [wrapperRef])
const payload = React.useMemo<ClipboardFeature>( () => ({ copiedRange, copySelection, cutSelection, pasteFromClipboard, clearCopiedRange, canPaste: !!resolveCell, }), [ copiedRange, copySelection, cutSelection, pasteFromClipboard, clearCopiedRange, resolveCell, ], ) useRegisterGridFeature("clipboard", payload)
return null}"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import * as React from "react"
import { useDataTableFlash } from "../../hooks/use-data-table-flash"import { GRID_EDGE_SPEED, GRID_EDGE_ZONE, useDataGridInternals, useRegisterGridFeature, type FillFeature,} from "../core/data-grid-features"import { emptyCell } from "../types/grid-cell"import type { CellState, SelectionBounds } from "../types/grid-cell"
/** * The fill rectangle while dragging the fill handle to a target cell. Single * axis (the standard behavior): extends the source selection toward the target on the * dominant axis only (down/up OR left/right). */function computeFillBounds( source: SelectionBounds, targetRow: number, targetCol: number,): SelectionBounds { const vert = Math.max( Math.max(0, targetRow - source.maxRow), Math.max(0, source.minRow - targetRow), ) const horiz = Math.max( Math.max(0, targetCol - source.maxColIndex), Math.max(0, source.minColIndex - targetCol), ) if (vert === 0 && horiz === 0) return source if (vert >= horiz) { return targetRow > source.maxRow ? { ...source, maxRow: targetRow } : { ...source, minRow: targetRow } } return targetCol > source.maxColIndex ? { ...source, maxColIndex: targetCol } : { ...source, minColIndex: targetCol }}
/** * Opt-in fill handle for the grid — the small square at the selection's * bottom-right corner. Drag it to fill down/across (tiles the source values, * the standard behavior); double-click to auto-fill down to the last row. After a fill * the filled range becomes the selection (standard behavior). Drop it inside * `<DataGrid>`; leave it out and no handle renders and no fill code ships. * * @example * <DataGrid grid={grid}> * <DataGridFillHandle /> * <DataTable>…</DataTable> * </DataGrid> */export function DataGridFillHandle() { const { selectionBounds, wrapperRef, pointerRef, envRef, selectRange } = useDataGridInternals() const { flashCells } = useDataTableFlash()
const [fillBounds, setFillBounds] = React.useState<SelectionBounds | null>( null, ) const fillModeRef = React.useRef(false) const fillSourceRef = React.useRef<SelectionBounds | null>(null) const fillBoundsRef = React.useRef<SelectionBounds | null>(null) fillBoundsRef.current = fillBounds const fillRafRef = React.useRef<number | null>(null) const onUpRef = React.useRef<(() => void) | null>(null)
// Copy the source selection's values into the fill extension (tiled), flash. const applyFill = React.useCallback( (source: SelectionBounds, fill: SelectionBounds) => { const env = envRef.current const srcRows = source.maxRow - source.minRow + 1 const srcCols = source.maxColIndex - source.minColIndex + 1 const patch = new Map<string, Record<string, CellState<string>>>() const flashed: { rowId: string; columnId: string }[] = [] for (let r = fill.minRow; r <= fill.maxRow; r++) { for (let c = fill.minColIndex; c <= fill.maxColIndex; c++) { const inSource = r >= source.minRow && r <= source.maxRow && c >= source.minColIndex && c <= source.maxColIndex if (inSource) continue const targetRowId = env.orderedRows[r]?.id const targetCol = env.columnIds[c] if (!targetRowId || !targetCol) continue const sr = source.minRow + ((((r - source.minRow) % srcRows) + srcRows) % srcRows) const sc = source.minColIndex + ((((c - source.minColIndex) % srcCols) + srcCols) % srcCols) const srcRow = env.orderedRows[sr]?.original const srcColId = env.columnIds[sc] if (!srcRow || !srcColId) continue const p = patch.get(targetRowId) ?? {} // Blank source cells may be unmaterialized (absent) — fall back to a // fresh empty cell so the destination always gets a valid CellState. const src = srcRow[srcColId] as CellState<string> | undefined p[targetCol] = src ? { ...src } : emptyCell<string>() patch.set(targetRowId, p) flashed.push({ rowId: targetRowId, columnId: targetCol }) } } if (patch.size === 0) return env.grid.updateRows(rows => rows.map(row => { const pp = patch.get(row.id) return pp ? { ...row, ...pp } : row }), ) flashCells(flashed, { scrollIntoView: false }) }, [envRef, flashCells], )
// rAF loop while dragging: track the pointer, live-preview the fill // rectangle, and auto-scroll at both axes' edges (a horizontal fill needs // horizontal auto-scroll just as a fill-down needs vertical). const fillStep = React.useCallback(() => { if (!fillModeRef.current) { fillRafRef.current = null return } const env = envRef.current const source = fillSourceRef.current const scrollEl = wrapperRef.current?.querySelector<HTMLElement>( '[data-slot="table-container"]', ) const { x, y } = pointerRef.current if (scrollEl) { const rect = scrollEl.getBoundingClientRect() if (y < rect.top + GRID_EDGE_ZONE) scrollEl.scrollTop -= GRID_EDGE_SPEED else if (y > rect.bottom - GRID_EDGE_ZONE) scrollEl.scrollTop += GRID_EDGE_SPEED if (x < rect.left + GRID_EDGE_ZONE) scrollEl.scrollLeft -= GRID_EDGE_SPEED else if (x > rect.right - GRID_EDGE_ZONE) scrollEl.scrollLeft += GRID_EDGE_SPEED } const attr = document .elementFromPoint(x, y) ?.closest("[data-cell]") ?.getAttribute("data-cell") if (attr && source) { const sep = attr.indexOf(":") const tRow = env.displayIndexOf(attr.slice(0, sep)) const tCol = env.columnIds.indexOf(attr.slice(sep + 1)) if (tRow !== undefined && tCol >= 0) { setFillBounds(computeFillBounds(source, tRow, tCol)) } } fillRafRef.current = requestAnimationFrame(fillStep) }, [envRef, pointerRef, wrapperRef])
const onFillHandleMouseDown = React.useCallback( (e: React.MouseEvent) => { if (!selectionBounds) return e.preventDefault() e.stopPropagation() // Seed the pointer — the rAF loop may run before the first mousemove. pointerRef.current = { x: e.clientX, y: e.clientY } fillSourceRef.current = selectionBounds fillModeRef.current = true setFillBounds(selectionBounds) if (fillRafRef.current == null) fillRafRef.current = requestAnimationFrame(fillStep)
// Drag-scoped mouseup — attached only for the duration of this drag. const onUp = () => { onUpRef.current = null fillModeRef.current = false if (fillRafRef.current != null) { cancelAnimationFrame(fillRafRef.current) fillRafRef.current = null } const source = fillSourceRef.current const fill = fillBoundsRef.current if (source && fill) { applyFill(source, fill) selectRange(fill) // extend the selection over the fill, the standard behavior } setFillBounds(null) fillSourceRef.current = null } onUpRef.current = onUp window.addEventListener("mouseup", onUp, { once: true }) }, [selectionBounds, fillStep, applyFill, selectRange, pointerRef], )
// Double-click the handle → auto-fill down to the grid's last row. const onFillHandleDoubleClick = React.useCallback(() => { const rowCount = envRef.current.orderedRows.length if (!selectionBounds || rowCount === 0) return const fill = { ...selectionBounds, maxRow: rowCount - 1 } if (fill.maxRow > selectionBounds.maxRow) { applyFill(selectionBounds, fill) selectRange(fill) // highlight the filled range, the standard behavior } }, [selectionBounds, envRef, applyFill, selectRange])
// Unmount safety: cancel a drag in flight. React.useEffect( () => () => { if (fillRafRef.current != null) cancelAnimationFrame(fillRafRef.current) if (onUpRef.current) { window.removeEventListener("mouseup", onUpRef.current) onUpRef.current = null } }, [], )
const payload = React.useMemo<FillFeature>( () => ({ fillBounds, onFillHandleMouseDown, onFillHandleDoubleClick }), [fillBounds, onFillHandleMouseDown, onFillHandleDoubleClick], ) useRegisterGridFeature("fill", payload)
return null}"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import * as React from "react"
import { useDataTableFlash } from "../../hooks/use-data-table-flash"import { GRID_EDGE_SPEED, GRID_EDGE_ZONE, clampGridIndex as clamp, useDataGridInternals, useRegisterGridFeature, type MoveFeature,} from "../core/data-grid-features"import { emptyCell, type CellPosition, type CellState, type SelectionBounds,} from "../types/grid-cell"
/** * Opt-in drag-to-move for the grid (border-drag) — grab the selection's * outer border and drag the whole block to a new location. The values MOVE * (source clears); holding Ctrl/Cmd COPIES instead (toggleable mid-drag). Drop * it inside `<DataGrid>`; leave it out and no grab strips render and no move * code ships. * * @example * <DataGrid grid={grid}> * <DataGridMove /> * <DataTable>…</DataTable> * </DataGrid> */export function DataGridMove() { const { selectionBounds, wrapperRef, pointerRef, envRef, selectRange } = useDataGridInternals() const { flashCells } = useDataTableFlash()
const [moveBounds, setMoveBounds] = React.useState<SelectionBounds | null>( null, ) const moveModeRef = React.useRef(false) const moveSourceRef = React.useRef<SelectionBounds | null>(null) const moveGrabRef = React.useRef<{ row: number; col: number } | null>(null) const moveOffsetRef = React.useRef({ dr: 0, dc: 0 }) const moveCopyRef = React.useRef(false) const moveRafRef = React.useRef<number | null>(null) const dragCleanupRef = React.useRef<(() => void) | null>(null)
// Move (or copy) the source block by (dr, dc). Reads the whole source block // into memory FIRST so an overlapping source/target is safe, clears the source // on a move, stamps the target, then selects + flashes the landing rectangle. const applyMove = React.useCallback( (source: SelectionBounds, dr: number, dc: number, copy: boolean) => { if (dr === 0 && dc === 0) return const env = envRef.current const patch = new Map<string, Record<string, CellState<string>>>() const ensure = (rowId: string) => { const existing = patch.get(rowId) if (existing) return existing const created: Record<string, CellState<string>> = {} patch.set(rowId, created) return created }
// 1. Snapshot the source block, and (for a move) clear it. const block: (CellState<string> | undefined)[][] = [] for (let r = source.minRow; r <= source.maxRow; r++) { const srcRow = env.orderedRows[r]?.original const srcRowId = env.orderedRows[r]?.id const rowVals: (CellState<string> | undefined)[] = [] for (let c = source.minColIndex; c <= source.maxColIndex; c++) { const col = env.columnIds[c] rowVals.push( srcRow && col ? (srcRow[col] as CellState<string>) : undefined, ) if (!copy && srcRowId && col) ensure(srcRowId)[col] = emptyCell<string>() } block.push(rowVals) }
// 2. Stamp the target (writes win over the source-clear on overlap). // Every in-bounds source cell overwrites its destination — an empty // source cell stamps empty (the move replaces the whole rectangle, it // doesn't merge). Only populated cells flash. const flashed: { rowId: string; columnId: string }[] = [] for (let r = source.minRow; r <= source.maxRow; r++) { const targetRowId = env.orderedRows[r + dr]?.id if (!targetRowId) continue for (let c = source.minColIndex; c <= source.maxColIndex; c++) { const col = env.columnIds[c + dc] if (!col) continue const val = block[r - source.minRow]?.[c - source.minColIndex] ensure(targetRowId)[col] = val ? { ...val } : emptyCell<string>() if (val && val.status !== "empty") { flashed.push({ rowId: targetRowId, columnId: col }) } } } if (patch.size === 0) return env.grid.updateRows(rows => rows.map(row => { const p = patch.get(row.id) return p ? { ...row, ...p } : row }), ) selectRange({ minRow: source.minRow + dr, maxRow: source.maxRow + dr, minColIndex: source.minColIndex + dc, maxColIndex: source.maxColIndex + dc, }) flashCells(flashed, { scrollIntoView: false }) }, [envRef, selectRange, flashCells], )
// rAF loop while dragging: ghost the landing rectangle under the pointer // (clamped in-grid) and auto-scroll at both axes' edges. const moveStep = React.useCallback(() => { if (!moveModeRef.current) { moveRafRef.current = null return } const env = envRef.current const source = moveSourceRef.current const grab = moveGrabRef.current const scrollEl = wrapperRef.current?.querySelector<HTMLElement>( '[data-slot="table-container"]', ) const { x, y } = pointerRef.current if (scrollEl) { const rect = scrollEl.getBoundingClientRect() if (y < rect.top + GRID_EDGE_ZONE) scrollEl.scrollTop -= GRID_EDGE_SPEED else if (y > rect.bottom - GRID_EDGE_ZONE) scrollEl.scrollTop += GRID_EDGE_SPEED if (x < rect.left + GRID_EDGE_ZONE) scrollEl.scrollLeft -= GRID_EDGE_SPEED else if (x > rect.right - GRID_EDGE_ZONE) scrollEl.scrollLeft += GRID_EDGE_SPEED } const attr = document .elementFromPoint(x, y) ?.closest("[data-cell]") ?.getAttribute("data-cell") if (attr && source && grab) { const sep = attr.indexOf(":") const tRow = env.displayIndexOf(attr.slice(0, sep)) const tCol = env.columnIds.indexOf(attr.slice(sep + 1)) if (tRow !== undefined && tCol >= 0) { // Offset so the grabbed cell tracks the pointer, clamped so the whole // block stays inside the grid. const maxRow = env.orderedRows.length - 1 const maxCol = env.columnIds.length - 1 const dr = clamp( tRow - grab.row, -source.minRow, maxRow - source.maxRow, ) const dc = clamp( tCol - grab.col, -source.minColIndex, maxCol - source.maxColIndex, ) moveOffsetRef.current = { dr, dc } setMoveBounds({ minRow: source.minRow + dr, maxRow: source.maxRow + dr, minColIndex: source.minColIndex + dc, maxColIndex: source.maxColIndex + dc, }) } } moveRafRef.current = requestAnimationFrame(moveStep) }, [envRef, pointerRef, wrapperRef])
const onSelectionMoveMouseDown = React.useCallback( (e: React.MouseEvent, grabPos: CellPosition) => { if (!selectionBounds || e.button !== 0) return e.preventDefault() e.stopPropagation() const env = envRef.current const grabRow = env.displayIndexOf(grabPos.rowId) const grabCol = env.columnIds.indexOf(grabPos.columnId) if (grabRow === undefined || grabCol < 0) return // Seed the pointer — the rAF loop may run before the first mousemove. pointerRef.current = { x: e.clientX, y: e.clientY } moveSourceRef.current = selectionBounds moveGrabRef.current = { row: grabRow, col: grabCol } moveOffsetRef.current = { dr: 0, dc: 0 } moveCopyRef.current = e.ctrlKey || e.metaKey moveModeRef.current = true setMoveBounds(selectionBounds) if (moveRafRef.current == null) moveRafRef.current = requestAnimationFrame(moveStep)
// Drag-scoped listeners — attached only for the duration of this drag. // The mousemove tracks the live copy-vs-move modifier (press/release // Ctrl/Cmd mid-drag); the mouseup applies and cleans everything up. const onDragMove = (ev: MouseEvent) => { moveCopyRef.current = ev.ctrlKey || ev.metaKey } const onUp = () => { cleanup() moveModeRef.current = false if (moveRafRef.current != null) { cancelAnimationFrame(moveRafRef.current) moveRafRef.current = null } const source = moveSourceRef.current const { dr, dc } = moveOffsetRef.current if (source) applyMove(source, dr, dc, moveCopyRef.current) setMoveBounds(null) moveSourceRef.current = null moveGrabRef.current = null } const cleanup = () => { dragCleanupRef.current = null window.removeEventListener("mousemove", onDragMove) window.removeEventListener("mouseup", onUp) } dragCleanupRef.current = cleanup window.addEventListener("mousemove", onDragMove) window.addEventListener("mouseup", onUp) }, [selectionBounds, envRef, moveStep, applyMove, pointerRef], )
// Unmount safety: cancel a drag in flight. React.useEffect( () => () => { if (moveRafRef.current != null) cancelAnimationFrame(moveRafRef.current) dragCleanupRef.current?.() }, [], )
const payload = React.useMemo<MoveFeature>( () => ({ moveBounds, onSelectionMoveMouseDown }), [moveBounds, onSelectionMoveMouseDown], ) useRegisterGridFeature("move", payload)
return null}"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import { cn } from "@/lib/utils"import * as React from "react"import { createPortal } from "react-dom"
import { GRID_EDGE_SPEED, GRID_EDGE_ZONE, useDataGridFeatures, useDataGridInternals, useRegisterGridFeature, type RowReorderFeature,} from "../core/data-grid-features"
/** * Read the row-reorder handle inside a gutter cell. Returns `null` when * `<DataGridRowReorder>` isn't mounted, so a gutter can render its drag grip * conditionally. Wire the grip's `onMouseDown` to `onRowReorderMouseDown`. */export function useDataGridRowReorder(): RowReorderFeature | null { return useDataGridFeatures().rowReorder ?? null}
/** Where the dragged row will land, plus the on-screen line geometry (fixed). */interface DropTarget { targetRowId: string position: "before" | "after" top: number left: number width: number}
export interface DataGridRowReorderProps { /** Extra classes for the drop-indicator line (defaults to a primary bar). */ className?: string}
/** * Opt-in drag-to-reorder ROWS for the grid (a gutter row handle). A grab * handle in the consumer's gutter calls `onRowReorderMouseDown`; drag up/down * and a drop line shows where the row will land; release commits the move as a * single undoable history entry. Grid-native (no dnd-kit) so it keeps the * grid cell chrome, column resize, virtualization, and scales — only the * dragged row moves (O(n) splice), nothing re-registers per row. * * Reorders the UNDERLYING row array by id, so it's correct with no active sort * (the grid's default). With a column sort applied, display order and storage * order diverge and a drop may not land where the line showed — disable the * handle while sorted if that matters for your data. * * The drag itself is pointer-only. For keyboard users, pair this with a * keyboard path in your row menu (e.g. "Move up" / "Move down" items calling * `grid.updateRows` with the same splice), and keep the grip focusable so it * is discoverable. * * @example * <DataGrid grid={grid}> * <DataGridRowReorder /> * <DataTable>…</DataTable> * </DataGrid> */export function DataGridRowReorder({ className,}: DataGridRowReorderProps = {}) { const { wrapperRef, pointerRef, envRef } = useDataGridInternals()
const [draggingRowId, setDraggingRowId] = React.useState<string | null>(null) const [dropTarget, setDropTarget] = React.useState<DropTarget | null>(null)
const dragModeRef = React.useRef(false) const dragRowIdRef = React.useRef<string | null>(null) const dropTargetRef = React.useRef<DropTarget | null>(null) const rafRef = React.useRef<number | null>(null) const dragCleanupRef = React.useRef<(() => void) | null>(null)
// Move the dragged row to just before/after the target row in the underlying // array (by id — survives display order), as one history commit. const applyReorder = React.useCallback( (sourceId: string, target: DropTarget) => { if (sourceId === target.targetRowId) return envRef.current.grid.updateRows(rows => { const from = rows.findIndex(r => r.id === sourceId) if (from < 0) return rows const copy = rows.slice() const [moved] = copy.splice(from, 1) if (!moved) return rows const targetIdx = copy.findIndex(r => r.id === target.targetRowId) if (targetIdx < 0) return rows const insertAt = target.position === "after" ? targetIdx + 1 : targetIdx // Dropping the row back into its own slot is a no-op — return the // original array (===) so no phantom history entry is committed. if (insertAt === from) return rows copy.splice(insertAt, 0, moved) return copy }) }, [envRef], )
// rAF loop while dragging: auto-scroll at the vertical edges (only when the // list actually overflows, so a short grid never scrolls) and resolve the drop // target by scanning the RENDERED rows for the one closest to the pointer — // robust where `elementFromPoint` returns nothing (gaps, the footer, past the // last row), which would otherwise leave the target stuck "after the last row" // and drop everything at the end. const reorderStep = React.useCallback(() => { if (!dragModeRef.current) { rafRef.current = null return } const scrollEl = wrapperRef.current?.querySelector<HTMLElement>( '[data-slot="table-container"]', ) const { y } = pointerRef.current if (scrollEl) { const rect = scrollEl.getBoundingClientRect() const canScrollUp = scrollEl.scrollTop > 0 const canScrollDown = scrollEl.scrollTop < scrollEl.scrollHeight - scrollEl.clientHeight - 1 if (y < rect.top + GRID_EDGE_ZONE && canScrollUp) scrollEl.scrollTop -= GRID_EDGE_SPEED else if (y > rect.bottom - GRID_EDGE_ZONE && canScrollDown) scrollEl.scrollTop += GRID_EDGE_SPEED
// Closest rendered row to the pointer → before/after by its midpoint. const rows = scrollEl.querySelectorAll<HTMLElement>("tr[data-row-id]") let best: DropTarget | null = null let bestDist = Infinity rows.forEach(tr => { const id = tr.getAttribute("data-row-id") if (!id) return const r = tr.getBoundingClientRect() const mid = r.top + r.height / 2 const dist = Math.abs(y - mid) if (dist < bestDist) { bestDist = dist const position: "before" | "after" = y < mid ? "before" : "after" best = { targetRowId: id, position, top: position === "before" ? r.top : r.bottom, left: r.left, width: r.width, } } }) if (best) { dropTargetRef.current = best setDropTarget(best) } } rafRef.current = requestAnimationFrame(reorderStep) }, [wrapperRef, pointerRef])
const onRowReorderMouseDown = React.useCallback( (e: React.MouseEvent, rowId: string) => { if (e.button !== 0) return e.preventDefault() e.stopPropagation() // Seed the pointer — the rAF loop may run before the first mousemove. pointerRef.current = { x: e.clientX, y: e.clientY } dragModeRef.current = true dragRowIdRef.current = rowId dropTargetRef.current = null setDraggingRowId(rowId) setDropTarget(null) if (rafRef.current == null) rafRef.current = requestAnimationFrame(reorderStep)
const onUp = () => { cleanup() dragModeRef.current = false if (rafRef.current != null) { cancelAnimationFrame(rafRef.current) rafRef.current = null } const source = dragRowIdRef.current const target = dropTargetRef.current if (source && target) applyReorder(source, target) setDraggingRowId(null) setDropTarget(null) dragRowIdRef.current = null dropTargetRef.current = null } const cleanup = () => { dragCleanupRef.current = null window.removeEventListener("mouseup", onUp) } dragCleanupRef.current = cleanup window.addEventListener("mouseup", onUp) }, [pointerRef, reorderStep, applyReorder], )
// Unmount safety: cancel a drag in flight. React.useEffect( () => () => { if (rafRef.current != null) cancelAnimationFrame(rafRef.current) dragCleanupRef.current?.() }, [], )
const payload = React.useMemo<RowReorderFeature>( () => ({ draggingRowId, onRowReorderMouseDown }), [draggingRowId, onRowReorderMouseDown], ) useRegisterGridFeature("rowReorder", payload)
// The drop indicator: a fixed-position primary line at the target row edge. // Portaled to the body so it's never clipped by the scroll container's // overflow. Fixed coords come straight from the target row's rect. if (!dropTarget || typeof document === "undefined") return null return createPortal( <div aria-hidden className={cn( "pointer-events-none fixed z-50 h-0.5 rounded-full bg-primary", className, )} style={{ top: dropTarget.top - 1, left: dropTarget.left, width: dropTarget.width, }} />, document.body, )}"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 { useDataTableActiveCellSetter, type ActiveCell,} from "../../core/data-table-context"import { useDataGridInternals } from "../core/data-grid-features"
/** * Opt-in grid-style cross-highlight — EVERY column and row in the current * selection lights up: the column HEADERS in the selection, and each selected * row's `data-active-row` marker (style your row-number gutter with * `group-data-[active-row=true]:…`). Drop it inside `<DataGrid>`; leave it out * and selection changes never touch the table's active-cell context. * * @example * <DataGrid grid={grid}> * <DataGridCrossHighlight /> * <DataTable>…</DataTable> * </DataGrid> */export function DataGridCrossHighlight() { const { selectionBounds, columnIds } = useDataGridInternals() const setActiveCell = useDataTableActiveCellSetter()
// Span of the selection: the ids of its columns (few, all rendered → Set) // and its inclusive row-index range (rows are virtualized → range, not Set). const active = React.useMemo<ActiveCell>(() => { if (!selectionBounds) return null const cols = new Set<string>() for ( let c = selectionBounds.minColIndex; c <= selectionBounds.maxColIndex; c++ ) { const id = columnIds[c] if (id) cols.add(id) } return { columnIds: cols, rowRange: { min: selectionBounds.minRow, max: selectionBounds.maxRow }, } }, [selectionBounds, columnIds])
// Publish on change; clear only on unmount (a per-change cleanup-to-null would // flash the highlight off then on between selections). React.useEffect(() => { setActiveCell(active) }, [active, setActiveCell]) React.useEffect(() => () => setActiveCell(null), [setActiveCell])
return null}"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import { cn } from "@/lib/utils"import * as React from "react"
import { useDataGridInternals } from "../core/data-grid-features"import type { CellState } from "../types/grid-cell"
/** * Beyond this many selected cells we skip the sum/avg scan (huge selections * like Ctrl+A over 500k rows) and show just the selected-cell count, so the * status bar never blocks a drag/keystroke. */const MAX_STATS_CELLS = 20_000
interface SelectionStats { /** Total cells in the selection rectangle. */ cells: number /** Whether the sum/avg scan ran (selection ≤ cap). */ scanned: boolean /** Non-empty cells. */ count: number /** Cells whose value parsed as a number. */ numericCount: number sum: number min: number max: number}
/** * Opt-in selection status bar — standard Count / Sum / Avg / Min / * Max of the current selection. Drop it wherever you want the readout (usually * a footer under the table); it renders nothing until a range of `minCells`+ is * selected. Requires being inside `<DataGrid>`. * * @example * <DataGrid grid={grid}> * <DataTable>…</DataTable> * <DataGridStatusBar /> * </DataGrid> */export function DataGridStatusBar({ className, /** Smallest selection (in cells) that shows the bar. Default 2 — hide for a single cell. */ minCells = 2,}: { className?: string minCells?: number}) { const { selectionBounds, orderedRows, columnIds } = useDataGridInternals()
const stats = React.useMemo<SelectionStats | null>(() => { if (!selectionBounds) return null const { minRow, maxRow, minColIndex, maxColIndex } = selectionBounds const cells = (maxRow - minRow + 1) * (maxColIndex - minColIndex + 1) if (cells < minCells) return null
const scanned = cells <= MAX_STATS_CELLS let count = 0 let numericCount = 0 let sum = 0 let min = Infinity let max = -Infinity if (scanned) { for (let r = minRow; r <= maxRow; r++) { const row = orderedRows[r]?.original if (!row) continue for (let c = minColIndex; c <= maxColIndex; c++) { const col = columnIds[c] if (!col) continue const raw = (row[col] as CellState<string> | undefined)?.raw ?? "" if (raw === "") continue count++ // Tolerate thousands separators; blank/whitespace isn't a number. const n = Number(raw.replace(/,/g, "")) if (raw.trim() !== "" && Number.isFinite(n)) { numericCount++ sum += n if (n < min) min = n if (n > max) max = n } } } } return { cells, scanned, count, numericCount, sum, min, max } }, [selectionBounds, orderedRows, columnIds, minCells])
if (!stats) return null
const fmt = (n: number) => n.toLocaleString(undefined, { maximumFractionDigits: 2 })
return ( <div className={cn( "flex items-center justify-end gap-4 border-t border-border bg-muted/30 px-3 py-1 text-xs text-muted-foreground tabular-nums", className, )} > {stats.scanned ? ( <> <StatusStat label="Count" value={stats.count.toLocaleString()} /> {stats.numericCount > 0 && ( <> <StatusStat label="Sum" value={fmt(stats.sum)} /> <StatusStat label="Avg" value={fmt(stats.sum / stats.numericCount)} /> <StatusStat label="Min" value={fmt(stats.min)} /> <StatusStat label="Max" value={fmt(stats.max)} /> </> )} </> ) : ( <StatusStat label="Selected" value={`${stats.cells.toLocaleString()} cells`} /> )} </div> )}
function StatusStat({ label, value }: { label: string; value: string }) { return ( <span> {label} <span className="font-medium text-foreground">{value}</span> </span> )}"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 { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger,} from "@/components/ui/dropdown-menu"import { ArrowLeft, ArrowLeftToLine, ArrowRight, ArrowRightToLine, Pencil, Shapes, Trash2,} from "lucide-react"
import { useColumnHeaderContext } from "../../components/data-table-column-header"import { useDataGridColumns } from "../core/data-grid-columns-context"
/** * Dynamic-column actions for the header dropdown — Rename, Insert left/right, * Move left/right, Delete. Reads the column from the header context and the * mutations from `<DataGridColumns>`. Drop it inside `<DataTableColumnActions>` * alongside the built-in Sort / Pin / Hide options: * * @example * <DataTableColumnActions> * <DataTableColumnSortOptions /> * <GridColumnMenuOptions /> * </DataTableColumnActions> */export function GridColumnMenuOptions({ withSeparator = true,}: { withSeparator?: boolean}) { const { column } = useColumnHeaderContext(true) const { columns, columnTypes, changeColumnType, addColumn, removeColumn, moveColumn, beginRename, } = useDataGridColumns()
const id = column.id const index = columns.findIndex(c => c.id === id) const currentType = columns[index]?.type const isFirst = index <= 0 const isLast = index === columns.length - 1 const isOnly = columns.length <= 1
return ( <> {withSeparator && <DropdownMenuSeparator />} <DropdownMenuGroup> <DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> Column </DropdownMenuLabel> <DropdownMenuItem onClick={() => beginRename(id)}> <Pencil className="mr-2 size-4 text-muted-foreground/70" /> Rename </DropdownMenuItem> {columnTypes.length > 0 && ( <DropdownMenuSub> <DropdownMenuSubTrigger> <Shapes className="mr-2 size-4 text-muted-foreground/70" /> Column type </DropdownMenuSubTrigger> <DropdownMenuSubContent> <DropdownMenuRadioGroup value={currentType} onValueChange={v => changeColumnType(id, v)} > {columnTypes.map(t => ( <DropdownMenuRadioItem key={t.value} value={t.value}> {t.label} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> </DropdownMenuSubContent> </DropdownMenuSub> )} <DropdownMenuItem onClick={() => addColumn({}, index)}> <ArrowLeftToLine className="mr-2 size-4 text-muted-foreground/70" /> Insert column left </DropdownMenuItem> <DropdownMenuItem onClick={() => addColumn({}, index + 1)}> <ArrowRightToLine className="mr-2 size-4 text-muted-foreground/70" /> Insert column right </DropdownMenuItem> <DropdownMenuItem disabled={isFirst} onClick={() => moveColumn(id, -1)}> <ArrowLeft className="mr-2 size-4 text-muted-foreground/70" /> Move left </DropdownMenuItem> <DropdownMenuItem disabled={isLast} onClick={() => moveColumn(id, 1)}> <ArrowRight className="mr-2 size-4 text-muted-foreground/70" /> Move right </DropdownMenuItem> <DropdownMenuItem variant="destructive" disabled={isOnly} onClick={() => removeColumn(id)} > <Trash2 className="mr-2 size-4" /> Delete column </DropdownMenuItem> </DropdownMenuGroup> </> )}"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 { Button } from "@/components/ui/button"import { Columns3 } from "lucide-react"
import { useDataGridColumns } from "../core/data-grid-columns-context"
/** * Toolbar button that appends a new column (opens rename so the user can name * it immediately). Requires a `<DataGridColumns>` ancestor. */export function DataGridAddColumnButton({ label = "Add column",}: { label?: string}) { const { addColumn, beginRename } = useDataGridColumns() return ( <Button type="button" variant="outline" onClick={() => { const created = addColumn() beginRename(created.id) }} > <Columns3 className="size-4" /> {label} </Button> )}"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 { ArrowDownToLine, ArrowUpToLine, ClipboardPaste, Copy, Eraser, Scissors, Trash2,} from "lucide-react"
import { RowMenuItem, RowMenuSeparator,} from "../../components/data-table-row-menu"import { useDataGridContext } from "../core/data-grid-context"import { useDataGridFeatures } from "../core/data-grid-features"import type { GridRow } from "../types/grid-cell"
/** * Grid cell context menu — a standard grid menu: Copy / Cut / Paste * (shown only when `<DataGridClipboard>` is mounted), Insert rows above/below, * Clear, Delete rows. Declarative — reads the display-order-aware selection * actions from `DataGridContext`, so the SAME component drops into * `<DataTableRowContextMenuSlot>` (right-click) or a toolbar kebab. Operates * on the current selection rectangle. */export function GridRowMenu() { const { clearSelection, deleteSelectedRows, insertRowsAbove, insertRowsBelow, hasSelection, } = useDataGridContext<GridRow>() const { clipboard } = useDataGridFeatures()
return ( <> {clipboard && ( <> <RowMenuItem onClick={clipboard.copySelection} disabled={!hasSelection} > <Copy className="size-4" /> Copy </RowMenuItem> <RowMenuItem onClick={clipboard.cutSelection} disabled={!hasSelection} > <Scissors className="size-4" /> Cut </RowMenuItem> {clipboard.canPaste && ( <RowMenuItem onClick={clipboard.pasteFromClipboard} disabled={!hasSelection} > <ClipboardPaste className="size-4" /> Paste </RowMenuItem> )} <RowMenuSeparator /> </> )} <RowMenuItem onClick={insertRowsAbove} disabled={!hasSelection}> <ArrowUpToLine className="size-4" /> Insert rows above </RowMenuItem> <RowMenuItem onClick={insertRowsBelow} disabled={!hasSelection}> <ArrowDownToLine className="size-4" /> Insert rows below </RowMenuItem> <RowMenuSeparator /> <RowMenuItem onClick={clearSelection} disabled={!hasSelection}> <Eraser className="size-4" /> Clear contents </RowMenuItem> <RowMenuItem variant="destructive" onClick={deleteSelectedRows} disabled={!hasSelection} > <Trash2 className="size-4" /> Delete rows </RowMenuItem> </> )}"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 { Button } from "@/components/ui/button"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"import { cn } from "@/lib/utils"import { Plus, Redo2, Undo2, X } from "lucide-react"
import { useDataGridContext } from "../core/data-grid-context"import type { GridRow } from "../types/grid-cell"
/** Undo the last data change (also Cmd/Ctrl+Z). */export function DataGridUndo() { const { grid } = useDataGridContext<GridRow>() return ( <Tooltip> <TooltipTrigger asChild> <Button type="button" variant="outline" size="icon" onClick={() => grid.undo()} disabled={!grid.canUndo} aria-label="Undo" > <Undo2 className="size-4" /> </Button> </TooltipTrigger> <TooltipContent>Undo the last change (Cmd/Ctrl+Z)</TooltipContent> </Tooltip> )}
/** Redo the last undone change (also Cmd/Ctrl+Shift+Z or Ctrl+Y). */export function DataGridRedo() { const { grid } = useDataGridContext<GridRow>() return ( <Tooltip> <TooltipTrigger asChild> <Button type="button" variant="outline" size="icon" onClick={() => grid.redo()} disabled={!grid.canRedo} aria-label="Redo" > <Redo2 className="size-4" /> </Button> </TooltipTrigger> <TooltipContent> Redo the last undone change (Cmd/Ctrl+Shift+Z) </TooltipContent> </Tooltip> )}
/** Toolbar layout wrapper — compose the pieces below (or your own) inside it. */export function DataGridToolbar({ children, className,}: { children: React.ReactNode className?: string}) { return ( <div className={cn("flex flex-wrap items-center gap-2", className)}> {children} </div> )}
/** * Append N empty rows to the grid. `count={1}` reads "Add row" (the singular); * any other count reads "Add {count} rows". Pass `className` to restyle — e.g. a * full-width dashed "Add row" bar under the grid body for a spreadsheet-style * always-visible single-row append. `label` overrides the text entirely. */export function DataGridAddRows({ count = 5, className, label,}: { count?: number className?: string label?: React.ReactNode}) { const { grid, columnIds } = useDataGridContext<GridRow>() const atCap = grid.rows.length >= grid.maxRows return ( <Button type="button" variant="outline" className={className} onClick={() => { const created = grid.addRows(count) // Focus the first new row's first cell — the container scrolls on focus // change, so this brings the new rows into view and readies them for typing. const first = created[0] if (first && columnIds[0]) { grid.selectCell({ rowId: first.id, columnId: columnIds[0] }) } }} disabled={atCap} > <Plus className="size-4" /> {label ?? (count === 1 ? "Add row" : `Add ${count} rows`)} </Button> )}
/** Replace every row with blanks (same count as the seed). */export function DataGridClearAll() { const { grid } = useDataGridContext<GridRow>() return ( <Button type="button" variant="outline" onClick={() => grid.clearAll()}> <X className="size-4" /> Clear all </Button> )}
/** Static hint that tabular data can be pasted in. */export function DataGridPasteHint() { return ( <span className="text-sm text-muted-foreground"> Paste a spreadsheet (Ctrl/Cmd+V) to fill rows </span> )}"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 { Button } from "@/components/ui/button"import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger,} from "@/components/ui/dialog"import { Kbd, KbdGroup } from "@/components/ui/kbd"import { Separator } from "@/components/ui/separator"import { Tooltip, TooltipContent, TooltipTrigger,} from "@/components/ui/tooltip"import { Keyboard } from "lucide-react"
import { GRID_SHORTCUTS, formatShortcutKey, useIsMac, type GridShortcutGroup,} from "../config/grid-shortcuts"
/** Read-only list of the grid's keyboard shortcuts, grouped by category. */export function GridShortcutsList({ groups = GRID_SHORTCUTS,}: { groups?: GridShortcutGroup[]}) { const isMac = useIsMac() return ( <div className="space-y-4"> {groups.map(group => ( <div key={group.category} className="space-y-1.5"> <div className="text-xs font-medium text-muted-foreground"> {group.category} </div> {group.shortcuts.map((s, i) => ( <div key={i} className="flex items-center justify-between gap-4 text-sm" > <span>{s.description}</span> <KbdGroup> {s.keys.map((k, j) => ( <Kbd key={j}>{formatShortcutKey(k, isMac)}</Kbd> ))} </KbdGroup> </div> ))} </div> ))} </div> )}
/** * Icon button that opens a keyboard-shortcuts help dialog. Self-contained (owns * its open state). Drop into a `<DataGridToolbar>`. Pass `open`/`onOpenChange` * to control it externally (e.g. to also open on the `?` key). */export function DataGridShortcutsButton({ open, onOpenChange,}: { open?: boolean onOpenChange?: (open: boolean) => void}) { return ( <Dialog open={open} onOpenChange={onOpenChange}> <Tooltip> <TooltipTrigger asChild> <DialogTrigger asChild> <Button type="button" variant="outline" size="icon" aria-label="Keyboard shortcuts" > <Keyboard className="size-4" /> </Button> </DialogTrigger> </TooltipTrigger> <TooltipContent>Keyboard shortcuts (?)</TooltipContent> </Tooltip> <DialogContent className="sm:max-w-md"> <DialogHeader> <DialogTitle>Keyboard shortcuts</DialogTitle> </DialogHeader> <div className="-mx-6"> <Separator /> </div> <GridShortcutsList /> </DialogContent> </Dialog> )}Update the import paths to match your project setup.
Optional change-set tracking for saves (useGridChanges):
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Copy and paste the following code into your project.
"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 *//** * niko-table grid — change tracking & persistence (opt-in). * * The engine (`useDataGrid`) is UNCONTROLLED — it owns the rows. This hook * OBSERVES it (via the `lastCommit` signal) and turns local edits into a CRUD * change-set, so a consumer can stage, autosave, or do full create/update/delete * against a backend. Install/import it ONLY on a grid that persists; grids that * don't pay nothing. Plain React, no dependency. * * Three patterns, one hook: * - Staging: edit → on Save read `getChangeSet().created` → one bulk mutation * → `reconcile({ succeededIds, failedIds })` → highlight failures. * - Autosave: `useEffect([dirtyRowIds])` + debounce → `getChangeSet()` * → batch upsert → `reconcile`. * - Full CRUD: create/update/delete all fall out of the change-set. */import * as React from "react"
import type { UseDataGrid } from "./use-data-grid"import type { GridRow } from "../types/grid-cell"
export interface UseGridChangesOptions<TRow extends GridRow> { /** * The server/pre-existing baseline. These rows' ids are "existing" (edits to * them are UPDATEs; removing them is a DELETE). Omit / pass `[]` for a fresh * import where everything the user enters is a CREATE. For a live-edit surface, * pass the SAME loaded rows you seeded `useDataGrid({ initialRows })` with. */ initialRows?: readonly TRow[] /** Row identity. Default `(r) => r.id`. */ getRowId?: (row: TRow) => string /** * Compare a current row to its baseline snapshot to filter reverts out of * `getChangeSet().updated` (a row edited back to its original value isn't a * real update). Default: reference equality — pair it with the engine's * structural sharing (unchanged rows keep their reference). */ isEqual?: (current: TRow, baseline: TRow) => boolean}
/** The precise CRUD delta to send to a backend. */export interface GridChangeSet<TRow extends GridRow> { /** New rows (not in the baseline). */ created: TRow[] /** Existing rows whose values changed (reverts filtered via `isEqual`). */ updated: TRow[] /** Ids of baseline rows that were removed. */ deleted: string[]}
/** Outcome of a save, fed back to `reconcile` to settle dirty state. */export interface GridReconcileResult { /** Rows that persisted — cleared from dirty and folded into the baseline. */ succeededIds?: Iterable<string> /** Rows the server rejected — stay dirty and land in `failedRowIds`. */ failedIds?: Iterable<string> // NB: client→server id remapping for created rows (rename the grid row under // its DB id) is intentionally NOT in v1 — no current consumer needs it // (imports create-then-close), and renaming a row id in an uncontrolled grid // is subtle. Consumers that need it keep their own client→server map for now.}
export interface GridChanges<TRow extends GridRow> { /** Ids with unsaved changes (created ∪ updated ∪ deleted). Reactive. */ dirtyRowIds: ReadonlySet<string> /** `dirtyRowIds.size > 0`. */ isDirty: boolean /** Ids the last `reconcile` marked failed — highlight them (status + flashCells). */ failedRowIds: ReadonlySet<string> /** The precise CRUD delta to persist. O(dirty) over a one-time O(n) row index. */ getChangeSet: () => GridChangeSet<TRow> /** Settle dirty state after a save (clear succeeded, keep failed dirty). */ reconcile: (result: GridReconcileResult) => void /** Re-baseline from `rows` (or the grid's current rows) — for a fresh load / new import. */ reset: (rows?: readonly TRow[]) => void}
const EMPTY_SET: ReadonlySet<string> = new Set()
export function useGridChanges<TRow extends GridRow>( grid: UseDataGrid<TRow>, options: UseGridChangesOptions<TRow> = {},): GridChanges<TRow> { const getRowId = options.getRowId ?? ((r: TRow) => r.id) const isEqual = options.isEqual ?? ((a: TRow, b: TRow) => a === b)
// Mutable tracking (refs — never trigger a render on their own). const existingIdsRef = React.useRef<Set<string>>(null as never) const baselineRef = React.useRef<Map<string, TRow>>(null as never) const dirtyRef = React.useRef<Set<string>>(null as never) const deletedRef = React.useRef<Set<string>>(null as never) const seenSeqRef = React.useRef(0)
// Lazy one-time init from `initialRows` (the baseline). `reset()` re-bases. if (existingIdsRef.current === null) { existingIdsRef.current = new Set( (options.initialRows ?? []).map(r => getRowId(r)), ) baselineRef.current = new Map( (options.initialRows ?? []).map(r => [getRowId(r), r]), ) dirtyRef.current = new Set() deletedRef.current = new Set() }
// Reactive mirrors — new identity only when the set's CONTENT changes, so an // `useEffect([dirtyRowIds])` autosave fires precisely (not on every keystroke). const [dirtyRowIds, setDirtyRowIds] = React.useState<ReadonlySet<string>>( () => new Set(dirtyRef.current), ) const [failedRowIds, setFailedRowIds] = React.useState<ReadonlySet<string>>(EMPTY_SET)
const flushDirty = React.useCallback(() => { setDirtyRowIds(new Set(dirtyRef.current)) }, [])
// Process each commit. `set/add/remove` reclassify only the touched ids (O(1) // each — no row scan); `bulk/reset` do a full recompute (O(n), rare). React.useEffect(() => { const commit = grid.lastCommit if (!commit || commit.seq === seenSeqRef.current) return // If commits were coalesced (React batched several dispatches into one // observed `lastCommit`), `seq` jumps by more than 1 and the intermediate // ids are gone — the granular path would miss those creates/updates/deletes. // Fall back to a full recompute in that case; contiguous seq keeps O(1). const skipped = commit.seq > seenSeqRef.current + 1 seenSeqRef.current = commit.seq
const existing = existingIdsRef.current const dirty = dirtyRef.current const deleted = deletedRef.current let changed = false
if (commit.kind === "bulk" || commit.kind === "reset" || skipped) { // Full recompute from the current rows vs the baseline. const nextDirty = new Set<string>() const nextDeleted = new Set<string>() const currentIds = new Set<string>() for (const row of grid.rows) { const id = getRowId(row) currentIds.add(id) if (!existing.has(id)) { nextDirty.add(id) // created } else if (!isEqual(row, baselineRef.current.get(id) as TRow)) { nextDirty.add(id) // updated } } for (const id of existing) { if (!currentIds.has(id)) { nextDirty.add(id) // deleted nextDeleted.add(id) } } dirtyRef.current = nextDirty deletedRef.current = nextDeleted changed = true } else if (commit.kind === "remove") { for (const id of commit.ids) { if (existing.has(id)) { if (!dirty.has(id)) changed = true dirty.add(id) deleted.add(id) // an existing row removed → DELETE } else { // A new (unsaved) row removed → nothing to persist. if (dirty.delete(id)) changed = true deleted.delete(id) } } } else if (commit.kind === "set" || commit.kind === "add") { // The row is present; existing ⇒ UPDATE, else ⇒ CREATE. Both dirty. // (Revert-to-original is filtered later in `getChangeSet`.) for (const id of commit.ids) { deleted.delete(id) if (!dirty.has(id)) { dirty.add(id) changed = true } } }
if (changed) flushDirty() // eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the commit; helpers are stable }, [grid.lastCommit])
const getChangeSet = React.useCallback((): GridChangeSet<TRow> => { const existing = existingIdsRef.current const baseline = baselineRef.current const deleted = deletedRef.current // O(n) once: index the current rows by id. const byId = new Map<string, TRow>() for (const row of grid.rows) byId.set(getRowId(row), row)
const created: TRow[] = [] const updated: TRow[] = [] for (const id of dirtyRef.current) { if (deleted.has(id)) continue const row = byId.get(id) if (!row) continue if (!existing.has(id)) { created.push(row) } else if (!isEqual(row, baseline.get(id) as TRow)) { updated.push(row) // reverts filtered here } } return { created, updated, deleted: [...deleted] } // eslint-disable-next-line react-hooks/exhaustive-deps -- reads live refs + grid.rows }, [grid])
const reconcile = React.useCallback( (result: GridReconcileResult) => { const existing = existingIdsRef.current const baseline = baselineRef.current const dirty = dirtyRef.current const deleted = deletedRef.current const byId = new Map<string, TRow>() for (const row of grid.rows) byId.set(getRowId(row), row)
for (const id of result.succeededIds ?? []) { dirty.delete(id) if (deleted.has(id)) { // A persisted DELETE — drop it from the baseline entirely. deleted.delete(id) existing.delete(id) baseline.delete(id) continue } // Fold the persisted created/updated row into the baseline (under its // current id) — subsequent edits to it are UPDATEs, and re-basing it to // the row's current reference means it reads clean. const row = byId.get(id) existing.add(id) if (row) baseline.set(id, row) }
setFailedRowIds(new Set(result.failedIds ?? [])) setDirtyRowIds(new Set(dirty)) }, [grid], )
const reset = React.useCallback( (rows?: readonly TRow[]) => { const base = rows ?? grid.rows existingIdsRef.current = new Set(base.map(r => getRowId(r))) baselineRef.current = new Map(base.map(r => [getRowId(r), r])) dirtyRef.current = new Set() deletedRef.current = new Set() seenSeqRef.current = grid.lastCommit?.seq ?? 0 setDirtyRowIds(EMPTY_SET) setFailedRowIds(EMPTY_SET) }, [grid], )
return React.useMemo( () => ({ dirtyRowIds, isDirty: dirtyRowIds.size > 0, failedRowIds, getChangeSet, reconcile, reset, }), [dirtyRowIds, failedRowIds, getChangeSet, reconcile, reset], )}Update the import paths to match your project setup.
DataTableAside:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Install the following dependencies.
Copy and paste the following code into your project.
"use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import * as React from "react"import { XIcon } from "lucide-react"import { cn } from "@/lib/utils"
interface DataTableAsideContextValue { open: boolean onOpenChange: (open: boolean) => void side: "left" | "right"}
const DataTableAsideContext = React.createContext< DataTableAsideContextValue | undefined>(undefined)
function useDataTableAside() { const context = React.useContext(DataTableAsideContext) if (!context) { throw new Error( "DataTableAside components must be used within DataTableAside", ) } return context}
interface DataTableAsideProps { children: React.ReactNode side?: "left" | "right" open?: boolean onOpenChange?: (open: boolean) => void defaultOpen?: boolean}
function DataTableAside({ children, side = "right", open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false,}: DataTableAsideProps) { const [internalOpen, setInternalOpen] = React.useState(defaultOpen)
const open = controlledOpen ?? internalOpen const onOpenChange = controlledOnOpenChange ?? setInternalOpen
/** * PERFORMANCE: Memoize context value to prevent unnecessary consumer re-renders * * WHY: Without memoization, a new context value object is created on every render. * React Context uses Object.is() to compare values - new object = all consumers re-render. * * IMPACT: Prevents unnecessary re-renders of DataTableAsideTrigger, DataTableAsideContent, etc. * when aside state hasn't changed. * * WHAT: Only creates new context value when open, onOpenChange, or side actually change. */ const contextValue = React.useMemo<DataTableAsideContextValue>( () => ({ open, onOpenChange, side, }), [open, onOpenChange, side], )
return ( <DataTableAsideContext.Provider value={contextValue}> {children} </DataTableAsideContext.Provider> )}
DataTableAside.displayName = "DataTableAside"
interface DataTableAsideTriggerProps extends React.ComponentPropsWithoutRef<"button"> { asChild?: boolean children?: React.ReactNode}
function DataTableAsideTrigger({ className, asChild = false, children, ...props}: DataTableAsideTriggerProps) { const { open, onOpenChange } = useDataTableAside()
const handleToggle = React.useCallback(() => { onOpenChange(!open) }, [onOpenChange, open])
if (asChild && React.isValidElement(children)) { const childProps = children.props as { onClick?: (e: React.MouseEvent) => void } return React.cloneElement(children, { onClick: (e: React.MouseEvent) => { handleToggle() childProps.onClick?.(e) }, } as Partial<unknown> & React.Attributes) }
return ( <button data-slot="aside-trigger" type="button" className={className} onClick={handleToggle} {...props} > {children} </button> )}
DataTableAsideTrigger.displayName = "DataTableAsideTrigger"
interface DataTableAsideContentProps extends React.ComponentPropsWithoutRef<"aside"> { width?: string sticky?: boolean}
function DataTableAsideContent({ children, className, width = "w-1/2", sticky = false, ...props}: DataTableAsideContentProps) { const { open, side } = useDataTableAside()
if (!open) return null
const slideAnimation = side === "left" ? "slide-in-from-left" : "slide-in-from-right"
return ( <aside data-slot="aside-content" className={cn( "shrink-0 animate-in", width, slideAnimation, sticky && "sticky top-0", className, )} {...props} > {children} </aside> )}
DataTableAsideContent.displayName = "DataTableAsideContent"
function DataTableAsideHeader({ className, ...props}: React.ComponentPropsWithoutRef<"div">) { return ( <div data-slot="aside-header" className={cn("flex flex-col gap-2", className)} {...props} /> )}
DataTableAsideHeader.displayName = "DataTableAsideHeader"
function DataTableAsideTitle({ className, ...props}: React.ComponentPropsWithoutRef<"h3">) { return ( <h3 data-slot="aside-title" className={cn("text-lg leading-none font-semibold", className)} {...props} /> )}
DataTableAsideTitle.displayName = "DataTableAsideTitle"
function DataTableAsideDescription({ className, ...props}: React.ComponentPropsWithoutRef<"p">) { return ( <p data-slot="aside-description" className={cn("text-sm text-muted-foreground", className)} {...props} /> )}
DataTableAsideDescription.displayName = "DataTableAsideDescription"
interface DataTableAsideCloseProps extends React.ComponentPropsWithoutRef<"button"> { showIcon?: boolean}
function DataTableAsideClose({ className, showIcon = true, children, ...props}: DataTableAsideCloseProps) { const { onOpenChange } = useDataTableAside()
const handleClose = React.useCallback(() => { onOpenChange(false) }, [onOpenChange])
return ( <button data-slot="aside-close" type="button" className={cn( "rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none", className, )} onClick={handleClose} {...props} > {showIcon && <XIcon className="size-4" />} {children} {showIcon && !children && <span className="sr-only">Close</span>} </button> )}
DataTableAsideClose.displayName = "DataTableAsideClose"
export { DataTableAside, DataTableAsideTrigger, DataTableAsideContent, DataTableAsideHeader, DataTableAsideTitle, DataTableAsideDescription, DataTableAsideClose,}Update the import paths to match your project setup.
DataTableSelectionBar:
Requires the @niko-table registry in your components.json. See the Installation Guide for setup. Or install directly via URL:
This component relies on other items which must be installed first.
Copy and paste the following code into your project.
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */
import * as React from "react"import { Button } from "@/components/ui/button"import { Badge } from "@/components/ui/badge"
interface DataTableSelectionBarProps { selectedCount: number onClear?: () => void children?: React.ReactNode className?: string}
/** * Reusable selection bar. Memoized so table-state changes don't re-render * unchanged selection state. Pass children to add custom action buttons. */export const DataTableSelectionBar = React.memo(function DataTableSelectionBar({ selectedCount, onClear, children, className,}: DataTableSelectionBarProps) { if (selectedCount === 0) return null
return ( <div className={className}> <div className="flex items-center justify-between rounded-lg border border-border bg-muted/50 px-4 py-3"> <div className="flex items-center gap-2"> <Badge variant="secondary">{selectedCount}</Badge> <span className="text-sm text-muted-foreground"> {selectedCount === 1 ? "row selected" : "rows selected"} </span> {onClear && ( <Button variant="ghost" size="sm" onClick={onClear} className="h-7 px-2 text-xs" > Clear </Button> )} </div> {children && <div className="flex items-center gap-2">{children}</div>} </div> </div> )})Update the import paths to match your project setup.
Drag and Drop
Section titled “Drag and Drop”DataTableRowDnd:
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, { type CSSProperties } from "react"import type { RowData } from "@tanstack/react-table"import { DndContext, KeyboardSensor, MouseSensor, TouchSensor, closestCenter, type DragEndEvent, type Modifier, type UniqueIdentifier, useSensor, useSensors,} from "@dnd-kit/core"import { restrictToVerticalAxis } from "@dnd-kit/modifiers"import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy,} from "@dnd-kit/sortable"import { CSS } from "@dnd-kit/utilities"import { GripVertical } from "lucide-react"
import { TableRow } from "@/components/ui/table"import { Button } from "@/components/ui/button"import { cn } from "@/lib/utils"
import type { DataTableInstance, DataTableRow } from "../types"// ============================================================================// TableRowDndProvider// ============================================================================
export interface TableRowDndProviderProps<TData extends RowData> { children: React.ReactNode /** The table instance */ table: DataTableInstance<TData> /** The data array (needed for arrayMove reordering) */ data: TData[] /** Callback when rows are reordered. Receives the new data array. */ onReorder: (data: TData[]) => void /** DnD modifiers. Defaults to [restrictToVerticalAxis]. */ modifiers?: Modifier[]}
export function TableRowDndProvider<TData extends RowData>({ children, table, data, onReorder, modifiers = [restrictToVerticalAxis],}: TableRowDndProviderProps<TData>) { // Stable across SSR + hydration — see TableColumnDndProvider. const dndContextId = React.useId()
const rows = table.getRowModel().rows const dataIds = React.useMemo<UniqueIdentifier[]>( () => rows.map(row => row.id), [rows], )
const sensors = useSensors( useSensor(MouseSensor, {}), useSensor(TouchSensor, {}), useSensor(KeyboardSensor, {}), )
const handleDragEnd = React.useCallback( (event: DragEndEvent) => { const { active, over } = event if (active && over && active.id !== over.id) { const oldIndex = dataIds.indexOf(active.id) const newIndex = dataIds.indexOf(over.id) onReorder(arrayMove(data, oldIndex, newIndex)) } }, [dataIds, data, onReorder], )
return ( <DndContext id={dndContextId} collisionDetection={closestCenter} modifiers={modifiers} onDragEnd={handleDragEnd} sensors={sensors} > {children} </DndContext> )}
TableRowDndProvider.displayName = "TableRowDndProvider"
// ============================================================================// TableDraggableRow// ============================================================================
export interface TableDraggableRowProps<TData extends RowData> { /** The row instance from TanStack Table */ row: DataTableRow<TData> /** * Position in the current display model (post sort/filter). Used for * `scrollRowIntoView` / `flashRows`. Defaults to TanStack source `row.index` * only when the caller omits it — prefer passing the map index. */ displayIndex?: number children: React.ReactNode className?: string}
export function TableDraggableRow<TData extends RowData>({ row, displayIndex = row.index, children, className,}: TableDraggableRowProps<TData>) { const { transform, transition, setNodeRef, isDragging } = useSortable({ id: row.id, })
const style: CSSProperties = { transform: CSS.Transform.toString(transform), transition: transition, opacity: isDragging ? 0.8 : 1, zIndex: isDragging ? 1 : 0, position: "relative", }
return ( <TableRow ref={setNodeRef} style={style} data-row-index={displayIndex} data-row-id={row.id} data-state={row.getIsSelected() && "selected"} className={cn(isDragging && "bg-muted/50", className)} > {children} </TableRow> )}
TableDraggableRow.displayName = "TableDraggableRow"
// ============================================================================// TableRowDragHandle// ============================================================================
export interface TableRowDragHandleProps { /** The row ID used for sortable identification */ rowId: string className?: string}
export function TableRowDragHandle({ rowId, className,}: TableRowDragHandleProps) { const { attributes, listeners } = useSortable({ id: rowId, })
return ( <Button variant="ghost" size="icon" className={cn("size-8 cursor-grab active:cursor-grabbing", className)} {...attributes} {...listeners} > <GripVertical className="size-4 text-muted-foreground" /> <span className="sr-only">Drag to reorder</span> </Button> )}
TableRowDragHandle.displayName = "TableRowDragHandle"
// Re-export for use in DataTableDndBodyexport { SortableContext, verticalListSortingStrategy }export type { UniqueIdentifier }"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 { TableRowDndProvider, type TableRowDndProviderProps, TableDraggableRow, type TableDraggableRowProps, TableRowDragHandle, type TableRowDragHandleProps,} from "../filters/table-row-dnd"
import type { RowData } from "@tanstack/react-table"// ============================================================================// DataTableRowDndProvider// ============================================================================
export type DataTableRowDndProviderProps<TData extends RowData> = Omit< TableRowDndProviderProps<TData>, "table">
/** * Context-aware row DnD provider that automatically connects to the DataTable context. * * @example * <DataTableRoot data={data} columns={columns} getRowId={(row) => row.id}> * <DataTableRowDndProvider data={data} onReorder={setData}> * <DataTable> * <DataTableHeader /> * <DataTableDndBody /> * </DataTable> * </DataTableRowDndProvider> * </DataTableRoot> */export function DataTableRowDndProvider<TData extends RowData>( props: DataTableRowDndProviderProps<TData>,) { const { table } = useDataTable<TData>() return <TableRowDndProvider<TData> table={table} {...props} />}
DataTableRowDndProvider.displayName = "DataTableRowDndProvider"
// ============================================================================// DataTableDraggableRow// ============================================================================
export type DataTableDraggableRowProps<TData extends RowData> = TableDraggableRowProps<TData>
/** * Context-aware draggable row component. * * @example * <DataTableDraggableRow row={row}> * {row.getVisibleCells().map(cell => ( * <TableCell key={cell.id}> * {flexRender(cell.column.columnDef.cell, cell.getContext())} * </TableCell> * ))} * </DataTableDraggableRow> */export function DataTableDraggableRow<TData extends RowData>( props: DataTableDraggableRowProps<TData>,) { return <TableDraggableRow<TData> {...props} />}
DataTableDraggableRow.displayName = "DataTableDraggableRow"
// ============================================================================// DataTableRowDragHandle// ============================================================================
export type DataTableRowDragHandleProps = TableRowDragHandleProps
/** * Context-aware row drag handle button. * * @example - In column definition * { * id: "drag-handle", * size: 40, * header: () => null, * cell: ({ row }) => <DataTableRowDragHandle rowId={row.id} />, * } */export function DataTableRowDragHandle(props: DataTableRowDragHandleProps) { return <TableRowDragHandle {...props} />}
DataTableRowDragHandle.displayName = "DataTableRowDragHandle""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import { cn } from "@/lib/utils"import { useDataTable } from "./data-table-context"import { TableRow, TableBody, TableCell } from "@/components/ui/table"import { DataTableRowContextMenu } from "../components/data-table-row-context-menu"import { useResolvedRowContextMenuRenderer } from "../components/data-table-row-context-menu-slot"import { resolveRowFromClick } from "../lib/row-click"import { renderCellContent } from "../lib/render-cell-content"import { getCommonPinningStyles } from "../lib/styles"import { TableDraggableRow, SortableContext, verticalListSortingStrategy, type UniqueIdentifier,} from "../filters/table-row-dnd"
import type { DataTableRow } from "../types"import type { RowData } from "@tanstack/react-table"// ============================================================================// DndBodyRow — memoized row// ============================================================================
/** * Per-row component for `DataTableDndBody` (row-DnD). Memoized to keep a * single-row state change (selection toggle, expansion) from reconciling * every visible row. * * `TableDraggableRow` already manages its own `useSortable` state, so the * memoization here only guards against extrinsic re-renders triggered by * the parent body. */interface DndBodyRowProps { row: DataTableRow<RowData> /** Position in the current display model (post sort/filter). */ displayIndex: number expandColumnId: string | undefined isClickable: boolean isExpanded: boolean /** Selection state — read by `TableDraggableRow` internally; prop exists to invalidate React.memo. */ isSelected: boolean /** Column resizing is on — cells size from `column.getSize()` instead of `columnDef.size`. */ columnSizingEnabled: boolean /** Column layout signature — invalidates React.memo on visibility/order/pinning change. */ columnLayoutSignature: string /** * Per-row memo key. Change this string to force React.memo to re-render a * specific row when row-level state changes outside of TanStack Table's * tracked props (e.g. inline edit mode, optimistic state). */ rowMemoKey: string /** * 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}
const DndBodyRow = React.memo(function DndBodyRow({ row, displayIndex, expandColumnId, isClickable, isExpanded, columnSizingEnabled, renderRowContextMenu,}: DndBodyRowProps) { const expandCell = isExpanded && expandColumnId ? row.getAllCells().find(c => c.column.id === expandColumnId) : undefined
const visibleCells = row.getVisibleCells()
const rowElement = ( <TableDraggableRow row={row} displayIndex={displayIndex} className="group data-[context-menu-open]:bg-muted/50" > {visibleCells.map(cell => { const size = cell.column.columnDef.size const cellStyle = { // Resizing: width tracks `getSize()`; off: unchanged. width: columnSizingEnabled ? cell.column.getSize() : size ? `${size}px` : undefined, ...getCommonPinningStyles(cell.column, false), }
return ( <TableCell key={cell.id} data-col-id={cell.column.id} style={cellStyle} className={cn( "truncate", isClickable && "cursor-pointer", cell.column.getIsPinned() && "bg-background group-hover:bg-muted/50 group-data-[context-menu-open]:bg-muted/50 group-data-[state=selected]:bg-muted", )} > {renderCellContent(cell)} </TableCell> ) })} </TableDraggableRow> )
// Only stand up the context-menu shell when the consumer supplies items // for this row — a `null` return keeps the plain draggable row. 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> )} </> )})
DndBodyRow.displayName = "DndBodyRow"
// ============================================================================// DataTableDndBody (Row DnD)// ============================================================================
export interface DataTableDndBodyProps<TData extends RowData> { children?: React.ReactNode className?: string /** * Click is delegated on `<tbody>`. The event's `currentTarget` * is therefore the `<tbody>` — typed as `HTMLElement` to stay * consistent with the virtualized variants. Consumers needing * the row element can `event.target.closest("tr[data-row-id]")`. */ onRowClick?: (row: TData, event: React.MouseEvent<HTMLElement>) => void /** * Return a per-row memo invalidation key. When the returned string changes * for a specific row, React.memo re-renders that row even if TanStack Table * props (selection, expansion, column layout) are unchanged. Use this for * row-level external state that cell renderers depend on — e.g. inline edit * mode, optimistic overlays, or any closure-captured state in column * definitions that changes independently of the table's own state. * * @example * // Trigger re-render on inline edit toggle (only the edited row re-renders) * getRowMemoKey={(row) => (isEditing(row.id) ? "editing" : "")} */ getRowMemoKey?: (row: TData) => string /** * 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}
/** * DnD-aware table body that renders rows as draggable items. * Drop-in replacement for DataTableBody when using row drag-and-drop. * * Must be wrapped in a DataTableRowDndProvider (or TableRowDndProvider). * * @example * <DataTableRowDndProvider data={data} onReorder={setData}> * <DataTable> * <DataTableHeader /> * <DataTableDndBody /> * </DataTable> * </DataTableRowDndProvider> */export function DataTableDndBody<TData extends RowData>({ children, className, onRowClick, getRowMemoKey, renderRowContextMenu,}: DataTableDndBodyProps<TData>) { const { table, columns, isLoading } = useDataTable<TData>() const { rows } = table.getRowModel() const containerRef = React.useRef<HTMLTableSectionElement>(null)
const dataIds = React.useMemo<UniqueIdentifier[]>( () => rows.map(row => row.id), [rows], )
/** Single row-click handler with event delegation (useCallback). */ const handleRowClick = React.useCallback( (event: React.MouseEvent<HTMLTableSectionElement>) => { if (!onRowClick) return const row = resolveRowFromClick(event.target as HTMLElement, table) if (!row) return onRowClick(row.original, event) }, [onRowClick, table], )
// Hoisted: per-row find was O(rows × cols) per render despite stable column set. const expandColumnId = React.useMemo( () => table.getAllColumns().find(col => col.columnDef.meta?.expandedContent) ?.id, // `columns` keeps the memo in sync when consumers swap column sets — // `table` alone is too stable (TanStack reuses the same instance). [table, columns], )
// String signature of the visible column layout. Memoized rows compare it // to invalidate on column toggle / reorder / pin / resize. For external row // state (inline edits, optimistic overlays), pass `getRowMemoKey`. const { columnVisibility, columnOrder, columnPinning, columnSizing } = table.state const resizing = table.options.enableColumnResizing ?? false const columnLayoutSignature = React.useMemo( () => table .getVisibleLeafColumns() .map(c => { const pinned = c.getIsPinned() const base = pinned ? `${c.id}:${pinned}` : c.id return resizing ? `${base}:${c.getSize()}` : base }) .join(","), // eslint-disable-next-line react-hooks/exhaustive-deps [ table, columns, columnVisibility, columnOrder, columnPinning, columnSizing, 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, )
// Capture / restore table inline sizing when resize turns on (same as // `DataTableBody`) so `w-full` cannot compress columns while dragging. const tableStyleSnapshotRef = React.useRef<{ tableLayout: string width: string minWidth: string } | null>(null)
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 }
if (!tableStyleSnapshotRef.current) { tableStyleSnapshotRef.current = { tableLayout: tableEl.style.tableLayout, width: tableEl.style.width, minWidth: tableEl.style.minWidth, } } const totalDesiredWidth = table .getVisibleLeafColumns() .reduce((sum, col) => sum + col.getSize(), 0) tableEl.style.tableLayout = "fixed" tableEl.style.width = `${totalDesiredWidth}px` tableEl.style.minWidth = `${totalDesiredWidth}px` return restore }, [ resizing, table, columnSizing, columnVisibility, columnOrder, columnPinning, ])
return ( <TableBody ref={containerRef} className={className} onClick={onRowClick ? handleRowClick : undefined} > {!isLoading && rows?.length ? ( <SortableContext items={dataIds} strategy={verticalListSortingStrategy}> {rows.map((row, displayIndex) => ( <DndBodyRow key={row.id} row={row as DataTableRow<RowData>} displayIndex={displayIndex} expandColumnId={expandColumnId} isClickable={isClickable} isExpanded={row.getIsExpanded()} isSelected={row.getIsSelected()} columnSizingEnabled={resizing} columnLayoutSignature={columnLayoutSignature} rowMemoKey={ getRowMemoKey ? getRowMemoKey(row.original as TData) : "" } renderRowContextMenu={ resolvedRenderRowContextMenu as ((row: unknown) => React.ReactNode) | undefined } /> ))} </SortableContext> ) : null}
{children} </TableBody> )}
DataTableDndBody.displayName = "DataTableDndBody"Update the import paths to match your project setup.
DataTableColumnDnd:
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, { type CSSProperties } from "react"import { DndContext, KeyboardSensor, MouseSensor, TouchSensor, closestCenter, type DragEndEvent, type Modifier, useSensor, useSensors,} from "@dnd-kit/core"import { restrictToHorizontalAxis } from "@dnd-kit/modifiers"import { arrayMove, SortableContext, useSortable, horizontalListSortingStrategy,} from "@dnd-kit/sortable"import { CSS } from "@dnd-kit/utilities"
import { TableHead, TableCell as TableCellUI } from "@/components/ui/table"import { cn } from "@/lib/utils"
import type { DataTableCell, DataTableHeader } from "../types"import type { RowData } from "@tanstack/react-table"// ============================================================================// TableColumnDndProvider// ============================================================================
export interface TableColumnDndProviderProps { children: React.ReactNode /** The current column order (array of column IDs) */ columnOrder: string[] /** Callback when columns are reordered. Receives the new column order. */ onColumnOrderChange: (columnOrder: string[]) => void /** DnD modifiers. Defaults to [restrictToHorizontalAxis]. */ modifiers?: Modifier[]}
export function TableColumnDndProvider({ children, columnOrder, onColumnOrderChange, modifiers = [restrictToHorizontalAxis],}: TableColumnDndProviderProps) { // Stable across SSR + hydration. Without this, @dnd-kit's module-level // `DndDescribedBy-N` counter can diverge (server N=73, client N=1) and // React warns on `aria-describedby` mismatches. const dndContextId = React.useId()
// 8px drag threshold so a click on inline header chrome (sort menu, // help tooltip trigger) lands as a click, not as a drag start. dnd-kit // suppresses the click only after the pointer moves past the threshold. const sensors = useSensors( useSensor(MouseSensor, { activationConstraint: { distance: 8 } }), useSensor(TouchSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, {}), )
const handleDragEnd = React.useCallback( (event: DragEndEvent) => { const { active, over } = event if (active && over && active.id !== over.id) { const oldIndex = columnOrder.indexOf(active.id as string) const newIndex = columnOrder.indexOf(over.id as string) onColumnOrderChange(arrayMove(columnOrder, oldIndex, newIndex)) } }, [columnOrder, onColumnOrderChange], )
return ( <DndContext id={dndContextId} collisionDetection={closestCenter} modifiers={modifiers} onDragEnd={handleDragEnd} sensors={sensors} > <SortableContext items={columnOrder} strategy={horizontalListSortingStrategy} > {children} </SortableContext> </DndContext> )}
TableColumnDndProvider.displayName = "TableColumnDndProvider"
// ============================================================================// TableDraggableHeader// ============================================================================
export interface TableDraggableHeaderProps<TData extends RowData, TValue> { /** The header instance from TanStack Table */ header: DataTableHeader<TData, TValue> children: React.ReactNode className?: string /** Additional styles merged with DnD transform styles */ style?: CSSProperties}
export function TableDraggableHeader<TData extends RowData, TValue>({ header, children, className, style: externalStyle,}: TableDraggableHeaderProps<TData, TValue>) { const { attributes, isDragging, listeners, setNodeRef, transform } = useSortable({ id: header.column.id, })
const style: CSSProperties = { opacity: isDragging ? 0.8 : 1, position: "relative", transform: CSS.Translate.toString(transform), transition: "width transform 0.2s ease-in-out", whiteSpace: "nowrap", zIndex: isDragging ? 1 : 0, cursor: "grab", ...externalStyle, }
return ( <TableHead colSpan={header.colSpan} ref={setNodeRef} style={style} className={cn(isDragging && "bg-muted/50", className)} {...attributes} {...listeners} > {children} </TableHead> )}
TableDraggableHeader.displayName = "TableDraggableHeader"
// ============================================================================// TableDragAlongCell// ============================================================================
export interface TableDragAlongCellProps<TData extends RowData, TValue> { /** The cell instance from TanStack Table */ cell: DataTableCell<TData, TValue> children: React.ReactNode className?: string /** Additional styles merged with DnD transform styles */ style?: CSSProperties}
export function TableDragAlongCell<TData extends RowData, TValue>({ cell, children, className, style: externalStyle,}: TableDragAlongCellProps<TData, TValue>) { const { isDragging, setNodeRef, transform } = useSortable({ id: cell.column.id, })
const style: CSSProperties = { opacity: isDragging ? 0.8 : 1, position: "relative", transform: CSS.Translate.toString(transform), transition: "width transform 0.2s ease-in-out", zIndex: isDragging ? 1 : 0, ...externalStyle, }
return ( <TableCellUI style={style} ref={setNodeRef} className={className}> {children} </TableCellUI> )}
TableDragAlongCell.displayName = "TableDragAlongCell""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 { RowData } from "@tanstack/react-table"import { TableColumnDndProvider, type TableColumnDndProviderProps, TableDraggableHeader, type TableDraggableHeaderProps, TableDragAlongCell, type TableDragAlongCellProps,} from "../filters/table-column-dnd"
// ============================================================================// DataTableColumnDndProvider// ============================================================================
export type DataTableColumnDndProviderProps = Omit< TableColumnDndProviderProps, never>
/** * Context-aware column DnD provider that wraps children with drag-and-drop * column reordering capabilities. * * @example * <DataTableRoot * data={data} * columns={columns} * state={{ columnOrder }} * onColumnOrderChange={setColumnOrder} * > * <DataTableColumnDndProvider * columnOrder={columnOrder} * onColumnOrderChange={setColumnOrder} * > * <DataTable> * <DataTableDndHeader /> * <DataTableDndColumnBody /> * </DataTable> * </DataTableColumnDndProvider> * </DataTableRoot> */export function DataTableColumnDndProvider( props: DataTableColumnDndProviderProps,) { return <TableColumnDndProvider {...props} />}
DataTableColumnDndProvider.displayName = "DataTableColumnDndProvider"
// ============================================================================// DataTableDraggableHeader// ============================================================================
export type DataTableDraggableHeaderProps< TData extends RowData, TValue,> = TableDraggableHeaderProps<TData, TValue>
/** * Context-aware draggable header cell for column DnD. * * @example * <DataTableDraggableHeader header={header}> * {flexRender(header.column.columnDef.header, header.getContext())} * </DataTableDraggableHeader> */export function DataTableDraggableHeader<TData extends RowData, TValue>( props: DataTableDraggableHeaderProps<TData, TValue>,) { return <TableDraggableHeader<TData, TValue> {...props} />}
DataTableDraggableHeader.displayName = "DataTableDraggableHeader"
// ============================================================================// DataTableDragAlongCell// ============================================================================
export type DataTableDragAlongCellProps< TData extends RowData, TValue,> = TableDragAlongCellProps<TData, TValue>
/** * Context-aware cell that follows column drag position. * * @example * <DataTableDragAlongCell cell={cell}> * {flexRender(cell.column.columnDef.cell, cell.getContext())} * </DataTableDragAlongCell> */export function DataTableDragAlongCell<TData extends RowData, TValue>( props: DataTableDragAlongCellProps<TData, TValue>,) { return <TableDragAlongCell<TData, TValue> {...props} />}
DataTableDragAlongCell.displayName = "DataTableDragAlongCell""use client"
/** * niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance. * * Before reporting anything: please check the changelog first. * - In-repo: ./CHANGELOG.md * - Docs site: https://niko-table.com/changelog * * Found a bug or have a fix? Open an issue or PR on GitHub so other * users (and future LLMs reading this code) benefit: * https://github.com/Semkoo/niko-table-registry */import React from "react"import { cn } from "@/lib/utils"import { useDataTable } from "./data-table-context"import { TableHeader, TableRow, TableBody, TableCell,} from "@/components/ui/table"import { flexRender } from "@tanstack/react-table"import { DataTableColumnHeaderRoot } from "../components/data-table-column-header"import { DataTableColumnResizeHandle } from "../lib/column-resize-handle"import { DataTableRowContextMenu } from "../components/data-table-row-context-menu"import { useResolvedRowContextMenuRenderer } from "../components/data-table-row-context-menu-slot"import { resolveRowFromClick } from "../lib/row-click"import { renderCellContent } from "../lib/render-cell-content"import { TableDraggableHeader, TableDragAlongCell,} from "../filters/table-column-dnd"
import type { DataTableRow } from "../types"import type { RowData } from "@tanstack/react-table"// ============================================================================// DndColumnBodyRow — memoized row// ============================================================================
/** * Per-row component for `DataTableDndColumnBody` (column-DnD). Memoized * to avoid cascading reconciliation across all visible rows on selection * or expansion changes. `TableDragAlongCell` handles per-cell drag state * internally, so cell-level memoization isn't required here. */interface DndColumnBodyRowProps { row: DataTableRow<RowData> expandColumnId: string | undefined isClickable: boolean isExpanded: boolean isSelected: boolean /** Column resizing is on — cells size from `column.getSize()` instead of `columnDef.size`. */ columnSizingEnabled: boolean /** Column layout signature — invalidates React.memo on visibility/order/pinning change. */ columnLayoutSignature: string /** * Per-row memo key. Change this string to force React.memo to re-render a * specific row when row-level state changes outside of TanStack Table's * tracked props (e.g. inline edit mode, optimistic state). */ rowMemoKey: string /** * 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}
const DndColumnBodyRow = React.memo(function DndColumnBodyRow({ row, expandColumnId, isClickable, isExpanded, isSelected, columnSizingEnabled, renderRowContextMenu,}: DndColumnBodyRowProps) { const expandCell = isExpanded && expandColumnId ? row.getAllCells().find(c => c.column.id === expandColumnId) : undefined
const visibleCells = row.getVisibleCells()
const rowElement = ( <TableRow data-row-id={row.id} data-state={isSelected ? "selected" : undefined} className={cn( isClickable && "cursor-pointer", "group data-[context-menu-open]:bg-muted/50", )} > {visibleCells.map(cell => { const size = cell.column.columnDef.size const cellStyle = { // Resizing: width tracks `getSize()`; off: unchanged. width: columnSizingEnabled ? cell.column.getSize() : size ? `${size}px` : undefined, }
return ( <TableDragAlongCell key={cell.id} cell={cell} style={cellStyle} className={cn( !cell.getIsGrouped() && "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", )} > {renderCellContent(cell)} </TableDragAlongCell> ) })} </TableRow> )
// Only stand up the context-menu shell when the consumer supplies items // for this row — a `null` return keeps the plain row. 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> )} </> )})
DndColumnBodyRow.displayName = "DndColumnBodyRow"
// ============================================================================// DataTableDndHeader (Column DnD)// ============================================================================
export interface DataTableDndHeaderProps { className?: string /** * Makes the header sticky at the top when scrolling. * @default true */ sticky?: boolean}
/** * DnD-aware table header that renders column headers as draggable items. * Drop-in replacement for DataTableHeader when using column drag-and-drop. * * Must be wrapped in a DataTableColumnDndProvider (or TableColumnDndProvider). * * @example * <DataTableColumnDndProvider columnOrder={columnOrder} onColumnOrderChange={setColumnOrder}> * <DataTable> * <DataTableDndHeader /> * <DataTableDndColumnBody /> * </DataTable> * </DataTableColumnDndProvider> */export const DataTableDndHeader = React.memo(function DataTableDndHeader({ className, sticky = true,}: DataTableDndHeaderProps) { const { table } = useDataTable() const resizing = table?.options.enableColumnResizing ?? false
const headerGroups = table?.getHeaderGroups() ?? []
if (headerGroups.length === 0) { return null }
return ( <TableHeader className={cn( sticky && "sticky top-0 z-30 bg-background", sticky && "after:absolute after:right-0 after:bottom-0 after:left-0 after:h-px after:bg-border", className, )} > {headerGroups.map(headerGroup => ( <TableRow key={headerGroup.id}> {headerGroup.headers.map(header => { const size = header.column.columnDef.size return ( <TableDraggableHeader key={header.id} header={header} style={{ width: resizing ? header.getSize() : size ? `${size}px` : undefined, }} > {header.isPlaceholder ? null : ( <DataTableColumnHeaderRoot column={header.column}> {flexRender( header.column.columnDef.header, header.getContext(), )} </DataTableColumnHeaderRoot> )} {resizing && header.column.getCanResize() && ( <DataTableColumnResizeHandle header={header} /> )} </TableDraggableHeader> ) })} </TableRow> ))} </TableHeader> )})
DataTableDndHeader.displayName = "DataTableDndHeader"
// ============================================================================// DataTableDndColumnBody (Column DnD)// ============================================================================
export interface DataTableDndColumnBodyProps<TData extends RowData> { children?: React.ReactNode className?: string /** * Click is delegated on `<tbody>`. The event's `currentTarget` * is therefore the `<tbody>` — typed as `HTMLElement` to stay * consistent with the virtualized variants. Consumers needing * the row element can `event.target.closest("tr[data-row-id]")`. */ onRowClick?: (row: TData, event: React.MouseEvent<HTMLElement>) => void /** * Return a per-row memo invalidation key. When the returned string changes * for a specific row, React.memo re-renders that row even if TanStack Table * props (selection, expansion, column layout) are unchanged. Use this for * row-level external state that cell renderers depend on — e.g. inline edit * mode, optimistic overlays, or any closure-captured state in column * definitions that changes independently of the table's own state. * * @example * // Trigger re-render on inline edit toggle (only the edited row re-renders) * getRowMemoKey={(row) => (isEditing(row.id) ? "editing" : "")} */ getRowMemoKey?: (row: TData) => string /** * 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}
/** * DnD-aware table body for column drag-and-drop. * Each cell is wrapped with useSortable to follow column drag position. * * Must be wrapped in a DataTableColumnDndProvider (or TableColumnDndProvider). * * @example * <DataTableColumnDndProvider columnOrder={columnOrder} onColumnOrderChange={setColumnOrder}> * <DataTable> * <DataTableDndHeader /> * <DataTableDndColumnBody /> * </DataTable> * </DataTableColumnDndProvider> */export function DataTableDndColumnBody<TData extends RowData>({ children, className, onRowClick, getRowMemoKey, renderRowContextMenu,}: DataTableDndColumnBodyProps<TData>) { const { table, columns, isLoading } = useDataTable<TData>() const { rows } = table.getRowModel() const containerRef = React.useRef<HTMLTableSectionElement>(null)
/** Single row-click handler with event delegation (useCallback). */ const handleRowClick = React.useCallback( (event: React.MouseEvent<HTMLTableSectionElement>) => { if (!onRowClick) return const row = resolveRowFromClick(event.target as HTMLElement, table) if (!row) return onRowClick(row.original, event) }, [onRowClick, table], )
// Resolve the expand column once at the table level (see comment // on the equivalent memo in `DataTableDndBody` above). const expandColumnId = React.useMemo( () => table.getAllColumns().find(col => col.columnDef.meta?.expandedContent) ?.id, // `columns` keeps the memo in sync when consumers swap column sets — // `table` alone is too stable (TanStack reuses the same instance). [table, columns], )
// String signature of the visible column layout. Memoized rows compare it // to invalidate on column toggle / reorder / pin / resize. For external row // state (inline edits, optimistic overlays), pass `getRowMemoKey`. const { columnVisibility, columnOrder, columnPinning, columnSizing } = table.state const resizing = table.options.enableColumnResizing ?? false const columnLayoutSignature = React.useMemo( () => table .getVisibleLeafColumns() .map(c => { const pinned = c.getIsPinned() const base = pinned ? `${c.id}:${pinned}` : c.id return resizing ? `${base}:${c.getSize()}` : base }) .join(","), // eslint-disable-next-line react-hooks/exhaustive-deps [ table, columns, columnVisibility, columnOrder, columnPinning, columnSizing, 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, )
// Capture / restore table inline sizing when resize turns on (same as // `DataTableDndBody`) so `w-full` cannot compress columns while dragging. const tableStyleSnapshotRef = React.useRef<{ tableLayout: string width: string minWidth: string } | null>(null)
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 }
if (!tableStyleSnapshotRef.current) { tableStyleSnapshotRef.current = { tableLayout: tableEl.style.tableLayout, width: tableEl.style.width, minWidth: tableEl.style.minWidth, } } const totalDesiredWidth = table .getVisibleLeafColumns() .reduce((sum, col) => sum + col.getSize(), 0) tableEl.style.tableLayout = "fixed" tableEl.style.width = `${totalDesiredWidth}px` tableEl.style.minWidth = `${totalDesiredWidth}px` return restore }, [ resizing, table, columnSizing, columnVisibility, columnOrder, columnPinning, ])
return ( <TableBody ref={containerRef} className={className} onClick={onRowClick ? handleRowClick : undefined} > {!isLoading && rows?.length ? rows.map(row => ( <DndColumnBodyRow key={row.id} row={row as DataTableRow<RowData>} expandColumnId={expandColumnId} isClickable={isClickable} isExpanded={row.getIsExpanded()} isSelected={row.getIsSelected()} columnSizingEnabled={resizing} columnLayoutSignature={columnLayoutSignature} rowMemoKey={ getRowMemoKey ? getRowMemoKey(row.original as TData) : "" } renderRowContextMenu={ resolvedRenderRowContextMenu as ((row: unknown) => React.ReactNode) | undefined } /> )) : null}
{children} </TableBody> )}
DataTableDndColumnBody.displayName = "DataTableDndColumnBody"Update the import paths to match your project setup.
DataTableVirtualizedRowDnd (needs virtualized + row-dnd):
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 *//** * @internal Deep-import only. * Intentionally not re-exported from the package barrel — the DnD * virtualized variants are an opt-in advanced surface; the deep * path keeps consumers explicit about pulling them in and avoids * bloating the barrel for the common (non-DnD) case. */
import React from "react"import { useVirtualizer } from "@tanstack/react-virtual"import { cn } from "@/lib/utils"import { useDataTable } from "./data-table-context"import { TableRow, TableBody, TableCell } from "@/components/ui/table"import { DataTableRowContextMenu } from "../components/data-table-row-context-menu"import { useResolvedRowContextMenuRenderer } from "../components/data-table-row-context-menu-slot"import { createScrollHandler } from "../lib/create-scroll-handler"import { resolveRowFromClick } from "../lib/row-click"import { renderCellContent } from "../lib/render-cell-content"import { getCommonPinningStyles } from "../lib/styles"import { SortableContext, verticalListSortingStrategy, type UniqueIdentifier,} from "../filters/table-row-dnd"import { useSortable } from "@dnd-kit/sortable"import { CSS } from "@dnd-kit/utilities"import type { ScrollEvent } from "./data-table-virtualized-structure"
import type { DataTableRow } from "../types"import type { RowData } from "@tanstack/react-table"// ============================================================================// Stable measureElement — computed once at module level// ============================================================================
// See `data-table-virtualized-structure.tsx` for full rationale: sums base +// adjacent expanded-row height since ResizeObserver only attaches to the base.// Prefers `ResizeObserverEntry.borderBoxSize` over `getBoundingClientRect`// when TanStack Virtual passes the entry — skips a forced layout read on// the hot path. Disabled in Firefox (stale getBoundingClientRect during// scroll). Module-scoped for stable reference identity.const measureElement: | ((element: Element, entry?: ResizeObserverEntry | undefined) => number) | undefined = typeof window !== "undefined" && navigator.userAgent.indexOf("Firefox") === -1 ? (element, entry) => { const baseHeight = entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height const next = element.nextElementSibling if ( next && next.getAttribute("data-slot") === "datatable-expanded-row" ) { return baseHeight + next.getBoundingClientRect().height } return baseHeight } : undefined
// ============================================================================// VirtualizedDraggableRow — internal row component for virtualized row DnD// ============================================================================
interface VirtualizedDraggableRowProps { children: React.ReactNode /** * Stable row identity from `row.id`. Used by event delegation to * resolve which row was clicked via `table.getRow(rowId)` — * survives sort/filter/reorder, unlike `row.index`. */ rowId: string /** * Virtualizer slot index (`virtualRow.index`). Forwarded to * `data-index`, which TanStack Virtual's `measureElement` reads to * map a measured DOM node back to its virtualizer slot. Must be * the virtualizer's index, not the source-data `row.index` — * under sort/filter those drift apart. */ virtualIndex: number /** * Whether this row is currently selected. Used to set `data-state` * + a `group` class on the row so the existing * `data-[state=selected]` and `group-data-[state=selected]` * selectors (on the row itself and on pinned-column cells) * actually fire. */ isSelected?: boolean /** * Whether the row is currently expanded. Used to imperatively * re-trigger `measureRef` (the virtualizer's `measureElement`) * when expansion toggles — the DnD body intentionally uses a * stable `key={row.id}` so `useSortable`'s registration is * preserved across expand/collapse, but that means `setRefs` * doesn't re-run on toggle and the virtualizer would otherwise * keep the stale collapsed-height measurement. */ isExpanded?: boolean className?: string measureRef?: (node: HTMLTableRowElement | null) => void columnLayoutSignature?: string rowMemoKey?: string}
function VirtualizedDraggableRow({ children, rowId, virtualIndex, isSelected, isExpanded, className, measureRef, columnLayoutSignature, rowMemoKey,}: VirtualizedDraggableRowProps) { const { transform, transition, setNodeRef, isDragging } = useSortable({ id: rowId, })
// Cache the row DOM node so the `isExpanded` effect can pass it // back to `measureRef` (the virtualizer's `measureElement`) on // demand — without unmounting the row. const elementRef = React.useRef<HTMLTableRowElement | null>(null)
const setRefs = React.useCallback( (node: HTMLTableRowElement | null) => { setNodeRef(node) elementRef.current = node if (measureRef) measureRef(node) }, [setNodeRef, measureRef], )
// Re-measure on expansion toggle — `measureRef` is idempotent and // re-walks `nextElementSibling` to include the expanded pane. // Keeps `key={row.id}` stable so `useSortable` registration survives. React.useEffect(() => { if (measureRef && elementRef.current) { measureRef(elementRef.current) } }, [isExpanded, columnLayoutSignature, rowMemoKey, measureRef])
const style: React.CSSProperties = { transform: CSS.Transform.toString(transform), transition: transition, opacity: isDragging ? 0.8 : 1, zIndex: isDragging ? 1 : 0, position: "relative", }
return ( <TableRow ref={setRefs} style={style} className={cn( "group flex w-full", isDragging && "bg-muted/50", className, )} data-index={virtualIndex} data-row-id={rowId} data-state={isSelected ? "selected" : undefined} > {children} </TableRow> )}
// ============================================================================// VirtualizedDndBodyRow — memoized row// ============================================================================
/** * Per-row component for `DataTableVirtualizedDndBody` (row-DnD + * virtualization). Wraps the existing `VirtualizedDraggableRow` (which * owns the `useSortable` registration) and renders cells inside it. * Memoized so a single-row state change (selection, expansion) doesn't * reconcile every other visible row. */interface VirtualizedDndBodyRowProps<TData extends RowData> { row: DataTableRow<TData> virtualIndex: number expandColumnId: string | undefined isExpanded: boolean isSelected: boolean isClickable: boolean estimateSize: number measureRef: ((node: HTMLTableRowElement | null) => void) | undefined /** Column resizing is on — cells size from `column.getSize()` instead of `columnDef.size`. */ columnSizingEnabled: boolean /** Column layout signature — invalidates React.memo on visibility/order/pinning change. */ columnLayoutSignature: string /** * Per-row memo key. Change this string to force React.memo to re-render a * specific row when row-level state changes outside of TanStack Table's * tracked props (e.g. inline edit mode, optimistic state). */ rowMemoKey: string /** * Right-click menu items for this row. Must be a stable callback so * `React.memo` keeps holding. Return `null` to opt a specific row out. */ renderRowContextMenu?: (row: TData) => React.ReactNode}
const VirtualizedDndBodyRowInner = function VirtualizedDndBodyRow< TData extends RowData,>({ row, virtualIndex, expandColumnId, isExpanded, isSelected, isClickable, estimateSize, measureRef, columnSizingEnabled, columnLayoutSignature, rowMemoKey, renderRowContextMenu,}: VirtualizedDndBodyRowProps<TData>) { const expandCell = isExpanded && expandColumnId ? row.getAllCells().find(c => c.column.id === expandColumnId) : undefined
const visibleCells = row.getVisibleCells()
const rowElement = ( <VirtualizedDraggableRow rowId={row.id} virtualIndex={virtualIndex} isSelected={isSelected} isExpanded={isExpanded} measureRef={measureRef} columnLayoutSignature={columnLayoutSignature} rowMemoKey={rowMemoKey} className="data-[context-menu-open]:bg-muted/50" > {visibleCells.map(cell => { const size = cell.column.columnDef.size const fixedWidth = columnSizingEnabled ? cell.column.getSize() : size ? `${size}px` : undefined const cellStyle = { width: fixedWidth, minHeight: `${estimateSize}px`, ...getCommonPinningStyles(cell.column, false), }
return ( <TableCell key={cell.id} data-col-id={cell.column.id} className={cn( fixedWidth != null ? "shrink-0" : "min-w-0 flex-1", "flex items-center truncate", isClickable && "cursor-pointer", cell.column.getIsPinned() && "bg-background group-hover:bg-muted/50 group-data-[context-menu-open]:bg-muted/50 group-data-[state=selected]:bg-muted", )} style={cellStyle} > {renderCellContent(cell)} </TableCell> ) })} </VirtualizedDraggableRow> )
// Only stand up the context-menu shell when the consumer returns items // for this row. `useSortable`'s ref merges through the trigger the same // as any other className/prop pass-through, so row measurement/drag is // unaffected by the extra wrapper. const menuItems = renderRowContextMenu?.(row.original as TData)
return ( <> {menuItems ? ( <DataTableRowContextMenu row={row.original as TData} trigger={rowElement} > {menuItems} </DataTableRowContextMenu> ) : ( rowElement )}
{isExpanded && expandCell && ( <TableRow data-slot="datatable-expanded-row" className="flex w-full"> <TableCell colSpan={visibleCells.length} className="w-full p-0"> {expandCell.column.columnDef.meta?.expandedContent?.(row.original)} </TableCell> </TableRow> )} </> )}
const VirtualizedDndBodyRow = React.memo( VirtualizedDndBodyRowInner,) as typeof VirtualizedDndBodyRowInner
// ============================================================================// DataTableVirtualizedDndBody (Row DnD + Virtualization)// ============================================================================
export interface DataTableVirtualizedDndBodyProps<TData extends RowData> { children?: React.ReactNode estimateSize?: number overscan?: number className?: string onScroll?: (event: ScrollEvent) => void onScrolledTop?: () => void onScrolledBottom?: () => void scrollThreshold?: number /** * Click is delegated on `<tbody>` (so a single listener serves all * virtual rows). The event therefore arrives with `currentTarget` * = the `<tbody>`. If you need the row element, query * `event.target.closest("tr[data-row-id]")` — typed as * `HTMLElement` to reflect that runtime shape rather than lying * with `HTMLTableRowElement`. */ onRowClick?: (row: TData, event: React.MouseEvent<HTMLElement>) => void /** * Virtualizer-index-driven prefetch trigger — fires when the * last rendered virtual row is within `prefetchThreshold` rows * of the end. See `DataTableVirtualizedBody.onNearEnd` for the * full rationale (strictly better than `onScrolledBottom` for * infinite scroll: catches scrollbar drags, programmatic * `scrollToIndex` jumps, and short initial renders). */ onNearEnd?: () => void /** Default `10`. See `DataTableVirtualizedBody.prefetchThreshold`. */ prefetchThreshold?: number /** * Return a per-row memo invalidation key. When the returned string changes * for a specific row, React.memo re-renders that row even if TanStack Table * props (selection, expansion, column layout) are unchanged. Use this for * row-level external state that cell renderers depend on — e.g. inline edit * mode, optimistic overlays, or any closure-captured state in column * definitions that changes independently of the table's own state. * * @example * // Trigger re-render on inline edit toggle (only the edited row re-renders) * getRowMemoKey={(row) => (isEditing(row.id) ? "editing" : "")} */ getRowMemoKey?: (row: TData) => string /** * 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}
/** * Virtualized DnD-aware table body that combines row virtualization with * drag-and-drop reordering. Uses @tanstack/react-virtual for performance * and @dnd-kit/sortable for drag interactions. * * Must be wrapped in a DataTableRowDndProvider (or TableRowDndProvider). * * @example * <DataTableRowDndProvider data={data} onReorder={setData}> * <DataTable height={400}> * <DataTableVirtualizedHeader /> * <DataTableVirtualizedDndBody estimateSize={34} overscan={10} /> * </DataTable> * </DataTableRowDndProvider> */export function DataTableVirtualizedDndBody<TData extends RowData>({ children, estimateSize = 34, overscan = 20, className, onScroll, onRowClick, onScrolledTop, onScrolledBottom, scrollThreshold = 50, onNearEnd, prefetchThreshold = 10, getRowMemoKey, renderRowContextMenu,}: DataTableVirtualizedDndBodyProps<TData>) { const { table, columns } = useDataTable() const { rows } = table.getRowModel()
// Hoist expand-column lookup above the virtualizer loop. See `DataTableVirtualizedBody`. const expandColumnId = React.useMemo( () => table.getAllColumns().find(col => col.columnDef.meta?.expandedContent) ?.id, [table, columns], )
// String signature of the visible column layout. Memoized rows compare it // to invalidate on column toggle / reorder / pin / resize. For external row // state (inline edits, optimistic overlays), pass `getRowMemoKey`. const { columnVisibility, columnOrder, columnPinning, columnSizing } = table.state const resizing = table.options.enableColumnResizing ?? false const columnLayoutSignature = React.useMemo( () => table .getVisibleLeafColumns() .map(c => { const pinned = c.getIsPinned() const base = pinned ? `${c.id}:${pinned}` : c.id return resizing ? `${base}:${c.getSize()}` : base }) .join(","), // eslint-disable-next-line react-hooks/exhaustive-deps [ table, columns, columnVisibility, columnOrder, columnPinning, columnSizing, resizing, ], )
const [scrollElement, setScrollElement] = React.useState<HTMLDivElement | null>(null)
const parentRef = React.useCallback( (node: HTMLTableSectionElement | null) => { if (node !== null) { const container = node.closest( '[data-slot="table-container"]', ) as HTMLDivElement | null setScrollElement(container) } }, [], )
// No `columnsLocked` gate — DnD bodies use flex layout (no auto-layout // pass to mismeasure during), so `ResizeObserver` can attach immediately. const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => scrollElement, estimateSize: () => estimateSize, overscan, enabled: !!scrollElement, measureElement, })
/** Single row-click handler with event delegation (useCallback). */ const handleRowClick = React.useCallback( (event: React.MouseEvent<HTMLTableSectionElement>) => { if (!onRowClick) return const row = resolveRowFromClick(event.target as HTMLElement, table) if (!row) return onRowClick(row.original as TData, event) }, [onRowClick, table], )
React.useEffect(() => { if (!scrollElement) return if (!onScroll && !onScrolledTop && !onScrolledBottom) return
const handleScroll = createScrollHandler({ onScroll, onScrolledTop, onScrolledBottom, scrollThreshold, }) scrollElement.addEventListener("scroll", handleScroll, { passive: true }) return () => scrollElement.removeEventListener("scroll", handleScroll) }, [ scrollElement, onScroll, onScrolledTop, onScrolledBottom, scrollThreshold, ])
const dataIds = React.useMemo<UniqueIdentifier[]>( () => rows.map(row => row.id), [rows], )
const virtualItems = rowVirtualizer.getVirtualItems() const hasVirtualItems = virtualItems.length > 0
const topSpacerHeight = virtualItems[0]?.start ?? 0 const lastItem = hasVirtualItems ? (virtualItems[virtualItems.length - 1] ?? null) : null const bottomSpacerHeight = lastItem ? rowVirtualizer.getTotalSize() - lastItem.end : 0
const isNearEnd = onNearEnd !== undefined && rows.length > 0 && lastItem !== null && lastItem.index >= rows.length - 1 - prefetchThreshold
const wasNearEndRef = React.useRef(false) React.useEffect(() => { if (isNearEnd && !wasNearEndRef.current) { onNearEnd?.() } wasNearEndRef.current = isNearEnd }, [isNearEnd, onNearEnd])
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, )
// Stable wrapper around the virtualizer's measure callback (see // `DataTableVirtualizedBody` for full rationale — `measureElement` // is recreated on every render and would invalidate `React.memo` // on the row component if forwarded directly). const measureElementRef = React.useRef(rowVirtualizer.measureElement) measureElementRef.current = rowVirtualizer.measureElement const stableMeasureElement = React.useCallback( (node: HTMLTableRowElement | null) => { measureElementRef.current(node) }, [], )
return ( <TableBody ref={parentRef} className={cn("block", className)} onClick={onRowClick ? handleRowClick : undefined} > <SortableContext items={dataIds} strategy={verticalListSortingStrategy}> {/* Top spacer for virtual scrolling offset */} {topSpacerHeight > 0 && ( <TableRow style={{ height: `${topSpacerHeight}px`, display: "block" }} /> )}
{/* Render visible rows as draggable */} {virtualItems.map(virtualRow => { const row = rows[virtualRow.index] if (!row) return null
return ( <VirtualizedDndBodyRow key={row.id} row={row as DataTableRow<TData>} virtualIndex={virtualRow.index} expandColumnId={expandColumnId} isExpanded={row.getIsExpanded()} isSelected={row.getIsSelected()} isClickable={isClickable} estimateSize={estimateSize} measureRef={stableMeasureElement} columnSizingEnabled={resizing} columnLayoutSignature={columnLayoutSignature} rowMemoKey={ getRowMemoKey ? getRowMemoKey(row.original as TData) : "" } renderRowContextMenu={resolvedRenderRowContextMenu} /> ) })}
{/* Bottom spacer for remaining virtual height */} {bottomSpacerHeight > 0 && ( <TableRow style={{ height: `${bottomSpacerHeight}px`, display: "block" }} /> )} </SortableContext>
{/* Empty state and other children */} {children} </TableBody> )}
DataTableVirtualizedDndBody.displayName = "DataTableVirtualizedDndBody"Update the import paths to match your project setup.
DataTableVirtualizedColumnDnd (needs virtualized + column-dnd):
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 *//** * @internal Deep-import only. * Intentionally not re-exported from the package barrel — the DnD * virtualized variants are an opt-in advanced surface; the deep * path keeps consumers explicit about pulling them in and avoids * bloating the barrel for the common (non-DnD) case. */
import React from "react"import { useVirtualizer } from "@tanstack/react-virtual"import { flexRender } from "@tanstack/react-table"import { cn } from "@/lib/utils"import { useDataTable } from "./data-table-context"import { TableHeader, TableRow, TableBody, TableCell,} from "@/components/ui/table"import { DataTableColumnHeaderRoot } from "../components/data-table-column-header"import { DataTableColumnResizeHandle } from "../lib/column-resize-handle"import { DataTableRowContextMenu } from "../components/data-table-row-context-menu"import { useResolvedRowContextMenuRenderer } from "../components/data-table-row-context-menu-slot"import { createScrollHandler } from "../lib/create-scroll-handler"import { resolveRowFromClick } from "../lib/row-click"import { renderCellContent } from "../lib/render-cell-content"import { getCommonPinningStyles } from "../lib/styles"import { TableDraggableHeader, TableDragAlongCell,} from "../filters/table-column-dnd"import type { ScrollEvent } from "./data-table-virtualized-structure"
import type { DataTableRow } from "../types"import type { RowData } from "@tanstack/react-table"// ============================================================================// Stable measureElement — computed once at module level// ============================================================================
// See `data-table-virtualized-structure.tsx` for full rationale: sums base +// adjacent expanded-row height since ResizeObserver only attaches to the base.// Prefers `ResizeObserverEntry.borderBoxSize` over `getBoundingClientRect`// when TanStack Virtual passes the entry — skips a forced layout read on// the hot path. Disabled in Firefox (stale getBoundingClientRect during// scroll). Module-scoped for stable reference identity.const measureElement: | ((element: Element, entry?: ResizeObserverEntry | undefined) => number) | undefined = typeof window !== "undefined" && navigator.userAgent.indexOf("Firefox") === -1 ? (element, entry) => { const baseHeight = entry?.borderBoxSize?.[0]?.blockSize ?? element.getBoundingClientRect().height const next = element.nextElementSibling if ( next && next.getAttribute("data-slot") === "datatable-expanded-row" ) { return baseHeight + next.getBoundingClientRect().height } return baseHeight } : undefined
// ============================================================================// VirtualizedDndColumnBodyRow — memoized row// ============================================================================
/** * Per-row component for `DataTableVirtualizedDndColumnBody` * (column-DnD + virtualization). Memoized to keep selection / * expansion changes local to one row instead of cascading across the * viewport. `TableDragAlongCell` manages per-cell drag state internally. */interface VirtualizedDndColumnBodyRowProps<TData extends RowData> { row: DataTableRow<TData> virtualIndex: number expandColumnId: string | undefined isExpanded: boolean isSelected: boolean isClickable: boolean estimateSize: number measureRef: ((node: HTMLTableRowElement | null) => void) | undefined /** Column resizing is on — cells size from `column.getSize()` instead of `columnDef.size`. */ columnSizingEnabled: boolean /** Column layout signature — invalidates React.memo on visibility/order/pinning change. */ columnLayoutSignature: string /** * Per-row memo key. Change this string to force React.memo to re-render a * specific row when row-level state changes outside of TanStack Table's * tracked props (e.g. inline edit mode, optimistic state). */ rowMemoKey: string /** * Right-click menu items for this row. Must be a stable callback so * `React.memo` keeps holding. Return `null` to opt a specific row out. */ renderRowContextMenu?: (row: TData) => React.ReactNode}
const VirtualizedDndColumnBodyRowInner = function VirtualizedDndColumnBodyRow< TData extends RowData,>({ row, virtualIndex, expandColumnId, isExpanded, isSelected, isClickable, estimateSize, measureRef, columnSizingEnabled, columnLayoutSignature, rowMemoKey, renderRowContextMenu,}: VirtualizedDndColumnBodyRowProps<TData>) { const expandCell = isExpanded && expandColumnId ? row.getAllCells().find(c => c.column.id === expandColumnId) : undefined
const visibleCells = row.getVisibleCells()
// Cache the row DOM node so the isExpanded effect can re-trigger measureRef // without unmounting. Same pattern as VirtualizedDraggableRow (row-dnd file). const elementRef = React.useRef<HTMLTableRowElement | null>(null) const setRef = React.useCallback( (node: HTMLTableRowElement | null) => { elementRef.current = node if (measureRef) measureRef(node) }, [measureRef], )
// Re-measure on expansion toggle so the virtualizer picks up the combined // base + expanded-pane height without remounting the row (stable key = no // useSortable re-registration). Column-DnD rows don't have useSortable on // the row itself but we keep key stable for consistency. React.useEffect(() => { if (measureRef && elementRef.current) measureRef(elementRef.current) }, [isExpanded, rowMemoKey, columnLayoutSignature, measureRef])
const rowElement = ( <TableRow ref={setRef} data-index={virtualIndex} data-row-id={row.id} data-state={isSelected ? "selected" : undefined} className={cn( "group flex w-full data-[context-menu-open]:bg-muted/50", isClickable && "cursor-pointer", )} > {visibleCells.map(cell => { const size = cell.column.columnDef.size const fixedWidth = columnSizingEnabled ? cell.column.getSize() : size ? `${size}px` : undefined return ( <TableDragAlongCell key={cell.id} cell={cell} className={cn( fixedWidth != null ? "shrink-0" : "min-w-0 flex-1", "flex items-center 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", )} style={{ width: fixedWidth, minHeight: `${estimateSize}px`, }} > {renderCellContent(cell)} </TableDragAlongCell> ) })} </TableRow> )
// Only stand up the context-menu shell when the consumer returns items // for this row — a `null` return keeps the plain row (and zero portal cost). const menuItems = renderRowContextMenu?.(row.original as TData)
return ( <> {menuItems ? ( <DataTableRowContextMenu row={row.original as TData} trigger={rowElement} > {menuItems} </DataTableRowContextMenu> ) : ( rowElement )}
{isExpanded && expandCell && ( <TableRow data-slot="datatable-expanded-row" className="flex w-full"> <TableCell colSpan={visibleCells.length} className="w-full p-0"> {expandCell.column.columnDef.meta?.expandedContent?.(row.original)} </TableCell> </TableRow> )} </> )}
const VirtualizedDndColumnBodyRow = React.memo( VirtualizedDndColumnBodyRowInner,) as typeof VirtualizedDndColumnBodyRowInner
// ============================================================================// DataTableVirtualizedDndHeader (Column DnD + Virtualization)// ============================================================================
export interface DataTableVirtualizedDndHeaderProps { className?: string /** * Makes the header sticky at the top when scrolling. * @default true */ sticky?: boolean}
/** * Virtualized DnD-aware table header for column drag-and-drop. * Uses flex layout matching the virtualized table structure. * * Must be wrapped in a DataTableColumnDndProvider (or TableColumnDndProvider). * * @example * <DataTableColumnDndProvider columnOrder={columnOrder} onColumnOrderChange={setColumnOrder}> * <DataTable height={400}> * <DataTableVirtualizedDndHeader /> * <DataTableVirtualizedDndColumnBody estimateSize={34} /> * </DataTable> * </DataTableColumnDndProvider> */export const DataTableVirtualizedDndHeader = React.memo( function DataTableVirtualizedDndHeader({ className, sticky = true, }: DataTableVirtualizedDndHeaderProps) { const { table } = useDataTable() const resizing = table?.options.enableColumnResizing ?? false
const headerGroups = table?.getHeaderGroups() ?? []
if (headerGroups.length === 0) { return null }
return ( <TableHeader className={cn( "block", sticky && "sticky top-0 z-30 bg-background", className, )} > {headerGroups.map(headerGroup => ( <TableRow key={headerGroup.id} className="flex w-full border-b"> {headerGroup.headers.map(header => { const size = header.column.columnDef.size const fixedWidth = resizing ? header.getSize() : size ? `${size}px` : undefined
return ( <TableDraggableHeader key={header.id} header={header} className={cn( fixedWidth != null ? "shrink-0" : "min-w-0 flex-1", "flex items-center", header.column.getIsPinned() && "bg-background", )} style={{ width: fixedWidth, ...getCommonPinningStyles(header.column, true), }} > {header.isPlaceholder ? null : ( <DataTableColumnHeaderRoot column={header.column}> {flexRender( header.column.columnDef.header, header.getContext(), )} </DataTableColumnHeaderRoot> )} {resizing && header.column.getCanResize() && ( <DataTableColumnResizeHandle header={header} /> )} </TableDraggableHeader> ) })} </TableRow> ))} </TableHeader> ) },)
DataTableVirtualizedDndHeader.displayName = "DataTableVirtualizedDndHeader"
// ============================================================================// DataTableVirtualizedDndColumnBody (Column DnD + Virtualization)// ============================================================================
export interface DataTableVirtualizedDndColumnBodyProps<TData extends RowData> { children?: React.ReactNode estimateSize?: number overscan?: number className?: string onScroll?: (event: ScrollEvent) => void onScrolledTop?: () => void onScrolledBottom?: () => void scrollThreshold?: number /** * Click is delegated on `<tbody>` (so a single listener serves all * virtual rows). The event therefore arrives with `currentTarget` * = the `<tbody>`. If you need the row element, query * `event.target.closest("tr[data-row-id]")` — typed as * `HTMLElement` to reflect that runtime shape rather than lying * with `HTMLTableRowElement`. */ onRowClick?: (row: TData, event: React.MouseEvent<HTMLElement>) => void /** * Virtualizer-index-driven prefetch trigger — fires when the * last rendered virtual row is within `prefetchThreshold` rows * of the end. See `DataTableVirtualizedBody.onNearEnd` for the * full rationale. */ onNearEnd?: () => void /** Default `10`. See `DataTableVirtualizedBody.prefetchThreshold`. */ prefetchThreshold?: number /** * Return a per-row memo invalidation key. When the returned string changes * for a specific row, React.memo re-renders that row even if TanStack Table * props (selection, expansion, column layout) are unchanged. Use this for * row-level external state that cell renderers depend on — e.g. inline edit * mode, optimistic overlays, or any closure-captured state in column * definitions that changes independently of the table's own state. * * @example * // Trigger re-render on inline edit toggle (only the edited row re-renders) * getRowMemoKey={(row) => (isEditing(row.id) ? "editing" : "")} */ getRowMemoKey?: (row: TData) => string /** * 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}
/** * Virtualized DnD-aware table body for column drag-and-drop. * Each cell follows column drag position using useSortable. * * Must be wrapped in a DataTableColumnDndProvider (or TableColumnDndProvider). * * @example * <DataTableColumnDndProvider columnOrder={columnOrder} onColumnOrderChange={setColumnOrder}> * <DataTable height={400}> * <DataTableVirtualizedDndHeader /> * <DataTableVirtualizedDndColumnBody estimateSize={34} /> * </DataTable> * </DataTableColumnDndProvider> */export function DataTableVirtualizedDndColumnBody<TData extends RowData>({ children, estimateSize = 34, overscan = 20, className, onScroll, onRowClick, onScrolledTop, onScrolledBottom, scrollThreshold = 50, onNearEnd, prefetchThreshold = 10, getRowMemoKey, renderRowContextMenu,}: DataTableVirtualizedDndColumnBodyProps<TData>) { const { table, columns } = useDataTable() const { rows } = table.getRowModel()
// Hoist expand-column lookup above the virtualizer loop. See `DataTableVirtualizedBody`. const expandColumnId = React.useMemo( () => table.getAllColumns().find(col => col.columnDef.meta?.expandedContent) ?.id, [table, columns], )
// String signature of the visible column layout. Memoized rows compare it // to invalidate on column toggle / reorder / pin / resize. For external row // state (inline edits, optimistic overlays), pass `getRowMemoKey`. const { columnVisibility, columnOrder, columnPinning, columnSizing } = table.state const resizing = table.options.enableColumnResizing ?? false const columnLayoutSignature = React.useMemo( () => table .getVisibleLeafColumns() .map(c => { const pinned = c.getIsPinned() const base = pinned ? `${c.id}:${pinned}` : c.id return resizing ? `${base}:${c.getSize()}` : base }) .join(","), // eslint-disable-next-line react-hooks/exhaustive-deps [ table, columns, columnVisibility, columnOrder, columnPinning, columnSizing, resizing, ], )
const [scrollElement, setScrollElement] = React.useState<HTMLDivElement | null>(null)
const parentRef = React.useCallback( (node: HTMLTableSectionElement | null) => { if (node !== null) { const container = node.closest( '[data-slot="table-container"]', ) as HTMLDivElement | null setScrollElement(container) } }, [], )
// No `columnsLocked` gate — DnD bodies use flex layout (no auto-layout // pass to mismeasure during), so `ResizeObserver` can attach immediately. const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => scrollElement, estimateSize: () => estimateSize, overscan, enabled: !!scrollElement, measureElement, })
React.useEffect(() => { if (!scrollElement) return if (!onScroll && !onScrolledTop && !onScrolledBottom) return
const handleScroll = createScrollHandler({ onScroll, onScrolledTop, onScrolledBottom, scrollThreshold, }) scrollElement.addEventListener("scroll", handleScroll, { passive: true }) return () => scrollElement.removeEventListener("scroll", handleScroll) }, [ scrollElement, onScroll, onScrolledTop, onScrolledBottom, scrollThreshold, ])
/** Single row-click handler with event delegation (useCallback). */ const handleRowClick = React.useCallback( (event: React.MouseEvent<HTMLTableSectionElement>) => { if (!onRowClick) return const row = resolveRowFromClick(event.target as HTMLElement, table) if (!row) return onRowClick(row.original as TData, event) }, [onRowClick, table], )
const virtualItems = rowVirtualizer.getVirtualItems() const hasVirtualItems = virtualItems.length > 0
const topSpacerHeight = virtualItems[0]?.start ?? 0 const lastItem = hasVirtualItems ? (virtualItems[virtualItems.length - 1] ?? null) : null const bottomSpacerHeight = lastItem ? rowVirtualizer.getTotalSize() - lastItem.end : 0
const isNearEnd = onNearEnd !== undefined && rows.length > 0 && lastItem !== null && lastItem.index >= rows.length - 1 - prefetchThreshold
const wasNearEndRef = React.useRef(false) React.useEffect(() => { if (isNearEnd && !wasNearEndRef.current) { onNearEnd?.() } wasNearEndRef.current = isNearEnd }, [isNearEnd, onNearEnd])
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, )
// Stable wrapper for the virtualizer's measure ref so it doesn't // invalidate the memoized row on every parent render. See // `DataTableVirtualizedBody` for the full latest-ref-pattern rationale. const measureElementRef = React.useRef(rowVirtualizer.measureElement) measureElementRef.current = rowVirtualizer.measureElement const stableMeasureElement = React.useCallback( (node: HTMLTableRowElement | null) => { measureElementRef.current(node) }, [], )
return ( <TableBody ref={parentRef} className={cn("block", className)} onClick={onRowClick ? handleRowClick : undefined} > {/* Top spacer for virtual scrolling offset */} {topSpacerHeight > 0 && ( <TableRow style={{ height: `${topSpacerHeight}px`, display: "block" }} /> )}
{/* Render visible rows with drag-along cells */} {virtualItems.map(virtualRow => { const row = rows[virtualRow.index] if (!row) return null
return ( <VirtualizedDndColumnBodyRow key={row.id} row={row as DataTableRow<TData>} virtualIndex={virtualRow.index} expandColumnId={expandColumnId} isExpanded={row.getIsExpanded()} isSelected={row.getIsSelected()} isClickable={isClickable} estimateSize={estimateSize} measureRef={stableMeasureElement} columnSizingEnabled={resizing} columnLayoutSignature={columnLayoutSignature} rowMemoKey={ getRowMemoKey ? getRowMemoKey(row.original as TData) : "" } renderRowContextMenu={resolvedRenderRowContextMenu} /> ) })}
{/* Bottom spacer for remaining virtual height */} {bottomSpacerHeight > 0 && ( <TableRow style={{ height: `${bottomSpacerHeight}px`, display: "block" }} /> )}
{/* Empty state and other children */} {children} </TableBody> )}
DataTableVirtualizedDndColumnBody.displayName = "DataTableVirtualizedDndColumnBody"Update the import paths to match your project setup.
Row DnD should not be combined with sorting or filtering. Column DnD is safe with any feature. Row and column packages are axis-isolated — installing one does not copy the other.
Architecture Note
Section titled “Architecture Note”The DataTable components use a two-layer architecture for maximum flexibility:
- Components (
data-table-*.tsxin/componentsfolder) - Context-aware wrapper components that useuseDataTable()hook to automatically get the table fromDataTableRootcontext. These eliminate prop drilling and are the recommended way to use components:DataTableSearchFilter,DataTableFilterMenu,DataTableSortMenu,DataTablePagination, etc.
- Filters (
table-*.tsxin/filtersfolder) - Core implementation components that accept atableprop directly and read from the TanStack Table instance (liketable.state,table.setGlobalFilter(), etc.). These can be used standalone:TableSearchFilter,TableFilterMenu,TableSortMenu, etc.
Why this architecture?
- Use
DataTable*components from their direct paths (e.g.@/components/niko-table/components/data-table-pagination) when you want context-based, zero-config usage - Use
Table*components from filters (e.g.@/components/niko-table/filters/table-pagination) when you want to build custom components or manage the table instance yourself - All filter components use TanStack Table hooks directly, giving you full control
Learn more in the Introduction.
Custom Table Component
Section titled “Custom Table Component”@niko-table/data-table pulls @niko-table/data-table-ui, which installs a custom components/ui/table.tsx that extends the default Shadcn table with:
- A
TableComponentexport (used internally by DataTable — plain<table>without the scroll wrapper) data-slotattributes on all elements- A scroll container with
tabIndex={0}so keyboard users can focus and arrow-scroll it (WCAG 2.1.1)
À-la-carte controls that only depend on data-table-core do not install this file, so a deliberately customized table.tsx will not be overwritten. Install @niko-table/data-table-ui yourself when you want our primitive, or keep yours if it exports TableComponent.
Note: Our
table.tsxis 100% backward compatible with existing Shadcn tables. If you already have atable.tsxand installdata-table/data-table-ui, the CLI will prompt before overwriting.
Monorepo / Non-Standard Layouts
Section titled “Monorepo / Non-Standard Layouts”If your project uses a non-standard UI component path (e.g., packages/ui/src/components/ instead of src/components/ui/), the shadcn CLI may place table.tsx in the wrong location. This is a known upstream shadcn CLI quirk with registry:ui path resolution.
Workaround: After installing, check that table.tsx landed in the correct directory. If it was placed in a nested ui/ folder, move it to your actual UI components path and ensure it exports TableComponent:
// Your existing table.tsx — add this function and exportfunction TableComponent({ className, ...props}: React.ComponentProps<"table">) { return ( <table data-slot="table" className={cn("w-full caption-bottom text-sm", className)} {...props} /> )}
// Add TableComponent to your existing exportsexport { TableComponent, Table, TableHeader, // ... your other exports}Manual Installation
Section titled “Manual Installation”Prefer to copy and paste all components manually? Check out our Manual Installation Guide page where you can copy all the component files at once.
Your First Table
Section titled “Your First Table”Let’s build your first table. We’ll start with a simple table and progressively add features.
-
Start by defining your data
The following data represents a list of users with their names and emails.
components/example-table.tsx type User = {id: stringname: stringemail: stringstatus: "active" | "inactive"}const data: User[] = [{ id: "1", name: "John Doe", email: "john@example.com", status: "active" },{ id: "2", name: "Jane Smith", email: "jane@example.com", status: "active" },{ id: "3", name: "Bob Johnson", email: "bob@example.com", status: "inactive" },] -
Define your columns
Columns define how your data is displayed in the table.
components/example-table.tsx import type { DataTableColumnDef } from "@/components/niko-table/types"const columns: DataTableColumnDef<User>[] = [{accessorKey: "name",header: "Name",},{accessorKey: "email",header: "Email",},{accessorKey: "status",header: "Status",cell: ({ row }) => (<spanclassName={row.original.status === "active"? "text-success": "text-muted-foreground"}>{row.original.status}</span>),},] -
Build your table
You can now build your table using DataTable components.
components/example-table.tsx import { DataTableRoot } from "@/components/niko-table/core/data-table-root"import { DataTable } from "@/components/niko-table/core/data-table"import {DataTableHeader,DataTableBody,} from "@/components/niko-table/core/data-table-structure"export function ExampleTable() {return (<DataTableRoot data={data} columns={columns}><DataTable><DataTableHeader /><DataTableBody /></DataTable></DataTableRoot>)}
Dependencies by Example
Section titled “Dependencies by Example”Different examples require different dependencies. Here’s what you need for each:
Simple Table
Section titled “Simple Table”@tanstack/react-table@^9- Shadcn:
table
Basic Table
Section titled “Basic Table”@tanstack/react-table@^9- Shadcn:
table,button,dropdown-menu
Search Table
Section titled “Search Table”@tanstack/react-table@^9- Shadcn:
table,button,input,dropdown-menu
Faceted Filter Table
Section titled “Faceted Filter Table”@tanstack/react-table@^9- Shadcn:
table,button,input,dropdown-menu,popover,command,checkbox,select
Row Selection Table
Section titled “Row Selection Table”@tanstack/react-table@^9- Shadcn:
table,button,checkbox,dropdown-menu
Row Expansion Table
Section titled “Row Expansion Table”@tanstack/react-table@^9- Shadcn:
table,button,input,dropdown-menu
Tree Table
Section titled “Tree Table”@tanstack/react-table@^9- Shadcn:
table,button,input,dropdown-menu
Grouping Table
Section titled “Grouping Table”@tanstack/react-table@^9- Shadcn:
table,button,input,dropdown-menu - Registry:
@niko-table/data-table-column-group
Virtualization Table
Section titled “Virtualization Table”@tanstack/react-table@^9@tanstack/react-virtual- Shadcn:
table,button,input,dropdown-menu,scroll-area
Advanced Table (All Features)
Section titled “Advanced Table (All Features)”@tanstack/react-table@^9- Shadcn:
table,button,input,dropdown-menu,popover,command,checkbox,select,scroll-area,separator,skeleton,tooltip - Inline Filters: Included in DataTable components (no additional dependencies)
- Sortable Rows (optional):
@niko-table/data-table-row-dnd— installs its@dnd-kitdependencies automatically (built on DiceUI Sortable patterns)
Advanced Table with URL State (nuqs)
Section titled “Advanced Table with URL State (nuqs)”@tanstack/react-table@^9nuqs(nuqs.dev) - Type-safe search params state manager for URL state management- Shadcn: All components from Advanced Table
- Inline Filters: Included in DataTable components (no additional dependencies)
- Sortable Rows (optional):
@niko-table/data-table-row-dnd— installs its@dnd-kitdependencies automatically (built on DiceUI Sortable patterns)
Optional Dependencies
Section titled “Optional Dependencies”Some advanced features may require additional dependencies:
URL State Management (nuqs)
Section titled “URL State Management (nuqs)”For URL state persistence in tables, install nuqs:
npm install nuqsnuqs provides type-safe search params state management, perfect for syncing table filters, pagination, and sorting with the URL.
Sortable/Draggable Rows
Section titled “Sortable/Draggable Rows”For drag-and-drop row reordering, install the registry block — it brings its @dnd-kit dependencies with it:
See the Row DnD Table example for usage. (The sortable primitives follow DiceUI Sortable patterns.)
Project Structure
Section titled “Project Structure”After installation, your project structure should look like:
src/├── components/│ ├── ui/ # Shadcn UI components│ └── niko-table/ # DataTable components│ ├── core/│ ├── types/│ ├── hooks/│ ├── components/│ ├── filters/│ ├── config/│ └── lib/└── lib/ └── utils.tsVerify Installation
Section titled “Verify Installation”Create a simple test to verify everything is working:
import { DataTableRoot } from "@/components/niko-table/core/data-table-root"import { DataTable } from "@/components/niko-table/core/data-table"import { DataTableHeader, DataTableBody,} from "@/components/niko-table/core/data-table-structure"
const columns = [ { accessorKey: "name", header: "Name" }, { accessorKey: "email", header: "Email" },]
const data = [ { id: "1", name: "John Doe", email: "john@example.com" }, { id: "2", name: "Jane Smith", email: "jane@example.com" },]
export function TestTable() { return ( <DataTableRoot data={data} columns={columns}> <DataTable> <DataTableHeader /> <DataTableBody /> </DataTable> </DataTableRoot> )}Next Steps
Section titled “Next Steps”Now that you have the DataTable installed, check out the examples:
- Simple Table - Basic table with no pagination
- Basic Table - Add pagination and sorting
- Search Table - Add search functionality
- Faceted Filter Table - Add advanced filtering
- Advanced Table - All features combined