shadimage
niko-table
blockdata-table-gridsource: ok · hit

Data Grid (editable)

Composable editable spreadsheet grid: a headless engine (focus, selection, edit lifecycle, undo/redo, clipboard — all rowId-addressed so sort/filter work while editing) + a skin composed with DataTableRoot and the virtualized body. Keyboard cell nav, drag range-select with edge auto-scroll, quote-aware TSV copy/cut/paste with one-to-many tiling, fill handle, drag-move, dynamic columns, row drag-to-reorder, selection status bar, cross-highlight, and text/number/checkbox/date/select/combobox cells. Virtualized to 100k+ rows.

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

Where it goes

statsdata-tablekanban

Interaction

animated-counterdragdropdown

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

https://niko-table.com/r/data-table-core.jsonhttps://niko-table.com/r/data-table-virtualized.jsonhttps://niko-table.com/r/data-table-column-resize.jsonbuttoninputcheckboxcalendarpopovercommanddialogdropdown-menuseparatorkbdtooltip

Files31 · source cached 10/14/2026, 3:10:29 PM

src/components/niko-table/grid/types/grid-cell.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
 */
/**
 * niko-table grid — cell model.
 *
 * Part of the niko-table editable DataGrid (registry item `niko-table/grid`).
 * Domain-free: no app/entity types, safe to mirror to the registry.
 *
 * Each editable cell keeps three things separate so a pasted-but-unmatched
 * value stays visible (and correctable) rather than being silently dropped:
 *   raw    — the literal text the user typed or pasted
 *   value  — the resolved id / parsed value, or null when unmatched
 *   status — drives the cell's highlight (invalid = red)
 */

export type CellStatus = "valid" | "invalid" | "empty"

export interface CellState<T> {
  raw: string
  value: T | null
  status: CellStatus
  /** Human-readable reason shown in the cell's tooltip when invalid. */
  error?: string
}

/**
 * A cell is addressed by its row's stable id (NOT its position), so focus,
 * selection, and edits stay correct when the display is filtered / sorted /
 * reordered. The column is addressed by id too.
 */
export interface CellPosition {
  rowId: string
  columnId: string
}

/**
 * The copied rectangle (the "marching ants" copy outline), captured by identity so the
 * outline stays on the same cells across sort/filter. Sets give O(1) membership;
 * first/last ids mark which edges of a cell sit on the rectangle's perimeter.
 */
export interface CopiedRange {
  rowIds: Set<string>
  firstRowId: string
  lastRowId: string
  columnIds: Set<string>
  firstColumnId: string
  lastColumnId: string
}

/**
 * Inclusive bounds of the selection rectangle, in DISPLAY space: `minRow`/
 * `maxRow` are the visible (post filter/sort) row indices, `minColIndex`/
 * `maxColIndex` the visible column indices. Computed by the container, which
 * knows the display order.
 */
export interface SelectionBounds {
  minRow: number
  maxRow: number
  minColIndex: number
  maxColIndex: number
}

/**
 * A grid row: a client-only id (React key, never a persisted id) plus one
 * `CellState` per editable column. The index signature is permissive so
 * consumers can extend rows; the id is always a string.
 */
export interface GridRow {
  id: string
  [columnId: string]: CellState<string> | string
}

/** A blank, empty-status cell. */
export function emptyCell<T>(): CellState<T> {
  return { raw: "", value: null, status: "empty" }
}

/** Inclusive membership test for the selection rectangle. */
export function isCellInBounds(
  bounds: SelectionBounds | null,
  rowIndex: number,
  colIndex: number,
): boolean {
  return (
    !!bounds &&
    rowIndex >= bounds.minRow &&
    rowIndex <= bounds.maxRow &&
    colIndex >= bounds.minColIndex &&
    colIndex <= bounds.maxColIndex
  )
}
src/components/niko-table/grid/config/grid-shortcuts.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
 */

/**
 * niko-table grid — keyboard shortcut METADATA (a discoverable reference).
 *
 * The grid's actual keyboard is a raw scoped `keydown` state machine (the right
 * tool for an input surface — nav, type-to-edit, etc. aren't "hotkeys"). This
 * list is the human-readable mirror of it, for a help dialog or a consumer's own
 * shortcut UI. Keep it in sync with `<DataGrid>`'s handler when shortcuts change.
 */

import * as React from "react"

export interface GridShortcut {
  /** Key tokens. "Mod" renders as ⌘ on macOS, Ctrl elsewhere. */
  keys: string[]
  description: string
}

export interface GridShortcutGroup {
  category: string
  shortcuts: GridShortcut[]
}

export const GRID_SHORTCUTS: GridShortcutGroup[] = [
  {
    category: "Navigation",
    shortcuts: [
      { keys: ["↑", "↓", "←", "→"], description: "Move between cells" },
      { keys: ["Tab"], description: "Next cell (appends a row off the end)" },
      { keys: ["Shift", "Tab"], description: "Previous cell" },
      { keys: ["Mod", "↑↓←→"], description: "Jump to the edge of the data" },
      { keys: ["Home"], description: "First column (⌘/Ctrl: first cell)" },
      { keys: ["End"], description: "Last column (⌘/Ctrl: last cell)" },
      { keys: ["PageUp"], description: "Up one page" },
      { keys: ["PageDown"], description: "Down one page" },
    ],
  },
  {
    category: "Editing",
    shortcuts: [
      {
        keys: ["Enter"],
        description: "Edit the cell, or commit and move down",
      },
      { keys: ["Type"], description: "Overwrite the cell" },
      { keys: ["Delete"], description: "Clear the selected cells" },
      {
        keys: ["Drag ⤡"],
        description: "Fill handle — drag to fill down/across",
      },
      { keys: ["Double-click ⤡"], description: "Fill handle — auto-fill down" },
      { keys: ["Esc"], description: "Cancel edit / clear copy marker" },
    ],
  },
  {
    category: "Selection",
    shortcuts: [
      { keys: ["Drag"], description: "Select a range" },
      {
        keys: ["Drag border"],
        description: "Move the selection (hold ⌘/Ctrl to copy)",
      },
      { keys: ["Shift", "Click"], description: "Extend selection to a cell" },
      { keys: ["Shift", "↑↓←→"], description: "Extend selection" },
      {
        keys: ["Mod", "Shift", "↑↓←→"],
        description: "Extend to the data edge",
      },
      { keys: ["Mod", "A"], description: "Select all cells" },
    ],
  },
  {
    category: "Clipboard",
    shortcuts: [
      { keys: ["Mod", "C"], description: "Copy" },
      { keys: ["Mod", "X"], description: "Cut" },
      { keys: ["Mod", "V"], description: "Paste" },
    ],
  },
  {
    category: "History",
    shortcuts: [
      { keys: ["Mod", "Z"], description: "Undo" },
      { keys: ["Mod", "Shift", "Z"], description: "Redo" },
    ],
  },
]

const subscribeToNothing = () => () => {}

function readIsMac(): boolean {
  const nav = navigator as Navigator & {
    userAgentData?: { platform?: string }
  }
  const platform = nav.userAgentData?.platform ?? nav.platform ?? nav.userAgent
  return /Mac|iPhone|iPad|iPod/i.test(platform)
}

/**
 * True on macOS (⌘) vs Ctrl platforms. Hydration-safe: the server snapshot is
 * `false` and the client value resolves right after hydration, so SSR'd "Ctrl"
 * markup never mismatches a "⌘" client render.
 */
export function useIsMac(): boolean {
  return React.useSyncExternalStore(subscribeToNothing, readIsMac, () => false)
}

/** Render a key token to its display label for the current platform. */
export function formatShortcutKey(token: string, isMac: boolean): string {
  switch (token) {
    case "Mod":
      return isMac ? "⌘" : "Ctrl"
    case "Shift":
      return isMac ? "⇧" : "Shift"
    case "Enter":
      return isMac ? "⏎" : "Enter"
    case "Esc":
      return "Esc"
    default:
      return token
  }
}
src/components/niko-table/grid/hooks/use-data-grid.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
 */
/**
 * niko-table grid — headless interaction engine.
 *
 * Part of the niko-table editable DataGrid (registry item `niko-table/grid`).
 * Domain-free. Owns the grid STATE a table model has no concept of: the
 * focused cell, the selection anchor, the edit lifecycle, the copy-source
 * marching-ants — all addressed by ROW ID (not position) so they survive
 * filtering / sorting / reordering. Navigation and selection GEOMETRY live in
 * the container (`<DataGrid>`), which knows the display order; this hook is pure
 * state + id-keyed setters.
 */

import { useCallback, useEffect, useMemo, useReducer, useState } from "react"

import {
  type CellPosition,
  type CellState,
  type GridRow,
} from "../types/grid-cell"

/** Max undo/redo depth. Snapshots share unchanged row objects, so memory is
 *  roughly O(changes), not O(rows × history). */
const MAX_HISTORY = 100

/**
 * A general "what changed last commit" signal (see `UseDataGrid.lastCommit`).
 * `ids` enumerates the touched rows for granular ops (the O(1) hot path);
 * `bulk`/`reset` don't enumerate. `seq` advances on every real commit (never on
 * a no-op) so observers can `useEffect([lastCommit?.seq])`. Domain-free — an
 * analytics hook, a dirty-badge, or `useGridChanges` can all read it.
 */
export type GridCommit =
  | { kind: "set" | "add" | "remove"; ids: readonly string[]; seq: number }
  | { kind: "bulk" | "reset"; seq: number }

/** Meta each commit is tagged with so the reducer can surface `lastCommit`. */
type GridCommitMeta =
  | { kind: "set" | "add" | "remove"; ids: readonly string[] }
  | { kind: "bulk" | "reset" }

interface HistoryState<TRow> {
  rows: TRow[]
  past: TRow[][]
  future: TRow[][]
  lastCommit: GridCommit | null
}

type HistoryAction<TRow> =
  | { type: "commit"; updater: (rows: TRow[]) => TRow[]; meta: GridCommitMeta }
  | { type: "settle"; updater: (rows: TRow[]) => TRow[] }
  | { type: "undo" }
  | { type: "redo" }

/**
 * True when `next` is the same row list as `prev` — either by array identity
 * or by element identity (same length, every `next[i] === prev[i]`).
 *
 * Updaters often `rows.map(...)` and return the same row refs for unchanged
 * rows. Without the element-wise check, a brand-new array of identical refs
 * would still bump `seq` and re-fire `lastCommit` observers (e.g. validation
 * settle loops forever after the first edit).
 */
function isSameRowList<TRow>(prev: TRow[], next: TRow[]): boolean {
  if (next === prev) return true
  if (next.length !== prev.length) return false
  for (let i = 0; i < next.length; i++) {
    if (next[i] !== prev[i]) return false
  }
  return true
}

function historyReducer<TRow>(
  state: HistoryState<TRow>,
  action: HistoryAction<TRow>,
): HistoryState<TRow> {
  const seq = (state.lastCommit?.seq ?? 0) + 1
  switch (action.type) {
    case "commit": {
      const next = action.updater(state.rows)
      if (isSameRowList(state.rows, next)) return state // no-op → no history entry, seq unchanged
      const past =
        state.past.length >= MAX_HISTORY
          ? [...state.past.slice(1), state.rows]
          : [...state.past, state.rows]
      return {
        rows: next,
        past,
        future: [],
        lastCommit: { ...action.meta, seq },
      }
    }
    case "settle": {
      // A non-undoable row update: re-settling derived state (e.g. validation
      // status folded onto cells after an edit) must NOT push a history entry
      // or clear the redo stack, or undo/redo would step through the settle
      // instead of the user's actual edits. Past/future are left untouched.
      const next = action.updater(state.rows)
      if (isSameRowList(state.rows, next)) return state // converged → no change, no seq bump
      return { ...state, rows: next, lastCommit: { kind: "bulk", seq } }
    }
    case "undo": {
      if (state.past.length === 0) return state
      const prev = state.past[state.past.length - 1]!
      return {
        rows: prev,
        past: state.past.slice(0, -1),
        future: [state.rows, ...state.future],
        lastCommit: { kind: "bulk", seq },
      }
    }
    case "redo": {
      if (state.future.length === 0) return state
      const next = state.future[0]!
      return {
        rows: next,
        past: [...state.past, state.rows],
        future: state.future.slice(1),
        lastCommit: { kind: "bulk", seq },
      }
    }
    default:
      return state
  }
}

export interface UseDataGridConfig<TRow extends GridRow> {
  /** Editable column ids in visual order — the focusable/navigable cells. */
  columnIds: readonly string[]
  /** Factory for a fresh, all-empty row. Owns id generation. */
  createEmptyRow: (id: string) => TRow
  /** Hard cap on rows (e.g. a server's bulk-create max). Default 200. */
  maxRows?: number
  /** Rows to seed on mount. Default 5. Ignored when `initialRows` is given. */
  initialRowCount?: number
  /** Seed with these exact rows (e.g. editing existing data) instead of blanks. */
  initialRows?: TRow[]
  /**
   * Client id generator for runtime inserts (`addRows` / `insertRows`).
   * Default `crypto.randomUUID()`. Injectable for tests.
   *
   * Blank grids seeded via `initialRowCount` (no `initialRows`) use
   * deterministic `row-0`… ids so SSR and hydration agree — `makeId` is not
   * used for that path.
   */
  makeId?: () => string
}

export interface UseDataGrid<TRow extends GridRow> {
  rows: TRow[]
  /** What changed in the last commit — an observe-changes signal for external
   *  layers (e.g. `useGridChanges` for staging/autosave/CRUD). Null until the
   *  first commit; `seq` advances only on a real (non-no-op) commit. */
  lastCommit: GridCommit | null
  /** Active cell (moving corner of the selection + edit target), by id. */
  focusedCell: CellPosition | null
  /** Open cell editor, or null in navigate mode. */
  editingCell: CellPosition | null
  /** Fixed corner of the selection rectangle (null ⇒ single-cell selection). */
  selectionAnchor: CellPosition | null
  /** Char to seed the editor with when editing starts by typing (else null). */
  editSeed: string | null
  /** Select a single cell (collapses any range + closes the editor). */
  selectCell: (pos: CellPosition) => void
  /** Clear the cell selection (focus + anchor + editing). */
  deselect: () => void
  /** Extend the selection so the active corner becomes `pos` (anchor stays). */
  extendSelectionTo: (pos: CellPosition) => void
  /** Open the editor for a cell, optionally seeding it with a typed char. */
  startEditing: (pos: CellPosition, seed?: string | null) => void
  stopEditing: () => void
  /** Set a single resolved cell by row id (O(1): only that row's ref changes). */
  setCell: (rowId: string, columnId: string, next: CellState<string>) => void
  /**
   * Replace the whole rows array (paste expand + fill, sort, etc.). Undoable by
   * default; pass `{ history: false }` to re-settle derived state (validation)
   * without pushing an undo entry or clearing the redo stack.
   */
  updateRows: (
    updater: (rows: TRow[]) => TRow[],
    opts?: { history?: boolean },
  ) => void
  /** Append `count` empty rows; returns the created rows (with ids). */
  addRows: (count: number) => TRow[]
  /** Insert `count` empty rows above/below a row (by id); returns them. */
  insertRows: (
    anchorRowId: string,
    position: "above" | "below",
    count: number,
  ) => TRow[]
  removeRow: (rowId: string) => void
  removeRows: (rowIds: Iterable<string>) => void
  /** Replace every row with blanks (count = seed length or `initialRowCount`). */
  clearAll: () => void
  /** Undo the last data mutation (setCell / paste / insert / delete / clear). */
  undo: () => void
  redo: () => void
  canUndo: boolean
  canRedo: boolean
  /** Editable column ids in visual order — for the container's navigation. */
  columnIds: readonly string[]
  maxRows: number
}

export function useDataGrid<TRow extends GridRow>(
  config: UseDataGridConfig<TRow>,
): UseDataGrid<TRow> {
  const {
    columnIds,
    createEmptyRow,
    maxRows = 200,
    initialRowCount = 5,
    initialRows,
    makeId = () => crypto.randomUUID(),
  } = config

  const [history, dispatch] = useReducer(
    historyReducer<TRow>,
    null,
    (): HistoryState<TRow> => ({
      // Deterministic ids for the blank seed — `crypto.randomUUID()` here would
      // mismatch SSR vs client. Runtime inserts still go through `makeId`.
      rows:
        initialRows ??
        Array.from({ length: initialRowCount }, (_, i) =>
          createEmptyRow(`row-${i}`),
        ),
      past: [],
      future: [],
      lastCommit: null,
    }),
  )
  const rows = history.rows
  const lastCommit = history.lastCommit
  const canUndo = history.past.length > 0
  const canRedo = history.future.length > 0

  const [focusedCell, setFocusedCell] = useState<CellPosition | null>(null)
  const [editingCell, setEditingCell] = useState<CellPosition | null>(null)
  const [selectionAnchor, setSelectionAnchor] = useState<CellPosition | null>(
    null,
  )
  const [editSeed, setEditSeed] = useState<string | null>(null)

  const selectCell = useCallback((pos: CellPosition) => {
    setFocusedCell(pos)
    setSelectionAnchor(pos)
    setEditingCell(null)
    setEditSeed(null)
  }, [])

  // Clear the cell selection entirely (click-away, deselect a column/row).
  const deselect = useCallback(() => {
    setFocusedCell(null)
    setSelectionAnchor(null)
    setEditingCell(null)
    setEditSeed(null)
  }, [])

  const extendSelectionTo = useCallback(
    (pos: CellPosition) => {
      setSelectionAnchor(anchor => anchor ?? focusedCell ?? pos)
      setFocusedCell(pos)
      // Match `selectCell`: leave edit mode so a shift-drag can't leave an
      // open editor committing against a selection the user has already moved.
      setEditingCell(null)
      setEditSeed(null)
    },
    [focusedCell],
  )

  const startEditing = useCallback(
    (pos: CellPosition, seed: string | null = null) => {
      setFocusedCell(pos)
      setSelectionAnchor(pos)
      setEditingCell(pos)
      setEditSeed(seed)
    },
    [],
  )

  const stopEditing = useCallback(() => {
    setEditingCell(null)
    setEditSeed(null)
  }, [])

  // O(1) commit: only the matching row's object identity changes. All row
  // mutations flow through `dispatch({ type: "commit" })` so each is undoable.
  const setCell = useCallback(
    (rowId: string, columnId: string, next: CellState<string>) => {
      dispatch({
        type: "commit",
        updater: prev =>
          prev.map(row =>
            row.id === rowId ? ({ ...row, [columnId]: next } as TRow) : row,
          ),
        meta: { kind: "set", ids: [rowId] },
      })
    },
    [],
  )

  const updateRows = useCallback(
    (updater: (rows: TRow[]) => TRow[], opts?: { history?: boolean }) => {
      const boundedUpdater = (prev: TRow[]) => {
        // Preserve the updater's no-op identity (`prev` back out) so the
        // reducer's no-op guard holds and no phantom history entry is pushed.
        const next = updater(prev)
        if (next === prev) return prev
        return next.length > maxRows ? next.slice(0, maxRows) : next
      }
      // `history: false` re-settles derived state without an undoable entry —
      // used by validation to fold cell status after an edit (see the reducer's
      // `settle` case). Everything else stays undoable.
      dispatch(
        opts?.history === false
          ? { type: "settle", updater: boundedUpdater }
          : { type: "commit", meta: { kind: "bulk" }, updater: boundedUpdater },
      )
    },
    [maxRows],
  )

  const addRows = useCallback(
    (count: number) => {
      const room = Math.max(0, maxRows - rows.length)
      const toAdd = Math.min(count, room)
      const created = Array.from({ length: toAdd }, () =>
        createEmptyRow(makeId()),
      )
      if (created.length > 0)
        dispatch({
          type: "commit",
          updater: prev => [...prev, ...created],
          meta: { kind: "add", ids: created.map(r => r.id) },
        })
      return created
    },
    [rows.length, createEmptyRow, makeId, maxRows],
  )

  const insertRows = useCallback(
    (anchorRowId: string, position: "above" | "below", count: number) => {
      const room = Math.max(0, maxRows - rows.length)
      const toAdd = Math.min(count, room)
      const created = Array.from({ length: toAdd }, () =>
        createEmptyRow(makeId()),
      )
      if (created.length > 0) {
        dispatch({
          type: "commit",
          updater: prev => {
            const idx = prev.findIndex(r => r.id === anchorRowId)
            if (idx === -1) return [...prev, ...created]
            const at = position === "above" ? idx : idx + 1
            const next = prev.slice()
            next.splice(at, 0, ...created)
            return next
          },
          meta: { kind: "add", ids: created.map(r => r.id) },
        })
      }
      return created
    },
    [rows.length, createEmptyRow, makeId, maxRows],
  )

  // Drop any focus/selection/edit state that pointed at removed rows —
  // otherwise a stale focusedCell leaves the grid cursor-less until the next
  // click, and a stale anchor produces a bogus selection rectangle.
  const releaseRemovedRows = useCallback((removed: (id: string) => boolean) => {
    setFocusedCell(fc => (fc && removed(fc.rowId) ? null : fc))
    setSelectionAnchor(a => (a && removed(a.rowId) ? null : a))
    setEditingCell(ec => (ec && removed(ec.rowId) ? null : ec))
  }, [])

  const removeRow = useCallback(
    (rowId: string) => {
      // Decide OUTSIDE the reducer updater (React may defer/replay it): when
      // the last-row guard blocks the delete, focus/edit state on the
      // surviving row must be kept — releasing it would silently close an
      // open editor and drop its draft even though nothing was deleted.
      if (rows.length <= 1 || !rows.some(r => r.id === rowId)) return
      dispatch({
        type: "commit",
        updater: prev => {
          if (prev.length <= 1) return prev
          const next = prev.filter(r => r.id !== rowId)
          return next.length === prev.length ? prev : next
        },
        meta: { kind: "remove", ids: [rowId] },
      })
      releaseRemovedRows(id => id === rowId)
    },
    [rows, releaseRemovedRows],
  )

  const removeRows = useCallback(
    (rowIds: Iterable<string>) => {
      const ids = new Set(rowIds)
      if (!rows.some(r => ids.has(r.id))) return
      dispatch({
        type: "commit",
        updater: prev => {
          const kept = prev.filter(r => !ids.has(r.id))
          if (kept.length === prev.length) return prev
          return kept.length > 0 ? kept : [createEmptyRow(makeId())]
        },
        meta: { kind: "remove", ids: [...ids] },
      })
      releaseRemovedRows(id => ids.has(id))
    },
    [rows, createEmptyRow, makeId, releaseRemovedRows],
  )

  // Replace every row with blanks. Row count matches the seed (`initialRows`
  // length, else `initialRowCount`) so a 500-row stress demo stays 500-tall.
  const clearAll = useCallback(() => {
    const count = initialRows?.length ?? initialRowCount
    dispatch({
      type: "commit",
      updater: () =>
        Array.from({ length: count }, () => createEmptyRow(makeId())),
      meta: { kind: "reset" },
    })
    setFocusedCell(null)
    setEditingCell(null)
    setSelectionAnchor(null)
    setEditSeed(null)
  }, [createEmptyRow, initialRowCount, initialRows, makeId])

  const undo = useCallback(() => dispatch({ type: "undo" }), [])
  const redo = useCallback(() => dispatch({ type: "redo" }), [])

  // Undo/redo can restore or drop rows without going through removeRow —
  // drop focus/anchor/edit that point at ids no longer in `rows`.
  useEffect(() => {
    const present = new Set(rows.map(r => r.id))
    releaseRemovedRows(id => !present.has(id))
  }, [rows, releaseRemovedRows])

  return useMemo(
    () => ({
      rows,
      lastCommit,
      focusedCell,
      editingCell,
      selectionAnchor,
      editSeed,
      selectCell,
      deselect,
      extendSelectionTo,
      startEditing,
      stopEditing,
      setCell,
      updateRows,
      addRows,
      insertRows,
      removeRow,
      removeRows,
      clearAll,
      undo,
      redo,
      canUndo,
      canRedo,
      columnIds,
      maxRows,
    }),
    [
      rows,
      lastCommit,
      focusedCell,
      editingCell,
      selectionAnchor,
      editSeed,
      selectCell,
      deselect,
      extendSelectionTo,
      startEditing,
      stopEditing,
      setCell,
      updateRows,
      addRows,
      insertRows,
      removeRow,
      removeRows,
      clearAll,
      undo,
      redo,
      canUndo,
      canRedo,
      columnIds,
      maxRows,
    ],
  )
}
src/components/niko-table/grid/hooks/use-grid-clipboard.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
 */
/**
 * niko-table grid — clipboard pure helpers.
 *
 * Part of the niko-table editable DataGrid (registry item `niko-table/grid`).
 * Domain-free, unit-testable without React. The copy/cut/paste WIRING lives in
 * the `<DataGrid>` container (it needs the display order); these helpers do the
 * TSV serialization/parsing over display-ordered rows.
 */

import type { CellState, GridRow } from "../types/grid-cell"

/**
 * Read a row's cell raw text. Rows created before a dynamically added column
 * have no CellState entry for it — treat that as empty, never crash.
 */
function rawOf(row: GridRow, columnId: string): string {
  return (row[columnId] as CellState<string> | undefined)?.raw ?? ""
}

/**
 * Quote a cell for TSV when it contains a tab, newline, or quote — the same
 * convention Excel/Google Sheets use on their clipboard, so multi-line cells
 * round-trip instead of tearing into extra rows.
 */
function encodeTsvCell(raw: string): string {
  return /[\t\n\r"]/.test(raw) ? `"${raw.replace(/"/g, '""')}"` : raw
}

/**
 * Split clipboard text into a matrix: rows on newlines, cells on tabs.
 * Quote-aware: a cell wrapped in double quotes may contain literal tabs and
 * newlines, and `""` escapes a quote (Excel/Sheets clipboard convention).
 */
export function parseTsv(text: string): string[][] {
  // Strip only ONE trailing newline (the terminator external editors append) —
  // NOT all of them, so a selection ending in blank rows round-trips.
  const normalized = text.replace(/\r\n?/g, "\n").replace(/\n$/, "")
  const rows: string[][] = []
  let row: string[] = []
  let cell = ""
  let inQuotes = false
  for (let i = 0; i < normalized.length; i++) {
    const ch = normalized[i]!
    if (inQuotes) {
      if (ch === '"') {
        if (normalized[i + 1] === '"') {
          cell += '"'
          i++
        } else {
          inQuotes = false
        }
      } else {
        cell += ch
      }
    } else if (ch === '"' && cell === "") {
      inQuotes = true
    } else if (ch === "\t") {
      row.push(cell)
      cell = ""
    } else if (ch === "\n") {
      row.push(cell)
      rows.push(row)
      row = []
      cell = ""
    } else {
      cell += ch
    }
  }
  row.push(cell)
  rows.push(row)
  return rows
}

/**
 * Serialize a selection rectangle to TSV (raw cell text). Rows are pulled lazily
 * by DISPLAY index via `getRow`, so this is O(selection) — it never touches rows
 * outside the rectangle (important at large row counts). `getRow` typically maps
 * a display index to the TanStack row's `.original`.
 */
export function serializeSelection(
  getRow: (displayIndex: number) => GridRow | undefined,
  columnIds: readonly string[],
  bounds: {
    minRow: number
    maxRow: number
    minColIndex: number
    maxColIndex: number
  },
): string {
  const lines: string[] = []
  for (let r = bounds.minRow; r <= bounds.maxRow; r++) {
    const row = getRow(r)
    if (!row) continue
    const cells: string[] = []
    for (let c = bounds.minColIndex; c <= bounds.maxColIndex; c++) {
      const columnId = columnIds[c]
      cells.push(columnId ? encodeTsvCell(rawOf(row, columnId)) : "")
    }
    lines.push(cells.join("\t"))
  }
  return lines.join("\n")
}
src/components/niko-table/grid/hooks/use-grid-columns.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"

/** GridRow's reserved row-identity key — a column may not shadow it. */
const RESERVED_COLUMN_ID = "id"

/**
 * A dynamic column descriptor. `type` is a consumer-defined tag (e.g. "text",
 * "select", "date") that the consumer maps to a cell editor + resolver — the
 * grid itself stays type-agnostic. Extra per-type config (e.g. `options` for a
 * select) rides along and is passed straight back to the consumer's renderer.
 */
export interface GridColumnSpec {
  id: string
  label: string
  type: string
  width?: number
  options?: readonly string[]
}

export interface GridColumnsApi {
  /** Current columns, in display order. Build your `tableColumns` from this. */
  columns: GridColumnSpec[]
  /** Derived id list, in order — feed straight to `useDataGrid({ columnIds })`. */
  columnIds: string[]
  /**
   * Append (or insert at `atIndex`) a new column and return it. Missing fields
   * default to a text column labelled "Column N". Existing rows need no change
   * — a cell with no value for the new column renders empty.
   */
  addColumn: (
    spec?: Partial<GridColumnSpec>,
    atIndex?: number,
  ) => GridColumnSpec
  /** Remove a column. Its cell data is left orphaned in the rows (harmless). */
  removeColumn: (id: string) => void
  /** Rename a column's header label. */
  renameColumn: (id: string, label: string) => void
  /** Replace a column's type (and optional per-type config). */
  setColumnType: (
    id: string,
    type: string,
    extra?: Pick<GridColumnSpec, "options">,
  ) => void
  /** Move a column one slot left (`-1`) or right (`1`). No-op at the edges. */
  moveColumn: (id: string, dir: -1 | 1) => void
  /**
   * Reorder columns to match `orderedIds` (e.g. from a drag-reorder view menu's
   * `onColumnOrderChange`). Ids not listed keep their relative order at the end.
   */
  reorderColumns: (orderedIds: string[]) => void
}

export interface UseGridColumnsOptions {
  initialColumns: GridColumnSpec[]
  /** Mint a unique id for a new column. Default: an incrementing `newcol_N`. */
  makeColumnId?: () => string
}

/**
 * Owns the grid's column definitions as STATE so they can be added, renamed,
 * retyped, reordered, and removed at runtime. Opt-in: a grid with a fixed
 * column set never uses this. Pass `.columnIds` to `useDataGrid` and build your
 * TanStack `tableColumns` from `.columns`; drop `<DataGridColumns value={…}>`
 * around the table to wire the header menu + add-column button.
 */
export function useGridColumns({
  initialColumns,
  makeColumnId,
}: UseGridColumnsOptions): GridColumnsApi {
  // Drop any column that shadows the reserved `id` key (would corrupt row.id),
  // and any later duplicate id — every column id must be unique.
  const [columns, setColumns] = React.useState<GridColumnSpec[]>(() => {
    const seen = new Set<string>()
    return initialColumns.filter(c => {
      if (c.id === RESERVED_COLUMN_ID || seen.has(c.id)) return false
      seen.add(c.id)
      return true
    })
  })

  const idRef = React.useRef(0)
  const mintId = React.useCallback(
    () => makeColumnId?.() ?? `newcol_${idRef.current++}`,
    [makeColumnId],
  )

  // Live set of active column ids, kept in sync so `addColumn` can guarantee a
  // unique id: a caller-supplied `spec.id` may collide with an existing column,
  // and a minted id may too after removes/remounts. Reserving synchronously
  // covers back-to-back adds in one tick (state updates are async).
  const idSetRef = React.useRef<Set<string>>(new Set(columns.map(c => c.id)))
  React.useEffect(() => {
    idSetRef.current = new Set(columns.map(c => c.id))
  }, [columns])
  const mintUniqueId = React.useCallback(
    (preferred?: string) => {
      const active = idSetRef.current
      let id =
        preferred && preferred !== RESERVED_COLUMN_ID ? preferred : mintId()
      while (id === RESERVED_COLUMN_ID || active.has(id)) id = mintId()
      active.add(id)
      return id
    },
    [mintId],
  )
  // Monotonic default-label counter. Reading `columns.length` would be stale
  // across a batch of synchronous addColumn calls (state updates are async), so
  // rapid back-to-back adds would collide on the same "Column N"; a ref doesn't.
  const labelIndexRef = React.useRef(initialColumns.length)

  const addColumn = React.useCallback<GridColumnsApi["addColumn"]>(
    (spec, atIndex) => {
      // Resolve id + label UP FRONT so the returned column matches exactly what
      // gets inserted. A spec that shadows the reserved `id` key is remapped.
      const created: GridColumnSpec = {
        id: mintUniqueId(spec?.id),
        label: spec?.label || `Column ${(labelIndexRef.current += 1)}`,
        type: spec?.type ?? "text",
        ...(spec?.width !== undefined ? { width: spec.width } : {}),
        ...(spec?.options !== undefined ? { options: spec.options } : {}),
      }
      setColumns(prev => {
        const at =
          atIndex === undefined
            ? prev.length
            : Math.max(0, Math.min(atIndex, prev.length))
        const copy = prev.slice()
        copy.splice(at, 0, created)
        return copy
      })
      return created
    },
    [mintUniqueId],
  )

  const removeColumn = React.useCallback((id: string) => {
    setColumns(prev =>
      prev.length <= 1 ? prev : prev.filter(c => c.id !== id),
    )
  }, [])

  const renameColumn = React.useCallback((id: string, label: string) => {
    setColumns(prev => prev.map(c => (c.id === id ? { ...c, label } : c)))
  }, [])

  const setColumnType = React.useCallback<GridColumnsApi["setColumnType"]>(
    (id, type, extra) => {
      setColumns(prev =>
        prev.map(c =>
          c.id === id ? { ...c, type, options: extra?.options } : c,
        ),
      )
    },
    [],
  )

  const moveColumn = React.useCallback((id: string, dir: -1 | 1) => {
    setColumns(prev => {
      const i = prev.findIndex(c => c.id === id)
      const j = i + dir
      if (i < 0 || j < 0 || j >= prev.length) return prev
      const copy = prev.slice()
      const [moved] = copy.splice(i, 1)
      copy.splice(j, 0, moved!)
      return copy
    })
  }, [])

  const reorderColumns = React.useCallback((orderedIds: string[]) => {
    setColumns(prev => {
      const rank = new Map(orderedIds.map((id, i) => [id, i]))
      // Stable sort by the requested rank; unlisted ids keep their order last.
      return prev
        .map((c, i) => ({ c, i }))
        .sort((a, b) => {
          const ra = rank.get(a.c.id)
          const rb = rank.get(b.c.id)
          if (ra === undefined && rb === undefined) return a.i - b.i
          if (ra === undefined) return 1
          if (rb === undefined) return -1
          return ra - rb
        })
        .map(({ c }) => c)
    })
  }, [])

  const columnIds = React.useMemo(() => columns.map(c => c.id), [columns])

  return React.useMemo(
    () => ({
      columns,
      columnIds,
      addColumn,
      removeColumn,
      renameColumn,
      setColumnType,
      moveColumn,
      reorderColumns,
    }),
    [
      columns,
      columnIds,
      addColumn,
      removeColumn,
      renameColumn,
      setColumnType,
      moveColumn,
      reorderColumns,
    ],
  )
}
src/components/niko-table/grid/hooks/use-grid-navigation.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 {
  clampGridIndex as clamp,
  type GridDisplayRow,
} from "../core/data-grid-features"
import type { CellPosition, CellState, GridRow } from "../types/grid-cell"
import type { UseDataGrid } from "./use-data-grid"

/** PageUp/Down sizing fallbacks when no row / scroll container is measurable. */
const FALLBACK_ROW_HEIGHT = 36
const FALLBACK_VIEWPORT_HEIGHT = 400

interface UseGridNavigationOptions<TRow extends GridRow> {
  grid: UseDataGrid<TRow>
  orderedRows: readonly GridDisplayRow[]
  columnIds: readonly string[]
  displayIndexOf: (id: string) => number | undefined
  focusedCell: CellPosition | null
  wrapperRef: React.RefObject<HTMLDivElement | null>
}

export interface GridNavigation {
  /** Move the active cell by a delta in DISPLAY order (arrows). */
  moveFocus: (dRow: number, dCol: number, extend?: boolean) => void
  /** Tab to the next/previous cell (appends a row off the end). */
  tabNext: (reverse: boolean) => void
  /** Select or extend to an absolute display cell (Home/End variants). */
  moveTo: (rowIdx: number, colIdx: number, extend: boolean) => void
  /** Ctrl+A — select the whole grid. */
  selectAll: () => void
  /** Ctrl+Arrow — jump to the edge of the contiguous data block (the standard jump-to-data-edge behavior). */
  moveToEdge: (dRow: number, dCol: number, extend: boolean) => void
  /** Rows per "page" for PageUp/PageDown (from the scroll viewport). */
  pageRows: () => number
}

/**
 * Display-order navigation for the grid core: arrows, Tab, Home/End,
 * Ctrl+Arrow edge jumps, select-all, and page sizing.
 */
export function useGridNavigation<TRow extends GridRow>({
  grid,
  orderedRows,
  columnIds,
  displayIndexOf,
  focusedCell,
  wrapperRef,
}: UseGridNavigationOptions<TRow>): GridNavigation {
  const moveFocus = React.useCallback(
    (dRow: number, dCol: number, extend = false) => {
      if (orderedRows.length === 0 || columnIds.length === 0) return
      const base = focusedCell ?? {
        rowId: orderedRows[0]!.id,
        columnId: columnIds[0]!,
      }
      const curRow = displayIndexOf(base.rowId) ?? 0
      const nextRow = clamp(curRow + dRow, 0, orderedRows.length - 1)
      const curCol = columnIds.indexOf(base.columnId)
      const nextCol = clamp(curCol + dCol, 0, columnIds.length - 1)
      const pos: CellPosition = {
        rowId: orderedRows[nextRow]!.id,
        columnId: columnIds[nextCol]!,
      }
      if (extend) grid.extendSelectionTo(pos)
      else grid.selectCell(pos)
    },
    [orderedRows, displayIndexOf, focusedCell, columnIds, grid],
  )

  const tabNext = React.useCallback(
    (reverse: boolean) => {
      if (orderedRows.length === 0 || columnIds.length === 0) return
      const lastCol = columnIds.length - 1
      const base = focusedCell ?? {
        rowId: orderedRows[0]!.id,
        columnId: columnIds[0]!,
      }
      const rowDisp = displayIndexOf(base.rowId) ?? 0
      const colIdx = columnIds.indexOf(base.columnId)

      let nextRowDisp = rowDisp
      let nextCol: number
      if (reverse) {
        if (colIdx > 0) nextCol = colIdx - 1
        else {
          nextCol = lastCol
          nextRowDisp = Math.max(0, rowDisp - 1)
        }
      } else if (colIdx < lastCol) {
        nextCol = colIdx + 1
      } else {
        nextCol = 0
        if (rowDisp + 1 >= orderedRows.length) {
          // Tab off the last cell → append a row and land on it.
          const created = grid.addRows(1)
          if (created.length > 0) {
            grid.selectCell({
              rowId: created[0]!.id,
              columnId: columnIds[0]!,
            })
          }
          return
        }
        nextRowDisp = rowDisp + 1
      }
      grid.selectCell({
        rowId: orderedRows[nextRowDisp]!.id,
        columnId: columnIds[nextCol]!,
      })
    },
    [orderedRows, displayIndexOf, focusedCell, columnIds, grid],
  )

  const moveTo = React.useCallback(
    (rowIdx: number, colIdx: number, extend: boolean) => {
      if (orderedRows.length === 0 || columnIds.length === 0) return
      const r = clamp(rowIdx, 0, orderedRows.length - 1)
      const c = clamp(colIdx, 0, columnIds.length - 1)
      const pos: CellPosition = {
        rowId: orderedRows[r]!.id,
        columnId: columnIds[c]!,
      }
      if (extend) grid.extendSelectionTo(pos)
      else grid.selectCell(pos)
    },
    [orderedRows, columnIds, grid],
  )

  const selectAll = React.useCallback(() => {
    if (orderedRows.length === 0 || columnIds.length === 0) return
    grid.selectCell({ rowId: orderedRows[0]!.id, columnId: columnIds[0]! })
    grid.extendSelectionTo({
      rowId: orderedRows[orderedRows.length - 1]!.id,
      columnId: columnIds[columnIds.length - 1]!,
    })
  }, [orderedRows, columnIds, grid])

  // Ctrl+Arrow — from a filled cell, land on the last filled cell of the
  // contiguous block; from an empty/edge cell, skip to the next filled cell
  // (or the far edge).
  const moveToEdge = React.useCallback(
    (dRow: number, dCol: number, extend: boolean) => {
      if (!focusedCell || orderedRows.length === 0 || columnIds.length === 0)
        return
      const rowMax = orderedRows.length - 1
      const colMax = columnIds.length - 1
      const startRow = displayIndexOf(focusedCell.rowId) ?? 0
      const startCol = columnIds.indexOf(focusedCell.columnId)
      const inBounds = (r: number, c: number) =>
        r >= 0 && r <= rowMax && c >= 0 && c <= colMax
      const isEmptyAt = (r: number, c: number) => {
        const row = orderedRows[r]?.original
        const col = columnIds[c]
        if (!row || !col) return true
        // Rows created before a dynamically added column have no entry for it.
        const cell = row[col] as CellState<string> | undefined
        return (cell?.raw ?? "") === ""
      }

      let r = startRow
      let c = startCol
      if (!inBounds(r + dRow, c + dCol)) {
        moveTo(r, c, extend)
        return
      }
      if (isEmptyAt(r, c) || isEmptyAt(r + dRow, c + dCol)) {
        // Skip to the next filled cell (or the far edge).
        r += dRow
        c += dCol
        while (inBounds(r + dRow, c + dCol) && isEmptyAt(r, c)) {
          r += dRow
          c += dCol
        }
      } else {
        // Walk to the last filled cell of the contiguous block.
        while (inBounds(r + dRow, c + dCol) && !isEmptyAt(r + dRow, c + dCol)) {
          r += dRow
          c += dCol
        }
      }
      moveTo(r, c, extend)
    },
    [focusedCell, orderedRows, columnIds, displayIndexOf, moveTo],
  )

  const pageRows = React.useCallback(() => {
    const scrollEl = wrapperRef.current?.querySelector<HTMLElement>(
      '[data-slot="table-container"]',
    )
    const rowEl = wrapperRef.current?.querySelector<HTMLElement>("[data-index]")
    const rowH = rowEl?.getBoundingClientRect().height || FALLBACK_ROW_HEIGHT
    const viewport = scrollEl?.clientHeight || FALLBACK_VIEWPORT_HEIGHT
    return Math.max(1, Math.floor(viewport / rowH) - 1)
  }, [wrapperRef])

  return React.useMemo(
    () => ({ moveFocus, tabNext, moveTo, selectAll, moveToEdge, pageRows }),
    [moveFocus, tabNext, moveTo, selectAll, moveToEdge, pageRows],
  )
}
src/components/niko-table/grid/hooks/use-grid-keyboard.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 { CellPosition, GridRow } from "../types/grid-cell"
import type { UseDataGrid } from "./use-data-grid"
import type { GridNavigation } from "./use-grid-navigation"

interface UseGridKeyboardOptions<TRow extends GridRow> {
  grid: UseDataGrid<TRow>
  nav: GridNavigation
  focusedCell: CellPosition | null
  editingCell: CellPosition | null
  displayIndexOf: (id: string) => number | undefined
  rowCount: number
  columnCount: number
  clearSelection: () => void
  /** From the clipboard feature, when mounted — Escape clears the copy marker. */
  clearCopiedRange: (() => void) | undefined
  onRequestShortcuts: (() => void) | undefined
}

/**
 * The grid's scoped keyboard state machine — a raw `keydown` handler, NOT a
 * hotkey lib, because a grid is an input surface (type-to-edit must catch any
 * printable char, matching how text and grid editors handle input). Attach to the
 * grid wrapper's `onKeyDownCapture` (capture phase beats inner comboboxes).
 */
export function useGridKeyboard<TRow extends GridRow>({
  grid,
  nav,
  focusedCell,
  editingCell,
  displayIndexOf,
  rowCount,
  columnCount,
  clearSelection,
  clearCopiedRange,
  onRequestShortcuts,
}: UseGridKeyboardOptions<TRow>) {
  const { moveFocus, tabNext, moveTo, selectAll, moveToEdge, pageRows } = nav

  return (event: React.KeyboardEvent<HTMLDivElement>) => {
    if (editingCell) return

    // Only treat keys as grid shortcuts when they originate from a cell (or
    // the grid wrapper itself, so focusing the grid + arrows still works).
    // Toolbar buttons, add-row footers, and other controls nested inside the
    // wrapper keep their native keyboard behavior (Enter/Space activation).
    const target = event.target as HTMLElement
    if (target !== event.currentTarget && !target.closest("[data-cell]")) {
      return
    }

    // Undo / redo — Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z, Ctrl+Y. (Cmd+C/X/V fall
    // through to `<DataGridClipboard>`'s native listeners; while editing, the
    // input's own undo handles it — gated by the `editingCell` guard above.)
    const mod = event.metaKey || event.ctrlKey
    if (mod) {
      const k = event.key.toLowerCase()
      if (k === "z") {
        event.preventDefault()
        event.stopPropagation()
        if (event.shiftKey) grid.redo()
        else grid.undo()
        return
      }
      if (k === "y") {
        event.preventDefault()
        event.stopPropagation()
        grid.redo()
        return
      }
      if (k === "a") {
        event.preventDefault()
        event.stopPropagation()
        selectAll()
        return
      }
    }

    switch (event.key) {
      case "Tab":
        event.preventDefault()
        event.stopPropagation()
        tabNext(event.shiftKey)
        return
      // Ctrl/Cmd+Arrow jumps to the data-block edge; plain Arrow moves one cell.
      case "ArrowUp":
        event.preventDefault()
        event.stopPropagation()
        if (mod) moveToEdge(-1, 0, event.shiftKey)
        else moveFocus(-1, 0, event.shiftKey)
        return
      case "ArrowDown":
        event.preventDefault()
        event.stopPropagation()
        if (mod) moveToEdge(1, 0, event.shiftKey)
        else moveFocus(1, 0, event.shiftKey)
        return
      case "ArrowLeft":
        event.preventDefault()
        event.stopPropagation()
        if (mod) moveToEdge(0, -1, event.shiftKey)
        else moveFocus(0, -1, event.shiftKey)
        return
      case "ArrowRight":
        event.preventDefault()
        event.stopPropagation()
        if (mod) moveToEdge(0, 1, event.shiftKey)
        else moveFocus(0, 1, event.shiftKey)
        return
      case "Home":
        // Home → first column of the row; Ctrl+Home → first cell of the grid.
        event.preventDefault()
        event.stopPropagation()
        if (mod) moveTo(0, 0, event.shiftKey)
        else if (focusedCell)
          moveTo(displayIndexOf(focusedCell.rowId) ?? 0, 0, event.shiftKey)
        return
      case "End":
        // End → last column of the row; Ctrl+End → last cell of the grid.
        event.preventDefault()
        event.stopPropagation()
        if (mod) moveTo(rowCount - 1, columnCount - 1, event.shiftKey)
        else if (focusedCell)
          moveTo(
            displayIndexOf(focusedCell.rowId) ?? 0,
            columnCount - 1,
            event.shiftKey,
          )
        return
      case "PageUp":
        event.preventDefault()
        event.stopPropagation()
        moveFocus(-pageRows(), 0, event.shiftKey)
        return
      case "PageDown":
        event.preventDefault()
        event.stopPropagation()
        moveFocus(pageRows(), 0, event.shiftKey)
        return
      case "Delete":
      case "Backspace":
        // Clear the selected cells (standard behavior). Editing is guarded above.
        event.preventDefault()
        event.stopPropagation()
        clearSelection()
        return
      case "Enter":
        if (focusedCell) {
          event.preventDefault()
          event.stopPropagation()
          grid.startEditing(focusedCell)
        }
        return
      case "Escape":
        event.preventDefault()
        event.stopPropagation()
        clearCopiedRange?.()
        if (focusedCell) grid.selectCell(focusedCell)
        return
    }
    // `?` opens the shortcuts help (opt-in) instead of type-to-edit.
    if (
      focusedCell &&
      onRequestShortcuts &&
      event.key === "?" &&
      !event.metaKey &&
      !event.ctrlKey
    ) {
      event.preventDefault()
      event.stopPropagation()
      onRequestShortcuts()
      return
    }

    if (
      focusedCell &&
      !event.metaKey &&
      !event.ctrlKey &&
      !event.altKey &&
      event.key.length === 1
    ) {
      event.preventDefault()
      event.stopPropagation()
      grid.startEditing(focusedCell, event.key === " " ? undefined : event.key)
    }
  }
}
src/components/niko-table/grid/core/data-grid.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 * as React from "react"

import { TooltipProvider } from "@/components/ui/tooltip"

import { useDataTable } from "../../core/data-table-context"
import { useDataTableScroll } from "../../hooks/use-data-table-scroll"
import type { UseDataGrid } from "../hooks/use-data-grid"
import { useGridKeyboard } from "../hooks/use-grid-keyboard"
import { useGridNavigation } from "../hooks/use-grid-navigation"
import {
  emptyCell,
  type CellPosition,
  type CellState,
  type GridRow,
  type SelectionBounds,
} from "../types/grid-cell"
import { DataGridContextProvider } from "./data-grid-context"
import {
  DataGridFeaturesProvider,
  DataGridInternalsProvider,
  GRID_EDGE_SPEED,
  GRID_EDGE_ZONE,
  type DataGridInternals,
  type GridEnv,
  type GridFeatures,
  type RegisterGridFeature,
} from "./data-grid-features"

export interface DataGridProps<TRow extends GridRow> {
  grid: UseDataGrid<TRow>
  children: React.ReactNode
  className?: string
  /**
   * Called when the user presses `?` (with a focused cell, not editing) — wire
   * it to open a keyboard-shortcuts help dialog. When unset, `?` types normally.
   */
  onRequestShortcuts?: () => void
}

/**
 * The DataGrid container — the irreducible core. Place INSIDE `DataTableRoot`,
 * wrapping the `DataTable`. It reads the table's DISPLAY order (post
 * filter/sort) and owns the selection rectangle, keyboard navigation, drag
 * range-select, and the scroll-into-view seam. Cells (addressed by row id)
 * read their state from `DataGridContext`.
 *
 * Everything else is opt-in, composable children (mix and match — an unmounted
 * feature attaches no listeners and tree-shakes out of the bundle):
 *
 * @example
 * <DataGrid grid={grid}>
 *   <DataGridClipboard resolveCell={resolveCell} />  // copy/cut/paste
 *   <DataGridFillHandle />                           // corner-drag fill
 *   <DataGridMove />                                 // border-drag move/copy
 *   <DataGridCrossHighlight />                       // header/gutter highlight
 *   <DataTable>…</DataTable>
 * </DataGrid>
 */

// Radix's TooltipProvider reads `delayDuration`, Base UI's reads `delay`;
// spread so it typechecks in both shadcn generations.
const gridTooltipDelay = { delayDuration: 0, delay: 0 }

export function DataGrid<TRow extends GridRow>({
  grid,
  children,
  className,
  onRequestShortcuts,
}: DataGridProps<TRow>) {
  const wrapperRef = React.useRef<HTMLDivElement>(null)
  const { scrollRowIntoView } = useDataTableScroll()
  const { table } = useDataTable<TRow>()

  // Display order (post filter/sort) — the source of truth for navigation.
  // `row.index` is the row's SOURCE (data-array) position, which diverges from
  // its display position once the table is sorted or filtered — so we build the
  // id→DISPLAY-index map from `rows` array positions. Memoized on the row-model
  // identity: it rebuilds only when the model actually changes (incl. per edit),
  // not on unrelated renders.
  const rowModel = table.getRowModel()
  const orderedRows = rowModel.rows
  const displayIndexById = React.useMemo(() => {
    const map = new Map<string, number>()
    orderedRows.forEach((r, i) => map.set(r.id, i))
    return map
  }, [orderedRows])
  const displayIndexOf = React.useCallback(
    (id: string): number | undefined => displayIndexById.get(id),
    [displayIndexById],
  )

  const { focusedCell, editingCell, selectionAnchor } = grid

  // Display-space COLUMNS: the engine's editable columns filtered + ordered by
  // what the table actually renders (visibility, pinning, column order). Keeps
  // keyboard traversal, selection bounds, and paste targeting aligned with the
  // mounted cells when the user hides or pins columns via the header menus.
  const { columnVisibility, columnOrder, columnPinning } = table.state
  const gridColumnIds = grid.columnIds
  const columnIds = React.useMemo(() => {
    const editable = new Set(gridColumnIds)
    return table
      .getVisibleLeafColumns()
      .map(c => c.id)
      .filter(id => editable.has(id))
    // eslint-disable-next-line react-hooks/exhaustive-deps -- visibility/order/pinning are the reactive inputs behind getVisibleLeafColumns()
  }, [table, gridColumnIds, columnVisibility, columnOrder, columnPinning])

  // Selection rectangle in DISPLAY space.
  const selectionBounds = React.useMemo<SelectionBounds | null>(() => {
    if (!focusedCell) return null
    const anchor = selectionAnchor ?? focusedCell
    const aRow = displayIndexOf(anchor.rowId)
    const fRow = displayIndexOf(focusedCell.rowId)
    if (aRow === undefined || fRow === undefined) return null
    const aCol = columnIds.indexOf(anchor.columnId)
    const fCol = columnIds.indexOf(focusedCell.columnId)
    // Hidden / reordered-away columns yield -1 — never treat that as a bound.
    if (aCol < 0 || fCol < 0) return null
    return {
      minRow: Math.min(aRow, fRow),
      maxRow: Math.max(aRow, fRow),
      minColIndex: Math.min(aCol, fCol),
      maxColIndex: Math.max(aCol, fCol),
    }
  }, [focusedCell, selectionAnchor, columnIds, displayIndexOf])

  // Drop focus when the focused row/column leaves the display model (filter,
  // hide column). Keeps Enter / type-to-edit from targeting a ghost cell.
  const deselect = grid.deselect
  React.useEffect(() => {
    if (!focusedCell) return
    const rowGone = displayIndexOf(focusedCell.rowId) === undefined
    const colGone = columnIds.indexOf(focusedCell.columnId) < 0
    if (rowGone || colGone) deselect()
  }, [focusedCell, displayIndexOf, columnIds, deselect])

  // --- opt-in features (registered by mounted children) -------------------
  const [features, setFeatures] = React.useState<GridFeatures>({})
  const registerFeature = React.useCallback<RegisterGridFeature>(
    (key, payload) => {
      setFeatures(prev =>
        prev[key] === payload ? prev : { ...prev, [key]: payload },
      )
    },
    [],
  )

  // --- scroll-into-view seam ---------------------------------------------
  // Coarse vertical scroll (for far-off rows that aren't rendered yet) goes
  // through the virtualizer's `scrollToIndex`. Then we fine-tune BOTH axes from
  // the focused cell's real rect — the virtualizer's `align: "auto"` no-ops when
  // the target row is already rendered (within overscan) but below the visible
  // fold, so single-step arrow nav wouldn't scroll. Direct measurement fixes it,
  // and it mirrors exactly how the horizontal axis already worked.
  React.useLayoutEffect(() => {
    if (!focusedCell) return
    const idx = displayIndexOf(focusedCell.rowId)
    if (idx !== undefined) scrollRowIntoView(idx, { align: "auto" })

    // rAF so the row is mounted (after a virtualized vertical scroll) before we
    // measure its cell for the fine-grained adjustment.
    const raf = requestAnimationFrame(() => {
      const wrapper = wrapperRef.current
      const scrollEl = wrapper?.querySelector<HTMLElement>(
        '[data-slot="table-container"]',
      )
      const cellEl = wrapper?.querySelector<HTMLElement>(
        `[data-cell="${focusedCell.rowId}:${focusedCell.columnId}"]`,
      )
      if (!scrollEl || !cellEl) return
      const cont = scrollEl.getBoundingClientRect()
      const cell = cellEl.getBoundingClientRect()

      // Horizontal: sticky pins overlap the edges. Only LEFT-pinned cells have
      // a non-auto `left` — right pins are also sticky but must inset the
      // right edge (treating every sticky td as left used to yank scrollLeft).
      const rowEl = cellEl.closest("tr")
      let leftInset = 0
      let rightInset = 0
      rowEl?.querySelectorAll<HTMLElement>("td").forEach(td => {
        // The focused cell itself must not inset the scroll: a pinned focused
        // cell is already visible, and counting it would yank scrollLeft.
        if (td === cellEl) return
        const style = getComputedStyle(td)
        if (style.position !== "sticky") return
        const rect = td.getBoundingClientRect()
        if (style.left !== "auto") {
          leftInset = Math.max(leftInset, rect.right - cont.left)
        }
        if (style.right !== "auto") {
          rightInset = Math.max(rightInset, cont.right - rect.left)
        }
      })
      if (cell.left < cont.left + leftInset) {
        scrollEl.scrollLeft -= cont.left + leftInset - cell.left
      } else if (cell.right > cont.right - rightInset) {
        scrollEl.scrollLeft += cell.right - (cont.right - rightInset)
      }

      // Vertical: the sticky header overlaps the top edge — offset past it
      // (mirror of `leftInset`) so the focused row never hides under the header.
      const headerEl = scrollEl.querySelector<HTMLElement>("thead")
      const topInset = headerEl?.getBoundingClientRect().height ?? 0
      if (cell.top < cont.top + topInset) {
        scrollEl.scrollTop -= cont.top + topInset - cell.top
      } else if (cell.bottom > cont.bottom) {
        scrollEl.scrollTop += cell.bottom - cont.bottom
      }
    })
    return () => cancelAnimationFrame(raf)
  }, [focusedCell, displayIndexOf, scrollRowIntoView])

  // --- navigation (display order) ----------------------------------------
  const nav = useGridNavigation({
    grid,
    orderedRows,
    columnIds,
    displayIndexOf,
    focusedCell,
    wrapperRef,
  })
  const { moveFocus, tabNext } = nav

  // Display index → the row's data (for lazy TSV serialization).
  const getDisplayRow = React.useCallback(
    (i: number): GridRow | undefined => orderedRows[i]?.original as GridRow,
    [orderedRows],
  )

  // --- selection actions (used by keyboard + GridRowMenu + features) ------
  const selectedRowIds = React.useCallback((): string[] => {
    if (!selectionBounds) return []
    const ids: string[] = []
    for (let r = selectionBounds.minRow; r <= selectionBounds.maxRow; r++) {
      const id = orderedRows[r]?.id
      if (id) ids.push(id)
    }
    return ids
  }, [selectionBounds, orderedRows])

  const clearSelection = React.useCallback(() => {
    if (!selectionBounds) return
    const ids = new Set(selectedRowIds())
    const { minColIndex, maxColIndex } = selectionBounds
    grid.updateRows(rows =>
      rows.map(row => {
        if (!ids.has(row.id)) return row
        const patched = { ...row } as TRow
        for (let c = minColIndex; c <= maxColIndex; c++) {
          const col = columnIds[c]
          if (col) (patched as GridRow)[col] = emptyCell<string>()
        }
        return patched
      }),
    )
  }, [selectionBounds, selectedRowIds, columnIds, grid])

  const deleteSelectedRows = React.useCallback(() => {
    const ids = selectedRowIds()
    if (ids.length > 0) grid.removeRows(ids)
  }, [selectedRowIds, grid])

  const insertRowsAbove = React.useCallback(() => {
    if (!selectionBounds) return
    const topId = orderedRows[selectionBounds.minRow]?.id
    const count = selectionBounds.maxRow - selectionBounds.minRow + 1
    if (topId) grid.insertRows(topId, "above", count)
  }, [selectionBounds, orderedRows, grid])

  const insertRowsBelow = React.useCallback(() => {
    if (!selectionBounds) return
    const bottomId = orderedRows[selectionBounds.maxRow]?.id
    const count = selectionBounds.maxRow - selectionBounds.minRow + 1
    if (bottomId) grid.insertRows(bottomId, "below", count)
  }, [selectionBounds, orderedRows, grid])

  // Select an entire column (header click) — top to bottom, that one column.
  // Clicking a column that's already fully selected deselects it (toggle).
  const selectColumn = React.useCallback(
    (columnId: string) => {
      if (orderedRows.length === 0) return
      const c = columnIds.indexOf(columnId)
      const b = selectionBounds
      const already =
        b != null &&
        b.minRow === 0 &&
        b.maxRow === orderedRows.length - 1 &&
        b.minColIndex === c &&
        b.maxColIndex === c
      if (already) {
        grid.deselect()
        return
      }
      grid.selectCell({ rowId: orderedRows[0]!.id, columnId })
      grid.extendSelectionTo({
        rowId: orderedRows[orderedRows.length - 1]!.id,
        columnId,
      })
    },
    [orderedRows, columnIds, selectionBounds, grid],
  )

  // Select an entire row (row-number click) — all columns, that one row.
  // Clicking a row that's already fully selected deselects it (toggle).
  const selectRow = React.useCallback(
    (rowId: string) => {
      if (columnIds.length === 0) return
      const r = displayIndexOf(rowId)
      const b = selectionBounds
      const already =
        b != null &&
        r != null &&
        b.minRow === r &&
        b.maxRow === r &&
        b.minColIndex === 0 &&
        b.maxColIndex === columnIds.length - 1
      if (already) {
        grid.deselect()
        return
      }
      grid.selectCell({ rowId, columnId: columnIds[0]! })
      grid.extendSelectionTo({
        rowId,
        columnId: columnIds[columnIds.length - 1]!,
      })
    },
    [columnIds, selectionBounds, displayIndexOf, grid],
  )

  // Fill every selected cell with one value (Ctrl/Cmd+Enter from an editor).
  const fillSelectionWith = React.useCallback(
    (cell: CellState<string>) => {
      if (!selectionBounds) return
      const ids = new Set(selectedRowIds())
      const { minColIndex, maxColIndex } = selectionBounds
      grid.updateRows(rows =>
        rows.map(row => {
          if (!ids.has(row.id)) return row
          const patched = { ...row } as TRow
          for (let c = minColIndex; c <= maxColIndex; c++) {
            const col = columnIds[c]
            if (col) (patched as GridRow)[col] = { ...cell }
          }
          return patched
        }),
      )
    },
    [selectionBounds, selectedRowIds, columnIds, grid],
  )

  // Latest values once-subscribed listeners / rAF loops read (shared with
  // feature components through the internals context).
  const envRef = React.useRef<GridEnv>({
    orderedRows: [],
    columnIds: [],
    displayIndexOf,
    grid: grid as unknown as UseDataGrid<GridRow>,
    selectionBounds: null,
  })
  envRef.current = {
    orderedRows,
    columnIds,
    displayIndexOf,
    grid: grid as unknown as UseDataGrid<GridRow>,
    selectionBounds,
  }

  // Select the whole display-space rectangle (fill/move land here after apply).
  const selectRange = React.useCallback((bounds: SelectionBounds) => {
    const env = envRef.current
    const anchorRowId = env.orderedRows[bounds.minRow]?.id
    const anchorColId = env.columnIds[bounds.minColIndex]
    const focusRowId = env.orderedRows[bounds.maxRow]?.id
    const focusColId = env.columnIds[bounds.maxColIndex]
    if (!anchorRowId || !anchorColId || !focusRowId || !focusColId) return
    env.grid.selectCell({ rowId: anchorRowId, columnId: anchorColId })
    env.grid.extendSelectionTo({ rowId: focusRowId, columnId: focusColId })
  }, [])

  // --- drag range-select --------------------------------------------------
  const draggingRef = React.useRef(false)
  const pointerRef = React.useRef({ x: 0, y: 0 })
  const rafRef = React.useRef<number | null>(null)

  const dragStep = React.useCallback(() => {
    if (!draggingRef.current) {
      rafRef.current = null
      return
    }
    const scrollEl = wrapperRef.current?.querySelector<HTMLElement>(
      '[data-slot="table-container"]',
    )
    if (scrollEl) {
      const rect = scrollEl.getBoundingClientRect()
      const { x, y } = pointerRef.current
      if (y < rect.top + GRID_EDGE_ZONE) scrollEl.scrollTop -= GRID_EDGE_SPEED
      else if (y > rect.bottom - GRID_EDGE_ZONE)
        scrollEl.scrollTop += GRID_EDGE_SPEED
      if (x < rect.left + GRID_EDGE_ZONE) scrollEl.scrollLeft -= GRID_EDGE_SPEED
      else if (x > rect.right - GRID_EDGE_ZONE)
        scrollEl.scrollLeft += GRID_EDGE_SPEED

      const attr = document
        .elementFromPoint(x, y)
        ?.closest("[data-cell]")
        ?.getAttribute("data-cell")
      if (attr) {
        const sep = attr.indexOf(":")
        grid.extendSelectionTo({
          rowId: attr.slice(0, sep),
          columnId: attr.slice(sep + 1),
        })
      }
    }
    rafRef.current = requestAnimationFrame(dragStep)
  }, [grid])

  // Is a cell inside the current selection rectangle (display space)?
  const isPosInSelection = React.useCallback(
    (pos: CellPosition): boolean => {
      if (!selectionBounds) return false
      const r = displayIndexOf(pos.rowId)
      if (r === undefined) return false
      const c = columnIds.indexOf(pos.columnId)
      return (
        r >= selectionBounds.minRow &&
        r <= selectionBounds.maxRow &&
        c >= selectionBounds.minColIndex &&
        c <= selectionBounds.maxColIndex
      )
    },
    [selectionBounds, displayIndexOf, columnIds],
  )

  const onCellMouseDown = React.useCallback(
    (pos: CellPosition, e: React.MouseEvent) => {
      // A cell editor (date calendar, combobox, etc.) portals its content out of
      // this cell's DOM, but React events still bubble through the React tree to
      // this handler. A mousedown INSIDE an open editor must NOT reselect the
      // cell — `selectCell` clears `editingCell`, unmounting the editor before
      // its own click / onSelect fires, so picking a value by mouse would
      // silently no-op. Editors mark their portaled surface with
      // `data-grid-cell-editor`.
      if (
        (e.target as HTMLElement | null)?.closest?.("[data-grid-cell-editor]")
      )
        return
      // Right-click: keep the selection if the cell is inside it (so the context
      // menu acts on the whole range, the standard behavior); otherwise move to the cell.
      // Never start a drag — let the context menu open.
      if (e.button === 2) {
        if (!isPosInSelection(pos)) grid.selectCell(pos)
        return
      }
      if (e.shiftKey) grid.extendSelectionTo(pos)
      else grid.selectCell(pos)
      // Seed the pointer from the mousedown itself — the drag rAF may run
      // before the first mousemove, and a stale pointer would extend the
      // selection toward wherever the cursor last was.
      pointerRef.current = { x: e.clientX, y: e.clientY }
      draggingRef.current = true
      if (rafRef.current == null)
        rafRef.current = requestAnimationFrame(dragStep)
    },
    [grid, dragStep, isPosInSelection],
  )

  const onCellMouseEnter = React.useCallback(
    (pos: CellPosition) => {
      if (draggingRef.current) grid.extendSelectionTo(pos)
    },
    [grid],
  )

  // Core listeners: track the pointer (shared with features through
  // `pointerRef`), end a drag range-select, and clear the selection on a
  // click OUTSIDE the grid (clicking away deselects, the standard behavior). Feature
  // components (fill, move) attach their own drag-scoped listeners only while
  // their drag is active.
  React.useEffect(() => {
    const onMove = (e: MouseEvent) => {
      pointerRef.current = { x: e.clientX, y: e.clientY }
    }
    const onUp = () => {
      draggingRef.current = false
      if (rafRef.current != null) {
        cancelAnimationFrame(rafRef.current)
        rafRef.current = null
      }
    }
    const onDocDown = (e: MouseEvent) => {
      const wrapper = wrapperRef.current
      const target = e.target as HTMLElement | null
      if (!wrapper || !target) return
      // Inside the grid → keep the selection.
      if (wrapper.contains(target)) return
      // Inside a portaled grid surface (menu / dialog / popover / cell editor)
      // → keep it too, so acting on the selection doesn't clear it first.
      if (
        target.closest(
          '[role="menu"],[role="dialog"],[data-slot*="menu"],[data-slot*="dialog"],[data-slot*="popover"],[data-grid-cell-editor]',
        )
      ) {
        return
      }
      grid.deselect()
    }
    window.addEventListener("mousemove", onMove)
    window.addEventListener("mouseup", onUp)
    window.addEventListener("mousedown", onDocDown, true)
    return () => {
      window.removeEventListener("mousemove", onMove)
      window.removeEventListener("mouseup", onUp)
      window.removeEventListener("mousedown", onDocDown, true)
      if (rafRef.current != null) cancelAnimationFrame(rafRef.current)
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps -- grid.deselect is stable; mount-once listeners
  }, [])

  // --- keyboard model -----------------------------------------------------
  // The scoped keydown state machine (see `use-grid-keyboard.ts`). Escape also
  // clears the clipboard feature's copy marker when that feature is mounted.
  const onKeyDownCapture = useGridKeyboard({
    grid,
    nav,
    focusedCell,
    editingCell,
    displayIndexOf,
    rowCount: orderedRows.length,
    columnCount: columnIds.length,
    clearSelection,
    clearCopiedRange: features.clipboard?.clearCopiedRange,
    onRequestShortcuts,
  })

  const contextValue = React.useMemo(
    () => ({
      grid,
      selectionBounds,
      columnIds,
      displayIndexOf,
      onCellMouseDown,
      onCellMouseEnter,
      moveFocus,
      tabNext,
      clearSelection,
      deleteSelectedRows,
      insertRowsAbove,
      insertRowsBelow,
      selectColumn,
      selectRow,
      fillSelectionWith,
      hasSelection: selectionBounds !== null,
    }),
    [
      grid,
      selectionBounds,
      columnIds,
      displayIndexOf,
      onCellMouseDown,
      onCellMouseEnter,
      moveFocus,
      tabNext,
      clearSelection,
      deleteSelectedRows,
      insertRowsAbove,
      insertRowsBelow,
      selectColumn,
      selectRow,
      fillSelectionWith,
    ],
  )

  const internals = React.useMemo<DataGridInternals>(
    () => ({
      grid: grid as unknown as UseDataGrid<GridRow>,
      selectionBounds,
      orderedRows,
      columnIds,
      displayIndexOf,
      getDisplayRow,
      selectRange,
      clearSelection,
      wrapperRef,
      pointerRef,
      envRef,
    }),
    [
      grid,
      selectionBounds,
      orderedRows,
      columnIds,
      displayIndexOf,
      getDisplayRow,
      selectRange,
      clearSelection,
    ],
  )

  return (
    <TooltipProvider {...gridTooltipDelay}>
      <DataGridContextProvider value={contextValue}>
        <DataGridInternalsProvider value={internals}>
          <DataGridFeaturesProvider
            features={features}
            register={registerFeature}
          >
            <div
              ref={wrapperRef}
              role="grid"
              tabIndex={0}
              onKeyDownCapture={onKeyDownCapture}
              className={className ?? "outline-none"}
            >
              {children}
            </div>
          </DataGridFeaturesProvider>
        </DataGridInternalsProvider>
      </DataGridContextProvider>
    </TooltipProvider>
  )
}
src/components/niko-table/grid/core/data-grid-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 { cn } from "@/lib/utils"
import * as React from "react"

import type { CellEditorProps } from "../cells/cell-props"
import type { UseDataGrid } from "../hooks/use-data-grid"
import {
  type CellPosition,
  type CellState,
  type GridRow,
  type SelectionBounds,
  emptyCell,
  isCellInBounds,
} from "../types/grid-cell"
import { useDataGridFeatures } from "./data-grid-features"

// ---------------------------------------------------------------------------
// Context — the CORE container provides the engine, the display-space
// selection rectangle, navigation, and selection actions to cells + menus.
// Opt-in capabilities (clipboard, fill, move, cross-highlight) publish through
// the separate features context — see `data-grid-features.tsx`.
// ---------------------------------------------------------------------------

export interface DataGridContextValue<TRow extends GridRow> {
  grid: UseDataGrid<TRow>
  /** Selection rectangle in DISPLAY space (post filter/sort). */
  selectionBounds: SelectionBounds | null
  /**
   * Editable columns in DISPLAY order — the engine's columns filtered/ordered
   * by the table's visibility, pinning, and column order.
   */
  columnIds: readonly string[]
  /**
   * Map a row id → its index in the current display order (post filter/sort).
   * Prefer this over TanStack's `row.index`, which is the source-data index.
   */
  displayIndexOf: (rowId: string) => number | undefined
  /** Mouse-down on a cell: focus (or shift-extend) + begin a drag-select. */
  onCellMouseDown: (pos: CellPosition, e: React.MouseEvent) => void
  /** Mouse-enter on a cell while dragging: extend the selection. */
  onCellMouseEnter: (pos: CellPosition) => void
  /** Move the active cell in DISPLAY order (arrows). */
  moveFocus: (deltaRow: number, deltaCol: number, extend?: boolean) => void
  /** Tab to the next/previous cell in display order (appends a row off the end). */
  tabNext: (reverse: boolean) => void
  // Selection actions (display-order aware — implemented by the container).
  clearSelection: () => void
  deleteSelectedRows: () => void
  insertRowsAbove: () => void
  insertRowsBelow: () => void
  /** Select the whole column (top to bottom). */
  selectColumn: (columnId: string) => void
  /** Select the whole row (all columns). */
  selectRow: (rowId: string) => void
  /** Fill every selected cell with one value (Ctrl/Cmd+Enter). */
  fillSelectionWith: (next: CellState<string>) => void
  hasSelection: boolean
}

const MISSING = Symbol("no-data-grid-context")
const DataGridContext = React.createContext<
  DataGridContextValue<GridRow> | typeof MISSING
>(MISSING)

export function DataGridContextProvider<TRow extends GridRow>({
  value,
  children,
}: {
  value: DataGridContextValue<TRow>
  children: React.ReactNode
}) {
  return (
    <DataGridContext.Provider
      value={value as unknown as DataGridContextValue<GridRow>}
    >
      {children}
    </DataGridContext.Provider>
  )
}

export function useDataGridContext<
  TRow extends GridRow,
>(): DataGridContextValue<TRow> {
  const ctx = React.useContext(DataGridContext)
  if (ctx === MISSING) {
    throw new Error("useDataGridContext must be used within <DataGrid>")
  }
  return ctx as unknown as DataGridContextValue<TRow>
}

// ---------------------------------------------------------------------------
// useGridCell — derive a cell's editor + wrapper props. Addressed by row id;
// selection membership tested against the display-space rectangle. Overlay
// geometry for OPT-IN features (copy outline, fill preview, move ghost) is
// derived only when the matching feature component is mounted.
// ---------------------------------------------------------------------------

/** Which sides of a cell sit on a rectangle's perimeter (else null). */
interface CopyEdges {
  top: boolean
  right: boolean
  bottom: boolean
  left: boolean
}

/** The sides of `bounds`' perimeter this display cell sits on, or null. */
function edgesOf(
  bounds: SelectionBounds | null | undefined,
  displayIndex: number,
  colIndex: number,
): CopyEdges | null {
  if (!bounds || !isCellInBounds(bounds, displayIndex, colIndex)) return null
  return {
    top: displayIndex === bounds.minRow,
    bottom: displayIndex === bounds.maxRow,
    left: colIndex === bounds.minColIndex,
    right: colIndex === bounds.maxColIndex,
  }
}

export interface GridCellRender {
  editorProps: CellEditorProps
  wrapperProps: {
    "data-cell": string
    onMouseDown: (e: React.MouseEvent) => void
    onMouseEnter: () => void
    onDoubleClick: () => void
  }
  /** Copy-outline edges for this cell, or null (requires `<DataGridClipboard>`). */
  copyEdges: CopyEdges | null
  /** Bottom-right corner of the selection (requires `<DataGridFillHandle>`). */
  isFillCorner: boolean
  /** Fill-preview edges, or null (requires `<DataGridFillHandle>`). */
  fillEdges: CopyEdges | null
  /** Selection-perimeter grab edges, or null (requires `<DataGridMove>`). */
  selectionEdges: CopyEdges | null
  /** Drag-to-move ghost edges, or null (requires `<DataGridMove>`). */
  moveEdges: CopyEdges | null
}

export function useGridCell(
  row: GridRow,
  columnId: string,
  displayIndex?: number,
): GridCellRender {
  const {
    grid,
    onCellMouseDown,
    onCellMouseEnter,
    moveFocus,
    fillSelectionWith,
    displayIndexOf,
  } = useDataGridContext()
  const resolvedDisplayIndex = displayIndexOf(row.id) ?? displayIndex ?? 0
  const state = useGridCellState(row, columnId, resolvedDisplayIndex)
  const pos: CellPosition = { rowId: row.id, columnId }

  const editorProps: CellEditorProps = {
    cell: state.cell,
    isFocused: state.isFocused,
    isSelected: state.isSelected,
    isEditing: state.isEditing,
    editSeed: state.editSeed,
    onEditingChange: editing =>
      editing ? grid.startEditing(pos) : grid.stopEditing(),
    onCommit: next => grid.setCell(row.id, columnId, next),
    onFillSelection: fillSelectionWith,
    onMoveFocus: moveFocus,
  }

  return {
    editorProps,
    wrapperProps: {
      "data-cell": `${row.id}:${columnId}`,
      onMouseDown: e => onCellMouseDown(pos, e),
      onMouseEnter: () => onCellMouseEnter(pos),
      onDoubleClick: () => grid.startEditing(pos),
    },
    copyEdges: state.copyEdges,
    isFillCorner: state.isFillCorner,
    fillEdges: state.fillEdges,
    selectionEdges: state.selectionEdges,
    moveEdges: state.moveEdges,
  }
}

/** The per-cell visual state — everything the cell chrome derives per render. */
interface GridCellState {
  cell: CellState<string>
  isFocused: boolean
  isSelected: boolean
  isEditing: boolean
  editSeed: string | null
  copyEdges: CopyEdges | null
  isFillCorner: boolean
  fillEdges: CopyEdges | null
  selectionEdges: CopyEdges | null
  moveEdges: CopyEdges | null
}

function useGridCellState(
  row: GridRow,
  columnId: string,
  displayIndex: number,
): GridCellState {
  const { grid, selectionBounds, columnIds } = useDataGridContext()
  const features = useDataGridFeatures()

  const isFocused =
    grid.focusedCell?.rowId === row.id && grid.focusedCell.columnId === columnId
  const isEditing =
    grid.editingCell?.rowId === row.id && grid.editingCell.columnId === columnId
  // DISPLAY-space column index (respects hiding/pinning/reorder).
  const colIndex = columnIds.indexOf(columnId)
  const isSelected = isCellInBounds(selectionBounds, displayIndex, colIndex)

  // Fill-handle anchor: the selection's bottom-right corner (opt-in).
  const isFillCorner =
    !!features.fill &&
    !!selectionBounds &&
    displayIndex === selectionBounds.maxRow &&
    colIndex === selectionBounds.maxColIndex

  // Fill-preview perimeter (only while dragging the fill handle).
  const fillEdges = features.fill
    ? edgesOf(features.fill.fillBounds, displayIndex, colIndex)
    : null

  // Selection perimeter — the sides a drag-to-move grab zone is drawn on.
  const selectionEdges =
    features.move && isSelected
      ? edgesOf(selectionBounds, displayIndex, colIndex)
      : null

  // Drag-to-move ghost perimeter (where the block will land on release).
  const moveEdges = features.move
    ? edgesOf(features.move.moveBounds, displayIndex, colIndex)
    : null

  // Copy outline: a cell in the copied rectangle draws a dashed border on the
  // sides that lie on the rectangle's perimeter, forming one continuous outline.
  const copiedRange = features.clipboard?.copiedRange ?? null
  const copyEdges: CopyEdges | null =
    copiedRange &&
    copiedRange.rowIds.has(row.id) &&
    copiedRange.columnIds.has(columnId)
      ? {
          top: copiedRange.firstRowId === row.id,
          bottom: copiedRange.lastRowId === row.id,
          left: copiedRange.firstColumnId === columnId,
          right: copiedRange.lastColumnId === columnId,
        }
      : null

  const cell =
    (row[columnId] as CellState<string> | undefined) ?? emptyCell<string>()

  return {
    cell,
    isFocused,
    isSelected,
    isEditing,
    editSeed: isEditing ? grid.editSeed : null,
    copyEdges,
    isFillCorner,
    fillEdges,
    selectionEdges,
    moveEdges,
  }
}

// ---------------------------------------------------------------------------
// DataGridCell — per-cell chrome: data-cell attr, mouse handlers, feature
// overlays, and moving DOM focus onto the active cell (fires when the row
// mounts, so it works with virtualization). Feature overlays render only when
// the matching opt-in component is mounted inside <DataGrid>.
//
// PERF: the grid context changes on every focus move, which re-runs this
// component for EVERY mounted cell (hundreds when virtualized). The state
// derivation above is a handful of comparisons — cheap — but reconciling each
// cell's editor subtree is not. So the actual DOM lives in a memoized inner
// component fed only value-comparable props + identity-stable handlers (the
// latest-ref pattern): per keystroke, only the handful of cells whose visual
// state changed re-render; the rest stop at the memo.
// ---------------------------------------------------------------------------

/** Identity-stable handlers a cell hands to its wrapper + editor. */
interface StableCellHandlers {
  onMouseDown: (e: React.MouseEvent) => void
  onMouseEnter: () => void
  onDoubleClick: () => void
  onEditingChange: (editing: boolean) => void
  onCommit: (next: CellState<string>) => void
  onFillSelection: (next: CellState<string>) => void
  onMoveFocus: (deltaRow: number, deltaCol: number, extend?: boolean) => void
  onFillHandleMouseDown: (e: React.MouseEvent) => void
  onFillHandleDoubleClick: () => void
  onSelectionMoveMouseDown: (e: React.MouseEvent) => void
}

function edgesEqual(a: CopyEdges | null, b: CopyEdges | null): boolean {
  if (a === b) return true
  if (!a || !b) return false
  return (
    a.top === b.top &&
    a.right === b.right &&
    a.bottom === b.bottom &&
    a.left === b.left
  )
}

interface DataGridCellInnerProps {
  dataCell: string
  state: GridCellState
  /** The fill handle is rendered (feature mounted + corner cell). */
  showFillHandle: boolean
  /** The drag-to-move grab strips are rendered (feature mounted). */
  showMoveStrips: boolean
  handlers: StableCellHandlers
  children: (props: CellEditorProps) => React.ReactNode
}

function cellInnerPropsEqual(
  prev: DataGridCellInnerProps,
  next: DataGridCellInnerProps,
): boolean {
  const a = prev.state
  const b = next.state
  return (
    prev.dataCell === next.dataCell &&
    prev.showFillHandle === next.showFillHandle &&
    prev.showMoveStrips === next.showMoveStrips &&
    prev.handlers === next.handlers &&
    prev.children === next.children &&
    a.cell === b.cell &&
    a.isFocused === b.isFocused &&
    a.isSelected === b.isSelected &&
    a.isEditing === b.isEditing &&
    a.editSeed === b.editSeed &&
    a.isFillCorner === b.isFillCorner &&
    edgesEqual(a.copyEdges, b.copyEdges) &&
    edgesEqual(a.fillEdges, b.fillEdges) &&
    edgesEqual(a.selectionEdges, b.selectionEdges) &&
    edgesEqual(a.moveEdges, b.moveEdges)
  )
}

const DataGridCellInner = React.memo(function DataGridCellInner({
  dataCell,
  state,
  showFillHandle,
  showMoveStrips,
  handlers: h,
  children,
}: DataGridCellInnerProps) {
  const {
    cell,
    isFocused,
    isSelected,
    isEditing,
    editSeed,
    copyEdges,
    fillEdges,
    selectionEdges,
    moveEdges,
  } = state
  const ref = React.useRef<HTMLDivElement>(null)

  React.useLayoutEffect(() => {
    if (!isFocused || isEditing) return
    const el = ref.current?.querySelector<HTMLElement>(
      'input, textarea, select, button, [contenteditable]:not([contenteditable="false"]), [tabindex]:not([tabindex="-1"])',
    )
    if (el && !el.contains(document.activeElement)) {
      el.focus({ preventScroll: true })
    }
  }, [isFocused, isEditing])

  const editorProps: CellEditorProps = {
    cell,
    isFocused,
    isSelected,
    isEditing,
    editSeed,
    onEditingChange: h.onEditingChange,
    onCommit: h.onCommit,
    onFillSelection: h.onFillSelection,
    onMoveFocus: h.onMoveFocus,
  }

  return (
    <div
      ref={ref}
      className="relative h-full w-full"
      data-cell={dataCell}
      onMouseDown={h.onMouseDown}
      onMouseEnter={h.onMouseEnter}
      onDoubleClick={h.onDoubleClick}
    >
      {copyEdges && (
        <div
          aria-hidden
          className={cn(
            "pointer-events-none absolute inset-0 z-[6] border-0 border-dashed border-primary",
            copyEdges.top && "border-t",
            copyEdges.right && "border-r",
            copyEdges.bottom && "border-b",
            copyEdges.left && "border-l",
          )}
        />
      )}
      {/* Fill-drag preview outline (dashed). */}
      {fillEdges && (
        <div
          aria-hidden
          className={cn(
            "pointer-events-none absolute inset-0 z-[6] border-0 border-dashed border-primary",
            fillEdges.top && "border-t",
            fillEdges.right && "border-r",
            fillEdges.bottom && "border-b",
            fillEdges.left && "border-l",
          )}
        />
      )}
      {/* Drag-to-move ghost — where the block lands on release (solid outline +
          faint tint, distinct from the dashed fill/copy previews). */}
      {moveEdges && (
        <div
          aria-hidden
          className={cn(
            "pointer-events-none absolute inset-0 z-[7] border-0 border-primary bg-primary/5",
            moveEdges.top && "border-t-2",
            moveEdges.right && "border-r-2",
            moveEdges.bottom && "border-b-2",
            moveEdges.left && "border-l-2",
          )}
        />
      )}
      {/* Active-cell (cursor) border — drawn as an overlay on the wrapper, NOT
          on the focusable control, so it can't be zeroed out by the control's
          own `:focus-visible` reset. Bold + brand-colored, grid-style. */}
      {isFocused && (
        <div
          aria-hidden
          className="pointer-events-none absolute inset-0 z-[7] border-2 border-primary"
        />
      )}
      {/* Drag-to-move grab zones — thin strips along the selection's outer
          border. Hovering shows a move cursor; mouse-down drags the whole block
          to a new location (border-drag). Only on the perimeter so cell
          interior clicks still select/edit; suppressed while editing. The fill
          handle (below) sits above these at the bottom-right corner. */}
      {selectionEdges && showMoveStrips && !isEditing && (
        <>
          {selectionEdges.top && (
            <div
              onMouseDown={h.onSelectionMoveMouseDown}
              className="absolute top-0 right-0 left-0 z-[8] h-1.5 cursor-move"
            />
          )}
          {selectionEdges.bottom && (
            <div
              onMouseDown={h.onSelectionMoveMouseDown}
              className="absolute right-0 bottom-0 left-0 z-[8] h-1.5 cursor-move"
            />
          )}
          {selectionEdges.left && (
            <div
              onMouseDown={h.onSelectionMoveMouseDown}
              className="absolute top-0 bottom-0 left-0 z-[8] w-1.5 cursor-move"
            />
          )}
          {selectionEdges.right && (
            <div
              onMouseDown={h.onSelectionMoveMouseDown}
              className="absolute top-0 right-0 bottom-0 z-[8] w-1.5 cursor-move"
            />
          )}
        </>
      )}
      {/* Fill handle — the small square at the selection's bottom-right corner.
          Drag to fill (down or across); double-click to auto-fill down. Sits
          FLUSH INSIDE the cell corner: the body `<td>` is `overflow-hidden`, so a
          handle hanging outside (negative offsets) would be clipped and unusable.
          A larger transparent hit-area wraps the visible 8px square so it's easy
          to grab on either the bottom edge (fill down) or right edge (fill across). */}
      {state.isFillCorner && showFillHandle && (
        <div
          onMouseDown={h.onFillHandleMouseDown}
          onDoubleClick={e => {
            e.stopPropagation()
            h.onFillHandleDoubleClick()
          }}
          className="group/fill absolute right-0 bottom-0 z-[9] flex size-3.5 cursor-crosshair items-end justify-end"
          aria-label="Fill handle — drag to fill the selection"
        >
          <div className="size-2 rounded-[1px] border border-background bg-primary shadow-sm transition-transform group-hover/fill:scale-125" />
        </div>
      )}
      {children(editorProps)}
    </div>
  )
}, cellInnerPropsEqual)

export function DataGridCell({
  row,
  columnId,
  displayIndex: displayIndexProp,
  children,
}: {
  row: GridRow
  columnId: string
  /**
   * Optional fallback. Prefer omitting — the cell resolves display index from
   * row id via the grid's display order, so sort/filter stay correct. Passing
   * TanStack's `row.index` (source-data index) will be ignored when the row is
   * still in the current display model.
   */
  displayIndex?: number
  children: (props: CellEditorProps) => React.ReactNode
}) {
  const {
    grid,
    onCellMouseDown,
    onCellMouseEnter,
    moveFocus,
    fillSelectionWith,
    displayIndexOf,
  } = useDataGridContext()
  const features = useDataGridFeatures()
  const displayIndex = displayIndexOf(row.id) ?? displayIndexProp ?? 0
  const state = useGridCellState(row, columnId, displayIndex)

  // Latest-ref + one stable handler set per cell instance. The handlers read
  // through the ref, so their identity never changes and the memoized inner
  // keeps holding while the grid context churns on every focus move.
  const latest = React.useRef({
    grid,
    features,
    onCellMouseDown,
    onCellMouseEnter,
    moveFocus,
    fillSelectionWith,
    rowId: row.id,
    columnId,
  })
  latest.current = {
    grid,
    features,
    onCellMouseDown,
    onCellMouseEnter,
    moveFocus,
    fillSelectionWith,
    rowId: row.id,
    columnId,
  }

  const handlers = React.useMemo<StableCellHandlers>(() => {
    const posOf = () => ({
      rowId: latest.current.rowId,
      columnId: latest.current.columnId,
    })
    return {
      onMouseDown: e => latest.current.onCellMouseDown(posOf(), e),
      onMouseEnter: () => latest.current.onCellMouseEnter(posOf()),
      onDoubleClick: () => latest.current.grid.startEditing(posOf()),
      onEditingChange: editing =>
        editing
          ? latest.current.grid.startEditing(posOf())
          : latest.current.grid.stopEditing(),
      onCommit: next =>
        latest.current.grid.setCell(
          latest.current.rowId,
          latest.current.columnId,
          next,
        ),
      onFillSelection: next => latest.current.fillSelectionWith(next),
      onMoveFocus: (dRow, dCol, extend) =>
        latest.current.moveFocus(dRow, dCol, extend),
      onFillHandleMouseDown: e =>
        latest.current.features.fill?.onFillHandleMouseDown(e),
      onFillHandleDoubleClick: () =>
        latest.current.features.fill?.onFillHandleDoubleClick(),
      onSelectionMoveMouseDown: e =>
        latest.current.features.move?.onSelectionMoveMouseDown(e, posOf()),
    }
  }, [])

  return (
    <DataGridCellInner
      dataCell={`${row.id}:${columnId}`}
      state={state}
      showFillHandle={!!features.fill}
      showMoveStrips={!!features.move}
      handlers={handlers}
    >
      {children}
    </DataGridCellInner>
  )
}
src/components/niko-table/grid/core/data-grid-features.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 * as React from "react"

import type { UseDataGrid } from "../hooks/use-data-grid"
import type {
  CellPosition,
  CopiedRange,
  GridRow,
  SelectionBounds,
} from "../types/grid-cell"

// ---------------------------------------------------------------------------
// Opt-in grid features — the composable seam.
//
// `<DataGrid>` ships only the irreducible core (selection, keyboard nav, the
// scroll seam). Everything else is an opt-in CHILD component that registers
// its capability here when mounted — mix and match, niko-table style:
//
//   <DataGrid grid={grid}>
//     <DataGridClipboard resolveCell={...} />   // copy/cut/paste + marching ants
//     <DataGridFillHandle />                    // corner-drag fill
//     <DataGridMove />                          // border-drag move/copy
//     <DataGridCrossHighlight />                // header + row-number highlight
//     <DataTable>…</DataTable>
//   </DataGrid>
//
// A feature that isn't mounted costs NOTHING: no document/window listeners,
// no per-cell overlays, no state churn — and it tree-shakes out of the bundle.
// ---------------------------------------------------------------------------

/** Published by `<DataGridClipboard>`. */
export interface ClipboardFeature {
  /** The copied rectangle (the marching-ants copy outline), or null. */
  copiedRange: CopiedRange | null
  copySelection: () => void
  cutSelection: () => void
  /** Read the clipboard and paste from the focused cell (menu "Paste"). */
  pasteFromClipboard: () => void
  /** Clear the copy marker (wired to Escape by the core keyboard). */
  clearCopiedRange: () => void
  /** Paste is available (a `resolveCell` was provided). */
  canPaste: boolean
}

/** Published by `<DataGridFillHandle>`. */
export interface FillFeature {
  /** Fill-drag rectangle (source + extension), display space, or null. */
  fillBounds: SelectionBounds | null
  onFillHandleMouseDown: (e: React.MouseEvent) => void
  onFillHandleDoubleClick: () => void
}

/** Published by `<DataGridMove>`. */
export interface MoveFeature {
  /** Drag-to-move ghost rectangle (target landing zone), or null. */
  moveBounds: SelectionBounds | null
  onSelectionMoveMouseDown: (e: React.MouseEvent, grabPos: CellPosition) => void
}

/** Published by `<DataGridRowReorder>`. */
export interface RowReorderFeature {
  /** The row id being dragged (for the handle's grabbing state), or null. */
  draggingRowId: string | null
  /** Start a row-reorder drag from a gutter grab handle. */
  onRowReorderMouseDown: (e: React.MouseEvent, rowId: string) => void
}

export interface GridFeatures {
  clipboard?: ClipboardFeature
  fill?: FillFeature
  move?: MoveFeature
  rowReorder?: RowReorderFeature
}

export type RegisterGridFeature = <K extends keyof GridFeatures>(
  key: K,
  payload: GridFeatures[K] | undefined,
) => void

const FeaturesContext = React.createContext<GridFeatures | null>(null)
const FeatureRegistryContext = React.createContext<RegisterGridFeature | null>(
  null,
)

export function DataGridFeaturesProvider({
  features,
  register,
  children,
}: {
  features: GridFeatures
  register: RegisterGridFeature
  children: React.ReactNode
}) {
  return (
    <FeatureRegistryContext.Provider value={register}>
      <FeaturesContext.Provider value={features}>
        {children}
      </FeaturesContext.Provider>
    </FeatureRegistryContext.Provider>
  )
}

/** Read the currently-registered opt-in features (cells, menus). */
export function useDataGridFeatures(): GridFeatures {
  const ctx = React.useContext(FeaturesContext)
  if (ctx === null) {
    throw new Error("useDataGridFeatures must be used within <DataGrid>")
  }
  return ctx
}

/**
 * Publish a feature payload while mounted; unregisters on unmount. Feature
 * components memoize their payload and call this once.
 */
export function useRegisterGridFeature<K extends keyof GridFeatures>(
  key: K,
  payload: NonNullable<GridFeatures[K]>,
): void {
  const register = React.useContext(FeatureRegistryContext)
  if (register === null) {
    throw new Error(
      "Grid feature components (DataGridClipboard, DataGridFillHandle, …) must be placed inside <DataGrid>",
    )
  }
  React.useEffect(() => {
    register(key, payload)
    return () => register(key, undefined)
  }, [register, key, payload])
}

// ---------------------------------------------------------------------------
// Internals — the shared machinery feature components build on. Deliberately
// exported (registry consumers can write their own features) but NOT part of
// the everyday public API.
// ---------------------------------------------------------------------------

/** px from a scroll-container edge where drag auto-scroll kicks in. */
export const GRID_EDGE_ZONE = 36
/** px per frame while auto-scrolling at an edge. */
export const GRID_EDGE_SPEED = 14

export function clampGridIndex(v: number, min: number, max: number): number {
  return Math.max(min, Math.min(max, v))
}

/** The display-order row shape features read (a subset of TanStack's `Row`). */
export interface GridDisplayRow {
  readonly id: string
  readonly original: GridRow
}

/** Latest-snapshot env for once-subscribed listeners / rAF loops. */
export interface GridEnv {
  orderedRows: readonly GridDisplayRow[]
  columnIds: readonly string[]
  displayIndexOf: (id: string) => number | undefined
  grid: UseDataGrid<GridRow>
  selectionBounds: SelectionBounds | null
}

export interface DataGridInternals {
  grid: UseDataGrid<GridRow>
  /** Selection rectangle in DISPLAY space (post filter/sort). */
  selectionBounds: SelectionBounds | null
  orderedRows: readonly GridDisplayRow[]
  columnIds: readonly string[]
  displayIndexOf: (id: string) => number | undefined
  /** Display index → the row's data (lazy TSV serialization). */
  getDisplayRow: (i: number) => GridRow | undefined
  /** Select the whole display-space rectangle (anchor top-left → bottom-right). */
  selectRange: (bounds: SelectionBounds) => void
  /** Clear the selected cells' values. */
  clearSelection: () => void
  /** The grid wrapper element (query the scroll container from here). */
  wrapperRef: React.RefObject<HTMLDivElement | null>
  /** Latest pointer position (updated by the core's window mousemove). */
  pointerRef: React.RefObject<{ x: number; y: number }>
  /** Latest env snapshot for once-subscribed listeners / rAF loops. */
  envRef: React.RefObject<GridEnv>
}

const InternalsContext = React.createContext<DataGridInternals | null>(null)

export function DataGridInternalsProvider({
  value,
  children,
}: {
  value: DataGridInternals
  children: React.ReactNode
}) {
  return (
    <InternalsContext.Provider value={value}>
      {children}
    </InternalsContext.Provider>
  )
}

/** Access the grid's internal machinery (feature components only). */
export function useDataGridInternals(): DataGridInternals {
  const ctx = React.useContext(InternalsContext)
  if (ctx === null) {
    throw new Error("useDataGridInternals must be used within <DataGrid>")
  }
  return ctx
}
src/components/niko-table/grid/core/data-grid-columns-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 { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogFooter,
  DialogHeader,
  DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import * as React from "react"

import type { GridColumnsApi } from "../hooks/use-grid-columns"
import type { CellState, GridRow } from "../types/grid-cell"
import { useDataGridInternals } from "./data-grid-features"

/** A selectable column type for the header "Change type" submenu. */
export interface GridColumnTypeOption {
  value: string
  label: string
  /** Options applied to a column switched to this type (e.g. select values). */
  options?: readonly string[]
}

// ---------------------------------------------------------------------------
// Dynamic columns — the opt-in seam. `<DataGridColumns value={…}>` publishes
// the column API (from `useGridColumns`) to the header menu / add-column button
// deep inside the table, and hosts a built-in rename dialog so "Rename" works
// from the header dropdown (which unmounts on close). A grid with fixed columns
// never mounts this.
// ---------------------------------------------------------------------------

interface DataGridColumnsContextValue extends GridColumnsApi {
  /** The column currently being renamed (dialog open), or null. */
  renameTarget: string | null
  /** Open the rename dialog for a column. */
  beginRename: (id: string) => void
  /** Close the rename dialog. */
  endRename: () => void
  /** Types offered in the header "Change type" submenu (empty = hidden). */
  columnTypes: GridColumnTypeOption[]
  /** Switch a column's type (applies that type's default options). */
  changeColumnType: (id: string, type: string) => void
}

const DataGridColumnsContext =
  React.createContext<DataGridColumnsContextValue | null>(null)

/**
 * Read the dynamic-columns API (mutations + rename state) inside the table.
 * Throws when used without a `<DataGridColumns>` ancestor.
 */
export function useDataGridColumns(): DataGridColumnsContextValue {
  const ctx = React.useContext(DataGridColumnsContext)
  if (ctx === null) {
    throw new Error(
      "useDataGridColumns must be used within <DataGridColumns> (dynamic columns are opt-in)",
    )
  }
  return ctx
}

/**
 * Wire dynamic columns into the grid. Wrap the toolbar + table with it and pass
 * your `useGridColumns()` result:
 *
 * @example
 * const cols = useGridColumns({ initialColumns });
 * const grid = useDataGrid({ columnIds: cols.columnIds, … });
 * <DataGrid grid={grid}>
 *   <DataGridColumns value={cols}>
 *     <DataGridToolbar><DataGridAddColumnButton /></DataGridToolbar>
 *     <DataTable>…</DataTable>   // headers render <GridColumnMenuOptions/>
 *   </DataGridColumns>
 * </DataGrid>
 */
export function DataGridColumns({
  value,
  columnTypes,
  resolveCell,
  children,
}: {
  value: GridColumnsApi
  /** Types for the header "Change type" submenu. Omit to hide retype. */
  columnTypes?: GridColumnTypeOption[]
  /**
   * Re-resolve a cell's raw value when its column's type changes (e.g. text →
   * select re-validates existing values against the new options). Same resolver
   * you pass to `<DataGridClipboard>`. Omit and a type change updates the type
   * only, leaving existing cells until they're next edited.
   */
  resolveCell?: (columnId: string, raw: string) => CellState<string>
  children: React.ReactNode
}) {
  const [renameTarget, setRenameTarget] = React.useState<string | null>(null)
  const beginRename = React.useCallback((id: string) => setRenameTarget(id), [])
  const endRename = React.useCallback(() => setRenameTarget(null), [])

  const types = React.useMemo(() => columnTypes ?? [], [columnTypes])
  const setColumnType = value.setColumnType
  const changeColumnType = React.useCallback(
    (id: string, type: string) => {
      const opt = types.find(t => t.value === type)
      setColumnType(id, type, opt?.options ? { options: opt.options } : {})
    },
    [types, setColumnType],
  )

  const ctx = React.useMemo<DataGridColumnsContextValue>(
    () => ({
      ...value,
      renameTarget,
      beginRename,
      endRename,
      columnTypes: types,
      changeColumnType,
    }),
    [value, renameTarget, beginRename, endRename, types, changeColumnType],
  )

  return (
    <DataGridColumnsContext.Provider value={ctx}>
      {children}
      <ColumnRenameDialog />
      {/* Only mounts (and touches grid internals) when re-resolve is opted in. */}
      {resolveCell && <ColumnRetypeReconciler resolveCell={resolveCell} />}
    </DataGridColumnsContext.Provider>
  )
}

/**
 * Watches column type/options and re-resolves that column's cells when they
 * change — so a text → select switch immediately re-validates existing values
 * (unmatched ones turn red). Runs in a layout effect (before paint, no flash of
 * mis-typed values) and only mounts when `<DataGridColumns resolveCell>` is set,
 * so it never touches grid internals for a read-only/no-retype table.
 */
function ColumnRetypeReconciler({
  resolveCell,
}: {
  resolveCell: (columnId: string, raw: string) => CellState<string>
}) {
  const { columns } = useDataGridColumns()
  const { grid } = useDataGridInternals()
  const prevSig = React.useRef<Map<string, string> | null>(null)

  React.useLayoutEffect(() => {
    const next = new Map<string, string>()
    const changed: string[] = []
    for (const c of columns) {
      const sig = `${c.type}\u0000${(c.options ?? []).join("\u0001")}`
      next.set(c.id, sig)
      const prev = prevSig.current
      // Skip the first pass (prev === null) — initial data is already resolved.
      if (prev?.has(c.id) && prev.get(c.id) !== sig) changed.push(c.id)
    }
    prevSig.current = next
    if (changed.length === 0) return

    grid.updateRows(rows => {
      let mutated = false
      const nextRows = rows.map(row => {
        let out = row
        for (const id of changed) {
          const cell = row[id] as CellState<string> | undefined
          if (cell === undefined) continue // empty cell — nothing to resolve
          if (out === row) out = { ...row } as GridRow
          out[id] = resolveCell(id, cell.raw)
          mutated = true
        }
        return out
      })
      // All affected cells were empty — return the same array so the history
      // reducer's no-op guard holds (no phantom undo entry).
      return mutated ? nextRows : rows
    })
  }, [columns, grid, resolveCell])

  return null
}

/** Built-in rename dialog — opened via `beginRename` from the header menu. */
function ColumnRenameDialog() {
  const { columns, renameColumn, renameTarget, endRename } =
    useDataGridColumns()
  const column = columns.find(c => c.id === renameTarget) ?? null

  return (
    <Dialog
      open={renameTarget !== null}
      onOpenChange={open => {
        if (!open) endRename()
      }}
    >
      <DialogContent className="sm:max-w-sm">
        <DialogHeader>
          <DialogTitle>Rename column</DialogTitle>
        </DialogHeader>
        <div className="-mx-4">
          <Separator />
        </div>
        {/* Keyed on the column id: remounting re-seeds the draft from the new
            column's label, so we never setState-in-an-effect to reseed. */}
        {column && (
          <RenameForm
            key={column.id}
            initialLabel={column.label}
            onSave={label => {
              renameColumn(column.id, label)
              endRename()
            }}
            onCancel={endRename}
          />
        )}
      </DialogContent>
    </Dialog>
  )
}

function RenameForm({
  initialLabel,
  onSave,
  onCancel,
}: {
  initialLabel: string
  onSave: (label: string) => void
  onCancel: () => void
}) {
  const [draft, setDraft] = React.useState(initialLabel)
  const commit = () => onSave(draft.trim() || "Untitled")

  return (
    <>
      <Input
        autoFocus
        value={draft}
        onChange={e => setDraft(e.target.value)}
        onKeyDown={e => {
          if (e.key === "Enter") {
            e.preventDefault()
            commit()
          }
        }}
        placeholder="Column name"
        aria-label="Column name"
      />
      <div className="-mx-4">
        <Separator />
      </div>
      <DialogFooter>
        <Button variant="outline" onClick={onCancel}>
          Cancel
        </Button>
        <Button onClick={commit}>Save</Button>
      </DialogFooter>
    </>
  )
}
src/components/niko-table/grid/cells/cell-props.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
 */
/**
 * niko-table grid — the cell editor contract.
 *
 * Any component satisfying `CellEditorProps` can be a grid cell: text input,
 * searchable dropdown, date picker, number spinner, multi-select. Domain-free.
 */

import type { CellState } from "../types/grid-cell"

export interface CellEditorProps {
  cell: CellState<string>
  /** The active cell (shows the focus ring; the edit target). */
  isFocused: boolean
  /** Within the current selection rectangle (range highlight). */
  isSelected: boolean
  /** Whether this cell's editor is open. */
  isEditing: boolean
  /** Char to seed the editor with when editing started by typing (else null). */
  editSeed?: string | null
  /** Open/close this cell's editor. */
  onEditingChange: (editing: boolean) => void
  onCommit: (next: CellState<string>) => void
  /**
   * Commit this value to EVERY selected cell (Ctrl/Cmd+Enter). When absent the
   * editor falls back to `onCommit` (single cell).
   */
  onFillSelection?: (next: CellState<string>) => void
  /** Move the active cell after committing (e.g. Enter in a text cell). */
  onMoveFocus?: (deltaRow: number, deltaCol: number) => void
  /** Resolved display label for the cell's value (entity/select cells). */
  displayLabel?: string | null
}

/** Option shape for combobox/select cells. */
export interface GridComboboxOption {
  label: string
  value: string
}
src/components/niko-table/grid/cells/cell-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 { cn } from "@/lib/utils"

import type { CellStatus } from "../types/grid-cell"

/** Row height for every grid cell (matches the h-9 form-control rhythm). */
export const CELL_HEIGHT = 36

/**
 * Shared cell-trigger look: a borderless control that fills its cell, turns
 * red when invalid, and shows an inset focus ring. Used by every cell editor
 * so comboboxes and inputs line up pixel-for-pixel.
 */
/**
 * Shared cell-trigger look, applied on top of shadcn `Input` / `Button` so we
 * reuse those primitives (a11y, focus, disabled handling) but strip their
 * border / rounding / default ring / hover fill so they sit flush in a cell.
 * tailwind-merge lets these later utilities win over the primitives' base
 * classes, so we compose rather than fork.
 */
export function cellTriggerClass(opts: {
  status: CellStatus
  isFocused: boolean
  isEmpty: boolean
  /** Within the current selection rectangle (range highlight). */
  isSelected?: boolean
}): string {
  return cn(
    // Layout + reset the primitive chrome (border/rounding/shadow/ring/hover).
    "flex h-9 w-full items-center justify-start gap-1 truncate rounded-none border-0 bg-transparent px-2 text-left text-sm font-normal shadow-none transition-colors outline-none",
    "hover:bg-transparent focus-visible:border-0 focus-visible:ring-0",
    opts.isEmpty && "text-muted-foreground",
    // Range highlight for selected cells — but NOT the active cell, which stays
    // unfilled so it reads as the cursor within the selection.
    // Skipped on invalid cells so the red error fill always wins. Repeat on
    // hover: so the shadcn Button's hover state doesn't wash out the tint.
    opts.isSelected &&
      !opts.isFocused &&
      opts.status !== "invalid" &&
      "bg-primary/10 hover:bg-primary/10",
    opts.status === "invalid" &&
      "text-destructive bg-destructive/10 hover:bg-destructive/10 aria-invalid:bg-destructive/10",
    // NOTE: the active-cell (cursor) border is drawn by `DataGridCell` as an
    // overlay on the cell WRAPPER, not here — a control's own `:focus-visible`
    // reset would otherwise zero out a ring on the focusable element.
  )
}
src/components/niko-table/grid/cells/grid-cell-display.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 { Button } from "@/components/ui/button"
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip"

import { cn } from "@/lib/utils"
import type { CellStatus } from "../types/grid-cell"

import { cellTriggerClass } from "./cell-styles"

export interface GridCellDisplayProps {
  status: CellStatus
  isFocused: boolean
  isSelected: boolean
  /** Text alignment — right for numbers, left (default) otherwise. */
  align?: "left" | "right"
  /**
   * Reason the cell is invalid (from your `resolve`). When `status` is
   * `"invalid"` and this is set, the cell shows it in a tooltip on hover/focus —
   * inline, per cell, no summary block. Valid/empty cells render the bare shell.
   */
  error?: string
  /** The rendered value (label / raw / placeholder) — already resolved by the cell. */
  children: React.ReactNode
}

/**
 * The read-only cell shell — a borderless, focusable button that fills the cell
 * and carries the selection/invalid styling. EVERY cell type renders this while
 * not editing.
 *
 * Deliberately hook-free and dependency-light: a grid shows ~280 cells at once
 * but edits ONE, so the display path must be cheap. Each cell type's heavy
 * editor (input, combobox, date picker, …) lives in a SEPARATE component that
 * the cell mounts only when `isEditing` — so a 279-cell viewport never carries
 * 279 date-picker subtrees, refs, or effects. New cell types MUST follow this
 * split: `isEditing ? <XEditor …/> : <GridCellDisplay …>{label}</GridCellDisplay>`.
 */
export function GridCellDisplay({
  status,
  isFocused,
  isSelected,
  align = "left",
  error,
  children,
}: GridCellDisplayProps) {
  const button = (
    <Button
      type="button"
      variant="ghost"
      tabIndex={-1}
      aria-invalid={status === "invalid"}
      className={cn(
        cellTriggerClass({
          status,
          isFocused,
          isSelected,
          isEmpty: status === "empty",
        }),
        align === "right" && "justify-end",
      )}
    >
      <span
        className={cn("flex-1 truncate", align === "right" && "text-right")}
      >
        {children}
      </span>
    </Button>
  )

  // Only invalid cells with a reason get the tooltip wrapper — valid/empty
  // cells (the overwhelming majority) render the bare shell, keeping the
  // display path cheap across a 280-cell viewport.
  if (status !== "invalid" || !error) return button

  return (
    <Tooltip>
      <TooltipTrigger asChild>{button}</TooltipTrigger>
      <TooltipContent>{error}</TooltipContent>
    </Tooltip>
  )
}
src/components/niko-table/grid/cells/grid-marching-ants.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
 */
/**
 * The "marching ants" copy outline for the copied cell — a self-contained,
 * animated SVG overlay (no global CSS / keyframes). Pinned to a `relative`
 * parent and non-interactive so it never blocks clicks on the cell beneath.
 */
export function GridMarchingAnts() {
  return (
    <svg
      aria-hidden
      className="pointer-events-none absolute inset-0 z-[6] size-full overflow-visible text-primary"
    >
      <rect
        x="0"
        y="0"
        width="100%"
        height="100%"
        fill="none"
        stroke="currentColor"
        strokeWidth="1.5"
        strokeDasharray="4 4"
      >
        <animate
          attributeName="stroke-dashoffset"
          from="0"
          to="-8"
          dur="0.5s"
          repeatCount="indefinite"
        />
      </rect>
    </svg>
  )
}
src/components/niko-table/grid/cells/grid-text-cell.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 { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import * as React from "react"

import type { CellState } from "../types/grid-cell"
import type { CellEditorProps } from "./cell-props"
import { cellTriggerClass } from "./cell-styles"
import { GridCellDisplay } from "./grid-cell-display"

export interface GridTextCellProps extends CellEditorProps {
  placeholder?: string
  /** Resolve raw text into a validated cell (date / time / number parsers). */
  resolve: (raw: string) => CellState<string>
  /** Text alignment — right for numbers. Default left. */
  align?: "left" | "right"
  /** Virtual-keyboard hint for the editing input (e.g. "decimal" for numbers). */
  inputMode?: React.HTMLAttributes<HTMLInputElement>["inputMode"]
  /**
   * Format the DISPLAY value (e.g. `25` → `$25.00`). Applied only while not
   * editing — the editor always shows the raw text so entry stays precise.
   * Only called for valid, non-empty cells; invalid/empty fall back to raw.
   */
  format?: (raw: string) => string
}

/**
 * Free-text cell. Two modes, like an editable grid cell: a read-only display shell
 * while merely selected (single-click select + drag-select work), and a real
 * `<input>` once editing starts (double-click, Enter, or typing). Typing seeds
 * the input so it overwrites; the value resolves on blur / Enter and highlights
 * when unparsable.
 *
 * The `<input>` + its draft/commit machinery live in `GridTextEditor`, which
 * mounts ONLY when this cell edits — so a viewport of ~280 cells carries the
 * input machinery on one cell, not all of them (see `GridCellDisplay`).
 */
export function GridTextCell(props: GridTextCellProps) {
  const { cell, placeholder, isFocused, isSelected, isEditing, align, format } =
    props

  if (!isEditing) {
    // Format only valid, non-empty values; invalid/empty show raw (+ placeholder)
    // so a user's in-progress or unparsable text is never masked.
    const display =
      format && cell.status === "valid" ? format(cell.raw) : cell.raw
    return (
      <GridCellDisplay
        status={cell.status}
        error={cell.error}
        isFocused={isFocused}
        isSelected={isSelected}
        align={align}
      >
        {display || placeholder}
      </GridCellDisplay>
    )
  }
  return <GridTextEditor {...props} />
}

/**
 * Number cell — `GridTextCell` right-aligned with a numeric keyboard. Validity
 * (is this a number?) is the consumer's `resolve`'s job, so this stays a thin
 * preset and the grid remains type-agnostic.
 */
export function GridNumberCell(
  props: Omit<GridTextCellProps, "align" | "inputMode">,
) {
  return <GridTextCell {...props} align="right" inputMode="decimal" />
}

/** The editing `<input>` — mounted only while this cell is being edited. */
function GridTextEditor({
  cell,
  placeholder,
  resolve,
  isSelected,
  editSeed,
  align,
  inputMode,
  onEditingChange,
  onCommit,
  onFillSelection,
  onMoveFocus,
}: GridTextCellProps) {
  const commit = (value: string) => {
    // Unchanged value (including leaving an untouched empty cell via
    // Enter/Tab/blur) is a no-op — committing would push a phantom
    // undo-history entry for a visit that changed nothing.
    if (value === cell.raw) return
    onCommit(resolve(value))
  }

  // The un-committed draft. The input is uncontrolled (`defaultValue`), and
  // editing can be torn down EXTERNALLY before blur fires — clicking another
  // cell runs `selectCell` (which clears `editingCell`) in the same mousedown,
  // so this component unmounts while the input still holds the draft and its
  // `onBlur` never dispatches. We mirror the draft into a ref and commit it on
  // unmount (also covers the row being virtualized away mid-edit). Escape
  // clears the draft first, so cancel still discards.
  const draftRef = React.useRef<string | null>(null)
  const commitRef = React.useRef(commit)
  commitRef.current = commit

  const commitDraft = (value: string) => {
    draftRef.current = null
    commit(value)
  }

  // useLayoutEffect cleanup, NOT useEffect: it runs before the browser paints,
  // so the commit's re-render lands in the same frame. A passive effect would
  // let the teardown frame paint the OLD value first — a stale-data flash.
  React.useLayoutEffect(
    () => () => {
      if (draftRef.current != null) commitRef.current(draftRef.current)
    },
    [],
  )

  return (
    <Input
      autoFocus
      type="text"
      inputMode={inputMode}
      defaultValue={editSeed ?? cell.raw}
      placeholder={placeholder}
      aria-invalid={cell.status === "invalid"}
      onFocus={e => {
        // Type-to-edit seeds the input with the typed character — that seed IS
        // a draft, so a click-away with no further typing must still commit it.
        // A plain double-click open (no seed) leaves the draft null, so closing
        // without typing stays a no-op (no phantom history entry).
        if (editSeed != null && draftRef.current == null) {
          draftRef.current = e.currentTarget.value
        }
      }}
      onChange={e => {
        draftRef.current = e.target.value
      }}
      onBlur={e => {
        commitDraft(e.target.value)
        onEditingChange(false)
      }}
      onKeyDown={e => {
        // Commit the current value, close the editor, and move the focus.
        const commitAndMove = (dRow: number, dCol: number) => {
          commitDraft(e.currentTarget.value)
          onEditingChange(false)
          onMoveFocus?.(dRow, dCol)
        }
        if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && onFillSelection) {
          // Ctrl/Cmd+Enter: fill the whole selection with this value.
          e.preventDefault()
          draftRef.current = null
          onFillSelection(resolve(e.currentTarget.value))
          onEditingChange(false)
        } else if (e.key === "Enter") {
          e.preventDefault()
          commitAndMove(1, 0)
        } else if (e.key === "Tab") {
          e.preventDefault()
          commitAndMove(0, e.shiftKey ? -1 : 1)
        } else if (e.key === "Escape") {
          e.preventDefault()
          draftRef.current = null // cancel discards the draft
          onEditingChange(false)
        }
      }}
      className={cn(
        cellTriggerClass({
          status: cell.status,
          isFocused: true,
          isSelected,
          isEmpty: false,
        }),
        align === "right" && "text-right",
      )}
    />
  )
}
src/components/niko-table/grid/cells/grid-checkbox-cell.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 { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils"
import * as React from "react"

import type { CellEditorProps } from "./cell-props"
import { cellTriggerClass } from "./cell-styles"

interface GridCheckboxCellProps extends CellEditorProps {
  /** Truthy raw values (default: "true", "1", "yes"). Comparison is case-insensitive. */
  truthy?: readonly string[]
  /**
   * Accessible name for the checkbox (pass the column label so a screen reader
   * announces WHICH field it is). Falls back to the checked-state text.
   */
  "aria-label"?: string
}

const DEFAULT_TRUTHY = ["true", "1", "yes"] as const

/**
 * Boolean cell — a checkbox that's always interactive (no separate edit mode).
 * Stores `"true"` / `"false"` in the cell's `raw`. Click toggles; and because
 * the grid routes Enter/Space/type-to-edit through `isEditing`, this cell
 * interprets that "edit" as a toggle (then exits), so keyboard works too.
 */
export function GridCheckboxCell({
  cell,
  isFocused,
  isSelected,
  isEditing,
  truthy = DEFAULT_TRUTHY,
  "aria-label": ariaLabel,
  onCommit,
  onEditingChange,
}: GridCheckboxCellProps) {
  const set = new Set(truthy.map(t => t.toLowerCase()))
  const checked = set.has(cell.raw.trim().toLowerCase())

  const toggleRef = React.useRef<() => void>(() => {})
  toggleRef.current = () => {
    const next = !checked
    onCommit({ raw: String(next), value: String(next), status: "valid" })
  }

  // The grid triggers editing on Enter/Space/type — for a checkbox that means
  // "toggle". Do it in a layout effect (before paint) then exit edit mode.
  React.useLayoutEffect(() => {
    if (!isEditing) return
    toggleRef.current()
    onEditingChange(false)
  }, [isEditing, onEditingChange])

  // Reuse the shared cell shell so a checkbox cell fills/highlights EXACTLY like
  // every other cell: the selection-range tint when selected, and nothing while
  // it's the active cell (the active-cell border is drawn by the wrapper
  // overlay, same as text/select cells — so no redundant second outline here).
  // Centered instead of the default left align since it holds a single control.
  return (
    <div
      className={cn(
        cellTriggerClass({
          status: cell.status,
          isFocused,
          isSelected,
          isEmpty: false,
        }),
        "justify-center",
      )}
    >
      <Checkbox
        tabIndex={-1}
        checked={checked}
        onCheckedChange={() => toggleRef.current()}
        aria-label={ariaLabel ?? (checked ? "Checked" : "Unchecked")}
      />
    </div>
  )
}
src/components/niko-table/grid/cells/grid-date-cell.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 { Button } from "@/components/ui/button"
import { Calendar } from "@/components/ui/calendar"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"

import type { CellEditorProps } from "./cell-props"
import { cellTriggerClass } from "./cell-styles"
import { GridCellDisplay } from "./grid-cell-display"

export interface GridDateCellProps extends CellEditorProps {
  placeholder?: string
}

/** Parse an ISO `YYYY-MM-DD` (local, no TZ shift), or undefined. */
function parseISODate(raw: string): Date | undefined {
  const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(raw.trim())
  if (!m) return undefined
  const y = Number(m[1])
  const mo = Number(m[2])
  const day = Number(m[3])
  const d = new Date(y, mo - 1, day)
  // Reject calendar-invalid values JS silently rolls over (2026-02-31 → Mar 3).
  if (d.getFullYear() !== y || d.getMonth() !== mo - 1 || d.getDate() !== day) {
    return undefined
  }
  return d
}

function toISODate(d: Date): string {
  const p = (n: number) => String(n).padStart(2, "0")
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
}

/**
 * Date cell. Display shows the stored `YYYY-MM-DD`; editing opens a portaled
 * calendar (double-click / Enter / typing). Picking a day commits the ISO date.
 * The calendar + popover live in `GridDateEditor`, mounted ONLY while editing.
 *
 * Deliberate divergence from the text cell: typing opens the calendar but the
 * typed character is NOT seeded (`editSeed` is ignored) — a calendar has no
 * text affordance to seed. Escape / click-away closes without committing.
 */
export function GridDateCell(props: GridDateCellProps) {
  const { cell, placeholder, isFocused, isSelected, isEditing } = props
  if (!isEditing) {
    return (
      <GridCellDisplay
        status={cell.status}
        error={cell.error}
        isFocused={isFocused}
        isSelected={isSelected}
      >
        {cell.raw || placeholder}
      </GridCellDisplay>
    )
  }
  return <GridDateEditor {...props} />
}

/** The open calendar popover — mounted only while this cell is being edited. */
function GridDateEditor({
  cell,
  placeholder,
  isSelected,
  onCommit,
  onEditingChange,
}: GridDateCellProps) {
  const selected = parseISODate(cell.raw)
  return (
    <Popover
      open
      onOpenChange={open => {
        if (!open) onEditingChange(false)
      }}
    >
      <PopoverTrigger asChild>
        <Button
          type="button"
          variant="ghost"
          aria-invalid={cell.status === "invalid"}
          className={cellTriggerClass({
            status: cell.status,
            isFocused: true,
            isSelected,
            isEmpty: cell.status === "empty",
          })}
        >
          <span className="flex-1 truncate">{cell.raw || placeholder}</span>
        </Button>
      </PopoverTrigger>
      <PopoverContent
        data-grid-cell-editor=""
        align="start"
        className="w-auto p-0"
      >
        <Calendar
          mode="single"
          autoFocus
          selected={selected}
          defaultMonth={selected}
          onSelect={d => {
            if (d) {
              const iso = toISODate(d)
              // Only commit a real change — re-picking the same day would
              // otherwise push a phantom undo-history entry.
              if (iso !== cell.raw) {
                onCommit({ raw: iso, value: iso, status: "valid" })
              }
            }
            onEditingChange(false)
          }}
        />
      </PopoverContent>
    </Popover>
  )
}
src/components/niko-table/grid/cells/grid-combobox-cell.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 { Button } from "@/components/ui/button"
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
} from "@/components/ui/command"
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/components/ui/popover"
import * as React from "react"

import type { CellEditorProps, GridComboboxOption } from "./cell-props"
import { cellTriggerClass } from "./cell-styles"
import { GridCellDisplay } from "./grid-cell-display"

export interface GridComboboxCellProps extends CellEditorProps {
  options: GridComboboxOption[]
  placeholder: string
  searchPlaceholder?: string
  /** Show the in-dropdown search input. Default true. `GridSelectCell` = false. */
  searchable?: boolean
}

/**
 * The cell's shown label. Precedence: explicit displayLabel → invalid raw
 * (stays visible in red) → the selected option's label (a valid value without a
 * displayLabel must not fall back to the placeholder) → placeholder.
 */
function triggerTextFor(props: GridComboboxCellProps): string {
  const { cell, options, placeholder, displayLabel } = props
  const selectedLabel =
    cell.value != null
      ? options.find(o => o.value === cell.value)?.label
      : undefined
  return (
    displayLabel ??
    (cell.status === "invalid" ? cell.raw : (selectedLabel ?? placeholder))
  )
}

/**
 * Searchable single-select cell. Two modes: a read-only display shell while
 * merely selected (single-click select + drag-select work), and an open,
 * portaled dropdown once editing starts (double-click, Enter, or typing). A
 * pasted-but-unmatched value stays visible in red so the user can correct it.
 *
 * The `Popover` + `Command` dropdown lives in `GridComboboxEditor`, mounted
 * ONLY when this cell edits — so a viewport of ~280 select cells never
 * constructs 280 popover/command subtrees.
 */
export function GridComboboxCell(props: GridComboboxCellProps) {
  const { cell, isFocused, isSelected, isEditing } = props

  if (!isEditing) {
    return (
      <GridCellDisplay
        status={cell.status}
        error={cell.error}
        isFocused={isFocused}
        isSelected={isSelected}
      >
        {triggerTextFor(props)}
      </GridCellDisplay>
    )
  }
  return <GridComboboxEditor {...props} />
}

/** The open combobox dropdown — mounted only while this cell is being edited. */
function GridComboboxEditor(props: GridComboboxCellProps) {
  const {
    cell,
    options,
    searchPlaceholder,
    searchable = true,
    isSelected,
    editSeed,
    onEditingChange,
    onCommit,
  } = props
  const triggerText = triggerTextFor(props)
  // Type-to-edit seeds the search box with the first typed character.
  const [search, setSearch] = React.useState(editSeed ?? "")

  const commit = (item: GridComboboxOption | null) => {
    const next = item
      ? { raw: item.label, value: item.value, status: "valid" as const }
      : { raw: "", value: null, status: "empty" as const }
    // Skip no-op re-selects — otherwise every identical pick clones the row
    // and pushes a phantom undo entry.
    if (
      next.value === cell.value &&
      next.raw === cell.raw &&
      next.status === cell.status
    ) {
      onEditingChange(false)
      return
    }
    onCommit(next)
    onEditingChange(false)
  }

  return (
    <Popover
      open
      onOpenChange={open => {
        if (!open) onEditingChange(false)
      }}
    >
      <PopoverTrigger asChild>
        <Button
          type="button"
          variant="ghost"
          aria-invalid={cell.status === "invalid"}
          className={cellTriggerClass({
            status: cell.status,
            isFocused: true,
            isSelected,
            isEmpty: cell.status === "empty",
          })}
        >
          <span className="flex-1 truncate">{triggerText}</span>
        </Button>
      </PopoverTrigger>
      <PopoverContent
        data-grid-cell-editor=""
        align="start"
        className="w-[var(--radix-popover-trigger-width,var(--anchor-width))] min-w-48 p-0"
      >
        <Command>
          {searchable && (
            <CommandInput
              autoFocus
              value={search}
              onValueChange={setSearch}
              placeholder={searchPlaceholder ?? "Search…"}
            />
          )}
          <CommandList>
            <CommandEmpty>No match found.</CommandEmpty>
            <CommandGroup>
              {options.map(item => (
                <CommandItem
                  key={item.value}
                  value={item.label}
                  onSelect={() => commit(item)}
                >
                  <span className="truncate">{item.label}</span>
                </CommandItem>
              ))}
            </CommandGroup>
          </CommandList>
        </Command>
      </PopoverContent>
    </Popover>
  )
}
src/components/niko-table/grid/cells/grid-select-cell.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 { CellEditorProps, GridComboboxOption } from "./cell-props"
import { GridComboboxCell } from "./grid-combobox-cell"

export interface GridSelectCellProps extends CellEditorProps {
  options: GridComboboxOption[]
  placeholder: string
}

/**
 * Non-searchable single-select cell — the `GridComboboxCell` with the in-dropdown
 * search input hidden. Same `CellEditorProps` contract, so it drops into any
 * column. Use for short, fixed option lists (status, type) where search adds no
 * value; use `GridComboboxCell` for long entity lists.
 */
export function GridSelectCell(props: GridSelectCellProps) {
  return <GridComboboxCell {...props} searchable={false} />
}
src/components/niko-table/grid/components/grid-clipboard.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 * as React from "react"

import { useDataTableFlash } from "../../hooks/use-data-table-flash"
import {
  useDataGridInternals,
  useRegisterGridFeature,
  type ClipboardFeature,
} from "../core/data-grid-features"
import { parseTsv, serializeSelection } from "../hooks/use-grid-clipboard"
import type {
  CellState,
  CopiedRange,
  SelectionBounds,
} from "../types/grid-cell"

/** Capture the copied rectangle by identity (for the marching-ants outline). */
function buildCopiedRange(
  bounds: SelectionBounds | null,
  orderedRows: readonly { id: string }[],
  columnIds: readonly string[],
): CopiedRange | null {
  if (!bounds) return null
  const rowIds = new Set<string>()
  let firstRowId = ""
  let lastRowId = ""
  for (let r = bounds.minRow; r <= bounds.maxRow; r++) {
    const id = orderedRows[r]?.id
    if (!id) continue
    rowIds.add(id)
    if (r === bounds.minRow) firstRowId = id
    lastRowId = id
  }
  const cols = columnIds.slice(bounds.minColIndex, bounds.maxColIndex + 1)
  return {
    rowIds,
    firstRowId,
    lastRowId,
    columnIds: new Set(cols),
    firstColumnId: cols[0] ?? "",
    lastColumnId: cols[cols.length - 1] ?? "",
  }
}

export interface DataGridClipboardProps {
  /**
   * Resolve a pasted string into a CellState for a column. Required for paste
   * (Ctrl/Cmd+V); without it copy/cut still work. Keeps the grid
   * validation-agnostic.
   */
  resolveCell?: (columnId: string, raw: string) => CellState<string>
}

/**
 * Opt-in clipboard for the grid — copy / cut / paste via the NATIVE clipboard
 * events (input-safe, gives real `clipboardData`), a TSV wire format that
 * round-trips with external tabular editors, and the marching-ants copy outline. Drop it
 * inside `<DataGrid>`; leave it out and the grid attaches no document
 * listeners and ships no clipboard code.
 *
 * @example
 * <DataGrid grid={grid}>
 *   <DataGridClipboard resolveCell={resolveCell} />
 *   <DataTable>…</DataTable>
 * </DataGrid>
 */
export function DataGridClipboard({ resolveCell }: DataGridClipboardProps) {
  const {
    grid,
    selectionBounds,
    orderedRows,
    columnIds,
    displayIndexOf,
    getDisplayRow,
    clearSelection,
    wrapperRef,
  } = useDataGridInternals()
  const { flashCells } = useDataTableFlash()
  const [copiedRange, setCopiedRange] = React.useState<CopiedRange | null>(null)
  const { focusedCell, editingCell } = grid

  // Await the clipboard write; returns the rectangle it wrote on success (null
  // on failure or empty selection). It does NOT touch the marching-ants marker —
  // each caller decides: copy shows the outline, cut removes it. Awaiting the
  // write means a rejected write (permissions, focus) never destroys data, and
  // returning the captured bounds means each caller acts on the snapshot it
  // wrote, not whatever the selection has since become.
  const writeSelectionToClipboard =
    React.useCallback(async (): Promise<SelectionBounds | null> => {
      if (!selectionBounds) return null
      const snapshot = selectionBounds
      try {
        await navigator.clipboard.writeText(
          serializeSelection(getDisplayRow, columnIds, snapshot),
        )
      } catch {
        return null
      }
      return snapshot
    }, [selectionBounds, getDisplayRow, columnIds])

  const copySelection = React.useCallback(() => {
    void writeSelectionToClipboard().then(bounds => {
      if (bounds) {
        setCopiedRange(buildCopiedRange(bounds, orderedRows, columnIds))
      }
    })
  }, [writeSelectionToClipboard, orderedRows, columnIds])

  const cutSelection = React.useCallback(() => {
    void writeSelectionToClipboard().then(bounds => {
      if (!bounds) return
      // Match native cut: clear the source and DROP the marching-ants outline —
      // an outline around now-empty cells reads as "still copyable" and is wrong.
      clearSelection()
      setCopiedRange(null)
    })
  }, [writeSelectionToClipboard, clearSelection])

  // Apply pasted TSV text (shared by the native paste event and the
  // context-menu "Paste" action). Anchors at the selection's top-left (or the
  // focused cell), and TILES the copied block across a larger selection when it
  // divides evenly — the standard "paste one to many" behavior.
  const applyPaste = React.useCallback(
    (text: string) => {
      if (!resolveCell || !text) return
      const matrix = parseTsv(text)
      const pr = matrix.length
      const pc = matrix.reduce((m, cells) => Math.max(m, cells.length), 0)
      if (pr === 0 || pc === 0) return

      // Anchor at the selection's top-left; a range defines the paste target,
      // a single cell just anchors a normal (possibly grid-growing) paste.
      const b = selectionBounds
      const startRow = b
        ? b.minRow
        : focusedCell
          ? (displayIndexOf(focusedCell.rowId) ?? 0)
          : 0
      const startCol = b
        ? b.minColIndex
        : focusedCell
          ? columnIds.indexOf(focusedCell.columnId)
          : 0
      const selRows = b ? b.maxRow - b.minRow + 1 : 1
      const selCols = b ? b.maxColIndex - b.minColIndex + 1 : 1

      // Paste-one-to-many: when the selection is LARGER than the copied block
      // and an exact multiple in both axes, tile the block to fill the whole
      // selection (copy 1×1 → fill; copy 1×3 into 3×3 → repeat down). Otherwise
      // paste the block once at the anchor.
      const tile =
        (selRows > pr || selCols > pc) &&
        selRows % pr === 0 &&
        selCols % pc === 0
      const destRows = tile ? selRows : pr
      const destCols = tile ? selCols : pc

      // Grow the grid when a NON-tiled paste runs past the last row (the grid
      // grows rather than dropping overflow; tiling is bounded by the existing
      // selection so it never needs new rows). `addRows` clamps at `maxRows`
      // and commits its own history entry, so a grown paste undoes in two steps.
      // Note: `addRows` appends to STORAGE order — under an active column sort
      // the new rows may not appear at the visual end (same caveat as reorder).
      const overflow = tile ? 0 : startRow + destRows - orderedRows.length
      const created = overflow > 0 ? grid.addRows(overflow) : []

      const patchById = new Map<string, Record<string, CellState<string>>>()
      for (let dr = 0; dr < destRows; dr++) {
        const rowId =
          orderedRows[startRow + dr]?.id ??
          created[startRow + dr - orderedRows.length]?.id
        if (!rowId) continue
        const patch = patchById.get(rowId) ?? {}
        for (let dc = 0; dc < destCols; dc++) {
          const col = columnIds[startCol + dc]
          if (!col) continue
          const raw = matrix[dr % pr]?.[dc % pc] ?? ""
          patch[col] = resolveCell(col, raw)
        }
        patchById.set(rowId, patch)
      }
      if (patchById.size === 0) return
      grid.updateRows(rows =>
        rows.map(row => {
          const patch = patchById.get(row.id)
          return patch ? { ...row, ...patch } : row
        }),
      )
      setCopiedRange(null)
      // Flash the specific cells that received pasted values (cell-level for
      // value changes, the standard behavior), not whole rows. No scroll — user is here.
      const flashed: { rowId: string; columnId: string }[] = []
      patchById.forEach((patch, rowId) => {
        for (const columnId of Object.keys(patch))
          flashed.push({ rowId, columnId })
      })
      flashCells(flashed, { scrollIntoView: false })
    },
    [
      resolveCell,
      focusedCell,
      selectionBounds,
      orderedRows,
      columnIds,
      displayIndexOf,
      grid,
      flashCells,
    ],
  )

  const pasteFromClipboard = React.useCallback(() => {
    void (async () => {
      try {
        const text = await navigator.clipboard.readText()
        if (text) applyPaste(text)
      } catch {
        // Clipboard read may be blocked (permissions / focus) — Ctrl/Cmd+V still works.
      }
    })()
  }, [applyPaste])

  const clearCopiedRange = React.useCallback(() => setCopiedRange(null), [])

  // Native clipboard events — subscribed ONCE while this component is mounted;
  // handlers read the latest state through a ref instead of re-binding three
  // document listeners on every render.
  const clipboardRef = React.useRef({
    editingCell,
    selectionBounds,
    columnIds,
    orderedRows,
    getDisplayRow,
    resolveCell,
    clearSelection,
    applyPaste,
  })
  clipboardRef.current = {
    editingCell,
    selectionBounds,
    columnIds,
    orderedRows,
    getDisplayRow,
    resolveCell,
    clearSelection,
    applyPaste,
  }

  React.useEffect(() => {
    // Only intercept when the grid actually has focus — the listeners live on
    // `document`, so without this guard a grid with a lingering selection
    // would hijack copy/paste aimed at other inputs on the page.
    const gridHasFocus = () =>
      wrapperRef.current?.contains(document.activeElement) ?? false

    const onCopy = (e: ClipboardEvent) => {
      const s = clipboardRef.current
      if (!gridHasFocus()) return
      if (s.editingCell || !s.selectionBounds) return
      e.clipboardData?.setData(
        "text/plain",
        serializeSelection(s.getDisplayRow, s.columnIds, s.selectionBounds),
      )
      e.preventDefault()
      setCopiedRange(
        buildCopiedRange(s.selectionBounds, s.orderedRows, s.columnIds),
      )
    }
    const onCut = (e: ClipboardEvent) => {
      const s = clipboardRef.current
      if (!gridHasFocus()) return
      if (s.editingCell || !s.selectionBounds) return
      e.clipboardData?.setData(
        "text/plain",
        serializeSelection(s.getDisplayRow, s.columnIds, s.selectionBounds),
      )
      e.preventDefault()
      s.clearSelection()
      setCopiedRange(null)
    }
    const onPaste = (e: ClipboardEvent) => {
      const s = clipboardRef.current
      if (!gridHasFocus()) return
      if (s.editingCell || !s.resolveCell) return
      const text = e.clipboardData?.getData("text/plain")
      if (!text) return
      e.preventDefault()
      s.applyPaste(text)
    }

    document.addEventListener("copy", onCopy)
    document.addEventListener("cut", onCut)
    document.addEventListener("paste", onPaste)
    return () => {
      document.removeEventListener("copy", onCopy)
      document.removeEventListener("cut", onCut)
      document.removeEventListener("paste", onPaste)
    }
  }, [wrapperRef])

  const payload = React.useMemo<ClipboardFeature>(
    () => ({
      copiedRange,
      copySelection,
      cutSelection,
      pasteFromClipboard,
      clearCopiedRange,
      canPaste: !!resolveCell,
    }),
    [
      copiedRange,
      copySelection,
      cutSelection,
      pasteFromClipboard,
      clearCopiedRange,
      resolveCell,
    ],
  )
  useRegisterGridFeature("clipboard", payload)

  return null
}
src/components/niko-table/grid/components/grid-fill-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
 */
import * as React from "react"

import { useDataTableFlash } from "../../hooks/use-data-table-flash"
import {
  GRID_EDGE_SPEED,
  GRID_EDGE_ZONE,
  useDataGridInternals,
  useRegisterGridFeature,
  type FillFeature,
} from "../core/data-grid-features"
import { emptyCell } from "../types/grid-cell"
import type { CellState, SelectionBounds } from "../types/grid-cell"

/**
 * The fill rectangle while dragging the fill handle to a target cell. Single
 * axis (the standard behavior): extends the source selection toward the target on the
 * dominant axis only (down/up OR left/right).
 */
function computeFillBounds(
  source: SelectionBounds,
  targetRow: number,
  targetCol: number,
): SelectionBounds {
  const vert = Math.max(
    Math.max(0, targetRow - source.maxRow),
    Math.max(0, source.minRow - targetRow),
  )
  const horiz = Math.max(
    Math.max(0, targetCol - source.maxColIndex),
    Math.max(0, source.minColIndex - targetCol),
  )
  if (vert === 0 && horiz === 0) return source
  if (vert >= horiz) {
    return targetRow > source.maxRow
      ? { ...source, maxRow: targetRow }
      : { ...source, minRow: targetRow }
  }
  return targetCol > source.maxColIndex
    ? { ...source, maxColIndex: targetCol }
    : { ...source, minColIndex: targetCol }
}

/**
 * Opt-in fill handle for the grid — the small square at the selection's
 * bottom-right corner. Drag it to fill down/across (tiles the source values,
 * the standard behavior); double-click to auto-fill down to the last row. After a fill
 * the filled range becomes the selection (standard behavior). Drop it inside
 * `<DataGrid>`; leave it out and no handle renders and no fill code ships.
 *
 * @example
 * <DataGrid grid={grid}>
 *   <DataGridFillHandle />
 *   <DataTable>…</DataTable>
 * </DataGrid>
 */
export function DataGridFillHandle() {
  const { selectionBounds, wrapperRef, pointerRef, envRef, selectRange } =
    useDataGridInternals()
  const { flashCells } = useDataTableFlash()

  const [fillBounds, setFillBounds] = React.useState<SelectionBounds | null>(
    null,
  )
  const fillModeRef = React.useRef(false)
  const fillSourceRef = React.useRef<SelectionBounds | null>(null)
  const fillBoundsRef = React.useRef<SelectionBounds | null>(null)
  fillBoundsRef.current = fillBounds
  const fillRafRef = React.useRef<number | null>(null)
  const onUpRef = React.useRef<(() => void) | null>(null)

  // Copy the source selection's values into the fill extension (tiled), flash.
  const applyFill = React.useCallback(
    (source: SelectionBounds, fill: SelectionBounds) => {
      const env = envRef.current
      const srcRows = source.maxRow - source.minRow + 1
      const srcCols = source.maxColIndex - source.minColIndex + 1
      const patch = new Map<string, Record<string, CellState<string>>>()
      const flashed: { rowId: string; columnId: string }[] = []
      for (let r = fill.minRow; r <= fill.maxRow; r++) {
        for (let c = fill.minColIndex; c <= fill.maxColIndex; c++) {
          const inSource =
            r >= source.minRow &&
            r <= source.maxRow &&
            c >= source.minColIndex &&
            c <= source.maxColIndex
          if (inSource) continue
          const targetRowId = env.orderedRows[r]?.id
          const targetCol = env.columnIds[c]
          if (!targetRowId || !targetCol) continue
          const sr =
            source.minRow +
            ((((r - source.minRow) % srcRows) + srcRows) % srcRows)
          const sc =
            source.minColIndex +
            ((((c - source.minColIndex) % srcCols) + srcCols) % srcCols)
          const srcRow = env.orderedRows[sr]?.original
          const srcColId = env.columnIds[sc]
          if (!srcRow || !srcColId) continue
          const p = patch.get(targetRowId) ?? {}
          // Blank source cells may be unmaterialized (absent) — fall back to a
          // fresh empty cell so the destination always gets a valid CellState.
          const src = srcRow[srcColId] as CellState<string> | undefined
          p[targetCol] = src ? { ...src } : emptyCell<string>()
          patch.set(targetRowId, p)
          flashed.push({ rowId: targetRowId, columnId: targetCol })
        }
      }
      if (patch.size === 0) return
      env.grid.updateRows(rows =>
        rows.map(row => {
          const pp = patch.get(row.id)
          return pp ? { ...row, ...pp } : row
        }),
      )
      flashCells(flashed, { scrollIntoView: false })
    },
    [envRef, flashCells],
  )

  // rAF loop while dragging: track the pointer, live-preview the fill
  // rectangle, and auto-scroll at both axes' edges (a horizontal fill needs
  // horizontal auto-scroll just as a fill-down needs vertical).
  const fillStep = React.useCallback(() => {
    if (!fillModeRef.current) {
      fillRafRef.current = null
      return
    }
    const env = envRef.current
    const source = fillSourceRef.current
    const scrollEl = wrapperRef.current?.querySelector<HTMLElement>(
      '[data-slot="table-container"]',
    )
    const { x, y } = pointerRef.current
    if (scrollEl) {
      const rect = scrollEl.getBoundingClientRect()
      if (y < rect.top + GRID_EDGE_ZONE) scrollEl.scrollTop -= GRID_EDGE_SPEED
      else if (y > rect.bottom - GRID_EDGE_ZONE)
        scrollEl.scrollTop += GRID_EDGE_SPEED
      if (x < rect.left + GRID_EDGE_ZONE) scrollEl.scrollLeft -= GRID_EDGE_SPEED
      else if (x > rect.right - GRID_EDGE_ZONE)
        scrollEl.scrollLeft += GRID_EDGE_SPEED
    }
    const attr = document
      .elementFromPoint(x, y)
      ?.closest("[data-cell]")
      ?.getAttribute("data-cell")
    if (attr && source) {
      const sep = attr.indexOf(":")
      const tRow = env.displayIndexOf(attr.slice(0, sep))
      const tCol = env.columnIds.indexOf(attr.slice(sep + 1))
      if (tRow !== undefined && tCol >= 0) {
        setFillBounds(computeFillBounds(source, tRow, tCol))
      }
    }
    fillRafRef.current = requestAnimationFrame(fillStep)
  }, [envRef, pointerRef, wrapperRef])

  const onFillHandleMouseDown = React.useCallback(
    (e: React.MouseEvent) => {
      if (!selectionBounds) return
      e.preventDefault()
      e.stopPropagation()
      // Seed the pointer — the rAF loop may run before the first mousemove.
      pointerRef.current = { x: e.clientX, y: e.clientY }
      fillSourceRef.current = selectionBounds
      fillModeRef.current = true
      setFillBounds(selectionBounds)
      if (fillRafRef.current == null)
        fillRafRef.current = requestAnimationFrame(fillStep)

      // Drag-scoped mouseup — attached only for the duration of this drag.
      const onUp = () => {
        onUpRef.current = null
        fillModeRef.current = false
        if (fillRafRef.current != null) {
          cancelAnimationFrame(fillRafRef.current)
          fillRafRef.current = null
        }
        const source = fillSourceRef.current
        const fill = fillBoundsRef.current
        if (source && fill) {
          applyFill(source, fill)
          selectRange(fill) // extend the selection over the fill, the standard behavior
        }
        setFillBounds(null)
        fillSourceRef.current = null
      }
      onUpRef.current = onUp
      window.addEventListener("mouseup", onUp, { once: true })
    },
    [selectionBounds, fillStep, applyFill, selectRange, pointerRef],
  )

  // Double-click the handle → auto-fill down to the grid's last row.
  const onFillHandleDoubleClick = React.useCallback(() => {
    const rowCount = envRef.current.orderedRows.length
    if (!selectionBounds || rowCount === 0) return
    const fill = { ...selectionBounds, maxRow: rowCount - 1 }
    if (fill.maxRow > selectionBounds.maxRow) {
      applyFill(selectionBounds, fill)
      selectRange(fill) // highlight the filled range, the standard behavior
    }
  }, [selectionBounds, envRef, applyFill, selectRange])

  // Unmount safety: cancel a drag in flight.
  React.useEffect(
    () => () => {
      if (fillRafRef.current != null) cancelAnimationFrame(fillRafRef.current)
      if (onUpRef.current) {
        window.removeEventListener("mouseup", onUpRef.current)
        onUpRef.current = null
      }
    },
    [],
  )

  const payload = React.useMemo<FillFeature>(
    () => ({ fillBounds, onFillHandleMouseDown, onFillHandleDoubleClick }),
    [fillBounds, onFillHandleMouseDown, onFillHandleDoubleClick],
  )
  useRegisterGridFeature("fill", payload)

  return null
}
src/components/niko-table/grid/components/grid-move.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 * as React from "react"

import { useDataTableFlash } from "../../hooks/use-data-table-flash"
import {
  GRID_EDGE_SPEED,
  GRID_EDGE_ZONE,
  clampGridIndex as clamp,
  useDataGridInternals,
  useRegisterGridFeature,
  type MoveFeature,
} from "../core/data-grid-features"
import {
  emptyCell,
  type CellPosition,
  type CellState,
  type SelectionBounds,
} from "../types/grid-cell"

/**
 * Opt-in drag-to-move for the grid (border-drag) — grab the selection's
 * outer border and drag the whole block to a new location. The values MOVE
 * (source clears); holding Ctrl/Cmd COPIES instead (toggleable mid-drag). Drop
 * it inside `<DataGrid>`; leave it out and no grab strips render and no move
 * code ships.
 *
 * @example
 * <DataGrid grid={grid}>
 *   <DataGridMove />
 *   <DataTable>…</DataTable>
 * </DataGrid>
 */
export function DataGridMove() {
  const { selectionBounds, wrapperRef, pointerRef, envRef, selectRange } =
    useDataGridInternals()
  const { flashCells } = useDataTableFlash()

  const [moveBounds, setMoveBounds] = React.useState<SelectionBounds | null>(
    null,
  )
  const moveModeRef = React.useRef(false)
  const moveSourceRef = React.useRef<SelectionBounds | null>(null)
  const moveGrabRef = React.useRef<{ row: number; col: number } | null>(null)
  const moveOffsetRef = React.useRef({ dr: 0, dc: 0 })
  const moveCopyRef = React.useRef(false)
  const moveRafRef = React.useRef<number | null>(null)
  const dragCleanupRef = React.useRef<(() => void) | null>(null)

  // Move (or copy) the source block by (dr, dc). Reads the whole source block
  // into memory FIRST so an overlapping source/target is safe, clears the source
  // on a move, stamps the target, then selects + flashes the landing rectangle.
  const applyMove = React.useCallback(
    (source: SelectionBounds, dr: number, dc: number, copy: boolean) => {
      if (dr === 0 && dc === 0) return
      const env = envRef.current
      const patch = new Map<string, Record<string, CellState<string>>>()
      const ensure = (rowId: string) => {
        const existing = patch.get(rowId)
        if (existing) return existing
        const created: Record<string, CellState<string>> = {}
        patch.set(rowId, created)
        return created
      }

      // 1. Snapshot the source block, and (for a move) clear it.
      const block: (CellState<string> | undefined)[][] = []
      for (let r = source.minRow; r <= source.maxRow; r++) {
        const srcRow = env.orderedRows[r]?.original
        const srcRowId = env.orderedRows[r]?.id
        const rowVals: (CellState<string> | undefined)[] = []
        for (let c = source.minColIndex; c <= source.maxColIndex; c++) {
          const col = env.columnIds[c]
          rowVals.push(
            srcRow && col ? (srcRow[col] as CellState<string>) : undefined,
          )
          if (!copy && srcRowId && col)
            ensure(srcRowId)[col] = emptyCell<string>()
        }
        block.push(rowVals)
      }

      // 2. Stamp the target (writes win over the source-clear on overlap).
      // Every in-bounds source cell overwrites its destination — an empty
      // source cell stamps empty (the move replaces the whole rectangle, it
      // doesn't merge). Only populated cells flash.
      const flashed: { rowId: string; columnId: string }[] = []
      for (let r = source.minRow; r <= source.maxRow; r++) {
        const targetRowId = env.orderedRows[r + dr]?.id
        if (!targetRowId) continue
        for (let c = source.minColIndex; c <= source.maxColIndex; c++) {
          const col = env.columnIds[c + dc]
          if (!col) continue
          const val = block[r - source.minRow]?.[c - source.minColIndex]
          ensure(targetRowId)[col] = val ? { ...val } : emptyCell<string>()
          if (val && val.status !== "empty") {
            flashed.push({ rowId: targetRowId, columnId: col })
          }
        }
      }
      if (patch.size === 0) return
      env.grid.updateRows(rows =>
        rows.map(row => {
          const p = patch.get(row.id)
          return p ? { ...row, ...p } : row
        }),
      )
      selectRange({
        minRow: source.minRow + dr,
        maxRow: source.maxRow + dr,
        minColIndex: source.minColIndex + dc,
        maxColIndex: source.maxColIndex + dc,
      })
      flashCells(flashed, { scrollIntoView: false })
    },
    [envRef, selectRange, flashCells],
  )

  // rAF loop while dragging: ghost the landing rectangle under the pointer
  // (clamped in-grid) and auto-scroll at both axes' edges.
  const moveStep = React.useCallback(() => {
    if (!moveModeRef.current) {
      moveRafRef.current = null
      return
    }
    const env = envRef.current
    const source = moveSourceRef.current
    const grab = moveGrabRef.current
    const scrollEl = wrapperRef.current?.querySelector<HTMLElement>(
      '[data-slot="table-container"]',
    )
    const { x, y } = pointerRef.current
    if (scrollEl) {
      const rect = scrollEl.getBoundingClientRect()
      if (y < rect.top + GRID_EDGE_ZONE) scrollEl.scrollTop -= GRID_EDGE_SPEED
      else if (y > rect.bottom - GRID_EDGE_ZONE)
        scrollEl.scrollTop += GRID_EDGE_SPEED
      if (x < rect.left + GRID_EDGE_ZONE) scrollEl.scrollLeft -= GRID_EDGE_SPEED
      else if (x > rect.right - GRID_EDGE_ZONE)
        scrollEl.scrollLeft += GRID_EDGE_SPEED
    }
    const attr = document
      .elementFromPoint(x, y)
      ?.closest("[data-cell]")
      ?.getAttribute("data-cell")
    if (attr && source && grab) {
      const sep = attr.indexOf(":")
      const tRow = env.displayIndexOf(attr.slice(0, sep))
      const tCol = env.columnIds.indexOf(attr.slice(sep + 1))
      if (tRow !== undefined && tCol >= 0) {
        // Offset so the grabbed cell tracks the pointer, clamped so the whole
        // block stays inside the grid.
        const maxRow = env.orderedRows.length - 1
        const maxCol = env.columnIds.length - 1
        const dr = clamp(
          tRow - grab.row,
          -source.minRow,
          maxRow - source.maxRow,
        )
        const dc = clamp(
          tCol - grab.col,
          -source.minColIndex,
          maxCol - source.maxColIndex,
        )
        moveOffsetRef.current = { dr, dc }
        setMoveBounds({
          minRow: source.minRow + dr,
          maxRow: source.maxRow + dr,
          minColIndex: source.minColIndex + dc,
          maxColIndex: source.maxColIndex + dc,
        })
      }
    }
    moveRafRef.current = requestAnimationFrame(moveStep)
  }, [envRef, pointerRef, wrapperRef])

  const onSelectionMoveMouseDown = React.useCallback(
    (e: React.MouseEvent, grabPos: CellPosition) => {
      if (!selectionBounds || e.button !== 0) return
      e.preventDefault()
      e.stopPropagation()
      const env = envRef.current
      const grabRow = env.displayIndexOf(grabPos.rowId)
      const grabCol = env.columnIds.indexOf(grabPos.columnId)
      if (grabRow === undefined || grabCol < 0) return
      // Seed the pointer — the rAF loop may run before the first mousemove.
      pointerRef.current = { x: e.clientX, y: e.clientY }
      moveSourceRef.current = selectionBounds
      moveGrabRef.current = { row: grabRow, col: grabCol }
      moveOffsetRef.current = { dr: 0, dc: 0 }
      moveCopyRef.current = e.ctrlKey || e.metaKey
      moveModeRef.current = true
      setMoveBounds(selectionBounds)
      if (moveRafRef.current == null)
        moveRafRef.current = requestAnimationFrame(moveStep)

      // Drag-scoped listeners — attached only for the duration of this drag.
      // The mousemove tracks the live copy-vs-move modifier (press/release
      // Ctrl/Cmd mid-drag); the mouseup applies and cleans everything up.
      const onDragMove = (ev: MouseEvent) => {
        moveCopyRef.current = ev.ctrlKey || ev.metaKey
      }
      const onUp = () => {
        cleanup()
        moveModeRef.current = false
        if (moveRafRef.current != null) {
          cancelAnimationFrame(moveRafRef.current)
          moveRafRef.current = null
        }
        const source = moveSourceRef.current
        const { dr, dc } = moveOffsetRef.current
        if (source) applyMove(source, dr, dc, moveCopyRef.current)
        setMoveBounds(null)
        moveSourceRef.current = null
        moveGrabRef.current = null
      }
      const cleanup = () => {
        dragCleanupRef.current = null
        window.removeEventListener("mousemove", onDragMove)
        window.removeEventListener("mouseup", onUp)
      }
      dragCleanupRef.current = cleanup
      window.addEventListener("mousemove", onDragMove)
      window.addEventListener("mouseup", onUp)
    },
    [selectionBounds, envRef, moveStep, applyMove, pointerRef],
  )

  // Unmount safety: cancel a drag in flight.
  React.useEffect(
    () => () => {
      if (moveRafRef.current != null) cancelAnimationFrame(moveRafRef.current)
      dragCleanupRef.current?.()
    },
    [],
  )

  const payload = React.useMemo<MoveFeature>(
    () => ({ moveBounds, onSelectionMoveMouseDown }),
    [moveBounds, onSelectionMoveMouseDown],
  )
  useRegisterGridFeature("move", payload)

  return null
}
src/components/niko-table/grid/components/grid-row-reorder.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 { cn } from "@/lib/utils"
import * as React from "react"
import { createPortal } from "react-dom"

import {
  GRID_EDGE_SPEED,
  GRID_EDGE_ZONE,
  useDataGridFeatures,
  useDataGridInternals,
  useRegisterGridFeature,
  type RowReorderFeature,
} from "../core/data-grid-features"

/**
 * Read the row-reorder handle inside a gutter cell. Returns `null` when
 * `<DataGridRowReorder>` isn't mounted, so a gutter can render its drag grip
 * conditionally. Wire the grip's `onMouseDown` to `onRowReorderMouseDown`.
 */
export function useDataGridRowReorder(): RowReorderFeature | null {
  return useDataGridFeatures().rowReorder ?? null
}

/** Where the dragged row will land, plus the on-screen line geometry (fixed). */
interface DropTarget {
  targetRowId: string
  position: "before" | "after"
  top: number
  left: number
  width: number
}

export interface DataGridRowReorderProps {
  /** Extra classes for the drop-indicator line (defaults to a primary bar). */
  className?: string
}

/**
 * Opt-in drag-to-reorder ROWS for the grid (a gutter row handle). A grab
 * handle in the consumer's gutter calls `onRowReorderMouseDown`; drag up/down
 * and a drop line shows where the row will land; release commits the move as a
 * single undoable history entry. Grid-native (no dnd-kit) so it keeps the
 * grid cell chrome, column resize, virtualization, and scales — only the
 * dragged row moves (O(n) splice), nothing re-registers per row.
 *
 * Reorders the UNDERLYING row array by id, so it's correct with no active sort
 * (the grid's default). With a column sort applied, display order and storage
 * order diverge and a drop may not land where the line showed — disable the
 * handle while sorted if that matters for your data.
 *
 * The drag itself is pointer-only. For keyboard users, pair this with a
 * keyboard path in your row menu (e.g. "Move up" / "Move down" items calling
 * `grid.updateRows` with the same splice), and keep the grip focusable so it
 * is discoverable.
 *
 * @example
 * <DataGrid grid={grid}>
 *   <DataGridRowReorder />
 *   <DataTable>…</DataTable>
 * </DataGrid>
 */
export function DataGridRowReorder({
  className,
}: DataGridRowReorderProps = {}) {
  const { wrapperRef, pointerRef, envRef } = useDataGridInternals()

  const [draggingRowId, setDraggingRowId] = React.useState<string | null>(null)
  const [dropTarget, setDropTarget] = React.useState<DropTarget | null>(null)

  const dragModeRef = React.useRef(false)
  const dragRowIdRef = React.useRef<string | null>(null)
  const dropTargetRef = React.useRef<DropTarget | null>(null)
  const rafRef = React.useRef<number | null>(null)
  const dragCleanupRef = React.useRef<(() => void) | null>(null)

  // Move the dragged row to just before/after the target row in the underlying
  // array (by id — survives display order), as one history commit.
  const applyReorder = React.useCallback(
    (sourceId: string, target: DropTarget) => {
      if (sourceId === target.targetRowId) return
      envRef.current.grid.updateRows(rows => {
        const from = rows.findIndex(r => r.id === sourceId)
        if (from < 0) return rows
        const copy = rows.slice()
        const [moved] = copy.splice(from, 1)
        if (!moved) return rows
        const targetIdx = copy.findIndex(r => r.id === target.targetRowId)
        if (targetIdx < 0) return rows
        const insertAt = target.position === "after" ? targetIdx + 1 : targetIdx
        // Dropping the row back into its own slot is a no-op — return the
        // original array (===) so no phantom history entry is committed.
        if (insertAt === from) return rows
        copy.splice(insertAt, 0, moved)
        return copy
      })
    },
    [envRef],
  )

  // rAF loop while dragging: auto-scroll at the vertical edges (only when the
  // list actually overflows, so a short grid never scrolls) and resolve the drop
  // target by scanning the RENDERED rows for the one closest to the pointer —
  // robust where `elementFromPoint` returns nothing (gaps, the footer, past the
  // last row), which would otherwise leave the target stuck "after the last row"
  // and drop everything at the end.
  const reorderStep = React.useCallback(() => {
    if (!dragModeRef.current) {
      rafRef.current = null
      return
    }
    const scrollEl = wrapperRef.current?.querySelector<HTMLElement>(
      '[data-slot="table-container"]',
    )
    const { y } = pointerRef.current
    if (scrollEl) {
      const rect = scrollEl.getBoundingClientRect()
      const canScrollUp = scrollEl.scrollTop > 0
      const canScrollDown =
        scrollEl.scrollTop < scrollEl.scrollHeight - scrollEl.clientHeight - 1
      if (y < rect.top + GRID_EDGE_ZONE && canScrollUp)
        scrollEl.scrollTop -= GRID_EDGE_SPEED
      else if (y > rect.bottom - GRID_EDGE_ZONE && canScrollDown)
        scrollEl.scrollTop += GRID_EDGE_SPEED

      // Closest rendered row to the pointer → before/after by its midpoint.
      const rows = scrollEl.querySelectorAll<HTMLElement>("tr[data-row-id]")
      let best: DropTarget | null = null
      let bestDist = Infinity
      rows.forEach(tr => {
        const id = tr.getAttribute("data-row-id")
        if (!id) return
        const r = tr.getBoundingClientRect()
        const mid = r.top + r.height / 2
        const dist = Math.abs(y - mid)
        if (dist < bestDist) {
          bestDist = dist
          const position: "before" | "after" = y < mid ? "before" : "after"
          best = {
            targetRowId: id,
            position,
            top: position === "before" ? r.top : r.bottom,
            left: r.left,
            width: r.width,
          }
        }
      })
      if (best) {
        dropTargetRef.current = best
        setDropTarget(best)
      }
    }
    rafRef.current = requestAnimationFrame(reorderStep)
  }, [wrapperRef, pointerRef])

  const onRowReorderMouseDown = React.useCallback(
    (e: React.MouseEvent, rowId: string) => {
      if (e.button !== 0) return
      e.preventDefault()
      e.stopPropagation()
      // Seed the pointer — the rAF loop may run before the first mousemove.
      pointerRef.current = { x: e.clientX, y: e.clientY }
      dragModeRef.current = true
      dragRowIdRef.current = rowId
      dropTargetRef.current = null
      setDraggingRowId(rowId)
      setDropTarget(null)
      if (rafRef.current == null)
        rafRef.current = requestAnimationFrame(reorderStep)

      const onUp = () => {
        cleanup()
        dragModeRef.current = false
        if (rafRef.current != null) {
          cancelAnimationFrame(rafRef.current)
          rafRef.current = null
        }
        const source = dragRowIdRef.current
        const target = dropTargetRef.current
        if (source && target) applyReorder(source, target)
        setDraggingRowId(null)
        setDropTarget(null)
        dragRowIdRef.current = null
        dropTargetRef.current = null
      }
      const cleanup = () => {
        dragCleanupRef.current = null
        window.removeEventListener("mouseup", onUp)
      }
      dragCleanupRef.current = cleanup
      window.addEventListener("mouseup", onUp)
    },
    [pointerRef, reorderStep, applyReorder],
  )

  // Unmount safety: cancel a drag in flight.
  React.useEffect(
    () => () => {
      if (rafRef.current != null) cancelAnimationFrame(rafRef.current)
      dragCleanupRef.current?.()
    },
    [],
  )

  const payload = React.useMemo<RowReorderFeature>(
    () => ({ draggingRowId, onRowReorderMouseDown }),
    [draggingRowId, onRowReorderMouseDown],
  )
  useRegisterGridFeature("rowReorder", payload)

  // The drop indicator: a fixed-position primary line at the target row edge.
  // Portaled to the body so it's never clipped by the scroll container's
  // overflow. Fixed coords come straight from the target row's rect.
  if (!dropTarget || typeof document === "undefined") return null
  return createPortal(
    <div
      aria-hidden
      className={cn(
        "pointer-events-none fixed z-50 h-0.5 rounded-full bg-primary",
        className,
      )}
      style={{
        top: dropTarget.top - 1,
        left: dropTarget.left,
        width: dropTarget.width,
      }}
    />,
    document.body,
  )
}
src/components/niko-table/grid/components/grid-cross-highlight.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 * as React from "react"

import {
  useDataTableActiveCellSetter,
  type ActiveCell,
} from "../../core/data-table-context"
import { useDataGridInternals } from "../core/data-grid-features"

/**
 * Opt-in grid-style cross-highlight — EVERY column and row in the current
 * selection lights up: the column HEADERS in the selection, and each selected
 * row's `data-active-row` marker (style your row-number gutter with
 * `group-data-[active-row=true]:…`). Drop it inside `<DataGrid>`; leave it out
 * and selection changes never touch the table's active-cell context.
 *
 * @example
 * <DataGrid grid={grid}>
 *   <DataGridCrossHighlight />
 *   <DataTable>…</DataTable>
 * </DataGrid>
 */
export function DataGridCrossHighlight() {
  const { selectionBounds, columnIds } = useDataGridInternals()
  const setActiveCell = useDataTableActiveCellSetter()

  // Span of the selection: the ids of its columns (few, all rendered → Set)
  // and its inclusive row-index range (rows are virtualized → range, not Set).
  const active = React.useMemo<ActiveCell>(() => {
    if (!selectionBounds) return null
    const cols = new Set<string>()
    for (
      let c = selectionBounds.minColIndex;
      c <= selectionBounds.maxColIndex;
      c++
    ) {
      const id = columnIds[c]
      if (id) cols.add(id)
    }
    return {
      columnIds: cols,
      rowRange: { min: selectionBounds.minRow, max: selectionBounds.maxRow },
    }
  }, [selectionBounds, columnIds])

  // Publish on change; clear only on unmount (a per-change cleanup-to-null would
  // flash the highlight off then on between selections).
  React.useEffect(() => {
    setActiveCell(active)
  }, [active, setActiveCell])
  React.useEffect(() => () => setActiveCell(null), [setActiveCell])

  return null
}
src/components/niko-table/grid/components/grid-status-bar.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 { cn } from "@/lib/utils"
import * as React from "react"

import { useDataGridInternals } from "../core/data-grid-features"
import type { CellState } from "../types/grid-cell"

/**
 * Beyond this many selected cells we skip the sum/avg scan (huge selections
 * like Ctrl+A over 500k rows) and show just the selected-cell count, so the
 * status bar never blocks a drag/keystroke.
 */
const MAX_STATS_CELLS = 20_000

interface SelectionStats {
  /** Total cells in the selection rectangle. */
  cells: number
  /** Whether the sum/avg scan ran (selection ≤ cap). */
  scanned: boolean
  /** Non-empty cells. */
  count: number
  /** Cells whose value parsed as a number. */
  numericCount: number
  sum: number
  min: number
  max: number
}

/**
 * Opt-in selection status bar — standard Count / Sum / Avg / Min /
 * Max of the current selection. Drop it wherever you want the readout (usually
 * a footer under the table); it renders nothing until a range of `minCells`+ is
 * selected. Requires being inside `<DataGrid>`.
 *
 * @example
 * <DataGrid grid={grid}>
 *   <DataTable>…</DataTable>
 *   <DataGridStatusBar />
 * </DataGrid>
 */
export function DataGridStatusBar({
  className,
  /** Smallest selection (in cells) that shows the bar. Default 2 — hide for a single cell. */
  minCells = 2,
}: {
  className?: string
  minCells?: number
}) {
  const { selectionBounds, orderedRows, columnIds } = useDataGridInternals()

  const stats = React.useMemo<SelectionStats | null>(() => {
    if (!selectionBounds) return null
    const { minRow, maxRow, minColIndex, maxColIndex } = selectionBounds
    const cells = (maxRow - minRow + 1) * (maxColIndex - minColIndex + 1)
    if (cells < minCells) return null

    const scanned = cells <= MAX_STATS_CELLS
    let count = 0
    let numericCount = 0
    let sum = 0
    let min = Infinity
    let max = -Infinity
    if (scanned) {
      for (let r = minRow; r <= maxRow; r++) {
        const row = orderedRows[r]?.original
        if (!row) continue
        for (let c = minColIndex; c <= maxColIndex; c++) {
          const col = columnIds[c]
          if (!col) continue
          const raw = (row[col] as CellState<string> | undefined)?.raw ?? ""
          if (raw === "") continue
          count++
          // Tolerate thousands separators; blank/whitespace isn't a number.
          const n = Number(raw.replace(/,/g, ""))
          if (raw.trim() !== "" && Number.isFinite(n)) {
            numericCount++
            sum += n
            if (n < min) min = n
            if (n > max) max = n
          }
        }
      }
    }
    return { cells, scanned, count, numericCount, sum, min, max }
  }, [selectionBounds, orderedRows, columnIds, minCells])

  if (!stats) return null

  const fmt = (n: number) =>
    n.toLocaleString(undefined, { maximumFractionDigits: 2 })

  return (
    <div
      className={cn(
        "flex items-center justify-end gap-4 border-t border-border bg-muted/30 px-3 py-1 text-xs text-muted-foreground tabular-nums",
        className,
      )}
    >
      {stats.scanned ? (
        <>
          <StatusStat label="Count" value={stats.count.toLocaleString()} />
          {stats.numericCount > 0 && (
            <>
              <StatusStat label="Sum" value={fmt(stats.sum)} />
              <StatusStat
                label="Avg"
                value={fmt(stats.sum / stats.numericCount)}
              />
              <StatusStat label="Min" value={fmt(stats.min)} />
              <StatusStat label="Max" value={fmt(stats.max)} />
            </>
          )}
        </>
      ) : (
        <StatusStat
          label="Selected"
          value={`${stats.cells.toLocaleString()} cells`}
        />
      )}
    </div>
  )
}

function StatusStat({ label, value }: { label: string; value: string }) {
  return (
    <span>
      {label} <span className="font-medium text-foreground">{value}</span>
    </span>
  )
}
src/components/niko-table/grid/components/grid-column-menu.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 {
  DropdownMenuGroup,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuSub,
  DropdownMenuSubContent,
  DropdownMenuSubTrigger,
} from "@/components/ui/dropdown-menu"
import {
  ArrowLeft,
  ArrowLeftToLine,
  ArrowRight,
  ArrowRightToLine,
  Pencil,
  Shapes,
  Trash2,
} from "lucide-react"

import { useColumnHeaderContext } from "../../components/data-table-column-header"
import { useDataGridColumns } from "../core/data-grid-columns-context"

/**
 * Dynamic-column actions for the header dropdown — Rename, Insert left/right,
 * Move left/right, Delete. Reads the column from the header context and the
 * mutations from `<DataGridColumns>`. Drop it inside `<DataTableColumnActions>`
 * alongside the built-in Sort / Pin / Hide options:
 *
 * @example
 * <DataTableColumnActions>
 *   <DataTableColumnSortOptions />
 *   <GridColumnMenuOptions />
 * </DataTableColumnActions>
 */
export function GridColumnMenuOptions({
  withSeparator = true,
}: {
  withSeparator?: boolean
}) {
  const { column } = useColumnHeaderContext(true)
  const {
    columns,
    columnTypes,
    changeColumnType,
    addColumn,
    removeColumn,
    moveColumn,
    beginRename,
  } = useDataGridColumns()

  const id = column.id
  const index = columns.findIndex(c => c.id === id)
  const currentType = columns[index]?.type
  const isFirst = index <= 0
  const isLast = index === columns.length - 1
  const isOnly = columns.length <= 1

  return (
    <>
      {withSeparator && <DropdownMenuSeparator />}
      <DropdownMenuGroup>
        <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
          Column
        </DropdownMenuLabel>
        <DropdownMenuItem onClick={() => beginRename(id)}>
          <Pencil className="mr-2 size-4 text-muted-foreground/70" />
          Rename
        </DropdownMenuItem>
        {columnTypes.length > 0 && (
          <DropdownMenuSub>
            <DropdownMenuSubTrigger>
              <Shapes className="mr-2 size-4 text-muted-foreground/70" />
              Column type
            </DropdownMenuSubTrigger>
            <DropdownMenuSubContent>
              <DropdownMenuRadioGroup
                value={currentType}
                onValueChange={v => changeColumnType(id, v)}
              >
                {columnTypes.map(t => (
                  <DropdownMenuRadioItem key={t.value} value={t.value}>
                    {t.label}
                  </DropdownMenuRadioItem>
                ))}
              </DropdownMenuRadioGroup>
            </DropdownMenuSubContent>
          </DropdownMenuSub>
        )}
        <DropdownMenuItem onClick={() => addColumn({}, index)}>
          <ArrowLeftToLine className="mr-2 size-4 text-muted-foreground/70" />
          Insert column left
        </DropdownMenuItem>
        <DropdownMenuItem onClick={() => addColumn({}, index + 1)}>
          <ArrowRightToLine className="mr-2 size-4 text-muted-foreground/70" />
          Insert column right
        </DropdownMenuItem>
        <DropdownMenuItem disabled={isFirst} onClick={() => moveColumn(id, -1)}>
          <ArrowLeft className="mr-2 size-4 text-muted-foreground/70" />
          Move left
        </DropdownMenuItem>
        <DropdownMenuItem disabled={isLast} onClick={() => moveColumn(id, 1)}>
          <ArrowRight className="mr-2 size-4 text-muted-foreground/70" />
          Move right
        </DropdownMenuItem>
        <DropdownMenuItem
          variant="destructive"
          disabled={isOnly}
          onClick={() => removeColumn(id)}
        >
          <Trash2 className="mr-2 size-4" />
          Delete column
        </DropdownMenuItem>
      </DropdownMenuGroup>
    </>
  )
}
src/components/niko-table/grid/components/grid-add-column.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 { Button } from "@/components/ui/button"
import { Columns3 } from "lucide-react"

import { useDataGridColumns } from "../core/data-grid-columns-context"

/**
 * Toolbar button that appends a new column (opens rename so the user can name
 * it immediately). Requires a `<DataGridColumns>` ancestor.
 */
export function DataGridAddColumnButton({
  label = "Add column",
}: {
  label?: string
}) {
  const { addColumn, beginRename } = useDataGridColumns()
  return (
    <Button
      type="button"
      variant="outline"
      onClick={() => {
        const created = addColumn()
        beginRename(created.id)
      }}
    >
      <Columns3 className="size-4" />
      {label}
    </Button>
  )
}
src/components/niko-table/grid/components/grid-row-menu.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 {
  ArrowDownToLine,
  ArrowUpToLine,
  ClipboardPaste,
  Copy,
  Eraser,
  Scissors,
  Trash2,
} from "lucide-react"

import {
  RowMenuItem,
  RowMenuSeparator,
} from "../../components/data-table-row-menu"
import { useDataGridContext } from "../core/data-grid-context"
import { useDataGridFeatures } from "../core/data-grid-features"
import type { GridRow } from "../types/grid-cell"

/**
 * Grid cell context menu — a standard grid menu: Copy / Cut / Paste
 * (shown only when `<DataGridClipboard>` is mounted), Insert rows above/below,
 * Clear, Delete rows. Declarative — reads the display-order-aware selection
 * actions from `DataGridContext`, so the SAME component drops into
 * `<DataTableRowContextMenuSlot>` (right-click) or a toolbar kebab. Operates
 * on the current selection rectangle.
 */
export function GridRowMenu() {
  const {
    clearSelection,
    deleteSelectedRows,
    insertRowsAbove,
    insertRowsBelow,
    hasSelection,
  } = useDataGridContext<GridRow>()
  const { clipboard } = useDataGridFeatures()

  return (
    <>
      {clipboard && (
        <>
          <RowMenuItem
            onClick={clipboard.copySelection}
            disabled={!hasSelection}
          >
            <Copy className="size-4" />
            Copy
          </RowMenuItem>
          <RowMenuItem
            onClick={clipboard.cutSelection}
            disabled={!hasSelection}
          >
            <Scissors className="size-4" />
            Cut
          </RowMenuItem>
          {clipboard.canPaste && (
            <RowMenuItem
              onClick={clipboard.pasteFromClipboard}
              disabled={!hasSelection}
            >
              <ClipboardPaste className="size-4" />
              Paste
            </RowMenuItem>
          )}
          <RowMenuSeparator />
        </>
      )}
      <RowMenuItem onClick={insertRowsAbove} disabled={!hasSelection}>
        <ArrowUpToLine className="size-4" />
        Insert rows above
      </RowMenuItem>
      <RowMenuItem onClick={insertRowsBelow} disabled={!hasSelection}>
        <ArrowDownToLine className="size-4" />
        Insert rows below
      </RowMenuItem>
      <RowMenuSeparator />
      <RowMenuItem onClick={clearSelection} disabled={!hasSelection}>
        <Eraser className="size-4" />
        Clear contents
      </RowMenuItem>
      <RowMenuItem
        variant="destructive"
        onClick={deleteSelectedRows}
        disabled={!hasSelection}
      >
        <Trash2 className="size-4" />
        Delete rows
      </RowMenuItem>
    </>
  )
}
src/components/niko-table/grid/components/grid-toolbar.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 { Button } from "@/components/ui/button"
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { Plus, Redo2, Undo2, X } from "lucide-react"

import { useDataGridContext } from "../core/data-grid-context"
import type { GridRow } from "../types/grid-cell"

/** Undo the last data change (also Cmd/Ctrl+Z). */
export function DataGridUndo() {
  const { grid } = useDataGridContext<GridRow>()
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <Button
          type="button"
          variant="outline"
          size="icon"
          onClick={() => grid.undo()}
          disabled={!grid.canUndo}
          aria-label="Undo"
        >
          <Undo2 className="size-4" />
        </Button>
      </TooltipTrigger>
      <TooltipContent>Undo the last change (Cmd/Ctrl+Z)</TooltipContent>
    </Tooltip>
  )
}

/** Redo the last undone change (also Cmd/Ctrl+Shift+Z or Ctrl+Y). */
export function DataGridRedo() {
  const { grid } = useDataGridContext<GridRow>()
  return (
    <Tooltip>
      <TooltipTrigger asChild>
        <Button
          type="button"
          variant="outline"
          size="icon"
          onClick={() => grid.redo()}
          disabled={!grid.canRedo}
          aria-label="Redo"
        >
          <Redo2 className="size-4" />
        </Button>
      </TooltipTrigger>
      <TooltipContent>
        Redo the last undone change (Cmd/Ctrl+Shift+Z)
      </TooltipContent>
    </Tooltip>
  )
}

/** Toolbar layout wrapper — compose the pieces below (or your own) inside it. */
export function DataGridToolbar({
  children,
  className,
}: {
  children: React.ReactNode
  className?: string
}) {
  return (
    <div className={cn("flex flex-wrap items-center gap-2", className)}>
      {children}
    </div>
  )
}

/**
 * Append N empty rows to the grid. `count={1}` reads "Add row" (the singular);
 * any other count reads "Add {count} rows". Pass `className` to restyle — e.g. a
 * full-width dashed "Add row" bar under the grid body for a spreadsheet-style
 * always-visible single-row append. `label` overrides the text entirely.
 */
export function DataGridAddRows({
  count = 5,
  className,
  label,
}: {
  count?: number
  className?: string
  label?: React.ReactNode
}) {
  const { grid, columnIds } = useDataGridContext<GridRow>()
  const atCap = grid.rows.length >= grid.maxRows
  return (
    <Button
      type="button"
      variant="outline"
      className={className}
      onClick={() => {
        const created = grid.addRows(count)
        // Focus the first new row's first cell — the container scrolls on focus
        // change, so this brings the new rows into view and readies them for typing.
        const first = created[0]
        if (first && columnIds[0]) {
          grid.selectCell({ rowId: first.id, columnId: columnIds[0] })
        }
      }}
      disabled={atCap}
    >
      <Plus className="size-4" />
      {label ?? (count === 1 ? "Add row" : `Add ${count} rows`)}
    </Button>
  )
}

/** Replace every row with blanks (same count as the seed). */
export function DataGridClearAll() {
  const { grid } = useDataGridContext<GridRow>()
  return (
    <Button type="button" variant="outline" onClick={() => grid.clearAll()}>
      <X className="size-4" />
      Clear all
    </Button>
  )
}

/** Static hint that tabular data can be pasted in. */
export function DataGridPasteHint() {
  return (
    <span className="text-sm text-muted-foreground">
      Paste a spreadsheet (Ctrl/Cmd+V) to fill rows
    </span>
  )
}
src/components/niko-table/grid/components/grid-shortcuts-dialog.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 { Button } from "@/components/ui/button"
import {
  Dialog,
  DialogContent,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog"
import { Kbd, KbdGroup } from "@/components/ui/kbd"
import { Separator } from "@/components/ui/separator"
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip"
import { Keyboard } from "lucide-react"

import {
  GRID_SHORTCUTS,
  formatShortcutKey,
  useIsMac,
  type GridShortcutGroup,
} from "../config/grid-shortcuts"

/** Read-only list of the grid's keyboard shortcuts, grouped by category. */
export function GridShortcutsList({
  groups = GRID_SHORTCUTS,
}: {
  groups?: GridShortcutGroup[]
}) {
  const isMac = useIsMac()
  return (
    <div className="space-y-4">
      {groups.map(group => (
        <div key={group.category} className="space-y-1.5">
          <div className="text-xs font-medium text-muted-foreground">
            {group.category}
          </div>
          {group.shortcuts.map((s, i) => (
            <div
              key={i}
              className="flex items-center justify-between gap-4 text-sm"
            >
              <span>{s.description}</span>
              <KbdGroup>
                {s.keys.map((k, j) => (
                  <Kbd key={j}>{formatShortcutKey(k, isMac)}</Kbd>
                ))}
              </KbdGroup>
            </div>
          ))}
        </div>
      ))}
    </div>
  )
}

/**
 * Icon button that opens a keyboard-shortcuts help dialog. Self-contained (owns
 * its open state). Drop into a `<DataGridToolbar>`. Pass `open`/`onOpenChange`
 * to control it externally (e.g. to also open on the `?` key).
 */
export function DataGridShortcutsButton({
  open,
  onOpenChange,
}: {
  open?: boolean
  onOpenChange?: (open: boolean) => void
}) {
  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <Tooltip>
        <TooltipTrigger asChild>
          <DialogTrigger asChild>
            <Button
              type="button"
              variant="outline"
              size="icon"
              aria-label="Keyboard shortcuts"
            >
              <Keyboard className="size-4" />
            </Button>
          </DialogTrigger>
        </TooltipTrigger>
        <TooltipContent>Keyboard shortcuts (?)</TooltipContent>
      </Tooltip>
      <DialogContent className="sm:max-w-md">
        <DialogHeader>
          <DialogTitle>Keyboard shortcuts</DialogTitle>
        </DialogHeader>
        <div className="-mx-6">
          <Separator />
        </div>
        <GridShortcutsList />
      </DialogContent>
    </Dialog>
  )
}

More from niko-table

Browse all components in this library →