niko-table
blockdata-table-coresource: ok · hit
Data Table Core
Shared floor for Niko Table: context, non-virtualized structure, types, config, and the lib/hooks bodies and controls need. Does not include DataTableRoot or components/ui/table.tsx — install @niko-table/data-table for the full owner (pulls table.tsx), @niko-table/data-table-ui for the table primitive alone, or bring your own compatible table.tsx with a TableComponent export.
npx shadcn add "https://niko-table.com/r/data-table-core.json"free
Where it goes
data-table
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
skeletonbuttoncontext-menu
Files24 · source cached 10/8/2026, 9:04:32 PM
src/components/niko-table/core/data-table-context.tsx
"use client"
/**
* niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
*
* Before reporting anything: please check the changelog first.
* - In-repo: ./CHANGELOG.md
* - Docs site: https://niko-table.com/changelog
*
* Found a bug or have a fix? Open an issue or PR on GitHub so other
* users (and future LLMs reading this code) benefit:
* https://github.com/Semkoo/niko-table-registry
*/
import React, {
createContext,
useCallback,
useContext,
useReducer,
} from "react"
import { useGeneratedOptions } from "../hooks/use-generated-options"
import { useHeaderMinWidths } from "../lib/use-header-min-widths"
import type { DataTableColumnDef, DataTableInstance, Option } from "../types"
import type { RowData } from "@tanstack/react-table"
export type DataTableContextState = {
isLoading: boolean
}
/** Alignment for `scrollRowIntoView`, mirroring TanStack Virtual's `align`. */
export type ScrollAlign = "auto" | "start" | "center" | "end"
/** Scroll a row into view by its position in the current row model. */
export type ScrollRowIntoView = (
index: number,
opts?: { align?: ScrollAlign },
) => void
/**
* The active-selection SPAN for a grid-style cross-highlight — EVERY column
* and row in the current selection lights up (header + gutter), not just the
* focused cell. Grid-agnostic: the editable grid sets it from its selection
* rectangle. `null` disables the highlight (the default for read-only tables).
*
* Columns are addressed by id (there are few and all render, so a Set is
* cheap); rows by an inclusive row-model index range (rows are virtualized, so
* a Set of ids would be unbounded for a large selection — the body tests each
* visible row's `index` against the range in O(1)).
*/
export interface ActiveSelection {
/** Ids of columns whose header should highlight. */
columnIds: ReadonlySet<string>
/** Inclusive row-model index range whose rows/gutter should highlight. */
rowRange: { min: number; max: number }
}
export type ActiveCell = ActiveSelection | null
export interface FlashRowsOptions {
/** How long the highlight lasts, ms. Default 1400. */
durationMs?: number
/** Scroll the first flashed row into view first. Default true. */
scrollIntoView?: boolean
}
/**
* Briefly highlight rows (by id) so users see what just changed — e.g. after a
* mutation ("which row did adding a member update?"). Scrolls the row into view
* (reusing the virtualizer bridge) then plays a soft fade pulse. A row flash
* lights every cell in the row (works even under pinned columns).
*/
export type FlashRows = (ids: string[], opts?: FlashRowsOptions) => void
/** A single cell target for `flashCells`. */
export interface FlashCellRef {
rowId: string
columnId: string
}
/**
* Briefly highlight individual cells (cell-level, for value-level changes —
* e.g. an inline edit or paste). Same fade pulse as `flashRows`, scoped to cells.
*/
export type FlashCells = (
cells: FlashCellRef[],
opts?: FlashRowsOptions,
) => void
/** Internal key for a flashing cell (row id + column id). */
export function flashCellKey(rowId: string, columnId: string): string {
return `${rowId}:${columnId}`
}
type DataTableContextProps<TData extends RowData> = DataTableContextState & {
table: DataTableInstance<TData>
columns: DataTableColumnDef<TData>[]
generatedOptionsMap: Record<string, Option[]>
setIsLoading: (isLoading: boolean) => void
/** Ids of rows currently flashing (used by the body to animate them). */
flashingRowIds: ReadonlySet<string>
/** Keys (`flashCellKey`) of cells currently flashing. */
flashingCellKeys: ReadonlySet<string>
/** Flash rows by id — scroll into view + soft fade pulse. */
flashRows: FlashRows
/** Flash individual cells (value-level changes). */
flashCells: FlashCells
/**
* Scroll a row into view. Works on virtualized bodies (via the virtualizer,
* which registers itself) and plain bodies (DOM `scrollIntoView` fallback).
* A stable, virtualizer-agnostic handle so consumers never touch the
* virtualizer directly — the registry owner can change virtualization
* internals without breaking consumers.
*/
scrollRowIntoView: ScrollRowIntoView
/**
* Internal: the virtualized body registers its scroll function here on mount
* and clears it (null) on unmount. Non-virtualized bodies never register, so
* `scrollRowIntoView` falls back to a DOM query.
*/
registerRowScroller: (fn: ScrollRowIntoView | null) => void
/**
* The table's scroll container (`<DataTable>`'s `[data-slot="table-container"]`
* viewport). `<DataTable>` registers it on mount; opt-in features like
* `<DataTableColumnAutoFit />` read it to measure/observe available width.
* `null` until the container mounts (or when no `<DataTable>` is used).
*/
scrollContainer: HTMLElement | null
/** Internal: `<DataTable>` registers its scroll container element here. */
registerScrollContainer: (el: HTMLElement | null) => void
/**
* Header-fit floors: `columnId -> minimum width` so an un-resized column is
* never rendered narrower than its own header label (measured, never written
* to `columnSizing`). Empty when resizing is off. Read by the header, body,
* and the min-width lock so they agree on widths.
*/
headerMinWidths: ReadonlyMap<string, number>
/**
* Drive the resize preview line for a flex-column drag (which resizes outside
* TanStack). Pass `{ resizingColumnId, deltaOffset }` while dragging and
* `null` on release. Normal (TanStack) resizes ignore this.
*/
setColumnResizePreview: (info: ColumnResizeInfo | null) => void
/**
* Toggle a row's selection with standard Shift-range support: a plain
* click toggles one row and sets the anchor; a Shift+click selects every row
* between the anchor and this one (in current display order). Wire a select
* checkbox to this instead of `row.toggleSelected()` to get range selection.
*/
toggleRowSelection: (rowId: string, shiftKey: boolean) => void
}
// ---------------------------------------------------------------------------
// Active-cell cross-highlight — its OWN context pair, deliberately outside the
// main context value. The active cell changes on every keystroke in an
// editable grid; if it lived in the main value, every `useDataTable` consumer
// (toolbar, filters, header, body, empty states) would re-render per arrow
// key. Split out, only the header + body — the two subscribers — re-render,
// and read-only tables (where it stays `null`) never re-render at all.
// ---------------------------------------------------------------------------
const ActiveCellValueContext = createContext<ActiveCell>(null)
const ActiveCellSetterContext = createContext<
((cell: ActiveCell) => void) | null
>(null)
/**
* The active-selection span for the grid-style cross-highlight (`null` when
* inactive). In a header, `activeCell.columnIds.has(column.id)` lights the
* active columns; in a body/gutter, the row's **display** index (position in
* `getRowModel().rows` / the virtualizer index) within `activeCell.rowRange`
* lights the active rows — not TanStack's source-data `row.index`.
*/
export function useDataTableActiveCell(): ActiveCell {
return useContext(ActiveCellValueContext)
}
/**
* Set (or clear, with `null`) the active-cell cross-highlight. The setter is
* stable, so subscribing to it never causes re-renders. Used by the grid's
* `<DataGridCrossHighlight>` opt-in.
*/
export function useDataTableActiveCellSetter(): (cell: ActiveCell) => void {
const setter = useContext(ActiveCellSetterContext)
if (setter === null) {
throw new Error(
"useDataTableActiveCellSetter must be used within DataTableRoot",
)
}
return setter
}
// ---------------------------------------------------------------------------
// Column-resize preview context
// ---------------------------------------------------------------------------
// Resizing runs in `onEnd` mode: columns don't change width until the drag
// ends, so the memoized header/body never re-render mid-drag — that's what
// keeps resizing smooth on heavy (avatar/badge) tables. To still show a live
// guide line that follows the cursor, the provider publishes the in-flight
// resize offset here. Only `<ColumnResizePreviewLine>` subscribes, so nothing
// else in the table re-renders per pointer move.
// ---------------------------------------------------------------------------
export interface ColumnResizeInfo {
/** Id of the column being dragged, or `null` when no resize is active. */
resizingColumnId: string | null
/** Pixel delta from the drag start (add to the column's right edge). */
deltaOffset: number
}
const ColumnResizeInfoContext = createContext<ColumnResizeInfo>({
resizingColumnId: null,
deltaOffset: 0,
})
/**
* Live column-resize offset for the preview guide line. Returns the idle value
* (`{ resizingColumnId: null, deltaOffset: 0 }`) when no drag is in flight (or
* outside a provider), so it's safe to call unconditionally.
*/
export function useColumnResizeInfo(): ColumnResizeInfo {
return useContext(ColumnResizeInfoContext)
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const DataTableContext = createContext<DataTableContextProps<any> | undefined>(
undefined,
)
export function useDataTable<
TData extends RowData,
>(): DataTableContextProps<TData> {
const context = useContext(DataTableContext)
if (context === undefined) {
throw new Error("useDataTable must be used within DataTableRoot")
}
return context as DataTableContextProps<TData>
}
export enum DataTableActions {
SET,
SET_IS_LOADING,
}
type DataTableAction = {
type: DataTableActions.SET_IS_LOADING
value: boolean
}
function dataTableReducer(
state: DataTableContextState,
action: DataTableAction,
): DataTableContextState {
switch (action.type) {
case DataTableActions.SET_IS_LOADING:
return { ...state, isLoading: action.value }
default:
return state
}
}
function deriveInitialState(isLoading?: boolean): DataTableContextState {
return {
isLoading: isLoading ?? false,
}
}
interface DataTableProviderProps<TData extends RowData> {
children: React.ReactNode
table: DataTableInstance<TData>
columns?: DataTableColumnDef<TData>[]
isLoading?: boolean
}
/**
* A set of currently-"flashing" string keys with per-key auto-expiry timers.
* `flash(keys, durationMs)` adds them and removes each after its duration.
* Shared by row flash (keys = row ids) and cell flash (keys = `flashCellKey`).
*/
function useFlashSet(): [
ReadonlySet<string>,
(keys: string[], durationMs: number) => void,
] {
const [set, setSet] = React.useState<Set<string>>(() => new Set())
const timers = React.useRef<Map<string, ReturnType<typeof setTimeout>>>(
new Map(),
)
React.useEffect(() => {
const t = timers.current
return () => {
t.forEach(timer => clearTimeout(timer))
t.clear()
}
}, [])
const flash = useCallback((keys: string[], durationMs: number) => {
if (keys.length === 0) return
setSet(prev => {
const next = new Set(prev)
for (const k of keys) next.add(k)
return next
})
for (const k of keys) {
const existing = timers.current.get(k)
if (existing) clearTimeout(existing)
timers.current.set(
k,
setTimeout(() => {
setSet(prev => {
if (!prev.has(k)) return prev
const next = new Set(prev)
next.delete(k)
return next
})
timers.current.delete(k)
}, durationMs),
)
}
}, [])
return [set, flash]
}
export function DataTableProvider<TData extends RowData>({
children,
table,
columns,
isLoading: externalIsLoading,
}: DataTableProviderProps<TData>) {
const [state, dispatch] = useReducer(
dataTableReducer,
deriveInitialState(externalIsLoading),
)
const setIsLoading = useCallback((value: boolean) => {
dispatch({
type: DataTableActions.SET_IS_LOADING,
value,
})
}, [])
// Narrow, virtualizer-agnostic scroll bridge. The virtualized body registers
// its `scrollToIndex` here; `scrollRowIntoView` calls through it, or falls
// back to a DOM query for non-virtualized bodies. Both callbacks are stable.
const rowScrollerRef = React.useRef<ScrollRowIntoView | null>(null)
const registerRowScroller = useCallback((fn: ScrollRowIntoView | null) => {
rowScrollerRef.current = fn
}, [])
// The scroll container is state (not a ref) so opt-in consumers like
// `<DataTableColumnAutoFit />` re-run once it mounts. Set once on mount and
// cleared on unmount by `<DataTable>`'s ref callback.
const [scrollContainer, setScrollContainer] =
React.useState<HTMLElement | null>(null)
const registerScrollContainer = useCallback(
(el: HTMLElement | null) => setScrollContainer(el),
[],
)
const scrollRowIntoView = useCallback<ScrollRowIntoView>((index, opts) => {
if (rowScrollerRef.current) {
rowScrollerRef.current(index, opts)
return
}
if (typeof document !== "undefined") {
// Virtualized rows carry `data-index`; plain body rows carry
// `data-row-index` — match either so the DOM fallback works for both.
// Map the virtualizer-style `align` onto scrollIntoView's `block`.
const block: ScrollLogicalPosition =
opts?.align === undefined || opts.align === "auto"
? "nearest"
: opts.align
document
.querySelector(`[data-index="${index}"], [data-row-index="${index}"]`)
?.scrollIntoView({ block })
}
}, [])
// Row / cell flash — highlight-what-changed. Two identity sets (rows by id,
// cells by `flashCellKey`) with per-key timers so overlapping flashes each get
// their full duration; the body plays a fade pulse on matching cells.
const [flashingRowIds, flashRowKeys] = useFlashSet()
const [flashingCellKeys, flashCellKeys] = useFlashSet()
// Active-cell cross-highlight (grid-style) — provided through its own
// context pair (see above) so per-keystroke updates in an editable grid
// don't churn the main context value. `setActiveCell` is stable.
const [activeCell, setActiveCell] = React.useState<ActiveCell>(null)
// Anchor for Shift-range row selection. Ref, not state — it's
// read/written only inside the click handler, never rendered.
const rowSelectionAnchorRef = React.useRef<string | null>(null)
const toggleRowSelection = useCallback(
(rowId: string, shiftKey: boolean) => {
const model = table.getRowModel()
const row = model.rowsById[rowId]
if (!row) return
const anchorId = rowSelectionAnchorRef.current
if (shiftKey && anchorId != null && model.rowsById[anchorId]) {
// Select every row between the anchor and this one in DISPLAY order.
// Use positions in `model.rows` — TanStack's `row.index` is the
// source-data index and diverges after sort/filter.
const a = model.rows.findIndex(r => r.id === anchorId)
const b = model.rows.findIndex(r => r.id === rowId)
if (a === -1 || b === -1) return
const next = { ...table.state.rowSelection }
for (let i = Math.min(a, b); i <= Math.max(a, b); i++) {
const r = model.rows[i]
if (r?.getCanSelect()) next[r.id] = true
}
table.setRowSelection(next)
return
}
row.toggleSelected()
rowSelectionAnchorRef.current = rowId
},
[table],
)
const scrollFirstIdIntoView = useCallback(
(ids: string[]) => {
const model = table.getRowModel()
for (const id of ids) {
// Display index — `scrollRowIntoView` / the virtualizer speak display
// space, not TanStack's source-data `row.index`.
const idx = model.rows.findIndex(r => r.id === id)
if (idx !== -1) {
scrollRowIntoView(idx, { align: "center" })
return
}
}
},
[table, scrollRowIntoView],
)
const flashRows = useCallback<FlashRows>(
(ids, opts) => {
if (ids.length === 0) return
if (opts?.scrollIntoView ?? true) scrollFirstIdIntoView(ids)
flashRowKeys(ids, opts?.durationMs ?? 1400)
},
[flashRowKeys, scrollFirstIdIntoView],
)
const flashCells = useCallback<FlashCells>(
(cells, opts) => {
if (cells.length === 0) return
if (opts?.scrollIntoView ?? true)
scrollFirstIdIntoView(cells.map(c => c.rowId))
flashCellKeys(
cells.map(c => flashCellKey(c.rowId, c.columnId)),
opts?.durationMs ?? 1400,
)
},
[flashCellKeys, scrollFirstIdIntoView],
)
/**
* Derive `isLoading` at READ time, not via a sync useEffect.
*
* WHY: The previous pattern — `useEffect(() => setIsLoading(externalIsLoading))`
* — is a "copy prop into state" anti-pattern. Under React 19 + Next.js 16
* (Turbopack), it can schedule a reducer dispatch that lands on an unmounted
* component instance during HMR or Strict Mode double-mount, producing:
*
* "Can't perform a React state update on a component that hasn't mounted
* yet. This indicates that you have a side-effect in your render function
* that asynchronously tries to update the component."
*
* FIX: When `externalIsLoading` is provided it wins (matches the original
* effect's intent); otherwise fall back to the reducer-managed state.
* No cross-commit dispatch, no race, no unmount-target warning.
*
* BEHAVIOR NOTE: If a caller both passes `isLoading` as a prop AND calls
* `setIsLoading()` from inside the tree, the prop wins and the internal
* call becomes a no-op. This matches the documented contract of a
* controlled prop and was already the intended behavior of the effect.
*/
const effectiveIsLoading = externalIsLoading ?? state.isLoading
// Table instance ref is stable across state changes — extract individual
// state slices so context consumers re-render on filter/sort/select.
const tableState = table.state
const globalFilter = tableState.globalFilter
const sorting = tableState.sorting
const columnFilters = tableState.columnFilters
const columnVisibility = tableState.columnVisibility
const expanded = tableState.expanded
const rowSelection = tableState.rowSelection
const pagination = tableState.pagination
const columnPinning = tableState.columnPinning
const columnOrder = tableState.columnOrder
const columnSizing = tableState.columnSizing
// Lightweight state hash beats JSON.stringify for large selections
// (~0.1ms vs 20-50ms at 1k rows) while still triggering consumer updates.
const tableStateKey = React.useMemo(() => {
// Full sorted-keys hash — a "first 3 keys" signature collided on
// sequential row IDs (`r1,r2,r3` vs `r1,r2,r4`).
const getObjectHash = (
obj: Record<string, unknown> | undefined,
): string => {
if (!obj) return "0"
const keys = Object.keys(obj)
if (keys.length === 0) return "0"
return keys.sort().join(",")
}
const paginationKey = `${pagination.pageIndex ?? 0}:${pagination.pageSize ?? 0}`
// Handle globalFilter - can be string or object (for complex filters)
const globalFilterHash =
typeof globalFilter === "string"
? globalFilter
: globalFilter && typeof globalFilter === "object"
? getObjectHash(globalFilter)
: ""
return {
globalFilter: globalFilterHash,
sortingHash: JSON.stringify(sorting),
columnFiltersHash: JSON.stringify(columnFilters),
columnVisibilityHash: getObjectHash(
columnVisibility as Record<string, unknown> | undefined,
),
expandedHash: getObjectHash(
expanded as Record<string, unknown> | undefined,
),
rowSelectionHash: getObjectHash(
rowSelection as Record<string, unknown> | undefined,
),
paginationKey,
columnPinningHash: JSON.stringify(columnPinning),
columnOrderHash: JSON.stringify(columnOrder),
// Column widths — so a resize re-runs the context value memo and the
// memoized header/body pick up the new `column.getSize()`.
columnSizingHash: JSON.stringify(columnSizing),
}
}, [
globalFilter,
sorting,
columnFilters,
columnVisibility,
expanded,
rowSelection,
pagination,
columnPinning,
columnOrder,
columnSizing,
])
// Generate options for all select/multiSelect columns in a single pass.
// This replaces N separate per-column scans in faceted filter consumers.
const generatedOptionsMap = useGeneratedOptions(table)
// Header-fit: measure each header's natural width (once, off-DOM canvas) so
// un-resized columns are floored at their header width and never truncate
// their label on load. Only meaningful when resizing is on.
const headerMinWidths = useHeaderMinWidths(
table,
scrollContainer,
table.options.enableColumnResizing ?? false,
)
// Publish the in-flight resize offset for the preview guide line (see
// ColumnResizeInfoContext). Normal columns resize through TanStack, so the
// offset comes from `columnResizing` (mutates every pointer move; this
// provider re-renders with it). Flex columns resize through a custom drag
// (TanStack can't size a width-less column) that publishes its offset via
// `setColumnResizePreview` — that manual value wins while a flex drag is live.
const [manualResizePreview, setManualResizePreview] =
React.useState<ColumnResizeInfo | null>(null)
const columnResizing = table.state.columnResizing
const resizingColumnId = columnResizing.isResizingColumn || null
const resizeDeltaOffset = columnResizing.deltaOffset ?? 0
const columnResizeInfo = React.useMemo<ColumnResizeInfo>(
() =>
manualResizePreview ?? {
resizingColumnId,
deltaOffset: resizeDeltaOffset,
},
[manualResizePreview, resizingColumnId, resizeDeltaOffset],
)
// Memoize so context consumers (10+ filter/action components) only re-render
// when table, columns, loading, or actual table state changes.
const value = React.useMemo(
() =>
({
table,
columns:
columns || (table.options.columns as DataTableColumnDef<TData>[]),
isLoading: effectiveIsLoading,
generatedOptionsMap,
setIsLoading,
scrollRowIntoView,
registerRowScroller,
scrollContainer,
registerScrollContainer,
headerMinWidths,
setColumnResizePreview: setManualResizePreview,
flashingRowIds,
flashingCellKeys,
flashRows,
flashCells,
toggleRowSelection,
}) as DataTableContextProps<TData>,
// eslint-disable-next-line react-hooks/exhaustive-deps -- tableStateKey is load-bearing for reactivity (see block comment above)
[
table,
columns,
effectiveIsLoading,
generatedOptionsMap,
setIsLoading,
scrollRowIntoView,
registerRowScroller,
scrollContainer,
registerScrollContainer,
headerMinWidths,
setManualResizePreview,
flashingRowIds,
flashingCellKeys,
flashRows,
flashCells,
toggleRowSelection,
tableStateKey,
],
)
return (
<DataTableContext.Provider
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- generic table context is invariant under ReactTable options
value={value as DataTableContextProps<any>}
>
<ActiveCellSetterContext.Provider value={setActiveCell}>
<ActiveCellValueContext.Provider value={activeCell}>
<ColumnResizeInfoContext.Provider value={columnResizeInfo}>
<style href="niko-row-flash" precedence="default">
{ROW_FLASH_KEYFRAMES}
</style>
{children}
</ColumnResizeInfoContext.Provider>
</ActiveCellValueContext.Provider>
</ActiveCellSetterContext.Provider>
</DataTableContext.Provider>
)
}
/**
* Self-contained flash animation (no dependency on app globals). Soft fade
* pulse: a primary-tinted background fades to transparent once, ~1.2s.
*/
const ROW_FLASH_KEYFRAMES = `@keyframes niko-row-flash {
0% { background-color: color-mix(in oklab, var(--primary) 24%, transparent); }
100% { background-color: transparent; }
}`
export { DataTableContext }
src/components/niko-table/core/data-table-structure.tsx
"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 { Skeleton } from "@/components/ui/skeleton"
import {
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { cn } from "@/lib/utils"
import React from "react"
import { DataTableColumnHeaderRoot } from "../components/data-table-column-header"
import { DataTableEmptyState } from "../components/data-table-empty-state"
import { DataTableRowContextMenu } from "../components/data-table-row-context-menu"
import { useResolvedRowContextMenuRenderer } from "../components/data-table-row-context-menu-slot"
import { 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 { resolveRowFromClick } from "../lib/row-click"
import { getCommonPinningStyles } from "../lib/styles"
import { flashCellKey, useDataTable } from "./data-table-context"
import type { DataTableRow } from "../types"
import type { RowData } from "@tanstack/react-table"
export type { ScrollEvent }
// ============================================================================
// DataTableHeader
// ============================================================================
export interface DataTableHeaderProps {
className?: string
/**
* Makes the header sticky at the top when scrolling.
* @default true
*/
sticky?: boolean
}
export const DataTableHeader = React.memo(function DataTableHeader({
className,
sticky = true,
}: DataTableHeaderProps) {
const { table, headerMinWidths, setColumnResizePreview } = useDataTable()
const resizing = table?.options.enableColumnResizing ?? false
const headerGroups = table?.getHeaderGroups() ?? []
if (headerGroups.length === 0) {
return null
}
// Flex fill is on by default: resolve which column soaks up the leftover
// width (explicit `meta.flex`, else the first non-pinned data column).
const flexColumnIds = resolveFlexColumnIds(table)
const columnSizing = table.state.columnSizing
return (
<TableHeader
className={cn(
sticky && "sticky top-0 z-30 bg-background",
// Ensure border is visible when sticky using pseudo-element
sticky &&
"after:absolute after:right-0 after:bottom-0 after:left-0 after:h-px after:bg-border",
className,
)}
>
{headerGroups.map(headerGroup => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map(header => {
// A flex column has no explicit width — under `table-layout: fixed`
// it soaks up the leftover row width. Not drag-resizable.
const isFlex = flexColumnIds.has(header.column.id)
const headerStyle = {
width: resolveColumnWidth(header.column, {
resizing,
isFlex,
columnSizing,
headerMinWidths,
}),
...getCommonPinningStyles(header.column, true),
}
return (
<TableHead
key={header.id}
data-col-id={header.column.id}
style={headerStyle}
className={cn(
header.column.getIsPinned() && "bg-background",
// Anchor the absolute resize handle to the cell's right edge.
resizing && "relative overflow-hidden",
)}
>
{header.isPlaceholder ? null : (
<DataTableColumnHeaderRoot column={header.column}>
{resizing &&
typeof header.column.columnDef.header === "string" ? (
<span className="inline-block max-w-full truncate">
{header.column.columnDef.header}
</span>
) : (
flexRender(
header.column.columnDef.header,
header.getContext(),
)
)}
</DataTableColumnHeaderRoot>
)}
{resizing && header.column.getCanResize() && (
<DataTableColumnResizeHandle
header={header}
isFlex={isFlex}
setResizePreview={setColumnResizePreview}
/>
)}
</TableHead>
)
})}
</TableRow>
))}
</TableHeader>
)
})
DataTableHeader.displayName = "DataTableHeader"
// ============================================================================
// BodyRow — memoized to avoid cascading re-renders across visible rows
// ============================================================================
/**
* Per-row component for `DataTableBody`. Wrapped with `React.memo` so a
* single-row state change (selection toggle, expansion) doesn't cascade
* into a re-render across every visible row.
*
* Default shallow equality is sufficient: all props are either primitive
* (`isExpanded`, `isSelected`, `isClickable`, `expandColumnId`) or stable
* by contract (`row` is a TanStack row instance, kept stable across
* renders unless the source data array reference changes).
*/
interface BodyRowProps {
row: DataTableRow<RowData>
/** Position in the current display model (post sort/filter) — for scroll/flash. */
displayIndex: number
expandColumnId: string | undefined
isClickable: boolean
isExpanded: boolean
isSelected: boolean
/**
* Precomputed `columnId -> width` for every visible column (flex → undefined,
* header-fit floor applied). Built once per body render so cells do an O(1)
* lookup instead of recomputing width each. Stable identity, so `React.memo`
* holds; the signature below invalidates it when a width changes.
*/
columnWidths: ReadonlyMap<string, number | string | undefined>
/** Column layout signature — invalidates React.memo on visibility/order/pinning/resize change. */
columnLayoutSignature: string
/**
* Per-row memo key. Change this string to force React.memo to re-render a
* specific row when row-level state changes outside of TanStack Table's
* tracked props (e.g. inline edit mode, optimistic state).
*/
rowMemoKey: string
/** Whole row is flashing (highlight-what-changed). */
isRowFlashing: boolean
/** Keys of individual flashing cells (`flashCellKey`). */
flashingCellKeys: ReadonlySet<string>
/**
* Right-click menu items for this row. Must be a stable callback (wrap in
* `useCallback`) so `React.memo` keeps holding. Return `null` to opt a
* specific row out of having a menu.
*/
renderRowContextMenu?: (row: unknown) => React.ReactNode
}
/** Fade-pulse animation applied to a flashing cell (keyframe in the provider). */
const FLASH_ANIMATION = "niko-row-flash 1.2s ease-out"
const BodyRow = React.memo(function BodyRow({
row,
displayIndex,
expandColumnId,
isClickable,
isExpanded,
isSelected,
columnWidths,
isRowFlashing,
flashingCellKeys,
renderRowContextMenu,
}: BodyRowProps) {
const expandCell =
isExpanded && expandColumnId
? row.getAllCells().find(c => c.column.id === expandColumnId)
: undefined
const visibleCells = row.getVisibleCells()
const rowElement = (
<TableRow
data-row-index={displayIndex}
data-row-id={row.id}
data-row-type={
(row.original as { rowType?: string } | undefined)?.rowType
}
data-parity={displayIndex % 2 === 0 ? "even" : "odd"}
data-expanded={isExpanded ? "true" : undefined}
data-state={isSelected ? "selected" : undefined}
className={cn(
isClickable && "cursor-pointer",
"group data-[context-menu-open]:bg-muted/50",
)}
>
{visibleCells.map(cell => {
const flashing =
isRowFlashing ||
flashingCellKeys.has(flashCellKey(row.id, cell.column.id))
const cellStyle = {
// Precomputed by the body: flex → undefined (fills), otherwise the
// header-fit-floored width (or the declared size when resizing off).
width: columnWidths.get(cell.column.id),
...getCommonPinningStyles(cell.column, false),
...(flashing ? { animation: FLASH_ANIMATION } : {}),
}
return (
<TableCell
key={cell.id}
data-flash={flashing ? "true" : undefined}
data-col-id={cell.column.id}
style={cellStyle}
className={cn(
// Match virtualized/grid: clip overflow. `truncate` adds ellipsis
// on top of shadcn TableCell's `whitespace-nowrap` so shrink-via-
// resize doesn't paint into neighboring columns. Skip on grouped
// cells so the expand control + count stay visible.
!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)}
</TableCell>
)
})}
</TableRow>
)
// Only stand up the context-menu shell when the consumer supplies items
// for this row — a `null` return keeps the plain row (and zero portal cost).
const menuItems = renderRowContextMenu?.(row.original)
return (
<>
{menuItems ? (
<DataTableRowContextMenu row={row.original} trigger={rowElement}>
{menuItems}
</DataTableRowContextMenu>
) : (
rowElement
)}
{expandCell && (
<TableRow>
<TableCell colSpan={visibleCells.length} className="p-0">
{expandCell.column.columnDef.meta?.expandedContent?.(row.original)}
</TableCell>
</TableRow>
)}
</>
)
})
BodyRow.displayName = "BodyRow"
// ============================================================================
// DataTableBody
// ============================================================================
export interface DataTableBodyProps<TData extends RowData> {
children?: React.ReactNode
className?: string
onScroll?: (event: ScrollEvent) => void
onScrolledTop?: () => void
onScrolledBottom?: () => void
scrollThreshold?: number
/**
* Click is dispatched per-row from each cell's onClick. The event's
* `currentTarget` is the `<td>` cell — typed as `HTMLElement` to
* stay consistent with the virtualized variants (which delegate on
* `<tbody>`). Consumers needing the row element can
* `event.target.closest("tr[data-row-id]")`.
*/
onRowClick?: (row: TData, event: React.MouseEvent<HTMLElement>) => void
/**
* Return a per-row memo invalidation key. When this key changes for a
* specific row, only that row re-renders.
*/
getRowMemoKey?: (row: TData) => string
/**
* Attach a native right-click context menu to each row. Return the menu
* items (`ContextMenuItem`, `ContextMenuSeparator`, `ContextMenuSub`, …)
* for the given row, or `null` to give that row no menu. The popup shell
* and portalling are handled internally.
*
* Wrap the callback in `useCallback` so memoized rows don't re-render.
*/
renderRowContextMenu?: (row: TData) => React.ReactNode
}
export function DataTableBody<TData extends RowData>({
children,
className,
onScroll,
onScrolledTop,
onScrolledBottom,
scrollThreshold = 50,
onRowClick,
getRowMemoKey,
renderRowContextMenu,
}: DataTableBodyProps<TData>) {
const {
table,
columns,
isLoading,
flashingRowIds,
flashingCellKeys,
registerRowScroller,
headerMinWidths,
} = useDataTable<TData>()
const { rows } = table.getRowModel()
const containerRef = React.useRef<HTMLTableSectionElement>(null)
// Register a scroll handle SCOPED to this table's own container, so
// `scrollRowIntoView` resolves the row within this table (a plain body has no
// virtualizer to register otherwise, and the context's document-wide fallback
// could match a same-index row in a different table on the page).
React.useEffect(() => {
registerRowScroller((index, opts) => {
const container = containerRef.current?.closest(
'[data-slot="table-container"]',
)
const row = container?.querySelector(`[data-row-index="${index}"]`)
const block: ScrollLogicalPosition =
opts?.align === undefined || opts.align === "auto"
? "nearest"
: opts.align
row?.scrollIntoView({ block })
})
return () => registerRowScroller(null)
}, [registerRowScroller])
// Passive scroll listener — shared `createScrollHandler` keeps all four body
// variants in sync; passive flag unlocks the browser's scroll-thread path.
React.useEffect(() => {
const container = containerRef.current?.closest(
'[data-slot="table-container"]',
) as HTMLDivElement
if (!container) return
if (!onScroll && !onScrolledTop && !onScrolledBottom) return
const handleScroll = createScrollHandler({
onScroll,
onScrolledTop,
onScrolledBottom,
scrollThreshold,
})
container.addEventListener("scroll", handleScroll, { passive: true })
return () => container.removeEventListener("scroll", handleScroll)
}, [onScroll, onScrolledTop, onScrolledBottom, scrollThreshold])
// Single delegated click handler on <tbody> — matches the DnD bodies and
// removes one listener per row.
const handleBodyClick = React.useCallback(
(event: React.MouseEvent<HTMLTableSectionElement>) => {
if (!onRowClick) return
const row = resolveRowFromClick(event.target as HTMLElement, table)
if (!row) return
onRowClick(
row.original,
event as unknown as React.MouseEvent<HTMLElement>,
)
},
[onRowClick, table],
)
// Hoist expand-column lookup above the row map (was O(rows × cols) per render).
// `columns` is in deps because the table reference is too stable on its own.
const expandColumnId = React.useMemo(
() =>
table.getAllColumns().find(col => col.columnDef.meta?.expandedContent)
?.id,
// eslint-disable-next-line react-hooks/exhaustive-deps -- `columns` is an intentional invalidation key; the TanStack table instance is stable across column swaps
[table, columns],
)
const { columnVisibility, columnOrder, columnPinning, columnSizing } =
table.state
const resizing = table.options.enableColumnResizing ?? false
// Flex fill is on by default: which column soaks up the leftover row width.
// `columnSizing` is a dep — resizing a flex column pins it and shifts the
// fill to the next column.
const flexColumnIds = React.useMemo(
() => resolveFlexColumnIds(table),
// eslint-disable-next-line react-hooks/exhaustive-deps
[
table,
columnVisibility,
columnOrder,
columnPinning,
columnSizing,
resizing,
],
)
// Precompute every column's rendered width once (flex + header-fit rules),
// so each cell is an O(1) lookup and header/body/lock all agree.
const columnWidths = React.useMemo(() => {
const widths = new Map<string, number | string | undefined>()
for (const col of table.getVisibleLeafColumns()) {
widths.set(
col.id,
resolveColumnWidth(col, {
resizing,
isFlex: flexColumnIds.has(col.id),
columnSizing,
headerMinWidths,
}),
)
}
return widths
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
table,
flexColumnIds,
columnSizing,
columnVisibility,
columnOrder,
headerMinWidths,
resizing,
])
// String signature of the visible column layout. Memoized rows compare it
// to invalidate on column add/remove / toggle / reorder / pin / width change
// (resize OR header-fit/flex). `columns` must be included — add/remove does
// not change visibility/order/pinning. For external row state (inline edits,
// optimistic overlays), pass `getRowMemoKey`.
const columnLayoutSignature = React.useMemo(
() =>
table
.getVisibleLeafColumns()
.map(c => {
const pinned = c.getIsPinned()
const base = pinned ? `${c.id}:${pinned}` : c.id
return resizing ? `${base}:${columnWidths.get(c.id) ?? "flex"}` : base
})
.join(","),
// eslint-disable-next-line react-hooks/exhaustive-deps
[table, columns, columnWidths, resizing],
)
const isClickable = !!onRowClick
// Composable path: the per-row menu may come from the `renderRowContextMenu`
// prop OR a nested `<DataTableRowContextMenuSlot>` child (prop wins).
const resolvedRenderRowContextMenu = useResolvedRowContextMenuRenderer(
renderRowContextMenu,
children,
)
// Composable path: group rows render only when `<DataTableGroupedRows>` is
// nested in the body. Its presence is also what feature detection reads to
// turn `enableGrouping` / `enableExpanding` on, so there is no config flag
// and no renderer prop — compose the component, or don't.
const groupedRowsSlot = React.useMemo(
() => resolveGroupedRowsSlot(children),
[children],
)
// The table's own inline sizing captured before resizing overrides it, so a
// consumer that sets inline `table-layout` / `width` / `min-width` gets those
// exact values back when resizing turns off — instead of them being blanked.
const tableStyleSnapshotRef = React.useRef<{
tableLayout: string
width: string
minWidth: string
} | null>(null)
// When resizing is on, lock `table-layout: fixed` and an explicit pixel width
// (= sum of `getSize()`) so Tailwind `w-full` can't compress columns. Sticky
// pin offsets use the same sizes — a compressed table would leave the left
// pin overlaying the first data column.
React.useLayoutEffect(() => {
const tableEl = containerRef.current?.closest<HTMLTableElement>(
'[data-slot="table"]',
)
if (!tableEl) return
const restore = () => {
const snap = tableStyleSnapshotRef.current
if (!snap) return
tableEl.style.tableLayout = snap.tableLayout
tableEl.style.width = snap.width
tableEl.style.minWidth = snap.minWidth
tableStyleSnapshotRef.current = null
}
if (!resizing) {
restore()
return
}
// Capture the pre-override values once. Cleanup nulls the snapshot, so each
// resizing pass re-captures the restored (clean) values before overriding.
if (!tableStyleSnapshotRef.current) {
tableStyleSnapshotRef.current = {
tableLayout: tableEl.style.tableLayout,
width: tableEl.style.width,
minWidth: tableEl.style.minWidth,
}
}
const leafColumns = table.getVisibleLeafColumns()
// Min-width = sum of the rendered widths (header-fit floors included; a
// flex column contributes its natural `getSize()` as its floor).
const totalDesiredWidth = leafColumns.reduce((sum, col) => {
const width = columnWidths.get(col.id)
return sum + (typeof width === "number" ? width : col.getSize())
}, 0)
// A flex column has no fixed width, so the table must stretch to the
// container (width 100%) and let that column soak up the surplus; the sized
// columns still can't compress below their sum (minWidth).
const hasFlex = flexColumnIds.size > 0
tableEl.style.tableLayout = "fixed"
tableEl.style.width = hasFlex ? "100%" : `${totalDesiredWidth}px`
tableEl.style.minWidth = `${totalDesiredWidth}px`
return restore
}, [
resizing,
table,
columnWidths,
flexColumnIds,
columnVisibility,
columnOrder,
columnPinning,
])
return (
<TableBody
ref={containerRef}
className={className}
onClick={onRowClick ? handleBodyClick : undefined}
>
{/* Only show rows when not loading */}
{!isLoading && rows?.length
? rows.map((row, displayIndex) =>
// A grouped row is chrome, not a record: it gets the group renderer
// rather than BodyRow, and never carries expanded content or a row
// context menu.
groupedRowsSlot && row.getIsGrouped() ? (
<GroupedBodyRow
key={row.id}
row={row as DataTableRow<RowData>}
displayIndex={displayIndex}
isExpanded={row.getIsExpanded()}
columnWidths={columnWidths}
columnLayoutSignature={columnLayoutSignature}
slot={groupedRowsSlot}
/>
) : (
<BodyRow
key={row.id}
row={row as DataTableRow<RowData>}
displayIndex={displayIndex}
expandColumnId={expandColumnId}
isClickable={isClickable}
isExpanded={row.getIsExpanded()}
isSelected={row.getIsSelected()}
columnWidths={columnWidths}
columnLayoutSignature={columnLayoutSignature}
rowMemoKey={
getRowMemoKey ? getRowMemoKey(row.original as TData) : ""
}
isRowFlashing={flashingRowIds.has(row.id)}
flashingCellKeys={flashingCellKeys}
renderRowContextMenu={
resolvedRenderRowContextMenu as
((row: unknown) => React.ReactNode) | undefined
}
/>
),
)
: null}
{children}
</TableBody>
)
}
DataTableBody.displayName = "DataTableBody"
// ============================================================================
// DataTableEmptyBody
// ============================================================================
export interface DataTableEmptyBodyProps {
children?: React.ReactNode
colSpan?: number
className?: string
}
/**
* Empty state component for data tables.
* Use composition pattern with DataTableEmpty* components for full customization.
*
* @example
* <DataTableEmptyBody>
* <DataTableEmptyIcon>
* <PackageOpen className="size-12" />
* </DataTableEmptyIcon>
* <DataTableEmptyMessage>
* <DataTableEmptyTitle>No products found</DataTableEmptyTitle>
* <DataTableEmptyDescription>
* Get started by adding your first product
* </DataTableEmptyDescription>
* </DataTableEmptyMessage>
* <DataTableEmptyFilteredMessage>
* No matches found
* </DataTableEmptyFilteredMessage>
* <DataTableEmptyActions>
* <Button onClick={handleAdd}>Add Product</Button>
* </DataTableEmptyActions>
* </DataTableEmptyBody>
*/
export function DataTableEmptyBody({
children,
colSpan,
className,
}: DataTableEmptyBodyProps) {
const { table, columns, isLoading } = useDataTable()
// Hooks first (rules-of-hooks), then early-return below skips work when
// the empty state isn't visible.
const tableState = table.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={className}
>
<DataTableEmptyState isFiltered={isFiltered}>
{children}
</DataTableEmptyState>
</TableCell>
</TableRow>
)
}
DataTableEmptyBody.displayName = "DataTableEmptyBody"
// ============================================================================
// DataTableSkeleton
// ============================================================================
export interface DataTableSkeletonProps {
children?: React.ReactNode
colSpan?: number
/**
* Number of skeleton rows to display.
* @default 5
* @recommendation Set this to match your page size for better UX (e.g., if page size is 10, set rows={10})
*/
rows?: number
className?: string
cellClassName?: string
skeletonClassName?: string
}
export function DataTableSkeleton({
children,
colSpan,
rows = 5,
className,
cellClassName,
skeletonClassName,
}: DataTableSkeletonProps) {
const { table, columns, isLoading } = useDataTable()
// Show skeleton only when loading
if (!isLoading) return null
// Get visible columns from table to match actual structure
const visibleColumns = table.getVisibleLeafColumns()
const numColumns = colSpan ?? (visibleColumns.length || columns.length)
// If custom children provided, show single row with custom content
if (children) {
return (
<TableRow>
<TableCell
colSpan={numColumns}
className={cn("h-24 text-center", className)}
>
{children}
</TableCell>
</TableRow>
)
}
// Show skeleton rows that mimic the table structure
return (
<>
{Array.from({ length: rows }).map((_, rowIndex) => (
<TableRow key={rowIndex}>
{visibleColumns.map((column, colIndex) => {
const size = column.columnDef.size
const cellStyle = size ? { width: `${size}px` } : undefined
return (
<TableCell
key={colIndex}
className={cellClassName}
style={cellStyle}
>
<Skeleton className={cn("h-4 w-full", skeletonClassName)} />
</TableCell>
)
})}
</TableRow>
))}
</>
)
}
DataTableSkeleton.displayName = "DataTableSkeleton"
// ============================================================================
// DataTableLoading
// ============================================================================
export interface DataTableLoadingProps {
children?: React.ReactNode
colSpan?: number
className?: string
}
export function DataTableLoading({
children,
colSpan,
className,
}: DataTableLoadingProps) {
const { table, columns, isLoading } = useDataTable()
// Self-gate on `isLoading` to match peer composables — otherwise the row
// stays visible after data resolves.
if (!isLoading) return null
const visibleCount = table.getVisibleLeafColumns().length
return (
<TableRow>
<TableCell
colSpan={colSpan ?? (visibleCount || columns.length)}
className={className ?? "h-24 text-center"}
>
{children ?? (
<div className="flex items-center justify-center gap-2">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
<span className="text-sm text-muted-foreground">Loading...</span>
</div>
)}
</TableCell>
</TableRow>
)
}
DataTableLoading.displayName = "DataTableLoading"
// ============================================================================
// DataTableLoadingMore
// ============================================================================
export interface DataTableLoadingMoreProps {
/**
* Whether a next-page fetch is currently in flight. Typically
* wired to tRPC's `useInfiniteQuery.isFetchingNextPage`. When
* false, this component renders nothing.
*/
isFetching: boolean
/**
* Optional custom content inside the loading-more row. Defaults
* to a spinner + "Loading more…" label. Pass children to
* customize the label per-table (e.g. "Loading more venues…").
*/
children?: React.ReactNode
colSpan?: number
className?: string
}
/**
* Composable "loading more" row for infinite-scroll tables. Renders
* a spinner + label row at the end of the body when `isFetching` is
* true, and nothing when false — designed to be dropped as a child
* of `DataTableBody` alongside `DataTableSkeleton` and
* `DataTableEmptyBody`.
*
* Mirror of `DataTableVirtualizedLoadingMore` for non-virtualized
* tables — same API, same styling, same self-gating behavior.
*
* @example
* const query = api.thing.list.useInfiniteQuery(...);
* <DataTableBody
* onScrolledBottom={() => {
* if (query.hasNextPage && !query.isFetchingNextPage) {
* void query.fetchNextPage();
* }
* }}
* >
* <DataTableSkeleton rows={5} />
* <DataTableEmptyBody>No results</DataTableEmptyBody>
* <DataTableLoadingMore isFetching={query.isFetchingNextPage}>
* Loading more things…
* </DataTableLoadingMore>
* </DataTableBody>
*/
export function DataTableLoadingMore({
isFetching,
children,
colSpan,
className,
}: DataTableLoadingMoreProps) {
const { table, columns } = useDataTable()
// Self-gating — nothing to render when no fetch is in flight.
if (!isFetching) return null
const visibleCount = table.getVisibleLeafColumns().length
return (
<TableRow data-slot="datatable-loading-more-row">
<TableCell
colSpan={colSpan ?? (visibleCount || columns.length)}
className={cn("text-center text-xs text-muted-foreground", className)}
>
<span className="inline-flex items-center justify-center gap-2 py-3">
<span className="inline-block size-3.5 animate-spin rounded-full border-2 border-muted-foreground/60 border-t-transparent" />
{children ?? "Loading more…"}
</span>
</TableCell>
</TableRow>
)
}
DataTableLoadingMore.displayName = "DataTableLoadingMore"
src/components/niko-table/lib/column-resize-handle.tsx
"use client"
/**
* niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
*
* Before reporting anything: please check the changelog first.
* - In-repo: ./CHANGELOG.md
* - Docs site: https://niko-table.com/changelog
*
* Found a bug or have a fix? Open an issue or PR on GitHub so other
* users (and future LLMs reading this code) benefit:
* https://github.com/Semkoo/niko-table-registry
*/
/**
* Drag-to-resize / double-click-to-autosize grip used by DataTableHeader and
* DataTableVirtualizedHeader when `enableColumnResizing` is on. Lives in core
* so both bodies can import it without pulling the opt-in marker package.
*/
import * as React from "react"
import { cn } from "@/lib/utils"
import { DEFAULT_MAX_COLUMN_SIZE, DEFAULT_MIN_COLUMN_SIZE } from "./constants"
import type { RowData } from "@tanstack/react-table"
import type { DataTableHeader } from "../types"
/** Room for cell chrome the text measurement doesn't cover. */
const AUTOSIZE_CELL_PADDING = 24 // horizontal padding + buffer
const AUTOSIZE_HEADER_CHROME = 52 // sort/menu trigger + resize grip
/** Re-export for consumers that imported sizes from this module. */
export { DEFAULT_MAX_COLUMN_SIZE, DEFAULT_MIN_COLUMN_SIZE }
/** Keyboard resize step (px); the larger step applies while Shift is held. */
const KEYBOARD_RESIZE_STEP = 8
const KEYBOARD_RESIZE_STEP_LARGE = 40
/**
* Tightest width that fits every TEXT node in an element. Uses a DOM Range
* so `w-full` + `truncate` wrappers — whose `scrollWidth` echoes the current
* cell width — don't hide the natural content width. Autosize can grow AND
* shrink to fit.
*/
function widestTextWidth(el: Element): number {
const range = document.createRange()
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT)
let max = 0
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
if (!n.textContent || n.textContent.trim() === "") continue
range.selectNodeContents(n)
max = Math.max(max, range.getBoundingClientRect().width)
}
return max
}
/**
* Autosize a column to fit its content — measures the widest rendered
* cell/header text (mounted cells only — for virtualized tables, what's on
* screen) and sets that column's size.
*/
function autosizeColumn<TData extends RowData>(
header: DataTableHeader<TData, unknown>,
handle: Element,
) {
const root = handle.closest('[data-slot="table"]')
if (!root) return
const cells = root.querySelectorAll<HTMLElement>(
`[data-col-id="${CSS.escape(header.column.id)}"]`,
)
let content = 0
cells.forEach(c => {
const isHeader = c.getAttribute("data-slot") === "table-head"
const chrome = isHeader ? AUTOSIZE_HEADER_CHROME : AUTOSIZE_CELL_PADDING
content = Math.max(content, widestTextWidth(c) + chrome)
})
if (content <= 0) return
const def = header.column.columnDef
const width = Math.min(
Math.max(Math.ceil(content), def.minSize ?? DEFAULT_MIN_COLUMN_SIZE),
def.maxSize ?? DEFAULT_MAX_COLUMN_SIZE,
)
header
.getContext()
.table.setColumnSizing(prev => ({ ...prev, [header.column.id]: width }))
}
/**
* Drag-to-resize / double-click-to-autosize grip on a header's right edge.
* Keyboard-operable (WAI-ARIA window-splitter): focusable, Arrow Left/Right
* nudge the width (Shift = larger step), Enter autosizes; exposes
* `aria-value*`.
*/
export interface DataTableColumnResizeHandleProps<TData extends RowData> {
header: DataTableHeader<TData, unknown>
/**
* This column is currently the flex (fill) column. Flex columns have no width
* TanStack can drive, so they resize through a custom pointer drag that starts
* from the rendered width (no jump) and, on release, writes an explicit width
* — which pins the column and hands the fill to the next eligible column.
*/
isFlex?: boolean
/**
* Drive the resize preview line during a flex drag (from the DataTable
* context). Passed `{ resizingColumnId, deltaOffset }` while dragging, `null`
* on release.
*/
setResizePreview?: (
info: { resizingColumnId: string | null; deltaOffset: number } | null,
) => void
}
export function DataTableColumnResizeHandle<TData extends RowData>({
header,
isFlex = false,
setResizePreview,
}: DataTableColumnResizeHandleProps<TData>) {
const isResizing = header.column.getIsResizing()
const resize = header.getResizeHandler()
const def = header.column.columnDef
const min = def.minSize ?? DEFAULT_MIN_COLUMN_SIZE
const max = def.maxSize ?? DEFAULT_MAX_COLUMN_SIZE
// ARIA value: `getSize()` is wrong for a column without an explicit
// `columnSizing` entry — a flex column renders at whatever fills the
// container, and header-fit can floor an un-resized column wider than its
// declared size. Measure the rendered width when the handle gains focus so
// screen readers hear the real width; once an entry exists, `getSize()` is
// the truth again (user choice wins, render and ARIA agree).
const [focusedWidth, setFocusedWidth] = React.useState<number | null>(null)
const hasExplicitWidth =
header.getContext().table.atoms.columnSizing?.get()?.[header.column.id] !=
null
const ariaValueNow = Math.round(
hasExplicitWidth
? header.column.getSize()
: (focusedWidth ?? header.column.getSize()),
)
// `fallbackWidth` seeds the first nudge for a flex column (no `columnSizing`
// entry yet) from its rendered width so the arrow key doesn't jump it to the
// declared `size`. Non-flex columns fall back to `getSize()` as before.
const nudge = (delta: number, fallbackWidth?: number) => {
header.getContext().table.setColumnSizing(prev => {
const current =
prev[header.column.id] ?? fallbackWidth ?? header.column.getSize()
const next = Math.min(Math.max(current + delta, min), max)
return { ...prev, [header.column.id]: next }
})
}
// Tear down an in-flight flex drag if the handle unmounts mid-drag (route
// change, dialog close): the window listeners must not linger, and the
// eventual pointerup must not commit a width to a table that no longer
// exists (a consumer's `onColumnSizingChange` could persist it).
const flexDragTeardownRef = React.useRef<(() => void) | null>(null)
React.useEffect(() => () => flexDragTeardownRef.current?.(), [])
// Custom drag for the flex column: measure its rendered (filled) width as the
// start so there's no jump, follow the cursor via the preview line, and write
// an explicit width on release (which releases it from flex).
const startFlexResize = (
startClientX: number,
handleEl: HTMLElement | null,
) => {
const thEl = handleEl?.closest<HTMLElement>("[data-col-id]")
if (!thEl || !setResizePreview) return
const startWidth = thEl.getBoundingClientRect().width
const id = header.column.id
setResizePreview({ resizingColumnId: id, deltaOffset: 0 })
const onMove = (ev: PointerEvent) => {
setResizePreview({
resizingColumnId: id,
deltaOffset: ev.clientX - startClientX,
})
}
const teardown = () => {
flexDragTeardownRef.current = null
window.removeEventListener("pointermove", onMove)
window.removeEventListener("pointerup", onUp)
window.removeEventListener("pointercancel", onCancel)
setResizePreview(null)
}
flexDragTeardownRef.current = teardown
// Commit the new width only on a real release. A cancelled pointer (e.g. the
// browser stealing the gesture) just tears down and keeps the column flexing.
const onUp = (ev: PointerEvent) => {
const next = Math.min(
Math.max(startWidth + (ev.clientX - startClientX), min),
max,
)
header
.getContext()
.table.setColumnSizing(prev => ({ ...prev, [id]: next }))
teardown()
}
const onCancel = () => teardown()
window.addEventListener("pointermove", onMove)
window.addEventListener("pointerup", onUp)
window.addEventListener("pointercancel", onCancel)
}
return (
<div
data-slot="column-resize-handle"
role="separator"
aria-orientation="vertical"
aria-label="Resize column (arrow keys, or double-click to fit)"
aria-valuenow={ariaValueNow}
aria-valuemin={min}
// A flex column can legitimately render wider than the declared
// `maxSize` (fill ignores it), so keep the ARIA range valid.
aria-valuemax={Math.max(max, ariaValueNow)}
tabIndex={0}
onFocus={e => {
const width = e.currentTarget
.closest("[data-col-id]")
?.getBoundingClientRect().width
setFocusedWidth(width ?? null)
}}
onBlur={() => setFocusedWidth(null)}
onMouseDown={e => {
// Keep column-DnD header listeners from treating a resize drag as a
// reorder start (handle lives inside the sortable `<th>`).
e.stopPropagation()
// Flex columns resize via the pointer-based custom drag below.
if (!isFlex) resize(e)
}}
onTouchStart={e => {
e.stopPropagation()
if (!isFlex) resize(e)
}}
onDoubleClick={e => autosizeColumn(header, e.currentTarget)}
onClick={e => e.stopPropagation()}
onPointerDown={e => {
e.stopPropagation()
if (isFlex) {
e.preventDefault()
startFlexResize(e.clientX, e.currentTarget)
}
}}
onKeyDown={e => {
const step = e.shiftKey
? KEYBOARD_RESIZE_STEP_LARGE
: KEYBOARD_RESIZE_STEP
// Flex columns have no `columnSizing` entry, so seed the first nudge
// from the rendered width to avoid jumping to the declared `size`.
const fallbackWidth = isFlex
? e.currentTarget.closest("[data-col-id]")?.getBoundingClientRect()
.width
: undefined
if (e.key === "ArrowLeft") {
e.preventDefault()
nudge(-step, fallbackWidth)
} else if (e.key === "ArrowRight") {
e.preventDefault()
nudge(step, fallbackWidth)
} else if (e.key === "Enter") {
e.preventDefault()
autosizeColumn(header, e.currentTarget)
}
}}
className="group/resize absolute top-0 right-0 z-10 flex h-full w-2 cursor-col-resize touch-none justify-end outline-none select-none"
>
{/* Idle: invisible — an always-on grip bar reads as a header divider
and drifts 1–2px from real cell `border-r`. Show on hover/focus/drag. */}
<div
className={cn(
"my-1.5 w-px rounded bg-border opacity-0 transition-all",
"group-hover/resize:my-0 group-hover/resize:w-0.5 group-hover/resize:bg-primary group-hover/resize:opacity-100",
"group-focus-visible/resize:my-0 group-focus-visible/resize:w-0.5 group-focus-visible/resize:bg-primary group-focus-visible/resize:opacity-100",
isResizing && "my-0 w-0.5 bg-primary opacity-100",
)}
/>
</div>
)
}
src/components/niko-table/lib/flex-columns.ts
/**
* 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 { ColumnSizingState, RowData } from "@tanstack/react-table"
import type { DataTableColumn, DataTableInstance } from "../types"
/**
* Resolve which column(s) should flex to fill the leftover row width.
*
* Flex fill is ON BY DEFAULT for resizable tables. A flex column renders with
* no explicit width, so under `table-layout: fixed` it soaks up the surplus:
* the table fills its container and a trailing actions column pins to the right
* edge instead of leaving dead space. It's pure layout — never writes
* `columnSizing`, so no persistence side effects.
*
* Selection: when no column opts in explicitly, the DEFAULT is the first
* non-pinned, resizable column (the primary text column). Selection / expand /
* actions columns are non-resizable (`enableResizing: false`), so they're
* skipped automatically; the default lands on the first real data column.
*
* Overrides:
* - `meta: { flex: true }` on a column flexes THAT column instead of the
* default — use it when the primary column isn't the first one (e.g. a table
* whose first columns are narrow codes/dates and a later column should grow).
* - `meta: { flex: false }` opts a column out of ever being auto-picked.
* - `meta: { disableFlexFill: true }` on the table (TableMeta) turns fill off
* entirely — for a wide table meant to scroll horizontally rather than fill.
*
* Returns the set of column ids to render widthless (empty when fill is off or
* no eligible column exists — the table then sizes to its columns as before).
*/
export function resolveFlexColumnIds<TData extends RowData>(
table: DataTableInstance<TData>,
): ReadonlySet<string> {
const ids = new Set<string>()
// Flex only applies under the fixed layout that resizing turns on.
if (!table.options.enableColumnResizing) return ids
if (table.options.meta?.disableFlexFill) return ids
const leafColumns = table.getVisibleLeafColumns()
// A column the user has explicitly resized is fixed at their width and never
// flexes — so dragging a flex column pins it and hands the fill to the next
// eligible column, keeping the table full.
const columnSizing = table.state.columnSizing
// Explicit opt-in wins: flex the marked column(s) unless the user resized it.
// A pinned column can't flex (sticky offsets need a real width), so skip it
// and fall through to the auto-pick — a marked-but-pinned column shouldn't
// strand the table without a fill column.
let hasExplicit = false
for (const column of leafColumns) {
if (
column.columnDef.meta?.flex === true &&
!column.getIsPinned() &&
columnSizing[column.id] == null
) {
ids.add(column.id)
hasExplicit = true
}
}
if (hasExplicit) return ids
// Default: the first non-pinned, resizable, un-resized, non-opted-out column.
const auto = leafColumns.find(
column =>
column.getCanResize() &&
!column.getIsPinned() &&
column.columnDef.meta?.flex !== false &&
columnSizing[column.id] == null,
)
if (auto) ids.add(auto.id)
return ids
}
/**
* The width to render for a single column. Centralizes the flex + header-fit
* rules so the header, body cells, and the min-width lock all agree.
*
* - Off (no resizing): the declared `size` (or auto when unset).
* - Flex column: no width — it fills the leftover row width.
* - Resized by the user: exactly `getSize()` — their choice wins, even if it's
* narrower than the header (matches how spreadsheet grids let you shrink).
* - Otherwise: floored at the header's natural width (`headerMinWidths`) so the
* label never truncates on load. Pure layout — `headerMinWidths` is measured,
* never written back into `columnSizing`, so nothing persists or goes stale.
*/
export function resolveColumnWidth<TData extends RowData>(
column: DataTableColumn<TData>,
params: {
resizing: boolean
isFlex: boolean
columnSizing: ColumnSizingState
headerMinWidths: ReadonlyMap<string, number>
},
): number | string | undefined {
const size = column.columnDef.size
// Off (no resizing): the declared `size`. Flex only means "fill the surplus"
// under the fixed layout that resizing turns on, so a flex column keeps its
// declared size here — check resizing before flex.
if (!params.resizing) return size ? `${size}px` : undefined
if (params.isFlex) return undefined
const base = column.getSize()
if (params.columnSizing[column.id] != null) return base
const headerMin = params.headerMinWidths.get(column.id) ?? 0
return headerMin > base ? headerMin : base
}
src/components/niko-table/lib/use-header-min-widths.ts
"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 type { DataTableColumn, DataTableInstance } from "../types"
import * as React from "react"
import { formatLabel } from "./format"
// Non-label chrome inside a header cell: horizontal padding + the resize
// handle + a little slack. Added to the measured label width so the floor
// leaves the label breathing room, not a pixel-tight fit.
const BASE_HEADER_CHROME_PX = 28
// A sortable column also renders a sort trigger next to the label.
const SORT_AFFORDANCE_PX = 24
const EMPTY_MIN_WIDTHS: ReadonlyMap<string, number> = new Map()
// One reusable off-DOM canvas: `measureText` never touches layout, so measuring
// every header costs no reflow (unlike reading `scrollWidth` per cell).
let measureCanvas: HTMLCanvasElement | null = null
function getMeasureContext(): CanvasRenderingContext2D | null {
if (typeof document === "undefined") return null
if (!measureCanvas) measureCanvas = document.createElement("canvas")
return measureCanvas.getContext("2d")
}
/**
* The label text a column renders in its header. Mirrors the composable
* title's precedence (`useDerivedColumnTitle`): `meta.label`, else a plain
* string `header`, else the formatted column id — the same fallback
* `DataTableColumnTitle` renders when nothing else is set. One gap this can't
* see: a JSX `<DataTableColumnTitle title="..." />` override lives in rendered
* output, not the column def — mirror such overrides into `meta.label` so
* header-fit measures the right string.
*/
function headerLabel<TData extends RowData>(
column: DataTableColumn<TData, unknown>,
): string | null {
const columnDef = column.columnDef
if (columnDef.meta?.label) return columnDef.meta.label
if (typeof columnDef.header === "string") return columnDef.header
return formatLabel(column.id)
}
/** Content equality so a re-measure with identical results keeps identity. */
function minWidthsEqual(
a: ReadonlyMap<string, number>,
b: ReadonlyMap<string, number>,
): boolean {
if (a.size !== b.size) return false
for (const [id, width] of b) {
if (a.get(id) !== width) return false
}
return true
}
/**
* Measure the natural width each column's header needs so a column is never
* rendered so narrow that its label truncates on load (header-fit).
*
* Returns `columnId -> minimum width in px` (label width + header chrome). The
* caller floors each un-resized column at this width; a column the user has
* explicitly resized is left alone. Pure measurement — nothing is written to
* `columnSizing`, so it never persists or goes stale.
*
* Recomputes only when the visible columns, their labels, or the header font
* change — measurement itself is O(columns) with zero reflow.
*/
export function useHeaderMinWidths<TData extends RowData>(
table: DataTableInstance<TData>,
scrollElement: HTMLElement | null,
enabled: boolean,
): ReadonlyMap<string, number> {
const [minWidths, setMinWidths] =
React.useState<ReadonlyMap<string, number>>(EMPTY_MIN_WIDTHS)
const leafColumns = table.getVisibleLeafColumns()
// Signature: recompute when the visible set, a label, or sortability changes.
const signature = leafColumns
.map(c => `${c.id}:${headerLabel(c) ?? ""}:${c.getCanSort() ? 1 : 0}`)
.join("|")
// Measure-then-store is the canonical layout-measurement pattern (same as
// `useColumnAutoFit`): it needs the rendered DOM (header font), so it can't
// run during render. The one extra commit it triggers is intended.
React.useLayoutEffect(() => {
// Content-equal results keep the previous Map identity — downstream
// `columnWidths` memos and memoized body rows only re-render when a floor
// actually changed, not on every re-measure.
const store = (next: ReadonlyMap<string, number>) =>
setMinWidths(prev => (minWidthsEqual(prev, next) ? prev : next))
if (!enabled || !scrollElement) {
store(EMPTY_MIN_WIDTHS)
return
}
const ctx = getMeasureContext()
// Read the real header font (family/size/weight) so measurement matches the
// rendered label. Prefer the composable title element (semibold); fall back
// to the header cell itself so raw string headers are still measurable.
const fontEl =
scrollElement.querySelector<HTMLElement>(
'thead [data-slot="column-title"]',
) ?? scrollElement.querySelector<HTMLElement>("thead th[data-col-id]")
if (!ctx || !fontEl) {
// No header rendered yet / nothing to measure — drop any stale floors.
store(EMPTY_MIN_WIDTHS)
return
}
const cs = getComputedStyle(fontEl)
ctx.font = `${cs.fontStyle} ${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`
const next = new Map<string, number>()
for (const column of leafColumns) {
// Floors apply only to resizable columns. Fixed utility columns
// (selection, actions, gutters — `enableResizing: false`) keep their
// declared size: they often have no visible label, and the formatted-id
// fallback would otherwise invent a phantom floor for them.
if (!column.getCanResize()) continue
const label = headerLabel(column)
if (!label) continue
const textWidth = ctx.measureText(label).width
const chrome =
BASE_HEADER_CHROME_PX + (column.getCanSort() ? SORT_AFFORDANCE_PX : 0)
next.set(column.id, Math.ceil(textWidth) + chrome)
}
store(next)
// `leafColumns` identity churns each render; `signature` is the stable key.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, scrollElement, signature])
return minWidths
}
src/components/niko-table/components/data-table-row-context-menu.tsx
"use client"
import * as React from "react"
import {
ContextMenu,
ContextMenuContent,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import { DataTableRowMenuScope } from "./data-table-row-menu"
export interface DataTableRowContextMenuProps {
/**
* The row's `original` data. Provided to the menu children via
* `DataTableRowMenuScope` so a declarative `<XRowMenu>` can read it with
* `useDataTableRow()` and render the same items as the kebab dropdown.
*/
row: unknown
/**
* The `<TableRow>` element the menu anchors to. Radix's `asChild` merges
* the right-click listener onto this element (via Slot), so no wrapper
* node is introduced and table markup (`<tbody> > <tr>`) stays valid.
*/
trigger: React.ReactElement
/**
* The menu items to show on right-click — compose `ContextMenuItem`,
* `ContextMenuSeparator`, `ContextMenuSub`, etc. The portal + surface
* chrome is owned here so callers only supply the actions.
*
* Tip: reuse the same items your row's "…" actions column renders so
* right-click and the kebab menu stay in sync (write once).
*/
children: React.ReactNode
/** Extra className for the popup surface. */
className?: string
}
/**
* Attaches a right-click context menu to a single data-table row.
*
* Low-level primitive used internally by the bodies (and directly by card
* views): pass the row's data as `row`, the row's `<TableRow>` element as
* `trigger`, and the menu as `children`. Children are wrapped in a
* `DataTableRowMenuScope surface="context"`, so a declarative `<XRowMenu>`
* built from `RowMenuItem`/… (see `data-table-row-menu`) works here — as do
* raw `ContextMenuItem`s. The popup is portalled and only mounts on open.
*
* @example
* <DataTableRowContextMenu
* row={row.original}
* trigger={<TableRow data-row-id={row.id}>{cells}</TableRow>}
* >
* <PlayerRowMenu onView={view} onRemove={remove} />
* </DataTableRowContextMenu>
*/
export function DataTableRowContextMenu({
row,
trigger,
children,
className,
}: DataTableRowContextMenuProps) {
// Keep the anchored row visually highlighted while its menu is open, so the
// user can see which row they're acting on. We stamp `data-context-menu-open`
// on the row element; the table's row/cell styles react to it the same way
// they react to `data-state="selected"`.
const [open, setOpen] = React.useState(false)
const anchoredTrigger = open
? React.cloneElement(
trigger as React.ReactElement<{ "data-context-menu-open"?: string }>,
{ "data-context-menu-open": "" },
)
: trigger
return (
<ContextMenu onOpenChange={setOpen}>
<ContextMenuTrigger asChild>{anchoredTrigger}</ContextMenuTrigger>
<ContextMenuContent className={className}>
<DataTableRowMenuScope row={row} surface="context">
{children}
</DataTableRowMenuScope>
</ContextMenuContent>
</ContextMenu>
)
}
DataTableRowContextMenu.displayName = "DataTableRowContextMenu"
src/components/niko-table/components/data-table-row-context-menu-slot.tsx
"use client"
import * as React from "react"
import type { RowData } from "@tanstack/react-table"
export interface DataTableRowContextMenuSlotProps<TData extends RowData> {
/**
* The row menu. Preferred (shadcn/composable) form is a declarative
* component that reads the row via `useDataTableRow()`:
*
* <DataTableRowContextMenuSlot>
* <TeamRowMenu onEdit={…} onDelete={…} />
* </DataTableRowContextMenuSlot>
*
* A `(row) => menu` function is also accepted for cases that would rather
* close over the row explicitly.
*/
children: React.ReactNode | ((row: TData) => React.ReactNode)
/**
* Optional per-row predicate — return `false` to give a specific row no
* menu (e.g. locked/browse-only rows). Table-level gating (e.g. "no manage
* permission") is better done by simply not rendering the slot at all.
*/
enabledFor?: (row: TData) => boolean
}
/**
* Declarative, composable row context menu for niko-table bodies.
*
* Nest it inside a `DataTableBody` / `DataTableVirtualizedBody` — or any of
* the four DnD bodies (`DataTableDndBody`, `DataTableDndColumnBody`,
* `DataTableVirtualizedDndBody`, `DataTableVirtualizedDndColumnBody`) — so
* the per-row menu reads as part of the table's JSX tree. It renders nothing
* itself — the body detects it among its children and renders its menu for
* every (enabled) row inside a `DataTableRowMenuScope`, so a declarative
* `<XRowMenu>` composed from `RowMenuItem` / `RowMenuSub` / … resolves the
* row and surface from context. The body's `renderRowContextMenu` prop still
* works and wins when both are present.
*
* @example
* <DataTableVirtualizedBody estimateSize={44}>
* <DataTableRowContextMenuSlot enabledFor={(m) => !m.isLocked}>
* <PoolRowMenu onOffer={…} onDirectAssign={…} />
* </DataTableRowContextMenuSlot>
* <DataTableVirtualizedSkeleton rows={8} />
* </DataTableVirtualizedBody>
*/
export function DataTableRowContextMenuSlot<TData extends RowData>(
_props: DataTableRowContextMenuSlotProps<TData>,
): null {
return null
}
DataTableRowContextMenuSlot.displayName = "DataTableRowContextMenuSlot"
/**
* Resolve the effective per-row menu renderer for a body: the explicit
* `renderRowContextMenu` prop wins; otherwise a nested
* `<DataTableRowContextMenuSlot>` child (declarative node or function),
* honouring its optional `enabledFor` predicate.
*/
export function resolveRowContextMenuRenderer<TData extends RowData>(
prop: ((row: TData) => React.ReactNode) | undefined,
children: React.ReactNode,
): ((row: TData) => React.ReactNode) | undefined {
if (prop) return prop
let slotProps: DataTableRowContextMenuSlotProps<TData> | undefined
React.Children.forEach(children, child => {
if (!React.isValidElement(child)) return
// Match by displayName — reference equality breaks across re-exports / HMR.
const type = child.type as { displayName?: string }
if (
child.type === DataTableRowContextMenuSlot ||
type.displayName === "DataTableRowContextMenuSlot"
) {
slotProps = child.props as DataTableRowContextMenuSlotProps<TData>
}
})
if (!slotProps) return undefined
const { children: menu, enabledFor } = slotProps
return (row: TData) => {
if (enabledFor && !enabledFor(row)) return null
return typeof menu === "function"
? (menu as (row: TData) => React.ReactNode)(row)
: menu
}
}
/**
* Body-side hook: resolve the per-row menu renderer and return a **stable**
* callback identity, so memoized rows (`BodyRow`) don't re-render just because
* the body re-rendered. `resolveRowContextMenuRenderer` returns a fresh
* function each render (it closes over `children`); we keep the latest in a ref
* and hand out a stable wrapper that reads it. The wrapper identity only
* changes when the presence of a menu changes.
*/
export function useResolvedRowContextMenuRenderer<TData extends RowData>(
prop: ((row: TData) => React.ReactNode) | undefined,
children: React.ReactNode,
): ((row: TData) => React.ReactNode) | undefined {
const resolved = resolveRowContextMenuRenderer(prop, children)
const latestRef = React.useRef(resolved)
latestRef.current = resolved
const hasMenu = !!resolved
return React.useMemo(
() => (hasMenu ? (row: TData) => latestRef.current?.(row) : undefined),
[hasMenu],
)
}
src/components/niko-table/components/data-table-grouped-rows.tsx
"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 type { DataTableRow } from "../types"
/**
* What a custom group-row child reads from context.
*
* Everything a group row needs is here, so the child stays a plain component
* rather than a function of arguments — the same contract `useDataTableRow`
* gives a row menu.
*/
export interface DataTableGroupRowContextValue<
TData extends RowData = RowData,
> {
/** The grouped row itself (`row.getIsGrouped()` is true). */
row: DataTableRow<TData>
/** Value the rows underneath share, already resolved from the grouped column. */
groupValue: unknown
/** `groupValue` as a display string; empty when the value is null/undefined. */
groupLabel: string
/** How many rows this group holds. */
count: number
/** Whether the group is currently open. */
isExpanded: boolean
/** Open/close the group. */
toggle: () => void
/** Position in the current display model (post sort/filter/group). */
displayIndex: number
/** Shared `columnId -> width`, so a custom row still lines up with its leaves. */
columnWidths: ReadonlyMap<string, number | string | undefined>
}
const DataTableGroupRowContext =
React.createContext<DataTableGroupRowContextValue | null>(null)
/** Provider — used by the body, not by callers. */
export function DataTableGroupRowProvider({
value,
children,
}: {
value: DataTableGroupRowContextValue
children: React.ReactNode
}) {
return (
<DataTableGroupRowContext.Provider value={value}>
{children}
</DataTableGroupRowContext.Provider>
)
}
/**
* Read the group row a custom `<DataTableGroupedRows>` child is rendering for.
*
* @example
* function RegionGroupRow() {
* const { groupLabel, count, isExpanded, toggle } = useDataTableGroupRow()
* return (
* <TableCell colSpan={4} onClick={toggle}>
* {groupLabel} — {count} in region {isExpanded ? "▾" : "▸"}
* </TableCell>
* )
* }
*/
export function useDataTableGroupRow<
TData extends RowData = RowData,
>(): DataTableGroupRowContextValue<TData> {
const ctx = React.useContext(DataTableGroupRowContext)
if (!ctx) {
throw new Error(
"useDataTableGroupRow must be used inside <DataTableGroupedRows>. " +
"Nest your group-row component as its child.",
)
}
return ctx as DataTableGroupRowContextValue<TData>
}
export interface DataTableGroupedRowsProps<TData extends RowData = RowData> {
/**
* Optional replacement for the built-in group row. Composed as a child, not
* passed as a render prop or a callback:
*
* <DataTableGroupedRows>
* <RegionGroupRow />
* </DataTableGroupedRows>
*
* The child renders the row's cells; the body owns the surrounding
* `<TableRow>` so column geometry, parity and data attributes stay
* consistent with the leaf rows. Read the group from `useDataTableGroupRow()`.
*
* Omit it and the built-in row renders: a chevron, the group value, and a
* `· N rows` count, with aggregated cells from each column's
* `aggregatedCell` / `aggregationFn`.
*/
children?: React.ReactNode
/**
* Optional per-group predicate — return `false` to fall back to the built-in
* row for that group. Table-level gating is better expressed by simply not
* nesting this component.
*/
enabledFor?: (row: DataTableRow<TData>) => boolean
}
/**
* Declarative, composable group rows for niko-table bodies.
*
* Nest it inside a `DataTableBody` so grouping reads as part of the table's
* JSX tree. It renders nothing itself — its presence is what turns grouping
* on (feature detection maps it to `enableGrouping` + `enableExpanding`), and
* the body renders a group row wherever TanStack's grouped row model produces
* one.
*
* This is the whole opt-in. There is no `enableGrouping` config flag to set
* and no renderer to wire up: compose the component, or don't.
*
* @example
* // Built-in group row
* <DataTableBody>
* <DataTableGroupedRows />
* </DataTableBody>
*
* @example
* // Your own, reading the group from context
* <DataTableBody>
* <DataTableGroupedRows>
* <RegionGroupRow />
* </DataTableGroupedRows>
* </DataTableBody>
*/
export function DataTableGroupedRows<TData extends RowData = RowData>(
_props: DataTableGroupedRowsProps<TData>,
): null {
return null
}
DataTableGroupedRows.displayName = "DataTableGroupedRows"
/**
* Body-side: find a nested `<DataTableGroupedRows>` among `children`.
*
* Matches on `displayName` as well as reference, because re-exports and HMR
* both break reference equality.
*/
export function resolveGroupedRowsSlot<TData extends RowData = RowData>(
children: React.ReactNode,
): DataTableGroupedRowsProps<TData> | undefined {
let found: DataTableGroupedRowsProps<TData> | undefined
React.Children.forEach(children, child => {
if (!React.isValidElement(child)) return
const type = child.type as { displayName?: string }
if (
child.type === DataTableGroupedRows ||
type.displayName === "DataTableGroupedRows"
) {
found = child.props as DataTableGroupedRowsProps<TData>
}
})
return found
}
src/components/niko-table/core/data-table-grouped-row.tsx
"use client"
/**
* niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
*
* Before reporting anything: please check the changelog first.
* - In-repo: ./CHANGELOG.md
* - Docs site: https://niko-table.com/changelog
*
* Found a bug or have a fix? Open an issue or PR on GitHub so other
* users (and future LLMs reading this code) benefit:
* https://github.com/Semkoo/niko-table-registry
*/
import { flexRender, type RowData } from "@tanstack/react-table"
import { ChevronRight } from "lucide-react"
import React from "react"
import { Button } from "@/components/ui/button"
import { TableCell, TableRow } from "@/components/ui/table"
import { cn } from "@/lib/utils"
import {
DataTableGroupRowProvider,
useDataTableGroupRow,
type DataTableGroupRowContextValue,
type DataTableGroupedRowsProps,
} from "../components/data-table-grouped-rows"
import { isInteractiveClickTarget } from "../lib/row-click"
import { getCommonPinningStyles } from "../lib/styles"
import type { DataTableRow } from "../types"
/**
* The built-in group row: a chevron, the group value, a `· N rows` count, and
* one aggregated cell per column that defines `aggregatedCell` /
* `aggregationFn`.
*
* Exported so a custom group row can reuse it — wrap it, or render it for the
* groups you don't want to special-case.
*/
export function DataTableGroupedRowCells() {
const { row, groupLabel, count, isExpanded, toggle, columnWidths } =
useDataTableGroupRow()
const countLabel = `${count} ${count === 1 ? "row" : "rows"}`
return (
<>
{row.getVisibleCells().map(cell => {
const cellStyle = {
width: columnWidths.get(cell.column.id),
...getCommonPinningStyles(cell.column, false),
}
// Placeholder cells stay empty — the `<td>` still renders so the group
// row keeps the same column geometry as the leaf rows beneath it.
let content: React.ReactNode = null
if (cell.getIsGrouped()) {
content = (
<div className="flex min-w-0 items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-expanded={isExpanded}
aria-label={
isExpanded
? `Collapse the ${groupLabel} group`
: `Expand the ${groupLabel} group`
}
onClick={event => {
event.stopPropagation()
toggle()
}}
>
<ChevronRight
className={cn(
"size-3.5 transition-transform",
isExpanded && "rotate-90",
)}
/>
</Button>
<span
className="truncate font-medium"
title={groupLabel || undefined}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
{`· ${countLabel}`}
</span>
</div>
)
} else if (cell.getIsAggregated()) {
content = flexRender(
cell.column.columnDef.aggregatedCell ?? cell.column.columnDef.cell,
cell.getContext(),
)
}
return (
<TableCell
key={cell.id}
data-col-id={cell.column.id}
style={cellStyle}
className={cn(
"truncate",
// A pinned cell must be opaque to occlude what scrolls under it,
// so it carries a flat token rather than the row's translucent tint.
cell.column.getIsPinned() && "bg-muted",
)}
>
{content}
</TableCell>
)
})}
</>
)
}
DataTableGroupedRowCells.displayName = "DataTableGroupedRowCells"
/**
* Row rendered in place of a leaf row wherever TanStack's grouped row model
* produces a group. Internal — reached by composing `<DataTableGroupedRows>`
* into a body, never imported directly by callers.
*/
export interface GroupedBodyRowProps<TData extends RowData = RowData> {
row: DataTableRow<TData>
displayIndex: number
/**
* Passed as a prop (rather than read off `row`) so `React.memo` invalidates
* when the group is toggled — same contract as `BodyRow`.
*/
isExpanded: boolean
columnWidths: ReadonlyMap<string, number | string | undefined>
/** Invalidates React.memo on visibility/order/pinning/resize change. */
columnLayoutSignature: string
/** The nested slot's props, if the caller composed one. */
slot: DataTableGroupedRowsProps<TData> | undefined
/**
* Virtualized bodies only: the virtualizer's measure callback. A band is
* usually a different height from a leaf row, so it has to be measured
* rather than assumed — without this the rows below it drift.
*/
measureRef?: (node: HTMLTableRowElement | null) => void
/**
* Virtualized bodies only: the row's index in the virtual window, which the
* virtualizer reads back off the DOM as `data-index`.
*/
virtualIndex?: number
}
export const GroupedBodyRow = React.memo(function GroupedBodyRow<
TData extends RowData = RowData,
>({
row,
displayIndex,
isExpanded,
columnWidths,
slot,
measureRef,
virtualIndex,
}: GroupedBodyRowProps<TData>) {
const groupedColumnId = row.groupingColumnId
const groupValue = groupedColumnId
? row.getGroupingValue(groupedColumnId)
: undefined
const groupLabel =
groupValue === null || groupValue === undefined ? "" : String(groupValue)
const toggle = React.useCallback(() => {
row.toggleExpanded()
}, [row])
const handleRowClick = React.useCallback(
(event: React.MouseEvent<HTMLTableRowElement>) => {
// The chevron — and anything else interactive a custom row or an
// aggregated cell renders — owns its own click, so the toggle can't
// double-fire.
if (isInteractiveClickTarget(event.target as HTMLElement)) return
// A group row is chrome, not a record: never let it reach the body's
// delegated `onRowClick`.
event.stopPropagation()
row.toggleExpanded()
},
[row],
)
const contextValue = React.useMemo<DataTableGroupRowContextValue<TData>>(
() => ({
row,
groupValue,
groupLabel,
count: row.subRows.length,
isExpanded,
toggle,
displayIndex,
columnWidths,
}),
[
row,
groupValue,
groupLabel,
isExpanded,
toggle,
displayIndex,
columnWidths,
],
)
// A custom child replaces the built-in cells; `enabledFor` lets a table opt
// individual groups back onto the built-in row.
const useCustom =
!!slot?.children && (!slot.enabledFor || slot.enabledFor(row))
return (
<DataTableGroupRowProvider
value={contextValue as DataTableGroupRowContextValue}
>
<TableRow
ref={measureRef}
data-index={virtualIndex}
data-row-index={displayIndex}
data-row-id={row.id}
data-row-type="grouped"
data-expanded={isExpanded ? "true" : undefined}
onClick={handleRowClick}
className="group cursor-pointer bg-muted/40"
>
{useCustom ? slot.children : <DataTableGroupedRowCells />}
</TableRow>
</DataTableGroupRowProvider>
)
}) as <TData extends RowData = RowData>(
props: GroupedBodyRowProps<TData>,
) => React.JSX.Element
src/components/niko-table/components/data-table-row-menu.tsx
"use client"
import * as React from "react"
import {
ContextMenuGroup,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
} from "@/components/ui/context-menu"
import {
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "@/components/ui/dropdown-menu"
/**
* Composable row menu for niko-table — the shadcn-style, write-once way to give
* a table's "…" dropdown and its right-click context menu the *same* actions.
*
* Define the actions ONCE as a component using the polymorphic `RowMenuItem` /
* `RowMenuSeparator` / `RowMenuSub` … pieces and `useDataTableRow()`:
*
* @example
* function TeamRowMenu({ onEdit, onDelete }: TeamRowMenuProps) {
* const team = useDataTableRow<TeamRow>()
* return (
* <>
* <RowMenuItem onClick={() => onEdit(team)}>Edit</RowMenuItem>
* <RowMenuSeparator />
* <RowMenuItem variant="destructive" onClick={() => onDelete(team.id)}>
* Delete
* </RowMenuItem>
* </>
* )
* }
*
* Then drop `<TeamRowMenu … />` into the kebab cell (inside a
* `DataTableRowMenuScope surface="dropdown"`) and into the body's
* `<DataTableRowContextMenuSlot>` — the same component renders as a dropdown
* menu or a context menu depending on the surface it finds in context.
*/
type RowMenuSurface = "dropdown" | "context"
const RowMenuSurfaceContext = React.createContext<RowMenuSurface | null>(null)
/** Sentinel so `useDataTableRow` can tell "no provider" from a row of `undefined`. */
const NO_ROW = Symbol("niko-table.no-row")
const DataTableRowValueContext = React.createContext<unknown>(NO_ROW)
/**
* Provides the current row and the menu surface to the row-menu pieces nested
* inside. The context-menu body sets this up automatically; kebab cells wrap
* their `DropdownMenuContent` children with `surface="dropdown"`.
*/
export function DataTableRowMenuScope({
row,
surface,
children,
}: {
row: unknown
surface: RowMenuSurface
children: React.ReactNode
}) {
return (
<RowMenuSurfaceContext.Provider value={surface}>
<DataTableRowValueContext.Provider value={row}>
{children}
</DataTableRowValueContext.Provider>
</RowMenuSurfaceContext.Provider>
)
}
/** Read the row a row-menu is rendering for. Throws outside a scope. */
export function useDataTableRow<TData>(): TData {
const row = React.useContext(DataTableRowValueContext)
if (row === NO_ROW) {
throw new Error(
'useDataTableRow must be used inside a DataTableRowMenuScope — i.e. a table row context menu, or a kebab DropdownMenuContent wrapped with surface="dropdown".',
)
}
return row as TData
}
function useRowMenuSurface(): RowMenuSurface {
const surface = React.useContext(RowMenuSurfaceContext)
if (!surface) {
throw new Error(
'Row menu pieces (RowMenuItem, RowMenuSub, …) must be rendered inside a DataTableRowMenuScope — i.e. a table row context menu or a kebab DropdownMenuContent wrapped with surface="dropdown".',
)
}
return surface
}
// ---------------------------------------------------------------------------
// Polymorphic pieces — render the dropdown or context primitive by surface.
// ---------------------------------------------------------------------------
export function RowMenuItem(
props: React.ComponentProps<typeof DropdownMenuItem>,
) {
const Item =
useRowMenuSurface() === "context" ? ContextMenuItem : DropdownMenuItem
return <Item {...props} />
}
export function RowMenuSeparator(
props: React.ComponentProps<typeof DropdownMenuSeparator>,
) {
const Separator =
useRowMenuSurface() === "context"
? ContextMenuSeparator
: DropdownMenuSeparator
return <Separator {...props} />
}
export function RowMenuGroup(
props: React.ComponentProps<typeof DropdownMenuGroup>,
) {
const Group =
useRowMenuSurface() === "context" ? ContextMenuGroup : DropdownMenuGroup
return <Group {...props} />
}
export function RowMenuLabel(
props: React.ComponentProps<typeof DropdownMenuLabel>,
) {
const Label =
useRowMenuSurface() === "context" ? ContextMenuLabel : DropdownMenuLabel
return <Label {...props} />
}
export function RowMenuSub(
props: React.ComponentProps<typeof DropdownMenuSub>,
) {
const Sub =
useRowMenuSurface() === "context" ? ContextMenuSub : DropdownMenuSub
return <Sub {...props} />
}
export function RowMenuSubTrigger(
props: React.ComponentProps<typeof DropdownMenuSubTrigger>,
) {
const SubTrigger =
useRowMenuSurface() === "context"
? ContextMenuSubTrigger
: DropdownMenuSubTrigger
return <SubTrigger {...props} />
}
export function RowMenuSubContent(
props: React.ComponentProps<typeof DropdownMenuSubContent>,
) {
const SubContent =
useRowMenuSurface() === "context"
? ContextMenuSubContent
: DropdownMenuSubContent
return <SubContent {...props} />
}
src/components/niko-table/components/data-table-column-header.tsx
"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 { cn } from "@/lib/utils"
import type { DataTableColumn } from "../types"
// ============================================================================
// CONTEXT
// ============================================================================
interface TableColumnHeaderContextValue<TData extends RowData, TValue> {
column: DataTableColumn<TData, TValue>
}
const TableColumnHeaderContext = React.createContext<
TableColumnHeaderContextValue<RowData, unknown> | undefined
>(undefined)
export function useColumnHeaderContext<TData extends RowData, TValue>(
required: true,
): TableColumnHeaderContextValue<TData, TValue>
export function useColumnHeaderContext<TData extends RowData, TValue>(
required: false,
): TableColumnHeaderContextValue<TData, TValue> | undefined
export function useColumnHeaderContext<TData extends RowData, TValue>(
required = true,
) {
const context = React.useContext(TableColumnHeaderContext) as
TableColumnHeaderContextValue<TData, TValue> | undefined
if (required && !context) {
throw new Error(
"useColumnHeaderContext must be used within DataTableColumnHeaderRoot",
)
}
return context
}
// ============================================================================
// CONTEXT PROVIDER
// ============================================================================
/**
* Provider for column header context.
* Used internally by DataTableHeader to provide context to composable header components.
*/
export function DataTableColumnHeaderRoot<TData extends RowData, TValue>({
column,
children,
}: {
column: DataTableColumn<TData, TValue>
children: React.ReactNode
}) {
// Memoize so context subscribers only re-render when `column` identity changes.
const contextValue = React.useMemo(
() =>
({ column }) as unknown as TableColumnHeaderContextValue<
RowData,
unknown
>,
[column],
)
return (
<TableColumnHeaderContext.Provider value={contextValue}>
{children}
</TableColumnHeaderContext.Provider>
)
}
// ============================================================================
// ROOT COMPONENT
// ============================================================================
export type DataTableColumnHeaderProps = React.HTMLAttributes<HTMLDivElement>
/**
* Composable Column Header container.
*/
export function DataTableColumnHeader({
className,
children,
...props
}: DataTableColumnHeaderProps) {
return (
<div
className={cn(
// `min-w-0` lets the header shrink below its content so the title's
// `truncate` engages. Without it, a narrow (resized/auto-fit) column's
// label overflows into the next header cell instead of ellipsizing.
"group flex w-full min-w-0 items-center justify-between gap-1",
className,
)}
{...props}
>
{children}
</div>
)
}
DataTableColumnHeaderRoot.displayName = "DataTableColumnHeaderRoot"
DataTableColumnHeader.displayName = "DataTableColumnHeader"
src/components/niko-table/components/data-table-empty-state.tsx
"use client"
/**
* niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
*
* Before reporting anything: please check the changelog first.
* - In-repo: ./CHANGELOG.md
* - Docs site: https://niko-table.com/changelog
*
* Found a bug or have a fix? Open an issue or PR on GitHub so other
* users (and future LLMs reading this code) benefit:
* https://github.com/Semkoo/niko-table-registry
*/
import React from "react"
import { cn } from "@/lib/utils"
// ============================================================================
// Context for Empty State
// ============================================================================
interface DataTableEmptyStateContextValue {
isFiltered: boolean
}
const DataTableEmptyStateContext =
React.createContext<DataTableEmptyStateContextValue | null>(null)
function useDataTableEmptyState() {
const context = React.useContext(DataTableEmptyStateContext)
if (!context) {
throw new Error(
"Empty state components must be used within DataTableEmptyState",
)
}
return context
}
// ============================================================================
// Empty State Root
// ============================================================================
export interface DataTableEmptyStateProps {
children: React.ReactNode
isFiltered: boolean
className?: string
}
/**
* Root component for empty state composition.
* Provides context to child components about filter state.
*
* @internal - Used by DataTableEmptyBody and DataTableVirtualizedEmptyBody
*/
export function DataTableEmptyState({
children,
isFiltered,
className,
}: DataTableEmptyStateProps) {
return (
<DataTableEmptyStateContext.Provider value={{ isFiltered }}>
<div
className={cn(
"flex flex-col items-center justify-center gap-3 py-4",
className,
)}
>
{children}
</div>
</DataTableEmptyStateContext.Provider>
)
}
// ============================================================================
// Empty State Icon
// ============================================================================
export interface DataTableEmptyIconProps {
children: React.ReactNode
className?: string
}
/**
* Icon component for empty state. Memoized so table-state changes don't re-render it.
*
* @example
* <DataTableEmptyIcon>
* <PackageOpen />
* </DataTableEmptyIcon>
*/
export const DataTableEmptyIcon = React.memo(function DataTableEmptyIcon({
children,
className,
}: DataTableEmptyIconProps) {
return (
<div className={cn("text-muted-foreground/50", className)}>{children}</div>
)
})
DataTableEmptyIcon.displayName = "DataTableEmptyIcon"
// ============================================================================
// Empty State Message
// ============================================================================
export interface DataTableEmptyMessageProps {
children: React.ReactNode
className?: string
}
/**
* Message component for empty state. Memoized; renders only when not filtered.
*
* @example
* <DataTableEmptyMessage>
* <p className="font-semibold">No products found</p>
* <p className="text-sm text-muted-foreground">
* Get started by adding your first product
* </p>
* </DataTableEmptyMessage>
*/
export const DataTableEmptyMessage = React.memo(function DataTableEmptyMessage({
children,
className,
}: DataTableEmptyMessageProps) {
const { isFiltered } = useDataTableEmptyState()
if (isFiltered) return null
return (
<div
className={cn(
"flex flex-col items-center gap-1 text-center text-muted-foreground",
className,
)}
>
{children}
</div>
)
})
DataTableEmptyMessage.displayName = "DataTableEmptyMessage"
// ============================================================================
// Empty State Filtered Message
// ============================================================================
export interface DataTableEmptyFilteredMessageProps {
children: React.ReactNode
className?: string
}
/**
* Filtered-state message — renders only when a filter is active. Memoized.
*
* @example
* <DataTableEmptyFilteredMessage>
* No matches found for your search
* </DataTableEmptyFilteredMessage>
*/
export const DataTableEmptyFilteredMessage = React.memo(
function DataTableEmptyFilteredMessage({
children,
className,
}: DataTableEmptyFilteredMessageProps) {
const { isFiltered } = useDataTableEmptyState()
if (!isFiltered) return null
return (
<div
className={cn(
"flex flex-col items-center gap-1 text-center text-muted-foreground",
className,
)}
>
{children}
</div>
)
},
)
DataTableEmptyFilteredMessage.displayName = "DataTableEmptyFilteredMessage"
// ============================================================================
// Empty State Actions
// ============================================================================
export interface DataTableEmptyActionsProps {
children: React.ReactNode
className?: string
}
/**
* Actions component for empty state.
* Displays action buttons or links (e.g., "Add Item", "Clear Filters").
* Memoized to prevent unnecessary re-renders.
*
* @example
* <DataTableEmptyActions>
* <Button onClick={handleAdd}>Add Product</Button>
* </DataTableEmptyActions>
*/
export const DataTableEmptyActions = React.memo(function DataTableEmptyActions({
children,
className,
}: DataTableEmptyActionsProps) {
return <div className={cn("mt-2 flex gap-2", className)}>{children}</div>
})
DataTableEmptyActions.displayName = "DataTableEmptyActions"
// ============================================================================
// Convenience Components
// ============================================================================
export interface DataTableEmptyTitleProps {
children: React.ReactNode
className?: string
}
/**
* Title component for empty state messages.
* Convenience wrapper for consistent title styling.
* Memoized to prevent unnecessary re-renders.
*
* @example
* <DataTableEmptyMessage>
* <DataTableEmptyTitle>No products found</DataTableEmptyTitle>
* <DataTableEmptyDescription>
* Get started by adding your first product
* </DataTableEmptyDescription>
* </DataTableEmptyMessage>
*/
export const DataTableEmptyTitle = React.memo(function DataTableEmptyTitle({
children,
className,
}: DataTableEmptyTitleProps) {
return <p className={cn("font-semibold", className)}>{children}</p>
})
DataTableEmptyTitle.displayName = "DataTableEmptyTitle"
export interface DataTableEmptyDescriptionProps {
children: React.ReactNode
className?: string
}
/**
* Description component for empty state messages.
* Convenience wrapper for consistent description styling.
* Memoized to prevent unnecessary re-renders.
*
* @example
* <DataTableEmptyMessage>
* <DataTableEmptyTitle>No products found</DataTableEmptyTitle>
* <DataTableEmptyDescription>
* Get started by adding your first product
* </DataTableEmptyDescription>
* </DataTableEmptyMessage>
*/
export const DataTableEmptyDescription = React.memo(
function DataTableEmptyDescription({
children,
className,
}: DataTableEmptyDescriptionProps) {
return (
<p className={cn("text-sm text-muted-foreground", className)}>
{children}
</p>
)
},
)
DataTableEmptyDescription.displayName = "DataTableEmptyDescription"
src/components/niko-table/hooks/use-generated-options.ts
"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 { DataTableInstance, Option } from "../types"
import { formatLabel } from "../lib/format"
import { FILTER_VARIANTS } from "../lib/constants"
import { getFilteredRowsExcludingColumn } from "../lib/filter-rows"
import type { RowData } from "@tanstack/react-table"
export interface GenerateOptionsConfig {
/**
* Whether to include counts for each option label
* @default true
*/
showCounts?: boolean
/**
* If true, recompute counts based on the filtered rows; otherwise use all core rows
* @default true
*/
dynamicCounts?: boolean
/**
* If true, only generate options from filtered rows. If false, generate from all rows.
* This controls which rows are used to generate the option list itself.
* Note: This is separate from dynamicCounts which controls count calculation.
* @default true
*/
limitToFilteredRows?: boolean
/**
* Only generate options for these column ids (if provided)
*/
includeColumns?: string[]
/**
* Exclude these column ids from option generation
*/
excludeColumns?: string[]
/**
* Optional cap on number of options per column (after sorting)
*/
limitPerColumn?: number
/**
* Default merge strategy when a column does not provide meta.mergeStrategy.
* Column-level mergeStrategy still takes precedence.
*/
mergeStrategy?: "preserve" | "augment" | "replace"
}
/**
* Generate a map of options for select/multiSelect columns based on table data.
* Uses either filtered rows (dynamicCounts) or all core rows.
*/
export function useGeneratedOptions<TData extends RowData>(
table: DataTableInstance<TData>,
config: GenerateOptionsConfig = {},
): Record<string, Option[]> {
const {
showCounts = true,
dynamicCounts = true,
limitToFilteredRows = true,
includeColumns,
excludeColumns,
limitPerColumn,
mergeStrategy,
} = config
// Pull state slices to use as memo deps (stable values)
const state = table.state
const columnFilters = state.columnFilters
const globalFilter = state.globalFilter
// `table.options.columns` in deps so updated `meta.options` (e.g. from
// server-side facets) invalidate the memo — `table` ref alone is too stable.
const columns = React.useMemo(
() => table.getAllColumns(),
[table, table.options.columns],
)
// Extract `coreRows` so async-data row-array identity changes drive recompute;
// the `table` ref is stable and would otherwise hold stale (empty) results.
const coreRows = table.getCoreRowModel().rows
// Normalize array deps to stable strings for React hook linting
const includeKey = includeColumns?.join(",") ?? ""
const excludeKey = excludeColumns?.join(",") ?? ""
// Expensive: walks all columns × all rows (~50-100ms at 1k rows × 5 selects).
// Memoize so generation only runs when columns, filters, or config change.
const optionsByColumn = React.useMemo(() => {
const result: Record<string, Option[]> = {}
// Note: row selection is done per-column based on overrides
for (const column of columns) {
const meta = column.columnDef.meta ?? {}
const variant = meta.variant ?? FILTER_VARIANTS.TEXT
// Only generate for select-like variants
if (
variant !== FILTER_VARIANTS.SELECT &&
variant !== FILTER_VARIANTS.MULTI_SELECT
)
continue
const colId = column.id
if (includeColumns && !includeColumns.includes(colId)) continue
if (excludeColumns && excludeColumns.includes(colId)) continue
// Respect per-column overrides
const colAutoOptions = meta.autoOptions ?? true
const colShowCounts = meta.showCounts ?? showCounts
const colDynamicCounts = meta.dynamicCounts ?? dynamicCounts
const colMerge = meta.mergeStrategy ?? mergeStrategy
const colAutoOptionsFormat = meta.autoOptionsFormat ?? true
const colFormatOptionLabel = meta.formatOptionLabel
if (!colAutoOptions) {
result[column.id] = meta.options ?? []
continue
}
// `limitToFilteredRows` selects rows for option discovery; `dynamicCounts`
// selects rows for count computation. Both exclude this column's own
// filter. When both are true, compute once and reuse — was a double walk.
const filteredRowsExcl =
limitToFilteredRows || colDynamicCounts
? getFilteredRowsExcludingColumn(
table,
coreRows,
colId,
columnFilters,
globalFilter,
)
: coreRows
const optionSourceRows = limitToFilteredRows ? filteredRowsExcl : coreRows
const countSourceRows = colDynamicCounts ? filteredRowsExcl : coreRows
// If we have static options with augment strategy, we use static options and only calculate counts
if (meta.options && meta.options.length > 0 && colMerge === "augment") {
// Calculate counts from countSourceRows for all static options
const countMap = new Map<string, number>()
for (const row of countSourceRows) {
const raw = row.getValue(colId as string) as unknown
const values: unknown[] = Array.isArray(raw) ? raw : [raw]
for (const v of values) {
if (v === null || v === undefined) continue
const str = String(v)
if (str.trim() === "") continue
countMap.set(str, (countMap.get(str) ?? 0) + 1)
}
}
// If limitToFilteredRows is true, we should only return static options that have counts > 0
// in the optionSourceRows.
let filteredStaticOptions = meta.options
if (limitToFilteredRows) {
const occurrenceMap = new Map<string, boolean>()
for (const row of optionSourceRows) {
const raw = row.getValue(colId as string) as unknown
const values: unknown[] = Array.isArray(raw) ? raw : [raw]
for (const v of values) {
if (v == null) continue
occurrenceMap.set(String(v), true)
}
}
filteredStaticOptions = meta.options.filter((opt: Option) =>
occurrenceMap.has(opt.value),
)
}
// Fresh `countMap` always wins. The wrapper component mutates
// `meta.options` to inject counts, so on subsequent renders
// `opt.count` here is whatever was pinned last render — using it
// would freeze counts at their first-render value.
// Server-side tables that need true dataset-wide counts should pass
// them through the faceted column-header `options` prop instead,
// where caller-supplied counts are honored without mutation.
result[colId] = filteredStaticOptions.map((opt: Option) => ({
...opt,
count: colShowCounts ? (countMap.get(opt.value) ?? 0) : undefined,
}))
continue
}
// For auto-generated options, discover from optionSourceRows
const optionValues = new Set<string>()
for (const row of optionSourceRows) {
const raw = row.getValue(colId as string) as unknown
// Support array values (multi-select like arrays on the row)
const values: unknown[] = Array.isArray(raw) ? raw : [raw]
for (const v of values) {
if (v === null || v === undefined) continue
const str = String(v)
if (str.trim() === "") continue
optionValues.add(str)
}
}
// If we couldn't derive anything, skip (caller may still have static options)
if (optionValues.size === 0) {
result[colId] = []
continue
}
// Compute counts from countSourceRows
const counts = new Map<string, number>()
for (const row of countSourceRows) {
const raw = row.getValue(colId as string) as unknown
const values: unknown[] = Array.isArray(raw) ? raw : [raw]
for (const v of values) {
if (v === null || v === undefined) continue
const str = String(v)
if (str.trim() === "") continue
if (optionValues.has(str)) {
counts.set(str, (counts.get(str) ?? 0) + 1)
}
}
}
const options: Option[] = Array.from(optionValues)
.map(value => ({
value,
label: colFormatOptionLabel
? colFormatOptionLabel(value)
: colAutoOptionsFormat
? formatLabel(value)
: value,
count: colShowCounts ? (counts.get(value) ?? 0) : undefined,
}))
.sort((a, b) => a.label.localeCompare(b.label))
const finalOptions =
typeof limitPerColumn === "number" && limitPerColumn > 0
? options.slice(0, limitPerColumn)
: options
// If static options exist and strategy is preserve, keep them untouched.
// Per docs, `preserve` returns user-defined options as-is; counts are only
// injected by `augment`. We still respect limitToFilteredRows to hide
// options whose value is not present in the current option-source rows.
if (
meta.options &&
meta.options.length > 0 &&
(!colMerge || colMerge === "preserve")
) {
if (limitToFilteredRows) {
const availableOptions = new Set<string>()
for (const row of optionSourceRows) {
const raw = row.getValue(colId as string) as unknown
const values: unknown[] = Array.isArray(raw) ? raw : [raw]
for (const v of values) {
if (v != null) availableOptions.add(String(v))
}
}
result[colId] = meta.options.filter((opt: Option) =>
availableOptions.has(opt.value),
)
} else {
result[colId] = meta.options
}
continue
}
// Else, replace with generated
result[colId] = finalOptions
}
return result
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
columns,
coreRows,
table,
dynamicCounts,
showCounts,
includeKey,
excludeKey,
limitPerColumn,
mergeStrategy,
limitToFilteredRows,
// Recompute when filters/global filter change to keep counts in sync
columnFilters,
globalFilter,
])
return optionsByColumn
}
/**
* Convenience: generate options only for a specific column id
*/
export function useGeneratedOptionsForColumn<TData extends RowData>(
table: DataTableInstance<TData>,
columnId: string,
config?: GenerateOptionsConfig,
): Option[] {
const map = useGeneratedOptions(table, {
...config,
includeColumns: [columnId],
})
return map[columnId] ?? []
}
src/components/niko-table/lib/constants.ts
/**
* niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
*
* Before reporting anything: please check the changelog first.
* - In-repo: ./CHANGELOG.md
* - Docs site: https://niko-table.com/changelog
*
* Found a bug or have a fix? Open an issue or PR on GitHub so other
* users (and future LLMs reading this code) benefit:
* https://github.com/Semkoo/niko-table-registry
*/
/**
* Data table constants
* @description Centralized constants for the data table components
*/
/**
* Join operator constants for combining multiple filters.
*/
export const JOIN_OPERATORS = {
/** Logical AND (all filters must match) */
AND: "and",
/** Logical OR (any filter can match) */
OR: "or",
/** Mixed logic (combination of AND/OR) */
MIXED: "mixed",
} as const
/**
* Filter operator constants defining the comparison logic.
* Naming follows SQL/PostgREST standards (ilike, eq, ne, etc.).
*/
export const FILTER_OPERATORS = {
/** SQL ILIKE (Case-insensitive search) */
ILIKE: "ilike",
/** SQL NOT ILIKE */
NOT_ILIKE: "not.ilike",
/** SQL EQUAL (=) */
EQ: "eq",
/** SQL NOT EQUAL (!=) */
NEQ: "neq",
/** SQL IN (one of) */
IN: "in",
/** SQL NOT IN (none of) */
NOT_IN: "not.in",
/** Value is null or empty string */
EMPTY: "empty",
/** Value is not null and not empty string */
NOT_EMPTY: "not.empty",
/** SQL LESS THAN (<) */
LT: "lt",
/** SQL LESS THAN OR EQUAL (<=) */
LTE: "lte",
/** SQL GREATER THAN (>) */
GT: "gt",
/** SQL GREATER THAN OR EQUAL (>=) */
GTE: "gte",
/** SQL BETWEEN (range) */
BETWEEN: "between",
/** Relative date calculation (e.g., "today", "last-7-days") */
RELATIVE: "relative",
} as const
/**
* Filter variant constants defining the UI control type.
*/
export const FILTER_VARIANTS = {
/** Standard text input */
TEXT: "text",
/** Numeric input */
NUMBER: "number",
/** Two-value range input */
RANGE: "range",
/** Single date picker */
DATE: "date",
/** Date range picker */
DATE_RANGE: "dateRange",
/** Single select dropdown */
SELECT: "select",
/** Multi-select dropdown */
MULTI_SELECT: "multiSelect",
/** Checkbox or toggle */
BOOLEAN: "boolean",
} as const
// ============================================================================
// DERIVED TYPES
// ============================================================================
/** Join operators for combining multiple filters */
export type JoinOperator = (typeof JOIN_OPERATORS)[keyof typeof JOIN_OPERATORS]
/** Filter operators supported by the data table */
export type FilterOperator =
(typeof FILTER_OPERATORS)[keyof typeof FILTER_OPERATORS]
/** Filter variants supported by the data table (UI control type) */
export type FilterVariant =
(typeof FILTER_VARIANTS)[keyof typeof FILTER_VARIANTS]
// ============================================================================
// DEFAULT VALUES & UI CONFIG
// ============================================================================
/** Global default values */
export const DEFAULT_VALUES = {
JOIN_OPERATOR: JOIN_OPERATORS.AND,
PAGE_SIZE: 10,
PAGE_INDEX: 0,
} as const
/** System column IDs - used for smart pinning and feature detection */
export const SYSTEM_COLUMN_IDS = {
/** Row selection checkbox column */
SELECT: "select",
/** Row expand/collapse column */
EXPAND: "expand",
/** Row actions column (edit, delete, etc.) */
ACTIONS: "actions",
} as const
/** Array of all system column IDs for filtering */
export const SYSTEM_COLUMN_ID_LIST: string[] = [
SYSTEM_COLUMN_IDS.SELECT,
SYSTEM_COLUMN_IDS.EXPAND,
]
/** UI-related constraints and settings */
export const UI_CONSTANTS = {
/** Max characters allowed for a filter ID */
FILTER_ID_MAX_LENGTH: 100,
/** Default max height for scrollable filter popovers */
MAX_FILTER_DISPLAY_HEIGHT: 300,
/** Default debounce delay in milliseconds for search inputs */
DEBOUNCE_DELAY: 300,
} as const
/** Default keyboard shortcut key mappings */
export const KEYBOARD_SHORTCUTS = {
/** Open/Toggle filter menu */
FILTER_TOGGLE: "f",
/** Remove active filter (usually combined with Shift) */
FILTER_REMOVE: "f",
/** Close active UI elements */
ESCAPE: "escape",
/** Confirm or submit active action */
ENTER: "enter",
/** Remove character or navigate back */
BACKSPACE: "backspace",
/** Item deletion */
DELETE: "delete",
} as const
/**
* Default column width bounds when a column omits `minSize` / `maxSize`.
* Shared by `DataTableRoot` (defaultColumn) and the resize handle clamp —
* keep these in constants so root does not import the resize-handle module.
*/
export const DEFAULT_MIN_COLUMN_SIZE = 40
export const DEFAULT_MAX_COLUMN_SIZE = 1000
/** Standard internalized error messages */
export const ERROR_MESSAGES = {
/** Thrown when using the old global operator pattern */
DEPRECATED_GLOBAL_JOIN_OPERATOR:
"Global join operator is deprecated. Use individual filter join operators.",
/** General configuration error */
INVALID_FILTER_CONFIGURATION: "Invalid filter configuration provided.",
/** Thrown when mandatory metadata is missing from columns */
MISSING_COLUMN_META: "Column metadata is required for filtering.",
} as const
src/components/niko-table/lib/filter-rows.ts
/**
* 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"
/**
* Get filtered rows excluding a specific column's filter.
* This is useful when generating options for a column - we want to see
* options that exist in the filtered dataset (from other filters) but
* not be limited by the current column's own filter.
*/
export function getFilteredRowsExcludingColumn<TData extends RowData>(
table: DataTableInstance<TData>,
coreRows: DataTableRow<TData>[],
excludeColumnId: string,
columnFilters: Array<{ id: string; value: unknown }>,
globalFilter: unknown,
): DataTableRow<TData>[] {
// Filter out the current column's filter
const otherFilters = columnFilters.filter(
filter => filter.id !== excludeColumnId,
)
// If no filters to apply (excluding the current column), return core rows
if (otherFilters.length === 0 && !globalFilter) {
return coreRows
}
// Set of real column ids (leaf columns, including hidden accessor columns).
// `columnFilters` can carry filter ids that have no matching client column
// (for example a filter resolved entirely server-side). Those are skipped
// below — checking membership here avoids TanStack's `table.getColumn` dev
// warning ("Column with id 'x' does not exist") that fires before the
// `!column` guard would catch it.
const columnIds = new Set(table.getAllLeafColumns().map(c => c.id))
// Filter rows manually, excluding the current column's filter
return coreRows.filter(row => {
// Apply column filters (excluding the current column)
for (const filter of otherFilters) {
if (!columnIds.has(filter.id)) continue
const column = table.getColumn(filter.id)
if (!column) continue
// `getFilterFn` resolves the column's `filterFn` through the registry
// declared on `tableFeatures({ filterFns })`, so string names and inline
// functions both come back as a callable.
const filterFn = column.getFilterFn()
if (filterFn && !filterFn(row, filter.id, filter.value, () => {})) {
return false
}
}
// Apply global filter if present
if (globalFilter) {
const globalFilterFn = table.getGlobalFilterFn()
if (
globalFilterFn &&
!globalFilterFn(row, "global", globalFilter, () => {})
) {
return false
}
}
return true
})
}
src/components/niko-table/lib/create-scroll-handler.ts
/**
* 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
*/
/**
* Snapshot of the scroll container at the moment of a scroll event.
* Defined here (not on a body structure file) so the non-virtualized core
* does not need to depend on the virtualized package for a shared type.
*/
export interface ScrollEvent {
scrollTop: number
scrollHeight: number
clientHeight: number
isTop: boolean
isBottom: boolean
percentage: number
}
/**
* Builds the body-scroll listener used by every data-table body
* (regular, virtualized, virtualized-DnD ×2). Reads the scroll
* container's metrics on each event and dispatches:
*
* - `onScroll` with a `ScrollEvent` snapshot
* - `onScrolledTop` when at the top
* - `onScrolledBottom` when within `scrollThreshold` of the bottom
*
* The body shipped four byte-for-byte copies of this — extracted so
* scroll-math changes propagate to all four bodies and so consumers
* get one canonical attach point. The returned listener is intended
* to be passed to `addEventListener("scroll", handler, { passive: true })`.
*
* Why a factory instead of `useCallback` per body: the closure deps
* are stable across renders (the consumer-supplied callbacks +
* threshold are passed as args once), and bodies that wrap this in
* a `useEffect` already key on those callbacks via their own deps.
*/
export function createScrollHandler({
onScroll,
onScrolledTop,
onScrolledBottom,
scrollThreshold = 50,
}: {
onScroll?: (event: ScrollEvent) => void
onScrolledTop?: () => void
onScrolledBottom?: () => void
scrollThreshold?: number
}): (event: Event) => void {
/**
* Edge-transition gating only — no rAF coalescing.
*
* Edge gating: `onScrolledTop` / `onScrolledBottom` previously fired on
* EVERY scroll event while sitting at the edge — pinning at the bottom
* while data streamed in re-fired `onScrolledBottom` dozens of times per
* second. Tracking previous edge state limits firing to the leading edge
* (false→true) and is the actual source of the perf win.
*
* No rAF: iOS Safari pauses `requestAnimationFrame` during momentum
* scroll, which delayed edge callbacks until the finger lifted and
* momentum settled — broke infinite-scroll triggering on touch.
* Dispatch synchronously instead; the scroll math is cheap and edge
* gating already prevents redundant consumer renders. rAF is better
* reserved for DOM writes (row virtualization translateY), not
* consumer callbacks.
*/
let prevAtTop = false
let prevAtBottom = false
return (event: Event) => {
const element = event.currentTarget as HTMLDivElement | null
if (!element) return
const { scrollHeight, scrollTop, clientHeight } = element
const isTop = scrollTop === 0
const isBottom = scrollHeight - scrollTop - clientHeight < scrollThreshold
const percentage =
scrollHeight - clientHeight > 0
? (scrollTop / (scrollHeight - clientHeight)) * 100
: 0
onScroll?.({
scrollTop,
scrollHeight,
clientHeight,
isTop,
isBottom,
percentage,
})
if (isTop && !prevAtTop) onScrolledTop?.()
if (isBottom && !prevAtBottom) onScrolledBottom?.()
prevAtTop = isTop
prevAtBottom = isBottom
}
}
src/components/niko-table/lib/row-click.ts
/**
* niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
*
* Shared row-click guard. Centralized so the interactive-element list
* stays in sync across all body variants.
*/
import type { RowData } from "@tanstack/react-table"
import type { DataTableInstance } from "../types"
/**
* Returns `true` when the click landed on (or inside) an interactive
* element that should suppress `onRowClick`.
*
* Also suppresses when the user has an active text selection — accidental
* row clicks while drag-selecting cell text are a common annoyance,
* especially on rows that navigate to a detail view on click.
*/
export function isInteractiveClickTarget(target: HTMLElement): boolean {
// Drag-selected text: a click that ends a selection should not navigate.
// `window.getSelection` may be undefined in some environments (jsdom
// edge cases / non-browser SSR), so guard both shapes.
const selection =
typeof window !== "undefined" ? window.getSelection?.() : null
if (selection && !selection.isCollapsed && selection.toString().length > 0) {
return true
}
return Boolean(
target.closest("button") ||
target.closest("input") ||
target.closest("textarea") ||
target.closest("select") ||
target.closest("a") ||
target.closest("label") ||
target.closest("[contenteditable]") ||
target.closest('[role="button"]') ||
target.closest('[role="checkbox"]') ||
target.closest('[role="combobox"]') ||
target.closest('[role="menuitem"]') ||
target.closest('[role="textbox"]') ||
target.closest("[data-radix-collection-item]") ||
target.closest('[data-slot="checkbox"]') ||
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT" ||
target.tagName === "BUTTON" ||
target.tagName === "A",
)
}
/**
* For delegated `<tbody>` handlers. Returns the matched row, or `null`
* if the click should be ignored (interactive target or no row found).
*/
export function resolveRowFromClick<TData extends RowData>(
target: HTMLElement,
table: DataTableInstance<TData>,
) {
if (isInteractiveClickTarget(target)) return null
const rowEl = target.closest("tr[data-row-id]")
if (!rowEl) return null
const rowId = rowEl.getAttribute("data-row-id")
if (rowId === null) return null
return table.getRow(rowId) ?? null
}
src/components/niko-table/lib/render-cell-content.tsx
/**
* niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
*
* Before reporting anything: please check the changelog first.
* - In-repo: ./CHANGELOG.md
* - Docs site: https://niko-table.com/changelog
*
* Found a bug or have a fix? Open an issue or PR on GitHub so other
* users (and future LLMs reading this code) benefit:
* https://github.com/Semkoo/niko-table-registry
*/
import { flexRender, type RowData } from "@tanstack/react-table"
import { ChevronDown, ChevronRight } from "lucide-react"
import type { ReactNode } from "react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import type { DataTableCell } from "../types"
/**
* Renders a body cell with TanStack grouping / aggregation awareness.
*
* - Grouped column cell: expand/collapse control + value + leaf count
* - Aggregated cell: `aggregatedCell` (falls back to `cell`)
* - Placeholder cell: empty (grouped columns leave gaps on leaf rows)
* - Otherwise: normal `cell` render
*
* Used by every body structure so grouping works the same for regular,
* virtualized, and DnD tables.
*/
export function renderCellContent<TData extends RowData, TValue>(
cell: DataTableCell<TData, TValue>,
): ReactNode {
const context = cell.getContext()
// Cell context holds the core table, where React `useTable`'s `.state` isn't
// attached. `atoms.<slice>.get()` is the snapshot read that works on both,
// and is optional because a slice only exists once its feature is registered.
const grouping = context.table.atoms.grouping?.get() ?? []
// Only branch when a grouping is actually applied. `getIsAggregated()` is
// also true for any parent row produced by `getSubRows` (tree tables), and
// TanStack ships a default `aggregatedCell` that stringifies the value — so
// an unguarded aggregate branch replaces those rows' real cells.
if (grouping.length === 0) {
return flexRender(cell.column.columnDef.cell, context)
}
if (cell.getIsPlaceholder()) {
return null
}
if (cell.getIsAggregated()) {
return flexRender(
cell.column.columnDef.aggregatedCell ?? cell.column.columnDef.cell,
context,
)
}
if (cell.getIsGrouped()) {
const row = cell.row
const canExpand = row.getCanExpand()
const isExpanded = row.getIsExpanded()
const leafCount = row.subRows.length
return (
<div className="flex min-w-0 items-center gap-1">
{canExpand ? (
<Button
type="button"
variant="ghost"
size="icon"
className={cn("size-7 shrink-0", "dark:text-muted-foreground")}
aria-expanded={isExpanded}
aria-label={isExpanded ? "Collapse group" : "Expand group"}
onClick={row.getToggleExpandedHandler()}
>
{isExpanded ? (
<ChevronDown className="size-4" aria-hidden />
) : (
<ChevronRight className="size-4" aria-hidden />
)}
</Button>
) : null}
<span className="truncate">
{flexRender(cell.column.columnDef.cell, context)}
</span>
<span className="shrink-0 text-muted-foreground tabular-nums">
({leafCount})
</span>
</div>
)
}
return flexRender(cell.column.columnDef.cell, context)
}
src/components/niko-table/lib/format.ts
/**
* niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
*
* Before reporting anything: please check the changelog first.
* - In-repo: ./CHANGELOG.md
* - Docs site: https://niko-table.com/changelog
*
* Found a bug or have a fix? Open an issue or PR on GitHub so other
* users (and future LLMs reading this code) benefit:
* https://github.com/Semkoo/niko-table-registry
*/
export function formatDate(
date: Date | string | number | undefined,
opts: Intl.DateTimeFormatOptions = {},
) {
if (!date) return ""
try {
return new Intl.DateTimeFormat("en-US", {
month: opts.month ?? "long",
day: opts.day ?? "numeric",
year: opts.year ?? "numeric",
...opts,
}).format(new Date(date))
} catch {
return ""
}
}
/**
* Format a value into a human-readable label.
* Capitalizes first letter of each word and replaces hyphens/underscores with spaces.
*
* @example
* formatLabel("firstName") // "FirstName"
* formatLabel("first-name") // "First Name"
* formatLabel("first_name") // "First Name"
* formatLabel("true") // "Yes"
* formatLabel("false") // "No"
*/
export function formatLabel(value: string): string {
// Handle boolean values
if (value === "true") return "Yes"
if (value === "false") return "No"
return value
.replace(/[-_]/g, " ")
.split(" ")
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ")
}
/**
* Create a date relative to a fixed epoch by subtracting days.
* Deterministic — safe for mock data / SSR hydration (no `Date.now()`).
*
* @param days - Number of days to subtract from the mock epoch
* @returns Date object representing the date N days before the epoch
*
* @example
* daysAgo(7) // 7 days before 2026-07-15
* daysAgo(30) // 30 days before 2026-07-15
*/
const MOCK_EPOCH = new Date("2026-07-15T12:00:00.000Z")
export function daysAgo(days: number): Date {
const date = new Date(MOCK_EPOCH)
date.setUTCDate(date.getUTCDate() - days)
return date
}
/**
* Format URL query parameters into a human-readable query string for display.
* Decodes URL-encoded values and formats JSON objects in a readable way.
*
* @param urlParams - The parsed URL parameters object
* @param urlKeys - Mapping of parameter keys to URL query keys
* @returns Formatted query string (e.g., `?search=i&global={"filters":[...]}`)
*
* @example
* ```ts
* const urlParams = { search: "i", globalFilter: { filters: [...], joinOperator: "mixed" } }
* const urlKeys = { search: "search", globalFilter: "global" }
* formatQueryString(urlParams, urlKeys)
* // Returns: "?search=i&global={"filters":[...], "joinOperator":"mixed"}"
* ```
*/
export function formatQueryString(
urlParams: Record<string, unknown>,
urlKeys: Record<string, string>,
): string {
const parts: string[] = []
// Helper to format JSON compactly for display (but show full for global filter)
const formatJson = (obj: unknown, showFull = false): string => {
try {
if (showFull) {
// For global filter, show full JSON
return JSON.stringify(obj)
}
const str = JSON.stringify(obj)
// For short values, return as-is
if (str.length <= 80) {
return str
}
// For arrays, show count
if (Array.isArray(obj) && obj.length > 0) {
return `[{...}] (${obj.length} items)`
}
// For objects, show structure
if (typeof obj === "object" && obj !== null) {
const keys = Object.keys(obj)
if (keys.length > 0) {
const firstKey = keys[0] ?? ""
const firstValue = (obj as Record<string, unknown>)[firstKey]
if (Array.isArray(firstValue)) {
return `{${firstKey}: [...], ...}`
}
if (typeof firstValue === "object" && firstValue !== null) {
return `{${firstKey}: {...}, ...}`
}
return `{${firstKey}: ${String(firstValue)}, ...}`
}
}
// Fallback: truncate long strings
return str.length > 100 ? `${str.slice(0, 100)}...` : str
} catch {
return String(obj)
}
}
// Add all non-empty params using the URL key mapping
if (urlParams.pageIndex !== undefined && urlParams.pageIndex !== 0) {
parts.push(`${urlKeys.pageIndex}=${urlParams.pageIndex}`)
}
if (urlParams.pageSize !== undefined && urlParams.pageSize !== 10) {
parts.push(`${urlKeys.pageSize}=${urlParams.pageSize}`)
}
if (
urlParams.sort &&
Array.isArray(urlParams.sort) &&
urlParams.sort.length > 0
) {
parts.push(`${urlKeys.sort}=${formatJson(urlParams.sort)}`)
}
if (
urlParams.filters &&
Array.isArray(urlParams.filters) &&
urlParams.filters.length > 0
) {
// Show full JSON for filters
parts.push(`${urlKeys.filters}=${formatJson(urlParams.filters, true)}`)
}
if (urlParams.search && typeof urlParams.search === "string") {
parts.push(`${urlKeys.search}=${urlParams.search}`)
}
// Only include globalFilter if it's an object (complex filters)
// Show full JSON for global filter
if (
urlParams.globalFilter &&
typeof urlParams.globalFilter === "object" &&
urlParams.globalFilter !== null &&
"filters" in urlParams.globalFilter
) {
parts.push(
`${urlKeys.globalFilter}=${formatJson(urlParams.globalFilter, true)}`,
)
}
if (
urlParams.columnVisibility &&
typeof urlParams.columnVisibility === "object" &&
urlParams.columnVisibility !== null &&
Object.keys(urlParams.columnVisibility).length > 0
) {
parts.push(
`${urlKeys.columnVisibility}=${formatJson(urlParams.columnVisibility)}`,
)
}
if (
urlParams.inlineFilters &&
Array.isArray(urlParams.inlineFilters) &&
urlParams.inlineFilters.length > 0
) {
parts.push(
`${urlKeys.inlineFilters}=${formatJson(urlParams.inlineFilters)}`,
)
}
if (urlParams.filterMode && urlParams.filterMode !== "standard") {
parts.push(`${urlKeys.filterMode}=${urlParams.filterMode}`)
}
return parts.length > 0 ? `?${parts.join("&")}` : "No query params"
}
src/components/niko-table/lib/styles.ts
/**
* niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
*
* Before reporting anything: please check the changelog first.
* - In-repo: ./CHANGELOG.md
* - Docs site: https://niko-table.com/changelog
*
* Found a bug or have a fix? Open an issue or PR on GitHub so other
* users (and future LLMs reading this code) benefit:
* https://github.com/Semkoo/niko-table-registry
*/
import { type Column, type RowData } from "@tanstack/react-table"
import type { DataTableFeatures } from "./data-table-features"
import type React from "react"
export const getCommonPinningStyles = <TData extends RowData>(
column: Column<DataTableFeatures, TData>,
isHeader: boolean = false,
): React.CSSProperties => {
const isPinned = column.getIsPinned()
if (!isPinned) return {}
const isLeft = isPinned === "start"
const columnSize = column.getSize()
return {
position: "sticky",
top: isHeader ? 0 : undefined,
left: isLeft ? `${column.getStart("start")}px` : undefined,
right: !isLeft ? `${column.getAfter("end")}px` : undefined,
opacity: 1,
width: columnSize,
minWidth: columnSize, // Prevent column from shrinking
maxWidth: columnSize, // Prevent column from growing
flexShrink: 0, // Prevent flex shrinking
// Headers: z-20 to stay above other headers and body.
// Body: z-10 to stay above other body cells.
zIndex: isHeader ? 20 : 10,
backgroundColor: "var(--background)", // Ensure opaque background
// Directional separator on the pinned side — a real inner border (right for
// left-pinned, left for right-pinned) plus a soft shadow so the frozen
// column reads as distinct from scrolling content, even in a grid where
// every cell already has borders. `border-box` keeps it from shifting width.
// Pinned-column separator. Drawn as an INSET box-shadow (part of the sticky
// cell's own paint) rather than a plain border: a border on a sticky cell
// gets covered by scrolled-under content in some browsers, so it vanishes
// once you scroll horizontally. An inset shadow stays put. A soft OUTER
// shadow adds depth. `--border` alone is intentionally very faint (10% white
// in dark mode), so the line is a stronger foreground mix to read clearly.
...(isLeft
? {
boxShadow:
"inset -2px 0 0 0 color-mix(in oklab, var(--foreground) 40%, transparent), 4px 0 8px -2px color-mix(in oklab, var(--foreground) 14%, transparent)",
}
: {
boxShadow:
"inset 2px 0 0 0 color-mix(in oklab, var(--foreground) 40%, transparent), -4px 0 8px -2px color-mix(in oklab, var(--foreground) 14%, transparent)",
}),
}
}
src/components/niko-table/config/data-table.ts
/**
* niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
*
* Before reporting anything: please check the changelog first.
* - In-repo: ./CHANGELOG.md
* - Docs site: https://niko-table.com/changelog
*
* Found a bug or have a fix? Open an issue or PR on GitHub so other
* users (and future LLMs reading this code) benefit:
* https://github.com/Semkoo/niko-table-registry
*/
/**
* @file Data Table Configuration
* Defines runtime configuration values for the Data Table component.
* This includes filter operators, sort icons, and other constants.
*/
import type { LucideIcon } from "lucide-react"
import {
ArrowDownAZ,
ArrowDownZA,
ArrowDown01,
ArrowDown10,
ArrowUpDown,
Calendar,
Check,
X as XIcon,
} from "lucide-react"
import {
JOIN_OPERATORS,
FILTER_OPERATORS,
FILTER_VARIANTS,
type JoinOperator,
type FilterOperator,
type FilterVariant,
} from "../lib/constants"
export type SortIconVariant = FilterVariant
interface SortIcons {
asc: LucideIcon
desc: LucideIcon
unsorted: LucideIcon
}
/**
* A mapped type rather than an interface so it can be passed as a Select's
* `items` label map (interfaces carry no implicit index signature).
*/
type SortLabels = Record<"asc" | "desc", string>
export const SORT_ICONS: Record<SortIconVariant, SortIcons> = {
[FILTER_VARIANTS.TEXT]: {
asc: ArrowDownAZ,
desc: ArrowDownZA,
unsorted: ArrowUpDown,
},
[FILTER_VARIANTS.NUMBER]: {
asc: ArrowDown01,
desc: ArrowDown10,
unsorted: ArrowUpDown,
},
[FILTER_VARIANTS.RANGE]: {
asc: ArrowDown01,
desc: ArrowDown10,
unsorted: ArrowUpDown,
},
[FILTER_VARIANTS.DATE]: {
asc: ArrowUpDown,
desc: ArrowUpDown,
unsorted: Calendar,
},
[FILTER_VARIANTS.DATE_RANGE]: {
asc: ArrowUpDown,
desc: ArrowUpDown,
unsorted: Calendar,
},
[FILTER_VARIANTS.BOOLEAN]: {
asc: XIcon, // False First
desc: Check, // True First
unsorted: ArrowUpDown,
},
[FILTER_VARIANTS.SELECT]: {
asc: ArrowDownAZ,
desc: ArrowDownZA,
unsorted: ArrowUpDown,
},
[FILTER_VARIANTS.MULTI_SELECT]: {
asc: ArrowDownAZ,
desc: ArrowDownZA,
unsorted: ArrowUpDown,
},
}
export const SORT_LABELS: Record<SortIconVariant, SortLabels> = {
[FILTER_VARIANTS.TEXT]: {
asc: "Asc",
desc: "Desc",
},
[FILTER_VARIANTS.NUMBER]: {
asc: "Low to High",
desc: "High to Low",
},
[FILTER_VARIANTS.RANGE]: {
asc: "Low to High",
desc: "High to Low",
},
[FILTER_VARIANTS.DATE]: {
asc: "Oldest First",
desc: "Newest First",
},
[FILTER_VARIANTS.DATE_RANGE]: {
asc: "Oldest First",
desc: "Newest First",
},
[FILTER_VARIANTS.BOOLEAN]: {
asc: "False First",
desc: "True First",
},
[FILTER_VARIANTS.SELECT]: {
asc: "Asc",
desc: "Desc",
},
[FILTER_VARIANTS.MULTI_SELECT]: {
asc: "Asc",
desc: "Desc",
},
}
/**
* @credit Adapted from React Table's default config
* @see https://react-table.tanstack.com/docs/overview
*/
export const dataTableConfig = {
debounceMs: 300,
throttleMs: 50,
textOperators: [
{ label: "Contains", value: FILTER_OPERATORS.ILIKE },
{ label: "Does not contain", value: FILTER_OPERATORS.NOT_ILIKE },
{ label: "Is", value: FILTER_OPERATORS.EQ },
{ label: "Is not", value: FILTER_OPERATORS.NEQ },
{ label: "Is empty", value: FILTER_OPERATORS.EMPTY },
{ label: "Is not empty", value: FILTER_OPERATORS.NOT_EMPTY },
] satisfies { label: string; value: FilterOperator }[],
numericOperators: [
{ label: "Is", value: FILTER_OPERATORS.EQ },
{ label: "Is not", value: FILTER_OPERATORS.NEQ },
{ label: "Is less than", value: FILTER_OPERATORS.LT },
{
label: "Is less than or equal to",
value: FILTER_OPERATORS.LTE,
},
{ label: "Is greater than", value: FILTER_OPERATORS.GT },
{
label: "Is greater than or equal to",
value: FILTER_OPERATORS.GTE,
},
{ label: "Is between", value: FILTER_OPERATORS.BETWEEN },
{ label: "Is empty", value: FILTER_OPERATORS.EMPTY },
{ label: "Is not empty", value: FILTER_OPERATORS.NOT_EMPTY },
] satisfies { label: string; value: FilterOperator }[],
dateOperators: [
{ label: "Is", value: FILTER_OPERATORS.EQ },
{ label: "Is not", value: FILTER_OPERATORS.NEQ },
{ label: "Is before", value: FILTER_OPERATORS.LT },
{ label: "Is after", value: FILTER_OPERATORS.GT },
{ label: "Is on or before", value: FILTER_OPERATORS.LTE },
{ label: "Is on or after", value: FILTER_OPERATORS.GTE },
{ label: "Is between", value: FILTER_OPERATORS.BETWEEN },
// FILTER_OPERATORS.RELATIVE hidden until its filter case ships in
// `lib/filter-functions.ts`. The enum stays in the catalogue for
// server-side consumers — re-add this entry when wiring the logic.
{ label: "Is empty", value: FILTER_OPERATORS.EMPTY },
{ label: "Is not empty", value: FILTER_OPERATORS.NOT_EMPTY },
] satisfies { label: string; value: FilterOperator }[],
selectOperators: [
{ label: "Is", value: FILTER_OPERATORS.EQ },
{ label: "Is not", value: FILTER_OPERATORS.NEQ },
{ label: "Is empty", value: FILTER_OPERATORS.EMPTY },
{ label: "Is not empty", value: FILTER_OPERATORS.NOT_EMPTY },
] satisfies { label: string; value: FilterOperator }[],
multiSelectOperators: [
{ label: "Has any of", value: FILTER_OPERATORS.IN },
{ label: "Has none of", value: FILTER_OPERATORS.NOT_IN },
{ label: "Is empty", value: FILTER_OPERATORS.EMPTY },
{ label: "Is not empty", value: FILTER_OPERATORS.NOT_EMPTY },
] satisfies { label: string; value: FilterOperator }[],
booleanOperators: [
{ label: "Is", value: FILTER_OPERATORS.EQ },
{ label: "Is not", value: FILTER_OPERATORS.NEQ },
] satisfies { label: string; value: FilterOperator }[],
/**
* Choices for a boolean filter's value select. Also handed to the select as
* `items`, where Base UI reads the closed trigger's label from.
*/
booleanValues: [
{ label: "True", value: "true" },
{ label: "False", value: "false" },
],
sortOrders: [
{ label: "Asc", value: "asc" as const },
{ label: "Desc", value: "desc" as const },
],
filterVariants: [
FILTER_VARIANTS.TEXT,
FILTER_VARIANTS.NUMBER,
FILTER_VARIANTS.RANGE,
FILTER_VARIANTS.DATE,
FILTER_VARIANTS.DATE_RANGE,
FILTER_VARIANTS.BOOLEAN,
FILTER_VARIANTS.SELECT,
FILTER_VARIANTS.MULTI_SELECT,
] satisfies FilterVariant[],
operators: [
FILTER_OPERATORS.ILIKE,
FILTER_OPERATORS.NOT_ILIKE,
FILTER_OPERATORS.EQ,
FILTER_OPERATORS.NEQ,
FILTER_OPERATORS.IN,
FILTER_OPERATORS.NOT_IN,
FILTER_OPERATORS.EMPTY,
FILTER_OPERATORS.NOT_EMPTY,
FILTER_OPERATORS.LT,
FILTER_OPERATORS.LTE,
FILTER_OPERATORS.GT,
FILTER_OPERATORS.GTE,
FILTER_OPERATORS.BETWEEN,
FILTER_OPERATORS.RELATIVE,
] satisfies FilterOperator[],
joinOperators: [
JOIN_OPERATORS.AND,
JOIN_OPERATORS.OR,
] satisfies JoinOperator[],
} as const
export type DataTableConfig = typeof dataTableConfig
src/components/niko-table/lib/filter-functions.ts
/**
* niko-table — created by Semir N. (Semkoo, https://github.com/Semkoo) with AI assistance.
*
* Before reporting anything: please check the changelog first.
* - In-repo: ./CHANGELOG.md
* - Docs site: https://niko-table.com/changelog
*
* Found a bug or have a fix? Open an issue or PR on GitHub so other
* users (and future LLMs reading this code) benefit:
* https://github.com/Semkoo/niko-table-registry
*/
import type { FilterFn, RowData, TableFeatures } from "@tanstack/react-table"
import type { ExtendedColumnFilter, FilterOperator } from "../types"
import { JOIN_OPERATORS, FILTER_OPERATORS, FILTER_VARIANTS } from "./constants"
/**
* Feature-agnostic filter fn typing for shared Niko filter helpers: these only
* read the row, so they stay assignable whatever feature set a table registers.
*/
type AnyFilterFn<TData extends RowData = RowData> = FilterFn<
TableFeatures,
TData
>
// ============================================================================
// Regex Cache for Performance
// ============================================================================
// LRU-style regex cache. At 1k rows × 10 cols, naive `new RegExp` per cell
// burns 100-500ms per keystroke; reuse drops it to 5-20ms.
const regexCache = new Map<string, RegExp>()
const MAX_REGEX_CACHE_SIZE = 100
// Module-scoped guard so the RELATIVE-not-implemented warning fires once,
// not once per row × filter (would emit thousands of lines).
let hasLoggedRelativeFilterWarning = false
// LRU-evicted regex cache lookup.
function getOrCreateRegex(pattern: string, flags: string): RegExp {
const key = `${pattern}:${flags}`
if (regexCache.has(key)) {
const cachedRegex = regexCache.get(key)
if (cachedRegex !== undefined) {
return cachedRegex
}
}
// Limit cache size to prevent memory leaks
if (regexCache.size >= MAX_REGEX_CACHE_SIZE) {
const firstKey = regexCache.keys().next().value
if (firstKey !== undefined) {
regexCache.delete(firstKey)
}
}
try {
const regex = new RegExp(pattern, flags)
regexCache.set(key, regex)
return regex
} catch {
// Return a regex that matches nothing if pattern is invalid
const fallbackRegex = /(?!)/
regexCache.set(key, fallbackRegex)
return fallbackRegex
}
}
/**
* Custom filter function that handles our extended filter operators
*/
export const extendedFilter: AnyFilterFn = (
row,
columnId,
filterValue,
_addMeta,
) => {
// If no filter value, show all rows
if (!filterValue) return true
// Handle our extended filter format
if (
typeof filterValue === "object" &&
filterValue.operator &&
filterValue.value !== undefined
) {
const filter = filterValue as ExtendedColumnFilter<RowData>
return applyFilterOperator(
row.getValue(columnId),
filter.operator,
filter.value,
)
}
// Handle raw array filter values
if (Array.isArray(filterValue)) {
const cellValue = row.getValue(columnId)
if (cellValue == null) return false
// Handle numeric range arrays [min, max] from slider filters
// Check if both values are numbers - if so, treat as range
if (
filterValue.length === 2 &&
typeof filterValue[0] === "number" &&
typeof filterValue[1] === "number"
) {
const [min, max] = filterValue
const value = Number(cellValue)
if (isNaN(value)) return false
return value >= min && value <= max
}
// Handle string arrays (from TableFacetedFilter with multiple selection)
// When filterValue is an array like ["electronics", "clothing"], check if cell value is in the array
// Case-insensitive comparison for strings
if (typeof cellValue === "string") {
const cellLower = cellValue.toLowerCase()
return filterValue.some(val =>
typeof val === "string"
? val.toLowerCase() === cellLower
: String(val) === cellValue,
)
}
// For non-string types, convert to string for comparison
return filterValue.some(val => String(val) === String(cellValue))
}
// Fallback to default string contains behavior for simple values
const cellValue = row.getValue(columnId)
if (cellValue == null) return false
try {
const cellStr = String(cellValue).toLowerCase()
const filterStr = String(filterValue).toLowerCase()
const escapedFilter = filterStr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const regex = getOrCreateRegex(escapedFilter, "i") // ✅ Use cached regex
return regex.test(cellStr)
} catch {
return String(cellValue)
.toLowerCase()
.includes(String(filterValue).toLowerCase())
}
}
/**
* Global filter with operator precedence. Supports plain string search,
* pure OR, and mixed AND/OR (AND has higher precedence than OR — splits
* filters into OR-separated AND-groups).
*/
/**
* Whether a `globalFilter` value represents a search the reader actually made.
*
* A whitespace-only string does not: it is invisible in the search input, so
* treating it as active makes the UI insist something is filtered while showing
* nothing that could be. The advanced filter payload is an object and always
* counts.
*/
export function isGlobalFilterActive(value: unknown): boolean {
if (typeof value === "string") return value.trim().length > 0
return Boolean(value)
}
export const globalFilter: AnyFilterFn = (
row,
_columnId,
filterValue,
_addMeta,
) => {
// If no filter value, show all rows
if (!filterValue) return true
// Check if this is a complex filter object (from filter menu)
if (
typeof filterValue === "object" &&
filterValue.filters &&
Array.isArray(filterValue.filters)
) {
const filters = filterValue.filters
// Handle different join operator modes
if (filterValue.joinOperator === "or") {
// Pure OR logic: at least one filter must match
return filters.some((filter: ExtendedColumnFilter<RowData>) => {
const cellValue = row.getValue(filter.id)
return applyFilterOperator(
cellValue as string | number | boolean | null | undefined,
filter.operator,
filter.value as string | number | boolean | null | undefined,
)
})
} else if (filterValue.joinOperator === JOIN_OPERATORS.MIXED) {
// Mixed logic: process with proper operator precedence (AND before OR)
if (filters.length === 0) return true
if (filters.length === 1) {
const filter = filters[0]
const cellValue = row.getValue(filter.id)
return applyFilterOperator(
cellValue as string | number | boolean | null | undefined,
filter.operator,
filter.value as string | number | boolean | null | undefined,
)
}
// Apply mathematical precedence: AND has higher precedence than OR
// Split filters into OR-separated groups, then AND within each group
const orGroups: (typeof filters)[] = []
let currentAndGroup: typeof filters = []
// Add first filter to the first AND group
currentAndGroup.push(filters[0])
// Process remaining filters
for (let i = 1; i < filters.length; i++) {
const filter = filters[i]
if (filter.joinOperator === JOIN_OPERATORS.OR) {
// OR breaks the current AND group, start a new one
orGroups.push(currentAndGroup)
currentAndGroup = [filter]
} else {
// AND continues the current group
currentAndGroup.push(filter)
}
}
// Add the last group
orGroups.push(currentAndGroup)
// Evaluate each OR group (AND logic within each group)
const groupResults = orGroups.map(andGroup => {
return andGroup.every((filter: ExtendedColumnFilter<RowData>) => {
const cellValue = row.getValue(filter.id)
return applyFilterOperator(
cellValue as string | number | boolean | null | undefined,
filter.operator,
filter.value as string | number | boolean | null | undefined,
)
})
})
// OR all group results together
return groupResults.some(result => result)
}
// Default to AND logic for other cases
return filters.every((filter: ExtendedColumnFilter<RowData>) => {
const cellValue = row.getValue(filter.id)
return applyFilterOperator(
cellValue as string | number | boolean | null | undefined,
filter.operator,
filter.value as string | number | boolean | null | undefined,
)
})
}
// Regular global search (string search across all columns).
//
// Trimmed BEFORE the pattern is built, not merely to decide whether to
// search. A trailing space is invisible in the input, but the pattern would
// become /bob /i — matching only where a space actually follows — so the
// reader gets an empty table and nothing on screen explains why.
const searchValue = String(filterValue).trim().toLowerCase()
// Whitespace-only: nothing was really searched for, so nothing is filtered.
if (searchValue === "") return true
// Search across all columns that have filtering enabled
return row.getAllCells().some(cell => {
const column = cell.column
// Skip columns that have filtering disabled
if (column.getCanFilter() === false) return false
const cellValue = cell.getValue()
// Skip null/undefined values
if (cellValue == null) return false
try {
// Convert cell value to string and search using regex
const cellStr = String(cellValue).toLowerCase()
const escapedFilter = searchValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const regex = getOrCreateRegex(escapedFilter, "i") // ✅ Use cached regex
return regex.test(cellStr)
} catch {
// Fallback to simple includes if regex fails
return String(cellValue).toLowerCase().includes(searchValue)
}
})
}
/**
* Apply filter operator to a cell value
*/
function applyFilterOperator(
cellValue: string | number | boolean | null | undefined,
operator: FilterOperator,
filterValue: string | number | boolean | null | undefined | string[],
): boolean {
// Handle null/undefined cell values
if (cellValue == null) {
switch (operator) {
case FILTER_OPERATORS.EMPTY:
return true
case FILTER_OPERATORS.NOT_EMPTY:
return false
default:
return false
}
}
// Convert cell value to string for text operations
const cellStr = String(cellValue).toLowerCase()
const filterStr = String(filterValue).toLowerCase()
switch (operator) {
// Text operators
case FILTER_OPERATORS.ILIKE:
try {
// Escape special regex characters in the filter string
const escapedFilter = filterStr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const regex = getOrCreateRegex(escapedFilter, "i") // ✅ Use cached regex
return regex.test(cellStr)
} catch {
// Fallback to simple includes if regex fails
return cellStr.includes(filterStr)
}
case FILTER_OPERATORS.NOT_ILIKE:
try {
// Escape special regex characters in the filter string
const escapedFilter = filterStr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const regex = getOrCreateRegex(escapedFilter, "i") // ✅ Use cached regex
return !regex.test(cellStr)
} catch {
// Fallback to simple includes if regex fails
return !cellStr.includes(filterStr)
}
case FILTER_OPERATORS.EQ:
// Case-insensitive comparison for strings
if (typeof cellValue === "string" && typeof filterValue === "string") {
return cellStr === filterStr
}
// Boolean comparison - convert boolean to string for comparison with string filter values
// This handles cases where cellValue is boolean (true/false) and filterValue is string ("true"/"false")
if (typeof cellValue === "boolean") {
const cellBoolStr = String(cellValue)
return cellBoolStr === String(filterValue)
}
if (typeof filterValue === "boolean") {
const filterBoolStr = String(filterValue)
return filterBoolStr === String(cellValue)
}
// Date comparison - check if cellValue is a Date object
if (
typeof cellValue === "object" &&
cellValue !== null &&
"getTime" in cellValue
) {
const dateCell = (cellValue as { getTime: () => number }).getTime()
const dateFilter = Number(filterValue)
// For date equality, compare dates at day level (midnight to midnight)
if (!isNaN(dateCell) && !isNaN(dateFilter)) {
const cellDate = new Date(dateCell).setHours(0, 0, 0, 0)
const filterDate = new Date(dateFilter).setHours(0, 0, 0, 0)
return cellDate === filterDate
}
}
// Numeric comparison - convert both to numbers
if (typeof cellValue === "number" || typeof filterValue === "number") {
const numCell = Number(cellValue)
const numFilter = Number(filterValue)
// Check for valid numbers before comparing
if (!isNaN(numCell) && !isNaN(numFilter)) {
return numCell === numFilter
}
}
return cellValue === filterValue
case FILTER_OPERATORS.NEQ:
// Case-insensitive comparison for strings
if (typeof cellValue === "string" && typeof filterValue === "string") {
return cellStr !== filterStr
}
// Date comparison - check if cellValue is a Date object
if (
typeof cellValue === "object" &&
cellValue !== null &&
"getTime" in cellValue
) {
const dateCell = (cellValue as { getTime: () => number }).getTime()
const dateFilter = Number(filterValue)
// For date inequality, compare dates at day level (midnight to midnight)
if (!isNaN(dateCell) && !isNaN(dateFilter)) {
const cellDate = new Date(dateCell).setHours(0, 0, 0, 0)
const filterDate = new Date(dateFilter).setHours(0, 0, 0, 0)
return cellDate !== filterDate
}
}
// Numeric comparison - convert both to numbers
if (typeof cellValue === "number" || typeof filterValue === "number") {
const numCell = Number(cellValue)
const numFilter = Number(filterValue)
// Check for valid numbers before comparing
if (!isNaN(numCell) && !isNaN(numFilter)) {
return numCell !== numFilter
}
}
return cellValue !== filterValue
case FILTER_OPERATORS.EMPTY:
// Check for empty strings and whitespace-only strings
if (typeof cellValue === "string") {
return cellValue.trim() === ""
}
return cellValue == null
case FILTER_OPERATORS.NOT_EMPTY:
// Check for non-empty strings (excluding whitespace-only)
if (typeof cellValue === "string") {
return cellValue.trim() !== ""
}
return cellValue != null
// Numeric operators
case FILTER_OPERATORS.LT: {
const numCell = Number(cellValue)
const numFilter = Number(filterValue)
// Check for valid numbers (NaN would make comparison false)
if (isNaN(numCell) || isNaN(numFilter)) return false
return numCell < numFilter
}
case FILTER_OPERATORS.LTE: {
const numCell = Number(cellValue)
const numFilter = Number(filterValue)
if (isNaN(numCell) || isNaN(numFilter)) return false
return numCell <= numFilter
}
case FILTER_OPERATORS.GT: {
const numCell = Number(cellValue)
const numFilter = Number(filterValue)
if (isNaN(numCell) || isNaN(numFilter)) return false
return numCell > numFilter
}
case FILTER_OPERATORS.GTE: {
const numCell = Number(cellValue)
const numFilter = Number(filterValue)
if (isNaN(numCell) || isNaN(numFilter)) return false
return numCell >= numFilter
}
case FILTER_OPERATORS.BETWEEN:
if (Array.isArray(filterValue) && filterValue.length === 2) {
const [min, max] = filterValue
const numValue = Number(cellValue)
const numMin = Number(min)
const numMax = Number(max)
// Validate all numbers are valid
if (isNaN(numValue) || isNaN(numMin) || isNaN(numMax)) return false
return numValue >= numMin && numValue <= numMax
}
return false
// Array operators
case FILTER_OPERATORS.IN:
if (Array.isArray(filterValue)) {
// Handle case-insensitive string comparison
if (typeof cellValue === "string") {
const cellLower = cellValue.toLowerCase()
return filterValue.some(val =>
typeof val === "string"
? val.toLowerCase() === cellLower
: val === cellValue,
)
}
// For non-string types, convert to string for comparison
return filterValue.some(val => String(val) === String(cellValue))
}
return false
case FILTER_OPERATORS.NOT_IN:
if (Array.isArray(filterValue)) {
// Handle case-insensitive string comparison
if (typeof cellValue === "string") {
const cellLower = cellValue.toLowerCase()
return !filterValue.some(val =>
typeof val === "string"
? val.toLowerCase() === cellLower
: val === cellValue,
)
}
// For non-string types, convert to string for comparison
return !filterValue.some(val => String(val) === String(cellValue))
}
return true
// Date operators (basic implementation)
case FILTER_OPERATORS.RELATIVE:
// Not implemented — throw in dev (loud), return no matches in prod
// (safer than silently passing every row).
if (process.env.NODE_ENV !== "production") {
throw new Error(
"FILTER_OPERATORS.RELATIVE is not yet implemented. Either remove the 'Is relative to today' option from the date filter UI or implement this case.",
)
}
if (!hasLoggedRelativeFilterWarning) {
hasLoggedRelativeFilterWarning = true
console.error(
"FILTER_OPERATORS.RELATIVE is not yet implemented — returning no matches in production to avoid silently passing all rows.",
)
}
return false
default:
// Fallback to contains behavior using regex
try {
const escapedFilter = filterStr.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const regex = getOrCreateRegex(escapedFilter, "i") // ✅ Use cached regex
return regex.test(cellStr)
} catch {
return cellStr.includes(filterStr)
}
}
}
/**
* Filter function for number range (slider) filters
* Handles array values [min, max] for range filtering
*/
export const numberRangeFilter: AnyFilterFn = (
row,
columnId,
filterValue,
addMeta,
) => {
if (!filterValue) return true
// Handle ExtendedColumnFilter format
if (
typeof filterValue === "object" &&
filterValue.operator &&
filterValue.value !== undefined
) {
const filter = filterValue as ExtendedColumnFilter<RowData>
return applyFilterOperator(
row.getValue(columnId),
filter.operator,
filter.value,
)
}
// Handle array format [min, max] from slider
if (Array.isArray(filterValue) && filterValue.length === 2) {
const [min, max] = filterValue
const value = Number(row.getValue(columnId))
if (isNaN(value)) return false
const numMin = Number(min)
const numMax = Number(max)
if (isNaN(numMin) || isNaN(numMax)) return false
return value >= numMin && value <= numMax
}
// Fallback to extendedFilter for other formats
return extendedFilter(row, columnId, filterValue, addMeta)
}
/**
* Filter function for date range filters
* Handles both single date (timestamp) and date range [from, to] (timestamps)
*/
export const dateRangeFilter: AnyFilterFn = (
row,
columnId,
filterValue,
addMeta,
) => {
if (!filterValue) return true
// Handle ExtendedColumnFilter format
if (
typeof filterValue === "object" &&
filterValue.operator &&
filterValue.value !== undefined
) {
const filter = filterValue as ExtendedColumnFilter<RowData>
return applyFilterOperator(
row.getValue(columnId),
filter.operator,
filter.value,
)
}
const rowValue = row.getValue(columnId)
if (!rowValue) return false
// Handle Date objects - convert to timestamp
const rowTimestamp =
rowValue instanceof Date
? rowValue.getTime()
: typeof rowValue === "number"
? rowValue
: new Date(rowValue as string).getTime()
if (isNaN(rowTimestamp)) return false
// Handle array format [from, to] from date range picker
if (Array.isArray(filterValue)) {
if (filterValue.length === 2) {
const [from, to] = filterValue
const fromTime = Number(from)
const toTime = Number(to)
if (isNaN(fromTime) || isNaN(toTime)) return false
return rowTimestamp >= fromTime && rowTimestamp <= toTime
}
// Single date in array
if (filterValue.length === 1) {
const dateTime = Number(filterValue[0])
if (isNaN(dateTime)) return false
// Compare dates at day level (midnight to midnight)
const rowDate = new Date(rowTimestamp).setHours(0, 0, 0, 0)
const filterDate = new Date(dateTime).setHours(0, 0, 0, 0)
return rowDate === filterDate
}
}
// Handle single timestamp
if (typeof filterValue === "number") {
// Compare dates at day level (midnight to midnight)
const rowDate = new Date(rowTimestamp).setHours(0, 0, 0, 0)
const filterDate = new Date(filterValue).setHours(0, 0, 0, 0)
return rowDate === filterDate
}
// Fallback to extendedFilter for other formats
return extendedFilter(row, columnId, filterValue, addMeta)
}
/**
* Helper function to create filter value with operator
*
* @param operator - The filter operator to apply
* @param value - The value to filter by
* @returns ExtendedColumnFilter object with default properties
*/
export const createFilterValue = <TData = RowData>(
operator: FilterOperator,
value: string | number | boolean | null | undefined | string[],
): ExtendedColumnFilter<TData> => {
return {
id: "" as Extract<keyof TData, string>, // Will be set by the column
filterId: "", // Will be set by the filter system
operator,
value: value as string | string[],
variant: FILTER_VARIANTS.TEXT, // Default variant
joinOperator: JOIN_OPERATORS.AND, // Default join operator
}
}
// Mixed AND/OR: filters tagged JOIN_OPERATORS.MIXED apply AND-before-OR
// precedence. Pure AND still goes through columnFilters for perf.
src/components/niko-table/lib/data-table-features.ts
/**
* 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
*/
/**
* Canonical TanStack Table v9 feature registration for DataTable.
*
* In Table v8, every behavior was bundled into `useReactTable`. In Table v9 you
* import only what you need — the same principle as this registry: Composable —
* mix and match components (install only the registry pieces you need).
* `DataTable*` components are the UI layer of that idea; this file is the
* engine layer.
*
* `DataTableRoot` passes this object to `useTable`. Feature detection /
* `enable*` flags still decide *when* behavior runs; registration decides
* *whether the API exists at all*. Delete features, row models, and built-in
* `sortFns` / `aggregationFns` your tables never use — you own this file after
* install, just like every other copied component.
*
* Pass `DataTableFeatures` as the first generic to `ColumnDef`, `Column`,
* `Row`, and `ReactTable` so feature-gated APIs typecheck. For column defs,
* `createDataTableColumnHelper<TData>()` is the runtime counterpart — it
* pre-binds that generic the same way `DataTableColumnDef<TData>` does.
*/
import {
aggregationFn_count,
aggregationFn_extent,
aggregationFn_first,
aggregationFn_last,
aggregationFn_max,
aggregationFn_mean,
aggregationFn_median,
aggregationFn_min,
aggregationFn_sum,
aggregationFn_unique,
aggregationFn_uniqueCount,
columnFacetingFeature,
columnFilteringFeature,
columnGroupingFeature,
columnOrderingFeature,
columnPinningFeature,
columnResizingFeature,
columnSizingFeature,
columnVisibilityFeature,
createColumnHelper,
createExpandedRowModel,
createFacetedMinMaxValues,
createFacetedRowModel,
createFacetedUniqueValues,
createFilteredRowModel,
createGroupedRowModel,
createPaginatedRowModel,
createSortedRowModel,
globalFilteringFeature,
rowAggregationFeature,
rowExpandingFeature,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_alphanumericCaseSensitive,
sortFn_basic,
sortFn_datetime,
sortFn_text,
sortFn_textCaseSensitive,
tableFeatures,
type RowData,
} from "@tanstack/react-table"
import {
dateRangeFilter,
extendedFilter,
numberRangeFilter,
} from "./filter-functions"
/**
* Feature groups — the composable unit of the engine layer.
*
* Each group is a plain object holding the features, row models and named
* functions one capability needs. Spread the ones your tables use into
* `tableFeatures({ … })` and the rest never enters the bundle. That is the
* engine-side mirror of installing only the `DataTable*` pieces you need —
* you compose by listing what you want, not by toggling flags.
*
* @example
* // A read-only table: columns, sorting, nothing else.
* export const features = tableFeatures({
* ...coreFeatures,
* ...sortingFeatures,
* })
*/
/** Column visibility, ordering, pinning, sizing and resizing. Always useful. */
export const coreFeatures = {
columnOrderingFeature,
columnPinningFeature,
columnResizingFeature,
columnSizingFeature,
columnVisibilityFeature,
} as const
/** Column + global filtering, faceting, and the row models they need. */
export const filteringFeatures = {
columnFacetingFeature,
columnFilteringFeature,
globalFilteringFeature,
facetedRowModel: createFacetedRowModel(),
facetedUniqueValues: createFacetedUniqueValues(),
facetedMinMaxValues: createFacetedMinMaxValues(),
filteredRowModel: createFilteredRowModel(),
filterFns: {
extended: extendedFilter,
numberRange: numberRangeFilter,
dateRange: dateRangeFilter,
},
} as const
/**
* Sorting, plus the sort functions a column may name in `sortFn`.
*
* `'auto'` resolves only what is registered here, so drop any your tables
* never reference.
*/
export const sortingFeatures = {
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns: {
alphanumeric: sortFn_alphanumeric,
alphanumericCaseSensitive: sortFn_alphanumericCaseSensitive,
basic: sortFn_basic,
datetime: sortFn_datetime,
text: sortFn_text,
textCaseSensitive: sortFn_textCaseSensitive,
},
} as const
/** Row expansion — sub-rows and expanded content. Grouping requires it. */
export const expandingFeatures = {
rowExpandingFeature,
expandedRowModel: createExpandedRowModel(),
} as const
/**
* Grouping and aggregation, plus the aggregation functions a column may name
* in `aggregationFn`. Includes `expandingFeatures`, because a group that
* cannot collapse is not a group.
*
* Pairs with `<DataTableGroupedRows>` on the UI side — compose both, or
* neither.
*/
export const groupingFeatures = {
...expandingFeatures,
columnGroupingFeature,
rowAggregationFeature,
groupedRowModel: createGroupedRowModel(),
aggregationFns: {
count: aggregationFn_count,
extent: aggregationFn_extent,
first: aggregationFn_first,
last: aggregationFn_last,
max: aggregationFn_max,
mean: aggregationFn_mean,
median: aggregationFn_median,
min: aggregationFn_min,
sum: aggregationFn_sum,
unique: aggregationFn_unique,
uniqueCount: aggregationFn_uniqueCount,
},
} as const
/** Client-side pagination. Omit it for server-driven or infinite tables. */
export const paginationFeatures = {
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel(),
} as const
/** Row selection — checkboxes, the selection bar, bulk actions. */
export const selectionFeatures = {
rowSelectionFeature,
} as const
/**
* Batteries-included default: every group above.
*
* It exists so a fresh install and every copy-paste example work with no
* setup. It is a starting point, not a recommendation — replace it with the
* groups your tables actually use and the rest drops out of the bundle.
*/
export const features = tableFeatures({
...coreFeatures,
...filteringFeatures,
...sortingFeatures,
...groupingFeatures,
...paginationFeatures,
...selectionFeatures,
})
export type DataTableFeatures = typeof features
/**
* Pre-bound column helper for DataTable. TanStack v9's `createColumnHelper`
* needs the features generic first (`createColumnHelper<DataTableFeatures, TData>()`);
* this wraps that the same way `DataTableColumnDef<TData>` pre-binds features.
*
* Prefer the helper when you want typed `getValue()` in cells. Prefer a plain
* `DataTableColumnDef<TData>[]` array for dynamic columns and copy-paste examples.
*/
export function createDataTableColumnHelper<TData extends RowData>() {
return createColumnHelper<DataTableFeatures, TData>()
}
src/components/niko-table/types/index.ts
/**
* 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 Cell,
type Column,
type ColumnDef,
type Header,
type ReactTable,
type Row,
type RowData,
type TableFeatures,
} from "@tanstack/react-table"
import {
JOIN_OPERATORS,
FILTER_OPERATORS,
FILTER_VARIANTS,
} from "../lib/constants"
import type { DataTableFeatures } from "../lib/data-table-features"
// ============================================================================
// TANSTACK REACT-TABLE MODULE AUGMENTATION
// ============================================================================
declare module "@tanstack/react-table" {
// TypeScript requires an augmentation to repeat the original type parameters
// verbatim (same names), so `TFeatures` / `TValue` stay declared even though
// only `TData` is referenced below.
/* eslint-disable @typescript-eslint/no-unused-vars */
interface ColumnMeta<
TFeatures extends TableFeatures,
TData extends RowData,
TValue,
> {
// Display
label?: string
placeholder?: string
/**
* Which column absorbs the leftover row width when column resizing is on
* (needs `<DataTableColumnResize />`). Filling is ON BY DEFAULT — the first
* non-pinned data column flexes automatically — so you rarely set this.
*
* - `true` overrides the default to flex THIS column instead (use when the
* first column isn't the one that should grow).
* - `false` opts this column out of being auto-picked.
*
* The flex column renders with no width and absorbs the surplus, so every
* other column keeps its `size` and a trailing actions/menu column pins to
* the right edge. Pure layout — never touches `columnSizing`, so no
* persistence side effects. Turn filling off for a whole table with
* `TableMeta.disableFlexFill`.
*/
flex?: boolean
// Filtering
variant?: FilterVariant
options?: Option[]
range?: [number, number]
/**
* Automatically generate options for select/multiSelect columns if not provided.
* When true and no static `options` exist, generation logic (wrappers / hooks) may supply them.
*/
autoOptions?: boolean
/** Whether to automatically rename option labels using formatLabel. When false, uses raw value as label. */
autoOptionsFormat?: boolean
/**
* Per-column label formatter applied when options are auto-derived from
* row data. Receives the stringified row value and returns the display
* label. Wins over `autoOptionsFormat` when present. Ignored when the
* caller passes explicit `options`.
*/
formatOptionLabel?: (value: string) => string
/** Per-column override for showing counts (falls back to wrapper prop). */
showCounts?: boolean
/** Per-column override for using filtered rows for counts (falls back to wrapper prop). */
dynamicCounts?: boolean
/** Merge strategy override: preserve | augment | replace (falls back to wrapper prop). */
mergeStrategy?: "preserve" | "augment" | "replace"
// Formatting
unit?: string
icon?: React.ComponentType<{ className?: string }>
// Row Expansion
expandedContent?: (row: TData) => React.ReactNode
}
interface TableMeta<TFeatures extends TableFeatures, TData extends RowData> {
joinOperator?: JoinOperator
hasIndividualJoinOperators?: boolean
/**
* Turn off default flex fill for the whole table, so columns size to their
* own widths and the table scrolls horizontally instead of stretching to
* fill. Use for wide, many-column tables meant to scroll.
*
* Set it statically via `<DataTableRoot meta={{ disableFlexFill: true }}>`
* — it's read when table options are built, so toggling it at runtime
* alone won't re-apply until another table state change rebuilds them.
*/
disableFlexFill?: boolean
}
/* eslint-enable @typescript-eslint/no-unused-vars */
}
// ============================================================================
// CORE TYPES
// ============================================================================
export interface Option {
label: string
value: string
count?: number
icon?: React.ComponentType<{ className?: string }>
}
// ============================================================================
// FILTER TYPES
// ============================================================================
import type {
FilterVariant as _FilterVariant,
FilterOperator as _FilterOperator,
JoinOperator as _JoinOperator,
} from "../lib/constants"
export type FilterVariant = _FilterVariant
export type FilterOperator = _FilterOperator
export type JoinOperator = _JoinOperator
/**
* Extended column filter with additional metadata
*/
export interface ExtendedColumnFilter<TData> {
id: Extract<keyof TData, string>
value: string | string[]
variant: FilterVariant
operator: FilterOperator
filterId: string
joinOperator?: JoinOperator // Individual join operator for each filter
// You can extend with additional properties if needed
}
/** Global filter type */
export type GlobalFilter = string | Record<string, unknown>
/**
* Extended column sort (for URL state management)
*/
export interface ExtendedColumnSort<TData> {
id: Extract<keyof TData, string>
desc: boolean
// You can extend with additional properties if needed
}
/**
* Query keys for URL state management
*/
export interface QueryKeys {
page?: string
perPage?: string
sort?: string
filters?: string
joinOperator?: string
// Additional keys can be added as needed
}
// ============================================================================
// COLUMN DEFINITION
// ============================================================================
/**
* Extended column definition for data table
* Inherits all TanStack Table ColumnDef properties
*/
export type DataTableColumnDef<
TData extends RowData,
TValue = unknown,
> = ColumnDef<DataTableFeatures, TData, TValue> & {
// You can extend with additional properties if needed
}
/**
* Mixed column arrays (plain objects and `createDataTableColumnHelper` results).
* Per-column `TValue` varies, so the array uses `any` the same way TanStack's
* `columns` option does.
*/
export type DataTableColumns<TData extends RowData> = DataTableColumnDef<
TData,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
any
>[]
// ============================================================================
// ROW TYPES
// ============================================================================
/**
* Data table row type
* Alias for TanStack Table Row
*/
export type DataTableRow<TData extends RowData> = Row<
DataTableFeatures,
TData
> & {
// You can extend with additional properties if needed
}
export type DataTableColumn<TData extends RowData, TValue = unknown> = Column<
DataTableFeatures,
TData,
TValue
>
export type DataTableHeader<TData extends RowData, TValue = unknown> = Header<
DataTableFeatures,
TData,
TValue
>
export type DataTableCell<TData extends RowData, TValue = unknown> = Cell<
DataTableFeatures,
TData,
TValue
>
export type DataTableInstance<TData extends RowData> = ReactTable<
DataTableFeatures,
TData
> & {
// You can extend with additional properties if needed
}
// ============================================================================
// CONVENIENCE TYPE HELPERS
// ============================================================================
/**
* Convenience type for accessing constant values with better type safety
*/
export type JoinOperatorValues = typeof JOIN_OPERATORS
export type FilterOperatorValues = typeof FILTER_OPERATORS
export type FilterVariantValues = typeof FILTER_VARIANTS
/**
* Utility type to get the literal values from constant objects
*/
export type ValueOf<T> = T[keyof T]
More from niko-table
Data TableblockDataTable AsidecomponentDataTable Clear FiltercomponentDataTable Column Auto-fitcomponentDataTable Column Date FiltercomponentDataTable Column DnDcomponentDataTable Column Faceted FiltercomponentDataTable Column Groupcomponent
Browse all components in this library →