From 4fc9265aff5071185966a3905ac30dce47e0a37a Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Sun, 26 Oct 2025 21:06:22 +0600 Subject: [PATCH 1/8] feat: better skeleton --- src/app/data-grid/page.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/data-grid/page.tsx b/src/app/data-grid/page.tsx index 9773e4d4..496d1be7 100644 --- a/src/app/data-grid/page.tsx +++ b/src/app/data-grid/page.tsx @@ -1,12 +1,18 @@ import { Suspense } from "react"; import { DataGridDemo } from "@/components/data-grid/data-grid-demo"; +import { Skeleton } from "@/components/ui/skeleton"; export default async function DataGridPage() { return ( - Loading... +
+
+ + + +
+
} > From 9ae84885f10503f79d440db70ff531c983369f3b Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Sun, 26 Oct 2025 21:38:47 +0600 Subject: [PATCH 2/8] refactor: states --- public/r/data-grid.json | 11 ++--------- src/components/data-grid/data-grid.tsx | 8 ++++---- src/hooks/use-data-grid.tsx | 14 +++++++------- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/public/r/data-grid.json b/public/r/data-grid.json index 246a98b1..9822d086 100644 --- a/public/r/data-grid.json +++ b/public/r/data-grid.json @@ -19,7 +19,6 @@ "dialog", "dropdown-menu", "input", - "kbd", "popover", "select", "separator", @@ -29,7 +28,7 @@ "files": [ { "path": "src/components/data-grid/data-grid.tsx", - "content": "\"use client\";\n\nimport { flexRender } from \"@tanstack/react-table\";\nimport { Plus } from \"lucide-react\";\nimport * as React from \"react\";\nimport { DataGridColumnHeader } from \"@/components/data-grid/data-grid-column-header\";\nimport { DataGridContextMenu } from \"@/components/data-grid/data-grid-context-menu\";\nimport { DataGridKeyboardShortcuts } from \"@/components/data-grid/data-grid-keyboard-shortcuts\";\nimport { DataGridRow } from \"@/components/data-grid/data-grid-row\";\nimport { DataGridSearch } from \"@/components/data-grid/data-grid-search\";\nimport type { useDataGrid } from \"@/hooks/use-data-grid\";\nimport { getCommonPinningStyles } from \"@/lib/data-table\";\nimport { cn } from \"@/lib/utils\";\nimport type { CellPosition } from \"@/types/data-grid\";\n\ninterface DataGridProps\n extends ReturnType>,\n React.ComponentProps<\"div\"> {\n height?: number;\n onRowAdd?: (event?: React.MouseEvent) =>\n | Partial\n | Promise>\n | null\n // biome-ignore lint/suspicious/noConfusingVoidType: void is needed here to allow functions without explicit return\n | void;\n}\n\nexport function DataGrid({\n dataGridRef,\n headerRef,\n rowMapRef,\n footerRef,\n table,\n rowVirtualizer,\n height = 600,\n searchState,\n columnSizeVars,\n scrollToRow,\n onRowAdd: onRowAddProp,\n className,\n ...props\n}: DataGridProps) {\n const rows = table.getRowModel().rows;\n const columns = table.getAllColumns();\n\n const meta = table.options.meta;\n const rowHeight = meta?.rowHeight ?? \"short\";\n const focusedCell = meta?.focusedCell ?? null;\n\n const onGridContextMenu = React.useCallback(\n (event: React.MouseEvent) => {\n event.preventDefault();\n },\n [],\n );\n\n const onRowAdd = React.useCallback(\n async (event?: React.MouseEvent) => {\n if (!onRowAddProp) return;\n\n const result = await onRowAddProp();\n\n if (event?.defaultPrevented || result === null) return;\n\n if (result) {\n const adjustedRowIndex =\n (result.rowIndex ?? 0) >= rows.length ? rows.length : result.rowIndex;\n\n scrollToRow({\n rowIndex: adjustedRowIndex,\n columnId: result.columnId,\n });\n return;\n }\n\n scrollToRow({ rowIndex: rows.length });\n },\n [onRowAddProp, scrollToRow, rows.length],\n );\n\n const onAddRowKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (!onRowAddProp) return;\n\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault();\n onRowAdd();\n }\n },\n [onRowAddProp, onRowAdd],\n );\n\n return (\n \n {searchState && }\n \n \n \n \n {table.getHeaderGroups().map((headerGroup, rowIndex) => (\n \n {headerGroup.headers.map((header, colIndex) => {\n const sorting = table.getState().sorting;\n const currentSort = sorting.find(\n (sort) => sort.id === header.column.id,\n );\n const isSortable = header.column.getCanSort();\n\n return (\n \n {header.isPlaceholder ? null : typeof header.column\n .columnDef.header === \"function\" ? (\n
\n {flexRender(\n header.column.columnDef.header,\n header.getContext(),\n )}\n
\n ) : (\n \n )}\n \n );\n })}\n \n ))}\n \n \n {rowVirtualizer.getVirtualIndexes().map((virtualRowIndex) => {\n const row = rows[virtualRowIndex];\n if (!row) return null;\n\n return (\n \n );\n })}\n \n {onRowAdd && (\n \n \n \n
\n \n Add row\n
\n \n \n \n )}\n \n \n );\n}\n", + "content": "\"use client\";\n\nimport { flexRender } from \"@tanstack/react-table\";\nimport { Plus } from \"lucide-react\";\nimport * as React from \"react\";\nimport { DataGridColumnHeader } from \"@/components/data-grid/data-grid-column-header\";\nimport { DataGridContextMenu } from \"@/components/data-grid/data-grid-context-menu\";\nimport { DataGridRow } from \"@/components/data-grid/data-grid-row\";\nimport { DataGridSearch } from \"@/components/data-grid/data-grid-search\";\nimport type { useDataGrid } from \"@/hooks/use-data-grid\";\nimport { getCommonPinningStyles } from \"@/lib/data-table\";\nimport { cn } from \"@/lib/utils\";\nimport type { CellPosition } from \"@/types/data-grid\";\n\ninterface DataGridProps\n extends ReturnType>,\n React.ComponentProps<\"div\"> {\n height?: number;\n onRowAdd?: (event?: React.MouseEvent) =>\n | Partial\n | Promise>\n | null\n // biome-ignore lint/suspicious/noConfusingVoidType: void is needed here to allow functions without explicit return\n | void;\n}\n\nexport function DataGrid({\n dataGridRef,\n headerRef,\n rowMapRef,\n footerRef,\n table,\n rowVirtualizer,\n height = 600,\n searchState,\n columnSizeVars,\n onScrollToRow,\n onRowAdd: onRowAddProp,\n className,\n ...props\n}: DataGridProps) {\n const rows = table.getRowModel().rows;\n const columns = table.getAllColumns();\n\n const meta = table.options.meta;\n const rowHeight = meta?.rowHeight ?? \"short\";\n const focusedCell = meta?.focusedCell ?? null;\n\n const onGridContextMenu = React.useCallback(\n (event: React.MouseEvent) => {\n event.preventDefault();\n },\n [],\n );\n\n const onRowAdd = React.useCallback(\n async (event?: React.MouseEvent) => {\n if (!onRowAddProp) return;\n\n const result = await onRowAddProp();\n\n if (event?.defaultPrevented || result === null) return;\n\n if (result) {\n const adjustedRowIndex =\n (result.rowIndex ?? 0) >= rows.length ? rows.length : result.rowIndex;\n\n onScrollToRow({\n rowIndex: adjustedRowIndex,\n columnId: result.columnId,\n });\n return;\n }\n\n onScrollToRow({ rowIndex: rows.length });\n },\n [onRowAddProp, onScrollToRow, rows.length],\n );\n\n const onAddRowKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (!onRowAddProp) return;\n\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault();\n onRowAdd();\n }\n },\n [onRowAddProp, onRowAdd],\n );\n\n return (\n \n {searchState && }\n \n \n \n {table.getHeaderGroups().map((headerGroup, rowIndex) => (\n \n {headerGroup.headers.map((header, colIndex) => {\n const sorting = table.getState().sorting;\n const currentSort = sorting.find(\n (sort) => sort.id === header.column.id,\n );\n const isSortable = header.column.getCanSort();\n\n return (\n \n {header.isPlaceholder ? null : typeof header.column\n .columnDef.header === \"function\" ? (\n
\n {flexRender(\n header.column.columnDef.header,\n header.getContext(),\n )}\n
\n ) : (\n \n )}\n \n );\n })}\n \n ))}\n \n \n {rowVirtualizer.getVirtualIndexes().map((virtualRowIndex) => {\n const row = rows[virtualRowIndex];\n if (!row) return null;\n\n return (\n \n );\n })}\n \n {onRowAdd && (\n \n \n \n
\n \n Add row\n
\n \n \n \n )}\n \n \n );\n}\n", "type": "registry:component", "target": "src/components/data-grid/data-grid.tsx" }, @@ -63,12 +62,6 @@ "type": "registry:component", "target": "src/components/data-grid/data-grid-context-menu.tsx" }, - { - "path": "src/components/data-grid/data-grid-keyboard-shortcuts.tsx", - "content": "\"use client\";\n\nimport { SearchIcon, XIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Kbd, KbdGroup } from \"@/components/ui/kbd\";\nimport { Separator } from \"@/components/ui/separator\";\n\nconst SHORTCUT_KEY = \"/\";\n\ninterface ShortcutGroup {\n title: string;\n shortcuts: Array<{\n keys: string[];\n description: string;\n }>;\n}\n\ninterface DataGridKeyboardShortcutsProps {\n enableSearch?: boolean;\n}\n\nexport const DataGridKeyboardShortcuts = React.memo(\n DataGridKeyboardShortcutsImpl,\n (prev, next) => {\n return prev.enableSearch === next.enableSearch;\n },\n);\n\nfunction DataGridKeyboardShortcutsImpl({\n enableSearch = false,\n}: DataGridKeyboardShortcutsProps) {\n const [open, setOpen] = React.useState(false);\n const [input, setInput] = React.useState(\"\");\n const inputRef = React.useRef(null);\n\n const isMac =\n typeof navigator !== \"undefined\"\n ? /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)\n : false;\n\n const modKey = isMac ? \"⌘\" : \"Ctrl\";\n\n const onOpenChange = React.useCallback((isOpen: boolean) => {\n setOpen(isOpen);\n if (!isOpen) {\n setInput(\"\");\n }\n }, []);\n\n const onOpenAutoFocus = React.useCallback((event: Event) => {\n event.preventDefault();\n inputRef.current?.focus();\n }, []);\n\n const onInputChange = React.useCallback(\n (event: React.ChangeEvent) => {\n setInput(event.target.value);\n },\n [],\n );\n\n const shortcutGroups: ShortcutGroup[] = React.useMemo(\n () => [\n {\n title: \"Navigation\",\n shortcuts: [\n {\n keys: [\"↑\", \"↓\", \"←\", \"→\"],\n description: \"Navigate between cells\",\n },\n {\n keys: [\"Tab\"],\n description: \"Move to next cell\",\n },\n {\n keys: [\"Shift\", \"Tab\"],\n description: \"Move to previous cell\",\n },\n {\n keys: [\"Home\"],\n description: \"Move to first column\",\n },\n {\n keys: [\"End\"],\n description: \"Move to last column\",\n },\n {\n keys: [modKey, \"Home\"],\n description: \"Move to first cell\",\n },\n {\n keys: [modKey, \"End\"],\n description: \"Move to last cell\",\n },\n {\n keys: [\"PgUp\"],\n description: \"Move up one page\",\n },\n {\n keys: [\"PgDn\"],\n description: \"Move down one page\",\n },\n ],\n },\n {\n title: \"Selection\",\n shortcuts: [\n {\n keys: [\"Shift\", \"↑↓←→\"],\n description: \"Extend selection\",\n },\n {\n keys: [modKey, \"A\"],\n description: \"Select all cells\",\n },\n {\n keys: [modKey, \"Click\"],\n description: \"Toggle cell selection\",\n },\n {\n keys: [\"Shift\", \"Click\"],\n description: \"Select range\",\n },\n {\n keys: [\"Esc\"],\n description: \"Clear selection\",\n },\n ],\n },\n {\n title: \"Editing\",\n shortcuts: [\n {\n keys: [\"Enter\"],\n description: \"Start editing cell\",\n },\n {\n keys: [\"Double Click\"],\n description: \"Start editing cell\",\n },\n {\n keys: [\"Delete\"],\n description: \"Clear selected cells\",\n },\n {\n keys: [\"Backspace\"],\n description: \"Clear selected cells\",\n },\n ],\n },\n ...(enableSearch\n ? [\n {\n title: \"Search\",\n shortcuts: [\n {\n keys: [modKey, \"F\"],\n description: \"Open search\",\n },\n {\n keys: [\"Enter\"],\n description: \"Next match\",\n },\n {\n keys: [\"Shift\", \"Enter\"],\n description: \"Previous match\",\n },\n {\n keys: [\"Esc\"],\n description: \"Close search\",\n },\n ],\n },\n ]\n : []),\n {\n title: \"Sorting\",\n shortcuts: [\n {\n keys: [modKey, \"Shift\", \"S\"],\n description: \"Toggle the sort menu\",\n },\n {\n keys: [\"Backspace\"],\n description: \"Remove sort (when focused)\",\n },\n {\n keys: [\"Delete\"],\n description: \"Remove sort (when focused)\",\n },\n ],\n },\n {\n title: \"General\",\n shortcuts: [\n {\n keys: [modKey, \"/\"],\n description: \"Show keyboard shortcuts\",\n },\n ],\n },\n ],\n [modKey, enableSearch],\n );\n\n const filteredGroups = React.useMemo(() => {\n if (!input.trim()) return shortcutGroups;\n\n const query = input.toLowerCase();\n return shortcutGroups\n .map((group) => ({\n ...group,\n shortcuts: group.shortcuts.filter(\n (shortcut) =>\n shortcut.description.toLowerCase().includes(query) ||\n shortcut.keys.some((key) => key.toLowerCase().includes(query)),\n ),\n }))\n .filter((group) => group.shortcuts.length > 0);\n }, [shortcutGroups, input]);\n\n React.useEffect(() => {\n function onKeyDown(event: KeyboardEvent) {\n if ((event.ctrlKey || event.metaKey) && event.key === SHORTCUT_KEY) {\n event.preventDefault();\n setOpen(true);\n }\n }\n\n window.addEventListener(\"keydown\", onKeyDown);\n return () => {\n window.removeEventListener(\"keydown\", onKeyDown);\n };\n }, []);\n\n return (\n \n \n \n \n \n \n Keyboard shortcuts\n \n Use these keyboard shortcuts to navigate and interact with the data\n grid more efficiently.\n \n \n
\n
\n \n \n
\n
\n \n
\n {filteredGroups.length === 0 ? (\n
\n
\n \n
\n
\n
\n No shortcuts found\n
\n

\n Try searching for a different term.\n

\n
\n
\n ) : (\n
\n {filteredGroups.map((shortcutGroup) => (\n
\n

\n {shortcutGroup.title}\n

\n
\n {shortcutGroup.shortcuts.map((shortcut, index) => (\n \n ))}\n
\n
\n ))}\n
\n )}\n
\n \n
\n );\n}\n\nfunction ShortcutCard({\n keys,\n description,\n}: ShortcutGroup[\"shortcuts\"][number]) {\n return (\n
\n {description}\n \n {keys.map((key, index) => (\n \n {index > 0 && (\n +\n )}\n {key}\n \n ))}\n \n
\n );\n}\n", - "type": "registry:component", - "target": "src/components/data-grid/data-grid-keyboard-shortcuts.tsx" - }, { "path": "src/components/data-grid/data-grid-row.tsx", "content": "\"use client\";\n\nimport { flexRender, type Row } from \"@tanstack/react-table\";\nimport type { Virtualizer } from \"@tanstack/react-virtual\";\nimport * as React from \"react\";\nimport { useComposedRefs } from \"@/lib/compose-refs\";\nimport { getRowHeightValue } from \"@/lib/data-grid\";\nimport { getCommonPinningStyles } from \"@/lib/data-table\";\nimport { cn } from \"@/lib/utils\";\nimport type { CellPosition, RowHeightValue } from \"@/types/data-grid\";\n\ninterface DataGridRowProps extends React.ComponentProps<\"div\"> {\n row: Row;\n rowVirtualizer: Virtualizer;\n virtualRowIndex: number;\n rowMapRef: React.RefObject>;\n rowHeight: RowHeightValue;\n focusedCell: CellPosition | null;\n}\n\nexport const DataGridRow = React.memo(DataGridRowImpl, (prev, next) => {\n if (prev.row.id !== next.row.id) {\n return false;\n }\n\n const prevRowIndex = prev.virtualRowIndex;\n const nextRowIndex = next.virtualRowIndex;\n\n const prevHasFocus = prev.focusedCell?.rowIndex === prevRowIndex;\n const nextHasFocus = next.focusedCell?.rowIndex === nextRowIndex;\n\n if (prevHasFocus !== nextHasFocus) {\n return false;\n }\n\n if (nextHasFocus && prevHasFocus) {\n const prevFocusedCol = prev.focusedCell?.columnId;\n const nextFocusedCol = next.focusedCell?.columnId;\n if (prevFocusedCol !== nextFocusedCol) {\n return false;\n }\n }\n\n if (next.rowVirtualizer.isScrolling) {\n return true;\n }\n\n return false;\n}) as typeof DataGridRowImpl;\n\nfunction DataGridRowImpl({\n row,\n virtualRowIndex,\n rowVirtualizer,\n rowMapRef,\n rowHeight,\n focusedCell,\n ref,\n className,\n ...props\n}: DataGridRowProps) {\n const rowRef = useComposedRefs(ref, (node) => {\n if (node && typeof virtualRowIndex !== \"undefined\") {\n rowVirtualizer.measureElement(node);\n rowMapRef.current.set(virtualRowIndex, node);\n }\n });\n\n const isRowSelected = row.getIsSelected();\n\n return (\n \n {row.getVisibleCells().map((cell, colIndex) => {\n const isCellFocused =\n focusedCell?.rowIndex === virtualRowIndex &&\n focusedCell?.columnId === cell.column.id;\n\n return (\n \n {typeof cell.column.columnDef.header === \"function\" ? (\n \n {flexRender(cell.column.columnDef.cell, cell.getContext())}\n \n ) : (\n flexRender(cell.column.columnDef.cell, cell.getContext())\n )}\n \n );\n })}\n \n );\n}\n", @@ -88,7 +81,7 @@ }, { "path": "src/hooks/use-data-grid.tsx", - "content": "\"use client\";\n\nimport {\n type ColumnDef,\n getCoreRowModel,\n getSortedRowModel,\n type RowSelectionState,\n type SortingState,\n type TableOptions,\n type Updater,\n useReactTable,\n} from \"@tanstack/react-table\";\nimport { useVirtualizer, type Virtualizer } from \"@tanstack/react-virtual\";\nimport * as React from \"react\";\nimport { DataGridCell } from \"@/components/data-grid/data-grid-cell\";\nimport { getCellKey, getRowHeightValue, parseCellKey } from \"@/lib/data-grid\";\nimport type {\n CellPosition,\n ContextMenuState,\n NavigationDirection,\n RowHeightValue,\n SearchState,\n SelectionState,\n UpdateCell,\n} from \"@/types/data-grid\";\n\nconst DEFAULT_ROW_HEIGHT = \"short\";\nconst OVERSCAN = 3;\nconst VIEWPORT_OFFSET = 1;\nconst MIN_COLUMN_SIZE = 60;\nconst MAX_COLUMN_SIZE = 800;\nconst SEARCH_SHORTCUT_KEY = \"f\";\nconst NON_NAVIGABLE_COLUMN_IDS = [\"select\", \"actions\"];\n\nconst useIsomorphicLayoutEffect =\n typeof window !== \"undefined\" ? React.useLayoutEffect : React.useEffect;\n\nfunction useLazyRef(fn: () => T): React.RefObject {\n const ref = React.useRef(null);\n if (ref.current === null) {\n ref.current = fn();\n }\n return ref as React.RefObject;\n}\n\nfunction useAsRef(data: T) {\n const ref = React.useRef(data);\n\n useIsomorphicLayoutEffect(() => {\n ref.current = data;\n });\n\n return ref;\n}\n\ninterface DataGridState {\n sorting: SortingState;\n rowHeight: RowHeightValue;\n rowSelection: RowSelectionState;\n selectionState: SelectionState;\n focusedCell: CellPosition | null;\n editingCell: CellPosition | null;\n contextMenu: ContextMenuState;\n searchQuery: string;\n searchMatches: CellPosition[];\n matchIndex: number;\n searchOpen: boolean;\n lastClickedRowIndex: number | null;\n isScrolling: boolean;\n}\n\ninterface DataGridStore {\n subscribe: (callback: () => void) => () => void;\n getState: () => DataGridState;\n setState: (\n key: K,\n value: DataGridState[K],\n ) => void;\n notify: () => void;\n batch: (fn: () => void) => void;\n}\n\nfunction useStore(\n store: DataGridStore,\n selector: (state: DataGridState) => T,\n): T {\n const getSnapshot = React.useCallback(\n () => selector(store.getState()),\n [store, selector],\n );\n\n return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);\n}\n\ninterface UseDataGridProps\n extends Omit, \"pageCount\" | \"getCoreRowModel\"> {\n onDataChange?: (data: TData[]) => void;\n rowHeight?: RowHeightValue;\n overscan?: number;\n autoFocus?: boolean | Partial;\n enableColumnSelection?: boolean;\n enableSearch?: boolean;\n}\n\nexport function useDataGrid({\n columns,\n data,\n onDataChange,\n rowHeight: rowHeightProp = DEFAULT_ROW_HEIGHT,\n overscan = OVERSCAN,\n initialState,\n autoFocus = false,\n enableColumnSelection = false,\n enableSearch = false,\n ...dataGridProps\n}: UseDataGridProps) {\n const dataGridRef = React.useRef(null);\n const tableRef = React.useRef>>(null);\n const rowVirtualizerRef =\n React.useRef>(null);\n const headerRef = React.useRef(null);\n const rowMapRef = React.useRef>(new Map());\n const footerRef = React.useRef(null);\n\n const dataGridPropsRef = useAsRef(dataGridProps);\n const listenersRef = useLazyRef(() => new Set<() => void>());\n\n const stateRef = useLazyRef(() => {\n return {\n sorting: initialState?.sorting ?? [],\n rowHeight: rowHeightProp,\n rowSelection: initialState?.rowSelection ?? {},\n selectionState: {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n },\n focusedCell: null,\n editingCell: null,\n contextMenu: {\n open: false,\n x: 0,\n y: 0,\n },\n searchQuery: \"\",\n searchMatches: [],\n matchIndex: -1,\n searchOpen: false,\n lastClickedRowIndex: null,\n isScrolling: false,\n };\n });\n\n const store = React.useMemo(() => {\n let isBatching = false;\n let pendingNotification = false;\n\n return {\n subscribe: (callback) => {\n listenersRef.current.add(callback);\n return () => listenersRef.current.delete(callback);\n },\n getState: () => stateRef.current,\n setState: (key, value) => {\n if (Object.is(stateRef.current[key], value)) return;\n stateRef.current[key] = value;\n\n if (isBatching) {\n pendingNotification = true;\n } else {\n if (!pendingNotification) {\n pendingNotification = true;\n queueMicrotask(() => {\n pendingNotification = false;\n store.notify();\n });\n }\n }\n },\n notify: () => {\n for (const listener of listenersRef.current) {\n listener();\n }\n },\n batch: (fn) => {\n if (isBatching) {\n fn();\n return;\n }\n\n isBatching = true;\n const wasPending = pendingNotification;\n pendingNotification = false;\n\n try {\n fn();\n } finally {\n isBatching = false;\n if (pendingNotification || wasPending) {\n pendingNotification = false;\n store.notify();\n }\n }\n },\n };\n }, [listenersRef, stateRef]);\n\n const focusedCell = useStore(store, (state) => state.focusedCell);\n const editingCell = useStore(store, (state) => state.editingCell);\n const selectionState = useStore(store, (state) => state.selectionState);\n const searchQuery = useStore(store, (state) => state.searchQuery);\n const searchMatches = useStore(store, (state) => state.searchMatches);\n const matchIndex = useStore(store, (state) => state.matchIndex);\n const searchOpen = useStore(store, (state) => state.searchOpen);\n const sorting = useStore(store, (state) => state.sorting);\n const rowSelection = useStore(store, (state) => state.rowSelection);\n const contextMenu = useStore(store, (state) => state.contextMenu);\n const rowHeight = useStore(store, (state) => state.rowHeight);\n const isScrolling = useStore(store, (state) => state.isScrolling);\n\n const rowHeightValue = getRowHeightValue(rowHeight);\n\n const columnIds = React.useMemo(() => {\n return columns\n .map((c) => {\n if (c.id) return c.id;\n if (\"accessorKey\" in c) return c.accessorKey as string;\n return undefined;\n })\n .filter((id): id is string => Boolean(id));\n }, [columns]);\n\n const navigableColumnIds = React.useMemo(() => {\n return columnIds.filter((c) => !NON_NAVIGABLE_COLUMN_IDS.includes(c));\n }, [columnIds]);\n\n const onDataUpdate = React.useCallback(\n (updates: UpdateCell | Array) => {\n const updateArray = Array.isArray(updates) ? updates : [updates];\n\n if (updateArray.length === 0) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n\n const rowUpdatesMap = new Map<\n number,\n Array>\n >();\n\n for (const update of updateArray) {\n if (!rows || !currentTable) {\n const existingUpdates = rowUpdatesMap.get(update.rowIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(update.rowIndex, existingUpdates);\n } else {\n const row = rows[update.rowIndex];\n if (!row) continue;\n\n const originalData = row.original;\n const originalRowIndex = data.indexOf(originalData);\n if (originalRowIndex === -1) continue;\n\n const existingUpdates = rowUpdatesMap.get(originalRowIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(originalRowIndex, existingUpdates);\n }\n }\n\n const newData = data.map((row, index) => {\n const updates = rowUpdatesMap.get(index);\n if (!updates) return row;\n\n const updatedRow = { ...row } as Record;\n for (const { columnId, value } of updates) {\n updatedRow[columnId] = value;\n }\n return updatedRow as TData;\n });\n\n onDataChange?.(newData);\n },\n [data, onDataChange],\n );\n\n const getIsCellSelected = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return selectionState.selectedCells.has(getCellKey(rowIndex, columnId));\n },\n [selectionState.selectedCells],\n );\n\n const clearSelection = React.useCallback(() => {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"rowSelection\", {});\n });\n }, [store]);\n\n const selectAll = React.useCallback(() => {\n const allCells = new Set();\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {\n for (const columnId of columnIds) {\n allCells.add(getCellKey(rowIndex, columnId));\n }\n }\n\n const firstColumnId = columnIds[0];\n const lastColumnId = columnIds[columnIds.length - 1];\n\n store.setState(\"selectionState\", {\n selectedCells: allCells,\n selectionRange:\n columnIds.length > 0 && rowCount > 0 && firstColumnId && lastColumnId\n ? {\n start: { rowIndex: 0, columnId: firstColumnId },\n end: { rowIndex: rowCount - 1, columnId: lastColumnId },\n }\n : null,\n isSelecting: false,\n });\n }, [columnIds, data.length, store]);\n\n const selectColumn = React.useCallback(\n (columnId: string) => {\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n if (rowCount === 0) return;\n\n const selectedCells = new Set();\n\n for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: {\n start: { rowIndex: 0, columnId },\n end: { rowIndex: rowCount - 1, columnId },\n },\n isSelecting: false,\n });\n },\n [data.length, store],\n );\n\n const selectRange = React.useCallback(\n (start: CellPosition, end: CellPosition, isSelecting = false) => {\n const startColIndex = columnIds.indexOf(start.columnId);\n const endColIndex = columnIds.indexOf(end.columnId);\n\n const minRow = Math.min(start.rowIndex, end.rowIndex);\n const maxRow = Math.max(start.rowIndex, end.rowIndex);\n const minCol = Math.min(startColIndex, endColIndex);\n const maxCol = Math.max(startColIndex, endColIndex);\n\n const selectedCells = new Set();\n\n for (let rowIndex = minRow; rowIndex <= maxRow; rowIndex++) {\n for (let colIndex = minCol; colIndex <= maxCol; colIndex++) {\n const columnId = columnIds[colIndex];\n if (columnId) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n }\n }\n\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: { start, end },\n isSelecting,\n });\n },\n [columnIds, store],\n );\n\n const focusCell = React.useCallback(\n (rowIndex: number, columnId: string) => {\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", null);\n });\n\n const currentState = store.getState();\n\n if (currentState.searchOpen) return;\n\n if (\n dataGridRef.current &&\n document.activeElement !== dataGridRef.current\n ) {\n dataGridRef.current.focus();\n }\n },\n [store],\n );\n\n const navigateCell = React.useCallback(\n (direction: NavigationDirection) => {\n const currentState = store.getState();\n if (!currentState.focusedCell) return;\n\n const { rowIndex, columnId } = currentState.focusedCell;\n const currentColIndex = navigableColumnIds.indexOf(columnId);\n const rowVirtualizer = rowVirtualizerRef.current;\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n let newRowIndex = rowIndex;\n let newColumnId = columnId;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(rowCount - 1, rowIndex + 1);\n break;\n case \"left\":\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n break;\n case \"right\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n break;\n case \"home\":\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? columnId;\n }\n break;\n case \"end\":\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;\n }\n break;\n case \"ctrl+home\":\n newRowIndex = 0;\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? columnId;\n }\n break;\n case \"ctrl+end\":\n newRowIndex = Math.max(0, rowCount - 1);\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;\n }\n break;\n case \"pageup\":\n if (rowVirtualizer) {\n const visibleRange = rowVirtualizer.getVirtualItems();\n const pageSize = visibleRange.length ?? 10;\n newRowIndex = Math.max(0, rowIndex - pageSize);\n } else {\n newRowIndex = Math.max(0, rowIndex - 10);\n }\n break;\n case \"pagedown\":\n if (rowVirtualizer) {\n const visibleRange = rowVirtualizer.getVirtualItems();\n const pageSize = visibleRange.length ?? 10;\n newRowIndex = Math.min(rowCount - 1, rowIndex + pageSize);\n } else {\n newRowIndex = Math.min(rowCount - 1, rowIndex + 10);\n }\n break;\n }\n\n if (newRowIndex !== rowIndex || newColumnId !== columnId) {\n const rowDiff = newRowIndex - rowIndex;\n\n // For single-row vertical navigation (up/down arrows)\n if (\n Math.abs(rowDiff) === 1 &&\n (direction === \"up\" || direction === \"down\")\n ) {\n const container = dataGridRef.current;\n const currentRow = rowMapRef.current.get(rowIndex);\n const targetRow = rowMapRef.current.get(newRowIndex);\n\n if (!container || !currentRow) {\n // Fallback to simple focus if we can't find elements\n focusCell(newRowIndex, newColumnId);\n return;\n }\n\n // Check viewport boundaries\n const containerRect = container.getBoundingClientRect();\n const headerHeight =\n headerRef.current?.getBoundingClientRect().height ?? 0;\n const footerHeight =\n footerRef.current?.getBoundingClientRect().height ?? 0;\n\n const viewportTop =\n containerRect.top + headerHeight + VIEWPORT_OFFSET;\n const viewportBottom =\n containerRect.bottom - footerHeight - VIEWPORT_OFFSET;\n\n // If target row already exists, check if it's visible\n if (targetRow) {\n const rowRect = targetRow.getBoundingClientRect();\n const isFullyVisible =\n rowRect.top >= viewportTop && rowRect.bottom <= viewportBottom;\n\n if (isFullyVisible) {\n // Row is fully visible, just focus it\n focusCell(newRowIndex, newColumnId);\n return;\n }\n\n // Row exists but not fully visible, scroll it into view\n focusCell(newRowIndex, newColumnId);\n\n if (direction === \"down\") {\n // Scroll just enough to show the row at the bottom\n const scrollNeeded = rowRect.bottom - viewportBottom;\n container.scrollTop += scrollNeeded;\n } else {\n // Scroll just enough to show the row at the top\n const scrollNeeded = viewportTop - rowRect.top;\n container.scrollTop -= scrollNeeded;\n }\n return;\n }\n\n // Target row is not rendered yet\n // Focus immediately so the ring appears as the row is revealed\n focusCell(newRowIndex, newColumnId);\n\n // Scroll by exactly one row height to reveal it smoothly\n if (direction === \"down\") {\n container.scrollTop += rowHeightValue;\n } else {\n // For arrow up, ensure we don't go below 0\n const currentScrollTop = container.scrollTop;\n const targetScrollTop = Math.max(\n 0,\n currentScrollTop - rowHeightValue,\n );\n container.scrollTop = targetScrollTop;\n }\n return;\n }\n\n // For larger jumps (page up/down, ctrl+home/end, etc.)\n if (rowVirtualizer && Math.abs(rowDiff) > 1) {\n const align =\n direction === \"pageup\" || direction === \"ctrl+home\"\n ? \"start\"\n : direction === \"pagedown\" || direction === \"ctrl+end\"\n ? \"end\"\n : \"center\";\n rowVirtualizer.scrollToIndex(newRowIndex, { align });\n requestAnimationFrame(() => {\n focusCell(newRowIndex, newColumnId);\n });\n return;\n }\n\n // For horizontal navigation or when row is already visible\n focusCell(newRowIndex, newColumnId);\n }\n },\n [store, navigableColumnIds, focusCell, data.length, rowHeightValue],\n );\n\n const onCellEditingStart = React.useCallback(\n (rowIndex: number, columnId: string) => {\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", { rowIndex, columnId });\n });\n },\n [store],\n );\n\n const onCellEditingStop = React.useCallback(\n (opts?: { moveToNextRow?: boolean; direction?: NavigationDirection }) => {\n const currentState = store.getState();\n const currentEditing = currentState.editingCell;\n\n store.setState(\"editingCell\", null);\n\n if (opts?.moveToNextRow && currentEditing) {\n const { rowIndex, columnId } = currentEditing;\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n const nextRowIndex = rowIndex + 1;\n if (nextRowIndex < rowCount) {\n requestAnimationFrame(() => {\n focusCell(nextRowIndex, columnId);\n });\n }\n } else if (opts?.direction && currentEditing) {\n // Focus the current editing cell first, then navigate\n const { rowIndex, columnId } = currentEditing;\n focusCell(rowIndex, columnId);\n requestAnimationFrame(() => {\n navigateCell(opts.direction ?? \"right\");\n });\n }\n },\n [store, data.length, focusCell, navigateCell],\n );\n\n const onSearchOpenChange = React.useCallback(\n (open: boolean) => {\n if (open) {\n store.setState(\"searchOpen\", true);\n return;\n }\n\n const currentState = store.getState();\n const currentMatch =\n currentState.matchIndex >= 0 &&\n currentState.searchMatches[currentState.matchIndex];\n\n store.batch(() => {\n store.setState(\"searchOpen\", false);\n store.setState(\"searchQuery\", \"\");\n store.setState(\"searchMatches\", []);\n store.setState(\"matchIndex\", -1);\n\n if (currentMatch) {\n store.setState(\"focusedCell\", {\n rowIndex: currentMatch.rowIndex,\n columnId: currentMatch.columnId,\n });\n }\n });\n\n if (\n dataGridRef.current &&\n document.activeElement !== dataGridRef.current\n ) {\n dataGridRef.current.focus();\n }\n },\n [store],\n );\n\n const onSearch = React.useCallback(\n (query: string) => {\n if (!query.trim()) {\n store.batch(() => {\n store.setState(\"searchMatches\", []);\n store.setState(\"matchIndex\", -1);\n });\n return;\n }\n\n const matches: CellPosition[] = [];\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n\n const lowerQuery = query.toLowerCase();\n\n for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {\n const row = rows[rowIndex];\n if (!row) continue;\n\n for (const columnId of columnIds) {\n const cell = row\n .getVisibleCells()\n .find((c) => c.column.id === columnId);\n if (!cell) continue;\n\n const value = cell.getValue();\n const stringValue = String(value ?? \"\").toLowerCase();\n\n if (stringValue.includes(lowerQuery)) {\n matches.push({ rowIndex, columnId });\n }\n }\n }\n\n store.batch(() => {\n store.setState(\"searchMatches\", matches);\n store.setState(\"matchIndex\", matches.length > 0 ? 0 : -1);\n });\n\n // Scroll to first match but don't focus it (to keep focus in search input)\n if (matches.length > 0 && matches[0]) {\n const firstMatch = matches[0];\n rowVirtualizerRef.current?.scrollToIndex(firstMatch.rowIndex, {\n align: \"center\",\n });\n }\n },\n [columnIds, store],\n );\n\n const onSearchQueryChange = React.useCallback(\n (query: string) => store.setState(\"searchQuery\", query),\n [store],\n );\n\n const onNavigateToPrevMatch = React.useCallback(() => {\n const currentState = store.getState();\n if (currentState.searchMatches.length === 0) return;\n\n const prevIndex =\n currentState.matchIndex - 1 < 0\n ? currentState.searchMatches.length - 1\n : currentState.matchIndex - 1;\n const match = currentState.searchMatches[prevIndex];\n\n if (match) {\n rowVirtualizerRef.current?.scrollToIndex(match.rowIndex, {\n align: \"center\",\n });\n\n requestAnimationFrame(() => {\n store.setState(\"matchIndex\", prevIndex);\n requestAnimationFrame(() => {\n focusCell(match.rowIndex, match.columnId);\n });\n });\n }\n }, [store, focusCell]);\n\n const onNavigateToNextMatch = React.useCallback(() => {\n const currentState = store.getState();\n if (currentState.searchMatches.length === 0) return;\n\n const nextIndex =\n (currentState.matchIndex + 1) % currentState.searchMatches.length;\n const match = currentState.searchMatches[nextIndex];\n\n if (match) {\n rowVirtualizerRef.current?.scrollToIndex(match.rowIndex, {\n align: \"center\",\n });\n\n requestAnimationFrame(() => {\n store.setState(\"matchIndex\", nextIndex);\n requestAnimationFrame(() => {\n focusCell(match.rowIndex, match.columnId);\n });\n });\n }\n }, [store, focusCell]);\n\n const getIsSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return searchMatches.some(\n (match) => match.rowIndex === rowIndex && match.columnId === columnId,\n );\n },\n [searchMatches],\n );\n\n const getIsActiveSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n if (matchIndex < 0) return false;\n const currentMatch = searchMatches[matchIndex];\n return (\n currentMatch?.rowIndex === rowIndex &&\n currentMatch?.columnId === columnId\n );\n },\n [searchMatches, matchIndex],\n );\n\n const blurCell = React.useCallback(() => {\n const currentState = store.getState();\n if (\n currentState.editingCell &&\n document.activeElement instanceof HTMLElement\n ) {\n document.activeElement.blur();\n }\n\n store.batch(() => {\n store.setState(\"focusedCell\", null);\n store.setState(\"editingCell\", null);\n });\n }, [store]);\n\n const onCellClick = React.useCallback(\n (rowIndex: number, columnId: string, event?: React.MouseEvent) => {\n // Ignore right-click (button 2) - let onCellContextMenu handle it\n if (event?.button === 2) {\n return;\n }\n\n const currentState = store.getState();\n const currentFocused = currentState.focusedCell;\n\n if (event) {\n if (event.ctrlKey || event.metaKey) {\n event.preventDefault();\n const cellKey = getCellKey(rowIndex, columnId);\n const newSelectedCells = new Set(\n currentState.selectionState.selectedCells,\n );\n\n if (newSelectedCells.has(cellKey)) {\n newSelectedCells.delete(cellKey);\n } else {\n newSelectedCells.add(cellKey);\n }\n\n store.setState(\"selectionState\", {\n selectedCells: newSelectedCells,\n selectionRange: null,\n isSelecting: false,\n });\n focusCell(rowIndex, columnId);\n return;\n }\n\n if (event.shiftKey && currentState.focusedCell) {\n event.preventDefault();\n selectRange(currentState.focusedCell, { rowIndex, columnId });\n return;\n }\n }\n\n // Clear selection if there are selected cells or rows\n const hasSelectedCells =\n currentState.selectionState.selectedCells.size > 0;\n const hasSelectedRows = Object.keys(currentState.rowSelection).length > 0;\n\n if (hasSelectedCells && !currentState.selectionState.isSelecting) {\n // If there's a cell selection but we're not actively selecting (drag just finished),\n // don't clear it - keep the selection\n // Only clear if clicking elsewhere\n const cellKey = getCellKey(rowIndex, columnId);\n const isClickingSelectedCell =\n currentState.selectionState.selectedCells.has(cellKey);\n\n if (!isClickingSelectedCell) {\n clearSelection();\n } else {\n // Clicking on an already selected cell - just focus it\n focusCell(rowIndex, columnId);\n return;\n }\n } else if (hasSelectedRows && columnId !== \"select\") {\n // If there are selected rows but we're clicking on a non-checkbox cell, clear selections\n clearSelection();\n }\n\n if (\n currentFocused?.rowIndex === rowIndex &&\n currentFocused?.columnId === columnId\n ) {\n onCellEditingStart(rowIndex, columnId);\n } else {\n focusCell(rowIndex, columnId);\n }\n },\n [store, focusCell, onCellEditingStart, selectRange, clearSelection],\n );\n\n const onCellDoubleClick = React.useCallback(\n (rowIndex: number, columnId: string, event?: React.MouseEvent) => {\n if (event?.defaultPrevented) return;\n\n onCellEditingStart(rowIndex, columnId);\n },\n [onCellEditingStart],\n );\n\n const onCellMouseDown = React.useCallback(\n (rowIndex: number, columnId: string, event: React.MouseEvent) => {\n // Ignore right-click (button 2) - let onCellContextMenu handle it\n if (event.button === 2) {\n return;\n }\n\n event.preventDefault();\n\n // Only start drag selection if no modifier keys are pressed\n // Clear any existing selection and prepare for potential drag\n if (!event.ctrlKey && !event.metaKey && !event.shiftKey) {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: {\n start: { rowIndex, columnId },\n end: { rowIndex, columnId },\n },\n isSelecting: true,\n });\n store.setState(\"rowSelection\", {});\n });\n }\n },\n [store],\n );\n\n const onCellMouseEnter = React.useCallback(\n (rowIndex: number, columnId: string, _event: React.MouseEvent) => {\n const currentState = store.getState();\n if (\n currentState.selectionState.isSelecting &&\n currentState.selectionState.selectionRange\n ) {\n const start = currentState.selectionState.selectionRange.start;\n const end = { rowIndex, columnId };\n\n if (\n currentState.focusedCell?.rowIndex !== start.rowIndex ||\n currentState.focusedCell?.columnId !== start.columnId\n ) {\n focusCell(start.rowIndex, start.columnId);\n }\n\n selectRange(start, end, true);\n }\n },\n [store, selectRange, focusCell],\n );\n\n const onCellMouseUp = React.useCallback(() => {\n const currentState = store.getState();\n store.setState(\"selectionState\", {\n ...currentState.selectionState,\n isSelecting: false,\n });\n }, [store]);\n\n const onCellContextMenu = React.useCallback(\n (rowIndex: number, columnId: string, event: React.MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n\n const currentState = store.getState();\n const cellKey = getCellKey(rowIndex, columnId);\n const isTargetCellSelected =\n currentState.selectionState.selectedCells.has(cellKey);\n\n // If right-clicking on a non-selected cell, select only that cell\n if (!isTargetCellSelected) {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set([cellKey]),\n selectionRange: {\n start: { rowIndex, columnId },\n end: { rowIndex, columnId },\n },\n isSelecting: false,\n });\n store.setState(\"focusedCell\", { rowIndex, columnId });\n });\n }\n\n // Open context menu at cursor position\n store.setState(\"contextMenu\", {\n open: true,\n x: event.clientX,\n y: event.clientY,\n });\n },\n [store],\n );\n\n const onContextMenuOpenChange = React.useCallback(\n (open: boolean) => {\n if (!open) {\n const currentMenu = store.getState().contextMenu;\n store.setState(\"contextMenu\", {\n open: false,\n x: currentMenu.x,\n y: currentMenu.y,\n });\n }\n },\n [store],\n );\n\n const onDataGridKeyDown = React.useCallback(\n (event: KeyboardEvent) => {\n const currentState = store.getState();\n const { key, ctrlKey, metaKey, shiftKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n // Handle Cmd+F / Ctrl+F to open search (highest priority, works even when editing)\n if (enableSearch && isCtrlPressed && key === SEARCH_SHORTCUT_KEY) {\n event.preventDefault();\n onSearchOpenChange(true);\n return;\n }\n\n // Handle search navigation when search is open\n if (\n enableSearch &&\n currentState.searchOpen &&\n !currentState.editingCell\n ) {\n if (key === \"Enter\") {\n event.preventDefault();\n if (shiftKey) {\n onNavigateToPrevMatch();\n } else {\n onNavigateToNextMatch();\n }\n return;\n }\n if (key === \"Escape\") {\n event.preventDefault();\n onSearchOpenChange(false);\n return;\n }\n // When search is open, don't let data grid handle any other keys\n // (they should only affect the search input)\n return;\n }\n\n if (currentState.editingCell) return;\n\n if (!currentState.focusedCell) return;\n\n let direction: NavigationDirection | null = null;\n\n if (isCtrlPressed && key === \"a\") {\n event.preventDefault();\n selectAll();\n return;\n }\n\n if (key === \"Delete\" || key === \"Backspace\") {\n if (currentState.selectionState.selectedCells.size > 0) {\n event.preventDefault();\n const updates: Array<{\n rowIndex: number;\n columnId: string;\n value: unknown;\n }> = [];\n\n currentState.selectionState.selectedCells.forEach((cellKey) => {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n updates.push({ rowIndex, columnId, value: \"\" });\n });\n\n onDataUpdate(updates);\n clearSelection();\n }\n return;\n }\n\n switch (key) {\n case \"ArrowUp\":\n direction = \"up\";\n break;\n case \"ArrowDown\":\n direction = \"down\";\n break;\n case \"ArrowLeft\":\n direction = \"left\";\n break;\n case \"ArrowRight\":\n direction = \"right\";\n break;\n case \"Home\":\n direction = isCtrlPressed ? \"ctrl+home\" : \"home\";\n break;\n case \"End\":\n direction = isCtrlPressed ? \"ctrl+end\" : \"end\";\n break;\n case \"PageUp\":\n direction = \"pageup\";\n break;\n case \"PageDown\":\n direction = \"pagedown\";\n break;\n case \"Escape\":\n event.preventDefault();\n if (\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0\n ) {\n clearSelection();\n } else {\n blurCell();\n }\n return;\n case \"Tab\":\n event.preventDefault();\n direction = event.shiftKey ? \"left\" : \"right\";\n break;\n }\n\n if (direction) {\n event.preventDefault();\n\n // Tab navigation should not trigger selection, even with Shift\n if (shiftKey && key !== \"Tab\" && currentState.focusedCell) {\n const currentColIndex = navigableColumnIds.indexOf(\n currentState.focusedCell.columnId,\n );\n let newRowIndex = currentState.focusedCell.rowIndex;\n let newColumnId = currentState.focusedCell.columnId;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, currentState.focusedCell.rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(\n (tableRef.current?.getRowModel().rows.length || data.length) -\n 1,\n currentState.focusedCell.rowIndex + 1,\n );\n break;\n case \"left\":\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n break;\n case \"right\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n break;\n }\n\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n selectRange(selectionStart, {\n rowIndex: newRowIndex,\n columnId: newColumnId,\n });\n focusCell(newRowIndex, newColumnId);\n } else {\n if (currentState.selectionState.selectedCells.size > 0) {\n clearSelection();\n }\n navigateCell(direction);\n }\n }\n },\n [\n store,\n blurCell,\n navigateCell,\n selectAll,\n onDataUpdate,\n clearSelection,\n navigableColumnIds,\n data.length,\n selectRange,\n focusCell,\n onSearchOpenChange,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n enableSearch,\n ],\n );\n\n const onSortingChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newSorting =\n typeof updater === \"function\" ? updater(currentState.sorting) : updater;\n store.setState(\"sorting\", newSorting);\n },\n [store],\n );\n\n const onRowSelectionChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newRowSelection =\n typeof updater === \"function\"\n ? updater(currentState.rowSelection)\n : updater;\n\n const selectedRows = Object.keys(newRowSelection).filter(\n (key) => newRowSelection[key],\n );\n\n const selectedCells = new Set();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n\n for (const rowId of selectedRows) {\n const rowIndex = rows.findIndex((r) => r.id === rowId);\n if (rowIndex === -1) continue;\n\n for (const columnId of columnIds) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n }\n\n store.batch(() => {\n store.setState(\"rowSelection\", newRowSelection);\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"focusedCell\", null);\n store.setState(\"editingCell\", null);\n });\n },\n [store, columnIds],\n );\n\n const onRowSelect = React.useCallback(\n (rowIndex: number, selected: boolean, shiftKey: boolean) => {\n const currentState = store.getState();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n const currentRow = rows[rowIndex];\n if (!currentRow) return;\n\n if (shiftKey && currentState.lastClickedRowIndex !== null) {\n const startIndex = Math.min(currentState.lastClickedRowIndex, rowIndex);\n const endIndex = Math.max(currentState.lastClickedRowIndex, rowIndex);\n\n const newRowSelection: RowSelectionState = {\n ...currentState.rowSelection,\n };\n\n for (let i = startIndex; i <= endIndex; i++) {\n const row = rows[i];\n if (row) {\n newRowSelection[row.id] = selected;\n }\n }\n\n onRowSelectionChange(newRowSelection);\n } else {\n onRowSelectionChange({\n ...currentState.rowSelection,\n [currentRow.id]: selected,\n });\n }\n\n store.setState(\"lastClickedRowIndex\", rowIndex);\n },\n [store, onRowSelectionChange],\n );\n\n const onRowHeightChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newRowHeight =\n typeof updater === \"function\"\n ? updater(currentState.rowHeight)\n : updater;\n store.setState(\"rowHeight\", newRowHeight);\n },\n [store],\n );\n\n const onColumnClick = React.useCallback(\n (columnId: string) => {\n if (!enableColumnSelection) {\n clearSelection();\n return;\n }\n\n selectColumn(columnId);\n },\n [enableColumnSelection, selectColumn, clearSelection],\n );\n\n const defaultColumn: Partial> = React.useMemo(\n () => ({\n cell: DataGridCell,\n minSize: MIN_COLUMN_SIZE,\n maxSize: MAX_COLUMN_SIZE,\n }),\n [],\n );\n\n const tableOptions = React.useMemo>(\n () => ({\n ...dataGridPropsRef.current,\n data,\n columns,\n defaultColumn,\n initialState,\n state: {\n ...dataGridPropsRef.current.state,\n sorting,\n rowSelection,\n },\n onRowSelectionChange,\n onSortingChange,\n columnResizeMode: \"onChange\",\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n meta: {\n ...dataGridPropsRef.current.meta,\n dataGridRef,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n isScrolling,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n onDataUpdate,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n contextMenu,\n onContextMenuOpenChange,\n rowHeight,\n onRowHeightChange,\n onRowSelect,\n },\n }),\n [\n dataGridPropsRef,\n data,\n columns,\n defaultColumn,\n initialState,\n sorting,\n rowSelection,\n onRowSelectionChange,\n onSortingChange,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n isScrolling,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n onDataUpdate,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n contextMenu,\n onContextMenuOpenChange,\n rowHeight,\n onRowHeightChange,\n onRowSelect,\n ],\n );\n\n const table = useReactTable(tableOptions);\n\n if (!tableRef.current) {\n tableRef.current = table;\n }\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: we need to memoize the column size vars\n const columnSizeVars = React.useMemo(() => {\n const headers = table.getFlatHeaders();\n const colSizes: { [key: string]: number } = {};\n for (const header of headers) {\n colSizes[`--header-${header.id}-size`] = header.getSize();\n colSizes[`--col-${header.column.id}-size`] = header.column.getSize();\n }\n return colSizes;\n }, [table.getState().columnSizingInfo, table.getState().columnSizing]);\n\n const rowVirtualizer = useVirtualizer({\n count: table.getRowModel().rows.length,\n getScrollElement: () => dataGridRef.current,\n estimateSize: () => rowHeightValue,\n overscan,\n measureElement:\n typeof window !== \"undefined\" &&\n navigator.userAgent.indexOf(\"Firefox\") === -1\n ? (element) => element?.getBoundingClientRect().height\n : undefined,\n onChange: (instance) => {\n // Sync virtualizer's isScrolling state to our store\n const virtualizerIsScrolling = instance.isScrolling;\n const currentIsScrolling = store.getState().isScrolling;\n\n if (virtualizerIsScrolling !== currentIsScrolling) {\n store.setState(\"isScrolling\", virtualizerIsScrolling);\n }\n\n // Batch DOM updates in a single animation frame\n const virtualItems = instance.getVirtualItems();\n if (virtualItems.length === 0) return;\n\n requestAnimationFrame(() => {\n for (let i = 0; i < virtualItems.length; i++) {\n const virtualRow = virtualItems[i];\n if (!virtualRow) continue;\n const rowRef = rowMapRef.current.get(virtualRow.index);\n if (rowRef) {\n rowRef.style.transform = `translateY(${virtualRow.start}px)`;\n }\n }\n });\n },\n });\n\n if (!rowVirtualizerRef.current) {\n rowVirtualizerRef.current = rowVirtualizer;\n }\n\n const scrollToRow = React.useCallback(\n async (opts: Partial) => {\n const rowIndex = opts?.rowIndex ?? 0;\n const columnId = opts?.columnId;\n\n rowVirtualizer.scrollToIndex(rowIndex, {\n align: \"center\",\n });\n\n const targetColumnId = columnId ?? navigableColumnIds[0];\n\n if (!targetColumnId) return;\n\n queueMicrotask(() => {\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n store.batch(() => {\n store.setState(\"focusedCell\", {\n rowIndex,\n columnId: targetColumnId,\n });\n store.setState(\"editingCell\", null);\n });\n });\n });\n });\n },\n [rowVirtualizer, navigableColumnIds, store],\n );\n\n const searchState = React.useMemo(() => {\n if (!enableSearch) return undefined;\n\n return {\n searchMatches,\n matchIndex,\n searchOpen,\n onSearchOpenChange,\n searchQuery,\n onSearchQueryChange,\n onSearch,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n };\n }, [\n enableSearch,\n searchMatches,\n matchIndex,\n searchOpen,\n onSearchOpenChange,\n searchQuery,\n onSearchQueryChange,\n onSearch,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n ]);\n\n React.useEffect(() => {\n const dataGridElement = dataGridRef.current;\n if (!dataGridElement) return;\n\n dataGridElement.addEventListener(\"keydown\", onDataGridKeyDown);\n return () => {\n dataGridElement.removeEventListener(\"keydown\", onDataGridKeyDown);\n };\n }, [onDataGridKeyDown]);\n\n React.useEffect(() => {\n function onGlobalKeyDown(event: KeyboardEvent) {\n const dataGridElement = dataGridRef.current;\n if (!dataGridElement) return;\n\n const target = event.target;\n if (!(target instanceof HTMLElement)) return;\n\n const { key, ctrlKey, metaKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n if (enableSearch && isCtrlPressed && key === SEARCH_SHORTCUT_KEY) {\n const isInInput =\n target.tagName === \"INPUT\" || target.tagName === \"TEXTAREA\";\n const isInDataGrid = dataGridElement.contains(target);\n const isInSearchInput = target.closest('[role=\"search\"]') !== null;\n\n if (isInDataGrid || isInSearchInput || !isInInput) {\n event.preventDefault();\n event.stopPropagation();\n onSearchOpenChange(true);\n\n if (!isInDataGrid && !isInSearchInput) {\n requestAnimationFrame(() => {\n dataGridElement.focus();\n });\n }\n return;\n }\n }\n\n const isInDataGrid = dataGridElement.contains(target);\n if (!isInDataGrid) return;\n\n if (key === \"Escape\") {\n const currentState = store.getState();\n const hasSelections =\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0;\n\n if (hasSelections) {\n event.preventDefault();\n event.stopPropagation();\n clearSelection();\n }\n }\n }\n\n window.addEventListener(\"keydown\", onGlobalKeyDown, true);\n return () => {\n window.removeEventListener(\"keydown\", onGlobalKeyDown, true);\n };\n }, [enableSearch, onSearchOpenChange, store, clearSelection]);\n\n React.useEffect(() => {\n const currentState = store.getState();\n if (\n autoFocus &&\n data.length > 0 &&\n columns.length > 0 &&\n !currentState.focusedCell\n ) {\n if (navigableColumnIds.length > 0) {\n const rafId = requestAnimationFrame(() => {\n if (typeof autoFocus === \"object\") {\n const { rowIndex, columnId } = autoFocus;\n if (columnId) {\n focusCell(rowIndex ?? 0, columnId);\n }\n return;\n }\n\n const firstColumnId = navigableColumnIds[0];\n if (firstColumnId) {\n focusCell(0, firstColumnId);\n }\n });\n return () => cancelAnimationFrame(rafId);\n }\n }\n }, [\n autoFocus,\n data.length,\n columns.length,\n store,\n navigableColumnIds,\n focusCell,\n ]);\n\n React.useEffect(() => {\n function onOutsideClick(event: MouseEvent) {\n if (event.button === 2) {\n return;\n }\n\n if (\n dataGridRef.current &&\n !dataGridRef.current.contains(event.target as Node)\n ) {\n const target = event.target;\n const isInsidePopover =\n target instanceof HTMLElement &&\n (target.closest(\"[data-grid-cell-editor]\") ||\n target.closest(\"[data-grid-popover]\"));\n\n if (!isInsidePopover) {\n blurCell();\n const currentState = store.getState();\n if (\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0\n ) {\n clearSelection();\n }\n }\n }\n }\n\n document.addEventListener(\"mousedown\", onOutsideClick);\n return () => {\n document.removeEventListener(\"mousedown\", onOutsideClick);\n };\n }, [store, blurCell, clearSelection]);\n\n React.useEffect(() => {\n function cleanup() {\n document.removeEventListener(\"selectstart\", preventSelection);\n document.removeEventListener(\"contextmenu\", preventContextMenu);\n document.body.style.userSelect = \"\";\n }\n\n function preventSelection(event: Event) {\n event.preventDefault();\n }\n function preventContextMenu(event: Event) {\n event.preventDefault();\n }\n\n const unsubscribe = store.subscribe(() => {\n const currentState = store.getState();\n if (currentState.selectionState.isSelecting) {\n document.addEventListener(\"selectstart\", preventSelection);\n document.addEventListener(\"contextmenu\", preventContextMenu);\n document.body.style.userSelect = \"none\";\n } else {\n cleanup();\n }\n });\n\n return () => {\n cleanup();\n unsubscribe();\n };\n }, [store]);\n\n useIsomorphicLayoutEffect(() => {\n const rafId = requestAnimationFrame(() => {\n rowVirtualizer.measure();\n });\n return () => cancelAnimationFrame(rafId);\n }, [\n table.getState().columnFilters,\n table.getState().columnOrder,\n table.getState().columnPinning,\n table.getState().columnSizing,\n table.getState().columnVisibility,\n table.getState().expanded,\n table.getState().globalFilter,\n table.getState().grouping,\n table.getState().rowSelection,\n table.getState().sorting,\n rowHeight,\n ]);\n\n return {\n dataGridRef,\n headerRef,\n rowMapRef,\n footerRef,\n table,\n rowVirtualizer,\n searchState,\n columnSizeVars,\n scrollToRow,\n };\n}\n", + "content": "\"use client\";\n\nimport {\n type ColumnDef,\n getCoreRowModel,\n getSortedRowModel,\n type RowSelectionState,\n type SortingState,\n type TableOptions,\n type Updater,\n useReactTable,\n} from \"@tanstack/react-table\";\nimport { useVirtualizer, type Virtualizer } from \"@tanstack/react-virtual\";\nimport * as React from \"react\";\nimport { DataGridCell } from \"@/components/data-grid/data-grid-cell\";\nimport { getCellKey, getRowHeightValue, parseCellKey } from \"@/lib/data-grid\";\nimport type {\n CellPosition,\n ContextMenuState,\n NavigationDirection,\n RowHeightValue,\n SearchState,\n SelectionState,\n UpdateCell,\n} from \"@/types/data-grid\";\n\nconst DEFAULT_ROW_HEIGHT = \"short\";\nconst OVERSCAN = 3;\nconst VIEWPORT_OFFSET = 1;\nconst MIN_COLUMN_SIZE = 60;\nconst MAX_COLUMN_SIZE = 800;\nconst SEARCH_SHORTCUT_KEY = \"f\";\nconst NON_NAVIGABLE_COLUMN_IDS = [\"select\", \"actions\"];\n\nconst useIsomorphicLayoutEffect =\n typeof window !== \"undefined\" ? React.useLayoutEffect : React.useEffect;\n\nfunction useLazyRef(fn: () => T): React.RefObject {\n const ref = React.useRef(null);\n if (ref.current === null) {\n ref.current = fn();\n }\n return ref as React.RefObject;\n}\n\nfunction useAsRef(data: T) {\n const ref = React.useRef(data);\n\n useIsomorphicLayoutEffect(() => {\n ref.current = data;\n });\n\n return ref;\n}\n\ninterface DataGridState {\n sorting: SortingState;\n rowHeight: RowHeightValue;\n rowSelection: RowSelectionState;\n selectionState: SelectionState;\n focusedCell: CellPosition | null;\n editingCell: CellPosition | null;\n contextMenu: ContextMenuState;\n searchQuery: string;\n searchMatches: CellPosition[];\n matchIndex: number;\n searchOpen: boolean;\n lastClickedRowIndex: number | null;\n isScrolling: boolean;\n}\n\ninterface DataGridStore {\n subscribe: (callback: () => void) => () => void;\n getState: () => DataGridState;\n setState: (\n key: K,\n value: DataGridState[K],\n ) => void;\n notify: () => void;\n batch: (fn: () => void) => void;\n}\n\nfunction useStore(\n store: DataGridStore,\n selector: (state: DataGridState) => T,\n): T {\n const getSnapshot = React.useCallback(\n () => selector(store.getState()),\n [store, selector],\n );\n\n return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);\n}\n\ninterface UseDataGridProps\n extends Omit, \"pageCount\" | \"getCoreRowModel\"> {\n onDataChange?: (data: TData[]) => void;\n rowHeight?: RowHeightValue;\n overscan?: number;\n autoFocus?: boolean | Partial;\n enableColumnSelection?: boolean;\n enableSearch?: boolean;\n}\n\nexport function useDataGrid({\n columns,\n data,\n onDataChange,\n rowHeight: rowHeightProp = DEFAULT_ROW_HEIGHT,\n overscan = OVERSCAN,\n initialState,\n autoFocus = false,\n enableColumnSelection = false,\n enableSearch = false,\n ...dataGridProps\n}: UseDataGridProps) {\n const dataGridRef = React.useRef(null);\n const tableRef = React.useRef>>(null);\n const rowVirtualizerRef =\n React.useRef>(null);\n const headerRef = React.useRef(null);\n const rowMapRef = React.useRef>(new Map());\n const footerRef = React.useRef(null);\n\n const dataGridPropsRef = useAsRef(dataGridProps);\n const listenersRef = useLazyRef(() => new Set<() => void>());\n\n const stateRef = useLazyRef(() => {\n return {\n sorting: initialState?.sorting ?? [],\n rowHeight: rowHeightProp,\n rowSelection: initialState?.rowSelection ?? {},\n selectionState: {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n },\n focusedCell: null,\n editingCell: null,\n contextMenu: {\n open: false,\n x: 0,\n y: 0,\n },\n searchQuery: \"\",\n searchMatches: [],\n matchIndex: -1,\n searchOpen: false,\n lastClickedRowIndex: null,\n isScrolling: false,\n };\n });\n\n const store = React.useMemo(() => {\n let isBatching = false;\n let pendingNotification = false;\n\n return {\n subscribe: (callback) => {\n listenersRef.current.add(callback);\n return () => listenersRef.current.delete(callback);\n },\n getState: () => stateRef.current,\n setState: (key, value) => {\n if (Object.is(stateRef.current[key], value)) return;\n stateRef.current[key] = value;\n\n if (isBatching) {\n pendingNotification = true;\n } else {\n if (!pendingNotification) {\n pendingNotification = true;\n queueMicrotask(() => {\n pendingNotification = false;\n store.notify();\n });\n }\n }\n },\n notify: () => {\n for (const listener of listenersRef.current) {\n listener();\n }\n },\n batch: (fn) => {\n if (isBatching) {\n fn();\n return;\n }\n\n isBatching = true;\n const wasPending = pendingNotification;\n pendingNotification = false;\n\n try {\n fn();\n } finally {\n isBatching = false;\n if (pendingNotification || wasPending) {\n pendingNotification = false;\n store.notify();\n }\n }\n },\n };\n }, [listenersRef, stateRef]);\n\n const focusedCell = useStore(store, (state) => state.focusedCell);\n const editingCell = useStore(store, (state) => state.editingCell);\n const selectionState = useStore(store, (state) => state.selectionState);\n const searchQuery = useStore(store, (state) => state.searchQuery);\n const searchMatches = useStore(store, (state) => state.searchMatches);\n const matchIndex = useStore(store, (state) => state.matchIndex);\n const searchOpen = useStore(store, (state) => state.searchOpen);\n const sorting = useStore(store, (state) => state.sorting);\n const rowSelection = useStore(store, (state) => state.rowSelection);\n const contextMenu = useStore(store, (state) => state.contextMenu);\n const rowHeight = useStore(store, (state) => state.rowHeight);\n const isScrolling = useStore(store, (state) => state.isScrolling);\n\n const rowHeightValue = getRowHeightValue(rowHeight);\n\n const columnIds = React.useMemo(() => {\n return columns\n .map((c) => {\n if (c.id) return c.id;\n if (\"accessorKey\" in c) return c.accessorKey as string;\n return undefined;\n })\n .filter((id): id is string => Boolean(id));\n }, [columns]);\n\n const navigableColumnIds = React.useMemo(() => {\n return columnIds.filter((c) => !NON_NAVIGABLE_COLUMN_IDS.includes(c));\n }, [columnIds]);\n\n const onDataUpdate = React.useCallback(\n (updates: UpdateCell | Array) => {\n const updateArray = Array.isArray(updates) ? updates : [updates];\n\n if (updateArray.length === 0) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n\n const rowUpdatesMap = new Map<\n number,\n Array>\n >();\n\n for (const update of updateArray) {\n if (!rows || !currentTable) {\n const existingUpdates = rowUpdatesMap.get(update.rowIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(update.rowIndex, existingUpdates);\n } else {\n const row = rows[update.rowIndex];\n if (!row) continue;\n\n const originalData = row.original;\n const originalRowIndex = data.indexOf(originalData);\n if (originalRowIndex === -1) continue;\n\n const existingUpdates = rowUpdatesMap.get(originalRowIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(originalRowIndex, existingUpdates);\n }\n }\n\n const newData = data.map((row, index) => {\n const updates = rowUpdatesMap.get(index);\n if (!updates) return row;\n\n const updatedRow = { ...row } as Record;\n for (const { columnId, value } of updates) {\n updatedRow[columnId] = value;\n }\n return updatedRow as TData;\n });\n\n onDataChange?.(newData);\n },\n [data, onDataChange],\n );\n\n const getIsCellSelected = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return selectionState.selectedCells.has(getCellKey(rowIndex, columnId));\n },\n [selectionState.selectedCells],\n );\n\n const clearSelection = React.useCallback(() => {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"rowSelection\", {});\n });\n }, [store]);\n\n const selectAll = React.useCallback(() => {\n const allCells = new Set();\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {\n for (const columnId of columnIds) {\n allCells.add(getCellKey(rowIndex, columnId));\n }\n }\n\n const firstColumnId = columnIds[0];\n const lastColumnId = columnIds[columnIds.length - 1];\n\n store.setState(\"selectionState\", {\n selectedCells: allCells,\n selectionRange:\n columnIds.length > 0 && rowCount > 0 && firstColumnId && lastColumnId\n ? {\n start: { rowIndex: 0, columnId: firstColumnId },\n end: { rowIndex: rowCount - 1, columnId: lastColumnId },\n }\n : null,\n isSelecting: false,\n });\n }, [columnIds, data.length, store]);\n\n const selectColumn = React.useCallback(\n (columnId: string) => {\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n if (rowCount === 0) return;\n\n const selectedCells = new Set();\n\n for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: {\n start: { rowIndex: 0, columnId },\n end: { rowIndex: rowCount - 1, columnId },\n },\n isSelecting: false,\n });\n },\n [data.length, store],\n );\n\n const selectRange = React.useCallback(\n (start: CellPosition, end: CellPosition, isSelecting = false) => {\n const startColIndex = columnIds.indexOf(start.columnId);\n const endColIndex = columnIds.indexOf(end.columnId);\n\n const minRow = Math.min(start.rowIndex, end.rowIndex);\n const maxRow = Math.max(start.rowIndex, end.rowIndex);\n const minCol = Math.min(startColIndex, endColIndex);\n const maxCol = Math.max(startColIndex, endColIndex);\n\n const selectedCells = new Set();\n\n for (let rowIndex = minRow; rowIndex <= maxRow; rowIndex++) {\n for (let colIndex = minCol; colIndex <= maxCol; colIndex++) {\n const columnId = columnIds[colIndex];\n if (columnId) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n }\n }\n\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: { start, end },\n isSelecting,\n });\n },\n [columnIds, store],\n );\n\n const focusCell = React.useCallback(\n (rowIndex: number, columnId: string) => {\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", null);\n });\n\n const currentState = store.getState();\n\n if (currentState.searchOpen) return;\n\n if (\n dataGridRef.current &&\n document.activeElement !== dataGridRef.current\n ) {\n dataGridRef.current.focus();\n }\n },\n [store],\n );\n\n const navigateCell = React.useCallback(\n (direction: NavigationDirection) => {\n const currentState = store.getState();\n if (!currentState.focusedCell) return;\n\n const { rowIndex, columnId } = currentState.focusedCell;\n const currentColIndex = navigableColumnIds.indexOf(columnId);\n const rowVirtualizer = rowVirtualizerRef.current;\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n let newRowIndex = rowIndex;\n let newColumnId = columnId;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(rowCount - 1, rowIndex + 1);\n break;\n case \"left\":\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n break;\n case \"right\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n break;\n case \"home\":\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? columnId;\n }\n break;\n case \"end\":\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;\n }\n break;\n case \"ctrl+home\":\n newRowIndex = 0;\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? columnId;\n }\n break;\n case \"ctrl+end\":\n newRowIndex = Math.max(0, rowCount - 1);\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;\n }\n break;\n case \"pageup\":\n if (rowVirtualizer) {\n const visibleRange = rowVirtualizer.getVirtualItems();\n const pageSize = visibleRange.length ?? 10;\n newRowIndex = Math.max(0, rowIndex - pageSize);\n } else {\n newRowIndex = Math.max(0, rowIndex - 10);\n }\n break;\n case \"pagedown\":\n if (rowVirtualizer) {\n const visibleRange = rowVirtualizer.getVirtualItems();\n const pageSize = visibleRange.length ?? 10;\n newRowIndex = Math.min(rowCount - 1, rowIndex + pageSize);\n } else {\n newRowIndex = Math.min(rowCount - 1, rowIndex + 10);\n }\n break;\n }\n\n if (newRowIndex !== rowIndex || newColumnId !== columnId) {\n const rowDiff = newRowIndex - rowIndex;\n\n // For single-row vertical navigation (up/down arrows)\n if (\n Math.abs(rowDiff) === 1 &&\n (direction === \"up\" || direction === \"down\")\n ) {\n const container = dataGridRef.current;\n const currentRow = rowMapRef.current.get(rowIndex);\n const targetRow = rowMapRef.current.get(newRowIndex);\n\n if (!container || !currentRow) {\n // Fallback to simple focus if we can't find elements\n focusCell(newRowIndex, newColumnId);\n return;\n }\n\n // Check viewport boundaries\n const containerRect = container.getBoundingClientRect();\n const headerHeight =\n headerRef.current?.getBoundingClientRect().height ?? 0;\n const footerHeight =\n footerRef.current?.getBoundingClientRect().height ?? 0;\n\n const viewportTop =\n containerRect.top + headerHeight + VIEWPORT_OFFSET;\n const viewportBottom =\n containerRect.bottom - footerHeight - VIEWPORT_OFFSET;\n\n // If target row already exists, check if it's visible\n if (targetRow) {\n const rowRect = targetRow.getBoundingClientRect();\n const isFullyVisible =\n rowRect.top >= viewportTop && rowRect.bottom <= viewportBottom;\n\n if (isFullyVisible) {\n // Row is fully visible, just focus it\n focusCell(newRowIndex, newColumnId);\n return;\n }\n\n // Row exists but not fully visible, scroll it into view\n focusCell(newRowIndex, newColumnId);\n\n if (direction === \"down\") {\n // Scroll just enough to show the row at the bottom\n const scrollNeeded = rowRect.bottom - viewportBottom;\n container.scrollTop += scrollNeeded;\n } else {\n // Scroll just enough to show the row at the top\n const scrollNeeded = viewportTop - rowRect.top;\n container.scrollTop -= scrollNeeded;\n }\n return;\n }\n\n // Target row is not rendered yet\n // Focus immediately so the ring appears as the row is revealed\n focusCell(newRowIndex, newColumnId);\n\n // Scroll by exactly one row height to reveal it smoothly\n if (direction === \"down\") {\n container.scrollTop += rowHeightValue;\n } else {\n // For arrow up, ensure we don't go below 0\n const currentScrollTop = container.scrollTop;\n const targetScrollTop = Math.max(\n 0,\n currentScrollTop - rowHeightValue,\n );\n container.scrollTop = targetScrollTop;\n }\n return;\n }\n\n // For larger jumps (page up/down, ctrl+home/end, etc.)\n if (rowVirtualizer && Math.abs(rowDiff) > 1) {\n const align =\n direction === \"pageup\" || direction === \"ctrl+home\"\n ? \"start\"\n : direction === \"pagedown\" || direction === \"ctrl+end\"\n ? \"end\"\n : \"center\";\n rowVirtualizer.scrollToIndex(newRowIndex, { align });\n requestAnimationFrame(() => {\n focusCell(newRowIndex, newColumnId);\n });\n return;\n }\n\n // For horizontal navigation or when row is already visible\n focusCell(newRowIndex, newColumnId);\n }\n },\n [store, navigableColumnIds, focusCell, data.length, rowHeightValue],\n );\n\n const onCellEditingStart = React.useCallback(\n (rowIndex: number, columnId: string) => {\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", { rowIndex, columnId });\n });\n },\n [store],\n );\n\n const onCellEditingStop = React.useCallback(\n (opts?: { moveToNextRow?: boolean; direction?: NavigationDirection }) => {\n const currentState = store.getState();\n const currentEditing = currentState.editingCell;\n\n store.setState(\"editingCell\", null);\n\n if (opts?.moveToNextRow && currentEditing) {\n const { rowIndex, columnId } = currentEditing;\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n const nextRowIndex = rowIndex + 1;\n if (nextRowIndex < rowCount) {\n requestAnimationFrame(() => {\n focusCell(nextRowIndex, columnId);\n });\n }\n } else if (opts?.direction && currentEditing) {\n // Focus the current editing cell first, then navigate\n const { rowIndex, columnId } = currentEditing;\n focusCell(rowIndex, columnId);\n requestAnimationFrame(() => {\n navigateCell(opts.direction ?? \"right\");\n });\n }\n },\n [store, data.length, focusCell, navigateCell],\n );\n\n const onSearchOpenChange = React.useCallback(\n (open: boolean) => {\n if (open) {\n store.setState(\"searchOpen\", true);\n return;\n }\n\n const currentState = store.getState();\n const currentMatch =\n currentState.matchIndex >= 0 &&\n currentState.searchMatches[currentState.matchIndex];\n\n store.batch(() => {\n store.setState(\"searchOpen\", false);\n store.setState(\"searchQuery\", \"\");\n store.setState(\"searchMatches\", []);\n store.setState(\"matchIndex\", -1);\n\n if (currentMatch) {\n store.setState(\"focusedCell\", {\n rowIndex: currentMatch.rowIndex,\n columnId: currentMatch.columnId,\n });\n }\n });\n\n if (\n dataGridRef.current &&\n document.activeElement !== dataGridRef.current\n ) {\n dataGridRef.current.focus();\n }\n },\n [store],\n );\n\n const onSearch = React.useCallback(\n (query: string) => {\n if (!query.trim()) {\n store.batch(() => {\n store.setState(\"searchMatches\", []);\n store.setState(\"matchIndex\", -1);\n });\n return;\n }\n\n const matches: CellPosition[] = [];\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n\n const lowerQuery = query.toLowerCase();\n\n for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {\n const row = rows[rowIndex];\n if (!row) continue;\n\n for (const columnId of columnIds) {\n const cell = row\n .getVisibleCells()\n .find((c) => c.column.id === columnId);\n if (!cell) continue;\n\n const value = cell.getValue();\n const stringValue = String(value ?? \"\").toLowerCase();\n\n if (stringValue.includes(lowerQuery)) {\n matches.push({ rowIndex, columnId });\n }\n }\n }\n\n store.batch(() => {\n store.setState(\"searchMatches\", matches);\n store.setState(\"matchIndex\", matches.length > 0 ? 0 : -1);\n });\n\n // Scroll to first match but don't focus it (to keep focus in search input)\n if (matches.length > 0 && matches[0]) {\n const firstMatch = matches[0];\n rowVirtualizerRef.current?.scrollToIndex(firstMatch.rowIndex, {\n align: \"center\",\n });\n }\n },\n [columnIds, store],\n );\n\n const onSearchQueryChange = React.useCallback(\n (query: string) => store.setState(\"searchQuery\", query),\n [store],\n );\n\n const onNavigateToPrevMatch = React.useCallback(() => {\n const currentState = store.getState();\n if (currentState.searchMatches.length === 0) return;\n\n const prevIndex =\n currentState.matchIndex - 1 < 0\n ? currentState.searchMatches.length - 1\n : currentState.matchIndex - 1;\n const match = currentState.searchMatches[prevIndex];\n\n if (match) {\n rowVirtualizerRef.current?.scrollToIndex(match.rowIndex, {\n align: \"center\",\n });\n\n requestAnimationFrame(() => {\n store.setState(\"matchIndex\", prevIndex);\n requestAnimationFrame(() => {\n focusCell(match.rowIndex, match.columnId);\n });\n });\n }\n }, [store, focusCell]);\n\n const onNavigateToNextMatch = React.useCallback(() => {\n const currentState = store.getState();\n if (currentState.searchMatches.length === 0) return;\n\n const nextIndex =\n (currentState.matchIndex + 1) % currentState.searchMatches.length;\n const match = currentState.searchMatches[nextIndex];\n\n if (match) {\n rowVirtualizerRef.current?.scrollToIndex(match.rowIndex, {\n align: \"center\",\n });\n\n requestAnimationFrame(() => {\n store.setState(\"matchIndex\", nextIndex);\n requestAnimationFrame(() => {\n focusCell(match.rowIndex, match.columnId);\n });\n });\n }\n }, [store, focusCell]);\n\n const getIsSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return searchMatches.some(\n (match) => match.rowIndex === rowIndex && match.columnId === columnId,\n );\n },\n [searchMatches],\n );\n\n const getIsActiveSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n if (matchIndex < 0) return false;\n const currentMatch = searchMatches[matchIndex];\n return (\n currentMatch?.rowIndex === rowIndex &&\n currentMatch?.columnId === columnId\n );\n },\n [searchMatches, matchIndex],\n );\n\n const blurCell = React.useCallback(() => {\n const currentState = store.getState();\n if (\n currentState.editingCell &&\n document.activeElement instanceof HTMLElement\n ) {\n document.activeElement.blur();\n }\n\n store.batch(() => {\n store.setState(\"focusedCell\", null);\n store.setState(\"editingCell\", null);\n });\n }, [store]);\n\n const onCellClick = React.useCallback(\n (rowIndex: number, columnId: string, event?: React.MouseEvent) => {\n // Ignore right-click (button 2) - let onCellContextMenu handle it\n if (event?.button === 2) {\n return;\n }\n\n const currentState = store.getState();\n const currentFocused = currentState.focusedCell;\n\n if (event) {\n if (event.ctrlKey || event.metaKey) {\n event.preventDefault();\n const cellKey = getCellKey(rowIndex, columnId);\n const newSelectedCells = new Set(\n currentState.selectionState.selectedCells,\n );\n\n if (newSelectedCells.has(cellKey)) {\n newSelectedCells.delete(cellKey);\n } else {\n newSelectedCells.add(cellKey);\n }\n\n store.setState(\"selectionState\", {\n selectedCells: newSelectedCells,\n selectionRange: null,\n isSelecting: false,\n });\n focusCell(rowIndex, columnId);\n return;\n }\n\n if (event.shiftKey && currentState.focusedCell) {\n event.preventDefault();\n selectRange(currentState.focusedCell, { rowIndex, columnId });\n return;\n }\n }\n\n // Clear selection if there are selected cells or rows\n const hasSelectedCells =\n currentState.selectionState.selectedCells.size > 0;\n const hasSelectedRows = Object.keys(currentState.rowSelection).length > 0;\n\n if (hasSelectedCells && !currentState.selectionState.isSelecting) {\n // If there's a cell selection but we're not actively selecting (drag just finished),\n // don't clear it - keep the selection\n // Only clear if clicking elsewhere\n const cellKey = getCellKey(rowIndex, columnId);\n const isClickingSelectedCell =\n currentState.selectionState.selectedCells.has(cellKey);\n\n if (!isClickingSelectedCell) {\n clearSelection();\n } else {\n // Clicking on an already selected cell - just focus it\n focusCell(rowIndex, columnId);\n return;\n }\n } else if (hasSelectedRows && columnId !== \"select\") {\n // If there are selected rows but we're clicking on a non-checkbox cell, clear selections\n clearSelection();\n }\n\n if (\n currentFocused?.rowIndex === rowIndex &&\n currentFocused?.columnId === columnId\n ) {\n onCellEditingStart(rowIndex, columnId);\n } else {\n focusCell(rowIndex, columnId);\n }\n },\n [store, focusCell, onCellEditingStart, selectRange, clearSelection],\n );\n\n const onCellDoubleClick = React.useCallback(\n (rowIndex: number, columnId: string, event?: React.MouseEvent) => {\n if (event?.defaultPrevented) return;\n\n onCellEditingStart(rowIndex, columnId);\n },\n [onCellEditingStart],\n );\n\n const onCellMouseDown = React.useCallback(\n (rowIndex: number, columnId: string, event: React.MouseEvent) => {\n // Ignore right-click (button 2) - let onCellContextMenu handle it\n if (event.button === 2) {\n return;\n }\n\n event.preventDefault();\n\n // Only start drag selection if no modifier keys are pressed\n // Clear any existing selection and prepare for potential drag\n if (!event.ctrlKey && !event.metaKey && !event.shiftKey) {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: {\n start: { rowIndex, columnId },\n end: { rowIndex, columnId },\n },\n isSelecting: true,\n });\n store.setState(\"rowSelection\", {});\n });\n }\n },\n [store],\n );\n\n const onCellMouseEnter = React.useCallback(\n (rowIndex: number, columnId: string, _event: React.MouseEvent) => {\n const currentState = store.getState();\n if (\n currentState.selectionState.isSelecting &&\n currentState.selectionState.selectionRange\n ) {\n const start = currentState.selectionState.selectionRange.start;\n const end = { rowIndex, columnId };\n\n if (\n currentState.focusedCell?.rowIndex !== start.rowIndex ||\n currentState.focusedCell?.columnId !== start.columnId\n ) {\n focusCell(start.rowIndex, start.columnId);\n }\n\n selectRange(start, end, true);\n }\n },\n [store, selectRange, focusCell],\n );\n\n const onCellMouseUp = React.useCallback(() => {\n const currentState = store.getState();\n store.setState(\"selectionState\", {\n ...currentState.selectionState,\n isSelecting: false,\n });\n }, [store]);\n\n const onCellContextMenu = React.useCallback(\n (rowIndex: number, columnId: string, event: React.MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n\n const currentState = store.getState();\n const cellKey = getCellKey(rowIndex, columnId);\n const isTargetCellSelected =\n currentState.selectionState.selectedCells.has(cellKey);\n\n // If right-clicking on a non-selected cell, select only that cell\n if (!isTargetCellSelected) {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set([cellKey]),\n selectionRange: {\n start: { rowIndex, columnId },\n end: { rowIndex, columnId },\n },\n isSelecting: false,\n });\n store.setState(\"focusedCell\", { rowIndex, columnId });\n });\n }\n\n // Open context menu at cursor position\n store.setState(\"contextMenu\", {\n open: true,\n x: event.clientX,\n y: event.clientY,\n });\n },\n [store],\n );\n\n const onContextMenuOpenChange = React.useCallback(\n (open: boolean) => {\n if (!open) {\n const currentMenu = store.getState().contextMenu;\n store.setState(\"contextMenu\", {\n open: false,\n x: currentMenu.x,\n y: currentMenu.y,\n });\n }\n },\n [store],\n );\n\n const onDataGridKeyDown = React.useCallback(\n (event: KeyboardEvent) => {\n const currentState = store.getState();\n const { key, ctrlKey, metaKey, shiftKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n // Handle Cmd+F / Ctrl+F to open search (highest priority, works even when editing)\n if (enableSearch && isCtrlPressed && key === SEARCH_SHORTCUT_KEY) {\n event.preventDefault();\n onSearchOpenChange(true);\n return;\n }\n\n // Handle search navigation when search is open\n if (\n enableSearch &&\n currentState.searchOpen &&\n !currentState.editingCell\n ) {\n if (key === \"Enter\") {\n event.preventDefault();\n if (shiftKey) {\n onNavigateToPrevMatch();\n } else {\n onNavigateToNextMatch();\n }\n return;\n }\n if (key === \"Escape\") {\n event.preventDefault();\n onSearchOpenChange(false);\n return;\n }\n // When search is open, don't let data grid handle any other keys\n // (they should only affect the search input)\n return;\n }\n\n if (currentState.editingCell) return;\n\n if (!currentState.focusedCell) return;\n\n let direction: NavigationDirection | null = null;\n\n if (isCtrlPressed && key === \"a\") {\n event.preventDefault();\n selectAll();\n return;\n }\n\n if (key === \"Delete\" || key === \"Backspace\") {\n if (currentState.selectionState.selectedCells.size > 0) {\n event.preventDefault();\n const updates: Array<{\n rowIndex: number;\n columnId: string;\n value: unknown;\n }> = [];\n\n currentState.selectionState.selectedCells.forEach((cellKey) => {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n updates.push({ rowIndex, columnId, value: \"\" });\n });\n\n onDataUpdate(updates);\n clearSelection();\n }\n return;\n }\n\n switch (key) {\n case \"ArrowUp\":\n direction = \"up\";\n break;\n case \"ArrowDown\":\n direction = \"down\";\n break;\n case \"ArrowLeft\":\n direction = \"left\";\n break;\n case \"ArrowRight\":\n direction = \"right\";\n break;\n case \"Home\":\n direction = isCtrlPressed ? \"ctrl+home\" : \"home\";\n break;\n case \"End\":\n direction = isCtrlPressed ? \"ctrl+end\" : \"end\";\n break;\n case \"PageUp\":\n direction = \"pageup\";\n break;\n case \"PageDown\":\n direction = \"pagedown\";\n break;\n case \"Escape\":\n event.preventDefault();\n if (\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0\n ) {\n clearSelection();\n } else {\n blurCell();\n }\n return;\n case \"Tab\":\n event.preventDefault();\n direction = event.shiftKey ? \"left\" : \"right\";\n break;\n }\n\n if (direction) {\n event.preventDefault();\n\n // Tab navigation should not trigger selection, even with Shift\n if (shiftKey && key !== \"Tab\" && currentState.focusedCell) {\n const currentColIndex = navigableColumnIds.indexOf(\n currentState.focusedCell.columnId,\n );\n let newRowIndex = currentState.focusedCell.rowIndex;\n let newColumnId = currentState.focusedCell.columnId;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, currentState.focusedCell.rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(\n (tableRef.current?.getRowModel().rows.length || data.length) -\n 1,\n currentState.focusedCell.rowIndex + 1,\n );\n break;\n case \"left\":\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n break;\n case \"right\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n break;\n }\n\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n selectRange(selectionStart, {\n rowIndex: newRowIndex,\n columnId: newColumnId,\n });\n focusCell(newRowIndex, newColumnId);\n } else {\n if (currentState.selectionState.selectedCells.size > 0) {\n clearSelection();\n }\n navigateCell(direction);\n }\n }\n },\n [\n store,\n blurCell,\n navigateCell,\n selectAll,\n onDataUpdate,\n clearSelection,\n navigableColumnIds,\n data.length,\n selectRange,\n focusCell,\n onSearchOpenChange,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n enableSearch,\n ],\n );\n\n const onSortingChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newSorting =\n typeof updater === \"function\" ? updater(currentState.sorting) : updater;\n store.setState(\"sorting\", newSorting);\n },\n [store],\n );\n\n const onRowSelectionChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newRowSelection =\n typeof updater === \"function\"\n ? updater(currentState.rowSelection)\n : updater;\n\n const selectedRows = Object.keys(newRowSelection).filter(\n (key) => newRowSelection[key],\n );\n\n const selectedCells = new Set();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n\n for (const rowId of selectedRows) {\n const rowIndex = rows.findIndex((r) => r.id === rowId);\n if (rowIndex === -1) continue;\n\n for (const columnId of columnIds) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n }\n\n store.batch(() => {\n store.setState(\"rowSelection\", newRowSelection);\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"focusedCell\", null);\n store.setState(\"editingCell\", null);\n });\n },\n [store, columnIds],\n );\n\n const onRowSelect = React.useCallback(\n (rowIndex: number, selected: boolean, shiftKey: boolean) => {\n const currentState = store.getState();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n const currentRow = rows[rowIndex];\n if (!currentRow) return;\n\n if (shiftKey && currentState.lastClickedRowIndex !== null) {\n const startIndex = Math.min(currentState.lastClickedRowIndex, rowIndex);\n const endIndex = Math.max(currentState.lastClickedRowIndex, rowIndex);\n\n const newRowSelection: RowSelectionState = {\n ...currentState.rowSelection,\n };\n\n for (let i = startIndex; i <= endIndex; i++) {\n const row = rows[i];\n if (row) {\n newRowSelection[row.id] = selected;\n }\n }\n\n onRowSelectionChange(newRowSelection);\n } else {\n onRowSelectionChange({\n ...currentState.rowSelection,\n [currentRow.id]: selected,\n });\n }\n\n store.setState(\"lastClickedRowIndex\", rowIndex);\n },\n [store, onRowSelectionChange],\n );\n\n const onRowHeightChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newRowHeight =\n typeof updater === \"function\"\n ? updater(currentState.rowHeight)\n : updater;\n store.setState(\"rowHeight\", newRowHeight);\n },\n [store],\n );\n\n const onColumnClick = React.useCallback(\n (columnId: string) => {\n if (!enableColumnSelection) {\n clearSelection();\n return;\n }\n\n selectColumn(columnId);\n },\n [enableColumnSelection, selectColumn, clearSelection],\n );\n\n const defaultColumn: Partial> = React.useMemo(\n () => ({\n cell: DataGridCell,\n minSize: MIN_COLUMN_SIZE,\n maxSize: MAX_COLUMN_SIZE,\n }),\n [],\n );\n\n const tableOptions = React.useMemo>(\n () => ({\n ...dataGridPropsRef.current,\n data,\n columns,\n defaultColumn,\n initialState,\n state: {\n ...dataGridPropsRef.current.state,\n sorting,\n rowSelection,\n },\n onRowSelectionChange,\n onSortingChange,\n columnResizeMode: \"onChange\",\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n meta: {\n ...dataGridPropsRef.current.meta,\n dataGridRef,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n isScrolling,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n onDataUpdate,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n contextMenu,\n onContextMenuOpenChange,\n rowHeight,\n onRowHeightChange,\n onRowSelect,\n },\n }),\n [\n dataGridPropsRef,\n data,\n columns,\n defaultColumn,\n initialState,\n sorting,\n rowSelection,\n onRowSelectionChange,\n onSortingChange,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n isScrolling,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n onDataUpdate,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n contextMenu,\n onContextMenuOpenChange,\n rowHeight,\n onRowHeightChange,\n onRowSelect,\n ],\n );\n\n const table = useReactTable(tableOptions);\n\n if (!tableRef.current) {\n tableRef.current = table;\n }\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: we need to memoize the column size vars\n const columnSizeVars = React.useMemo(() => {\n const headers = table.getFlatHeaders();\n const colSizes: { [key: string]: number } = {};\n for (const header of headers) {\n colSizes[`--header-${header.id}-size`] = header.getSize();\n colSizes[`--col-${header.column.id}-size`] = header.column.getSize();\n }\n return colSizes;\n }, [table.getState().columnSizingInfo, table.getState().columnSizing]);\n\n const rowVirtualizer = useVirtualizer({\n count: table.getRowModel().rows.length,\n getScrollElement: () => dataGridRef.current,\n estimateSize: () => rowHeightValue,\n overscan,\n measureElement:\n typeof window !== \"undefined\" &&\n navigator.userAgent.indexOf(\"Firefox\") === -1\n ? (element) => element?.getBoundingClientRect().height\n : undefined,\n onChange: (instance) => {\n // Sync virtualizer's isScrolling state to our store\n const virtualizerIsScrolling = instance.isScrolling;\n const currentIsScrolling = store.getState().isScrolling;\n\n if (virtualizerIsScrolling !== currentIsScrolling) {\n store.setState(\"isScrolling\", virtualizerIsScrolling);\n }\n\n // Batch DOM updates in a single animation frame\n const virtualItems = instance.getVirtualItems();\n if (virtualItems.length === 0) return;\n\n requestAnimationFrame(() => {\n for (let i = 0; i < virtualItems.length; i++) {\n const virtualRow = virtualItems[i];\n if (!virtualRow) continue;\n const rowRef = rowMapRef.current.get(virtualRow.index);\n if (rowRef) {\n rowRef.style.transform = `translateY(${virtualRow.start}px)`;\n }\n }\n });\n },\n });\n\n if (!rowVirtualizerRef.current) {\n rowVirtualizerRef.current = rowVirtualizer;\n }\n\n const onScrollToRow = React.useCallback(\n async (opts: Partial) => {\n const rowIndex = opts?.rowIndex ?? 0;\n const columnId = opts?.columnId;\n\n rowVirtualizer.scrollToIndex(rowIndex, {\n align: \"center\",\n });\n\n const targetColumnId = columnId ?? navigableColumnIds[0];\n\n if (!targetColumnId) return;\n\n queueMicrotask(() => {\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n store.batch(() => {\n store.setState(\"focusedCell\", {\n rowIndex,\n columnId: targetColumnId,\n });\n store.setState(\"editingCell\", null);\n });\n });\n });\n });\n },\n [rowVirtualizer, navigableColumnIds, store],\n );\n\n const searchState = React.useMemo(() => {\n if (!enableSearch) return undefined;\n\n return {\n searchMatches,\n matchIndex,\n searchOpen,\n onSearchOpenChange,\n searchQuery,\n onSearchQueryChange,\n onSearch,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n };\n }, [\n enableSearch,\n searchMatches,\n matchIndex,\n searchOpen,\n onSearchOpenChange,\n searchQuery,\n onSearchQueryChange,\n onSearch,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n ]);\n\n React.useEffect(() => {\n const dataGridElement = dataGridRef.current;\n if (!dataGridElement) return;\n\n dataGridElement.addEventListener(\"keydown\", onDataGridKeyDown);\n return () => {\n dataGridElement.removeEventListener(\"keydown\", onDataGridKeyDown);\n };\n }, [onDataGridKeyDown]);\n\n React.useEffect(() => {\n function onGlobalKeyDown(event: KeyboardEvent) {\n const dataGridElement = dataGridRef.current;\n if (!dataGridElement) return;\n\n const target = event.target;\n if (!(target instanceof HTMLElement)) return;\n\n const { key, ctrlKey, metaKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n if (enableSearch && isCtrlPressed && key === SEARCH_SHORTCUT_KEY) {\n const isInInput =\n target.tagName === \"INPUT\" || target.tagName === \"TEXTAREA\";\n const isInDataGrid = dataGridElement.contains(target);\n const isInSearchInput = target.closest('[role=\"search\"]') !== null;\n\n if (isInDataGrid || isInSearchInput || !isInInput) {\n event.preventDefault();\n event.stopPropagation();\n onSearchOpenChange(true);\n\n if (!isInDataGrid && !isInSearchInput) {\n requestAnimationFrame(() => {\n dataGridElement.focus();\n });\n }\n return;\n }\n }\n\n const isInDataGrid = dataGridElement.contains(target);\n if (!isInDataGrid) return;\n\n if (key === \"Escape\") {\n const currentState = store.getState();\n const hasSelections =\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0;\n\n if (hasSelections) {\n event.preventDefault();\n event.stopPropagation();\n clearSelection();\n }\n }\n }\n\n window.addEventListener(\"keydown\", onGlobalKeyDown, true);\n return () => {\n window.removeEventListener(\"keydown\", onGlobalKeyDown, true);\n };\n }, [enableSearch, onSearchOpenChange, store, clearSelection]);\n\n React.useEffect(() => {\n const currentState = store.getState();\n if (\n autoFocus &&\n data.length > 0 &&\n columns.length > 0 &&\n !currentState.focusedCell\n ) {\n if (navigableColumnIds.length > 0) {\n const rafId = requestAnimationFrame(() => {\n if (typeof autoFocus === \"object\") {\n const { rowIndex, columnId } = autoFocus;\n if (columnId) {\n focusCell(rowIndex ?? 0, columnId);\n }\n return;\n }\n\n const firstColumnId = navigableColumnIds[0];\n if (firstColumnId) {\n focusCell(0, firstColumnId);\n }\n });\n return () => cancelAnimationFrame(rafId);\n }\n }\n }, [\n autoFocus,\n data.length,\n columns.length,\n store,\n navigableColumnIds,\n focusCell,\n ]);\n\n React.useEffect(() => {\n function onOutsideClick(event: MouseEvent) {\n if (event.button === 2) {\n return;\n }\n\n if (\n dataGridRef.current &&\n !dataGridRef.current.contains(event.target as Node)\n ) {\n const target = event.target;\n const isInsidePopover =\n target instanceof HTMLElement &&\n (target.closest(\"[data-grid-cell-editor]\") ||\n target.closest(\"[data-grid-popover]\"));\n\n if (!isInsidePopover) {\n blurCell();\n const currentState = store.getState();\n if (\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0\n ) {\n clearSelection();\n }\n }\n }\n }\n\n document.addEventListener(\"mousedown\", onOutsideClick);\n return () => {\n document.removeEventListener(\"mousedown\", onOutsideClick);\n };\n }, [store, blurCell, clearSelection]);\n\n React.useEffect(() => {\n function onCleanup() {\n document.removeEventListener(\"selectstart\", preventSelection);\n document.removeEventListener(\"contextmenu\", preventContextMenu);\n document.body.style.userSelect = \"\";\n }\n\n function preventSelection(event: Event) {\n event.preventDefault();\n }\n function preventContextMenu(event: Event) {\n event.preventDefault();\n }\n\n const onUnsubscribe = store.subscribe(() => {\n const currentState = store.getState();\n if (currentState.selectionState.isSelecting) {\n document.addEventListener(\"selectstart\", preventSelection);\n document.addEventListener(\"contextmenu\", preventContextMenu);\n document.body.style.userSelect = \"none\";\n } else {\n onCleanup();\n }\n });\n\n return () => {\n onCleanup();\n onUnsubscribe();\n };\n }, [store]);\n\n useIsomorphicLayoutEffect(() => {\n const rafId = requestAnimationFrame(() => {\n rowVirtualizer.measure();\n });\n return () => cancelAnimationFrame(rafId);\n }, [\n table.getState().columnFilters,\n table.getState().columnOrder,\n table.getState().columnPinning,\n table.getState().columnSizing,\n table.getState().columnVisibility,\n table.getState().expanded,\n table.getState().globalFilter,\n table.getState().grouping,\n table.getState().rowSelection,\n table.getState().sorting,\n rowHeight,\n ]);\n\n return {\n dataGridRef,\n headerRef,\n rowMapRef,\n footerRef,\n table,\n rowVirtualizer,\n searchState,\n columnSizeVars,\n onScrollToRow,\n };\n}\n", "type": "registry:hook" }, { diff --git a/src/components/data-grid/data-grid.tsx b/src/components/data-grid/data-grid.tsx index 9b3857c8..24ec26e5 100644 --- a/src/components/data-grid/data-grid.tsx +++ b/src/components/data-grid/data-grid.tsx @@ -35,7 +35,7 @@ export function DataGrid({ height = 600, searchState, columnSizeVars, - scrollToRow, + onScrollToRow, onRowAdd: onRowAddProp, className, ...props @@ -66,16 +66,16 @@ export function DataGrid({ const adjustedRowIndex = (result.rowIndex ?? 0) >= rows.length ? rows.length : result.rowIndex; - scrollToRow({ + onScrollToRow({ rowIndex: adjustedRowIndex, columnId: result.columnId, }); return; } - scrollToRow({ rowIndex: rows.length }); + onScrollToRow({ rowIndex: rows.length }); }, - [onRowAddProp, scrollToRow, rows.length], + [onRowAddProp, onScrollToRow, rows.length], ); const onAddRowKeyDown = React.useCallback( diff --git a/src/hooks/use-data-grid.tsx b/src/hooks/use-data-grid.tsx index d06971b7..d31cad27 100644 --- a/src/hooks/use-data-grid.tsx +++ b/src/hooks/use-data-grid.tsx @@ -1433,7 +1433,7 @@ export function useDataGrid({ rowVirtualizerRef.current = rowVirtualizer; } - const scrollToRow = React.useCallback( + const onScrollToRow = React.useCallback( async (opts: Partial) => { const rowIndex = opts?.rowIndex ?? 0; const columnId = opts?.columnId; @@ -1625,7 +1625,7 @@ export function useDataGrid({ }, [store, blurCell, clearSelection]); React.useEffect(() => { - function cleanup() { + function onCleanup() { document.removeEventListener("selectstart", preventSelection); document.removeEventListener("contextmenu", preventContextMenu); document.body.style.userSelect = ""; @@ -1638,20 +1638,20 @@ export function useDataGrid({ event.preventDefault(); } - const unsubscribe = store.subscribe(() => { + const onUnsubscribe = store.subscribe(() => { const currentState = store.getState(); if (currentState.selectionState.isSelecting) { document.addEventListener("selectstart", preventSelection); document.addEventListener("contextmenu", preventContextMenu); document.body.style.userSelect = "none"; } else { - cleanup(); + onCleanup(); } }); return () => { - cleanup(); - unsubscribe(); + onCleanup(); + onUnsubscribe(); }; }, [store]); @@ -1683,6 +1683,6 @@ export function useDataGrid({ rowVirtualizer, searchState, columnSizeVars, - scrollToRow, + onScrollToRow, }; } From 48026ad75ad2d7bf49fd8b19ceed8b4b37981b76 Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Sun, 26 Oct 2025 21:41:47 +0600 Subject: [PATCH 3/8] feat: update page --- src/app/data-grid/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/data-grid/page.tsx b/src/app/data-grid/page.tsx index 496d1be7..9d349b2d 100644 --- a/src/app/data-grid/page.tsx +++ b/src/app/data-grid/page.tsx @@ -6,7 +6,7 @@ export default async function DataGridPage() { return ( +
From ea0a33e8c8921ed7aff98e77e0d36a184c3983f6 Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Sun, 26 Oct 2025 21:43:34 +0600 Subject: [PATCH 4/8] feat: update page again --- src/app/data-grid/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/data-grid/page.tsx b/src/app/data-grid/page.tsx index 9d349b2d..453f6ca4 100644 --- a/src/app/data-grid/page.tsx +++ b/src/app/data-grid/page.tsx @@ -6,7 +6,7 @@ export default async function DataGridPage() { return ( +
From c35422c69f155c1024fe95396982be9fcc013b0c Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Sun, 26 Oct 2025 23:10:52 +0600 Subject: [PATCH 5/8] chore: update config --- next.config.ts | 2 +- tsconfig.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/next.config.ts b/next.config.ts index 36fb6497..b2aa8598 100644 --- a/next.config.ts +++ b/next.config.ts @@ -8,9 +8,9 @@ import type { NextConfig } from "next"; import "./src/env.js"; const nextConfig: NextConfig = { + cacheComponents: true, // Already doing typechecking as separate task in CI typescript: { ignoreBuildErrors: true }, - cacheComponents: true, }; export default nextConfig; diff --git a/tsconfig.json b/tsconfig.json index 7b409bea..5463aeb7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -38,7 +38,8 @@ "**/*.js", ".next/types/**/*.ts", ".next\\dev/types/**/*.ts", - ".next\\dev/types/**/*.ts" + ".next\\dev/types/**/*.ts", + ".next/dev/types/**/*.ts" ], "exclude": ["node_modules"] } From f4a2080b1b6c0d0055081232874e1803ed5a77d1 Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Mon, 27 Oct 2025 17:53:02 +0600 Subject: [PATCH 6/8] feat: add onrowsdelete --- .../data-grid/data-grid-context-menu.tsx | 41 ++++++++++-- src/components/data-grid/data-grid-demo.tsx | 15 +++++ src/hooks/use-data-grid.tsx | 66 ++++++++++++++++++- src/types/data-grid.ts | 1 + 4 files changed, 115 insertions(+), 8 deletions(-) diff --git a/src/components/data-grid/data-grid-context-menu.tsx b/src/components/data-grid/data-grid-context-menu.tsx index 5d51d785..9890baad 100644 --- a/src/components/data-grid/data-grid-context-menu.tsx +++ b/src/components/data-grid/data-grid-context-menu.tsx @@ -1,13 +1,14 @@ "use client"; import type { Table, TableMeta } from "@tanstack/react-table"; -import { CopyIcon, EraserIcon } from "lucide-react"; +import { CopyIcon, EraserIcon, Trash2Icon } from "lucide-react"; import * as React from "react"; import { toast } from "sonner"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { parseCellKey } from "@/lib/data-grid"; @@ -26,6 +27,7 @@ export function DataGridContextMenu({ const selectionState = meta?.selectionState; const dataGridRef = meta?.dataGridRef; const onDataUpdate = meta?.onDataUpdate; + const onRowsDelete = meta?.onRowsDelete; if (!contextMenu) return null; @@ -37,6 +39,7 @@ export function DataGridContextMenu({ onContextMenuOpenChange={onContextMenuOpenChange} selectionState={selectionState} onDataUpdate={onDataUpdate} + onRowsDelete={onRowsDelete} /> ); } @@ -48,6 +51,7 @@ interface ContextMenuProps | "onContextMenuOpenChange" | "selectionState" | "onDataUpdate" + | "onRowsDelete" >, Required, "contextMenu">> { table: Table; @@ -73,6 +77,7 @@ function ContextMenuImpl({ onContextMenuOpenChange, selectionState, onDataUpdate, + onRowsDelete, }: ContextMenuProps) { const triggerStyle = React.useMemo( () => ({ @@ -111,7 +116,6 @@ function ContextMenuImpl({ const rows = table.getRowModel().rows; const columnIds: string[] = []; - // Collect all unique column IDs from selected cells const selectedCellsArray = Array.from(selectionState.selectedCells); for (const cellKey of selectedCellsArray) { const { columnId } = parseCellKey(cellKey); @@ -120,7 +124,6 @@ function ContextMenuImpl({ } } - // Build grid of selected cells const cellData = new Map(); for (const cellKey of selectedCellsArray) { const { rowIndex, columnId } = parseCellKey(cellKey); @@ -136,7 +139,6 @@ function ContextMenuImpl({ } } - // Get row indices and column indices const rowIndices = new Set(); const colIndices = new Set(); @@ -153,7 +155,6 @@ function ContextMenuImpl({ const sortedColIndices = Array.from(colIndices).sort((a, b) => a - b); const sortedColumnIds = sortedColIndices.map((i) => columnIds[i]); - // Build TSV (tab-separated values) const tsvData = sortedRowIndices .map((rowIndex) => sortedColumnIds @@ -192,6 +193,27 @@ function ContextMenuImpl({ ); }, [onDataUpdate, selectionState]); + const onDelete = React.useCallback(async () => { + if ( + !selectionState?.selectedCells || + selectionState.selectedCells.size === 0 + ) + return; + + const rowIndices = new Set(); + for (const cellKey of selectionState.selectedCells) { + const { rowIndex } = parseCellKey(cellKey); + rowIndices.add(rowIndex); + } + + const rowIndicesArray = Array.from(rowIndices).sort((a, b) => a - b); + const rowCount = rowIndicesArray.length; + + await onRowsDelete?.(rowIndicesArray); + + toast.success(`${rowCount} row${rowCount !== 1 ? "s" : ""} deleted`); + }, [onRowsDelete, selectionState]); + return ( ({ Clear + {onRowsDelete && ( + <> + + + + Delete rows + + + )} ); diff --git a/src/components/data-grid/data-grid-demo.tsx b/src/components/data-grid/data-grid-demo.tsx index 2d0b16d1..4e2f6d8b 100644 --- a/src/components/data-grid/data-grid-demo.tsx +++ b/src/components/data-grid/data-grid-demo.tsx @@ -299,10 +299,25 @@ export function DataGridDemo() { }; }, [data.length]); + const onRowsDelete = React.useCallback( + ({ rows }: { rows: Person[]; rowIndices: number[] }) => { + // In a real app, you would make a server call here: + // await fetch('/api/people', { + // method: 'DELETE', + // body: JSON.stringify({ ids: rows.map(r => r.id) }) + // }); + + // For this demo, just filter out the deleted rows + setData((prev) => prev.filter((row) => !rows.includes(row))); + }, + [], + ); + const { table, ...dataGridProps } = useDataGrid({ columns, data, onDataChange: setData, + onRowsDelete, getRowId: (row) => row.id, initialState: { columnPinning: { diff --git a/src/hooks/use-data-grid.tsx b/src/hooks/use-data-grid.tsx index d31cad27..073319a0 100644 --- a/src/hooks/use-data-grid.tsx +++ b/src/hooks/use-data-grid.tsx @@ -95,6 +95,10 @@ function useStore( interface UseDataGridProps extends Omit, "pageCount" | "getCoreRowModel"> { onDataChange?: (data: TData[]) => void; + onRowsDelete?: (props: { + rows: TData[]; + rowIndices: number[]; + }) => void | Promise; rowHeight?: RowHeightValue; overscan?: number; autoFocus?: boolean | Partial; @@ -106,6 +110,7 @@ export function useDataGrid({ columns, data, onDataChange, + onRowsDelete: onRowsDeleteProp, rowHeight: rowHeightProp = DEFAULT_ROW_HEIGHT, overscan = OVERSCAN, initialState, @@ -412,6 +417,58 @@ export function useDataGrid({ [store], ); + const onRowsDelete = React.useCallback( + async (rowIndices: number[]) => { + if (!onRowsDeleteProp || rowIndices.length === 0) return; + + const currentTable = tableRef.current; + const rows = currentTable?.getRowModel().rows; + + if (!rows || rows.length === 0) return; + + const currentState = store.getState(); + const currentFocusedColumn = + currentState.focusedCell?.columnId ?? navigableColumnIds[0]; + + const minDeletedRowIndex = Math.min(...rowIndices); + + const rowsToDelete: TData[] = []; + for (const rowIndex of rowIndices) { + const row = rows[rowIndex]; + if (row) { + rowsToDelete.push(row.original); + } + } + + await onRowsDeleteProp({ + rows: rowsToDelete, + rowIndices, + }); + + store.batch(() => { + store.setState("selectionState", { + selectedCells: new Set(), + selectionRange: null, + isSelecting: false, + }); + store.setState("rowSelection", {}); + store.setState("editingCell", null); + }); + + requestAnimationFrame(() => { + const currentTable = tableRef.current; + const currentRows = currentTable?.getRowModel().rows ?? []; + const newRowCount = currentRows.length ?? data.length; + + if (newRowCount > 0 && currentFocusedColumn) { + const targetRowIndex = Math.min(minDeletedRowIndex, newRowCount - 1); + focusCell(targetRowIndex, currentFocusedColumn); + } + }); + }, + [onRowsDeleteProp, data.length, store, navigableColumnIds, focusCell], + ); + const navigateCell = React.useCallback( (direction: NavigationDirection) => { const currentState = store.getState(); @@ -1319,10 +1376,14 @@ export function useDataGrid({ editingCell, selectionState, searchOpen, + rowHeight, isScrolling, getIsCellSelected, getIsSearchMatch, getIsActiveSearchMatch, + onRowHeightChange, + onRowSelect, + onRowsDelete: onRowsDeleteProp ? onRowsDelete : undefined, onDataUpdate, onColumnClick, onCellClick, @@ -1335,9 +1396,6 @@ export function useDataGrid({ onCellEditingStop, contextMenu, onContextMenuOpenChange, - rowHeight, - onRowHeightChange, - onRowSelect, }, }), [ @@ -1359,6 +1417,8 @@ export function useDataGrid({ getIsSearchMatch, getIsActiveSearchMatch, onDataUpdate, + onRowsDeleteProp, + onRowsDelete, onColumnClick, onCellClick, onCellDoubleClick, diff --git a/src/types/data-grid.ts b/src/types/data-grid.ts index 3687b543..9575ad41 100644 --- a/src/types/data-grid.ts +++ b/src/types/data-grid.ts @@ -60,6 +60,7 @@ declare module "@tanstack/react-table" { getIsSearchMatch?: (rowIndex: number, columnId: string) => boolean; getIsActiveSearchMatch?: (rowIndex: number, columnId: string) => boolean; onDataUpdate?: (props: UpdateCell | Array) => void; + onRowsDelete?: (rowIndices: number[]) => void | Promise; onColumnClick?: (columnId: string) => void; onCellClick?: ( rowIndex: number, From 41af20a3da86f3aab86f94a4e7c702a7d786f0b7 Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Mon, 27 Oct 2025 18:09:15 +0600 Subject: [PATCH 7/8] feat: add onrowsdelete --- public/r/data-grid.json | 15 +++++--- src/components/data-grid/data-grid-demo.tsx | 8 ++--- src/components/data-grid/data-grid.tsx | 40 +++------------------ src/hooks/use-data-grid.tsx | 36 ++++++++++++++++++- 4 files changed, 52 insertions(+), 47 deletions(-) diff --git a/public/r/data-grid.json b/public/r/data-grid.json index 9822d086..253f4570 100644 --- a/public/r/data-grid.json +++ b/public/r/data-grid.json @@ -19,6 +19,7 @@ "dialog", "dropdown-menu", "input", + "kbd", "popover", "select", "separator", @@ -28,7 +29,7 @@ "files": [ { "path": "src/components/data-grid/data-grid.tsx", - "content": "\"use client\";\n\nimport { flexRender } from \"@tanstack/react-table\";\nimport { Plus } from \"lucide-react\";\nimport * as React from \"react\";\nimport { DataGridColumnHeader } from \"@/components/data-grid/data-grid-column-header\";\nimport { DataGridContextMenu } from \"@/components/data-grid/data-grid-context-menu\";\nimport { DataGridRow } from \"@/components/data-grid/data-grid-row\";\nimport { DataGridSearch } from \"@/components/data-grid/data-grid-search\";\nimport type { useDataGrid } from \"@/hooks/use-data-grid\";\nimport { getCommonPinningStyles } from \"@/lib/data-table\";\nimport { cn } from \"@/lib/utils\";\nimport type { CellPosition } from \"@/types/data-grid\";\n\ninterface DataGridProps\n extends ReturnType>,\n React.ComponentProps<\"div\"> {\n height?: number;\n onRowAdd?: (event?: React.MouseEvent) =>\n | Partial\n | Promise>\n | null\n // biome-ignore lint/suspicious/noConfusingVoidType: void is needed here to allow functions without explicit return\n | void;\n}\n\nexport function DataGrid({\n dataGridRef,\n headerRef,\n rowMapRef,\n footerRef,\n table,\n rowVirtualizer,\n height = 600,\n searchState,\n columnSizeVars,\n onScrollToRow,\n onRowAdd: onRowAddProp,\n className,\n ...props\n}: DataGridProps) {\n const rows = table.getRowModel().rows;\n const columns = table.getAllColumns();\n\n const meta = table.options.meta;\n const rowHeight = meta?.rowHeight ?? \"short\";\n const focusedCell = meta?.focusedCell ?? null;\n\n const onGridContextMenu = React.useCallback(\n (event: React.MouseEvent) => {\n event.preventDefault();\n },\n [],\n );\n\n const onRowAdd = React.useCallback(\n async (event?: React.MouseEvent) => {\n if (!onRowAddProp) return;\n\n const result = await onRowAddProp();\n\n if (event?.defaultPrevented || result === null) return;\n\n if (result) {\n const adjustedRowIndex =\n (result.rowIndex ?? 0) >= rows.length ? rows.length : result.rowIndex;\n\n onScrollToRow({\n rowIndex: adjustedRowIndex,\n columnId: result.columnId,\n });\n return;\n }\n\n onScrollToRow({ rowIndex: rows.length });\n },\n [onRowAddProp, onScrollToRow, rows.length],\n );\n\n const onAddRowKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (!onRowAddProp) return;\n\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault();\n onRowAdd();\n }\n },\n [onRowAddProp, onRowAdd],\n );\n\n return (\n \n {searchState && }\n \n \n \n {table.getHeaderGroups().map((headerGroup, rowIndex) => (\n \n {headerGroup.headers.map((header, colIndex) => {\n const sorting = table.getState().sorting;\n const currentSort = sorting.find(\n (sort) => sort.id === header.column.id,\n );\n const isSortable = header.column.getCanSort();\n\n return (\n \n {header.isPlaceholder ? null : typeof header.column\n .columnDef.header === \"function\" ? (\n
\n {flexRender(\n header.column.columnDef.header,\n header.getContext(),\n )}\n
\n ) : (\n \n )}\n
\n );\n })}\n
\n ))}\n
\n \n {rowVirtualizer.getVirtualIndexes().map((virtualRowIndex) => {\n const row = rows[virtualRowIndex];\n if (!row) return null;\n\n return (\n \n );\n })}\n
\n {onRowAdd && (\n \n \n \n
\n \n Add row\n
\n \n \n \n )}\n \n \n );\n}\n", + "content": "\"use client\";\n\nimport { flexRender } from \"@tanstack/react-table\";\nimport { Plus } from \"lucide-react\";\nimport * as React from \"react\";\nimport { DataGridColumnHeader } from \"@/components/data-grid/data-grid-column-header\";\nimport { DataGridContextMenu } from \"@/components/data-grid/data-grid-context-menu\";\nimport { DataGridKeyboardShortcuts } from \"@/components/data-grid/data-grid-keyboard-shortcuts\";\nimport { DataGridRow } from \"@/components/data-grid/data-grid-row\";\nimport { DataGridSearch } from \"@/components/data-grid/data-grid-search\";\nimport type { useDataGrid } from \"@/hooks/use-data-grid\";\nimport { getCommonPinningStyles } from \"@/lib/data-table\";\nimport { cn } from \"@/lib/utils\";\n\ninterface DataGridProps\n extends ReturnType>,\n React.ComponentProps<\"div\"> {\n height?: number;\n}\n\nexport function DataGrid({\n dataGridRef,\n headerRef,\n rowMapRef,\n footerRef,\n table,\n rowVirtualizer,\n height = 600,\n searchState,\n columnSizeVars,\n onRowAdd,\n className,\n ...props\n}: DataGridProps) {\n const rows = table.getRowModel().rows;\n const columns = table.getAllColumns();\n\n const meta = table.options.meta;\n const rowHeight = meta?.rowHeight ?? \"short\";\n const focusedCell = meta?.focusedCell ?? null;\n\n const onGridContextMenu = React.useCallback(\n (event: React.MouseEvent) => {\n event.preventDefault();\n },\n [],\n );\n\n const onAddRowKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (!onRowAdd) return;\n\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault();\n onRowAdd();\n }\n },\n [onRowAdd],\n );\n\n return (\n \n {searchState && }\n \n \n \n \n {table.getHeaderGroups().map((headerGroup, rowIndex) => (\n \n {headerGroup.headers.map((header, colIndex) => {\n const sorting = table.getState().sorting;\n const currentSort = sorting.find(\n (sort) => sort.id === header.column.id,\n );\n const isSortable = header.column.getCanSort();\n\n return (\n \n {header.isPlaceholder ? null : typeof header.column\n .columnDef.header === \"function\" ? (\n
\n {flexRender(\n header.column.columnDef.header,\n header.getContext(),\n )}\n
\n ) : (\n \n )}\n \n );\n })}\n \n ))}\n \n \n {rowVirtualizer.getVirtualIndexes().map((virtualRowIndex) => {\n const row = rows[virtualRowIndex];\n if (!row) return null;\n\n return (\n \n );\n })}\n \n {onRowAdd && (\n \n \n \n
\n \n Add row\n
\n \n \n \n )}\n \n \n );\n}\n", "type": "registry:component", "target": "src/components/data-grid/data-grid.tsx" }, @@ -58,10 +59,16 @@ }, { "path": "src/components/data-grid/data-grid-context-menu.tsx", - "content": "\"use client\";\n\nimport type { Table, TableMeta } from \"@tanstack/react-table\";\nimport { CopyIcon, EraserIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { toast } from \"sonner\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { parseCellKey } from \"@/lib/data-grid\";\nimport type { UpdateCell } from \"@/types/data-grid\";\n\ninterface DataGridContextMenuProps {\n table: Table;\n}\n\nexport function DataGridContextMenu({\n table,\n}: DataGridContextMenuProps) {\n const meta = table.options.meta;\n const contextMenu = meta?.contextMenu;\n const onContextMenuOpenChange = meta?.onContextMenuOpenChange;\n const selectionState = meta?.selectionState;\n const dataGridRef = meta?.dataGridRef;\n const onDataUpdate = meta?.onDataUpdate;\n\n if (!contextMenu) return null;\n\n return (\n \n );\n}\n\ninterface ContextMenuProps\n extends Pick<\n TableMeta,\n | \"dataGridRef\"\n | \"onContextMenuOpenChange\"\n | \"selectionState\"\n | \"onDataUpdate\"\n >,\n Required, \"contextMenu\">> {\n table: Table;\n}\n\nconst ContextMenu = React.memo(ContextMenuImpl, (prev, next) => {\n if (prev.contextMenu.open !== next.contextMenu.open) return false;\n if (!next.contextMenu.open) return true;\n if (prev.contextMenu.x !== next.contextMenu.x) return false;\n if (prev.contextMenu.y !== next.contextMenu.y) return false;\n\n const prevSize = prev.selectionState?.selectedCells?.size ?? 0;\n const nextSize = next.selectionState?.selectedCells?.size ?? 0;\n if (prevSize !== nextSize) return false;\n\n return true;\n}) as typeof ContextMenuImpl;\n\nfunction ContextMenuImpl({\n table,\n dataGridRef,\n contextMenu,\n onContextMenuOpenChange,\n selectionState,\n onDataUpdate,\n}: ContextMenuProps) {\n const triggerStyle = React.useMemo(\n () => ({\n position: \"fixed\",\n left: `${contextMenu.x}px`,\n top: `${contextMenu.y}px`,\n width: \"1px\",\n height: \"1px\",\n padding: 0,\n margin: 0,\n border: \"none\",\n background: \"transparent\",\n pointerEvents: \"none\",\n opacity: 0,\n }),\n [contextMenu.x, contextMenu.y],\n );\n\n const onCloseAutoFocus: NonNullable<\n React.ComponentProps[\"onCloseAutoFocus\"]\n > = React.useCallback(\n (event) => {\n event.preventDefault();\n dataGridRef?.current?.focus();\n },\n [dataGridRef],\n );\n\n const onCopy = React.useCallback(() => {\n if (\n !selectionState?.selectedCells ||\n selectionState.selectedCells.size === 0\n )\n return;\n\n const rows = table.getRowModel().rows;\n const columnIds: string[] = [];\n\n // Collect all unique column IDs from selected cells\n const selectedCellsArray = Array.from(selectionState.selectedCells);\n for (const cellKey of selectedCellsArray) {\n const { columnId } = parseCellKey(cellKey);\n if (columnId && !columnIds.includes(columnId)) {\n columnIds.push(columnId);\n }\n }\n\n // Build grid of selected cells\n const cellData = new Map();\n for (const cellKey of selectedCellsArray) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n const row = rows[rowIndex];\n if (row) {\n const cell = row\n .getVisibleCells()\n .find((c) => c.column.id === columnId);\n if (cell) {\n const value = cell.getValue();\n cellData.set(cellKey, String(value ?? \"\"));\n }\n }\n }\n\n // Get row indices and column indices\n const rowIndices = new Set();\n const colIndices = new Set();\n\n for (const cellKey of selectedCellsArray) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n rowIndices.add(rowIndex);\n const colIndex = columnIds.indexOf(columnId);\n if (colIndex >= 0) {\n colIndices.add(colIndex);\n }\n }\n\n const sortedRowIndices = Array.from(rowIndices).sort((a, b) => a - b);\n const sortedColIndices = Array.from(colIndices).sort((a, b) => a - b);\n const sortedColumnIds = sortedColIndices.map((i) => columnIds[i]);\n\n // Build TSV (tab-separated values)\n const tsvData = sortedRowIndices\n .map((rowIndex) =>\n sortedColumnIds\n .map((columnId) => {\n const cellKey = `${rowIndex}:${columnId}`;\n return cellData.get(cellKey) ?? \"\";\n })\n .join(\"\\t\"),\n )\n .join(\"\\n\");\n\n navigator.clipboard.writeText(tsvData);\n toast.success(\n `${selectionState.selectedCells.size} cell${selectionState.selectedCells.size !== 1 ? \"s\" : \"\"} copied`,\n );\n }, [table, selectionState]);\n\n const onClear = React.useCallback(() => {\n if (\n !selectionState?.selectedCells ||\n selectionState.selectedCells.size === 0\n )\n return;\n\n const updates: Array = [];\n\n for (const cellKey of selectionState.selectedCells) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n updates.push({ rowIndex, columnId, value: \"\" });\n }\n\n onDataUpdate?.(updates);\n\n toast.success(\n `${updates.length} cell${updates.length !== 1 ? \"s\" : \"\"} cleared`,\n );\n }, [onDataUpdate, selectionState]);\n\n return (\n \n \n \n \n \n Copy\n \n \n \n Clear\n \n \n \n );\n}\n", + "content": "\"use client\";\n\nimport type { Table, TableMeta } from \"@tanstack/react-table\";\nimport { CopyIcon, EraserIcon, Trash2Icon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { toast } from \"sonner\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { parseCellKey } from \"@/lib/data-grid\";\nimport type { UpdateCell } from \"@/types/data-grid\";\n\ninterface DataGridContextMenuProps {\n table: Table;\n}\n\nexport function DataGridContextMenu({\n table,\n}: DataGridContextMenuProps) {\n const meta = table.options.meta;\n const contextMenu = meta?.contextMenu;\n const onContextMenuOpenChange = meta?.onContextMenuOpenChange;\n const selectionState = meta?.selectionState;\n const dataGridRef = meta?.dataGridRef;\n const onDataUpdate = meta?.onDataUpdate;\n const onRowsDelete = meta?.onRowsDelete;\n\n if (!contextMenu) return null;\n\n return (\n \n );\n}\n\ninterface ContextMenuProps\n extends Pick<\n TableMeta,\n | \"dataGridRef\"\n | \"onContextMenuOpenChange\"\n | \"selectionState\"\n | \"onDataUpdate\"\n | \"onRowsDelete\"\n >,\n Required, \"contextMenu\">> {\n table: Table;\n}\n\nconst ContextMenu = React.memo(ContextMenuImpl, (prev, next) => {\n if (prev.contextMenu.open !== next.contextMenu.open) return false;\n if (!next.contextMenu.open) return true;\n if (prev.contextMenu.x !== next.contextMenu.x) return false;\n if (prev.contextMenu.y !== next.contextMenu.y) return false;\n\n const prevSize = prev.selectionState?.selectedCells?.size ?? 0;\n const nextSize = next.selectionState?.selectedCells?.size ?? 0;\n if (prevSize !== nextSize) return false;\n\n return true;\n}) as typeof ContextMenuImpl;\n\nfunction ContextMenuImpl({\n table,\n dataGridRef,\n contextMenu,\n onContextMenuOpenChange,\n selectionState,\n onDataUpdate,\n onRowsDelete,\n}: ContextMenuProps) {\n const triggerStyle = React.useMemo(\n () => ({\n position: \"fixed\",\n left: `${contextMenu.x}px`,\n top: `${contextMenu.y}px`,\n width: \"1px\",\n height: \"1px\",\n padding: 0,\n margin: 0,\n border: \"none\",\n background: \"transparent\",\n pointerEvents: \"none\",\n opacity: 0,\n }),\n [contextMenu.x, contextMenu.y],\n );\n\n const onCloseAutoFocus: NonNullable<\n React.ComponentProps[\"onCloseAutoFocus\"]\n > = React.useCallback(\n (event) => {\n event.preventDefault();\n dataGridRef?.current?.focus();\n },\n [dataGridRef],\n );\n\n const onCopy = React.useCallback(() => {\n if (\n !selectionState?.selectedCells ||\n selectionState.selectedCells.size === 0\n )\n return;\n\n const rows = table.getRowModel().rows;\n const columnIds: string[] = [];\n\n const selectedCellsArray = Array.from(selectionState.selectedCells);\n for (const cellKey of selectedCellsArray) {\n const { columnId } = parseCellKey(cellKey);\n if (columnId && !columnIds.includes(columnId)) {\n columnIds.push(columnId);\n }\n }\n\n const cellData = new Map();\n for (const cellKey of selectedCellsArray) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n const row = rows[rowIndex];\n if (row) {\n const cell = row\n .getVisibleCells()\n .find((c) => c.column.id === columnId);\n if (cell) {\n const value = cell.getValue();\n cellData.set(cellKey, String(value ?? \"\"));\n }\n }\n }\n\n const rowIndices = new Set();\n const colIndices = new Set();\n\n for (const cellKey of selectedCellsArray) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n rowIndices.add(rowIndex);\n const colIndex = columnIds.indexOf(columnId);\n if (colIndex >= 0) {\n colIndices.add(colIndex);\n }\n }\n\n const sortedRowIndices = Array.from(rowIndices).sort((a, b) => a - b);\n const sortedColIndices = Array.from(colIndices).sort((a, b) => a - b);\n const sortedColumnIds = sortedColIndices.map((i) => columnIds[i]);\n\n const tsvData = sortedRowIndices\n .map((rowIndex) =>\n sortedColumnIds\n .map((columnId) => {\n const cellKey = `${rowIndex}:${columnId}`;\n return cellData.get(cellKey) ?? \"\";\n })\n .join(\"\\t\"),\n )\n .join(\"\\n\");\n\n navigator.clipboard.writeText(tsvData);\n toast.success(\n `${selectionState.selectedCells.size} cell${selectionState.selectedCells.size !== 1 ? \"s\" : \"\"} copied`,\n );\n }, [table, selectionState]);\n\n const onClear = React.useCallback(() => {\n if (\n !selectionState?.selectedCells ||\n selectionState.selectedCells.size === 0\n )\n return;\n\n const updates: Array = [];\n\n for (const cellKey of selectionState.selectedCells) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n updates.push({ rowIndex, columnId, value: \"\" });\n }\n\n onDataUpdate?.(updates);\n\n toast.success(\n `${updates.length} cell${updates.length !== 1 ? \"s\" : \"\"} cleared`,\n );\n }, [onDataUpdate, selectionState]);\n\n const onDelete = React.useCallback(async () => {\n if (\n !selectionState?.selectedCells ||\n selectionState.selectedCells.size === 0\n )\n return;\n\n const rowIndices = new Set();\n for (const cellKey of selectionState.selectedCells) {\n const { rowIndex } = parseCellKey(cellKey);\n rowIndices.add(rowIndex);\n }\n\n const rowIndicesArray = Array.from(rowIndices).sort((a, b) => a - b);\n const rowCount = rowIndicesArray.length;\n\n await onRowsDelete?.(rowIndicesArray);\n\n toast.success(`${rowCount} row${rowCount !== 1 ? \"s\" : \"\"} deleted`);\n }, [onRowsDelete, selectionState]);\n\n return (\n \n \n \n \n \n Copy\n \n \n \n Clear\n \n {onRowsDelete && (\n <>\n \n \n \n Delete rows\n \n \n )}\n \n \n );\n}\n", "type": "registry:component", "target": "src/components/data-grid/data-grid-context-menu.tsx" }, + { + "path": "src/components/data-grid/data-grid-keyboard-shortcuts.tsx", + "content": "\"use client\";\n\nimport { SearchIcon, XIcon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport {\n Dialog,\n DialogClose,\n DialogContent,\n DialogDescription,\n DialogHeader,\n DialogTitle,\n} from \"@/components/ui/dialog\";\nimport { Input } from \"@/components/ui/input\";\nimport { Kbd, KbdGroup } from \"@/components/ui/kbd\";\nimport { Separator } from \"@/components/ui/separator\";\n\nconst SHORTCUT_KEY = \"/\";\n\ninterface ShortcutGroup {\n title: string;\n shortcuts: Array<{\n keys: string[];\n description: string;\n }>;\n}\n\ninterface DataGridKeyboardShortcutsProps {\n enableSearch?: boolean;\n}\n\nexport const DataGridKeyboardShortcuts = React.memo(\n DataGridKeyboardShortcutsImpl,\n (prev, next) => {\n return prev.enableSearch === next.enableSearch;\n },\n);\n\nfunction DataGridKeyboardShortcutsImpl({\n enableSearch = false,\n}: DataGridKeyboardShortcutsProps) {\n const [open, setOpen] = React.useState(false);\n const [input, setInput] = React.useState(\"\");\n const inputRef = React.useRef(null);\n\n const isMac =\n typeof navigator !== \"undefined\"\n ? /Mac|iPhone|iPad|iPod/.test(navigator.userAgent)\n : false;\n\n const modKey = isMac ? \"⌘\" : \"Ctrl\";\n\n const onOpenChange = React.useCallback((isOpen: boolean) => {\n setOpen(isOpen);\n if (!isOpen) {\n setInput(\"\");\n }\n }, []);\n\n const onOpenAutoFocus = React.useCallback((event: Event) => {\n event.preventDefault();\n inputRef.current?.focus();\n }, []);\n\n const onInputChange = React.useCallback(\n (event: React.ChangeEvent) => {\n setInput(event.target.value);\n },\n [],\n );\n\n const shortcutGroups: ShortcutGroup[] = React.useMemo(\n () => [\n {\n title: \"Navigation\",\n shortcuts: [\n {\n keys: [\"↑\", \"↓\", \"←\", \"→\"],\n description: \"Navigate between cells\",\n },\n {\n keys: [\"Tab\"],\n description: \"Move to next cell\",\n },\n {\n keys: [\"Shift\", \"Tab\"],\n description: \"Move to previous cell\",\n },\n {\n keys: [\"Home\"],\n description: \"Move to first column\",\n },\n {\n keys: [\"End\"],\n description: \"Move to last column\",\n },\n {\n keys: [modKey, \"Home\"],\n description: \"Move to first cell\",\n },\n {\n keys: [modKey, \"End\"],\n description: \"Move to last cell\",\n },\n {\n keys: [\"PgUp\"],\n description: \"Move up one page\",\n },\n {\n keys: [\"PgDn\"],\n description: \"Move down one page\",\n },\n ],\n },\n {\n title: \"Selection\",\n shortcuts: [\n {\n keys: [\"Shift\", \"↑↓←→\"],\n description: \"Extend selection\",\n },\n {\n keys: [modKey, \"A\"],\n description: \"Select all cells\",\n },\n {\n keys: [modKey, \"Click\"],\n description: \"Toggle cell selection\",\n },\n {\n keys: [\"Shift\", \"Click\"],\n description: \"Select range\",\n },\n {\n keys: [\"Esc\"],\n description: \"Clear selection\",\n },\n ],\n },\n {\n title: \"Editing\",\n shortcuts: [\n {\n keys: [\"Enter\"],\n description: \"Start editing cell\",\n },\n {\n keys: [\"Double Click\"],\n description: \"Start editing cell\",\n },\n {\n keys: [\"Delete\"],\n description: \"Clear selected cells\",\n },\n {\n keys: [\"Backspace\"],\n description: \"Clear selected cells\",\n },\n ],\n },\n ...(enableSearch\n ? [\n {\n title: \"Search\",\n shortcuts: [\n {\n keys: [modKey, \"F\"],\n description: \"Open search\",\n },\n {\n keys: [\"Enter\"],\n description: \"Next match\",\n },\n {\n keys: [\"Shift\", \"Enter\"],\n description: \"Previous match\",\n },\n {\n keys: [\"Esc\"],\n description: \"Close search\",\n },\n ],\n },\n ]\n : []),\n {\n title: \"Sorting\",\n shortcuts: [\n {\n keys: [modKey, \"Shift\", \"S\"],\n description: \"Toggle the sort menu\",\n },\n {\n keys: [\"Backspace\"],\n description: \"Remove sort (when focused)\",\n },\n {\n keys: [\"Delete\"],\n description: \"Remove sort (when focused)\",\n },\n ],\n },\n {\n title: \"General\",\n shortcuts: [\n {\n keys: [modKey, \"/\"],\n description: \"Show keyboard shortcuts\",\n },\n ],\n },\n ],\n [modKey, enableSearch],\n );\n\n const filteredGroups = React.useMemo(() => {\n if (!input.trim()) return shortcutGroups;\n\n const query = input.toLowerCase();\n return shortcutGroups\n .map((group) => ({\n ...group,\n shortcuts: group.shortcuts.filter(\n (shortcut) =>\n shortcut.description.toLowerCase().includes(query) ||\n shortcut.keys.some((key) => key.toLowerCase().includes(query)),\n ),\n }))\n .filter((group) => group.shortcuts.length > 0);\n }, [shortcutGroups, input]);\n\n React.useEffect(() => {\n function onKeyDown(event: KeyboardEvent) {\n if ((event.ctrlKey || event.metaKey) && event.key === SHORTCUT_KEY) {\n event.preventDefault();\n setOpen(true);\n }\n }\n\n window.addEventListener(\"keydown\", onKeyDown);\n return () => {\n window.removeEventListener(\"keydown\", onKeyDown);\n };\n }, []);\n\n return (\n \n \n \n \n \n \n Keyboard shortcuts\n \n Use these keyboard shortcuts to navigate and interact with the data\n grid more efficiently.\n \n \n
\n
\n \n \n
\n
\n \n
\n {filteredGroups.length === 0 ? (\n
\n
\n \n
\n
\n
\n No shortcuts found\n
\n

\n Try searching for a different term.\n

\n
\n
\n ) : (\n
\n {filteredGroups.map((shortcutGroup) => (\n
\n

\n {shortcutGroup.title}\n

\n
\n {shortcutGroup.shortcuts.map((shortcut, index) => (\n \n ))}\n
\n
\n ))}\n
\n )}\n
\n \n
\n );\n}\n\nfunction ShortcutCard({\n keys,\n description,\n}: ShortcutGroup[\"shortcuts\"][number]) {\n return (\n
\n {description}\n \n {keys.map((key, index) => (\n \n {index > 0 && (\n +\n )}\n {key}\n \n ))}\n \n
\n );\n}\n", + "type": "registry:component", + "target": "src/components/data-grid/data-grid-keyboard-shortcuts.tsx" + }, { "path": "src/components/data-grid/data-grid-row.tsx", "content": "\"use client\";\n\nimport { flexRender, type Row } from \"@tanstack/react-table\";\nimport type { Virtualizer } from \"@tanstack/react-virtual\";\nimport * as React from \"react\";\nimport { useComposedRefs } from \"@/lib/compose-refs\";\nimport { getRowHeightValue } from \"@/lib/data-grid\";\nimport { getCommonPinningStyles } from \"@/lib/data-table\";\nimport { cn } from \"@/lib/utils\";\nimport type { CellPosition, RowHeightValue } from \"@/types/data-grid\";\n\ninterface DataGridRowProps extends React.ComponentProps<\"div\"> {\n row: Row;\n rowVirtualizer: Virtualizer;\n virtualRowIndex: number;\n rowMapRef: React.RefObject>;\n rowHeight: RowHeightValue;\n focusedCell: CellPosition | null;\n}\n\nexport const DataGridRow = React.memo(DataGridRowImpl, (prev, next) => {\n if (prev.row.id !== next.row.id) {\n return false;\n }\n\n const prevRowIndex = prev.virtualRowIndex;\n const nextRowIndex = next.virtualRowIndex;\n\n const prevHasFocus = prev.focusedCell?.rowIndex === prevRowIndex;\n const nextHasFocus = next.focusedCell?.rowIndex === nextRowIndex;\n\n if (prevHasFocus !== nextHasFocus) {\n return false;\n }\n\n if (nextHasFocus && prevHasFocus) {\n const prevFocusedCol = prev.focusedCell?.columnId;\n const nextFocusedCol = next.focusedCell?.columnId;\n if (prevFocusedCol !== nextFocusedCol) {\n return false;\n }\n }\n\n if (next.rowVirtualizer.isScrolling) {\n return true;\n }\n\n return false;\n}) as typeof DataGridRowImpl;\n\nfunction DataGridRowImpl({\n row,\n virtualRowIndex,\n rowVirtualizer,\n rowMapRef,\n rowHeight,\n focusedCell,\n ref,\n className,\n ...props\n}: DataGridRowProps) {\n const rowRef = useComposedRefs(ref, (node) => {\n if (node && typeof virtualRowIndex !== \"undefined\") {\n rowVirtualizer.measureElement(node);\n rowMapRef.current.set(virtualRowIndex, node);\n }\n });\n\n const isRowSelected = row.getIsSelected();\n\n return (\n \n {row.getVisibleCells().map((cell, colIndex) => {\n const isCellFocused =\n focusedCell?.rowIndex === virtualRowIndex &&\n focusedCell?.columnId === cell.column.id;\n\n return (\n \n {typeof cell.column.columnDef.header === \"function\" ? (\n \n {flexRender(cell.column.columnDef.cell, cell.getContext())}\n \n ) : (\n flexRender(cell.column.columnDef.cell, cell.getContext())\n )}\n \n );\n })}\n \n );\n}\n", @@ -81,7 +88,7 @@ }, { "path": "src/hooks/use-data-grid.tsx", - "content": "\"use client\";\n\nimport {\n type ColumnDef,\n getCoreRowModel,\n getSortedRowModel,\n type RowSelectionState,\n type SortingState,\n type TableOptions,\n type Updater,\n useReactTable,\n} from \"@tanstack/react-table\";\nimport { useVirtualizer, type Virtualizer } from \"@tanstack/react-virtual\";\nimport * as React from \"react\";\nimport { DataGridCell } from \"@/components/data-grid/data-grid-cell\";\nimport { getCellKey, getRowHeightValue, parseCellKey } from \"@/lib/data-grid\";\nimport type {\n CellPosition,\n ContextMenuState,\n NavigationDirection,\n RowHeightValue,\n SearchState,\n SelectionState,\n UpdateCell,\n} from \"@/types/data-grid\";\n\nconst DEFAULT_ROW_HEIGHT = \"short\";\nconst OVERSCAN = 3;\nconst VIEWPORT_OFFSET = 1;\nconst MIN_COLUMN_SIZE = 60;\nconst MAX_COLUMN_SIZE = 800;\nconst SEARCH_SHORTCUT_KEY = \"f\";\nconst NON_NAVIGABLE_COLUMN_IDS = [\"select\", \"actions\"];\n\nconst useIsomorphicLayoutEffect =\n typeof window !== \"undefined\" ? React.useLayoutEffect : React.useEffect;\n\nfunction useLazyRef(fn: () => T): React.RefObject {\n const ref = React.useRef(null);\n if (ref.current === null) {\n ref.current = fn();\n }\n return ref as React.RefObject;\n}\n\nfunction useAsRef(data: T) {\n const ref = React.useRef(data);\n\n useIsomorphicLayoutEffect(() => {\n ref.current = data;\n });\n\n return ref;\n}\n\ninterface DataGridState {\n sorting: SortingState;\n rowHeight: RowHeightValue;\n rowSelection: RowSelectionState;\n selectionState: SelectionState;\n focusedCell: CellPosition | null;\n editingCell: CellPosition | null;\n contextMenu: ContextMenuState;\n searchQuery: string;\n searchMatches: CellPosition[];\n matchIndex: number;\n searchOpen: boolean;\n lastClickedRowIndex: number | null;\n isScrolling: boolean;\n}\n\ninterface DataGridStore {\n subscribe: (callback: () => void) => () => void;\n getState: () => DataGridState;\n setState: (\n key: K,\n value: DataGridState[K],\n ) => void;\n notify: () => void;\n batch: (fn: () => void) => void;\n}\n\nfunction useStore(\n store: DataGridStore,\n selector: (state: DataGridState) => T,\n): T {\n const getSnapshot = React.useCallback(\n () => selector(store.getState()),\n [store, selector],\n );\n\n return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);\n}\n\ninterface UseDataGridProps\n extends Omit, \"pageCount\" | \"getCoreRowModel\"> {\n onDataChange?: (data: TData[]) => void;\n rowHeight?: RowHeightValue;\n overscan?: number;\n autoFocus?: boolean | Partial;\n enableColumnSelection?: boolean;\n enableSearch?: boolean;\n}\n\nexport function useDataGrid({\n columns,\n data,\n onDataChange,\n rowHeight: rowHeightProp = DEFAULT_ROW_HEIGHT,\n overscan = OVERSCAN,\n initialState,\n autoFocus = false,\n enableColumnSelection = false,\n enableSearch = false,\n ...dataGridProps\n}: UseDataGridProps) {\n const dataGridRef = React.useRef(null);\n const tableRef = React.useRef>>(null);\n const rowVirtualizerRef =\n React.useRef>(null);\n const headerRef = React.useRef(null);\n const rowMapRef = React.useRef>(new Map());\n const footerRef = React.useRef(null);\n\n const dataGridPropsRef = useAsRef(dataGridProps);\n const listenersRef = useLazyRef(() => new Set<() => void>());\n\n const stateRef = useLazyRef(() => {\n return {\n sorting: initialState?.sorting ?? [],\n rowHeight: rowHeightProp,\n rowSelection: initialState?.rowSelection ?? {},\n selectionState: {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n },\n focusedCell: null,\n editingCell: null,\n contextMenu: {\n open: false,\n x: 0,\n y: 0,\n },\n searchQuery: \"\",\n searchMatches: [],\n matchIndex: -1,\n searchOpen: false,\n lastClickedRowIndex: null,\n isScrolling: false,\n };\n });\n\n const store = React.useMemo(() => {\n let isBatching = false;\n let pendingNotification = false;\n\n return {\n subscribe: (callback) => {\n listenersRef.current.add(callback);\n return () => listenersRef.current.delete(callback);\n },\n getState: () => stateRef.current,\n setState: (key, value) => {\n if (Object.is(stateRef.current[key], value)) return;\n stateRef.current[key] = value;\n\n if (isBatching) {\n pendingNotification = true;\n } else {\n if (!pendingNotification) {\n pendingNotification = true;\n queueMicrotask(() => {\n pendingNotification = false;\n store.notify();\n });\n }\n }\n },\n notify: () => {\n for (const listener of listenersRef.current) {\n listener();\n }\n },\n batch: (fn) => {\n if (isBatching) {\n fn();\n return;\n }\n\n isBatching = true;\n const wasPending = pendingNotification;\n pendingNotification = false;\n\n try {\n fn();\n } finally {\n isBatching = false;\n if (pendingNotification || wasPending) {\n pendingNotification = false;\n store.notify();\n }\n }\n },\n };\n }, [listenersRef, stateRef]);\n\n const focusedCell = useStore(store, (state) => state.focusedCell);\n const editingCell = useStore(store, (state) => state.editingCell);\n const selectionState = useStore(store, (state) => state.selectionState);\n const searchQuery = useStore(store, (state) => state.searchQuery);\n const searchMatches = useStore(store, (state) => state.searchMatches);\n const matchIndex = useStore(store, (state) => state.matchIndex);\n const searchOpen = useStore(store, (state) => state.searchOpen);\n const sorting = useStore(store, (state) => state.sorting);\n const rowSelection = useStore(store, (state) => state.rowSelection);\n const contextMenu = useStore(store, (state) => state.contextMenu);\n const rowHeight = useStore(store, (state) => state.rowHeight);\n const isScrolling = useStore(store, (state) => state.isScrolling);\n\n const rowHeightValue = getRowHeightValue(rowHeight);\n\n const columnIds = React.useMemo(() => {\n return columns\n .map((c) => {\n if (c.id) return c.id;\n if (\"accessorKey\" in c) return c.accessorKey as string;\n return undefined;\n })\n .filter((id): id is string => Boolean(id));\n }, [columns]);\n\n const navigableColumnIds = React.useMemo(() => {\n return columnIds.filter((c) => !NON_NAVIGABLE_COLUMN_IDS.includes(c));\n }, [columnIds]);\n\n const onDataUpdate = React.useCallback(\n (updates: UpdateCell | Array) => {\n const updateArray = Array.isArray(updates) ? updates : [updates];\n\n if (updateArray.length === 0) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n\n const rowUpdatesMap = new Map<\n number,\n Array>\n >();\n\n for (const update of updateArray) {\n if (!rows || !currentTable) {\n const existingUpdates = rowUpdatesMap.get(update.rowIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(update.rowIndex, existingUpdates);\n } else {\n const row = rows[update.rowIndex];\n if (!row) continue;\n\n const originalData = row.original;\n const originalRowIndex = data.indexOf(originalData);\n if (originalRowIndex === -1) continue;\n\n const existingUpdates = rowUpdatesMap.get(originalRowIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(originalRowIndex, existingUpdates);\n }\n }\n\n const newData = data.map((row, index) => {\n const updates = rowUpdatesMap.get(index);\n if (!updates) return row;\n\n const updatedRow = { ...row } as Record;\n for (const { columnId, value } of updates) {\n updatedRow[columnId] = value;\n }\n return updatedRow as TData;\n });\n\n onDataChange?.(newData);\n },\n [data, onDataChange],\n );\n\n const getIsCellSelected = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return selectionState.selectedCells.has(getCellKey(rowIndex, columnId));\n },\n [selectionState.selectedCells],\n );\n\n const clearSelection = React.useCallback(() => {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"rowSelection\", {});\n });\n }, [store]);\n\n const selectAll = React.useCallback(() => {\n const allCells = new Set();\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {\n for (const columnId of columnIds) {\n allCells.add(getCellKey(rowIndex, columnId));\n }\n }\n\n const firstColumnId = columnIds[0];\n const lastColumnId = columnIds[columnIds.length - 1];\n\n store.setState(\"selectionState\", {\n selectedCells: allCells,\n selectionRange:\n columnIds.length > 0 && rowCount > 0 && firstColumnId && lastColumnId\n ? {\n start: { rowIndex: 0, columnId: firstColumnId },\n end: { rowIndex: rowCount - 1, columnId: lastColumnId },\n }\n : null,\n isSelecting: false,\n });\n }, [columnIds, data.length, store]);\n\n const selectColumn = React.useCallback(\n (columnId: string) => {\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n if (rowCount === 0) return;\n\n const selectedCells = new Set();\n\n for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: {\n start: { rowIndex: 0, columnId },\n end: { rowIndex: rowCount - 1, columnId },\n },\n isSelecting: false,\n });\n },\n [data.length, store],\n );\n\n const selectRange = React.useCallback(\n (start: CellPosition, end: CellPosition, isSelecting = false) => {\n const startColIndex = columnIds.indexOf(start.columnId);\n const endColIndex = columnIds.indexOf(end.columnId);\n\n const minRow = Math.min(start.rowIndex, end.rowIndex);\n const maxRow = Math.max(start.rowIndex, end.rowIndex);\n const minCol = Math.min(startColIndex, endColIndex);\n const maxCol = Math.max(startColIndex, endColIndex);\n\n const selectedCells = new Set();\n\n for (let rowIndex = minRow; rowIndex <= maxRow; rowIndex++) {\n for (let colIndex = minCol; colIndex <= maxCol; colIndex++) {\n const columnId = columnIds[colIndex];\n if (columnId) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n }\n }\n\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: { start, end },\n isSelecting,\n });\n },\n [columnIds, store],\n );\n\n const focusCell = React.useCallback(\n (rowIndex: number, columnId: string) => {\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", null);\n });\n\n const currentState = store.getState();\n\n if (currentState.searchOpen) return;\n\n if (\n dataGridRef.current &&\n document.activeElement !== dataGridRef.current\n ) {\n dataGridRef.current.focus();\n }\n },\n [store],\n );\n\n const navigateCell = React.useCallback(\n (direction: NavigationDirection) => {\n const currentState = store.getState();\n if (!currentState.focusedCell) return;\n\n const { rowIndex, columnId } = currentState.focusedCell;\n const currentColIndex = navigableColumnIds.indexOf(columnId);\n const rowVirtualizer = rowVirtualizerRef.current;\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n let newRowIndex = rowIndex;\n let newColumnId = columnId;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(rowCount - 1, rowIndex + 1);\n break;\n case \"left\":\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n break;\n case \"right\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n break;\n case \"home\":\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? columnId;\n }\n break;\n case \"end\":\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;\n }\n break;\n case \"ctrl+home\":\n newRowIndex = 0;\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? columnId;\n }\n break;\n case \"ctrl+end\":\n newRowIndex = Math.max(0, rowCount - 1);\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;\n }\n break;\n case \"pageup\":\n if (rowVirtualizer) {\n const visibleRange = rowVirtualizer.getVirtualItems();\n const pageSize = visibleRange.length ?? 10;\n newRowIndex = Math.max(0, rowIndex - pageSize);\n } else {\n newRowIndex = Math.max(0, rowIndex - 10);\n }\n break;\n case \"pagedown\":\n if (rowVirtualizer) {\n const visibleRange = rowVirtualizer.getVirtualItems();\n const pageSize = visibleRange.length ?? 10;\n newRowIndex = Math.min(rowCount - 1, rowIndex + pageSize);\n } else {\n newRowIndex = Math.min(rowCount - 1, rowIndex + 10);\n }\n break;\n }\n\n if (newRowIndex !== rowIndex || newColumnId !== columnId) {\n const rowDiff = newRowIndex - rowIndex;\n\n // For single-row vertical navigation (up/down arrows)\n if (\n Math.abs(rowDiff) === 1 &&\n (direction === \"up\" || direction === \"down\")\n ) {\n const container = dataGridRef.current;\n const currentRow = rowMapRef.current.get(rowIndex);\n const targetRow = rowMapRef.current.get(newRowIndex);\n\n if (!container || !currentRow) {\n // Fallback to simple focus if we can't find elements\n focusCell(newRowIndex, newColumnId);\n return;\n }\n\n // Check viewport boundaries\n const containerRect = container.getBoundingClientRect();\n const headerHeight =\n headerRef.current?.getBoundingClientRect().height ?? 0;\n const footerHeight =\n footerRef.current?.getBoundingClientRect().height ?? 0;\n\n const viewportTop =\n containerRect.top + headerHeight + VIEWPORT_OFFSET;\n const viewportBottom =\n containerRect.bottom - footerHeight - VIEWPORT_OFFSET;\n\n // If target row already exists, check if it's visible\n if (targetRow) {\n const rowRect = targetRow.getBoundingClientRect();\n const isFullyVisible =\n rowRect.top >= viewportTop && rowRect.bottom <= viewportBottom;\n\n if (isFullyVisible) {\n // Row is fully visible, just focus it\n focusCell(newRowIndex, newColumnId);\n return;\n }\n\n // Row exists but not fully visible, scroll it into view\n focusCell(newRowIndex, newColumnId);\n\n if (direction === \"down\") {\n // Scroll just enough to show the row at the bottom\n const scrollNeeded = rowRect.bottom - viewportBottom;\n container.scrollTop += scrollNeeded;\n } else {\n // Scroll just enough to show the row at the top\n const scrollNeeded = viewportTop - rowRect.top;\n container.scrollTop -= scrollNeeded;\n }\n return;\n }\n\n // Target row is not rendered yet\n // Focus immediately so the ring appears as the row is revealed\n focusCell(newRowIndex, newColumnId);\n\n // Scroll by exactly one row height to reveal it smoothly\n if (direction === \"down\") {\n container.scrollTop += rowHeightValue;\n } else {\n // For arrow up, ensure we don't go below 0\n const currentScrollTop = container.scrollTop;\n const targetScrollTop = Math.max(\n 0,\n currentScrollTop - rowHeightValue,\n );\n container.scrollTop = targetScrollTop;\n }\n return;\n }\n\n // For larger jumps (page up/down, ctrl+home/end, etc.)\n if (rowVirtualizer && Math.abs(rowDiff) > 1) {\n const align =\n direction === \"pageup\" || direction === \"ctrl+home\"\n ? \"start\"\n : direction === \"pagedown\" || direction === \"ctrl+end\"\n ? \"end\"\n : \"center\";\n rowVirtualizer.scrollToIndex(newRowIndex, { align });\n requestAnimationFrame(() => {\n focusCell(newRowIndex, newColumnId);\n });\n return;\n }\n\n // For horizontal navigation or when row is already visible\n focusCell(newRowIndex, newColumnId);\n }\n },\n [store, navigableColumnIds, focusCell, data.length, rowHeightValue],\n );\n\n const onCellEditingStart = React.useCallback(\n (rowIndex: number, columnId: string) => {\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", { rowIndex, columnId });\n });\n },\n [store],\n );\n\n const onCellEditingStop = React.useCallback(\n (opts?: { moveToNextRow?: boolean; direction?: NavigationDirection }) => {\n const currentState = store.getState();\n const currentEditing = currentState.editingCell;\n\n store.setState(\"editingCell\", null);\n\n if (opts?.moveToNextRow && currentEditing) {\n const { rowIndex, columnId } = currentEditing;\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n const nextRowIndex = rowIndex + 1;\n if (nextRowIndex < rowCount) {\n requestAnimationFrame(() => {\n focusCell(nextRowIndex, columnId);\n });\n }\n } else if (opts?.direction && currentEditing) {\n // Focus the current editing cell first, then navigate\n const { rowIndex, columnId } = currentEditing;\n focusCell(rowIndex, columnId);\n requestAnimationFrame(() => {\n navigateCell(opts.direction ?? \"right\");\n });\n }\n },\n [store, data.length, focusCell, navigateCell],\n );\n\n const onSearchOpenChange = React.useCallback(\n (open: boolean) => {\n if (open) {\n store.setState(\"searchOpen\", true);\n return;\n }\n\n const currentState = store.getState();\n const currentMatch =\n currentState.matchIndex >= 0 &&\n currentState.searchMatches[currentState.matchIndex];\n\n store.batch(() => {\n store.setState(\"searchOpen\", false);\n store.setState(\"searchQuery\", \"\");\n store.setState(\"searchMatches\", []);\n store.setState(\"matchIndex\", -1);\n\n if (currentMatch) {\n store.setState(\"focusedCell\", {\n rowIndex: currentMatch.rowIndex,\n columnId: currentMatch.columnId,\n });\n }\n });\n\n if (\n dataGridRef.current &&\n document.activeElement !== dataGridRef.current\n ) {\n dataGridRef.current.focus();\n }\n },\n [store],\n );\n\n const onSearch = React.useCallback(\n (query: string) => {\n if (!query.trim()) {\n store.batch(() => {\n store.setState(\"searchMatches\", []);\n store.setState(\"matchIndex\", -1);\n });\n return;\n }\n\n const matches: CellPosition[] = [];\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n\n const lowerQuery = query.toLowerCase();\n\n for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {\n const row = rows[rowIndex];\n if (!row) continue;\n\n for (const columnId of columnIds) {\n const cell = row\n .getVisibleCells()\n .find((c) => c.column.id === columnId);\n if (!cell) continue;\n\n const value = cell.getValue();\n const stringValue = String(value ?? \"\").toLowerCase();\n\n if (stringValue.includes(lowerQuery)) {\n matches.push({ rowIndex, columnId });\n }\n }\n }\n\n store.batch(() => {\n store.setState(\"searchMatches\", matches);\n store.setState(\"matchIndex\", matches.length > 0 ? 0 : -1);\n });\n\n // Scroll to first match but don't focus it (to keep focus in search input)\n if (matches.length > 0 && matches[0]) {\n const firstMatch = matches[0];\n rowVirtualizerRef.current?.scrollToIndex(firstMatch.rowIndex, {\n align: \"center\",\n });\n }\n },\n [columnIds, store],\n );\n\n const onSearchQueryChange = React.useCallback(\n (query: string) => store.setState(\"searchQuery\", query),\n [store],\n );\n\n const onNavigateToPrevMatch = React.useCallback(() => {\n const currentState = store.getState();\n if (currentState.searchMatches.length === 0) return;\n\n const prevIndex =\n currentState.matchIndex - 1 < 0\n ? currentState.searchMatches.length - 1\n : currentState.matchIndex - 1;\n const match = currentState.searchMatches[prevIndex];\n\n if (match) {\n rowVirtualizerRef.current?.scrollToIndex(match.rowIndex, {\n align: \"center\",\n });\n\n requestAnimationFrame(() => {\n store.setState(\"matchIndex\", prevIndex);\n requestAnimationFrame(() => {\n focusCell(match.rowIndex, match.columnId);\n });\n });\n }\n }, [store, focusCell]);\n\n const onNavigateToNextMatch = React.useCallback(() => {\n const currentState = store.getState();\n if (currentState.searchMatches.length === 0) return;\n\n const nextIndex =\n (currentState.matchIndex + 1) % currentState.searchMatches.length;\n const match = currentState.searchMatches[nextIndex];\n\n if (match) {\n rowVirtualizerRef.current?.scrollToIndex(match.rowIndex, {\n align: \"center\",\n });\n\n requestAnimationFrame(() => {\n store.setState(\"matchIndex\", nextIndex);\n requestAnimationFrame(() => {\n focusCell(match.rowIndex, match.columnId);\n });\n });\n }\n }, [store, focusCell]);\n\n const getIsSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return searchMatches.some(\n (match) => match.rowIndex === rowIndex && match.columnId === columnId,\n );\n },\n [searchMatches],\n );\n\n const getIsActiveSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n if (matchIndex < 0) return false;\n const currentMatch = searchMatches[matchIndex];\n return (\n currentMatch?.rowIndex === rowIndex &&\n currentMatch?.columnId === columnId\n );\n },\n [searchMatches, matchIndex],\n );\n\n const blurCell = React.useCallback(() => {\n const currentState = store.getState();\n if (\n currentState.editingCell &&\n document.activeElement instanceof HTMLElement\n ) {\n document.activeElement.blur();\n }\n\n store.batch(() => {\n store.setState(\"focusedCell\", null);\n store.setState(\"editingCell\", null);\n });\n }, [store]);\n\n const onCellClick = React.useCallback(\n (rowIndex: number, columnId: string, event?: React.MouseEvent) => {\n // Ignore right-click (button 2) - let onCellContextMenu handle it\n if (event?.button === 2) {\n return;\n }\n\n const currentState = store.getState();\n const currentFocused = currentState.focusedCell;\n\n if (event) {\n if (event.ctrlKey || event.metaKey) {\n event.preventDefault();\n const cellKey = getCellKey(rowIndex, columnId);\n const newSelectedCells = new Set(\n currentState.selectionState.selectedCells,\n );\n\n if (newSelectedCells.has(cellKey)) {\n newSelectedCells.delete(cellKey);\n } else {\n newSelectedCells.add(cellKey);\n }\n\n store.setState(\"selectionState\", {\n selectedCells: newSelectedCells,\n selectionRange: null,\n isSelecting: false,\n });\n focusCell(rowIndex, columnId);\n return;\n }\n\n if (event.shiftKey && currentState.focusedCell) {\n event.preventDefault();\n selectRange(currentState.focusedCell, { rowIndex, columnId });\n return;\n }\n }\n\n // Clear selection if there are selected cells or rows\n const hasSelectedCells =\n currentState.selectionState.selectedCells.size > 0;\n const hasSelectedRows = Object.keys(currentState.rowSelection).length > 0;\n\n if (hasSelectedCells && !currentState.selectionState.isSelecting) {\n // If there's a cell selection but we're not actively selecting (drag just finished),\n // don't clear it - keep the selection\n // Only clear if clicking elsewhere\n const cellKey = getCellKey(rowIndex, columnId);\n const isClickingSelectedCell =\n currentState.selectionState.selectedCells.has(cellKey);\n\n if (!isClickingSelectedCell) {\n clearSelection();\n } else {\n // Clicking on an already selected cell - just focus it\n focusCell(rowIndex, columnId);\n return;\n }\n } else if (hasSelectedRows && columnId !== \"select\") {\n // If there are selected rows but we're clicking on a non-checkbox cell, clear selections\n clearSelection();\n }\n\n if (\n currentFocused?.rowIndex === rowIndex &&\n currentFocused?.columnId === columnId\n ) {\n onCellEditingStart(rowIndex, columnId);\n } else {\n focusCell(rowIndex, columnId);\n }\n },\n [store, focusCell, onCellEditingStart, selectRange, clearSelection],\n );\n\n const onCellDoubleClick = React.useCallback(\n (rowIndex: number, columnId: string, event?: React.MouseEvent) => {\n if (event?.defaultPrevented) return;\n\n onCellEditingStart(rowIndex, columnId);\n },\n [onCellEditingStart],\n );\n\n const onCellMouseDown = React.useCallback(\n (rowIndex: number, columnId: string, event: React.MouseEvent) => {\n // Ignore right-click (button 2) - let onCellContextMenu handle it\n if (event.button === 2) {\n return;\n }\n\n event.preventDefault();\n\n // Only start drag selection if no modifier keys are pressed\n // Clear any existing selection and prepare for potential drag\n if (!event.ctrlKey && !event.metaKey && !event.shiftKey) {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: {\n start: { rowIndex, columnId },\n end: { rowIndex, columnId },\n },\n isSelecting: true,\n });\n store.setState(\"rowSelection\", {});\n });\n }\n },\n [store],\n );\n\n const onCellMouseEnter = React.useCallback(\n (rowIndex: number, columnId: string, _event: React.MouseEvent) => {\n const currentState = store.getState();\n if (\n currentState.selectionState.isSelecting &&\n currentState.selectionState.selectionRange\n ) {\n const start = currentState.selectionState.selectionRange.start;\n const end = { rowIndex, columnId };\n\n if (\n currentState.focusedCell?.rowIndex !== start.rowIndex ||\n currentState.focusedCell?.columnId !== start.columnId\n ) {\n focusCell(start.rowIndex, start.columnId);\n }\n\n selectRange(start, end, true);\n }\n },\n [store, selectRange, focusCell],\n );\n\n const onCellMouseUp = React.useCallback(() => {\n const currentState = store.getState();\n store.setState(\"selectionState\", {\n ...currentState.selectionState,\n isSelecting: false,\n });\n }, [store]);\n\n const onCellContextMenu = React.useCallback(\n (rowIndex: number, columnId: string, event: React.MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n\n const currentState = store.getState();\n const cellKey = getCellKey(rowIndex, columnId);\n const isTargetCellSelected =\n currentState.selectionState.selectedCells.has(cellKey);\n\n // If right-clicking on a non-selected cell, select only that cell\n if (!isTargetCellSelected) {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set([cellKey]),\n selectionRange: {\n start: { rowIndex, columnId },\n end: { rowIndex, columnId },\n },\n isSelecting: false,\n });\n store.setState(\"focusedCell\", { rowIndex, columnId });\n });\n }\n\n // Open context menu at cursor position\n store.setState(\"contextMenu\", {\n open: true,\n x: event.clientX,\n y: event.clientY,\n });\n },\n [store],\n );\n\n const onContextMenuOpenChange = React.useCallback(\n (open: boolean) => {\n if (!open) {\n const currentMenu = store.getState().contextMenu;\n store.setState(\"contextMenu\", {\n open: false,\n x: currentMenu.x,\n y: currentMenu.y,\n });\n }\n },\n [store],\n );\n\n const onDataGridKeyDown = React.useCallback(\n (event: KeyboardEvent) => {\n const currentState = store.getState();\n const { key, ctrlKey, metaKey, shiftKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n // Handle Cmd+F / Ctrl+F to open search (highest priority, works even when editing)\n if (enableSearch && isCtrlPressed && key === SEARCH_SHORTCUT_KEY) {\n event.preventDefault();\n onSearchOpenChange(true);\n return;\n }\n\n // Handle search navigation when search is open\n if (\n enableSearch &&\n currentState.searchOpen &&\n !currentState.editingCell\n ) {\n if (key === \"Enter\") {\n event.preventDefault();\n if (shiftKey) {\n onNavigateToPrevMatch();\n } else {\n onNavigateToNextMatch();\n }\n return;\n }\n if (key === \"Escape\") {\n event.preventDefault();\n onSearchOpenChange(false);\n return;\n }\n // When search is open, don't let data grid handle any other keys\n // (they should only affect the search input)\n return;\n }\n\n if (currentState.editingCell) return;\n\n if (!currentState.focusedCell) return;\n\n let direction: NavigationDirection | null = null;\n\n if (isCtrlPressed && key === \"a\") {\n event.preventDefault();\n selectAll();\n return;\n }\n\n if (key === \"Delete\" || key === \"Backspace\") {\n if (currentState.selectionState.selectedCells.size > 0) {\n event.preventDefault();\n const updates: Array<{\n rowIndex: number;\n columnId: string;\n value: unknown;\n }> = [];\n\n currentState.selectionState.selectedCells.forEach((cellKey) => {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n updates.push({ rowIndex, columnId, value: \"\" });\n });\n\n onDataUpdate(updates);\n clearSelection();\n }\n return;\n }\n\n switch (key) {\n case \"ArrowUp\":\n direction = \"up\";\n break;\n case \"ArrowDown\":\n direction = \"down\";\n break;\n case \"ArrowLeft\":\n direction = \"left\";\n break;\n case \"ArrowRight\":\n direction = \"right\";\n break;\n case \"Home\":\n direction = isCtrlPressed ? \"ctrl+home\" : \"home\";\n break;\n case \"End\":\n direction = isCtrlPressed ? \"ctrl+end\" : \"end\";\n break;\n case \"PageUp\":\n direction = \"pageup\";\n break;\n case \"PageDown\":\n direction = \"pagedown\";\n break;\n case \"Escape\":\n event.preventDefault();\n if (\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0\n ) {\n clearSelection();\n } else {\n blurCell();\n }\n return;\n case \"Tab\":\n event.preventDefault();\n direction = event.shiftKey ? \"left\" : \"right\";\n break;\n }\n\n if (direction) {\n event.preventDefault();\n\n // Tab navigation should not trigger selection, even with Shift\n if (shiftKey && key !== \"Tab\" && currentState.focusedCell) {\n const currentColIndex = navigableColumnIds.indexOf(\n currentState.focusedCell.columnId,\n );\n let newRowIndex = currentState.focusedCell.rowIndex;\n let newColumnId = currentState.focusedCell.columnId;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, currentState.focusedCell.rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(\n (tableRef.current?.getRowModel().rows.length || data.length) -\n 1,\n currentState.focusedCell.rowIndex + 1,\n );\n break;\n case \"left\":\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n break;\n case \"right\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n break;\n }\n\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n selectRange(selectionStart, {\n rowIndex: newRowIndex,\n columnId: newColumnId,\n });\n focusCell(newRowIndex, newColumnId);\n } else {\n if (currentState.selectionState.selectedCells.size > 0) {\n clearSelection();\n }\n navigateCell(direction);\n }\n }\n },\n [\n store,\n blurCell,\n navigateCell,\n selectAll,\n onDataUpdate,\n clearSelection,\n navigableColumnIds,\n data.length,\n selectRange,\n focusCell,\n onSearchOpenChange,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n enableSearch,\n ],\n );\n\n const onSortingChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newSorting =\n typeof updater === \"function\" ? updater(currentState.sorting) : updater;\n store.setState(\"sorting\", newSorting);\n },\n [store],\n );\n\n const onRowSelectionChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newRowSelection =\n typeof updater === \"function\"\n ? updater(currentState.rowSelection)\n : updater;\n\n const selectedRows = Object.keys(newRowSelection).filter(\n (key) => newRowSelection[key],\n );\n\n const selectedCells = new Set();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n\n for (const rowId of selectedRows) {\n const rowIndex = rows.findIndex((r) => r.id === rowId);\n if (rowIndex === -1) continue;\n\n for (const columnId of columnIds) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n }\n\n store.batch(() => {\n store.setState(\"rowSelection\", newRowSelection);\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"focusedCell\", null);\n store.setState(\"editingCell\", null);\n });\n },\n [store, columnIds],\n );\n\n const onRowSelect = React.useCallback(\n (rowIndex: number, selected: boolean, shiftKey: boolean) => {\n const currentState = store.getState();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n const currentRow = rows[rowIndex];\n if (!currentRow) return;\n\n if (shiftKey && currentState.lastClickedRowIndex !== null) {\n const startIndex = Math.min(currentState.lastClickedRowIndex, rowIndex);\n const endIndex = Math.max(currentState.lastClickedRowIndex, rowIndex);\n\n const newRowSelection: RowSelectionState = {\n ...currentState.rowSelection,\n };\n\n for (let i = startIndex; i <= endIndex; i++) {\n const row = rows[i];\n if (row) {\n newRowSelection[row.id] = selected;\n }\n }\n\n onRowSelectionChange(newRowSelection);\n } else {\n onRowSelectionChange({\n ...currentState.rowSelection,\n [currentRow.id]: selected,\n });\n }\n\n store.setState(\"lastClickedRowIndex\", rowIndex);\n },\n [store, onRowSelectionChange],\n );\n\n const onRowHeightChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newRowHeight =\n typeof updater === \"function\"\n ? updater(currentState.rowHeight)\n : updater;\n store.setState(\"rowHeight\", newRowHeight);\n },\n [store],\n );\n\n const onColumnClick = React.useCallback(\n (columnId: string) => {\n if (!enableColumnSelection) {\n clearSelection();\n return;\n }\n\n selectColumn(columnId);\n },\n [enableColumnSelection, selectColumn, clearSelection],\n );\n\n const defaultColumn: Partial> = React.useMemo(\n () => ({\n cell: DataGridCell,\n minSize: MIN_COLUMN_SIZE,\n maxSize: MAX_COLUMN_SIZE,\n }),\n [],\n );\n\n const tableOptions = React.useMemo>(\n () => ({\n ...dataGridPropsRef.current,\n data,\n columns,\n defaultColumn,\n initialState,\n state: {\n ...dataGridPropsRef.current.state,\n sorting,\n rowSelection,\n },\n onRowSelectionChange,\n onSortingChange,\n columnResizeMode: \"onChange\",\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n meta: {\n ...dataGridPropsRef.current.meta,\n dataGridRef,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n isScrolling,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n onDataUpdate,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n contextMenu,\n onContextMenuOpenChange,\n rowHeight,\n onRowHeightChange,\n onRowSelect,\n },\n }),\n [\n dataGridPropsRef,\n data,\n columns,\n defaultColumn,\n initialState,\n sorting,\n rowSelection,\n onRowSelectionChange,\n onSortingChange,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n isScrolling,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n onDataUpdate,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n contextMenu,\n onContextMenuOpenChange,\n rowHeight,\n onRowHeightChange,\n onRowSelect,\n ],\n );\n\n const table = useReactTable(tableOptions);\n\n if (!tableRef.current) {\n tableRef.current = table;\n }\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: we need to memoize the column size vars\n const columnSizeVars = React.useMemo(() => {\n const headers = table.getFlatHeaders();\n const colSizes: { [key: string]: number } = {};\n for (const header of headers) {\n colSizes[`--header-${header.id}-size`] = header.getSize();\n colSizes[`--col-${header.column.id}-size`] = header.column.getSize();\n }\n return colSizes;\n }, [table.getState().columnSizingInfo, table.getState().columnSizing]);\n\n const rowVirtualizer = useVirtualizer({\n count: table.getRowModel().rows.length,\n getScrollElement: () => dataGridRef.current,\n estimateSize: () => rowHeightValue,\n overscan,\n measureElement:\n typeof window !== \"undefined\" &&\n navigator.userAgent.indexOf(\"Firefox\") === -1\n ? (element) => element?.getBoundingClientRect().height\n : undefined,\n onChange: (instance) => {\n // Sync virtualizer's isScrolling state to our store\n const virtualizerIsScrolling = instance.isScrolling;\n const currentIsScrolling = store.getState().isScrolling;\n\n if (virtualizerIsScrolling !== currentIsScrolling) {\n store.setState(\"isScrolling\", virtualizerIsScrolling);\n }\n\n // Batch DOM updates in a single animation frame\n const virtualItems = instance.getVirtualItems();\n if (virtualItems.length === 0) return;\n\n requestAnimationFrame(() => {\n for (let i = 0; i < virtualItems.length; i++) {\n const virtualRow = virtualItems[i];\n if (!virtualRow) continue;\n const rowRef = rowMapRef.current.get(virtualRow.index);\n if (rowRef) {\n rowRef.style.transform = `translateY(${virtualRow.start}px)`;\n }\n }\n });\n },\n });\n\n if (!rowVirtualizerRef.current) {\n rowVirtualizerRef.current = rowVirtualizer;\n }\n\n const onScrollToRow = React.useCallback(\n async (opts: Partial) => {\n const rowIndex = opts?.rowIndex ?? 0;\n const columnId = opts?.columnId;\n\n rowVirtualizer.scrollToIndex(rowIndex, {\n align: \"center\",\n });\n\n const targetColumnId = columnId ?? navigableColumnIds[0];\n\n if (!targetColumnId) return;\n\n queueMicrotask(() => {\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n store.batch(() => {\n store.setState(\"focusedCell\", {\n rowIndex,\n columnId: targetColumnId,\n });\n store.setState(\"editingCell\", null);\n });\n });\n });\n });\n },\n [rowVirtualizer, navigableColumnIds, store],\n );\n\n const searchState = React.useMemo(() => {\n if (!enableSearch) return undefined;\n\n return {\n searchMatches,\n matchIndex,\n searchOpen,\n onSearchOpenChange,\n searchQuery,\n onSearchQueryChange,\n onSearch,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n };\n }, [\n enableSearch,\n searchMatches,\n matchIndex,\n searchOpen,\n onSearchOpenChange,\n searchQuery,\n onSearchQueryChange,\n onSearch,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n ]);\n\n React.useEffect(() => {\n const dataGridElement = dataGridRef.current;\n if (!dataGridElement) return;\n\n dataGridElement.addEventListener(\"keydown\", onDataGridKeyDown);\n return () => {\n dataGridElement.removeEventListener(\"keydown\", onDataGridKeyDown);\n };\n }, [onDataGridKeyDown]);\n\n React.useEffect(() => {\n function onGlobalKeyDown(event: KeyboardEvent) {\n const dataGridElement = dataGridRef.current;\n if (!dataGridElement) return;\n\n const target = event.target;\n if (!(target instanceof HTMLElement)) return;\n\n const { key, ctrlKey, metaKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n if (enableSearch && isCtrlPressed && key === SEARCH_SHORTCUT_KEY) {\n const isInInput =\n target.tagName === \"INPUT\" || target.tagName === \"TEXTAREA\";\n const isInDataGrid = dataGridElement.contains(target);\n const isInSearchInput = target.closest('[role=\"search\"]') !== null;\n\n if (isInDataGrid || isInSearchInput || !isInInput) {\n event.preventDefault();\n event.stopPropagation();\n onSearchOpenChange(true);\n\n if (!isInDataGrid && !isInSearchInput) {\n requestAnimationFrame(() => {\n dataGridElement.focus();\n });\n }\n return;\n }\n }\n\n const isInDataGrid = dataGridElement.contains(target);\n if (!isInDataGrid) return;\n\n if (key === \"Escape\") {\n const currentState = store.getState();\n const hasSelections =\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0;\n\n if (hasSelections) {\n event.preventDefault();\n event.stopPropagation();\n clearSelection();\n }\n }\n }\n\n window.addEventListener(\"keydown\", onGlobalKeyDown, true);\n return () => {\n window.removeEventListener(\"keydown\", onGlobalKeyDown, true);\n };\n }, [enableSearch, onSearchOpenChange, store, clearSelection]);\n\n React.useEffect(() => {\n const currentState = store.getState();\n if (\n autoFocus &&\n data.length > 0 &&\n columns.length > 0 &&\n !currentState.focusedCell\n ) {\n if (navigableColumnIds.length > 0) {\n const rafId = requestAnimationFrame(() => {\n if (typeof autoFocus === \"object\") {\n const { rowIndex, columnId } = autoFocus;\n if (columnId) {\n focusCell(rowIndex ?? 0, columnId);\n }\n return;\n }\n\n const firstColumnId = navigableColumnIds[0];\n if (firstColumnId) {\n focusCell(0, firstColumnId);\n }\n });\n return () => cancelAnimationFrame(rafId);\n }\n }\n }, [\n autoFocus,\n data.length,\n columns.length,\n store,\n navigableColumnIds,\n focusCell,\n ]);\n\n React.useEffect(() => {\n function onOutsideClick(event: MouseEvent) {\n if (event.button === 2) {\n return;\n }\n\n if (\n dataGridRef.current &&\n !dataGridRef.current.contains(event.target as Node)\n ) {\n const target = event.target;\n const isInsidePopover =\n target instanceof HTMLElement &&\n (target.closest(\"[data-grid-cell-editor]\") ||\n target.closest(\"[data-grid-popover]\"));\n\n if (!isInsidePopover) {\n blurCell();\n const currentState = store.getState();\n if (\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0\n ) {\n clearSelection();\n }\n }\n }\n }\n\n document.addEventListener(\"mousedown\", onOutsideClick);\n return () => {\n document.removeEventListener(\"mousedown\", onOutsideClick);\n };\n }, [store, blurCell, clearSelection]);\n\n React.useEffect(() => {\n function onCleanup() {\n document.removeEventListener(\"selectstart\", preventSelection);\n document.removeEventListener(\"contextmenu\", preventContextMenu);\n document.body.style.userSelect = \"\";\n }\n\n function preventSelection(event: Event) {\n event.preventDefault();\n }\n function preventContextMenu(event: Event) {\n event.preventDefault();\n }\n\n const onUnsubscribe = store.subscribe(() => {\n const currentState = store.getState();\n if (currentState.selectionState.isSelecting) {\n document.addEventListener(\"selectstart\", preventSelection);\n document.addEventListener(\"contextmenu\", preventContextMenu);\n document.body.style.userSelect = \"none\";\n } else {\n onCleanup();\n }\n });\n\n return () => {\n onCleanup();\n onUnsubscribe();\n };\n }, [store]);\n\n useIsomorphicLayoutEffect(() => {\n const rafId = requestAnimationFrame(() => {\n rowVirtualizer.measure();\n });\n return () => cancelAnimationFrame(rafId);\n }, [\n table.getState().columnFilters,\n table.getState().columnOrder,\n table.getState().columnPinning,\n table.getState().columnSizing,\n table.getState().columnVisibility,\n table.getState().expanded,\n table.getState().globalFilter,\n table.getState().grouping,\n table.getState().rowSelection,\n table.getState().sorting,\n rowHeight,\n ]);\n\n return {\n dataGridRef,\n headerRef,\n rowMapRef,\n footerRef,\n table,\n rowVirtualizer,\n searchState,\n columnSizeVars,\n onScrollToRow,\n };\n}\n", + "content": "\"use client\";\n\nimport {\n type ColumnDef,\n getCoreRowModel,\n getSortedRowModel,\n type RowSelectionState,\n type SortingState,\n type TableOptions,\n type Updater,\n useReactTable,\n} from \"@tanstack/react-table\";\nimport { useVirtualizer, type Virtualizer } from \"@tanstack/react-virtual\";\nimport * as React from \"react\";\nimport { DataGridCell } from \"@/components/data-grid/data-grid-cell\";\nimport { getCellKey, getRowHeightValue, parseCellKey } from \"@/lib/data-grid\";\nimport type {\n CellPosition,\n ContextMenuState,\n NavigationDirection,\n RowHeightValue,\n SearchState,\n SelectionState,\n UpdateCell,\n} from \"@/types/data-grid\";\n\nconst DEFAULT_ROW_HEIGHT = \"short\";\nconst OVERSCAN = 3;\nconst VIEWPORT_OFFSET = 1;\nconst MIN_COLUMN_SIZE = 60;\nconst MAX_COLUMN_SIZE = 800;\nconst SEARCH_SHORTCUT_KEY = \"f\";\nconst NON_NAVIGABLE_COLUMN_IDS = [\"select\", \"actions\"];\n\nconst useIsomorphicLayoutEffect =\n typeof window !== \"undefined\" ? React.useLayoutEffect : React.useEffect;\n\nfunction useLazyRef(fn: () => T): React.RefObject {\n const ref = React.useRef(null);\n if (ref.current === null) {\n ref.current = fn();\n }\n return ref as React.RefObject;\n}\n\nfunction useAsRef(data: T) {\n const ref = React.useRef(data);\n\n useIsomorphicLayoutEffect(() => {\n ref.current = data;\n });\n\n return ref;\n}\n\ninterface DataGridState {\n sorting: SortingState;\n rowHeight: RowHeightValue;\n rowSelection: RowSelectionState;\n selectionState: SelectionState;\n focusedCell: CellPosition | null;\n editingCell: CellPosition | null;\n contextMenu: ContextMenuState;\n searchQuery: string;\n searchMatches: CellPosition[];\n matchIndex: number;\n searchOpen: boolean;\n lastClickedRowIndex: number | null;\n isScrolling: boolean;\n}\n\ninterface DataGridStore {\n subscribe: (callback: () => void) => () => void;\n getState: () => DataGridState;\n setState: (\n key: K,\n value: DataGridState[K],\n ) => void;\n notify: () => void;\n batch: (fn: () => void) => void;\n}\n\nfunction useStore(\n store: DataGridStore,\n selector: (state: DataGridState) => T,\n): T {\n const getSnapshot = React.useCallback(\n () => selector(store.getState()),\n [store, selector],\n );\n\n return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);\n}\n\ninterface UseDataGridProps\n extends Omit, \"pageCount\" | \"getCoreRowModel\"> {\n onDataChange?: (data: TData[]) => void;\n onRowAdd?: (event?: React.MouseEvent) =>\n | Partial\n | Promise>\n | null\n // biome-ignore lint/suspicious/noConfusingVoidType: void is needed here to allow functions without explicit return\n | void;\n onRowsDelete?: (props: {\n rows: TData[];\n rowIndices: number[];\n }) => void | Promise;\n rowHeight?: RowHeightValue;\n overscan?: number;\n autoFocus?: boolean | Partial;\n enableColumnSelection?: boolean;\n enableSearch?: boolean;\n}\n\nexport function useDataGrid({\n columns,\n data,\n onDataChange,\n onRowAdd: onRowAddProp,\n onRowsDelete: onRowsDeleteProp,\n rowHeight: rowHeightProp = DEFAULT_ROW_HEIGHT,\n overscan = OVERSCAN,\n initialState,\n autoFocus = false,\n enableColumnSelection = false,\n enableSearch = false,\n ...dataGridProps\n}: UseDataGridProps) {\n const dataGridRef = React.useRef(null);\n const tableRef = React.useRef>>(null);\n const rowVirtualizerRef =\n React.useRef>(null);\n const headerRef = React.useRef(null);\n const rowMapRef = React.useRef>(new Map());\n const footerRef = React.useRef(null);\n\n const dataGridPropsRef = useAsRef(dataGridProps);\n const listenersRef = useLazyRef(() => new Set<() => void>());\n\n const stateRef = useLazyRef(() => {\n return {\n sorting: initialState?.sorting ?? [],\n rowHeight: rowHeightProp,\n rowSelection: initialState?.rowSelection ?? {},\n selectionState: {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n },\n focusedCell: null,\n editingCell: null,\n contextMenu: {\n open: false,\n x: 0,\n y: 0,\n },\n searchQuery: \"\",\n searchMatches: [],\n matchIndex: -1,\n searchOpen: false,\n lastClickedRowIndex: null,\n isScrolling: false,\n };\n });\n\n const store = React.useMemo(() => {\n let isBatching = false;\n let pendingNotification = false;\n\n return {\n subscribe: (callback) => {\n listenersRef.current.add(callback);\n return () => listenersRef.current.delete(callback);\n },\n getState: () => stateRef.current,\n setState: (key, value) => {\n if (Object.is(stateRef.current[key], value)) return;\n stateRef.current[key] = value;\n\n if (isBatching) {\n pendingNotification = true;\n } else {\n if (!pendingNotification) {\n pendingNotification = true;\n queueMicrotask(() => {\n pendingNotification = false;\n store.notify();\n });\n }\n }\n },\n notify: () => {\n for (const listener of listenersRef.current) {\n listener();\n }\n },\n batch: (fn) => {\n if (isBatching) {\n fn();\n return;\n }\n\n isBatching = true;\n const wasPending = pendingNotification;\n pendingNotification = false;\n\n try {\n fn();\n } finally {\n isBatching = false;\n if (pendingNotification || wasPending) {\n pendingNotification = false;\n store.notify();\n }\n }\n },\n };\n }, [listenersRef, stateRef]);\n\n const focusedCell = useStore(store, (state) => state.focusedCell);\n const editingCell = useStore(store, (state) => state.editingCell);\n const selectionState = useStore(store, (state) => state.selectionState);\n const searchQuery = useStore(store, (state) => state.searchQuery);\n const searchMatches = useStore(store, (state) => state.searchMatches);\n const matchIndex = useStore(store, (state) => state.matchIndex);\n const searchOpen = useStore(store, (state) => state.searchOpen);\n const sorting = useStore(store, (state) => state.sorting);\n const rowSelection = useStore(store, (state) => state.rowSelection);\n const contextMenu = useStore(store, (state) => state.contextMenu);\n const rowHeight = useStore(store, (state) => state.rowHeight);\n const isScrolling = useStore(store, (state) => state.isScrolling);\n\n const rowHeightValue = getRowHeightValue(rowHeight);\n\n const columnIds = React.useMemo(() => {\n return columns\n .map((c) => {\n if (c.id) return c.id;\n if (\"accessorKey\" in c) return c.accessorKey as string;\n return undefined;\n })\n .filter((id): id is string => Boolean(id));\n }, [columns]);\n\n const navigableColumnIds = React.useMemo(() => {\n return columnIds.filter((c) => !NON_NAVIGABLE_COLUMN_IDS.includes(c));\n }, [columnIds]);\n\n const onDataUpdate = React.useCallback(\n (updates: UpdateCell | Array) => {\n const updateArray = Array.isArray(updates) ? updates : [updates];\n\n if (updateArray.length === 0) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n\n const rowUpdatesMap = new Map<\n number,\n Array>\n >();\n\n for (const update of updateArray) {\n if (!rows || !currentTable) {\n const existingUpdates = rowUpdatesMap.get(update.rowIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(update.rowIndex, existingUpdates);\n } else {\n const row = rows[update.rowIndex];\n if (!row) continue;\n\n const originalData = row.original;\n const originalRowIndex = data.indexOf(originalData);\n if (originalRowIndex === -1) continue;\n\n const existingUpdates = rowUpdatesMap.get(originalRowIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(originalRowIndex, existingUpdates);\n }\n }\n\n const newData = data.map((row, index) => {\n const updates = rowUpdatesMap.get(index);\n if (!updates) return row;\n\n const updatedRow = { ...row } as Record;\n for (const { columnId, value } of updates) {\n updatedRow[columnId] = value;\n }\n return updatedRow as TData;\n });\n\n onDataChange?.(newData);\n },\n [data, onDataChange],\n );\n\n const getIsCellSelected = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return selectionState.selectedCells.has(getCellKey(rowIndex, columnId));\n },\n [selectionState.selectedCells],\n );\n\n const clearSelection = React.useCallback(() => {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"rowSelection\", {});\n });\n }, [store]);\n\n const selectAll = React.useCallback(() => {\n const allCells = new Set();\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {\n for (const columnId of columnIds) {\n allCells.add(getCellKey(rowIndex, columnId));\n }\n }\n\n const firstColumnId = columnIds[0];\n const lastColumnId = columnIds[columnIds.length - 1];\n\n store.setState(\"selectionState\", {\n selectedCells: allCells,\n selectionRange:\n columnIds.length > 0 && rowCount > 0 && firstColumnId && lastColumnId\n ? {\n start: { rowIndex: 0, columnId: firstColumnId },\n end: { rowIndex: rowCount - 1, columnId: lastColumnId },\n }\n : null,\n isSelecting: false,\n });\n }, [columnIds, data.length, store]);\n\n const selectColumn = React.useCallback(\n (columnId: string) => {\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n if (rowCount === 0) return;\n\n const selectedCells = new Set();\n\n for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: {\n start: { rowIndex: 0, columnId },\n end: { rowIndex: rowCount - 1, columnId },\n },\n isSelecting: false,\n });\n },\n [data.length, store],\n );\n\n const selectRange = React.useCallback(\n (start: CellPosition, end: CellPosition, isSelecting = false) => {\n const startColIndex = columnIds.indexOf(start.columnId);\n const endColIndex = columnIds.indexOf(end.columnId);\n\n const minRow = Math.min(start.rowIndex, end.rowIndex);\n const maxRow = Math.max(start.rowIndex, end.rowIndex);\n const minCol = Math.min(startColIndex, endColIndex);\n const maxCol = Math.max(startColIndex, endColIndex);\n\n const selectedCells = new Set();\n\n for (let rowIndex = minRow; rowIndex <= maxRow; rowIndex++) {\n for (let colIndex = minCol; colIndex <= maxCol; colIndex++) {\n const columnId = columnIds[colIndex];\n if (columnId) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n }\n }\n\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: { start, end },\n isSelecting,\n });\n },\n [columnIds, store],\n );\n\n const focusCell = React.useCallback(\n (rowIndex: number, columnId: string) => {\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", null);\n });\n\n const currentState = store.getState();\n\n if (currentState.searchOpen) return;\n\n if (\n dataGridRef.current &&\n document.activeElement !== dataGridRef.current\n ) {\n dataGridRef.current.focus();\n }\n },\n [store],\n );\n\n const onRowsDelete = React.useCallback(\n async (rowIndices: number[]) => {\n if (!onRowsDeleteProp || rowIndices.length === 0) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n\n if (!rows || rows.length === 0) return;\n\n const currentState = store.getState();\n const currentFocusedColumn =\n currentState.focusedCell?.columnId ?? navigableColumnIds[0];\n\n const minDeletedRowIndex = Math.min(...rowIndices);\n\n const rowsToDelete: TData[] = [];\n for (const rowIndex of rowIndices) {\n const row = rows[rowIndex];\n if (row) {\n rowsToDelete.push(row.original);\n }\n }\n\n await onRowsDeleteProp({\n rows: rowsToDelete,\n rowIndices,\n });\n\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"rowSelection\", {});\n store.setState(\"editingCell\", null);\n });\n\n requestAnimationFrame(() => {\n const currentTable = tableRef.current;\n const currentRows = currentTable?.getRowModel().rows ?? [];\n const newRowCount = currentRows.length ?? data.length;\n\n if (newRowCount > 0 && currentFocusedColumn) {\n const targetRowIndex = Math.min(minDeletedRowIndex, newRowCount - 1);\n focusCell(targetRowIndex, currentFocusedColumn);\n }\n });\n },\n [onRowsDeleteProp, data.length, store, navigableColumnIds, focusCell],\n );\n\n const navigateCell = React.useCallback(\n (direction: NavigationDirection) => {\n const currentState = store.getState();\n if (!currentState.focusedCell) return;\n\n const { rowIndex, columnId } = currentState.focusedCell;\n const currentColIndex = navigableColumnIds.indexOf(columnId);\n const rowVirtualizer = rowVirtualizerRef.current;\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n let newRowIndex = rowIndex;\n let newColumnId = columnId;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(rowCount - 1, rowIndex + 1);\n break;\n case \"left\":\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n break;\n case \"right\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n break;\n case \"home\":\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? columnId;\n }\n break;\n case \"end\":\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;\n }\n break;\n case \"ctrl+home\":\n newRowIndex = 0;\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? columnId;\n }\n break;\n case \"ctrl+end\":\n newRowIndex = Math.max(0, rowCount - 1);\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;\n }\n break;\n case \"pageup\":\n if (rowVirtualizer) {\n const visibleRange = rowVirtualizer.getVirtualItems();\n const pageSize = visibleRange.length ?? 10;\n newRowIndex = Math.max(0, rowIndex - pageSize);\n } else {\n newRowIndex = Math.max(0, rowIndex - 10);\n }\n break;\n case \"pagedown\":\n if (rowVirtualizer) {\n const visibleRange = rowVirtualizer.getVirtualItems();\n const pageSize = visibleRange.length ?? 10;\n newRowIndex = Math.min(rowCount - 1, rowIndex + pageSize);\n } else {\n newRowIndex = Math.min(rowCount - 1, rowIndex + 10);\n }\n break;\n }\n\n if (newRowIndex !== rowIndex || newColumnId !== columnId) {\n const rowDiff = newRowIndex - rowIndex;\n\n // For single-row vertical navigation (up/down arrows)\n if (\n Math.abs(rowDiff) === 1 &&\n (direction === \"up\" || direction === \"down\")\n ) {\n const container = dataGridRef.current;\n const currentRow = rowMapRef.current.get(rowIndex);\n const targetRow = rowMapRef.current.get(newRowIndex);\n\n if (!container || !currentRow) {\n // Fallback to simple focus if we can't find elements\n focusCell(newRowIndex, newColumnId);\n return;\n }\n\n // Check viewport boundaries\n const containerRect = container.getBoundingClientRect();\n const headerHeight =\n headerRef.current?.getBoundingClientRect().height ?? 0;\n const footerHeight =\n footerRef.current?.getBoundingClientRect().height ?? 0;\n\n const viewportTop =\n containerRect.top + headerHeight + VIEWPORT_OFFSET;\n const viewportBottom =\n containerRect.bottom - footerHeight - VIEWPORT_OFFSET;\n\n // If target row already exists, check if it's visible\n if (targetRow) {\n const rowRect = targetRow.getBoundingClientRect();\n const isFullyVisible =\n rowRect.top >= viewportTop && rowRect.bottom <= viewportBottom;\n\n if (isFullyVisible) {\n // Row is fully visible, just focus it\n focusCell(newRowIndex, newColumnId);\n return;\n }\n\n // Row exists but not fully visible, scroll it into view\n focusCell(newRowIndex, newColumnId);\n\n if (direction === \"down\") {\n // Scroll just enough to show the row at the bottom\n const scrollNeeded = rowRect.bottom - viewportBottom;\n container.scrollTop += scrollNeeded;\n } else {\n // Scroll just enough to show the row at the top\n const scrollNeeded = viewportTop - rowRect.top;\n container.scrollTop -= scrollNeeded;\n }\n return;\n }\n\n // Target row is not rendered yet\n // Focus immediately so the ring appears as the row is revealed\n focusCell(newRowIndex, newColumnId);\n\n // Scroll by exactly one row height to reveal it smoothly\n if (direction === \"down\") {\n container.scrollTop += rowHeightValue;\n } else {\n // For arrow up, ensure we don't go below 0\n const currentScrollTop = container.scrollTop;\n const targetScrollTop = Math.max(\n 0,\n currentScrollTop - rowHeightValue,\n );\n container.scrollTop = targetScrollTop;\n }\n return;\n }\n\n // For larger jumps (page up/down, ctrl+home/end, etc.)\n if (rowVirtualizer && Math.abs(rowDiff) > 1) {\n const align =\n direction === \"pageup\" || direction === \"ctrl+home\"\n ? \"start\"\n : direction === \"pagedown\" || direction === \"ctrl+end\"\n ? \"end\"\n : \"center\";\n rowVirtualizer.scrollToIndex(newRowIndex, { align });\n requestAnimationFrame(() => {\n focusCell(newRowIndex, newColumnId);\n });\n return;\n }\n\n // For horizontal navigation or when row is already visible\n focusCell(newRowIndex, newColumnId);\n }\n },\n [store, navigableColumnIds, focusCell, data.length, rowHeightValue],\n );\n\n const onCellEditingStart = React.useCallback(\n (rowIndex: number, columnId: string) => {\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", { rowIndex, columnId });\n });\n },\n [store],\n );\n\n const onCellEditingStop = React.useCallback(\n (opts?: { moveToNextRow?: boolean; direction?: NavigationDirection }) => {\n const currentState = store.getState();\n const currentEditing = currentState.editingCell;\n\n store.setState(\"editingCell\", null);\n\n if (opts?.moveToNextRow && currentEditing) {\n const { rowIndex, columnId } = currentEditing;\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n const nextRowIndex = rowIndex + 1;\n if (nextRowIndex < rowCount) {\n requestAnimationFrame(() => {\n focusCell(nextRowIndex, columnId);\n });\n }\n } else if (opts?.direction && currentEditing) {\n // Focus the current editing cell first, then navigate\n const { rowIndex, columnId } = currentEditing;\n focusCell(rowIndex, columnId);\n requestAnimationFrame(() => {\n navigateCell(opts.direction ?? \"right\");\n });\n }\n },\n [store, data.length, focusCell, navigateCell],\n );\n\n const onSearchOpenChange = React.useCallback(\n (open: boolean) => {\n if (open) {\n store.setState(\"searchOpen\", true);\n return;\n }\n\n const currentState = store.getState();\n const currentMatch =\n currentState.matchIndex >= 0 &&\n currentState.searchMatches[currentState.matchIndex];\n\n store.batch(() => {\n store.setState(\"searchOpen\", false);\n store.setState(\"searchQuery\", \"\");\n store.setState(\"searchMatches\", []);\n store.setState(\"matchIndex\", -1);\n\n if (currentMatch) {\n store.setState(\"focusedCell\", {\n rowIndex: currentMatch.rowIndex,\n columnId: currentMatch.columnId,\n });\n }\n });\n\n if (\n dataGridRef.current &&\n document.activeElement !== dataGridRef.current\n ) {\n dataGridRef.current.focus();\n }\n },\n [store],\n );\n\n const onSearch = React.useCallback(\n (query: string) => {\n if (!query.trim()) {\n store.batch(() => {\n store.setState(\"searchMatches\", []);\n store.setState(\"matchIndex\", -1);\n });\n return;\n }\n\n const matches: CellPosition[] = [];\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n\n const lowerQuery = query.toLowerCase();\n\n for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {\n const row = rows[rowIndex];\n if (!row) continue;\n\n for (const columnId of columnIds) {\n const cell = row\n .getVisibleCells()\n .find((c) => c.column.id === columnId);\n if (!cell) continue;\n\n const value = cell.getValue();\n const stringValue = String(value ?? \"\").toLowerCase();\n\n if (stringValue.includes(lowerQuery)) {\n matches.push({ rowIndex, columnId });\n }\n }\n }\n\n store.batch(() => {\n store.setState(\"searchMatches\", matches);\n store.setState(\"matchIndex\", matches.length > 0 ? 0 : -1);\n });\n\n // Scroll to first match but don't focus it (to keep focus in search input)\n if (matches.length > 0 && matches[0]) {\n const firstMatch = matches[0];\n rowVirtualizerRef.current?.scrollToIndex(firstMatch.rowIndex, {\n align: \"center\",\n });\n }\n },\n [columnIds, store],\n );\n\n const onSearchQueryChange = React.useCallback(\n (query: string) => store.setState(\"searchQuery\", query),\n [store],\n );\n\n const onNavigateToPrevMatch = React.useCallback(() => {\n const currentState = store.getState();\n if (currentState.searchMatches.length === 0) return;\n\n const prevIndex =\n currentState.matchIndex - 1 < 0\n ? currentState.searchMatches.length - 1\n : currentState.matchIndex - 1;\n const match = currentState.searchMatches[prevIndex];\n\n if (match) {\n rowVirtualizerRef.current?.scrollToIndex(match.rowIndex, {\n align: \"center\",\n });\n\n requestAnimationFrame(() => {\n store.setState(\"matchIndex\", prevIndex);\n requestAnimationFrame(() => {\n focusCell(match.rowIndex, match.columnId);\n });\n });\n }\n }, [store, focusCell]);\n\n const onNavigateToNextMatch = React.useCallback(() => {\n const currentState = store.getState();\n if (currentState.searchMatches.length === 0) return;\n\n const nextIndex =\n (currentState.matchIndex + 1) % currentState.searchMatches.length;\n const match = currentState.searchMatches[nextIndex];\n\n if (match) {\n rowVirtualizerRef.current?.scrollToIndex(match.rowIndex, {\n align: \"center\",\n });\n\n requestAnimationFrame(() => {\n store.setState(\"matchIndex\", nextIndex);\n requestAnimationFrame(() => {\n focusCell(match.rowIndex, match.columnId);\n });\n });\n }\n }, [store, focusCell]);\n\n const getIsSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return searchMatches.some(\n (match) => match.rowIndex === rowIndex && match.columnId === columnId,\n );\n },\n [searchMatches],\n );\n\n const getIsActiveSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n if (matchIndex < 0) return false;\n const currentMatch = searchMatches[matchIndex];\n return (\n currentMatch?.rowIndex === rowIndex &&\n currentMatch?.columnId === columnId\n );\n },\n [searchMatches, matchIndex],\n );\n\n const blurCell = React.useCallback(() => {\n const currentState = store.getState();\n if (\n currentState.editingCell &&\n document.activeElement instanceof HTMLElement\n ) {\n document.activeElement.blur();\n }\n\n store.batch(() => {\n store.setState(\"focusedCell\", null);\n store.setState(\"editingCell\", null);\n });\n }, [store]);\n\n const onCellClick = React.useCallback(\n (rowIndex: number, columnId: string, event?: React.MouseEvent) => {\n // Ignore right-click (button 2) - let onCellContextMenu handle it\n if (event?.button === 2) {\n return;\n }\n\n const currentState = store.getState();\n const currentFocused = currentState.focusedCell;\n\n if (event) {\n if (event.ctrlKey || event.metaKey) {\n event.preventDefault();\n const cellKey = getCellKey(rowIndex, columnId);\n const newSelectedCells = new Set(\n currentState.selectionState.selectedCells,\n );\n\n if (newSelectedCells.has(cellKey)) {\n newSelectedCells.delete(cellKey);\n } else {\n newSelectedCells.add(cellKey);\n }\n\n store.setState(\"selectionState\", {\n selectedCells: newSelectedCells,\n selectionRange: null,\n isSelecting: false,\n });\n focusCell(rowIndex, columnId);\n return;\n }\n\n if (event.shiftKey && currentState.focusedCell) {\n event.preventDefault();\n selectRange(currentState.focusedCell, { rowIndex, columnId });\n return;\n }\n }\n\n // Clear selection if there are selected cells or rows\n const hasSelectedCells =\n currentState.selectionState.selectedCells.size > 0;\n const hasSelectedRows = Object.keys(currentState.rowSelection).length > 0;\n\n if (hasSelectedCells && !currentState.selectionState.isSelecting) {\n // If there's a cell selection but we're not actively selecting (drag just finished),\n // don't clear it - keep the selection\n // Only clear if clicking elsewhere\n const cellKey = getCellKey(rowIndex, columnId);\n const isClickingSelectedCell =\n currentState.selectionState.selectedCells.has(cellKey);\n\n if (!isClickingSelectedCell) {\n clearSelection();\n } else {\n // Clicking on an already selected cell - just focus it\n focusCell(rowIndex, columnId);\n return;\n }\n } else if (hasSelectedRows && columnId !== \"select\") {\n // If there are selected rows but we're clicking on a non-checkbox cell, clear selections\n clearSelection();\n }\n\n if (\n currentFocused?.rowIndex === rowIndex &&\n currentFocused?.columnId === columnId\n ) {\n onCellEditingStart(rowIndex, columnId);\n } else {\n focusCell(rowIndex, columnId);\n }\n },\n [store, focusCell, onCellEditingStart, selectRange, clearSelection],\n );\n\n const onCellDoubleClick = React.useCallback(\n (rowIndex: number, columnId: string, event?: React.MouseEvent) => {\n if (event?.defaultPrevented) return;\n\n onCellEditingStart(rowIndex, columnId);\n },\n [onCellEditingStart],\n );\n\n const onCellMouseDown = React.useCallback(\n (rowIndex: number, columnId: string, event: React.MouseEvent) => {\n // Ignore right-click (button 2) - let onCellContextMenu handle it\n if (event.button === 2) {\n return;\n }\n\n event.preventDefault();\n\n // Only start drag selection if no modifier keys are pressed\n // Clear any existing selection and prepare for potential drag\n if (!event.ctrlKey && !event.metaKey && !event.shiftKey) {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: {\n start: { rowIndex, columnId },\n end: { rowIndex, columnId },\n },\n isSelecting: true,\n });\n store.setState(\"rowSelection\", {});\n });\n }\n },\n [store],\n );\n\n const onCellMouseEnter = React.useCallback(\n (rowIndex: number, columnId: string, _event: React.MouseEvent) => {\n const currentState = store.getState();\n if (\n currentState.selectionState.isSelecting &&\n currentState.selectionState.selectionRange\n ) {\n const start = currentState.selectionState.selectionRange.start;\n const end = { rowIndex, columnId };\n\n if (\n currentState.focusedCell?.rowIndex !== start.rowIndex ||\n currentState.focusedCell?.columnId !== start.columnId\n ) {\n focusCell(start.rowIndex, start.columnId);\n }\n\n selectRange(start, end, true);\n }\n },\n [store, selectRange, focusCell],\n );\n\n const onCellMouseUp = React.useCallback(() => {\n const currentState = store.getState();\n store.setState(\"selectionState\", {\n ...currentState.selectionState,\n isSelecting: false,\n });\n }, [store]);\n\n const onCellContextMenu = React.useCallback(\n (rowIndex: number, columnId: string, event: React.MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n\n const currentState = store.getState();\n const cellKey = getCellKey(rowIndex, columnId);\n const isTargetCellSelected =\n currentState.selectionState.selectedCells.has(cellKey);\n\n // If right-clicking on a non-selected cell, select only that cell\n if (!isTargetCellSelected) {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set([cellKey]),\n selectionRange: {\n start: { rowIndex, columnId },\n end: { rowIndex, columnId },\n },\n isSelecting: false,\n });\n store.setState(\"focusedCell\", { rowIndex, columnId });\n });\n }\n\n // Open context menu at cursor position\n store.setState(\"contextMenu\", {\n open: true,\n x: event.clientX,\n y: event.clientY,\n });\n },\n [store],\n );\n\n const onContextMenuOpenChange = React.useCallback(\n (open: boolean) => {\n if (!open) {\n const currentMenu = store.getState().contextMenu;\n store.setState(\"contextMenu\", {\n open: false,\n x: currentMenu.x,\n y: currentMenu.y,\n });\n }\n },\n [store],\n );\n\n const onDataGridKeyDown = React.useCallback(\n (event: KeyboardEvent) => {\n const currentState = store.getState();\n const { key, ctrlKey, metaKey, shiftKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n // Handle Cmd+F / Ctrl+F to open search (highest priority, works even when editing)\n if (enableSearch && isCtrlPressed && key === SEARCH_SHORTCUT_KEY) {\n event.preventDefault();\n onSearchOpenChange(true);\n return;\n }\n\n // Handle search navigation when search is open\n if (\n enableSearch &&\n currentState.searchOpen &&\n !currentState.editingCell\n ) {\n if (key === \"Enter\") {\n event.preventDefault();\n if (shiftKey) {\n onNavigateToPrevMatch();\n } else {\n onNavigateToNextMatch();\n }\n return;\n }\n if (key === \"Escape\") {\n event.preventDefault();\n onSearchOpenChange(false);\n return;\n }\n // When search is open, don't let data grid handle any other keys\n // (they should only affect the search input)\n return;\n }\n\n if (currentState.editingCell) return;\n\n if (!currentState.focusedCell) return;\n\n let direction: NavigationDirection | null = null;\n\n if (isCtrlPressed && key === \"a\") {\n event.preventDefault();\n selectAll();\n return;\n }\n\n if (key === \"Delete\" || key === \"Backspace\") {\n if (currentState.selectionState.selectedCells.size > 0) {\n event.preventDefault();\n const updates: Array<{\n rowIndex: number;\n columnId: string;\n value: unknown;\n }> = [];\n\n currentState.selectionState.selectedCells.forEach((cellKey) => {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n updates.push({ rowIndex, columnId, value: \"\" });\n });\n\n onDataUpdate(updates);\n clearSelection();\n }\n return;\n }\n\n switch (key) {\n case \"ArrowUp\":\n direction = \"up\";\n break;\n case \"ArrowDown\":\n direction = \"down\";\n break;\n case \"ArrowLeft\":\n direction = \"left\";\n break;\n case \"ArrowRight\":\n direction = \"right\";\n break;\n case \"Home\":\n direction = isCtrlPressed ? \"ctrl+home\" : \"home\";\n break;\n case \"End\":\n direction = isCtrlPressed ? \"ctrl+end\" : \"end\";\n break;\n case \"PageUp\":\n direction = \"pageup\";\n break;\n case \"PageDown\":\n direction = \"pagedown\";\n break;\n case \"Escape\":\n event.preventDefault();\n if (\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0\n ) {\n clearSelection();\n } else {\n blurCell();\n }\n return;\n case \"Tab\":\n event.preventDefault();\n direction = event.shiftKey ? \"left\" : \"right\";\n break;\n }\n\n if (direction) {\n event.preventDefault();\n\n // Tab navigation should not trigger selection, even with Shift\n if (shiftKey && key !== \"Tab\" && currentState.focusedCell) {\n const currentColIndex = navigableColumnIds.indexOf(\n currentState.focusedCell.columnId,\n );\n let newRowIndex = currentState.focusedCell.rowIndex;\n let newColumnId = currentState.focusedCell.columnId;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, currentState.focusedCell.rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(\n (tableRef.current?.getRowModel().rows.length || data.length) -\n 1,\n currentState.focusedCell.rowIndex + 1,\n );\n break;\n case \"left\":\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n break;\n case \"right\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n break;\n }\n\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n selectRange(selectionStart, {\n rowIndex: newRowIndex,\n columnId: newColumnId,\n });\n focusCell(newRowIndex, newColumnId);\n } else {\n if (currentState.selectionState.selectedCells.size > 0) {\n clearSelection();\n }\n navigateCell(direction);\n }\n }\n },\n [\n store,\n blurCell,\n navigateCell,\n selectAll,\n onDataUpdate,\n clearSelection,\n navigableColumnIds,\n data.length,\n selectRange,\n focusCell,\n onSearchOpenChange,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n enableSearch,\n ],\n );\n\n const onSortingChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newSorting =\n typeof updater === \"function\" ? updater(currentState.sorting) : updater;\n store.setState(\"sorting\", newSorting);\n },\n [store],\n );\n\n const onRowSelectionChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newRowSelection =\n typeof updater === \"function\"\n ? updater(currentState.rowSelection)\n : updater;\n\n const selectedRows = Object.keys(newRowSelection).filter(\n (key) => newRowSelection[key],\n );\n\n const selectedCells = new Set();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n\n for (const rowId of selectedRows) {\n const rowIndex = rows.findIndex((r) => r.id === rowId);\n if (rowIndex === -1) continue;\n\n for (const columnId of columnIds) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n }\n\n store.batch(() => {\n store.setState(\"rowSelection\", newRowSelection);\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"focusedCell\", null);\n store.setState(\"editingCell\", null);\n });\n },\n [store, columnIds],\n );\n\n const onRowSelect = React.useCallback(\n (rowIndex: number, selected: boolean, shiftKey: boolean) => {\n const currentState = store.getState();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n const currentRow = rows[rowIndex];\n if (!currentRow) return;\n\n if (shiftKey && currentState.lastClickedRowIndex !== null) {\n const startIndex = Math.min(currentState.lastClickedRowIndex, rowIndex);\n const endIndex = Math.max(currentState.lastClickedRowIndex, rowIndex);\n\n const newRowSelection: RowSelectionState = {\n ...currentState.rowSelection,\n };\n\n for (let i = startIndex; i <= endIndex; i++) {\n const row = rows[i];\n if (row) {\n newRowSelection[row.id] = selected;\n }\n }\n\n onRowSelectionChange(newRowSelection);\n } else {\n onRowSelectionChange({\n ...currentState.rowSelection,\n [currentRow.id]: selected,\n });\n }\n\n store.setState(\"lastClickedRowIndex\", rowIndex);\n },\n [store, onRowSelectionChange],\n );\n\n const onRowHeightChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newRowHeight =\n typeof updater === \"function\"\n ? updater(currentState.rowHeight)\n : updater;\n store.setState(\"rowHeight\", newRowHeight);\n },\n [store],\n );\n\n const onColumnClick = React.useCallback(\n (columnId: string) => {\n if (!enableColumnSelection) {\n clearSelection();\n return;\n }\n\n selectColumn(columnId);\n },\n [enableColumnSelection, selectColumn, clearSelection],\n );\n\n const defaultColumn: Partial> = React.useMemo(\n () => ({\n cell: DataGridCell,\n minSize: MIN_COLUMN_SIZE,\n maxSize: MAX_COLUMN_SIZE,\n }),\n [],\n );\n\n const tableOptions = React.useMemo>(\n () => ({\n ...dataGridPropsRef.current,\n data,\n columns,\n defaultColumn,\n initialState,\n state: {\n ...dataGridPropsRef.current.state,\n sorting,\n rowSelection,\n },\n onRowSelectionChange,\n onSortingChange,\n columnResizeMode: \"onChange\",\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n meta: {\n ...dataGridPropsRef.current.meta,\n dataGridRef,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n rowHeight,\n isScrolling,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n onRowHeightChange,\n onRowSelect,\n onRowsDelete: onRowsDeleteProp ? onRowsDelete : undefined,\n onDataUpdate,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n contextMenu,\n onContextMenuOpenChange,\n },\n }),\n [\n dataGridPropsRef,\n data,\n columns,\n defaultColumn,\n initialState,\n sorting,\n rowSelection,\n onRowSelectionChange,\n onSortingChange,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n isScrolling,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n onDataUpdate,\n onRowsDeleteProp,\n onRowsDelete,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n contextMenu,\n onContextMenuOpenChange,\n rowHeight,\n onRowHeightChange,\n onRowSelect,\n ],\n );\n\n const table = useReactTable(tableOptions);\n\n if (!tableRef.current) {\n tableRef.current = table;\n }\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: we need to memoize the column size vars\n const columnSizeVars = React.useMemo(() => {\n const headers = table.getFlatHeaders();\n const colSizes: { [key: string]: number } = {};\n for (const header of headers) {\n colSizes[`--header-${header.id}-size`] = header.getSize();\n colSizes[`--col-${header.column.id}-size`] = header.column.getSize();\n }\n return colSizes;\n }, [table.getState().columnSizingInfo, table.getState().columnSizing]);\n\n const rowVirtualizer = useVirtualizer({\n count: table.getRowModel().rows.length,\n getScrollElement: () => dataGridRef.current,\n estimateSize: () => rowHeightValue,\n overscan,\n measureElement:\n typeof window !== \"undefined\" &&\n navigator.userAgent.indexOf(\"Firefox\") === -1\n ? (element) => element?.getBoundingClientRect().height\n : undefined,\n onChange: (instance) => {\n // Sync virtualizer's isScrolling state to our store\n const virtualizerIsScrolling = instance.isScrolling;\n const currentIsScrolling = store.getState().isScrolling;\n\n if (virtualizerIsScrolling !== currentIsScrolling) {\n store.setState(\"isScrolling\", virtualizerIsScrolling);\n }\n\n // Batch DOM updates in a single animation frame\n const virtualItems = instance.getVirtualItems();\n if (virtualItems.length === 0) return;\n\n requestAnimationFrame(() => {\n for (let i = 0; i < virtualItems.length; i++) {\n const virtualRow = virtualItems[i];\n if (!virtualRow) continue;\n const rowRef = rowMapRef.current.get(virtualRow.index);\n if (rowRef) {\n rowRef.style.transform = `translateY(${virtualRow.start}px)`;\n }\n }\n });\n },\n });\n\n if (!rowVirtualizerRef.current) {\n rowVirtualizerRef.current = rowVirtualizer;\n }\n\n const onScrollToRow = React.useCallback(\n async (opts: Partial) => {\n const rowIndex = opts?.rowIndex ?? 0;\n const columnId = opts?.columnId;\n\n rowVirtualizer.scrollToIndex(rowIndex, {\n align: \"center\",\n });\n\n const targetColumnId = columnId ?? navigableColumnIds[0];\n\n if (!targetColumnId) return;\n\n queueMicrotask(() => {\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n store.batch(() => {\n store.setState(\"focusedCell\", {\n rowIndex,\n columnId: targetColumnId,\n });\n store.setState(\"editingCell\", null);\n });\n });\n });\n });\n },\n [rowVirtualizer, navigableColumnIds, store],\n );\n\n const onRowAdd = React.useCallback(\n async (event?: React.MouseEvent) => {\n if (!onRowAddProp) return;\n\n const result = await onRowAddProp(event);\n\n if (event?.defaultPrevented || result === null) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n\n if (result) {\n const adjustedRowIndex =\n (result.rowIndex ?? 0) >= rows.length ? rows.length : result.rowIndex;\n\n onScrollToRow({\n rowIndex: adjustedRowIndex,\n columnId: result.columnId,\n });\n return;\n }\n\n onScrollToRow({ rowIndex: rows.length });\n },\n [onRowAddProp, onScrollToRow],\n );\n\n const searchState = React.useMemo(() => {\n if (!enableSearch) return undefined;\n\n return {\n searchMatches,\n matchIndex,\n searchOpen,\n onSearchOpenChange,\n searchQuery,\n onSearchQueryChange,\n onSearch,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n };\n }, [\n enableSearch,\n searchMatches,\n matchIndex,\n searchOpen,\n onSearchOpenChange,\n searchQuery,\n onSearchQueryChange,\n onSearch,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n ]);\n\n React.useEffect(() => {\n const dataGridElement = dataGridRef.current;\n if (!dataGridElement) return;\n\n dataGridElement.addEventListener(\"keydown\", onDataGridKeyDown);\n return () => {\n dataGridElement.removeEventListener(\"keydown\", onDataGridKeyDown);\n };\n }, [onDataGridKeyDown]);\n\n React.useEffect(() => {\n function onGlobalKeyDown(event: KeyboardEvent) {\n const dataGridElement = dataGridRef.current;\n if (!dataGridElement) return;\n\n const target = event.target;\n if (!(target instanceof HTMLElement)) return;\n\n const { key, ctrlKey, metaKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n if (enableSearch && isCtrlPressed && key === SEARCH_SHORTCUT_KEY) {\n const isInInput =\n target.tagName === \"INPUT\" || target.tagName === \"TEXTAREA\";\n const isInDataGrid = dataGridElement.contains(target);\n const isInSearchInput = target.closest('[role=\"search\"]') !== null;\n\n if (isInDataGrid || isInSearchInput || !isInInput) {\n event.preventDefault();\n event.stopPropagation();\n onSearchOpenChange(true);\n\n if (!isInDataGrid && !isInSearchInput) {\n requestAnimationFrame(() => {\n dataGridElement.focus();\n });\n }\n return;\n }\n }\n\n const isInDataGrid = dataGridElement.contains(target);\n if (!isInDataGrid) return;\n\n if (key === \"Escape\") {\n const currentState = store.getState();\n const hasSelections =\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0;\n\n if (hasSelections) {\n event.preventDefault();\n event.stopPropagation();\n clearSelection();\n }\n }\n }\n\n window.addEventListener(\"keydown\", onGlobalKeyDown, true);\n return () => {\n window.removeEventListener(\"keydown\", onGlobalKeyDown, true);\n };\n }, [enableSearch, onSearchOpenChange, store, clearSelection]);\n\n React.useEffect(() => {\n const currentState = store.getState();\n if (\n autoFocus &&\n data.length > 0 &&\n columns.length > 0 &&\n !currentState.focusedCell\n ) {\n if (navigableColumnIds.length > 0) {\n const rafId = requestAnimationFrame(() => {\n if (typeof autoFocus === \"object\") {\n const { rowIndex, columnId } = autoFocus;\n if (columnId) {\n focusCell(rowIndex ?? 0, columnId);\n }\n return;\n }\n\n const firstColumnId = navigableColumnIds[0];\n if (firstColumnId) {\n focusCell(0, firstColumnId);\n }\n });\n return () => cancelAnimationFrame(rafId);\n }\n }\n }, [\n autoFocus,\n data.length,\n columns.length,\n store,\n navigableColumnIds,\n focusCell,\n ]);\n\n React.useEffect(() => {\n function onOutsideClick(event: MouseEvent) {\n if (event.button === 2) {\n return;\n }\n\n if (\n dataGridRef.current &&\n !dataGridRef.current.contains(event.target as Node)\n ) {\n const target = event.target;\n const isInsidePopover =\n target instanceof HTMLElement &&\n (target.closest(\"[data-grid-cell-editor]\") ||\n target.closest(\"[data-grid-popover]\"));\n\n if (!isInsidePopover) {\n blurCell();\n const currentState = store.getState();\n if (\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0\n ) {\n clearSelection();\n }\n }\n }\n }\n\n document.addEventListener(\"mousedown\", onOutsideClick);\n return () => {\n document.removeEventListener(\"mousedown\", onOutsideClick);\n };\n }, [store, blurCell, clearSelection]);\n\n React.useEffect(() => {\n function onCleanup() {\n document.removeEventListener(\"selectstart\", preventSelection);\n document.removeEventListener(\"contextmenu\", preventContextMenu);\n document.body.style.userSelect = \"\";\n }\n\n function preventSelection(event: Event) {\n event.preventDefault();\n }\n function preventContextMenu(event: Event) {\n event.preventDefault();\n }\n\n const onUnsubscribe = store.subscribe(() => {\n const currentState = store.getState();\n if (currentState.selectionState.isSelecting) {\n document.addEventListener(\"selectstart\", preventSelection);\n document.addEventListener(\"contextmenu\", preventContextMenu);\n document.body.style.userSelect = \"none\";\n } else {\n onCleanup();\n }\n });\n\n return () => {\n onCleanup();\n onUnsubscribe();\n };\n }, [store]);\n\n useIsomorphicLayoutEffect(() => {\n const rafId = requestAnimationFrame(() => {\n rowVirtualizer.measure();\n });\n return () => cancelAnimationFrame(rafId);\n }, [\n table.getState().columnFilters,\n table.getState().columnOrder,\n table.getState().columnPinning,\n table.getState().columnSizing,\n table.getState().columnVisibility,\n table.getState().expanded,\n table.getState().globalFilter,\n table.getState().grouping,\n table.getState().rowSelection,\n table.getState().sorting,\n rowHeight,\n ]);\n\n return {\n dataGridRef,\n headerRef,\n rowMapRef,\n footerRef,\n table,\n rowVirtualizer,\n searchState,\n columnSizeVars,\n onRowAdd: onRowAddProp ? onRowAdd : undefined,\n };\n}\n", "type": "registry:hook" }, { @@ -101,7 +108,7 @@ }, { "path": "src/types/data-grid.ts", - "content": "import type { RowData } from \"@tanstack/react-table\";\n\nexport type RowHeightValue = \"short\" | \"medium\" | \"tall\" | \"extra-tall\";\n\nexport interface CellSelectOption {\n label: string;\n value: string;\n}\n\nexport type Cell =\n | {\n variant: \"short-text\";\n }\n | {\n variant: \"long-text\";\n }\n | {\n variant: \"number\";\n min?: number;\n max?: number;\n step?: number;\n }\n | {\n variant: \"select\";\n options: CellSelectOption[];\n }\n | {\n variant: \"multi-select\";\n options: CellSelectOption[];\n }\n | {\n variant: \"checkbox\";\n }\n | {\n variant: \"date\";\n };\n\nexport interface UpdateCell {\n rowIndex: number;\n columnId: string;\n value: unknown;\n}\n\ndeclare module \"@tanstack/react-table\" {\n // biome-ignore lint/correctness/noUnusedVariables: TData and TValue are used in the ColumnMeta interface\n interface ColumnMeta {\n label?: string;\n cell?: Cell;\n }\n\n // biome-ignore lint/correctness/noUnusedVariables: TData is used in the TableMeta interface\n interface TableMeta {\n dataGridRef?: React.RefObject;\n focusedCell?: CellPosition | null;\n editingCell?: CellPosition | null;\n selectionState?: SelectionState;\n searchOpen?: boolean;\n isScrolling?: boolean;\n getIsCellSelected?: (rowIndex: number, columnId: string) => boolean;\n getIsSearchMatch?: (rowIndex: number, columnId: string) => boolean;\n getIsActiveSearchMatch?: (rowIndex: number, columnId: string) => boolean;\n onDataUpdate?: (props: UpdateCell | Array) => void;\n onColumnClick?: (columnId: string) => void;\n onCellClick?: (\n rowIndex: number,\n columnId: string,\n event?: React.MouseEvent,\n ) => void;\n onCellDoubleClick?: (rowIndex: number, columnId: string) => void;\n onCellMouseDown?: (\n rowIndex: number,\n columnId: string,\n event: React.MouseEvent,\n ) => void;\n onCellMouseEnter?: (\n rowIndex: number,\n columnId: string,\n event: React.MouseEvent,\n ) => void;\n onCellMouseUp?: () => void;\n onCellContextMenu?: (\n rowIndex: number,\n columnId: string,\n event: React.MouseEvent,\n ) => void;\n onCellEditingStart?: (rowIndex: number, columnId: string) => void;\n onCellEditingStop?: (opts?: {\n direction?: NavigationDirection;\n moveToNextRow?: boolean;\n }) => void;\n contextMenu?: ContextMenuState;\n onContextMenuOpenChange?: (open: boolean) => void;\n rowHeight?: RowHeightValue;\n onRowHeightChange?: (value: RowHeightValue) => void;\n onRowSelect?: (\n rowIndex: number,\n checked: boolean,\n shiftKey: boolean,\n ) => void;\n }\n}\n\nexport interface CellPosition {\n rowIndex: number;\n columnId: string;\n}\n\nexport interface CellRange {\n start: CellPosition;\n end: CellPosition;\n}\n\nexport interface SelectionState {\n selectedCells: Set;\n selectionRange: CellRange | null;\n isSelecting: boolean;\n}\n\nexport interface ContextMenuState {\n open: boolean;\n x: number;\n y: number;\n}\n\nexport type NavigationDirection =\n | \"up\"\n | \"down\"\n | \"left\"\n | \"right\"\n | \"home\"\n | \"end\"\n | \"ctrl+home\"\n | \"ctrl+end\"\n | \"pageup\"\n | \"pagedown\";\n\nexport interface SearchState {\n searchMatches: CellPosition[];\n matchIndex: number;\n searchOpen: boolean;\n onSearchOpenChange: (open: boolean) => void;\n searchQuery: string;\n onSearchQueryChange: (query: string) => void;\n onSearch: (query: string) => void;\n onNavigateToNextMatch: () => void;\n onNavigateToPrevMatch: () => void;\n}\n", + "content": "import type { RowData } from \"@tanstack/react-table\";\n\nexport type RowHeightValue = \"short\" | \"medium\" | \"tall\" | \"extra-tall\";\n\nexport interface CellSelectOption {\n label: string;\n value: string;\n}\n\nexport type Cell =\n | {\n variant: \"short-text\";\n }\n | {\n variant: \"long-text\";\n }\n | {\n variant: \"number\";\n min?: number;\n max?: number;\n step?: number;\n }\n | {\n variant: \"select\";\n options: CellSelectOption[];\n }\n | {\n variant: \"multi-select\";\n options: CellSelectOption[];\n }\n | {\n variant: \"checkbox\";\n }\n | {\n variant: \"date\";\n };\n\nexport interface UpdateCell {\n rowIndex: number;\n columnId: string;\n value: unknown;\n}\n\ndeclare module \"@tanstack/react-table\" {\n // biome-ignore lint/correctness/noUnusedVariables: TData and TValue are used in the ColumnMeta interface\n interface ColumnMeta {\n label?: string;\n cell?: Cell;\n }\n\n // biome-ignore lint/correctness/noUnusedVariables: TData is used in the TableMeta interface\n interface TableMeta {\n dataGridRef?: React.RefObject;\n focusedCell?: CellPosition | null;\n editingCell?: CellPosition | null;\n selectionState?: SelectionState;\n searchOpen?: boolean;\n isScrolling?: boolean;\n getIsCellSelected?: (rowIndex: number, columnId: string) => boolean;\n getIsSearchMatch?: (rowIndex: number, columnId: string) => boolean;\n getIsActiveSearchMatch?: (rowIndex: number, columnId: string) => boolean;\n onDataUpdate?: (props: UpdateCell | Array) => void;\n onRowsDelete?: (rowIndices: number[]) => void | Promise;\n onColumnClick?: (columnId: string) => void;\n onCellClick?: (\n rowIndex: number,\n columnId: string,\n event?: React.MouseEvent,\n ) => void;\n onCellDoubleClick?: (rowIndex: number, columnId: string) => void;\n onCellMouseDown?: (\n rowIndex: number,\n columnId: string,\n event: React.MouseEvent,\n ) => void;\n onCellMouseEnter?: (\n rowIndex: number,\n columnId: string,\n event: React.MouseEvent,\n ) => void;\n onCellMouseUp?: () => void;\n onCellContextMenu?: (\n rowIndex: number,\n columnId: string,\n event: React.MouseEvent,\n ) => void;\n onCellEditingStart?: (rowIndex: number, columnId: string) => void;\n onCellEditingStop?: (opts?: {\n direction?: NavigationDirection;\n moveToNextRow?: boolean;\n }) => void;\n contextMenu?: ContextMenuState;\n onContextMenuOpenChange?: (open: boolean) => void;\n rowHeight?: RowHeightValue;\n onRowHeightChange?: (value: RowHeightValue) => void;\n onRowSelect?: (\n rowIndex: number,\n checked: boolean,\n shiftKey: boolean,\n ) => void;\n }\n}\n\nexport interface CellPosition {\n rowIndex: number;\n columnId: string;\n}\n\nexport interface CellRange {\n start: CellPosition;\n end: CellPosition;\n}\n\nexport interface SelectionState {\n selectedCells: Set;\n selectionRange: CellRange | null;\n isSelecting: boolean;\n}\n\nexport interface ContextMenuState {\n open: boolean;\n x: number;\n y: number;\n}\n\nexport type NavigationDirection =\n | \"up\"\n | \"down\"\n | \"left\"\n | \"right\"\n | \"home\"\n | \"end\"\n | \"ctrl+home\"\n | \"ctrl+end\"\n | \"pageup\"\n | \"pagedown\";\n\nexport interface SearchState {\n searchMatches: CellPosition[];\n matchIndex: number;\n searchOpen: boolean;\n onSearchOpenChange: (open: boolean) => void;\n searchQuery: string;\n onSearchQueryChange: (query: string) => void;\n onSearch: (query: string) => void;\n onNavigateToNextMatch: () => void;\n onNavigateToPrevMatch: () => void;\n}\n", "type": "registry:file", "target": "src/types/data-grid.ts" } diff --git a/src/components/data-grid/data-grid-demo.tsx b/src/components/data-grid/data-grid-demo.tsx index 4e2f6d8b..377635da 100644 --- a/src/components/data-grid/data-grid-demo.tsx +++ b/src/components/data-grid/data-grid-demo.tsx @@ -317,6 +317,7 @@ export function DataGridDemo() { columns, data, onDataChange: setData, + onRowAdd, onRowsDelete, getRowId: (row) => row.id, initialState: { @@ -340,12 +341,7 @@ export function DataGridDemo() { - + ); } diff --git a/src/components/data-grid/data-grid.tsx b/src/components/data-grid/data-grid.tsx index 24ec26e5..16db4172 100644 --- a/src/components/data-grid/data-grid.tsx +++ b/src/components/data-grid/data-grid.tsx @@ -11,18 +11,11 @@ import { DataGridSearch } from "@/components/data-grid/data-grid-search"; import type { useDataGrid } from "@/hooks/use-data-grid"; import { getCommonPinningStyles } from "@/lib/data-table"; import { cn } from "@/lib/utils"; -import type { CellPosition } from "@/types/data-grid"; interface DataGridProps extends ReturnType>, React.ComponentProps<"div"> { height?: number; - onRowAdd?: (event?: React.MouseEvent) => - | Partial - | Promise> - | null - // biome-ignore lint/suspicious/noConfusingVoidType: void is needed here to allow functions without explicit return - | void; } export function DataGrid({ @@ -35,8 +28,7 @@ export function DataGrid({ height = 600, searchState, columnSizeVars, - onScrollToRow, - onRowAdd: onRowAddProp, + onRowAdd, className, ...props }: DataGridProps) { @@ -54,40 +46,16 @@ export function DataGrid({ [], ); - const onRowAdd = React.useCallback( - async (event?: React.MouseEvent) => { - if (!onRowAddProp) return; - - const result = await onRowAddProp(); - - if (event?.defaultPrevented || result === null) return; - - if (result) { - const adjustedRowIndex = - (result.rowIndex ?? 0) >= rows.length ? rows.length : result.rowIndex; - - onScrollToRow({ - rowIndex: adjustedRowIndex, - columnId: result.columnId, - }); - return; - } - - onScrollToRow({ rowIndex: rows.length }); - }, - [onRowAddProp, onScrollToRow, rows.length], - ); - const onAddRowKeyDown = React.useCallback( (event: React.KeyboardEvent) => { - if (!onRowAddProp) return; + if (!onRowAdd) return; if (event.key === "Enter" || event.key === " ") { event.preventDefault(); onRowAdd(); } }, - [onRowAddProp, onRowAdd], + [onRowAdd], ); return ( @@ -102,7 +70,7 @@ export function DataGrid({
( interface UseDataGridProps extends Omit, "pageCount" | "getCoreRowModel"> { onDataChange?: (data: TData[]) => void; + onRowAdd?: (event?: React.MouseEvent) => + | Partial + | Promise> + | null + // biome-ignore lint/suspicious/noConfusingVoidType: void is needed here to allow functions without explicit return + | void; onRowsDelete?: (props: { rows: TData[]; rowIndices: number[]; @@ -110,6 +116,7 @@ export function useDataGrid({ columns, data, onDataChange, + onRowAdd: onRowAddProp, onRowsDelete: onRowsDeleteProp, rowHeight: rowHeightProp = DEFAULT_ROW_HEIGHT, overscan = OVERSCAN, @@ -1523,6 +1530,33 @@ export function useDataGrid({ [rowVirtualizer, navigableColumnIds, store], ); + const onRowAdd = React.useCallback( + async (event?: React.MouseEvent) => { + if (!onRowAddProp) return; + + const result = await onRowAddProp(event); + + if (event?.defaultPrevented || result === null) return; + + const currentTable = tableRef.current; + const rows = currentTable?.getRowModel().rows ?? []; + + if (result) { + const adjustedRowIndex = + (result.rowIndex ?? 0) >= rows.length ? rows.length : result.rowIndex; + + onScrollToRow({ + rowIndex: adjustedRowIndex, + columnId: result.columnId, + }); + return; + } + + onScrollToRow({ rowIndex: rows.length }); + }, + [onRowAddProp, onScrollToRow], + ); + const searchState = React.useMemo(() => { if (!enableSearch) return undefined; @@ -1743,6 +1777,6 @@ export function useDataGrid({ rowVirtualizer, searchState, columnSizeVars, - onScrollToRow, + onRowAdd: onRowAddProp ? onRowAdd : undefined, }; } From 14282c156e355f3aabbec04545c3e31e7faf6305 Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Mon, 27 Oct 2025 18:18:05 +0600 Subject: [PATCH 8/8] feat: update demo --- public/r/data-grid.json | 2 +- src/components/data-grid/data-grid-demo.tsx | 44 ++++++++++++--------- src/hooks/use-data-grid.tsx | 14 +++---- 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/public/r/data-grid.json b/public/r/data-grid.json index 253f4570..672083bb 100644 --- a/public/r/data-grid.json +++ b/public/r/data-grid.json @@ -88,7 +88,7 @@ }, { "path": "src/hooks/use-data-grid.tsx", - "content": "\"use client\";\n\nimport {\n type ColumnDef,\n getCoreRowModel,\n getSortedRowModel,\n type RowSelectionState,\n type SortingState,\n type TableOptions,\n type Updater,\n useReactTable,\n} from \"@tanstack/react-table\";\nimport { useVirtualizer, type Virtualizer } from \"@tanstack/react-virtual\";\nimport * as React from \"react\";\nimport { DataGridCell } from \"@/components/data-grid/data-grid-cell\";\nimport { getCellKey, getRowHeightValue, parseCellKey } from \"@/lib/data-grid\";\nimport type {\n CellPosition,\n ContextMenuState,\n NavigationDirection,\n RowHeightValue,\n SearchState,\n SelectionState,\n UpdateCell,\n} from \"@/types/data-grid\";\n\nconst DEFAULT_ROW_HEIGHT = \"short\";\nconst OVERSCAN = 3;\nconst VIEWPORT_OFFSET = 1;\nconst MIN_COLUMN_SIZE = 60;\nconst MAX_COLUMN_SIZE = 800;\nconst SEARCH_SHORTCUT_KEY = \"f\";\nconst NON_NAVIGABLE_COLUMN_IDS = [\"select\", \"actions\"];\n\nconst useIsomorphicLayoutEffect =\n typeof window !== \"undefined\" ? React.useLayoutEffect : React.useEffect;\n\nfunction useLazyRef(fn: () => T): React.RefObject {\n const ref = React.useRef(null);\n if (ref.current === null) {\n ref.current = fn();\n }\n return ref as React.RefObject;\n}\n\nfunction useAsRef(data: T) {\n const ref = React.useRef(data);\n\n useIsomorphicLayoutEffect(() => {\n ref.current = data;\n });\n\n return ref;\n}\n\ninterface DataGridState {\n sorting: SortingState;\n rowHeight: RowHeightValue;\n rowSelection: RowSelectionState;\n selectionState: SelectionState;\n focusedCell: CellPosition | null;\n editingCell: CellPosition | null;\n contextMenu: ContextMenuState;\n searchQuery: string;\n searchMatches: CellPosition[];\n matchIndex: number;\n searchOpen: boolean;\n lastClickedRowIndex: number | null;\n isScrolling: boolean;\n}\n\ninterface DataGridStore {\n subscribe: (callback: () => void) => () => void;\n getState: () => DataGridState;\n setState: (\n key: K,\n value: DataGridState[K],\n ) => void;\n notify: () => void;\n batch: (fn: () => void) => void;\n}\n\nfunction useStore(\n store: DataGridStore,\n selector: (state: DataGridState) => T,\n): T {\n const getSnapshot = React.useCallback(\n () => selector(store.getState()),\n [store, selector],\n );\n\n return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);\n}\n\ninterface UseDataGridProps\n extends Omit, \"pageCount\" | \"getCoreRowModel\"> {\n onDataChange?: (data: TData[]) => void;\n onRowAdd?: (event?: React.MouseEvent) =>\n | Partial\n | Promise>\n | null\n // biome-ignore lint/suspicious/noConfusingVoidType: void is needed here to allow functions without explicit return\n | void;\n onRowsDelete?: (props: {\n rows: TData[];\n rowIndices: number[];\n }) => void | Promise;\n rowHeight?: RowHeightValue;\n overscan?: number;\n autoFocus?: boolean | Partial;\n enableColumnSelection?: boolean;\n enableSearch?: boolean;\n}\n\nexport function useDataGrid({\n columns,\n data,\n onDataChange,\n onRowAdd: onRowAddProp,\n onRowsDelete: onRowsDeleteProp,\n rowHeight: rowHeightProp = DEFAULT_ROW_HEIGHT,\n overscan = OVERSCAN,\n initialState,\n autoFocus = false,\n enableColumnSelection = false,\n enableSearch = false,\n ...dataGridProps\n}: UseDataGridProps) {\n const dataGridRef = React.useRef(null);\n const tableRef = React.useRef>>(null);\n const rowVirtualizerRef =\n React.useRef>(null);\n const headerRef = React.useRef(null);\n const rowMapRef = React.useRef>(new Map());\n const footerRef = React.useRef(null);\n\n const dataGridPropsRef = useAsRef(dataGridProps);\n const listenersRef = useLazyRef(() => new Set<() => void>());\n\n const stateRef = useLazyRef(() => {\n return {\n sorting: initialState?.sorting ?? [],\n rowHeight: rowHeightProp,\n rowSelection: initialState?.rowSelection ?? {},\n selectionState: {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n },\n focusedCell: null,\n editingCell: null,\n contextMenu: {\n open: false,\n x: 0,\n y: 0,\n },\n searchQuery: \"\",\n searchMatches: [],\n matchIndex: -1,\n searchOpen: false,\n lastClickedRowIndex: null,\n isScrolling: false,\n };\n });\n\n const store = React.useMemo(() => {\n let isBatching = false;\n let pendingNotification = false;\n\n return {\n subscribe: (callback) => {\n listenersRef.current.add(callback);\n return () => listenersRef.current.delete(callback);\n },\n getState: () => stateRef.current,\n setState: (key, value) => {\n if (Object.is(stateRef.current[key], value)) return;\n stateRef.current[key] = value;\n\n if (isBatching) {\n pendingNotification = true;\n } else {\n if (!pendingNotification) {\n pendingNotification = true;\n queueMicrotask(() => {\n pendingNotification = false;\n store.notify();\n });\n }\n }\n },\n notify: () => {\n for (const listener of listenersRef.current) {\n listener();\n }\n },\n batch: (fn) => {\n if (isBatching) {\n fn();\n return;\n }\n\n isBatching = true;\n const wasPending = pendingNotification;\n pendingNotification = false;\n\n try {\n fn();\n } finally {\n isBatching = false;\n if (pendingNotification || wasPending) {\n pendingNotification = false;\n store.notify();\n }\n }\n },\n };\n }, [listenersRef, stateRef]);\n\n const focusedCell = useStore(store, (state) => state.focusedCell);\n const editingCell = useStore(store, (state) => state.editingCell);\n const selectionState = useStore(store, (state) => state.selectionState);\n const searchQuery = useStore(store, (state) => state.searchQuery);\n const searchMatches = useStore(store, (state) => state.searchMatches);\n const matchIndex = useStore(store, (state) => state.matchIndex);\n const searchOpen = useStore(store, (state) => state.searchOpen);\n const sorting = useStore(store, (state) => state.sorting);\n const rowSelection = useStore(store, (state) => state.rowSelection);\n const contextMenu = useStore(store, (state) => state.contextMenu);\n const rowHeight = useStore(store, (state) => state.rowHeight);\n const isScrolling = useStore(store, (state) => state.isScrolling);\n\n const rowHeightValue = getRowHeightValue(rowHeight);\n\n const columnIds = React.useMemo(() => {\n return columns\n .map((c) => {\n if (c.id) return c.id;\n if (\"accessorKey\" in c) return c.accessorKey as string;\n return undefined;\n })\n .filter((id): id is string => Boolean(id));\n }, [columns]);\n\n const navigableColumnIds = React.useMemo(() => {\n return columnIds.filter((c) => !NON_NAVIGABLE_COLUMN_IDS.includes(c));\n }, [columnIds]);\n\n const onDataUpdate = React.useCallback(\n (updates: UpdateCell | Array) => {\n const updateArray = Array.isArray(updates) ? updates : [updates];\n\n if (updateArray.length === 0) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n\n const rowUpdatesMap = new Map<\n number,\n Array>\n >();\n\n for (const update of updateArray) {\n if (!rows || !currentTable) {\n const existingUpdates = rowUpdatesMap.get(update.rowIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(update.rowIndex, existingUpdates);\n } else {\n const row = rows[update.rowIndex];\n if (!row) continue;\n\n const originalData = row.original;\n const originalRowIndex = data.indexOf(originalData);\n if (originalRowIndex === -1) continue;\n\n const existingUpdates = rowUpdatesMap.get(originalRowIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(originalRowIndex, existingUpdates);\n }\n }\n\n const newData = data.map((row, index) => {\n const updates = rowUpdatesMap.get(index);\n if (!updates) return row;\n\n const updatedRow = { ...row } as Record;\n for (const { columnId, value } of updates) {\n updatedRow[columnId] = value;\n }\n return updatedRow as TData;\n });\n\n onDataChange?.(newData);\n },\n [data, onDataChange],\n );\n\n const getIsCellSelected = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return selectionState.selectedCells.has(getCellKey(rowIndex, columnId));\n },\n [selectionState.selectedCells],\n );\n\n const clearSelection = React.useCallback(() => {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"rowSelection\", {});\n });\n }, [store]);\n\n const selectAll = React.useCallback(() => {\n const allCells = new Set();\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {\n for (const columnId of columnIds) {\n allCells.add(getCellKey(rowIndex, columnId));\n }\n }\n\n const firstColumnId = columnIds[0];\n const lastColumnId = columnIds[columnIds.length - 1];\n\n store.setState(\"selectionState\", {\n selectedCells: allCells,\n selectionRange:\n columnIds.length > 0 && rowCount > 0 && firstColumnId && lastColumnId\n ? {\n start: { rowIndex: 0, columnId: firstColumnId },\n end: { rowIndex: rowCount - 1, columnId: lastColumnId },\n }\n : null,\n isSelecting: false,\n });\n }, [columnIds, data.length, store]);\n\n const selectColumn = React.useCallback(\n (columnId: string) => {\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n if (rowCount === 0) return;\n\n const selectedCells = new Set();\n\n for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: {\n start: { rowIndex: 0, columnId },\n end: { rowIndex: rowCount - 1, columnId },\n },\n isSelecting: false,\n });\n },\n [data.length, store],\n );\n\n const selectRange = React.useCallback(\n (start: CellPosition, end: CellPosition, isSelecting = false) => {\n const startColIndex = columnIds.indexOf(start.columnId);\n const endColIndex = columnIds.indexOf(end.columnId);\n\n const minRow = Math.min(start.rowIndex, end.rowIndex);\n const maxRow = Math.max(start.rowIndex, end.rowIndex);\n const minCol = Math.min(startColIndex, endColIndex);\n const maxCol = Math.max(startColIndex, endColIndex);\n\n const selectedCells = new Set();\n\n for (let rowIndex = minRow; rowIndex <= maxRow; rowIndex++) {\n for (let colIndex = minCol; colIndex <= maxCol; colIndex++) {\n const columnId = columnIds[colIndex];\n if (columnId) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n }\n }\n\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: { start, end },\n isSelecting,\n });\n },\n [columnIds, store],\n );\n\n const focusCell = React.useCallback(\n (rowIndex: number, columnId: string) => {\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", null);\n });\n\n const currentState = store.getState();\n\n if (currentState.searchOpen) return;\n\n if (\n dataGridRef.current &&\n document.activeElement !== dataGridRef.current\n ) {\n dataGridRef.current.focus();\n }\n },\n [store],\n );\n\n const onRowsDelete = React.useCallback(\n async (rowIndices: number[]) => {\n if (!onRowsDeleteProp || rowIndices.length === 0) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n\n if (!rows || rows.length === 0) return;\n\n const currentState = store.getState();\n const currentFocusedColumn =\n currentState.focusedCell?.columnId ?? navigableColumnIds[0];\n\n const minDeletedRowIndex = Math.min(...rowIndices);\n\n const rowsToDelete: TData[] = [];\n for (const rowIndex of rowIndices) {\n const row = rows[rowIndex];\n if (row) {\n rowsToDelete.push(row.original);\n }\n }\n\n await onRowsDeleteProp({\n rows: rowsToDelete,\n rowIndices,\n });\n\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"rowSelection\", {});\n store.setState(\"editingCell\", null);\n });\n\n requestAnimationFrame(() => {\n const currentTable = tableRef.current;\n const currentRows = currentTable?.getRowModel().rows ?? [];\n const newRowCount = currentRows.length ?? data.length;\n\n if (newRowCount > 0 && currentFocusedColumn) {\n const targetRowIndex = Math.min(minDeletedRowIndex, newRowCount - 1);\n focusCell(targetRowIndex, currentFocusedColumn);\n }\n });\n },\n [onRowsDeleteProp, data.length, store, navigableColumnIds, focusCell],\n );\n\n const navigateCell = React.useCallback(\n (direction: NavigationDirection) => {\n const currentState = store.getState();\n if (!currentState.focusedCell) return;\n\n const { rowIndex, columnId } = currentState.focusedCell;\n const currentColIndex = navigableColumnIds.indexOf(columnId);\n const rowVirtualizer = rowVirtualizerRef.current;\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n let newRowIndex = rowIndex;\n let newColumnId = columnId;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(rowCount - 1, rowIndex + 1);\n break;\n case \"left\":\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n break;\n case \"right\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n break;\n case \"home\":\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? columnId;\n }\n break;\n case \"end\":\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;\n }\n break;\n case \"ctrl+home\":\n newRowIndex = 0;\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? columnId;\n }\n break;\n case \"ctrl+end\":\n newRowIndex = Math.max(0, rowCount - 1);\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;\n }\n break;\n case \"pageup\":\n if (rowVirtualizer) {\n const visibleRange = rowVirtualizer.getVirtualItems();\n const pageSize = visibleRange.length ?? 10;\n newRowIndex = Math.max(0, rowIndex - pageSize);\n } else {\n newRowIndex = Math.max(0, rowIndex - 10);\n }\n break;\n case \"pagedown\":\n if (rowVirtualizer) {\n const visibleRange = rowVirtualizer.getVirtualItems();\n const pageSize = visibleRange.length ?? 10;\n newRowIndex = Math.min(rowCount - 1, rowIndex + pageSize);\n } else {\n newRowIndex = Math.min(rowCount - 1, rowIndex + 10);\n }\n break;\n }\n\n if (newRowIndex !== rowIndex || newColumnId !== columnId) {\n const rowDiff = newRowIndex - rowIndex;\n\n // For single-row vertical navigation (up/down arrows)\n if (\n Math.abs(rowDiff) === 1 &&\n (direction === \"up\" || direction === \"down\")\n ) {\n const container = dataGridRef.current;\n const currentRow = rowMapRef.current.get(rowIndex);\n const targetRow = rowMapRef.current.get(newRowIndex);\n\n if (!container || !currentRow) {\n // Fallback to simple focus if we can't find elements\n focusCell(newRowIndex, newColumnId);\n return;\n }\n\n // Check viewport boundaries\n const containerRect = container.getBoundingClientRect();\n const headerHeight =\n headerRef.current?.getBoundingClientRect().height ?? 0;\n const footerHeight =\n footerRef.current?.getBoundingClientRect().height ?? 0;\n\n const viewportTop =\n containerRect.top + headerHeight + VIEWPORT_OFFSET;\n const viewportBottom =\n containerRect.bottom - footerHeight - VIEWPORT_OFFSET;\n\n // If target row already exists, check if it's visible\n if (targetRow) {\n const rowRect = targetRow.getBoundingClientRect();\n const isFullyVisible =\n rowRect.top >= viewportTop && rowRect.bottom <= viewportBottom;\n\n if (isFullyVisible) {\n // Row is fully visible, just focus it\n focusCell(newRowIndex, newColumnId);\n return;\n }\n\n // Row exists but not fully visible, scroll it into view\n focusCell(newRowIndex, newColumnId);\n\n if (direction === \"down\") {\n // Scroll just enough to show the row at the bottom\n const scrollNeeded = rowRect.bottom - viewportBottom;\n container.scrollTop += scrollNeeded;\n } else {\n // Scroll just enough to show the row at the top\n const scrollNeeded = viewportTop - rowRect.top;\n container.scrollTop -= scrollNeeded;\n }\n return;\n }\n\n // Target row is not rendered yet\n // Focus immediately so the ring appears as the row is revealed\n focusCell(newRowIndex, newColumnId);\n\n // Scroll by exactly one row height to reveal it smoothly\n if (direction === \"down\") {\n container.scrollTop += rowHeightValue;\n } else {\n // For arrow up, ensure we don't go below 0\n const currentScrollTop = container.scrollTop;\n const targetScrollTop = Math.max(\n 0,\n currentScrollTop - rowHeightValue,\n );\n container.scrollTop = targetScrollTop;\n }\n return;\n }\n\n // For larger jumps (page up/down, ctrl+home/end, etc.)\n if (rowVirtualizer && Math.abs(rowDiff) > 1) {\n const align =\n direction === \"pageup\" || direction === \"ctrl+home\"\n ? \"start\"\n : direction === \"pagedown\" || direction === \"ctrl+end\"\n ? \"end\"\n : \"center\";\n rowVirtualizer.scrollToIndex(newRowIndex, { align });\n requestAnimationFrame(() => {\n focusCell(newRowIndex, newColumnId);\n });\n return;\n }\n\n // For horizontal navigation or when row is already visible\n focusCell(newRowIndex, newColumnId);\n }\n },\n [store, navigableColumnIds, focusCell, data.length, rowHeightValue],\n );\n\n const onCellEditingStart = React.useCallback(\n (rowIndex: number, columnId: string) => {\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", { rowIndex, columnId });\n });\n },\n [store],\n );\n\n const onCellEditingStop = React.useCallback(\n (opts?: { moveToNextRow?: boolean; direction?: NavigationDirection }) => {\n const currentState = store.getState();\n const currentEditing = currentState.editingCell;\n\n store.setState(\"editingCell\", null);\n\n if (opts?.moveToNextRow && currentEditing) {\n const { rowIndex, columnId } = currentEditing;\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n const nextRowIndex = rowIndex + 1;\n if (nextRowIndex < rowCount) {\n requestAnimationFrame(() => {\n focusCell(nextRowIndex, columnId);\n });\n }\n } else if (opts?.direction && currentEditing) {\n // Focus the current editing cell first, then navigate\n const { rowIndex, columnId } = currentEditing;\n focusCell(rowIndex, columnId);\n requestAnimationFrame(() => {\n navigateCell(opts.direction ?? \"right\");\n });\n }\n },\n [store, data.length, focusCell, navigateCell],\n );\n\n const onSearchOpenChange = React.useCallback(\n (open: boolean) => {\n if (open) {\n store.setState(\"searchOpen\", true);\n return;\n }\n\n const currentState = store.getState();\n const currentMatch =\n currentState.matchIndex >= 0 &&\n currentState.searchMatches[currentState.matchIndex];\n\n store.batch(() => {\n store.setState(\"searchOpen\", false);\n store.setState(\"searchQuery\", \"\");\n store.setState(\"searchMatches\", []);\n store.setState(\"matchIndex\", -1);\n\n if (currentMatch) {\n store.setState(\"focusedCell\", {\n rowIndex: currentMatch.rowIndex,\n columnId: currentMatch.columnId,\n });\n }\n });\n\n if (\n dataGridRef.current &&\n document.activeElement !== dataGridRef.current\n ) {\n dataGridRef.current.focus();\n }\n },\n [store],\n );\n\n const onSearch = React.useCallback(\n (query: string) => {\n if (!query.trim()) {\n store.batch(() => {\n store.setState(\"searchMatches\", []);\n store.setState(\"matchIndex\", -1);\n });\n return;\n }\n\n const matches: CellPosition[] = [];\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n\n const lowerQuery = query.toLowerCase();\n\n for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {\n const row = rows[rowIndex];\n if (!row) continue;\n\n for (const columnId of columnIds) {\n const cell = row\n .getVisibleCells()\n .find((c) => c.column.id === columnId);\n if (!cell) continue;\n\n const value = cell.getValue();\n const stringValue = String(value ?? \"\").toLowerCase();\n\n if (stringValue.includes(lowerQuery)) {\n matches.push({ rowIndex, columnId });\n }\n }\n }\n\n store.batch(() => {\n store.setState(\"searchMatches\", matches);\n store.setState(\"matchIndex\", matches.length > 0 ? 0 : -1);\n });\n\n // Scroll to first match but don't focus it (to keep focus in search input)\n if (matches.length > 0 && matches[0]) {\n const firstMatch = matches[0];\n rowVirtualizerRef.current?.scrollToIndex(firstMatch.rowIndex, {\n align: \"center\",\n });\n }\n },\n [columnIds, store],\n );\n\n const onSearchQueryChange = React.useCallback(\n (query: string) => store.setState(\"searchQuery\", query),\n [store],\n );\n\n const onNavigateToPrevMatch = React.useCallback(() => {\n const currentState = store.getState();\n if (currentState.searchMatches.length === 0) return;\n\n const prevIndex =\n currentState.matchIndex - 1 < 0\n ? currentState.searchMatches.length - 1\n : currentState.matchIndex - 1;\n const match = currentState.searchMatches[prevIndex];\n\n if (match) {\n rowVirtualizerRef.current?.scrollToIndex(match.rowIndex, {\n align: \"center\",\n });\n\n requestAnimationFrame(() => {\n store.setState(\"matchIndex\", prevIndex);\n requestAnimationFrame(() => {\n focusCell(match.rowIndex, match.columnId);\n });\n });\n }\n }, [store, focusCell]);\n\n const onNavigateToNextMatch = React.useCallback(() => {\n const currentState = store.getState();\n if (currentState.searchMatches.length === 0) return;\n\n const nextIndex =\n (currentState.matchIndex + 1) % currentState.searchMatches.length;\n const match = currentState.searchMatches[nextIndex];\n\n if (match) {\n rowVirtualizerRef.current?.scrollToIndex(match.rowIndex, {\n align: \"center\",\n });\n\n requestAnimationFrame(() => {\n store.setState(\"matchIndex\", nextIndex);\n requestAnimationFrame(() => {\n focusCell(match.rowIndex, match.columnId);\n });\n });\n }\n }, [store, focusCell]);\n\n const getIsSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return searchMatches.some(\n (match) => match.rowIndex === rowIndex && match.columnId === columnId,\n );\n },\n [searchMatches],\n );\n\n const getIsActiveSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n if (matchIndex < 0) return false;\n const currentMatch = searchMatches[matchIndex];\n return (\n currentMatch?.rowIndex === rowIndex &&\n currentMatch?.columnId === columnId\n );\n },\n [searchMatches, matchIndex],\n );\n\n const blurCell = React.useCallback(() => {\n const currentState = store.getState();\n if (\n currentState.editingCell &&\n document.activeElement instanceof HTMLElement\n ) {\n document.activeElement.blur();\n }\n\n store.batch(() => {\n store.setState(\"focusedCell\", null);\n store.setState(\"editingCell\", null);\n });\n }, [store]);\n\n const onCellClick = React.useCallback(\n (rowIndex: number, columnId: string, event?: React.MouseEvent) => {\n // Ignore right-click (button 2) - let onCellContextMenu handle it\n if (event?.button === 2) {\n return;\n }\n\n const currentState = store.getState();\n const currentFocused = currentState.focusedCell;\n\n if (event) {\n if (event.ctrlKey || event.metaKey) {\n event.preventDefault();\n const cellKey = getCellKey(rowIndex, columnId);\n const newSelectedCells = new Set(\n currentState.selectionState.selectedCells,\n );\n\n if (newSelectedCells.has(cellKey)) {\n newSelectedCells.delete(cellKey);\n } else {\n newSelectedCells.add(cellKey);\n }\n\n store.setState(\"selectionState\", {\n selectedCells: newSelectedCells,\n selectionRange: null,\n isSelecting: false,\n });\n focusCell(rowIndex, columnId);\n return;\n }\n\n if (event.shiftKey && currentState.focusedCell) {\n event.preventDefault();\n selectRange(currentState.focusedCell, { rowIndex, columnId });\n return;\n }\n }\n\n // Clear selection if there are selected cells or rows\n const hasSelectedCells =\n currentState.selectionState.selectedCells.size > 0;\n const hasSelectedRows = Object.keys(currentState.rowSelection).length > 0;\n\n if (hasSelectedCells && !currentState.selectionState.isSelecting) {\n // If there's a cell selection but we're not actively selecting (drag just finished),\n // don't clear it - keep the selection\n // Only clear if clicking elsewhere\n const cellKey = getCellKey(rowIndex, columnId);\n const isClickingSelectedCell =\n currentState.selectionState.selectedCells.has(cellKey);\n\n if (!isClickingSelectedCell) {\n clearSelection();\n } else {\n // Clicking on an already selected cell - just focus it\n focusCell(rowIndex, columnId);\n return;\n }\n } else if (hasSelectedRows && columnId !== \"select\") {\n // If there are selected rows but we're clicking on a non-checkbox cell, clear selections\n clearSelection();\n }\n\n if (\n currentFocused?.rowIndex === rowIndex &&\n currentFocused?.columnId === columnId\n ) {\n onCellEditingStart(rowIndex, columnId);\n } else {\n focusCell(rowIndex, columnId);\n }\n },\n [store, focusCell, onCellEditingStart, selectRange, clearSelection],\n );\n\n const onCellDoubleClick = React.useCallback(\n (rowIndex: number, columnId: string, event?: React.MouseEvent) => {\n if (event?.defaultPrevented) return;\n\n onCellEditingStart(rowIndex, columnId);\n },\n [onCellEditingStart],\n );\n\n const onCellMouseDown = React.useCallback(\n (rowIndex: number, columnId: string, event: React.MouseEvent) => {\n // Ignore right-click (button 2) - let onCellContextMenu handle it\n if (event.button === 2) {\n return;\n }\n\n event.preventDefault();\n\n // Only start drag selection if no modifier keys are pressed\n // Clear any existing selection and prepare for potential drag\n if (!event.ctrlKey && !event.metaKey && !event.shiftKey) {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: {\n start: { rowIndex, columnId },\n end: { rowIndex, columnId },\n },\n isSelecting: true,\n });\n store.setState(\"rowSelection\", {});\n });\n }\n },\n [store],\n );\n\n const onCellMouseEnter = React.useCallback(\n (rowIndex: number, columnId: string, _event: React.MouseEvent) => {\n const currentState = store.getState();\n if (\n currentState.selectionState.isSelecting &&\n currentState.selectionState.selectionRange\n ) {\n const start = currentState.selectionState.selectionRange.start;\n const end = { rowIndex, columnId };\n\n if (\n currentState.focusedCell?.rowIndex !== start.rowIndex ||\n currentState.focusedCell?.columnId !== start.columnId\n ) {\n focusCell(start.rowIndex, start.columnId);\n }\n\n selectRange(start, end, true);\n }\n },\n [store, selectRange, focusCell],\n );\n\n const onCellMouseUp = React.useCallback(() => {\n const currentState = store.getState();\n store.setState(\"selectionState\", {\n ...currentState.selectionState,\n isSelecting: false,\n });\n }, [store]);\n\n const onCellContextMenu = React.useCallback(\n (rowIndex: number, columnId: string, event: React.MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n\n const currentState = store.getState();\n const cellKey = getCellKey(rowIndex, columnId);\n const isTargetCellSelected =\n currentState.selectionState.selectedCells.has(cellKey);\n\n // If right-clicking on a non-selected cell, select only that cell\n if (!isTargetCellSelected) {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set([cellKey]),\n selectionRange: {\n start: { rowIndex, columnId },\n end: { rowIndex, columnId },\n },\n isSelecting: false,\n });\n store.setState(\"focusedCell\", { rowIndex, columnId });\n });\n }\n\n // Open context menu at cursor position\n store.setState(\"contextMenu\", {\n open: true,\n x: event.clientX,\n y: event.clientY,\n });\n },\n [store],\n );\n\n const onContextMenuOpenChange = React.useCallback(\n (open: boolean) => {\n if (!open) {\n const currentMenu = store.getState().contextMenu;\n store.setState(\"contextMenu\", {\n open: false,\n x: currentMenu.x,\n y: currentMenu.y,\n });\n }\n },\n [store],\n );\n\n const onDataGridKeyDown = React.useCallback(\n (event: KeyboardEvent) => {\n const currentState = store.getState();\n const { key, ctrlKey, metaKey, shiftKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n // Handle Cmd+F / Ctrl+F to open search (highest priority, works even when editing)\n if (enableSearch && isCtrlPressed && key === SEARCH_SHORTCUT_KEY) {\n event.preventDefault();\n onSearchOpenChange(true);\n return;\n }\n\n // Handle search navigation when search is open\n if (\n enableSearch &&\n currentState.searchOpen &&\n !currentState.editingCell\n ) {\n if (key === \"Enter\") {\n event.preventDefault();\n if (shiftKey) {\n onNavigateToPrevMatch();\n } else {\n onNavigateToNextMatch();\n }\n return;\n }\n if (key === \"Escape\") {\n event.preventDefault();\n onSearchOpenChange(false);\n return;\n }\n // When search is open, don't let data grid handle any other keys\n // (they should only affect the search input)\n return;\n }\n\n if (currentState.editingCell) return;\n\n if (!currentState.focusedCell) return;\n\n let direction: NavigationDirection | null = null;\n\n if (isCtrlPressed && key === \"a\") {\n event.preventDefault();\n selectAll();\n return;\n }\n\n if (key === \"Delete\" || key === \"Backspace\") {\n if (currentState.selectionState.selectedCells.size > 0) {\n event.preventDefault();\n const updates: Array<{\n rowIndex: number;\n columnId: string;\n value: unknown;\n }> = [];\n\n currentState.selectionState.selectedCells.forEach((cellKey) => {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n updates.push({ rowIndex, columnId, value: \"\" });\n });\n\n onDataUpdate(updates);\n clearSelection();\n }\n return;\n }\n\n switch (key) {\n case \"ArrowUp\":\n direction = \"up\";\n break;\n case \"ArrowDown\":\n direction = \"down\";\n break;\n case \"ArrowLeft\":\n direction = \"left\";\n break;\n case \"ArrowRight\":\n direction = \"right\";\n break;\n case \"Home\":\n direction = isCtrlPressed ? \"ctrl+home\" : \"home\";\n break;\n case \"End\":\n direction = isCtrlPressed ? \"ctrl+end\" : \"end\";\n break;\n case \"PageUp\":\n direction = \"pageup\";\n break;\n case \"PageDown\":\n direction = \"pagedown\";\n break;\n case \"Escape\":\n event.preventDefault();\n if (\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0\n ) {\n clearSelection();\n } else {\n blurCell();\n }\n return;\n case \"Tab\":\n event.preventDefault();\n direction = event.shiftKey ? \"left\" : \"right\";\n break;\n }\n\n if (direction) {\n event.preventDefault();\n\n // Tab navigation should not trigger selection, even with Shift\n if (shiftKey && key !== \"Tab\" && currentState.focusedCell) {\n const currentColIndex = navigableColumnIds.indexOf(\n currentState.focusedCell.columnId,\n );\n let newRowIndex = currentState.focusedCell.rowIndex;\n let newColumnId = currentState.focusedCell.columnId;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, currentState.focusedCell.rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(\n (tableRef.current?.getRowModel().rows.length || data.length) -\n 1,\n currentState.focusedCell.rowIndex + 1,\n );\n break;\n case \"left\":\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n break;\n case \"right\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n break;\n }\n\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n selectRange(selectionStart, {\n rowIndex: newRowIndex,\n columnId: newColumnId,\n });\n focusCell(newRowIndex, newColumnId);\n } else {\n if (currentState.selectionState.selectedCells.size > 0) {\n clearSelection();\n }\n navigateCell(direction);\n }\n }\n },\n [\n store,\n blurCell,\n navigateCell,\n selectAll,\n onDataUpdate,\n clearSelection,\n navigableColumnIds,\n data.length,\n selectRange,\n focusCell,\n onSearchOpenChange,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n enableSearch,\n ],\n );\n\n const onSortingChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newSorting =\n typeof updater === \"function\" ? updater(currentState.sorting) : updater;\n store.setState(\"sorting\", newSorting);\n },\n [store],\n );\n\n const onRowSelectionChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newRowSelection =\n typeof updater === \"function\"\n ? updater(currentState.rowSelection)\n : updater;\n\n const selectedRows = Object.keys(newRowSelection).filter(\n (key) => newRowSelection[key],\n );\n\n const selectedCells = new Set();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n\n for (const rowId of selectedRows) {\n const rowIndex = rows.findIndex((r) => r.id === rowId);\n if (rowIndex === -1) continue;\n\n for (const columnId of columnIds) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n }\n\n store.batch(() => {\n store.setState(\"rowSelection\", newRowSelection);\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"focusedCell\", null);\n store.setState(\"editingCell\", null);\n });\n },\n [store, columnIds],\n );\n\n const onRowSelect = React.useCallback(\n (rowIndex: number, selected: boolean, shiftKey: boolean) => {\n const currentState = store.getState();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n const currentRow = rows[rowIndex];\n if (!currentRow) return;\n\n if (shiftKey && currentState.lastClickedRowIndex !== null) {\n const startIndex = Math.min(currentState.lastClickedRowIndex, rowIndex);\n const endIndex = Math.max(currentState.lastClickedRowIndex, rowIndex);\n\n const newRowSelection: RowSelectionState = {\n ...currentState.rowSelection,\n };\n\n for (let i = startIndex; i <= endIndex; i++) {\n const row = rows[i];\n if (row) {\n newRowSelection[row.id] = selected;\n }\n }\n\n onRowSelectionChange(newRowSelection);\n } else {\n onRowSelectionChange({\n ...currentState.rowSelection,\n [currentRow.id]: selected,\n });\n }\n\n store.setState(\"lastClickedRowIndex\", rowIndex);\n },\n [store, onRowSelectionChange],\n );\n\n const onRowHeightChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newRowHeight =\n typeof updater === \"function\"\n ? updater(currentState.rowHeight)\n : updater;\n store.setState(\"rowHeight\", newRowHeight);\n },\n [store],\n );\n\n const onColumnClick = React.useCallback(\n (columnId: string) => {\n if (!enableColumnSelection) {\n clearSelection();\n return;\n }\n\n selectColumn(columnId);\n },\n [enableColumnSelection, selectColumn, clearSelection],\n );\n\n const defaultColumn: Partial> = React.useMemo(\n () => ({\n cell: DataGridCell,\n minSize: MIN_COLUMN_SIZE,\n maxSize: MAX_COLUMN_SIZE,\n }),\n [],\n );\n\n const tableOptions = React.useMemo>(\n () => ({\n ...dataGridPropsRef.current,\n data,\n columns,\n defaultColumn,\n initialState,\n state: {\n ...dataGridPropsRef.current.state,\n sorting,\n rowSelection,\n },\n onRowSelectionChange,\n onSortingChange,\n columnResizeMode: \"onChange\",\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n meta: {\n ...dataGridPropsRef.current.meta,\n dataGridRef,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n rowHeight,\n isScrolling,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n onRowHeightChange,\n onRowSelect,\n onRowsDelete: onRowsDeleteProp ? onRowsDelete : undefined,\n onDataUpdate,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n contextMenu,\n onContextMenuOpenChange,\n },\n }),\n [\n dataGridPropsRef,\n data,\n columns,\n defaultColumn,\n initialState,\n sorting,\n rowSelection,\n onRowSelectionChange,\n onSortingChange,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n isScrolling,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n onDataUpdate,\n onRowsDeleteProp,\n onRowsDelete,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n contextMenu,\n onContextMenuOpenChange,\n rowHeight,\n onRowHeightChange,\n onRowSelect,\n ],\n );\n\n const table = useReactTable(tableOptions);\n\n if (!tableRef.current) {\n tableRef.current = table;\n }\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: we need to memoize the column size vars\n const columnSizeVars = React.useMemo(() => {\n const headers = table.getFlatHeaders();\n const colSizes: { [key: string]: number } = {};\n for (const header of headers) {\n colSizes[`--header-${header.id}-size`] = header.getSize();\n colSizes[`--col-${header.column.id}-size`] = header.column.getSize();\n }\n return colSizes;\n }, [table.getState().columnSizingInfo, table.getState().columnSizing]);\n\n const rowVirtualizer = useVirtualizer({\n count: table.getRowModel().rows.length,\n getScrollElement: () => dataGridRef.current,\n estimateSize: () => rowHeightValue,\n overscan,\n measureElement:\n typeof window !== \"undefined\" &&\n navigator.userAgent.indexOf(\"Firefox\") === -1\n ? (element) => element?.getBoundingClientRect().height\n : undefined,\n onChange: (instance) => {\n // Sync virtualizer's isScrolling state to our store\n const virtualizerIsScrolling = instance.isScrolling;\n const currentIsScrolling = store.getState().isScrolling;\n\n if (virtualizerIsScrolling !== currentIsScrolling) {\n store.setState(\"isScrolling\", virtualizerIsScrolling);\n }\n\n // Batch DOM updates in a single animation frame\n const virtualItems = instance.getVirtualItems();\n if (virtualItems.length === 0) return;\n\n requestAnimationFrame(() => {\n for (let i = 0; i < virtualItems.length; i++) {\n const virtualRow = virtualItems[i];\n if (!virtualRow) continue;\n const rowRef = rowMapRef.current.get(virtualRow.index);\n if (rowRef) {\n rowRef.style.transform = `translateY(${virtualRow.start}px)`;\n }\n }\n });\n },\n });\n\n if (!rowVirtualizerRef.current) {\n rowVirtualizerRef.current = rowVirtualizer;\n }\n\n const onScrollToRow = React.useCallback(\n async (opts: Partial) => {\n const rowIndex = opts?.rowIndex ?? 0;\n const columnId = opts?.columnId;\n\n rowVirtualizer.scrollToIndex(rowIndex, {\n align: \"center\",\n });\n\n const targetColumnId = columnId ?? navigableColumnIds[0];\n\n if (!targetColumnId) return;\n\n queueMicrotask(() => {\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n store.batch(() => {\n store.setState(\"focusedCell\", {\n rowIndex,\n columnId: targetColumnId,\n });\n store.setState(\"editingCell\", null);\n });\n });\n });\n });\n },\n [rowVirtualizer, navigableColumnIds, store],\n );\n\n const onRowAdd = React.useCallback(\n async (event?: React.MouseEvent) => {\n if (!onRowAddProp) return;\n\n const result = await onRowAddProp(event);\n\n if (event?.defaultPrevented || result === null) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n\n if (result) {\n const adjustedRowIndex =\n (result.rowIndex ?? 0) >= rows.length ? rows.length : result.rowIndex;\n\n onScrollToRow({\n rowIndex: adjustedRowIndex,\n columnId: result.columnId,\n });\n return;\n }\n\n onScrollToRow({ rowIndex: rows.length });\n },\n [onRowAddProp, onScrollToRow],\n );\n\n const searchState = React.useMemo(() => {\n if (!enableSearch) return undefined;\n\n return {\n searchMatches,\n matchIndex,\n searchOpen,\n onSearchOpenChange,\n searchQuery,\n onSearchQueryChange,\n onSearch,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n };\n }, [\n enableSearch,\n searchMatches,\n matchIndex,\n searchOpen,\n onSearchOpenChange,\n searchQuery,\n onSearchQueryChange,\n onSearch,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n ]);\n\n React.useEffect(() => {\n const dataGridElement = dataGridRef.current;\n if (!dataGridElement) return;\n\n dataGridElement.addEventListener(\"keydown\", onDataGridKeyDown);\n return () => {\n dataGridElement.removeEventListener(\"keydown\", onDataGridKeyDown);\n };\n }, [onDataGridKeyDown]);\n\n React.useEffect(() => {\n function onGlobalKeyDown(event: KeyboardEvent) {\n const dataGridElement = dataGridRef.current;\n if (!dataGridElement) return;\n\n const target = event.target;\n if (!(target instanceof HTMLElement)) return;\n\n const { key, ctrlKey, metaKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n if (enableSearch && isCtrlPressed && key === SEARCH_SHORTCUT_KEY) {\n const isInInput =\n target.tagName === \"INPUT\" || target.tagName === \"TEXTAREA\";\n const isInDataGrid = dataGridElement.contains(target);\n const isInSearchInput = target.closest('[role=\"search\"]') !== null;\n\n if (isInDataGrid || isInSearchInput || !isInInput) {\n event.preventDefault();\n event.stopPropagation();\n onSearchOpenChange(true);\n\n if (!isInDataGrid && !isInSearchInput) {\n requestAnimationFrame(() => {\n dataGridElement.focus();\n });\n }\n return;\n }\n }\n\n const isInDataGrid = dataGridElement.contains(target);\n if (!isInDataGrid) return;\n\n if (key === \"Escape\") {\n const currentState = store.getState();\n const hasSelections =\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0;\n\n if (hasSelections) {\n event.preventDefault();\n event.stopPropagation();\n clearSelection();\n }\n }\n }\n\n window.addEventListener(\"keydown\", onGlobalKeyDown, true);\n return () => {\n window.removeEventListener(\"keydown\", onGlobalKeyDown, true);\n };\n }, [enableSearch, onSearchOpenChange, store, clearSelection]);\n\n React.useEffect(() => {\n const currentState = store.getState();\n if (\n autoFocus &&\n data.length > 0 &&\n columns.length > 0 &&\n !currentState.focusedCell\n ) {\n if (navigableColumnIds.length > 0) {\n const rafId = requestAnimationFrame(() => {\n if (typeof autoFocus === \"object\") {\n const { rowIndex, columnId } = autoFocus;\n if (columnId) {\n focusCell(rowIndex ?? 0, columnId);\n }\n return;\n }\n\n const firstColumnId = navigableColumnIds[0];\n if (firstColumnId) {\n focusCell(0, firstColumnId);\n }\n });\n return () => cancelAnimationFrame(rafId);\n }\n }\n }, [\n autoFocus,\n data.length,\n columns.length,\n store,\n navigableColumnIds,\n focusCell,\n ]);\n\n React.useEffect(() => {\n function onOutsideClick(event: MouseEvent) {\n if (event.button === 2) {\n return;\n }\n\n if (\n dataGridRef.current &&\n !dataGridRef.current.contains(event.target as Node)\n ) {\n const target = event.target;\n const isInsidePopover =\n target instanceof HTMLElement &&\n (target.closest(\"[data-grid-cell-editor]\") ||\n target.closest(\"[data-grid-popover]\"));\n\n if (!isInsidePopover) {\n blurCell();\n const currentState = store.getState();\n if (\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0\n ) {\n clearSelection();\n }\n }\n }\n }\n\n document.addEventListener(\"mousedown\", onOutsideClick);\n return () => {\n document.removeEventListener(\"mousedown\", onOutsideClick);\n };\n }, [store, blurCell, clearSelection]);\n\n React.useEffect(() => {\n function onCleanup() {\n document.removeEventListener(\"selectstart\", preventSelection);\n document.removeEventListener(\"contextmenu\", preventContextMenu);\n document.body.style.userSelect = \"\";\n }\n\n function preventSelection(event: Event) {\n event.preventDefault();\n }\n function preventContextMenu(event: Event) {\n event.preventDefault();\n }\n\n const onUnsubscribe = store.subscribe(() => {\n const currentState = store.getState();\n if (currentState.selectionState.isSelecting) {\n document.addEventListener(\"selectstart\", preventSelection);\n document.addEventListener(\"contextmenu\", preventContextMenu);\n document.body.style.userSelect = \"none\";\n } else {\n onCleanup();\n }\n });\n\n return () => {\n onCleanup();\n onUnsubscribe();\n };\n }, [store]);\n\n useIsomorphicLayoutEffect(() => {\n const rafId = requestAnimationFrame(() => {\n rowVirtualizer.measure();\n });\n return () => cancelAnimationFrame(rafId);\n }, [\n table.getState().columnFilters,\n table.getState().columnOrder,\n table.getState().columnPinning,\n table.getState().columnSizing,\n table.getState().columnVisibility,\n table.getState().expanded,\n table.getState().globalFilter,\n table.getState().grouping,\n table.getState().rowSelection,\n table.getState().sorting,\n rowHeight,\n ]);\n\n return {\n dataGridRef,\n headerRef,\n rowMapRef,\n footerRef,\n table,\n rowVirtualizer,\n searchState,\n columnSizeVars,\n onRowAdd: onRowAddProp ? onRowAdd : undefined,\n };\n}\n", + "content": "\"use client\";\n\nimport {\n type ColumnDef,\n getCoreRowModel,\n getSortedRowModel,\n type RowSelectionState,\n type SortingState,\n type TableOptions,\n type Updater,\n useReactTable,\n} from \"@tanstack/react-table\";\nimport { useVirtualizer, type Virtualizer } from \"@tanstack/react-virtual\";\nimport * as React from \"react\";\nimport { DataGridCell } from \"@/components/data-grid/data-grid-cell\";\nimport { getCellKey, getRowHeightValue, parseCellKey } from \"@/lib/data-grid\";\nimport type {\n CellPosition,\n ContextMenuState,\n NavigationDirection,\n RowHeightValue,\n SearchState,\n SelectionState,\n UpdateCell,\n} from \"@/types/data-grid\";\n\nconst DEFAULT_ROW_HEIGHT = \"short\";\nconst OVERSCAN = 3;\nconst VIEWPORT_OFFSET = 1;\nconst MIN_COLUMN_SIZE = 60;\nconst MAX_COLUMN_SIZE = 800;\nconst SEARCH_SHORTCUT_KEY = \"f\";\nconst NON_NAVIGABLE_COLUMN_IDS = [\"select\", \"actions\"];\n\nconst useIsomorphicLayoutEffect =\n typeof window !== \"undefined\" ? React.useLayoutEffect : React.useEffect;\n\nfunction useLazyRef(fn: () => T): React.RefObject {\n const ref = React.useRef(null);\n if (ref.current === null) {\n ref.current = fn();\n }\n return ref as React.RefObject;\n}\n\nfunction useAsRef(data: T) {\n const ref = React.useRef(data);\n\n useIsomorphicLayoutEffect(() => {\n ref.current = data;\n });\n\n return ref;\n}\n\ninterface DataGridState {\n sorting: SortingState;\n rowHeight: RowHeightValue;\n rowSelection: RowSelectionState;\n selectionState: SelectionState;\n focusedCell: CellPosition | null;\n editingCell: CellPosition | null;\n contextMenu: ContextMenuState;\n searchQuery: string;\n searchMatches: CellPosition[];\n matchIndex: number;\n searchOpen: boolean;\n lastClickedRowIndex: number | null;\n isScrolling: boolean;\n}\n\ninterface DataGridStore {\n subscribe: (callback: () => void) => () => void;\n getState: () => DataGridState;\n setState: (\n key: K,\n value: DataGridState[K],\n ) => void;\n notify: () => void;\n batch: (fn: () => void) => void;\n}\n\nfunction useStore(\n store: DataGridStore,\n selector: (state: DataGridState) => T,\n): T {\n const getSnapshot = React.useCallback(\n () => selector(store.getState()),\n [store, selector],\n );\n\n return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);\n}\n\ninterface UseDataGridProps\n extends Omit, \"pageCount\" | \"getCoreRowModel\"> {\n onDataChange?: (data: TData[]) => void;\n onRowAdd?: (event?: React.MouseEvent) =>\n | Partial\n | Promise>\n | null\n // biome-ignore lint/suspicious/noConfusingVoidType: void is needed here to allow functions without explicit return\n | void;\n onRowsDelete?: (rows: TData[], rowIndices: number[]) => void | Promise;\n rowHeight?: RowHeightValue;\n overscan?: number;\n autoFocus?: boolean | Partial;\n enableColumnSelection?: boolean;\n enableSearch?: boolean;\n}\n\nfunction useDataGrid({\n columns,\n data,\n onDataChange,\n onRowAdd: onRowAddProp,\n onRowsDelete: onRowsDeleteProp,\n rowHeight: rowHeightProp = DEFAULT_ROW_HEIGHT,\n overscan = OVERSCAN,\n initialState,\n autoFocus = false,\n enableColumnSelection = false,\n enableSearch = false,\n ...dataGridProps\n}: UseDataGridProps) {\n const dataGridRef = React.useRef(null);\n const tableRef = React.useRef>>(null);\n const rowVirtualizerRef =\n React.useRef>(null);\n const headerRef = React.useRef(null);\n const rowMapRef = React.useRef>(new Map());\n const footerRef = React.useRef(null);\n\n const dataGridPropsRef = useAsRef(dataGridProps);\n const listenersRef = useLazyRef(() => new Set<() => void>());\n\n const stateRef = useLazyRef(() => {\n return {\n sorting: initialState?.sorting ?? [],\n rowHeight: rowHeightProp,\n rowSelection: initialState?.rowSelection ?? {},\n selectionState: {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n },\n focusedCell: null,\n editingCell: null,\n contextMenu: {\n open: false,\n x: 0,\n y: 0,\n },\n searchQuery: \"\",\n searchMatches: [],\n matchIndex: -1,\n searchOpen: false,\n lastClickedRowIndex: null,\n isScrolling: false,\n };\n });\n\n const store = React.useMemo(() => {\n let isBatching = false;\n let pendingNotification = false;\n\n return {\n subscribe: (callback) => {\n listenersRef.current.add(callback);\n return () => listenersRef.current.delete(callback);\n },\n getState: () => stateRef.current,\n setState: (key, value) => {\n if (Object.is(stateRef.current[key], value)) return;\n stateRef.current[key] = value;\n\n if (isBatching) {\n pendingNotification = true;\n } else {\n if (!pendingNotification) {\n pendingNotification = true;\n queueMicrotask(() => {\n pendingNotification = false;\n store.notify();\n });\n }\n }\n },\n notify: () => {\n for (const listener of listenersRef.current) {\n listener();\n }\n },\n batch: (fn) => {\n if (isBatching) {\n fn();\n return;\n }\n\n isBatching = true;\n const wasPending = pendingNotification;\n pendingNotification = false;\n\n try {\n fn();\n } finally {\n isBatching = false;\n if (pendingNotification || wasPending) {\n pendingNotification = false;\n store.notify();\n }\n }\n },\n };\n }, [listenersRef, stateRef]);\n\n const focusedCell = useStore(store, (state) => state.focusedCell);\n const editingCell = useStore(store, (state) => state.editingCell);\n const selectionState = useStore(store, (state) => state.selectionState);\n const searchQuery = useStore(store, (state) => state.searchQuery);\n const searchMatches = useStore(store, (state) => state.searchMatches);\n const matchIndex = useStore(store, (state) => state.matchIndex);\n const searchOpen = useStore(store, (state) => state.searchOpen);\n const sorting = useStore(store, (state) => state.sorting);\n const rowSelection = useStore(store, (state) => state.rowSelection);\n const contextMenu = useStore(store, (state) => state.contextMenu);\n const rowHeight = useStore(store, (state) => state.rowHeight);\n const isScrolling = useStore(store, (state) => state.isScrolling);\n\n const rowHeightValue = getRowHeightValue(rowHeight);\n\n const columnIds = React.useMemo(() => {\n return columns\n .map((c) => {\n if (c.id) return c.id;\n if (\"accessorKey\" in c) return c.accessorKey as string;\n return undefined;\n })\n .filter((id): id is string => Boolean(id));\n }, [columns]);\n\n const navigableColumnIds = React.useMemo(() => {\n return columnIds.filter((c) => !NON_NAVIGABLE_COLUMN_IDS.includes(c));\n }, [columnIds]);\n\n const onDataUpdate = React.useCallback(\n (updates: UpdateCell | Array) => {\n const updateArray = Array.isArray(updates) ? updates : [updates];\n\n if (updateArray.length === 0) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n\n const rowUpdatesMap = new Map<\n number,\n Array>\n >();\n\n for (const update of updateArray) {\n if (!rows || !currentTable) {\n const existingUpdates = rowUpdatesMap.get(update.rowIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(update.rowIndex, existingUpdates);\n } else {\n const row = rows[update.rowIndex];\n if (!row) continue;\n\n const originalData = row.original;\n const originalRowIndex = data.indexOf(originalData);\n if (originalRowIndex === -1) continue;\n\n const existingUpdates = rowUpdatesMap.get(originalRowIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(originalRowIndex, existingUpdates);\n }\n }\n\n const newData = data.map((row, index) => {\n const updates = rowUpdatesMap.get(index);\n if (!updates) return row;\n\n const updatedRow = { ...row } as Record;\n for (const { columnId, value } of updates) {\n updatedRow[columnId] = value;\n }\n return updatedRow as TData;\n });\n\n onDataChange?.(newData);\n },\n [data, onDataChange],\n );\n\n const getIsCellSelected = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return selectionState.selectedCells.has(getCellKey(rowIndex, columnId));\n },\n [selectionState.selectedCells],\n );\n\n const clearSelection = React.useCallback(() => {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"rowSelection\", {});\n });\n }, [store]);\n\n const selectAll = React.useCallback(() => {\n const allCells = new Set();\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {\n for (const columnId of columnIds) {\n allCells.add(getCellKey(rowIndex, columnId));\n }\n }\n\n const firstColumnId = columnIds[0];\n const lastColumnId = columnIds[columnIds.length - 1];\n\n store.setState(\"selectionState\", {\n selectedCells: allCells,\n selectionRange:\n columnIds.length > 0 && rowCount > 0 && firstColumnId && lastColumnId\n ? {\n start: { rowIndex: 0, columnId: firstColumnId },\n end: { rowIndex: rowCount - 1, columnId: lastColumnId },\n }\n : null,\n isSelecting: false,\n });\n }, [columnIds, data.length, store]);\n\n const selectColumn = React.useCallback(\n (columnId: string) => {\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n if (rowCount === 0) return;\n\n const selectedCells = new Set();\n\n for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: {\n start: { rowIndex: 0, columnId },\n end: { rowIndex: rowCount - 1, columnId },\n },\n isSelecting: false,\n });\n },\n [data.length, store],\n );\n\n const selectRange = React.useCallback(\n (start: CellPosition, end: CellPosition, isSelecting = false) => {\n const startColIndex = columnIds.indexOf(start.columnId);\n const endColIndex = columnIds.indexOf(end.columnId);\n\n const minRow = Math.min(start.rowIndex, end.rowIndex);\n const maxRow = Math.max(start.rowIndex, end.rowIndex);\n const minCol = Math.min(startColIndex, endColIndex);\n const maxCol = Math.max(startColIndex, endColIndex);\n\n const selectedCells = new Set();\n\n for (let rowIndex = minRow; rowIndex <= maxRow; rowIndex++) {\n for (let colIndex = minCol; colIndex <= maxCol; colIndex++) {\n const columnId = columnIds[colIndex];\n if (columnId) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n }\n }\n\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: { start, end },\n isSelecting,\n });\n },\n [columnIds, store],\n );\n\n const focusCell = React.useCallback(\n (rowIndex: number, columnId: string) => {\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", null);\n });\n\n const currentState = store.getState();\n\n if (currentState.searchOpen) return;\n\n if (\n dataGridRef.current &&\n document.activeElement !== dataGridRef.current\n ) {\n dataGridRef.current.focus();\n }\n },\n [store],\n );\n\n const onRowsDelete = React.useCallback(\n async (rowIndices: number[]) => {\n if (!onRowsDeleteProp || rowIndices.length === 0) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n\n if (!rows || rows.length === 0) return;\n\n const currentState = store.getState();\n const currentFocusedColumn =\n currentState.focusedCell?.columnId ?? navigableColumnIds[0];\n\n const minDeletedRowIndex = Math.min(...rowIndices);\n\n const rowsToDelete: TData[] = [];\n for (const rowIndex of rowIndices) {\n const row = rows[rowIndex];\n if (row) {\n rowsToDelete.push(row.original);\n }\n }\n\n await onRowsDeleteProp(rowsToDelete, rowIndices);\n\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"rowSelection\", {});\n store.setState(\"editingCell\", null);\n });\n\n requestAnimationFrame(() => {\n const currentTable = tableRef.current;\n const currentRows = currentTable?.getRowModel().rows ?? [];\n const newRowCount = currentRows.length ?? data.length;\n\n if (newRowCount > 0 && currentFocusedColumn) {\n const targetRowIndex = Math.min(minDeletedRowIndex, newRowCount - 1);\n focusCell(targetRowIndex, currentFocusedColumn);\n }\n });\n },\n [onRowsDeleteProp, data.length, store, navigableColumnIds, focusCell],\n );\n\n const navigateCell = React.useCallback(\n (direction: NavigationDirection) => {\n const currentState = store.getState();\n if (!currentState.focusedCell) return;\n\n const { rowIndex, columnId } = currentState.focusedCell;\n const currentColIndex = navigableColumnIds.indexOf(columnId);\n const rowVirtualizer = rowVirtualizerRef.current;\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n let newRowIndex = rowIndex;\n let newColumnId = columnId;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(rowCount - 1, rowIndex + 1);\n break;\n case \"left\":\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n break;\n case \"right\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n break;\n case \"home\":\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? columnId;\n }\n break;\n case \"end\":\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;\n }\n break;\n case \"ctrl+home\":\n newRowIndex = 0;\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? columnId;\n }\n break;\n case \"ctrl+end\":\n newRowIndex = Math.max(0, rowCount - 1);\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;\n }\n break;\n case \"pageup\":\n if (rowVirtualizer) {\n const visibleRange = rowVirtualizer.getVirtualItems();\n const pageSize = visibleRange.length ?? 10;\n newRowIndex = Math.max(0, rowIndex - pageSize);\n } else {\n newRowIndex = Math.max(0, rowIndex - 10);\n }\n break;\n case \"pagedown\":\n if (rowVirtualizer) {\n const visibleRange = rowVirtualizer.getVirtualItems();\n const pageSize = visibleRange.length ?? 10;\n newRowIndex = Math.min(rowCount - 1, rowIndex + pageSize);\n } else {\n newRowIndex = Math.min(rowCount - 1, rowIndex + 10);\n }\n break;\n }\n\n if (newRowIndex !== rowIndex || newColumnId !== columnId) {\n const rowDiff = newRowIndex - rowIndex;\n\n // For single-row vertical navigation (up/down arrows)\n if (\n Math.abs(rowDiff) === 1 &&\n (direction === \"up\" || direction === \"down\")\n ) {\n const container = dataGridRef.current;\n const currentRow = rowMapRef.current.get(rowIndex);\n const targetRow = rowMapRef.current.get(newRowIndex);\n\n if (!container || !currentRow) {\n // Fallback to simple focus if we can't find elements\n focusCell(newRowIndex, newColumnId);\n return;\n }\n\n // Check viewport boundaries\n const containerRect = container.getBoundingClientRect();\n const headerHeight =\n headerRef.current?.getBoundingClientRect().height ?? 0;\n const footerHeight =\n footerRef.current?.getBoundingClientRect().height ?? 0;\n\n const viewportTop =\n containerRect.top + headerHeight + VIEWPORT_OFFSET;\n const viewportBottom =\n containerRect.bottom - footerHeight - VIEWPORT_OFFSET;\n\n // If target row already exists, check if it's visible\n if (targetRow) {\n const rowRect = targetRow.getBoundingClientRect();\n const isFullyVisible =\n rowRect.top >= viewportTop && rowRect.bottom <= viewportBottom;\n\n if (isFullyVisible) {\n // Row is fully visible, just focus it\n focusCell(newRowIndex, newColumnId);\n return;\n }\n\n // Row exists but not fully visible, scroll it into view\n focusCell(newRowIndex, newColumnId);\n\n if (direction === \"down\") {\n // Scroll just enough to show the row at the bottom\n const scrollNeeded = rowRect.bottom - viewportBottom;\n container.scrollTop += scrollNeeded;\n } else {\n // Scroll just enough to show the row at the top\n const scrollNeeded = viewportTop - rowRect.top;\n container.scrollTop -= scrollNeeded;\n }\n return;\n }\n\n // Target row is not rendered yet\n // Focus immediately so the ring appears as the row is revealed\n focusCell(newRowIndex, newColumnId);\n\n // Scroll by exactly one row height to reveal it smoothly\n if (direction === \"down\") {\n container.scrollTop += rowHeightValue;\n } else {\n // For arrow up, ensure we don't go below 0\n const currentScrollTop = container.scrollTop;\n const targetScrollTop = Math.max(\n 0,\n currentScrollTop - rowHeightValue,\n );\n container.scrollTop = targetScrollTop;\n }\n return;\n }\n\n // For larger jumps (page up/down, ctrl+home/end, etc.)\n if (rowVirtualizer && Math.abs(rowDiff) > 1) {\n const align =\n direction === \"pageup\" || direction === \"ctrl+home\"\n ? \"start\"\n : direction === \"pagedown\" || direction === \"ctrl+end\"\n ? \"end\"\n : \"center\";\n rowVirtualizer.scrollToIndex(newRowIndex, { align });\n requestAnimationFrame(() => {\n focusCell(newRowIndex, newColumnId);\n });\n return;\n }\n\n // For horizontal navigation or when row is already visible\n focusCell(newRowIndex, newColumnId);\n }\n },\n [store, navigableColumnIds, focusCell, data.length, rowHeightValue],\n );\n\n const onCellEditingStart = React.useCallback(\n (rowIndex: number, columnId: string) => {\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", { rowIndex, columnId });\n });\n },\n [store],\n );\n\n const onCellEditingStop = React.useCallback(\n (opts?: { moveToNextRow?: boolean; direction?: NavigationDirection }) => {\n const currentState = store.getState();\n const currentEditing = currentState.editingCell;\n\n store.setState(\"editingCell\", null);\n\n if (opts?.moveToNextRow && currentEditing) {\n const { rowIndex, columnId } = currentEditing;\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n const nextRowIndex = rowIndex + 1;\n if (nextRowIndex < rowCount) {\n requestAnimationFrame(() => {\n focusCell(nextRowIndex, columnId);\n });\n }\n } else if (opts?.direction && currentEditing) {\n // Focus the current editing cell first, then navigate\n const { rowIndex, columnId } = currentEditing;\n focusCell(rowIndex, columnId);\n requestAnimationFrame(() => {\n navigateCell(opts.direction ?? \"right\");\n });\n }\n },\n [store, data.length, focusCell, navigateCell],\n );\n\n const onSearchOpenChange = React.useCallback(\n (open: boolean) => {\n if (open) {\n store.setState(\"searchOpen\", true);\n return;\n }\n\n const currentState = store.getState();\n const currentMatch =\n currentState.matchIndex >= 0 &&\n currentState.searchMatches[currentState.matchIndex];\n\n store.batch(() => {\n store.setState(\"searchOpen\", false);\n store.setState(\"searchQuery\", \"\");\n store.setState(\"searchMatches\", []);\n store.setState(\"matchIndex\", -1);\n\n if (currentMatch) {\n store.setState(\"focusedCell\", {\n rowIndex: currentMatch.rowIndex,\n columnId: currentMatch.columnId,\n });\n }\n });\n\n if (\n dataGridRef.current &&\n document.activeElement !== dataGridRef.current\n ) {\n dataGridRef.current.focus();\n }\n },\n [store],\n );\n\n const onSearch = React.useCallback(\n (query: string) => {\n if (!query.trim()) {\n store.batch(() => {\n store.setState(\"searchMatches\", []);\n store.setState(\"matchIndex\", -1);\n });\n return;\n }\n\n const matches: CellPosition[] = [];\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n\n const lowerQuery = query.toLowerCase();\n\n for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {\n const row = rows[rowIndex];\n if (!row) continue;\n\n for (const columnId of columnIds) {\n const cell = row\n .getVisibleCells()\n .find((c) => c.column.id === columnId);\n if (!cell) continue;\n\n const value = cell.getValue();\n const stringValue = String(value ?? \"\").toLowerCase();\n\n if (stringValue.includes(lowerQuery)) {\n matches.push({ rowIndex, columnId });\n }\n }\n }\n\n store.batch(() => {\n store.setState(\"searchMatches\", matches);\n store.setState(\"matchIndex\", matches.length > 0 ? 0 : -1);\n });\n\n // Scroll to first match but don't focus it (to keep focus in search input)\n if (matches.length > 0 && matches[0]) {\n const firstMatch = matches[0];\n rowVirtualizerRef.current?.scrollToIndex(firstMatch.rowIndex, {\n align: \"center\",\n });\n }\n },\n [columnIds, store],\n );\n\n const onSearchQueryChange = React.useCallback(\n (query: string) => store.setState(\"searchQuery\", query),\n [store],\n );\n\n const onNavigateToPrevMatch = React.useCallback(() => {\n const currentState = store.getState();\n if (currentState.searchMatches.length === 0) return;\n\n const prevIndex =\n currentState.matchIndex - 1 < 0\n ? currentState.searchMatches.length - 1\n : currentState.matchIndex - 1;\n const match = currentState.searchMatches[prevIndex];\n\n if (match) {\n rowVirtualizerRef.current?.scrollToIndex(match.rowIndex, {\n align: \"center\",\n });\n\n requestAnimationFrame(() => {\n store.setState(\"matchIndex\", prevIndex);\n requestAnimationFrame(() => {\n focusCell(match.rowIndex, match.columnId);\n });\n });\n }\n }, [store, focusCell]);\n\n const onNavigateToNextMatch = React.useCallback(() => {\n const currentState = store.getState();\n if (currentState.searchMatches.length === 0) return;\n\n const nextIndex =\n (currentState.matchIndex + 1) % currentState.searchMatches.length;\n const match = currentState.searchMatches[nextIndex];\n\n if (match) {\n rowVirtualizerRef.current?.scrollToIndex(match.rowIndex, {\n align: \"center\",\n });\n\n requestAnimationFrame(() => {\n store.setState(\"matchIndex\", nextIndex);\n requestAnimationFrame(() => {\n focusCell(match.rowIndex, match.columnId);\n });\n });\n }\n }, [store, focusCell]);\n\n const getIsSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return searchMatches.some(\n (match) => match.rowIndex === rowIndex && match.columnId === columnId,\n );\n },\n [searchMatches],\n );\n\n const getIsActiveSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n if (matchIndex < 0) return false;\n const currentMatch = searchMatches[matchIndex];\n return (\n currentMatch?.rowIndex === rowIndex &&\n currentMatch?.columnId === columnId\n );\n },\n [searchMatches, matchIndex],\n );\n\n const blurCell = React.useCallback(() => {\n const currentState = store.getState();\n if (\n currentState.editingCell &&\n document.activeElement instanceof HTMLElement\n ) {\n document.activeElement.blur();\n }\n\n store.batch(() => {\n store.setState(\"focusedCell\", null);\n store.setState(\"editingCell\", null);\n });\n }, [store]);\n\n const onCellClick = React.useCallback(\n (rowIndex: number, columnId: string, event?: React.MouseEvent) => {\n // Ignore right-click (button 2) - let onCellContextMenu handle it\n if (event?.button === 2) {\n return;\n }\n\n const currentState = store.getState();\n const currentFocused = currentState.focusedCell;\n\n if (event) {\n if (event.ctrlKey || event.metaKey) {\n event.preventDefault();\n const cellKey = getCellKey(rowIndex, columnId);\n const newSelectedCells = new Set(\n currentState.selectionState.selectedCells,\n );\n\n if (newSelectedCells.has(cellKey)) {\n newSelectedCells.delete(cellKey);\n } else {\n newSelectedCells.add(cellKey);\n }\n\n store.setState(\"selectionState\", {\n selectedCells: newSelectedCells,\n selectionRange: null,\n isSelecting: false,\n });\n focusCell(rowIndex, columnId);\n return;\n }\n\n if (event.shiftKey && currentState.focusedCell) {\n event.preventDefault();\n selectRange(currentState.focusedCell, { rowIndex, columnId });\n return;\n }\n }\n\n // Clear selection if there are selected cells or rows\n const hasSelectedCells =\n currentState.selectionState.selectedCells.size > 0;\n const hasSelectedRows = Object.keys(currentState.rowSelection).length > 0;\n\n if (hasSelectedCells && !currentState.selectionState.isSelecting) {\n // If there's a cell selection but we're not actively selecting (drag just finished),\n // don't clear it - keep the selection\n // Only clear if clicking elsewhere\n const cellKey = getCellKey(rowIndex, columnId);\n const isClickingSelectedCell =\n currentState.selectionState.selectedCells.has(cellKey);\n\n if (!isClickingSelectedCell) {\n clearSelection();\n } else {\n // Clicking on an already selected cell - just focus it\n focusCell(rowIndex, columnId);\n return;\n }\n } else if (hasSelectedRows && columnId !== \"select\") {\n // If there are selected rows but we're clicking on a non-checkbox cell, clear selections\n clearSelection();\n }\n\n if (\n currentFocused?.rowIndex === rowIndex &&\n currentFocused?.columnId === columnId\n ) {\n onCellEditingStart(rowIndex, columnId);\n } else {\n focusCell(rowIndex, columnId);\n }\n },\n [store, focusCell, onCellEditingStart, selectRange, clearSelection],\n );\n\n const onCellDoubleClick = React.useCallback(\n (rowIndex: number, columnId: string, event?: React.MouseEvent) => {\n if (event?.defaultPrevented) return;\n\n onCellEditingStart(rowIndex, columnId);\n },\n [onCellEditingStart],\n );\n\n const onCellMouseDown = React.useCallback(\n (rowIndex: number, columnId: string, event: React.MouseEvent) => {\n // Ignore right-click (button 2) - let onCellContextMenu handle it\n if (event.button === 2) {\n return;\n }\n\n event.preventDefault();\n\n // Only start drag selection if no modifier keys are pressed\n // Clear any existing selection and prepare for potential drag\n if (!event.ctrlKey && !event.metaKey && !event.shiftKey) {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: {\n start: { rowIndex, columnId },\n end: { rowIndex, columnId },\n },\n isSelecting: true,\n });\n store.setState(\"rowSelection\", {});\n });\n }\n },\n [store],\n );\n\n const onCellMouseEnter = React.useCallback(\n (rowIndex: number, columnId: string, _event: React.MouseEvent) => {\n const currentState = store.getState();\n if (\n currentState.selectionState.isSelecting &&\n currentState.selectionState.selectionRange\n ) {\n const start = currentState.selectionState.selectionRange.start;\n const end = { rowIndex, columnId };\n\n if (\n currentState.focusedCell?.rowIndex !== start.rowIndex ||\n currentState.focusedCell?.columnId !== start.columnId\n ) {\n focusCell(start.rowIndex, start.columnId);\n }\n\n selectRange(start, end, true);\n }\n },\n [store, selectRange, focusCell],\n );\n\n const onCellMouseUp = React.useCallback(() => {\n const currentState = store.getState();\n store.setState(\"selectionState\", {\n ...currentState.selectionState,\n isSelecting: false,\n });\n }, [store]);\n\n const onCellContextMenu = React.useCallback(\n (rowIndex: number, columnId: string, event: React.MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n\n const currentState = store.getState();\n const cellKey = getCellKey(rowIndex, columnId);\n const isTargetCellSelected =\n currentState.selectionState.selectedCells.has(cellKey);\n\n // If right-clicking on a non-selected cell, select only that cell\n if (!isTargetCellSelected) {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set([cellKey]),\n selectionRange: {\n start: { rowIndex, columnId },\n end: { rowIndex, columnId },\n },\n isSelecting: false,\n });\n store.setState(\"focusedCell\", { rowIndex, columnId });\n });\n }\n\n // Open context menu at cursor position\n store.setState(\"contextMenu\", {\n open: true,\n x: event.clientX,\n y: event.clientY,\n });\n },\n [store],\n );\n\n const onContextMenuOpenChange = React.useCallback(\n (open: boolean) => {\n if (!open) {\n const currentMenu = store.getState().contextMenu;\n store.setState(\"contextMenu\", {\n open: false,\n x: currentMenu.x,\n y: currentMenu.y,\n });\n }\n },\n [store],\n );\n\n const onDataGridKeyDown = React.useCallback(\n (event: KeyboardEvent) => {\n const currentState = store.getState();\n const { key, ctrlKey, metaKey, shiftKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n // Handle Cmd+F / Ctrl+F to open search (highest priority, works even when editing)\n if (enableSearch && isCtrlPressed && key === SEARCH_SHORTCUT_KEY) {\n event.preventDefault();\n onSearchOpenChange(true);\n return;\n }\n\n // Handle search navigation when search is open\n if (\n enableSearch &&\n currentState.searchOpen &&\n !currentState.editingCell\n ) {\n if (key === \"Enter\") {\n event.preventDefault();\n if (shiftKey) {\n onNavigateToPrevMatch();\n } else {\n onNavigateToNextMatch();\n }\n return;\n }\n if (key === \"Escape\") {\n event.preventDefault();\n onSearchOpenChange(false);\n return;\n }\n // When search is open, don't let data grid handle any other keys\n // (they should only affect the search input)\n return;\n }\n\n if (currentState.editingCell) return;\n\n if (!currentState.focusedCell) return;\n\n let direction: NavigationDirection | null = null;\n\n if (isCtrlPressed && key === \"a\") {\n event.preventDefault();\n selectAll();\n return;\n }\n\n if (key === \"Delete\" || key === \"Backspace\") {\n if (currentState.selectionState.selectedCells.size > 0) {\n event.preventDefault();\n const updates: Array<{\n rowIndex: number;\n columnId: string;\n value: unknown;\n }> = [];\n\n currentState.selectionState.selectedCells.forEach((cellKey) => {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n updates.push({ rowIndex, columnId, value: \"\" });\n });\n\n onDataUpdate(updates);\n clearSelection();\n }\n return;\n }\n\n switch (key) {\n case \"ArrowUp\":\n direction = \"up\";\n break;\n case \"ArrowDown\":\n direction = \"down\";\n break;\n case \"ArrowLeft\":\n direction = \"left\";\n break;\n case \"ArrowRight\":\n direction = \"right\";\n break;\n case \"Home\":\n direction = isCtrlPressed ? \"ctrl+home\" : \"home\";\n break;\n case \"End\":\n direction = isCtrlPressed ? \"ctrl+end\" : \"end\";\n break;\n case \"PageUp\":\n direction = \"pageup\";\n break;\n case \"PageDown\":\n direction = \"pagedown\";\n break;\n case \"Escape\":\n event.preventDefault();\n if (\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0\n ) {\n clearSelection();\n } else {\n blurCell();\n }\n return;\n case \"Tab\":\n event.preventDefault();\n direction = event.shiftKey ? \"left\" : \"right\";\n break;\n }\n\n if (direction) {\n event.preventDefault();\n\n // Tab navigation should not trigger selection, even with Shift\n if (shiftKey && key !== \"Tab\" && currentState.focusedCell) {\n const currentColIndex = navigableColumnIds.indexOf(\n currentState.focusedCell.columnId,\n );\n let newRowIndex = currentState.focusedCell.rowIndex;\n let newColumnId = currentState.focusedCell.columnId;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, currentState.focusedCell.rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(\n (tableRef.current?.getRowModel().rows.length || data.length) -\n 1,\n currentState.focusedCell.rowIndex + 1,\n );\n break;\n case \"left\":\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n break;\n case \"right\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n break;\n }\n\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n selectRange(selectionStart, {\n rowIndex: newRowIndex,\n columnId: newColumnId,\n });\n focusCell(newRowIndex, newColumnId);\n } else {\n if (currentState.selectionState.selectedCells.size > 0) {\n clearSelection();\n }\n navigateCell(direction);\n }\n }\n },\n [\n store,\n blurCell,\n navigateCell,\n selectAll,\n onDataUpdate,\n clearSelection,\n navigableColumnIds,\n data.length,\n selectRange,\n focusCell,\n onSearchOpenChange,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n enableSearch,\n ],\n );\n\n const onSortingChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newSorting =\n typeof updater === \"function\" ? updater(currentState.sorting) : updater;\n store.setState(\"sorting\", newSorting);\n },\n [store],\n );\n\n const onRowSelectionChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newRowSelection =\n typeof updater === \"function\"\n ? updater(currentState.rowSelection)\n : updater;\n\n const selectedRows = Object.keys(newRowSelection).filter(\n (key) => newRowSelection[key],\n );\n\n const selectedCells = new Set();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n\n for (const rowId of selectedRows) {\n const rowIndex = rows.findIndex((r) => r.id === rowId);\n if (rowIndex === -1) continue;\n\n for (const columnId of columnIds) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n }\n\n store.batch(() => {\n store.setState(\"rowSelection\", newRowSelection);\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"focusedCell\", null);\n store.setState(\"editingCell\", null);\n });\n },\n [store, columnIds],\n );\n\n const onRowSelect = React.useCallback(\n (rowIndex: number, selected: boolean, shiftKey: boolean) => {\n const currentState = store.getState();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n const currentRow = rows[rowIndex];\n if (!currentRow) return;\n\n if (shiftKey && currentState.lastClickedRowIndex !== null) {\n const startIndex = Math.min(currentState.lastClickedRowIndex, rowIndex);\n const endIndex = Math.max(currentState.lastClickedRowIndex, rowIndex);\n\n const newRowSelection: RowSelectionState = {\n ...currentState.rowSelection,\n };\n\n for (let i = startIndex; i <= endIndex; i++) {\n const row = rows[i];\n if (row) {\n newRowSelection[row.id] = selected;\n }\n }\n\n onRowSelectionChange(newRowSelection);\n } else {\n onRowSelectionChange({\n ...currentState.rowSelection,\n [currentRow.id]: selected,\n });\n }\n\n store.setState(\"lastClickedRowIndex\", rowIndex);\n },\n [store, onRowSelectionChange],\n );\n\n const onRowHeightChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newRowHeight =\n typeof updater === \"function\"\n ? updater(currentState.rowHeight)\n : updater;\n store.setState(\"rowHeight\", newRowHeight);\n },\n [store],\n );\n\n const onColumnClick = React.useCallback(\n (columnId: string) => {\n if (!enableColumnSelection) {\n clearSelection();\n return;\n }\n\n selectColumn(columnId);\n },\n [enableColumnSelection, selectColumn, clearSelection],\n );\n\n const defaultColumn: Partial> = React.useMemo(\n () => ({\n cell: DataGridCell,\n minSize: MIN_COLUMN_SIZE,\n maxSize: MAX_COLUMN_SIZE,\n }),\n [],\n );\n\n const tableOptions = React.useMemo>(\n () => ({\n ...dataGridPropsRef.current,\n data,\n columns,\n defaultColumn,\n initialState,\n state: {\n ...dataGridPropsRef.current.state,\n sorting,\n rowSelection,\n },\n onRowSelectionChange,\n onSortingChange,\n columnResizeMode: \"onChange\",\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n meta: {\n ...dataGridPropsRef.current.meta,\n dataGridRef,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n rowHeight,\n isScrolling,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n onRowHeightChange,\n onRowSelect,\n onRowsDelete: onRowsDeleteProp ? onRowsDelete : undefined,\n onDataUpdate,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n contextMenu,\n onContextMenuOpenChange,\n },\n }),\n [\n dataGridPropsRef,\n data,\n columns,\n defaultColumn,\n initialState,\n sorting,\n rowSelection,\n onRowSelectionChange,\n onSortingChange,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n isScrolling,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n onDataUpdate,\n onRowsDeleteProp,\n onRowsDelete,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n contextMenu,\n onContextMenuOpenChange,\n rowHeight,\n onRowHeightChange,\n onRowSelect,\n ],\n );\n\n const table = useReactTable(tableOptions);\n\n if (!tableRef.current) {\n tableRef.current = table;\n }\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: we need to memoize the column size vars\n const columnSizeVars = React.useMemo(() => {\n const headers = table.getFlatHeaders();\n const colSizes: { [key: string]: number } = {};\n for (const header of headers) {\n colSizes[`--header-${header.id}-size`] = header.getSize();\n colSizes[`--col-${header.column.id}-size`] = header.column.getSize();\n }\n return colSizes;\n }, [table.getState().columnSizingInfo, table.getState().columnSizing]);\n\n const rowVirtualizer = useVirtualizer({\n count: table.getRowModel().rows.length,\n getScrollElement: () => dataGridRef.current,\n estimateSize: () => rowHeightValue,\n overscan,\n measureElement:\n typeof window !== \"undefined\" &&\n navigator.userAgent.indexOf(\"Firefox\") === -1\n ? (element) => element?.getBoundingClientRect().height\n : undefined,\n onChange: (instance) => {\n // Sync virtualizer's isScrolling state to our store\n const virtualizerIsScrolling = instance.isScrolling;\n const currentIsScrolling = store.getState().isScrolling;\n\n if (virtualizerIsScrolling !== currentIsScrolling) {\n store.setState(\"isScrolling\", virtualizerIsScrolling);\n }\n\n // Batch DOM updates in a single animation frame\n const virtualItems = instance.getVirtualItems();\n if (virtualItems.length === 0) return;\n\n requestAnimationFrame(() => {\n for (let i = 0; i < virtualItems.length; i++) {\n const virtualRow = virtualItems[i];\n if (!virtualRow) continue;\n const rowRef = rowMapRef.current.get(virtualRow.index);\n if (rowRef) {\n rowRef.style.transform = `translateY(${virtualRow.start}px)`;\n }\n }\n });\n },\n });\n\n if (!rowVirtualizerRef.current) {\n rowVirtualizerRef.current = rowVirtualizer;\n }\n\n const onScrollToRow = React.useCallback(\n async (opts: Partial) => {\n const rowIndex = opts?.rowIndex ?? 0;\n const columnId = opts?.columnId;\n\n rowVirtualizer.scrollToIndex(rowIndex, {\n align: \"center\",\n });\n\n const targetColumnId = columnId ?? navigableColumnIds[0];\n\n if (!targetColumnId) return;\n\n queueMicrotask(() => {\n requestAnimationFrame(() => {\n requestAnimationFrame(() => {\n store.batch(() => {\n store.setState(\"focusedCell\", {\n rowIndex,\n columnId: targetColumnId,\n });\n store.setState(\"editingCell\", null);\n });\n });\n });\n });\n },\n [rowVirtualizer, navigableColumnIds, store],\n );\n\n const onRowAdd = React.useCallback(\n async (event?: React.MouseEvent) => {\n if (!onRowAddProp) return;\n\n const result = await onRowAddProp(event);\n\n if (event?.defaultPrevented || result === null) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n\n if (result) {\n const adjustedRowIndex =\n (result.rowIndex ?? 0) >= rows.length ? rows.length : result.rowIndex;\n\n onScrollToRow({\n rowIndex: adjustedRowIndex,\n columnId: result.columnId,\n });\n return;\n }\n\n onScrollToRow({ rowIndex: rows.length });\n },\n [onRowAddProp, onScrollToRow],\n );\n\n const searchState = React.useMemo(() => {\n if (!enableSearch) return undefined;\n\n return {\n searchMatches,\n matchIndex,\n searchOpen,\n onSearchOpenChange,\n searchQuery,\n onSearchQueryChange,\n onSearch,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n };\n }, [\n enableSearch,\n searchMatches,\n matchIndex,\n searchOpen,\n onSearchOpenChange,\n searchQuery,\n onSearchQueryChange,\n onSearch,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n ]);\n\n React.useEffect(() => {\n const dataGridElement = dataGridRef.current;\n if (!dataGridElement) return;\n\n dataGridElement.addEventListener(\"keydown\", onDataGridKeyDown);\n return () => {\n dataGridElement.removeEventListener(\"keydown\", onDataGridKeyDown);\n };\n }, [onDataGridKeyDown]);\n\n React.useEffect(() => {\n function onGlobalKeyDown(event: KeyboardEvent) {\n const dataGridElement = dataGridRef.current;\n if (!dataGridElement) return;\n\n const target = event.target;\n if (!(target instanceof HTMLElement)) return;\n\n const { key, ctrlKey, metaKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n if (enableSearch && isCtrlPressed && key === SEARCH_SHORTCUT_KEY) {\n const isInInput =\n target.tagName === \"INPUT\" || target.tagName === \"TEXTAREA\";\n const isInDataGrid = dataGridElement.contains(target);\n const isInSearchInput = target.closest('[role=\"search\"]') !== null;\n\n if (isInDataGrid || isInSearchInput || !isInInput) {\n event.preventDefault();\n event.stopPropagation();\n onSearchOpenChange(true);\n\n if (!isInDataGrid && !isInSearchInput) {\n requestAnimationFrame(() => {\n dataGridElement.focus();\n });\n }\n return;\n }\n }\n\n const isInDataGrid = dataGridElement.contains(target);\n if (!isInDataGrid) return;\n\n if (key === \"Escape\") {\n const currentState = store.getState();\n const hasSelections =\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0;\n\n if (hasSelections) {\n event.preventDefault();\n event.stopPropagation();\n clearSelection();\n }\n }\n }\n\n window.addEventListener(\"keydown\", onGlobalKeyDown, true);\n return () => {\n window.removeEventListener(\"keydown\", onGlobalKeyDown, true);\n };\n }, [enableSearch, onSearchOpenChange, store, clearSelection]);\n\n React.useEffect(() => {\n const currentState = store.getState();\n if (\n autoFocus &&\n data.length > 0 &&\n columns.length > 0 &&\n !currentState.focusedCell\n ) {\n if (navigableColumnIds.length > 0) {\n const rafId = requestAnimationFrame(() => {\n if (typeof autoFocus === \"object\") {\n const { rowIndex, columnId } = autoFocus;\n if (columnId) {\n focusCell(rowIndex ?? 0, columnId);\n }\n return;\n }\n\n const firstColumnId = navigableColumnIds[0];\n if (firstColumnId) {\n focusCell(0, firstColumnId);\n }\n });\n return () => cancelAnimationFrame(rafId);\n }\n }\n }, [\n autoFocus,\n data.length,\n columns.length,\n store,\n navigableColumnIds,\n focusCell,\n ]);\n\n React.useEffect(() => {\n function onOutsideClick(event: MouseEvent) {\n if (event.button === 2) {\n return;\n }\n\n if (\n dataGridRef.current &&\n !dataGridRef.current.contains(event.target as Node)\n ) {\n const target = event.target;\n const isInsidePopover =\n target instanceof HTMLElement &&\n (target.closest(\"[data-grid-cell-editor]\") ||\n target.closest(\"[data-grid-popover]\"));\n\n if (!isInsidePopover) {\n blurCell();\n const currentState = store.getState();\n if (\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0\n ) {\n clearSelection();\n }\n }\n }\n }\n\n document.addEventListener(\"mousedown\", onOutsideClick);\n return () => {\n document.removeEventListener(\"mousedown\", onOutsideClick);\n };\n }, [store, blurCell, clearSelection]);\n\n React.useEffect(() => {\n function onCleanup() {\n document.removeEventListener(\"selectstart\", preventSelection);\n document.removeEventListener(\"contextmenu\", preventContextMenu);\n document.body.style.userSelect = \"\";\n }\n\n function preventSelection(event: Event) {\n event.preventDefault();\n }\n function preventContextMenu(event: Event) {\n event.preventDefault();\n }\n\n const onUnsubscribe = store.subscribe(() => {\n const currentState = store.getState();\n if (currentState.selectionState.isSelecting) {\n document.addEventListener(\"selectstart\", preventSelection);\n document.addEventListener(\"contextmenu\", preventContextMenu);\n document.body.style.userSelect = \"none\";\n } else {\n onCleanup();\n }\n });\n\n return () => {\n onCleanup();\n onUnsubscribe();\n };\n }, [store]);\n\n useIsomorphicLayoutEffect(() => {\n const rafId = requestAnimationFrame(() => {\n rowVirtualizer.measure();\n });\n return () => cancelAnimationFrame(rafId);\n }, [\n table.getState().columnFilters,\n table.getState().columnOrder,\n table.getState().columnPinning,\n table.getState().columnSizing,\n table.getState().columnVisibility,\n table.getState().expanded,\n table.getState().globalFilter,\n table.getState().grouping,\n table.getState().rowSelection,\n table.getState().sorting,\n rowHeight,\n ]);\n\n return {\n dataGridRef,\n headerRef,\n rowMapRef,\n footerRef,\n table,\n rowVirtualizer,\n searchState,\n columnSizeVars,\n onRowAdd: onRowAddProp ? onRowAdd : undefined,\n };\n}\n\nexport { useDataGrid, type UseDataGridProps };\n", "type": "registry:hook" }, { diff --git a/src/components/data-grid/data-grid-demo.tsx b/src/components/data-grid/data-grid-demo.tsx index 377635da..369171b8 100644 --- a/src/components/data-grid/data-grid-demo.tsx +++ b/src/components/data-grid/data-grid-demo.tsx @@ -8,7 +8,7 @@ import { DataGridRowHeightMenu } from "@/components/data-grid/data-grid-row-heig import { DataGridSortMenu } from "@/components/data-grid/data-grid-sort-menu"; import { DataGridViewMenu } from "@/components/data-grid/data-grid-view-menu"; import { Checkbox } from "@/components/ui/checkbox"; -import { useDataGrid } from "@/hooks/use-data-grid"; +import { type UseDataGridProps, useDataGrid } from "@/hooks/use-data-grid"; import { useWindowSize } from "@/hooks/use-window-size"; interface Person { @@ -284,23 +284,31 @@ export function DataGridDemo() { [], ); - const onRowAdd = React.useCallback(() => { - const newId = data.length + 1; - setData((prev) => [ - ...prev, - { - id: newId.toString(), - }, - ]); + const onRowAdd: NonNullable["onRowAdd"]> = + React.useCallback(() => { + // In a real app, you would make a server call here: + // await fetch('/api/people', { + // method: 'POST', + // body: JSON.stringify({ name: 'New Person' }) + // }); - return { - rowIndex: data.length, - columnId: "name", - }; - }, [data.length]); + // For this demo, just add a new row to the data + const newId = data.length + 1; + setData((prev) => [ + ...prev, + { + id: newId.toString(), + }, + ]); + + return { + rowIndex: data.length, + columnId: "name", + }; + }, [data.length]); - const onRowsDelete = React.useCallback( - ({ rows }: { rows: Person[]; rowIndices: number[] }) => { + const onRowsDelete: NonNullable["onRowsDelete"]> = + React.useCallback((rows) => { // In a real app, you would make a server call here: // await fetch('/api/people', { // method: 'DELETE', @@ -309,9 +317,7 @@ export function DataGridDemo() { // For this demo, just filter out the deleted rows setData((prev) => prev.filter((row) => !rows.includes(row))); - }, - [], - ); + }, []); const { table, ...dataGridProps } = useDataGrid({ columns, diff --git a/src/hooks/use-data-grid.tsx b/src/hooks/use-data-grid.tsx index 01e02c87..1755f7b7 100644 --- a/src/hooks/use-data-grid.tsx +++ b/src/hooks/use-data-grid.tsx @@ -101,10 +101,7 @@ interface UseDataGridProps | null // biome-ignore lint/suspicious/noConfusingVoidType: void is needed here to allow functions without explicit return | void; - onRowsDelete?: (props: { - rows: TData[]; - rowIndices: number[]; - }) => void | Promise; + onRowsDelete?: (rows: TData[], rowIndices: number[]) => void | Promise; rowHeight?: RowHeightValue; overscan?: number; autoFocus?: boolean | Partial; @@ -112,7 +109,7 @@ interface UseDataGridProps enableSearch?: boolean; } -export function useDataGrid({ +function useDataGrid({ columns, data, onDataChange, @@ -447,10 +444,7 @@ export function useDataGrid({ } } - await onRowsDeleteProp({ - rows: rowsToDelete, - rowIndices, - }); + await onRowsDeleteProp(rowsToDelete, rowIndices); store.batch(() => { store.setState("selectionState", { @@ -1780,3 +1774,5 @@ export function useDataGrid({ onRowAdd: onRowAddProp ? onRowAdd : undefined, }; } + +export { useDataGrid, type UseDataGridProps };