shadimage
niko-table
blockdata-tablesource: ok · hit

Data Table

Full DataTable owner (DataTableRoot, DataTable chrome, feature detection) on top of data-table-core, plus the Table UI primitive via data-table-ui. Prefer this for typical installs; à-la-carte controls depend on data-table-core instead and will not overwrite a customized components/ui/table.tsx.

npx shadcn add "https://niko-table.com/r/data-table.json"
free

Where it goes

data-tablecheckoutfeature-grid

Use cases

dashboardecommerce

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

https://niko-table.com/r/data-table-core.jsonhttps://niko-table.com/r/data-table-ui.jsonalertbuttondropdown-menu

Files16 · source cached 10/14/2026, 11:27:22 AM

src/components/niko-table/core/data-table.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"
import { TableComponent } from "@/components/ui/table"

import { useColumnResizeInfo, useDataTable } from "./data-table-context"

/**
 * Extracts height from Tailwind arbitrary values (e.g., h-[600px], max-h-[400px]).
 * Converts them to inline styles to ensure scroll events work reliably.
 * For other height utilities, use the height/maxHeight props directly.
 */
function parseHeightFromClassName(className?: string) {
  if (!className)
    return { height: undefined, maxHeight: undefined, safeClassName: className }

  const classes = className.split(/\s+/)
  let height: string | undefined
  let maxHeight: string | undefined
  const remainingClasses: string[] = []

  for (const cls of classes) {
    // Match arbitrary values: h-[600px], max-h-[400px]
    const heightMatch = cls.match(/^h-\[([^\]]+)\]$/)
    const maxHeightMatch = cls.match(/^max-h-\[([^\]]+)\]$/)

    if (heightMatch) {
      height = heightMatch[1]
    } else if (maxHeightMatch) {
      maxHeight = maxHeightMatch[1]
    } else {
      remainingClasses.push(cls)
    }
  }

  return {
    height,
    maxHeight,
    safeClassName: remainingClasses.join(" "),
  }
}

export interface DataTableContainerProps {
  children: React.ReactNode
  /**
   * Additional CSS classes for the container.
   * Arbitrary height values (e.g., h-[600px], max-h-[400px]) are automatically extracted
   * and applied as inline styles to ensure scroll event callbacks work reliably.
   * For other height utilities, use the height/maxHeight props directly.
   */
  className?: string
  /**
   * Sets the height of the table container.
   * When provided, enables vertical scrolling and allows DataTableBody/DataTableVirtualizedBody
   * to use onScroll, onScrolledTop, and onScrolledBottom callbacks.
   * Takes precedence over height utilities in className.
   */
  height?: number | string
  /**
   * Sets the maximum height of the table container.
   * Defaults to the height value if not specified.
   * Takes precedence over max-height utilities in className.
   */
  maxHeight?: number | string
}

/**
 * DataTable container component that wraps the table and provides scrolling behavior.
 *
 * @example
 * Without height - table grows with content, no scroll
 * <DataTable>
 *   <DataTableHeader />
 *   <DataTableBody />
 * </DataTable>
 *
 * @example
 * With height prop - enables scrolling and scroll event callbacks
 * <DataTable height={600}>
 *   <DataTableHeader />
 *   <DataTableBody
 *     onScroll={(e) => console.log(`Scrolled ${e.percentage}%`)}
 *     onScrolledBottom={() => console.log('Load more data')}
 *   />
 * </DataTable>
 *
 * @example
 * With arbitrary height in className - automatically extracted and applied as inline style
 * <DataTable className="h-[600px]">
 *   <DataTableBody onScroll={...} />
 * </DataTable>
 *
 * @example
 * Prefer using height prop for better type safety and clarity
 * <DataTable height="600px" className="rounded-lg">
 *   <DataTableBody onScroll={...} />
 * </DataTable>
 */
/**
 * A single vertical guide line that follows the cursor while a column is being
 * resized. Resizing runs in `onEnd` mode, so the columns themselves don't move
 * until the drag ends (that's what keeps heavy tables smooth); this line gives
 * the live "where the edge will land" feedback in the meantime.
 *
 * It subscribes to the dedicated resize-info context, so it — and nothing else
 * in the table — re-renders per pointer move. Positioned in the scroll
 * container's content space so it tracks correctly through horizontal scroll.
 */
function ColumnResizePreviewLine() {
  const { resizingColumnId, deltaOffset } = useColumnResizeInfo()
  const { scrollContainer } = useDataTable()

  if (!resizingColumnId || !scrollContainer) return null

  const escapedId =
    typeof CSS !== "undefined" && CSS.escape
      ? CSS.escape(resizingColumnId)
      : resizingColumnId
  const cell = scrollContainer.querySelector<HTMLElement>(
    `thead [data-col-id="${escapedId}"]`,
  )
  if (!cell) return null

  const containerRect = scrollContainer.getBoundingClientRect()
  const cellRect = cell.getBoundingClientRect()
  // Right edge of the dragged column in the container's scrollable content
  // space, shifted by the live drag delta.
  const left =
    cellRect.right -
    containerRect.left +
    scrollContainer.scrollLeft +
    deltaOffset

  return (
    <div
      aria-hidden
      data-slot="column-resize-preview"
      className="bg-primary pointer-events-none absolute top-0 z-40 w-px"
      style={{ left, height: scrollContainer.scrollHeight }}
    />
  )
}

export function DataTable({
  children,
  className,
  height,
  maxHeight,
}: DataTableContainerProps) {
  // Parse height from className if not provided via props
  const parsed = React.useMemo(
    () => parseHeightFromClassName(className),
    [className],
  )

  const finalHeight = height ?? parsed.height
  const finalMaxHeight = maxHeight ?? parsed.maxHeight ?? finalHeight

  // Register the scroll container so opt-in features (e.g.
  // `<DataTableColumnAutoFit />`) can measure/observe the viewport width.
  const { registerScrollContainer } = useDataTable()

  return (
    <div
      ref={registerScrollContainer}
      data-slot="table-container"
      className={cn(
        "relative w-full overflow-auto rounded-lg border",
        // Custom scrollbar styling to match ScrollArea aesthetic
        // Scrollbar visible but subtle by default, more prominent on hover
        "[&::-webkit-scrollbar]:h-2.5 [&::-webkit-scrollbar]:w-2.5",
        "[&::-webkit-scrollbar-track]:bg-transparent",
        "[&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-border/40",
        "hover:[&::-webkit-scrollbar-thumb]:bg-border",
        "[&::-webkit-scrollbar-thumb:hover]:bg-border/80!",
        // Firefox scrollbar styling
        "scrollbar-thin scrollbar-thumb-border/40 scrollbar-track-transparent",
        "hover:scrollbar-thumb-border",
        parsed.safeClassName,
      )}
      style={{
        height: finalHeight,
        maxHeight: finalMaxHeight,
      }}
    >
      <TableComponent>{children}</TableComponent>
      <ColumnResizePreviewLine />
    </div>
  )
}

DataTable.displayName = "DataTable"
src/components/niko-table/core/data-table-root.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 {
  useTable,
  type ColumnDef,
  type ColumnFiltersState,
  type ColumnOrderState,
  type ColumnPinningState,
  type ColumnSizingState,
  type ColumnVisibilityState,
  type ExpandedState,
  type FilterFn,
  type FilterFnOption,
  type GroupingState,
  type PaginationState,
  type ReactTable,
  type RowData,
  type RowSelectionState,
  type SortingState,
  type TableOptions,
  type Updater,
} from "@tanstack/react-table"
import { TooltipProvider } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import React from "react"
import { detectFeaturesFromChildren } from "../config/feature-detection"
import {
  DEFAULT_MIN_COLUMN_SIZE,
  FILTER_VARIANTS,
  SYSTEM_COLUMN_IDS,
  SYSTEM_COLUMN_ID_LIST,
} from "../lib/constants"
import { features, type DataTableFeatures } from "../lib/data-table-features"
import { globalFilter as globalFilterFn } from "../lib/filter-functions"
import {
  type DataTableColumnDef,
  type DataTableColumns,
  type GlobalFilter,
} from "../types"
import { DataTableProvider } from "./data-table-context"

/**
 * Delay (ms) before a tooltip inside a data table opens. Deliberately long so
 * header help/sort tooltips don't pop while the assigner is scanning or
 * clicking through sorts — they only appear on a considered hover. Scopes to
 * the table via a nested `TooltipProvider`, so action-button tooltips outside
 * the table keep their own (faster) provider delay.
 */
const TABLE_TOOLTIP_DELAY_MS = 1000

/**
 * Dual-generation tooltip delay: Radix's provider reads `delayDuration`,
 * Base UI's reads `delay`, and each ignores the other prop. Spread as an
 * object (not literal attributes) so it typechecks against both shadcn
 * generations and the CLI's Base UI codemod leaves it alone.
 */
const tooltipProviderDelay = {
  delayDuration: TABLE_TOOLTIP_DELAY_MS,
  delay: TABLE_TOOLTIP_DELAY_MS,
}

export interface DataTableConfig {
  // Feature toggles
  enablePagination?: boolean
  enableFilters?: boolean
  enableSorting?: boolean
  enableRowSelection?: boolean
  enableMultiSort?: boolean
  enableGrouping?: boolean
  enableExpanding?: boolean
  /**
   * Enable drag-to-resize column widths (opt-in; off by default so existing
   * tables are unaffected). When on, columns render at `column.getSize()` and a
   * resize handle appears on each resizable header's right edge. Drop
   * `<DataTableColumnResize />` inside the root to enable via feature detection.
   * Double-click a grip to autosize; columns opt out with `enableResizing: false`.
   */
  enableColumnResizing?: boolean
  /**
   * When grouping is active, how grouped columns are placed in the column flow.
   * TanStack default: `'reorder'` (move grouped columns to the start).
   * `'remove'` hides them; `false` leaves them in place.
   */
  groupedColumnMode?: false | "reorder" | "remove"

  // Manual modes (for server-side)
  manualSorting?: boolean
  manualPagination?: boolean
  manualFiltering?: boolean
  pageCount?: number

  // Initial state
  initialPageSize?: number
  initialPageIndex?: number

  // Auto-reset behaviors
  autoResetPageIndex?: boolean
  autoResetExpanded?: boolean
}

interface TableRootProps<TData extends RowData> extends Omit<
  Partial<TableOptions<DataTableFeatures, TData>>,
  // `key` is dropped so React's reserved `key` prop keeps its own semantics
  // — TanStack v9 added a `key` table option that would otherwise force
  // `<DataTableRoot key={…}>` to be a string.
  "columns" | "data" | "getRowId" | "key"
> {
  // Option 1: Pass a pre-configured table instance
  table?: ReactTable<DataTableFeatures, TData>

  // Option 2: Let DataTableRoot create its own table
  columns?: DataTableColumns<TData>
  data?: TData[]

  children: React.ReactNode
  className?: string

  // Configuration object
  config?: DataTableConfig
  getRowId?: (originalRow: TData, index: number) => string

  // Loading state
  isLoading?: boolean

  // Event handlers
  onGlobalFilterChange?: (value: GlobalFilter) => void
  onPaginationChange?: (updater: Updater<PaginationState>) => void
  onSortingChange?: (updater: Updater<SortingState>) => void
  onColumnVisibilityChange?: (updater: Updater<ColumnVisibilityState>) => void
  onColumnFiltersChange?: (updater: Updater<ColumnFiltersState>) => void
  onRowSelectionChange?: (updater: Updater<RowSelectionState>) => void
  onExpandedChange?: (updater: Updater<ExpandedState>) => void
  onGroupingChange?: (updater: Updater<GroupingState>) => void
  onColumnOrderChange?: (updater: Updater<ColumnOrderState>) => void
  onRowSelection?: (selectedRows: TData[]) => void
}

// Internal component that handles hooks for direct props mode
function DataTableRootInternal<TData extends RowData>({
  columns,
  data,
  children,
  className,
  config,
  getRowId,
  isLoading,
  onGlobalFilterChange,
  onPaginationChange,
  onSortingChange,
  onColumnVisibilityChange,
  onColumnFiltersChange,
  onRowSelectionChange,
  onExpandedChange,
  onGroupingChange,
  onColumnOrderChange,
  onColumnPinningChange,
  onColumnSizingChange,
  onRowSelection,
  // Destructured by name so the `tableOptions` memo depends on stable values
  // — depending on the whole `rest` bag invalidated the memo every render
  // and triggered the "state update on a component that hasn't mounted yet"
  // warning under React 19 + Strict Mode + Turbopack HMR.
  state: restState,
  initialState: restInitialState,
  globalFilterFn: restGlobalFilterFn,
  // Lifted out of the passthrough bag: these are emitted after the spread in
  // `tableOptions`, so a consumer-supplied value (server-driven grouping /
  // expansion) must win over the feature-derived default.
  manualGrouping: manualGroupingProp,
  manualExpanding: manualExpandingProp,
  // Spread into `tableOptions` but NOT in the memo deps. Lift any passthrough
  // option that needs to invalidate the memo into the destructure list above.
  ...passthroughTableOptions
}: Omit<TableRootProps<TData>, "table"> & {
  columns: DataTableColumns<TData>
  data: TData[]
}) {
  // Memoize so `columns.some()` only runs when the columns array changes.
  const hasSelectColumn = React.useMemo(
    () => columns?.some(col => col.id === SYSTEM_COLUMN_IDS.SELECT) ?? false,
    [columns],
  )

  const hasExpandColumn = React.useMemo(
    () =>
      columns?.some(
        col =>
          col.id === SYSTEM_COLUMN_IDS.EXPAND ||
          (col.meta &&
            "expandedContent" in col.meta &&
            col.meta.expandedContent),
      ) ?? false,
    [columns],
  )

  // Stable identity prevents downstream memo cascades (detectFeatures,
  // processedColumns, tableOptions) from invalidating each render.
  const finalConfig: DataTableConfig = React.useMemo(
    () => ({
      enablePagination: config?.enablePagination,
      enableFilters: config?.enableFilters,
      enableSorting: config?.enableSorting,
      enableRowSelection: config?.enableRowSelection ?? hasSelectColumn,
      enableMultiSort: config?.enableMultiSort,
      enableGrouping: config?.enableGrouping,
      enableExpanding: config?.enableExpanding ?? hasExpandColumn,
      enableColumnResizing: config?.enableColumnResizing,
      groupedColumnMode: config?.groupedColumnMode,
      manualSorting: config?.manualSorting,
      manualPagination: config?.manualPagination,
      manualFiltering: config?.manualFiltering,
      pageCount: config?.pageCount,
      initialPageSize: config?.initialPageSize,
      initialPageIndex: config?.initialPageIndex,
      // Default `false` — preserves pagination cursor across filter changes
      // (better UX for server-side / infinite scroll) and avoids the async
      // `onPaginationChange` race that fires "state update on unmounted
      // component" warnings. Opt in via `config={{ autoResetPageIndex: true }}`.
      autoResetPageIndex: config?.autoResetPageIndex ?? false,
      autoResetExpanded: config?.autoResetExpanded ?? false,
    }),
    [
      config?.enablePagination,
      config?.enableFilters,
      config?.enableSorting,
      config?.enableRowSelection,
      hasSelectColumn,
      config?.enableMultiSort,
      config?.enableGrouping,
      config?.enableExpanding,
      config?.enableColumnResizing,
      config?.groupedColumnMode,
      hasExpandColumn,
      config?.manualSorting,
      config?.manualPagination,
      config?.manualFiltering,
      config?.pageCount,
      config?.initialPageSize,
      config?.initialPageIndex,
      config?.autoResetPageIndex,
      config?.autoResetExpanded,
    ],
  )

  // Cache once: `detectFeaturesFromChildren` recursively walks the React tree
  // (50-150ms on deep trees). Children structure is stable post-mount.
  const detectedFeaturesRef = React.useRef<ReturnType<
    typeof detectFeaturesFromChildren
  > | null>(null)

  // Only detect features once on mount (children structure is stable)
  if (detectedFeaturesRef.current === null) {
    detectedFeaturesRef.current = detectFeaturesFromChildren(children, columns)
  }

  // Memoize merged feature object so tableOptions stays stable.
  const detectFeatures = React.useMemo(() => {
    const detectedFeatures = detectedFeaturesRef.current ?? {}

    const features = {
      // Use config first, then explicit props, then detected features, then defaults
      enablePagination:
        finalConfig.enablePagination ??
        detectedFeatures.enablePagination ??
        false,
      enableFilters:
        finalConfig.enableFilters ?? detectedFeatures.enableFilters ?? false,
      enableRowSelection:
        finalConfig.enableRowSelection ??
        detectedFeatures.enableRowSelection ??
        false,
      enableSorting:
        finalConfig.enableSorting ?? detectedFeatures.enableSorting ?? false,
      enableMultiSort:
        finalConfig.enableMultiSort ?? detectedFeatures.enableMultiSort ?? true,
      enableGrouping:
        finalConfig.enableGrouping ?? detectedFeatures.enableGrouping ?? false,
      enableExpanding:
        finalConfig.enableExpanding ??
        detectedFeatures.enableExpanding ??
        finalConfig.enableGrouping ??
        detectedFeatures.enableGrouping ??
        false,
      enableColumnResizing:
        finalConfig.enableColumnResizing ??
        detectedFeatures.enableColumnResizing ??
        false,
      manualSorting:
        finalConfig.manualSorting ?? detectedFeatures.manualSorting ?? false,
      manualPagination:
        finalConfig.manualPagination ??
        detectedFeatures.manualPagination ??
        false,
      manualFiltering:
        finalConfig.manualFiltering ??
        detectedFeatures.manualFiltering ??
        false,
      pageCount: finalConfig.pageCount ?? detectedFeatures.pageCount,
    }

    return features
  }, [finalConfig])

  // State management
  const [globalFilter, setGlobalFilter] = React.useState<GlobalFilter>(
    restInitialState?.globalFilter ?? "",
  )
  const [rowSelection, setRowSelection] = React.useState<RowSelectionState>(
    restInitialState?.rowSelection ?? {},
  )
  const [columnVisibility, setColumnVisibility] =
    React.useState<ColumnVisibilityState>(
      restInitialState?.columnVisibility ?? {},
    )
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
    restInitialState?.columnFilters ?? [],
  )
  const [sorting, setSorting] = React.useState<SortingState>(
    restInitialState?.sorting ?? [],
  )
  const [expanded, setExpanded] = React.useState<ExpandedState>(
    restInitialState?.expanded ?? {},
  )
  const [grouping, setGrouping] = React.useState<GroupingState>(
    restInitialState?.grouping ?? [],
  )
  const [columnPinning, setColumnPinning] = React.useState<ColumnPinningState>({
    start: restInitialState?.columnPinning?.start ?? [],
    end: restInitialState?.columnPinning?.end ?? [],
  })
  const [columnOrder, setColumnOrder] = React.useState<ColumnOrderState>(
    restInitialState?.columnOrder ?? [],
  )
  const [columnSizing, setColumnSizing] = React.useState<ColumnSizingState>(
    restInitialState?.columnSizing ?? {},
  )
  const [pagination, setPagination] = React.useState<PaginationState>({
    pageIndex:
      finalConfig.initialPageIndex ??
      restInitialState?.pagination?.pageIndex ??
      0,
    pageSize:
      finalConfig.initialPageSize ??
      restInitialState?.pagination?.pageSize ??
      10,
  })

  // Mount-ref guards prevent React-19 + StrictMode "state update on unmounted
  // component" warnings when TanStack's async dispatches land on a torn-down fiber.
  const isMountedRef = React.useRef(true)
  React.useEffect(() => {
    isMountedRef.current = true
    return () => {
      isMountedRef.current = false
    }
  }, [])

  // Stable identity keeps tableOptions memo from invalidating each render.
  const handleGlobalFilterChange = React.useCallback(
    (value: GlobalFilter) => {
      // Mount-guard local writes; external handler is caller's responsibility.
      if (isMountedRef.current) {
        setGlobalFilter(value)
      }
      onGlobalFilterChange?.(value)
    },
    [onGlobalFilterChange],
  )

  // O(1) row-by-id lookup; Array.find()-per-selection is O(n × m) — ~500ms lag
  // at 10k rows × 100 selected.
  const rowIdMap = React.useMemo(() => {
    const map = new Map<string, TData>()
    data?.forEach((row, idx) => {
      const rowId =
        getRowId?.(row, idx) ??
        (row as { id?: string | number }).id?.toString() ??
        String(idx)
      map.set(rowId, row)
    })
    return map
  }, [data, getRowId])

  // Stable identity prevents table re-init. Pure setter — `onRowSelection`
  // fires from the effect below so concurrent-mode double-invokes don't double-fire.
  // Honors the full TanStack `Updater<T> = T | ((old: T) => T)` contract.
  const handleRowSelectionChange = React.useCallback(
    (valueFn: Updater<RowSelectionState>) => {
      if (!isMountedRef.current) return
      if (typeof valueFn === "function") {
        setRowSelection(prev => valueFn(prev))
      } else {
        setRowSelection(valueFn)
      }
    },
    [],
  )

  /**
   * PERFORMANCE: Stable mount-guarded fallback setters
   *
   * WHY: Inline `(u) => isMounted && setX(u)` closures inside `tableOptions` get
   * recreated on every memo invalidation, and the 6 setX refs added noise to the
   * dep array (state setters are already stable by React contract).
   *
   * IMPACT: tableOptions memo no longer depends on 6 setters; fallback handlers
   * keep referential identity across renders.
   *
   * WHAT: Hoists each fallback to a `useCallback([])`. Mount-guard preserved so
   * StrictMode-unmounted fibers don't receive setState calls.
   */
  const handleSortingChange = React.useCallback((u: Updater<SortingState>) => {
    if (isMountedRef.current) setSorting(u)
  }, [])

  const handleColumnFiltersChange = React.useCallback(
    (u: Updater<ColumnFiltersState>) => {
      if (isMountedRef.current) setColumnFilters(u)
    },
    [],
  )

  const handleColumnVisibilityChange = React.useCallback(
    (u: Updater<ColumnVisibilityState>) => {
      if (isMountedRef.current) setColumnVisibility(u)
    },
    [],
  )

  const handleColumnPinningChange = React.useCallback(
    (updater: Updater<ColumnPinningState>) => {
      if (!isMountedRef.current) return
      setColumnPinning(prev => {
        const next = typeof updater === "function" ? updater(prev) : updater
        return {
          start: next.start ?? [],
          end: next.end ?? [],
        }
      })
    },
    [],
  )

  const handleColumnOrderChange = React.useCallback(
    (u: Updater<ColumnOrderState>) => {
      if (isMountedRef.current) setColumnOrder(u)
    },
    [],
  )

  const handleColumnSizingChange = React.useCallback(
    (u: Updater<ColumnSizingState>) => {
      if (isMountedRef.current) setColumnSizing(u)
    },
    [],
  )

  const handleExpandedChange = React.useCallback(
    (u: Updater<ExpandedState>) => {
      if (isMountedRef.current) setExpanded(u)
    },
    [],
  )

  const handleGroupingChange = React.useCallback(
    (u: Updater<GroupingState>) => {
      if (isMountedRef.current) setGrouping(u)
    },
    [],
  )

  const handlePaginationChange = React.useCallback(
    (u: Updater<PaginationState>) => {
      if (isMountedRef.current) setPagination(u)
    },
    [],
  )

  // Fire `onRowSelection` only on user-driven changes — skip the initial mount.
  const skipInitialRowSelectionRef = React.useRef(true)
  React.useEffect(() => {
    if (!isMountedRef.current) return
    if (skipInitialRowSelectionRef.current) {
      skipInitialRowSelectionRef.current = false
      return
    }
    if (!onRowSelection) return
    const selectedRows = Object.keys(rowSelection)
      .filter(key => rowSelection[key])
      .map(key => rowIdMap.get(key))
      .filter((row): row is TData => row !== undefined)
    onRowSelection(selectedRows)
  }, [rowSelection, rowIdMap, onRowSelection])

  /**
   * Auto-apply filterFn based on meta.variant if not explicitly provided
   * This allows developers to set variant in meta and get the right filterFn automatically
   */
  const processedColumns = React.useMemo(() => {
    return columns.map(col => {
      // If filterFn is already defined, use it (manual override)
      if (col.filterFn) return col

      const meta = col.meta ?? {}
      const variant = meta.variant

      // Auto-apply filterFn based on variant
      let autoFilterFn: FilterFnOption<DataTableFeatures, TData> | undefined
      if (
        variant === FILTER_VARIANTS.RANGE ||
        variant === FILTER_VARIANTS.NUMBER
      ) {
        // For number/range variants, use numberRangeFilter if no explicit filterFn
        autoFilterFn = "numberRange" as FilterFnOption<DataTableFeatures, TData>
      } else if (
        variant === FILTER_VARIANTS.DATE ||
        variant === FILTER_VARIANTS.DATE_RANGE
      ) {
        // For date variants, use dateRangeFilter if no explicit filterFn
        autoFilterFn = "dateRange" as FilterFnOption<DataTableFeatures, TData>
      }

      // Only override if we have an auto filterFn and no explicit one
      if (autoFilterFn) {
        return {
          ...col,
          filterFn: autoFilterFn,
        }
      }

      return col
    })
  }, [columns])

  // TanStack's `defaultColumn` is per-render-cheaper than mapping columns ourselves.
  const defaultColumn = React.useMemo<Partial<DataTableColumnDef<TData>>>(
    () => ({
      // Follow table-level sorting detection — don't opt every column into
      // sortable chrome when sorting is off.
      enableSorting: detectFeatures.enableSorting ?? false,
      enableHiding: true,
      filterFn: "extended" as FilterFnOption<DataTableFeatures, TData>,
      // Override TanStack's internal default (150) so unset `size` stays undefined
      // — virtualized flex layout uses this to distinguish fixed vs flexible cols.
      // `column.getSize()` still falls back to 150 internally.
      size: undefined,
      // Align mouse-drag floor with the resize handle's keyboard/autosize
      // clamp (TanStack's built-in default is 20 — too tight for padded cells).
      ...(detectFeatures.enableColumnResizing
        ? { minSize: DEFAULT_MIN_COLUMN_SIZE }
        : {}),
    }),
    [detectFeatures.enableColumnResizing, detectFeatures.enableSorting],
  )

  // Extract controlled-state slices for the tableOptions dep array.
  const controlledSorting = restState?.sorting ?? sorting
  const controlledColumnVisibility =
    restState?.columnVisibility ?? columnVisibility
  const controlledRowSelection = restState?.rowSelection ?? rowSelection
  const controlledColumnFilters = restState?.columnFilters ?? columnFilters
  const controlledGlobalFilter =
    restState?.globalFilter !== undefined
      ? restState.globalFilter
      : globalFilter
  const controlledColumnPinning = restState?.columnPinning ?? columnPinning
  const controlledColumnOrder = restState?.columnOrder ?? columnOrder
  const controlledColumnSizing = restState?.columnSizing ?? columnSizing
  const controlledExpanded = restState?.expanded ?? expanded
  const controlledGrouping = restState?.grouping ?? grouping
  const controlledPagination = restState?.pagination ?? pagination

  // System columns (select, expand) follow the first data column's pinning so
  // they stay visually attached as the "row header".
  const finalColumnPinning = React.useMemo(() => {
    // Use centralized system column IDs from constants

    // Helper to safely extract column ID (handles both id and accessorKey)
    const getColumnId = (
      col: DataTableColumnDef<TData>,
    ): string | undefined => {
      if (col.id) return col.id
      // Type-safe check for accessorKey property
      if ("accessorKey" in col && typeof col.accessorKey === "string") {
        return col.accessorKey
      }
      return undefined
    }

    // 1. Identify the "First Data Column" (first non-system column)
    const firstDataCol = columns.find(col => {
      const id = getColumnId(col)
      return id && !SYSTEM_COLUMN_ID_LIST.includes(id)
    })

    if (!firstDataCol) return controlledColumnPinning

    const firstDataColId = getColumnId(firstDataCol)
    if (!firstDataColId) return controlledColumnPinning

    // 2. Check pinning state of the first data column
    const isPinnedStart =
      controlledColumnPinning.start?.includes(firstDataColId)
    const isPinnedEnd = controlledColumnPinning.end?.includes(firstDataColId)

    // If not fixed to either side, return default (system cols float naturally)
    if (!isPinnedStart && !isPinnedEnd) {
      return controlledColumnPinning
    }

    const start = [...(controlledColumnPinning.start ?? [])]
    const end = [...(controlledColumnPinning.end ?? [])]

    // 3. Prepare system columns list
    const systemColsPresent: string[] = []
    if (hasSelectColumn) systemColsPresent.push(SYSTEM_COLUMN_IDS.SELECT)
    if (hasExpandColumn) systemColsPresent.push(SYSTEM_COLUMN_IDS.EXPAND)

    // 4. Clean existing lists (remove system cols to avoid duplication)
    const cleanStart = start.filter(id => !SYSTEM_COLUMN_ID_LIST.includes(id))
    const cleanEnd = end.filter(id => !SYSTEM_COLUMN_ID_LIST.includes(id))

    // 5. Construct new pinning state
    if (isPinnedStart) {
      // Pin start (LTR left): [System, ...Others]
      return {
        start: [...systemColsPresent, ...cleanStart],
        end: cleanEnd,
      }
    }

    if (isPinnedEnd) {
      // Pin end (LTR right): [System, ...Others]
      // We place system cols *before* others in the end group so they appear
      // to the immediate left of the end-pinned data columns.
      return {
        start: cleanStart,
        end: [...systemColsPresent, ...cleanEnd],
      }
    }

    return controlledColumnPinning
  }, [controlledColumnPinning, columns, hasSelectColumn, hasExpandColumn])

  // Critical: stable options reference. New object → useTable recreates
  // the instance → state resets and sorting/filter/expand break.
  const tableOptions = React.useMemo<TableOptions<DataTableFeatures, TData>>(
    () => ({
      ...passthroughTableOptions,
      features,
      data,
      columns: processedColumns as ColumnDef<
        DataTableFeatures,
        TData,
        unknown
      >[],
      defaultColumn,
      state: {
        ...restState,
        // Always use our local state as the source of truth
        // External state (restState) takes precedence only if explicitly provided
        sorting: controlledSorting,
        columnVisibility: controlledColumnVisibility,
        columnPinning: finalColumnPinning,
        columnOrder: controlledColumnOrder,
        columnSizing: controlledColumnSizing,
        rowSelection: controlledRowSelection,
        columnFilters: controlledColumnFilters,
        globalFilter: controlledGlobalFilter,
        expanded: controlledExpanded,
        grouping: controlledGrouping,
        pagination: controlledPagination,
      },
      enableColumnResizing: detectFeatures.enableColumnResizing,
      // `onEnd`, not `onChange`: apply the new width once, on pointer release.
      // In `onChange` every mousemove writes `columnSizing`, which invalidates
      // the memoized header + every visible (avatar/badge-heavy) body row ~60x
      // a second — the source of resize lag. `onEnd` keeps widths stable during
      // the drag; `<ColumnResizePreviewLine>` shows a live guide line instead.
      columnResizeMode: "onEnd",
      onColumnSizingChange: onColumnSizingChange ?? handleColumnSizingChange,
      enableRowSelection: detectFeatures.enableRowSelection,
      enableFilters: detectFeatures.enableFilters,
      enableSorting: detectFeatures.enableSorting,
      enableMultiSort: detectFeatures.enableMultiSort,
      enableGrouping: detectFeatures.enableGrouping,
      enableExpanding:
        detectFeatures.enableExpanding || detectFeatures.enableGrouping,
      groupedColumnMode: finalConfig.groupedColumnMode ?? "reorder",
      // When a feature is off, skip its client row model (v8 omitted get*RowModel).
      // `manual*` still wins for server-side modes when the feature is on.
      manualSorting:
        detectFeatures.manualSorting || !detectFeatures.enableSorting,
      manualPagination:
        detectFeatures.manualPagination || !detectFeatures.enablePagination,
      manualFiltering:
        detectFeatures.manualFiltering || !detectFeatures.enableFilters,
      manualGrouping: manualGroupingProp ?? !detectFeatures.enableGrouping,
      manualExpanding:
        manualExpandingProp ??
        !(detectFeatures.enableExpanding || detectFeatures.enableGrouping),
      // Enable auto-reset behaviors by default (standard TanStack Table behavior)
      // Can be overridden via config
      autoResetPageIndex: finalConfig.autoResetPageIndex,
      autoResetExpanded: finalConfig.autoResetExpanded,
      onGlobalFilterChange: handleGlobalFilterChange,
      onRowSelectionChange: onRowSelectionChange ?? handleRowSelectionChange,
      // Default state setters are mount-ref guarded so TanStack's async
      // auto-reset dispatches don't land on a StrictMode-unmounted fiber.
      // Consumer-supplied handlers are NOT guarded — caller's responsibility.
      onSortingChange: onSortingChange ?? handleSortingChange,
      onColumnFiltersChange: onColumnFiltersChange ?? handleColumnFiltersChange,
      onColumnVisibilityChange:
        onColumnVisibilityChange ?? handleColumnVisibilityChange,
      onColumnPinningChange: onColumnPinningChange ?? handleColumnPinningChange,
      onColumnOrderChange: onColumnOrderChange ?? handleColumnOrderChange,
      onExpandedChange: onExpandedChange ?? handleExpandedChange,
      onGroupingChange: onGroupingChange ?? handleGroupingChange,
      onPaginationChange: onPaginationChange ?? handlePaginationChange,
      // filterFns live on `features` (data-table-features.ts) — do not re-register here.
      // Allow globalFilterFn to be overridden via rest props, otherwise use default
      globalFilterFn:
        (restGlobalFilterFn as FilterFn<DataTableFeatures, TData>) ??
        (globalFilterFn as unknown as FilterFn<DataTableFeatures, TData>),
      // Use provided getRowId or fallback to checking for 'id' property, then index
      getRowId:
        getRowId ??
        ((originalRow, index) => {
          // Try to use 'id' property if it exists
          const rowWithId = originalRow as { id?: string | number }
          if (rowWithId.id !== undefined && rowWithId.id !== null) {
            return String(rowWithId.id)
          }
          // Fallback to index
          return String(index)
        }),
      pageCount: (() => {
        if (!detectFeatures.manualPagination) return undefined
        return finalConfig.pageCount !== undefined
          ? finalConfig.pageCount
          : detectFeatures.pageCount !== undefined
            ? detectFeatures.pageCount
            : -1
      })(),
    }),
    // Deps are the *destructured* rest props, NOT the whole rest bag — see
    // destructure-site comment. `passthroughTableOptions` is intentionally
    // NOT a dep (lift any option that needs to invalidate the memo).
    // eslint-disable-next-line react-hooks/exhaustive-deps
    [
      restState,
      restGlobalFilterFn,
      data,
      processedColumns,
      defaultColumn,
      detectFeatures,
      finalConfig,
      handleGlobalFilterChange,
      onRowSelectionChange,
      handleRowSelectionChange,
      onSortingChange,
      handleSortingChange,
      onColumnFiltersChange,
      handleColumnFiltersChange,
      onColumnVisibilityChange,
      handleColumnVisibilityChange,
      onColumnPinningChange,
      handleColumnPinningChange,
      onColumnOrderChange,
      handleColumnOrderChange,
      onColumnSizingChange,
      handleColumnSizingChange,
      onExpandedChange,
      handleExpandedChange,
      onGroupingChange,
      handleGroupingChange,
      onPaginationChange,
      handlePaginationChange,
      getRowId,
      manualGroupingProp,
      manualExpandingProp,
      // Use controlled state values - these update when either external or local state changes
      controlledSorting,
      controlledColumnVisibility,
      controlledRowSelection,
      controlledColumnFilters,
      controlledGlobalFilter,
      controlledColumnOrder,
      controlledColumnSizing,
      controlledExpanded,
      controlledGrouping,
      controlledPagination,
      // Add column pinning state to dependencies so the table updates when it changes
      finalColumnPinning,
    ],
  )

  // Instance ref is stable across state changes; React Compiler warns about
  // incompatible-library here — TanStack manages its own memoization (expected).

  const table = useTable<DataTableFeatures, TData>(tableOptions)

  return (
    <DataTableProvider
      table={table}
      columns={processedColumns as DataTableColumnDef<TData>[]}
      isLoading={isLoading}
    >
      <TooltipProvider {...tooltipProviderDelay}>
        <div className={cn("w-full min-w-0 space-y-4", className)}>
          {children}
        </div>
      </TooltipProvider>
    </DataTableProvider>
  )
}

// Main wrapper component
export function DataTableRoot<TData extends RowData>({
  table: externalTable,
  columns,
  data,
  children,
  className,
  isLoading,
  ...rest
}: TableRootProps<TData>) {
  // If a table instance is provided, use it directly (no hooks needed)
  if (externalTable) {
    return (
      <DataTableProvider
        table={externalTable}
        columns={columns as DataTableColumnDef<TData>[]}
        isLoading={isLoading}
      >
        <TooltipProvider {...tooltipProviderDelay}>
          <div className={cn("w-full min-w-0 space-y-4", className)}>
            {children}
          </div>
        </TooltipProvider>
      </DataTableProvider>
    )
  }

  // Validate required props for internal table creation
  if (!columns || !data) {
    throw new Error(
      "DataTableRoot: Either provide a 'table' prop or both 'columns' and 'data' props",
    )
  }

  // Otherwise, delegate to the internal component that handles hooks
  return (
    <DataTableRootInternal
      columns={columns}
      data={data}
      className={className}
      isLoading={isLoading}
      {...rest}
    >
      {children}
    </DataTableRootInternal>
  )
}

DataTableRoot.displayName = "DataTableRoot"
src/components/niko-table/core/data-table-error-boundary.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 { AlertCircle } from "lucide-react"
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"

export interface DataTableErrorBoundaryProps {
  /**
   * The content to render when there's no error
   */
  children: React.ReactNode
  /**
   * Custom fallback UI to show when an error occurs
   */
  fallback?: React.ReactNode
  /**
   * Callback fired when an error is caught
   */
  onError?: (error: Error, errorInfo: React.ErrorInfo) => void
  /**
   * Whether to show a reset button
   * @default true
   */
  showResetButton?: boolean
  /**
   * Custom reset button text
   * @default "Try Again"
   */
  resetButtonText?: string
}

interface DataTableErrorBoundaryState {
  hasError: boolean
  error: Error | null
}

/**
 * Error boundary component for DataTable.
 * Catches JavaScript errors anywhere in the data table component tree,
 * logs those errors, and displays a fallback UI instead of crashing.
 *
 * @example
 * Basic usage
 * <DataTableErrorBoundary>
 *   <DataTableRoot data={data} columns={columns}>
 *     <DataTable>
 *       <DataTableHeader />
 *       <DataTableBody />
 *     </DataTable>
 *   </DataTableRoot>
 * </DataTableErrorBoundary>
 *
 * @example
 * // With custom fallback
 * <DataTableErrorBoundary
 *   fallback={
 *     <div className="p-8 text-center">
 *       <h3>Oops! Something went wrong.</h3>
 *       <p>Please contact support if this persists.</p>
 *     </div>
 *   }
 * >
 *   <DataTableRoot data={data} columns={columns}>
 *     {/* ... *\/}
 *   </DataTableRoot>
 * </DataTableErrorBoundary>
 *
 * @example
 * // With error logging
 * <DataTableErrorBoundary
 *   onError={(error, errorInfo) => {
 *     console.error("DataTable Error:", error, errorInfo)
 *     // Send to error tracking service
 *     trackError(error)
 *   }}
 * >
 *   <DataTableRoot data={data} columns={columns}>
 *     {/* ... *\/}
 *   </DataTableRoot>
 * </DataTableErrorBoundary>
 */
export class DataTableErrorBoundary extends React.Component<
  DataTableErrorBoundaryProps,
  DataTableErrorBoundaryState
> {
  static displayName = "DataTableErrorBoundary"

  constructor(props: DataTableErrorBoundaryProps) {
    super(props)
    this.state = { hasError: false, error: null }
  }

  static getDerivedStateFromError(error: Error): DataTableErrorBoundaryState {
    return { hasError: true, error }
  }

  componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
    console.error("DataTable Error Boundary caught an error:", error, errorInfo)
    this.props.onError?.(error, errorInfo)
  }

  handleReset = () => {
    this.setState({ hasError: false, error: null })
  }

  render() {
    if (this.state.hasError) {
      // Use custom fallback if provided
      if (this.props.fallback) {
        return this.props.fallback
      }

      const { showResetButton = true, resetButtonText = "Try Again" } =
        this.props

      // Default error UI
      return (
        <Alert variant="destructive" className="my-4">
          <AlertCircle className="h-4 w-4" />
          <AlertTitle>Table Error</AlertTitle>
          <AlertDescription className="mt-2 flex flex-col gap-2">
            <p>
              {this.state.error?.message ||
                "Something went wrong while displaying the table."}
            </p>
            {showResetButton && (
              <Button
                variant="outline"
                size="sm"
                onClick={this.handleReset}
                className="mt-2 w-fit"
              >
                {resetButtonText}
              </Button>
            )}
          </AlertDescription>
        </Alert>
      )
    }

    return this.props.children
  }
}
src/components/niko-table/components/data-table-column-title.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 { TableColumnTitle } from "../filters/table-column-title"
import { useColumnHeaderContext } from "./data-table-column-header"

/**
 * Renders the column title using context.
 */
export function DataTableColumnTitle<TData extends RowData, TValue>(
  props: Omit<React.ComponentProps<typeof TableColumnTitle>, "column">,
) {
  const { column } = useColumnHeaderContext<TData, TValue>(true)
  return <TableColumnTitle column={column} {...props} />
}

DataTableColumnTitle.displayName = "DataTableColumnTitle"
src/components/niko-table/components/data-table-column-actions.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 { TableColumnActions } from "../filters/table-column-actions"
import { useColumnHeaderContext } from "./data-table-column-header"

/**
 * Composable container for column actions.
 *
 * Uses column context to automatically detect active states (pinned, sorted, etc.).
 *
 * @example
 * ```tsx
 * <DataTableColumnActions>
 *   <DataTableColumnSortOptions />
 *   <DataTableColumnPinOptions />
 *   <DataTableColumnHideOptions />
 * </DataTableColumnActions>
 * ```
 */
export function DataTableColumnActions<TData extends RowData, TValue>(
  props: Omit<React.ComponentProps<typeof TableColumnActions>, "isActive"> & {
    /** Override to manually set active state */
    isActive?: boolean
  },
) {
  const context = useColumnHeaderContext<TData, TValue>(false)

  // Auto-detect active state from column context
  const autoIsActive = context?.column
    ? !!(
        context.column.getIsSorted() ||
        context.column.getIsPinned() ||
        context.column.getIsFiltered()
      )
    : false

  const isActive = props.isActive ?? autoIsActive

  return <TableColumnActions {...props} isActive={isActive} />
}

DataTableColumnActions.displayName = "DataTableColumnActions"
src/components/niko-table/components/data-table-column-filter.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"

/**
 * Wrapper for groups of column filters.
 */
export function DataTableColumnFilter({
  children,
  className,
}: {
  children?: React.ReactNode
  className?: string
}) {
  if (children) {
    return <div className={cn("flex items-center", className)}>{children}</div>
  }
  return null
}

DataTableColumnFilter.displayName = "DataTableColumnFilter"
src/components/niko-table/components/data-table-toolbar-section.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"

export interface DataTableToolbarSectionProps extends React.ComponentProps<"div"> {
  children?: React.ReactNode
}

/**
 * A simple, flexible toolbar container for composing table controls.
 * Use this as a layout container and add your own search, filters, sorting, etc.
 *
 * @example - Basic toolbar with search and filters
 * <DataTableToolbarSection>
 *   <DataTableSearchInput placeholder="Search..." />
 *   <DataTableFilterButton column="status" title="Status" />
 *   <DataTableSortMenu />
 * </DataTableToolbarSection>
 *
 * @example - Custom layout with left and right sections
 * <DataTableToolbarSection className="justify-between">
 *   <div className="flex gap-2">
 *     <DataTableSearchInput />
 *     <DataTableFilterButton column="status" />
 *   </div>
 *   <div className="flex gap-2">
 *     <DataTableSortMenu />
 *     <DataTableViewMenu />
 *   </div>
 * </DataTableToolbarSection>
 *
 * @example - With custom elements
 * <DataTableToolbarSection>
 *   <DataTableSearchInput />
 *   <span className="text-sm text-muted-foreground">
 *     {table.getFilteredRowModel().rows.length} results
 *   </span>
 *   <Button variant="outline">Export</Button>
 * </DataTableToolbarSection>
 */

const DataTableToolbarSectionInternal = React.forwardRef<
  HTMLDivElement,
  DataTableToolbarSectionProps
>(({ children, className, ...props }, ref) => {
  return (
    <div
      ref={ref}
      role="toolbar"
      aria-orientation="horizontal"
      className={cn("flex w-full flex-wrap items-center gap-2 p-1", className)}
      {...props}
    >
      {children}
    </div>
  )
})

DataTableToolbarSectionInternal.displayName = "DataTableToolbarSectionInternal"

// Memoized so table-state changes don't re-render unchanged toolbars.
export const DataTableToolbarSection = React.memo(
  DataTableToolbarSectionInternal,
)

DataTableToolbarSection.displayName = "DataTableToolbarSection"
src/components/niko-table/filters/table-column-actions.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 { MoreVertical } from "lucide-react"

import { Button } from "@/components/ui/button"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"

export interface TableColumnActionsProps {
  children: React.ReactNode
  className?: string
  /**
   * Optional label shown at the top of the dropdown.
   * @default "Column Actions"
   */
  label?: string
  /**
   * Whether to show a visual indicator when actions are active.
   */
  isActive?: boolean
  /**
   * Custom trigger element. If not provided, uses a MoreVertical icon button.
   */
  trigger?: React.ReactNode
  /**
   * Alignment of the dropdown content.
   * @default "end"
   */
  align?: "start" | "center" | "end"
}

/**
 * A simple dropdown container for composing column actions.
 *
 * Use with `*Options` components to compose actions in a single dropdown:
 *
 * @example
 * ```tsx
 * <TableColumnActions>
 *   <TableColumnSortOptions />
 *   <TableColumnPinOptions />
 *   <TableColumnHideOptions />
 * </TableColumnActions>
 * ```
 *
 * For standalone dropdowns, use the `*Menu` variants instead:
 * ```tsx
 * <TableColumnSortMenu />
 * <TableColumnPin />
 * ```
 */
export function TableColumnActions({
  children,
  className,
  label = "Column Actions",
  isActive = false,
  trigger,
  align = "end",
}: TableColumnActionsProps) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        {trigger ?? (
          <Button
            variant="ghost"
            size="icon"
            className={cn(
              "size-7 transition-opacity group-hover:opacity-100 dark:text-muted-foreground",
              isActive ? "text-primary opacity-100" : "opacity-0",
              className,
            )}
          >
            <MoreVertical className="size-4" />
            <span className="sr-only">{label}</span>
          </Button>
        )}
      </DropdownMenuTrigger>
      <DropdownMenuContent align={align} className="w-48">
        <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
          {label}
        </DropdownMenuLabel>
        {children}
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

TableColumnActions.displayName = "TableColumnActions"
src/components/niko-table/filters/table-column-title.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"
import { useDerivedColumnTitle } from "../hooks/use-derived-column-title"

import type { DataTableColumn } from "../types"
import type { RowData } from "@tanstack/react-table"
/**
 * Renders the column title.
 */
export function TableColumnTitle<TData extends RowData, TValue>({
  column,
  title,
  className,
  children,
}: {
  column: DataTableColumn<TData, TValue>
  title?: string
  className?: string
  children?: React.ReactNode
}) {
  const derivedTitle = useDerivedColumnTitle(column, column.id, title)

  return (
    <div
      data-slot="column-title"
      className={cn(
        // `min-w-0` so `truncate` can shrink this flex item below its text
        // width (a nowrap flex child otherwise keeps full content width and
        // spills into the neighbouring header cell on narrow columns).
        "min-w-0 truncate py-0.5 text-sm font-semibold transition-colors",
        className,
      )}
    >
      {children ?? derivedTitle}
    </div>
  )
}

TableColumnTitle.displayName = "TableColumnTitle"
src/components/niko-table/hooks/use-debounce.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 { useEffect, useState } from "react"

/**
 * Debounces a value by delaying updates until after a specified delay period.
 *
 * @template T - The type of the value to debounce
 * @param value - The value to debounce
 * @param delay - The delay in milliseconds before updating the debounced value (default: 300ms)
 * @returns The debounced value
 *
 * @example
 * // Basic usage with search input
 * function SearchFilter() {
 *   const [search, setSearch] = useState("")
 *   const debouncedSearch = useDebounce(search, 500)
 *
 *   useEffect(() => {
 *     // This only runs after user stops typing for 500ms
 *     console.log("Searching for:", debouncedSearch)
 *   }, [debouncedSearch])
 *
 *   return (
 *     <input
 *       value={search}
 *       onChange={(e) => setSearch(e.target.value)}
 *       placeholder="Search..."
 *     />
 *   )
 * }
 *
 * @example
 * // With API calls
 * function ProductSearch() {
 *   const [query, setQuery] = useState("")
 *   const debouncedQuery = useDebounce(query, 300)
 *
 *   useEffect(() => {
 *     if (debouncedQuery) {
 *       // API call only happens after 300ms of no typing
 *       fetchProducts(debouncedQuery).then(setProducts)
 *     }
 *   }, [debouncedQuery])
 *
 *   return <input value={query} onChange={(e) => setQuery(e.target.value)} />
 * }
 *
 * @example
 * // With table filtering
 * function DataTableWithDebounce() {
 *   const [filterValue, setFilterValue] = useState("")
 *   const debouncedFilter = useDebounce(filterValue, 400)
 *
 *   return (
 *     <DataTableRoot
 *       data={data}
 *       columns={columns}
 *       onGlobalFilterChange={debouncedFilter}
 *     >
 *       <DataTableToolbarSection>
 *         <input
 *           value={filterValue}
 *           onChange={(e) => setFilterValue(e.target.value)}
 *         />
 *       </DataTableToolbarSection>
 *       <DataTable>
 *         <DataTableHeader />
 *         <DataTableBody />
 *       </DataTable>
 *     </DataTableRoot>
 *   )
 * }
 */
export function useDebounce<T>(value: T, delay = 300): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value)

  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value)
    }, delay)
    return () => {
      clearTimeout(handler)
    }
  }, [value, delay])

  return debouncedValue
}
src/components/niko-table/hooks/use-derived-column-title.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 * as React from "react"
import { formatLabel } from "../lib/format"

import type { DataTableColumn } from "../types"
/**
 * A hook that derives the title for a column filter component.
 * It follows this priority order:
 * 1. Provided title prop
 * 2. Column metadata label (column.columnDef.meta?.label)
 * 3. Formatted accessor key
 *
 * @param column - The table column
 * @param accessorKey - The accessor key of the column
 * @param title - Optional title override
 * @returns The derived title string
 *
 * @example
 * const derivedTitle = useDerivedColumnTitle(column, "firstName", "First Name")
 * Returns "First Name"
 *
 * @example - With column.meta.label = "First Name"
 * const derivedTitle = useDerivedColumnTitle(column, "firstName")
 *   Returns "First Name" from metadata
 *
 * @example - Without title or metadata
 * const derivedTitle = useDerivedColumnTitle(column, "first_name")
 * Returns "First Name" (formatted from accessorKey)
 */
export function useDerivedColumnTitle<TData extends RowData, TValue = unknown>(
  column: DataTableColumn<TData, TValue> | undefined,
  accessorKey: string,
  title?: string,
): string {
  return React.useMemo(() => {
    if (title) return title
    if (!column) return formatLabel(accessorKey)
    const label = column.columnDef.meta?.label
    return label ?? formatLabel(accessorKey)
  }, [title, column, accessorKey])
}
src/components/niko-table/hooks/use-keyboard-shortcut.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 { useEffect, useCallback, useLayoutEffect, useRef } from "react"

export interface UseKeyboardShortcutOptions {
  /**
   * The key to listen for (e.g., 'f', 's', 'Enter')
   */
  key: string

  /**
   * Function to call when the shortcut is triggered
   */
  onTrigger: () => void

  /**
   * Whether the shortcut is enabled
   * @default true
   */
  enabled?: boolean

  /**
   * Whether to require Shift key
   * @default false
   */
  requireShift?: boolean

  /**
   * Whether to require Ctrl/Cmd key
   * @default false
   */
  requireCtrl?: boolean

  /**
   * Whether to require Alt key
   * @default false
   */
  requireAlt?: boolean

  /**
   * Whether to prevent default browser behavior
   * @default true
   */
  preventDefault?: boolean

  /**
   * Whether to stop event propagation
   * @default false
   */
  stopPropagation?: boolean

  /**
   * Condition function to determine if shortcut should trigger
   * Useful for checking if modals are open, inputs are focused, etc.
   */
  condition?: () => boolean
}

/**
 * Hook for managing keyboard shortcuts with fine-grained control
 *
 * @example
 * ```tsx
 * // Simple shortcut
 * useKeyboardShortcut({
 *   key: 'f',
 *   onTrigger: () => setFilterOpen(true)
 * })
 *
 * // Toggle behavior with condition
 * useKeyboardShortcut({
 *   key: 's',
 *   onTrigger: () => setSortOpen(prev => !prev),
 *   condition: () => !isInputFocused
 * })
 *
 * // Shift + key combination
 * useKeyboardShortcut({
 *   key: 'f',
 *   requireShift: true,
 *   onTrigger: () => clearAllFilters()
 * })
 * ```
 */
export function useKeyboardShortcut({
  key,
  onTrigger,
  enabled = true,
  requireShift = false,
  requireCtrl = false,
  requireAlt = false,
  preventDefault = true,
  stopPropagation = false,
  condition,
}: UseKeyboardShortcutOptions) {
  // Mirror params into a ref so callers can pass inline `onTrigger` /
  // `condition` without the listener detaching every render.
  const paramsRef = useRef({
    key,
    onTrigger,
    enabled,
    requireShift,
    requireCtrl,
    requireAlt,
    preventDefault,
    stopPropagation,
    condition,
  })
  useLayoutEffect(() => {
    paramsRef.current = {
      key,
      onTrigger,
      enabled,
      requireShift,
      requireCtrl,
      requireAlt,
      preventDefault,
      stopPropagation,
      condition,
    }
  })

  const handleKeyDown = useCallback((event: KeyboardEvent) => {
    const p = paramsRef.current
    if (!p.enabled) return

    if (event.key.toLowerCase() !== p.key.toLowerCase()) return

    if (p.requireShift && !event.shiftKey) return
    if (p.requireCtrl && !(event.ctrlKey || event.metaKey)) return
    if (p.requireAlt && !event.altKey) return

    if (!p.requireShift && event.shiftKey) return
    if (!p.requireCtrl && (event.ctrlKey || event.metaKey)) return
    if (!p.requireAlt && event.altKey) return

    if (p.condition && !p.condition()) return

    if (
      event.target instanceof HTMLInputElement ||
      event.target instanceof HTMLTextAreaElement ||
      event.target instanceof HTMLSelectElement ||
      (event.target as HTMLElement)?.isContentEditable
    ) {
      return
    }

    if (p.preventDefault) event.preventDefault()
    if (p.stopPropagation) event.stopPropagation()
    p.onTrigger()
  }, [])

  useEffect(() => {
    // Attach unconditionally — handler short-circuits on `enabled === false`.
    window.addEventListener("keydown", handleKeyDown)
    return () => window.removeEventListener("keydown", handleKeyDown)
  }, [handleKeyDown])
}

/**
 * Hook for managing multiple keyboard shortcuts at once
 *
 * @example
 * ```tsx
 * useKeyboardShortcuts([
 *   { key: 'f', onTrigger: () => setFilterOpen(true) },
 *   { key: 's', onTrigger: () => setSortOpen(prev => !prev) },
 *   { key: 'f', requireShift: true, onTrigger: () => clearFilters() }
 * ])
 * ```
 */
export function useKeyboardShortcuts(shortcuts: UseKeyboardShortcutOptions[]) {
  // Mirror `shortcuts` into a ref so callers can pass inline array literals
  // without the window-level listener detaching on every render.
  const shortcutsRef = useRef(shortcuts)

  useLayoutEffect(() => {
    shortcutsRef.current = shortcuts
  })

  const handleKeyDown = useCallback((event: KeyboardEvent) => {
    // Check each shortcut
    for (const shortcut of shortcutsRef.current) {
      const {
        key,
        onTrigger,
        enabled = true,
        requireShift = false,
        requireCtrl = false,
        requireAlt = false,
        preventDefault = true,
        stopPropagation = false,
        condition,
      } = shortcut

      // Skip if disabled
      if (!enabled) continue

      // Skip if wrong key
      if (event.key.toLowerCase() !== key.toLowerCase()) continue

      // Skip if modifier requirements not met
      if (requireShift && !event.shiftKey) continue
      if (requireCtrl && !(event.ctrlKey || event.metaKey)) continue
      if (requireAlt && !event.altKey) continue

      // Skip if modifiers are present when not required
      if (!requireShift && event.shiftKey) continue
      if (!requireCtrl && (event.ctrlKey || event.metaKey)) continue
      if (!requireAlt && event.altKey) continue

      // Skip if custom condition fails
      if (condition && !condition()) continue

      // Skip if user is typing in an input field
      if (
        event.target instanceof HTMLInputElement ||
        event.target instanceof HTMLTextAreaElement ||
        event.target instanceof HTMLSelectElement ||
        (event.target as HTMLElement)?.isContentEditable
      ) {
        continue
      }

      // Prevent default behavior if requested
      if (preventDefault) {
        event.preventDefault()
      }

      // Stop propagation if requested
      if (stopPropagation) {
        event.stopPropagation()
      }

      // Trigger the callback and break (only one shortcut should trigger)
      onTrigger()
      break
    }
  }, [])

  useEffect(() => {
    // Attach unconditionally — the handler short-circuits per-shortcut on
    // `enabled === false`, so an idle listener is free.
    window.addEventListener("keydown", handleKeyDown)

    return () => {
      window.removeEventListener("keydown", handleKeyDown)
    }
  }, [handleKeyDown])
}
src/components/niko-table/lib/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
 */

import { dataTableConfig } from "../config/data-table"
import { FILTER_OPERATORS, FILTER_VARIANTS, JOIN_OPERATORS } from "./constants"
import type {
  ExtendedColumnFilter,
  FilterOperator,
  FilterVariant,
} from "../types"

export function getFilterOperators(filterVariant: FilterVariant) {
  const operatorMap: Record<
    FilterVariant,
    { label: string; value: FilterOperator }[]
  > = {
    [FILTER_VARIANTS.TEXT]: dataTableConfig.textOperators,
    [FILTER_VARIANTS.NUMBER]: dataTableConfig.numericOperators,
    [FILTER_VARIANTS.RANGE]: dataTableConfig.numericOperators,
    [FILTER_VARIANTS.DATE]: dataTableConfig.dateOperators,
    [FILTER_VARIANTS.DATE_RANGE]: dataTableConfig.dateOperators,
    [FILTER_VARIANTS.BOOLEAN]: dataTableConfig.booleanOperators,
    [FILTER_VARIANTS.SELECT]: dataTableConfig.selectOperators,
    [FILTER_VARIANTS.MULTI_SELECT]: dataTableConfig.multiSelectOperators,
  }

  return operatorMap[filterVariant] ?? dataTableConfig.textOperators
}

export function getDefaultFilterOperator(filterVariant: FilterVariant) {
  const operators = getFilterOperators(filterVariant)

  return (
    operators[0]?.value ??
    (filterVariant === FILTER_VARIANTS.TEXT
      ? FILTER_OPERATORS.ILIKE
      : FILTER_OPERATORS.EQ)
  )
}

export function getValidFilters<TData>(
  filters: ExtendedColumnFilter<TData>[],
): ExtendedColumnFilter<TData>[] {
  return filters.filter(filter => {
    // isEmpty and isNotEmpty don't need values
    if (
      filter.operator === FILTER_OPERATORS.EMPTY ||
      filter.operator === FILTER_OPERATORS.NOT_EMPTY
    ) {
      return true
    }

    // For array values (like isBetween with range [min, max])
    if (Array.isArray(filter.value)) {
      // All array elements must be non-empty
      return (
        filter.value.length > 0 &&
        filter.value.every(
          val => val !== "" && val !== null && val !== undefined,
        )
      )
    }

    // For non-array values
    return (
      filter.value !== "" && filter.value !== null && filter.value !== undefined
    )
  })
}

/**
 * Operators whose same-column repetitions are equivalent to a single `IN`
 * (e.g. "brand is apple OR brand is samsung" ≡ "brand IN (apple, samsung)").
 * Only these are collapsed into a faceted multi-select entry.
 */
const EQUALITY_OPERATORS = new Set<FilterOperator>([
  FILTER_OPERATORS.EQ,
  FILTER_OPERATORS.IN,
])

/**
 * A pending menu row: an equality filter whose value hasn't been chosen yet.
 * These are inert (no query effect) and must neither pollute a merged `IN`
 * nor trigger OR routing while the user is still picking a value.
 */
function isPendingEqualityFilter<TData>(
  filter: ExtendedColumnFilter<TData>,
): boolean {
  return (
    EQUALITY_OPERATORS.has(filter.operator) &&
    (filter.value === "" ||
      filter.value == null ||
      (Array.isArray(filter.value) && filter.value.length === 0))
  )
}

/**
 * Collapse repeated equality filters on the same column into one `IN`
 * multi-select filter, kept in first-occurrence position.
 *
 * @description The advanced filter menu and the faceted-filter dropdown must
 * stay in sync, but they read different state channels — the dropdown reads a
 * column's value from `columnFilters`, while OR logic is routed to
 * `globalFilter`. Two "brand is X" menu rows are semantically the faceted
 * multi-select's own `{ operator: "in", value: [...] }` shape, so collapsing
 * them yields a single `columnFilters` entry both surfaces read and write.
 *
 * A column is only collapsed when EVERY filter on it is an equality operator;
 * "brand is X OR brand contains Y" can't be a multi-select, so it is left
 * untouched (and continues to route through `globalFilter`).
 */
function collapseSameColumnEqualityFilters<TData>(
  filters: ExtendedColumnFilter<TData>[],
): ExtendedColumnFilter<TData>[] {
  const groups = new Map<string, ExtendedColumnFilter<TData>[]>()
  const indicesById = new Map<string, number[]>()
  filters.forEach((filter, index) => {
    const group = groups.get(filter.id) ?? []
    group.push(filter)
    groups.set(filter.id, group)
    const indices = indicesById.get(filter.id) ?? []
    indices.push(index)
    indicesById.set(filter.id, indices)
  })

  const emitted = new Set<string>()
  const result: ExtendedColumnFilter<TData>[] = []

  for (const filter of filters) {
    const group = groups.get(filter.id) ?? []
    // Pending rows (no value yet) pass through untouched — merging them would
    // leak "" into the IN values or make the row vanish from the menu.
    const mergeable = group.filter(member => !isPendingEqualityFilter(member))
    // Only collapse a contiguous run of the column's filters — nothing from
    // another column interleaved. Merging across an interleaved filter would
    // cross an AND/OR clause boundary and change the boolean grouping the
    // mixed-filter evaluator relies on: e.g. "brand=A AND category=C OR
    // brand=B" ((A ∧ C) ∨ B) must not become "brand IN (A,B) AND category=C"
    // ((A ∨ B) ∧ C).
    const indices = indicesById.get(filter.id) ?? []
    const firstIndex = indices[0]
    const lastIndex = indices[indices.length - 1]
    const contiguous =
      indices.length > 0 &&
      firstIndex !== undefined &&
      lastIndex !== undefined &&
      lastIndex - firstIndex + 1 === indices.length
    const collapsible =
      contiguous &&
      mergeable.length > 1 &&
      group.every(member => EQUALITY_OPERATORS.has(member.operator))

    if (!collapsible || isPendingEqualityFilter(filter)) {
      result.push(filter)
      continue
    }

    // Emit the merged filter once, at the first occurrence of the column.
    if (emitted.has(filter.id)) continue
    emitted.add(filter.id)

    const values: string[] = []
    for (const member of mergeable) {
      const memberValues = Array.isArray(member.value)
        ? member.value
        : [member.value]
      for (const value of memberValues) {
        if (!values.includes(value)) values.push(value)
      }
    }

    result.push({
      // collapsible guarantees mergeable.length > 1, so mergeable[0] exists.
      ...mergeable[0]!, // preserve id, filterId and the group's leading joinOperator
      value: values,
      variant: FILTER_VARIANTS.MULTI_SELECT,
      operator: FILTER_OPERATORS.IN,
    })
  }

  return result
}

/**
 * Inverse of {@link collapseSameColumnEqualityFilters}, for menu display:
 * expand a multi-value `IN` filter into one simple "is" row per value,
 * OR-joined after the first row.
 *
 * @description The canonical stored state keeps multi-value equality as a
 * single `IN` entry in `columnFilters` (that's what the faceted dropdown
 * reads), but the filter menu should present it as plain per-value rows —
 * "Brand is Samsung / or Brand is Adidas" — not a "has any of" multi-select
 * row. Row `filterId`s derive from column + value (`brand-in-samsung`) so
 * identities stay stable across edit → collapse → expand cycles and rows
 * don't remount. `NOT_IN` ("has none of") has no per-row equivalent and is
 * left untouched. Returns the input array unchanged (same reference) when
 * nothing is expandable.
 */
export function expandMergedEqualityFilters<TData>(
  filters: ExtendedColumnFilter<TData>[],
): ExtendedColumnFilter<TData>[] {
  const isExpandable = (filter: ExtendedColumnFilter<TData>) =>
    filter.operator === FILTER_OPERATORS.IN &&
    Array.isArray(filter.value) &&
    filter.value.length > 0

  if (!filters.some(isExpandable)) return filters

  const result: ExtendedColumnFilter<TData>[] = []
  for (const filter of filters) {
    if (!isExpandable(filter)) {
      result.push(filter)
      continue
    }
    const values = filter.value as string[]
    values.forEach((value, index) => {
      result.push({
        ...filter,
        value,
        variant: FILTER_VARIANTS.SELECT,
        operator: FILTER_OPERATORS.EQ,
        filterId: `${filter.id}-in-${value}`,
        joinOperator:
          index === 0
            ? (filter.joinOperator ?? JOIN_OPERATORS.AND)
            : JOIN_OPERATORS.OR,
      })
    })
  }
  return result
}

/**
 * Process filters to detect OR logic and same-column filters. Auto-converts
 * same-column AND to OR for UX (e.g. "brand=apple AND brand=samsung" is
 * impossible), and collapses repeated same-column equality filters into a
 * single `IN` multi-select entry so the faceted dropdown and the advanced
 * filter menu stay in sync (see {@link collapseSameColumnEqualityFilters}).
 *
 * @param filters - Array of filters to process
 * @returns Object with `processedFilters`, `hasOrFilters`,
 *   `hasSameColumnFilters`, `shouldUseGlobalFilter`, and effective `joinOperator`.
 *
 * @example
 * ```ts
 * const result = processFiltersForLogic(filters)
 * if (result.shouldUseGlobalFilter) {
 *   setGlobalFilter({ filters: result.processedFilters, joinOperator: result.joinOperator })
 * } else {
 *   setColumnFilters(result.processedFilters.map(f => ({ id: f.id, value: f })))
 * }
 * ```
 */
export function processFiltersForLogic<TData>(
  inputFilters: ExtendedColumnFilter<TData>[],
): {
  processedFilters: ExtendedColumnFilter<TData>[]
  hasOrFilters: boolean
  hasSameColumnFilters: boolean
  shouldUseGlobalFilter: boolean
  joinOperator: typeof JOIN_OPERATORS.MIXED | typeof JOIN_OPERATORS.AND
} {
  // Merge repeated same-column equality filters first, so a "brand is X /
  // or brand is Y" menu pair becomes one faceted-readable IN entry rather than
  // two rows routed to globalFilter (where the dropdown can't see them).
  const filters = collapseSameColumnEqualityFilters(inputFilters)

  // Pending equality rows (no value yet) are inert and excluded from routing
  // decisions — a merged IN entry plus a just-added empty row must not
  // re-route the set to globalFilter (which would blank the faceted dropdown
  // mid-edit).
  const activeFilters = filters.filter(f => !isPendingEqualityFilter(f))

  // Check for explicit OR operators
  const hasOrFilters = activeFilters.some(
    (filter, index) => index > 0 && filter.joinOperator === JOIN_OPERATORS.OR,
  )

  // Check for multiple filters on the same column (UX: should use OR logic)
  const columnIds = activeFilters.map(f => f.id)
  const hasSameColumnFilters = columnIds.length !== new Set(columnIds).size

  // Process filters: convert same-column AND to OR for better UX
  const processedFilters = hasSameColumnFilters
    ? filters.map((filter, index) => {
        // If this is not the first filter and it's on the same column as a previous filter,
        // convert AND to OR for better UX (same column filters should use OR logic)
        const previousFilters = filters.slice(0, index)
        const hasSameColumnBefore = previousFilters.some(
          f => f.id === filter.id,
        )
        if (hasSameColumnBefore && filter.joinOperator === JOIN_OPERATORS.AND) {
          return { ...filter, joinOperator: JOIN_OPERATORS.OR }
        }
        return filter
      })
    : filters

  const shouldUseGlobalFilter = hasOrFilters || hasSameColumnFilters
  const joinOperator = shouldUseGlobalFilter
    ? JOIN_OPERATORS.MIXED
    : JOIN_OPERATORS.AND

  return {
    processedFilters,
    hasOrFilters,
    hasSameColumnFilters,
    shouldUseGlobalFilter,
    joinOperator,
  }
}
src/components/niko-table/config/feature-detection.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 {
  Children,
  isValidElement,
  type ComponentType,
  type PropsWithChildren,
  type ReactNode,
} from "react"

/**
 * Feature requirements that components can declare
 */
export interface FeatureRequirements {
  enableFilters?: boolean
  enablePagination?: boolean
  enableRowSelection?: boolean
  enableSorting?: boolean
  enableMultiSort?: boolean
  enableGrouping?: boolean
  enableExpanding?: boolean
  enableColumnResizing?: boolean
  manualSorting?: boolean
  manualPagination?: boolean
  manualFiltering?: boolean
  pageCount?: number
}

// Tree-walk detection is 50-150ms; cache by `children` identity. Client-only
// (SSR cache would mismatch) and skipped when `columns` provided (changes too
// often). Map (not WeakMap) since ReactNode can be primitive.
const detectionCache =
  typeof window !== "undefined" ? new Map<unknown, FeatureRequirements>() : null

// LRU cap so long-running apps don't leak.
const MAX_CACHE_SIZE = 50

/**
 * Component feature registry - maps component displayNames to their requirements
 */
const COMPONENT_FEATURES: Record<string, FeatureRequirements> = {
  // Pagination components
  DataTablePagination: { enablePagination: true },
  TablePagination: { enablePagination: true },

  // Filtering components
  DataTableViewMenu: { enableFilters: true },
  TableViewMenu: { enableFilters: true },
  DataTableViewDndMenu: { enableFilters: true },
  TableViewDndMenu: { enableFilters: true },

  // Column resize — a marker component; its presence turns on resizable columns.
  DataTableColumnResize: { enableColumnResizing: true },
  DataTableSearchFilter: { enableFilters: true },
  TableSearchFilter: { enableFilters: true },
  DataTableFacetedFilter: { enableFilters: true },
  TableFacetedFilter: { enableFilters: true },
  DataTableSliderFilter: { enableFilters: true },
  TableSliderFilter: { enableFilters: true },

  // Advanced filtering & sorting components
  DataTableSortMenu: { enableSorting: true },
  TableSortMenu: { enableSorting: true },
  DataTableFilterMenu: { enableFilters: true },
  TableFilterMenu: { enableFilters: true },

  DataTableDateFilter: { enableFilters: true },
  DataTableInlineFilter: { enableFilters: true },
  TableInlineFilter: { enableFilters: true },
  DataTableClearFilter: { enableFilters: true },
  TableClearFilter: { enableFilters: true },

  // Column-level filter menu components
  DataTableColumnFacetedFilterMenu: { enableFilters: true },
  TableColumnFacetedFilterMenu: { enableFilters: true },
  DataTableColumnFacetedFilterOptions: { enableFilters: true },
  TableColumnFacetedFilterOptions: { enableFilters: true },
  DataTableColumnSliderFilterMenu: { enableFilters: true },
  TableColumnSliderFilterMenu: { enableFilters: true },
  DataTableColumnSliderFilterOptions: { enableFilters: true },
  TableColumnSliderFilterOptions: { enableFilters: true },
  DataTableColumnDateFilterMenu: { enableFilters: true },
  TableColumnDateFilterMenu: { enableFilters: true },
  DataTableColumnDateFilterOptions: { enableFilters: true },
  TableColumnDateFilterOptions: { enableFilters: true },

  // Selection components
  DataTableSelectionBar: { enableRowSelection: true },

  // Sorting components (most components support sorting by default)
  DataTableColumnHeader: { enableSorting: true },
  TableColumnHeader: { enableSorting: true },
  TableColumnSortMenu: { enableSorting: true, enableMultiSort: true },
  DataTableColumnSortMenu: { enableSorting: true, enableMultiSort: true },
  TableColumnSortOptions: { enableSorting: true, enableMultiSort: true },
  DataTableColumnSortOptions: { enableSorting: true, enableMultiSort: true },

  // Grouping — also needs expanding so group rows can collapse/expand
  //
  // `DataTableGroupedRows` is the body-side marker: composing it is the whole
  // opt-in, so a table that renders group rows never has to set a config flag.
  DataTableGroupedRows: { enableGrouping: true, enableExpanding: true },
  TableGroupedRows: { enableGrouping: true, enableExpanding: true },
  TableColumnGroupOptions: { enableGrouping: true, enableExpanding: true },
  DataTableColumnGroupOptions: { enableGrouping: true, enableExpanding: true },
  TableColumnGroupMenu: { enableGrouping: true, enableExpanding: true },
  DataTableColumnGroupMenu: { enableGrouping: true, enableExpanding: true },
}

/**
 * Walks the React tree to aggregate feature requirements declared by child
 * components (via displayName) and column header functions.
 */
export function detectFeaturesFromChildren(
  children: ReactNode,
  columns?: Array<{ header?: unknown; enableColumnFilter?: boolean }>,
): FeatureRequirements {
  // Skip cache when `columns` provided — column content drives detection and
  // changes frequently, would return stale results.
  const shouldCache =
    detectionCache && !columns && children && typeof children === "object"

  if (shouldCache) {
    const cached = detectionCache.get(children)
    if (cached) {
      return cached
    }
  }

  const requirements: FeatureRequirements = {}

  const searchRecursively = (children: ReactNode) => {
    const childrenArray = Children.toArray(children)

    for (const child of childrenArray) {
      if (isValidElement(child)) {
        // Check if this component has feature requirements
        if (typeof child.type === "function") {
          const componentType = child.type as ComponentType<unknown> & {
            displayName?: string
          }
          const displayName = componentType.displayName
          const componentFeatures = displayName
            ? COMPONENT_FEATURES[displayName]
            : undefined

          if (componentFeatures) {
            // Merge requirements (any component requiring a feature enables it)
            Object.keys(componentFeatures).forEach(key => {
              const featureKey = key as keyof FeatureRequirements
              if (componentFeatures[featureKey]) {
                ;(requirements as Record<string, unknown>)[featureKey] = true
              }
            })
          }
        }

        // Recursively check nested children
        const propsWithChildren = child.props as PropsWithChildren<unknown>
        if (propsWithChildren?.children) {
          searchRecursively(propsWithChildren.children)
        }
      }
    }
  }

  // Check columns for header components (like TableColumnHeader, TableColumnSortMenu)
  if (columns && Array.isArray(columns)) {
    for (const column of columns) {
      // Check if column has enableColumnFilter set
      if (column.enableColumnFilter) {
        requirements.enableFilters = true
      }

      if (column.header && typeof column.header === "function") {
        try {
          // Try to call the header function with mock context to get the rendered component
          // Using unknown for the context type since we're creating a minimal mock
          const headerFn = column.header as (context: {
            column: Record<string, unknown>
          }) => ReactNode
          const headerResult = headerFn({
            column: {
              getCanSort: () => true,
              getIsSorted: () => false,
              toggleSorting: () => {},
              clearSorting: () => {},
              getCanHide: () => true,
              getIsVisible: () => true,
              toggleVisibility: () => {},
              getCanPin: () => true,
              getIsPinned: () => false,
              pin: () => {},
              getCanGroup: () => true,
              getIsGrouped: () => false,
              toggleGrouping: () => {},
              columnDef: { meta: {} },
              id: "mock",
            },
          })

          // Recursively check the header result and all its children for feature components
          const checkElementForFeatures = (element: ReactNode) => {
            if (!isValidElement(element)) return

            if (typeof element.type === "function") {
              const componentType = element.type as ComponentType<unknown> & {
                displayName?: string
              }
              const displayName = componentType.displayName
              const componentFeatures = displayName
                ? COMPONENT_FEATURES[displayName]
                : undefined

              if (componentFeatures) {
                Object.keys(componentFeatures).forEach(key => {
                  const featureKey = key as keyof FeatureRequirements
                  if (componentFeatures[featureKey]) {
                    ;(requirements as Record<string, unknown>)[featureKey] =
                      true
                  }
                })
              }
            }

            // Recursively check children
            const propsWithChildren =
              element.props as PropsWithChildren<unknown>
            if (propsWithChildren?.children) {
              Children.toArray(propsWithChildren.children).forEach(
                checkElementForFeatures,
              )
            }
          }

          checkElementForFeatures(headerResult)
        } catch {
          // Ignore errors from calling header function
        }
      }
    }
  }

  searchRecursively(children)

  // Cache the result only when caching is appropriate (no columns provided)
  if (shouldCache && detectionCache) {
    // Limit cache size to prevent memory leaks
    if (detectionCache.size >= MAX_CACHE_SIZE) {
      // Remove oldest entry (first in the map)
      const firstKey = detectionCache.keys().next().value
      if (firstKey !== undefined) {
        detectionCache.delete(firstKey)
      }
    }

    detectionCache.set(children, requirements)
  }

  return requirements
}
/**
 * Register a component's feature requirements
 * This allows third-party components to declare their needs
 */
export function registerComponentFeatures(
  displayName: string,
  features: FeatureRequirements,
) {
  COMPONENT_FEATURES[displayName] = features
}

/**
 * Get all registered components and their features (for debugging)
 */
export function getRegisteredComponents() {
  return { ...COMPONENT_FEATURES }
}
src/components/niko-table/hooks/use-data-table-scroll.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 { ScrollRowIntoView } from "../core/data-table-context"
import { useDataTable } from "../core/data-table-context"

/**
 * Scroll a row into view by its index in the current row model. Works on
 * virtualized bodies (the virtualizer registers itself) and plain bodies (DOM
 * `scrollIntoView` fallback), so consumers never branch on body type.
 *
 * Throws outside a `DataTableRoot` (inherited from `useDataTable`).
 */
export function useDataTableScroll(): {
  scrollRowIntoView: ScrollRowIntoView
} {
  const { scrollRowIntoView } = useDataTable()
  return { scrollRowIntoView }
}
src/components/niko-table/hooks/use-data-table-flash.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 { FlashCells, FlashRows } from "../core/data-table-context"
import { useDataTable } from "../core/data-table-context"

/**
 * Briefly highlight what just changed. `flashRows([id])` for a record-level
 * change (record-level — e.g. a member added to a game); `flashCells([{rowId,
 * columnId}])` for value-level changes (cell-level — e.g. a grid edit/paste).
 * Both scroll the first target into view (on virtualized bodies) then play a
 * soft fade pulse. Works on read tables and the editable grid alike.
 *
 * Throws outside a `DataTableRoot` (inherited from `useDataTable`).
 *
 * @example
 * const { flashRows, flashCells } = useDataTableFlash();
 * flashRows([updatedGameId]);                        // whole record changed
 * flashCells([{ rowId, columnId: "email" }]);        // one value changed
 */
export function useDataTableFlash(): {
  flashRows: FlashRows
  flashCells: FlashCells
} {
  const { flashRows, flashCells } = useDataTable()
  return { flashRows, flashCells }
}

More from niko-table

Browse all components in this library →