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 · lighticonslucide-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
Data TableblockDataTable AsidecomponentDataTable Clear FiltercomponentDataTable Column Auto-fitcomponentDataTable Column Date FiltercomponentDataTable Column DnDcomponentDataTable Column Faceted FiltercomponentDataTable Column Groupcomponent
Browse all components in this library →