shadimage
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 · light
iconslucide-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

Browse all components in this library →