diff --git a/public/r/data-grid.json b/public/r/data-grid.json index 20b6c6d99..ca75d1dbe 100644 --- a/public/r/data-grid.json +++ b/public/r/data-grid.json @@ -35,7 +35,7 @@ }, { "path": "src/components/data-grid/data-grid-cell.tsx", - "content": "\"use client\";\n\nimport type { Cell, Table } from \"@tanstack/react-table\";\nimport * as React from \"react\";\n\nimport {\n CheckboxCell,\n DateCell,\n FileCell,\n LongTextCell,\n MultiSelectCell,\n NumberCell,\n SelectCell,\n ShortTextCell,\n UrlCell,\n} from \"@/components/data-grid/data-grid-cell-variants\";\n\ninterface DataGridCellProps {\n cell: Cell;\n table: Table;\n}\n\nexport function DataGridCell({ cell, table }: DataGridCellProps) {\n const meta = table.options.meta;\n const originalRowIndex = cell.row.index;\n\n const rows = table.getRowModel().rows;\n const displayRowIndex = rows.findIndex(\n (row) => row.original === cell.row.original,\n );\n const rowIndex = displayRowIndex >= 0 ? displayRowIndex : originalRowIndex;\n const columnId = cell.column.id;\n\n const isFocused =\n meta?.focusedCell?.rowIndex === rowIndex &&\n meta?.focusedCell?.columnId === columnId;\n const isEditing =\n meta?.editingCell?.rowIndex === rowIndex &&\n meta?.editingCell?.columnId === columnId;\n const isSelected = meta?.getIsCellSelected?.(rowIndex, columnId) ?? false;\n\n const cellOpts = cell.column.columnDef.meta?.cell;\n const variant = cellOpts?.variant ?? \"text\";\n\n switch (variant) {\n case \"short-text\":\n return (\n \n );\n case \"long-text\":\n return (\n \n );\n case \"number\":\n return (\n \n );\n case \"url\":\n return (\n \n );\n case \"checkbox\":\n return (\n \n );\n case \"select\":\n return (\n \n );\n case \"multi-select\":\n return (\n \n );\n case \"date\":\n return (\n \n );\n case \"file\":\n return (\n \n );\n\n default:\n return (\n \n );\n }\n}\n", + "content": "\"use client\";\n\nimport type { Cell, Table } from \"@tanstack/react-table\";\nimport * as React from \"react\";\n\nimport {\n CheckboxCell,\n DateCell,\n FileCell,\n LongTextCell,\n MultiSelectCell,\n NumberCell,\n SelectCell,\n ShortTextCell,\n UrlCell,\n} from \"@/components/data-grid/data-grid-cell-variants\";\n\ninterface DataGridCellProps {\n cell: Cell;\n table: Table;\n}\n\nexport function DataGridCell({ cell, table }: DataGridCellProps) {\n const meta = table.options.meta;\n const originalRowIndex = cell.row.index;\n\n const rows = table.getRowModel().rows;\n const displayRowIndex = rows.findIndex(\n (row) => row.original === cell.row.original,\n );\n const rowIndex = displayRowIndex >= 0 ? displayRowIndex : originalRowIndex;\n const columnId = cell.column.id;\n\n const isFocused =\n meta?.focusedCell?.rowIndex === rowIndex &&\n meta?.focusedCell?.columnId === columnId;\n const isEditing =\n meta?.editingCell?.rowIndex === rowIndex &&\n meta?.editingCell?.columnId === columnId;\n const isSelected = meta?.getIsCellSelected?.(rowIndex, columnId) ?? false;\n const readOnly = meta?.readOnly ?? false;\n\n const cellOpts = cell.column.columnDef.meta?.cell;\n const variant = cellOpts?.variant ?? \"text\";\n\n switch (variant) {\n case \"short-text\":\n return (\n \n );\n case \"long-text\":\n return (\n \n );\n case \"number\":\n return (\n \n );\n case \"url\":\n return (\n \n );\n case \"checkbox\":\n return (\n \n );\n case \"select\":\n return (\n \n );\n case \"multi-select\":\n return (\n \n );\n case \"date\":\n return (\n \n );\n case \"file\":\n return (\n \n );\n\n default:\n return (\n \n );\n }\n}\n", "type": "registry:component", "target": "src/components/data-grid/data-grid-cell.tsx" }, @@ -47,7 +47,7 @@ }, { "path": "src/components/data-grid/data-grid-cell-variants.tsx", - "content": "\"use client\";\n\nimport {\n Check,\n File,\n FileArchive,\n FileAudio,\n FileImage,\n FileSpreadsheet,\n FileText,\n FileVideo,\n Presentation,\n Upload,\n X,\n} from \"lucide-react\";\nimport * as React from \"react\";\nimport { toast } from \"sonner\";\nimport { DataGridCellWrapper } from \"@/components/data-grid/data-grid-cell-wrapper\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n} from \"@/components/ui/command\";\nimport {\n Popover,\n PopoverAnchor,\n PopoverContent,\n} from \"@/components/ui/popover\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { useBadgeOverflow } from \"@/hooks/use-badge-overflow\";\nimport { useDebouncedCallback } from \"@/hooks/use-debounced-callback\";\nimport { getCellKey, getLineCount } from \"@/lib/data-grid\";\nimport { cn } from \"@/lib/utils\";\nimport type { CellVariantProps, FileCellData } from \"@/types/data-grid\";\n\nexport function ShortTextCell({\n cell,\n table,\n rowIndex,\n columnId,\n isEditing,\n isFocused,\n isSelected,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue);\n const cellRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue);\n if (cellRef.current && !isEditing) {\n cellRef.current.textContent = initialValue;\n }\n }\n\n const onBlur = React.useCallback(() => {\n // Read the current value directly from the DOM to avoid stale state\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (currentValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: currentValue });\n }\n meta?.onCellEditingStop?.();\n }, [meta, rowIndex, columnId, initialValue]);\n\n const onInput = React.useCallback(\n (event: React.FormEvent) => {\n const currentValue = event.currentTarget.textContent ?? \"\";\n setValue(currentValue);\n },\n [],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Enter\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (currentValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: currentValue });\n }\n meta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (currentValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: currentValue });\n }\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n cellRef.current?.blur();\n }\n } else if (\n isFocused &&\n event.key.length === 1 &&\n !event.ctrlKey &&\n !event.metaKey\n ) {\n // Handle typing to pre-fill the value when editing starts\n setValue(event.key);\n\n queueMicrotask(() => {\n if (cellRef.current && cellRef.current.contentEditable === \"true\") {\n cellRef.current.textContent = event.key;\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n });\n }\n },\n [isEditing, isFocused, initialValue, meta, rowIndex, columnId],\n );\n\n React.useEffect(() => {\n if (isEditing && cellRef.current) {\n cellRef.current.focus();\n\n if (!cellRef.current.textContent && value) {\n cellRef.current.textContent = value;\n }\n\n if (cellRef.current.textContent) {\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n }\n }, [isEditing, value]);\n\n const displayValue = !isEditing ? (value ?? \"\") : \"\";\n\n return (\n \n \n {displayValue}\n \n \n );\n}\n\nexport function LongTextCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const textareaRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n const sideOffset = -(containerRef.current?.clientHeight ?? 0);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n }\n\n // Debounced auto-save (300ms delay)\n const debouncedSave = useDebouncedCallback((newValue: string) => {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: newValue });\n }, 300);\n\n const onSave = React.useCallback(() => {\n // Immediately save any pending changes and close the popover\n if (value !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n meta?.onCellEditingStop?.();\n }, [meta, value, initialValue, rowIndex, columnId]);\n\n const onCancel = React.useCallback(() => {\n // Restore the original value\n setValue(initialValue ?? \"\");\n meta?.onDataUpdate?.({ rowIndex, columnId, value: initialValue });\n meta?.onCellEditingStop?.();\n }, [meta, initialValue, rowIndex, columnId]);\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n if (isOpen) {\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n // Immediately save any pending changes when closing\n if (value !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n meta?.onCellEditingStop?.();\n }\n },\n [meta, value, initialValue, rowIndex, columnId],\n );\n\n const onOpenAutoFocus: NonNullable<\n React.ComponentProps[\"onOpenAutoFocus\"]\n > = React.useCallback((event) => {\n event.preventDefault();\n if (textareaRef.current) {\n textareaRef.current.focus();\n const length = textareaRef.current.value.length;\n textareaRef.current.setSelectionRange(length, length);\n }\n }, []);\n\n const onBlur = React.useCallback(() => {\n // Immediately save any pending changes on blur\n if (value !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n meta?.onCellEditingStop?.();\n }, [meta, value, initialValue, rowIndex, columnId]);\n\n const onChange = React.useCallback(\n (event: React.ChangeEvent) => {\n const newValue = event.target.value;\n setValue(newValue);\n // Debounced auto-save\n debouncedSave(newValue);\n },\n [debouncedSave],\n );\n\n const onKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n onCancel();\n } else if (event.key === \"Enter\" && (event.ctrlKey || event.metaKey)) {\n event.preventDefault();\n onSave();\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n // Save any pending changes\n if (value !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n return;\n }\n // Stop propagation to prevent grid navigation\n event.stopPropagation();\n },\n [onSave, onCancel, value, initialValue, meta, rowIndex, columnId],\n );\n\n return (\n \n \n \n {value}\n \n \n \n \n \n \n );\n}\n\nexport function NumberCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as number;\n const [value, setValue] = React.useState(String(initialValue ?? \"\"));\n const inputRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n const cellOpts = cell.column.columnDef.meta?.cell;\n const min = cellOpts?.variant === \"number\" ? cellOpts.min : undefined;\n const max = cellOpts?.variant === \"number\" ? cellOpts.max : undefined;\n const step = cellOpts?.variant === \"number\" ? cellOpts.step : undefined;\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(String(initialValue ?? \"\"));\n }\n\n const onBlur = React.useCallback(() => {\n const numValue = value === \"\" ? null : Number(value);\n if (numValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n meta?.onCellEditingStop?.();\n }, [meta, rowIndex, columnId, initialValue, value]);\n\n const onChange = React.useCallback(\n (event: React.ChangeEvent) => {\n setValue(event.target.value);\n },\n [],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Enter\") {\n event.preventDefault();\n const numValue = value === \"\" ? null : Number(value);\n if (numValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n meta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const numValue = value === \"\" ? null : Number(value);\n if (numValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(String(initialValue ?? \"\"));\n inputRef.current?.blur();\n }\n } else if (isFocused) {\n // Handle Backspace to start editing with empty value\n if (event.key === \"Backspace\") {\n setValue(\"\");\n } else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {\n // Handle typing to pre-fill the value when editing starts\n setValue(event.key);\n }\n }\n },\n [isEditing, isFocused, initialValue, meta, rowIndex, columnId, value],\n );\n\n React.useEffect(() => {\n if (isEditing && inputRef.current) {\n inputRef.current.focus();\n inputRef.current.select();\n }\n }, [isEditing]);\n\n return (\n \n {isEditing ? (\n \n ) : (\n {value}\n )}\n \n );\n}\n\nfunction getUrlHref(urlString: string): string {\n if (!urlString || urlString.trim() === \"\") return \"\";\n\n const trimmed = urlString.trim();\n\n // Reject dangerous protocols (extra safety, though our http:// prefix would neutralize them)\n if (/^(javascript|data|vbscript|file):/i.test(trimmed)) {\n return \"\";\n }\n\n // Check if it already has a protocol\n if (trimmed.startsWith(\"http://\") || trimmed.startsWith(\"https://\")) {\n return trimmed;\n }\n\n // Add http:// prefix for links without protocol\n return `http://${trimmed}`;\n}\n\nexport function UrlCell({\n cell,\n table,\n rowIndex,\n columnId,\n isEditing,\n isFocused,\n isSelected,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const cellRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n if (cellRef.current && !isEditing) {\n cellRef.current.textContent = initialValue ?? \"\";\n }\n }\n\n const onBlur = React.useCallback(() => {\n const currentValue = cellRef.current?.textContent?.trim() ?? \"\";\n\n if (currentValue !== initialValue) {\n meta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n meta?.onCellEditingStop?.();\n }, [meta, rowIndex, columnId, initialValue]);\n\n const onInput = React.useCallback(\n (event: React.FormEvent) => {\n const currentValue = event.currentTarget.textContent ?? \"\";\n setValue(currentValue);\n },\n [],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Enter\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent?.trim() ?? \"\";\n if (currentValue !== initialValue) {\n meta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n meta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent?.trim() ?? \"\";\n if (currentValue !== initialValue) {\n meta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue ?? \"\");\n cellRef.current?.blur();\n }\n } else if (\n isFocused &&\n event.key.length === 1 &&\n !event.ctrlKey &&\n !event.metaKey\n ) {\n // Handle typing to pre-fill the value when editing starts\n setValue(event.key);\n\n queueMicrotask(() => {\n if (cellRef.current && cellRef.current.contentEditable === \"true\") {\n cellRef.current.textContent = event.key;\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n });\n }\n },\n [isEditing, isFocused, initialValue, meta, rowIndex, columnId],\n );\n\n const onLinkClick = React.useCallback(\n (event: React.MouseEvent) => {\n if (isEditing) {\n event.preventDefault();\n return;\n }\n\n // Check if URL was rejected due to dangerous protocol\n const href = getUrlHref(value);\n if (!href) {\n event.preventDefault();\n toast.error(\"Invalid URL\", {\n description:\n \"URL contains a dangerous protocol (javascript:, data:, vbscript:, or file:)\",\n });\n return;\n }\n\n // Stop propagation to prevent grid from interfering with link navigation\n event.stopPropagation();\n },\n [isEditing, value],\n );\n\n React.useEffect(() => {\n if (isEditing && cellRef.current) {\n cellRef.current.focus();\n\n if (!cellRef.current.textContent && value) {\n cellRef.current.textContent = value;\n }\n\n if (cellRef.current.textContent) {\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n }\n }, [isEditing, value]);\n\n const displayValue = !isEditing ? (value ?? \"\") : \"\";\n const urlHref = displayValue ? getUrlHref(displayValue) : \"\";\n const isDangerousUrl = displayValue && !urlHref;\n\n return (\n \n {!isEditing && displayValue ? (\n \n \n {displayValue}\n \n \n ) : (\n \n {displayValue}\n \n )}\n \n );\n}\n\nexport function CheckboxCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isSelected,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as boolean;\n const [value, setValue] = React.useState(Boolean(initialValue));\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(Boolean(initialValue));\n }\n\n const onCheckedChange = React.useCallback(\n (checked: boolean) => {\n setValue(checked);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: checked });\n },\n [meta, rowIndex, columnId],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isFocused && (event.key === \" \" || event.key === \"Enter\")) {\n event.preventDefault();\n event.stopPropagation();\n onCheckedChange(!value);\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isFocused, value, onCheckedChange, meta],\n );\n\n const onWrapperClick = React.useCallback(\n (event: React.MouseEvent) => {\n if (isFocused) {\n event.preventDefault();\n event.stopPropagation();\n onCheckedChange(!value);\n }\n },\n [isFocused, value, onCheckedChange],\n );\n\n const onCheckboxClick = React.useCallback((event: React.MouseEvent) => {\n event.stopPropagation();\n }, []);\n\n const onCheckboxMouseDown = React.useCallback(\n (event: React.MouseEvent) => {\n event.stopPropagation();\n },\n [],\n );\n\n const onCheckboxDoubleClick = React.useCallback(\n (event: React.MouseEvent) => {\n event.stopPropagation();\n },\n [],\n );\n\n return (\n \n \n \n );\n}\n\nexport function SelectCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue);\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n const cellOpts = cell.column.columnDef.meta?.cell;\n const options = cellOpts?.variant === \"select\" ? cellOpts.options : [];\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue);\n }\n\n const onValueChange = React.useCallback(\n (newValue: string) => {\n setValue(newValue);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: newValue });\n meta?.onCellEditingStop?.();\n },\n [meta, rowIndex, columnId],\n );\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n if (isOpen) {\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n meta?.onCellEditingStop?.();\n }\n },\n [meta, rowIndex, columnId],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n meta?.onCellEditingStop?.();\n } else if (!isEditing && isFocused && event.key === \"Tab\") {\n event.preventDefault();\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, initialValue, meta],\n );\n\n const displayLabel =\n options.find((opt) => opt.value === value)?.label ?? value;\n\n return (\n \n {isEditing ? (\n \n \n \n \n \n {options.map((option) => (\n \n {option.label}\n \n ))}\n \n \n ) : (\n {displayLabel}\n )}\n \n );\n}\n\nexport function MultiSelectCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n}: CellVariantProps) {\n const cellValue = React.useMemo(() => {\n const value = cell.getValue() as string[];\n return value ?? [];\n }, [cell]);\n\n const cellKey = getCellKey(rowIndex, columnId);\n const prevCellKeyRef = React.useRef(cellKey);\n\n const [selectedValues, setSelectedValues] =\n React.useState(cellValue);\n const [searchValue, setSearchValue] = React.useState(\"\");\n const containerRef = React.useRef(null);\n const inputRef = React.useRef(null);\n const meta = table.options.meta;\n const cellOpts = cell.column.columnDef.meta?.cell;\n const options = cellOpts?.variant === \"multi-select\" ? cellOpts.options : [];\n const sideOffset = -(containerRef.current?.clientHeight ?? 0);\n\n const prevCellValueRef = React.useRef(cellValue);\n if (cellValue !== prevCellValueRef.current) {\n prevCellValueRef.current = cellValue;\n setSelectedValues(cellValue);\n }\n\n if (prevCellKeyRef.current !== cellKey) {\n prevCellKeyRef.current = cellKey;\n setSearchValue(\"\");\n }\n\n const onValueChange = React.useCallback(\n (value: string) => {\n const newValues = selectedValues.includes(value)\n ? selectedValues.filter((v) => v !== value)\n : [...selectedValues, value];\n\n setSelectedValues(newValues);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n setSearchValue(\"\");\n queueMicrotask(() => inputRef.current?.focus());\n },\n [selectedValues, meta, rowIndex, columnId],\n );\n\n const removeValue = React.useCallback(\n (valueToRemove: string, event?: React.MouseEvent) => {\n event?.stopPropagation();\n event?.preventDefault();\n const newValues = selectedValues.filter((v) => v !== valueToRemove);\n setSelectedValues(newValues);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n // Focus back on input after removing\n setTimeout(() => inputRef.current?.focus(), 0);\n },\n [selectedValues, meta, rowIndex, columnId],\n );\n\n const clearAll = React.useCallback(() => {\n setSelectedValues([]);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: [] });\n queueMicrotask(() => inputRef.current?.focus());\n }, [meta, rowIndex, columnId]);\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n if (isOpen) {\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n setSearchValue(\"\");\n meta?.onCellEditingStop?.();\n }\n },\n [meta, rowIndex, columnId],\n );\n\n const onOpenAutoFocus: NonNullable<\n React.ComponentProps[\"onOpenAutoFocus\"]\n > = React.useCallback((event) => {\n event.preventDefault();\n inputRef.current?.focus();\n }, []);\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && event.key === \"Escape\") {\n event.preventDefault();\n setSelectedValues(cellValue);\n setSearchValue(\"\");\n meta?.onCellEditingStop?.();\n } else if (!isEditing && isFocused && event.key === \"Tab\") {\n event.preventDefault();\n setSearchValue(\"\");\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, cellValue, meta],\n );\n\n const onInputKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n // Handle backspace when input is empty - remove last selected item\n if (\n event.key === \"Backspace\" &&\n searchValue === \"\" &&\n selectedValues.length > 0\n ) {\n event.preventDefault();\n const lastValue = selectedValues[selectedValues.length - 1];\n if (lastValue) {\n removeValue(lastValue);\n }\n }\n // Prevent escape from propagating to close the popover immediately\n // Let the command handle it first\n if (event.key === \"Escape\") {\n event.stopPropagation();\n }\n },\n [searchValue, selectedValues, removeValue],\n );\n\n const displayLabels = selectedValues\n .map((val) => options.find((opt) => opt.value === val)?.label ?? val)\n .filter(Boolean);\n\n const rowHeight = table.options.meta?.rowHeight ?? \"short\";\n const lineCount = getLineCount(rowHeight);\n\n const { visibleItems: visibleLabels, hiddenCount: hiddenBadgeCount } =\n useBadgeOverflow({\n items: displayLabels,\n getLabel: (label) => label,\n containerRef,\n lineCount,\n });\n\n return (\n \n {isEditing ? (\n \n \n
\n \n \n \n
\n {selectedValues.map((value) => {\n const option = options.find((opt) => opt.value === value);\n const label = option?.label ?? value;\n\n return (\n \n {label}\n removeValue(value, event)}\n onPointerDown={(event) => {\n event.preventDefault();\n event.stopPropagation();\n }}\n >\n \n \n \n );\n })}\n \n
\n \n No options found.\n \n {options.map((option) => {\n const isSelected = selectedValues.includes(option.value);\n\n return (\n onValueChange(option.value)}\n >\n \n \n
\n {option.label}\n \n );\n })}\n \n {selectedValues.length > 0 && (\n <>\n \n \n \n Clear all\n \n \n \n )}\n \n \n \n
\n ) : null}\n {displayLabels.length > 0 ? (\n
\n {visibleLabels.map((label, index) => (\n \n {label}\n \n ))}\n {hiddenBadgeCount > 0 && (\n \n +{hiddenBadgeCount}\n \n )}\n
\n ) : null}\n \n );\n}\n\nfunction formatDateForDisplay(dateStr: string) {\n if (!dateStr) return \"\";\n const date = new Date(dateStr);\n return date.toLocaleDateString();\n}\n\nexport function DateCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n }\n\n const selectedDate = value ? new Date(value) : undefined;\n\n const onDateSelect = React.useCallback(\n (date: Date | undefined) => {\n if (!date) return;\n\n const formattedDate = date.toISOString().split(\"T\")[0] ?? \"\";\n setValue(formattedDate);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: formattedDate });\n meta?.onCellEditingStop?.();\n },\n [meta, rowIndex, columnId],\n );\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n if (isOpen) {\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n meta?.onCellEditingStop?.();\n }\n },\n [meta, rowIndex, columnId],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n meta?.onCellEditingStop?.();\n } else if (!isEditing && isFocused && event.key === \"Tab\") {\n event.preventDefault();\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, initialValue, meta],\n );\n\n return (\n \n \n \n \n {formatDateForDisplay(value)}\n \n \n {isEditing && (\n \n \n \n )}\n \n \n );\n}\n\nfunction formatFileSize(bytes: number): string {\n if (bytes === 0) return \"0 B\";\n const k = 1024;\n const sizes = [\"B\", \"KB\", \"MB\", \"GB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;\n}\n\nfunction getFileIcon(\n type: string,\n): React.ComponentType> {\n if (type.startsWith(\"image/\")) return FileImage;\n if (type.startsWith(\"video/\")) return FileVideo;\n if (type.startsWith(\"audio/\")) return FileAudio;\n if (type.includes(\"pdf\")) return FileText;\n if (type.includes(\"zip\") || type.includes(\"rar\")) return FileArchive;\n if (\n type.includes(\"word\") ||\n type.includes(\"document\") ||\n type.includes(\"doc\")\n )\n return FileText;\n if (type.includes(\"sheet\") || type.includes(\"excel\") || type.includes(\"xls\"))\n return FileSpreadsheet;\n if (\n type.includes(\"presentation\") ||\n type.includes(\"powerpoint\") ||\n type.includes(\"ppt\")\n )\n return Presentation;\n return File;\n}\n\nexport function FileCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n}: CellVariantProps) {\n const cellValue = React.useMemo(\n () => (cell.getValue() as FileCellData[]) ?? [],\n [cell],\n );\n\n const cellKey = getCellKey(rowIndex, columnId);\n const prevCellKeyRef = React.useRef(cellKey);\n\n const labelId = React.useId();\n const descriptionId = React.useId();\n\n const [files, setFiles] = React.useState(cellValue);\n const [uploadingFiles, setUploadingFiles] = React.useState>(\n new Set(),\n );\n const [isDraggingOver, setIsDraggingOver] = React.useState(false);\n const [isDragging, setIsDragging] = React.useState(false);\n const [error, setError] = React.useState(null);\n const containerRef = React.useRef(null);\n const fileInputRef = React.useRef(null);\n const dropzoneRef = React.useRef(null);\n const meta = table.options.meta;\n const cellOpts = cell.column.columnDef.meta?.cell;\n const sideOffset = -(containerRef.current?.clientHeight ?? 0);\n\n const fileCellOpts = cellOpts?.variant === \"file\" ? cellOpts : null;\n const maxFileSize = fileCellOpts?.maxFileSize ?? 10 * 1024 * 1024;\n const maxFiles = fileCellOpts?.maxFiles ?? 10;\n const accept = fileCellOpts?.accept;\n const multiple = fileCellOpts?.multiple ?? true;\n\n const acceptedTypes = React.useMemo(\n () => (accept ? accept.split(\",\").map((t) => t.trim()) : null),\n [accept],\n );\n\n // Sync files when cellValue changes (same pattern as other cells)\n const prevCellValueRef = React.useRef(cellValue);\n if (cellValue !== prevCellValueRef.current) {\n prevCellValueRef.current = cellValue;\n // Revoke old object URLs to prevent memory leaks\n for (const file of files) {\n if (file.url) {\n URL.revokeObjectURL(file.url);\n }\n }\n setFiles(cellValue);\n setError(null);\n }\n\n if (prevCellKeyRef.current !== cellKey) {\n prevCellKeyRef.current = cellKey;\n setError(null);\n }\n\n const validateFile = React.useCallback(\n (file: File): string | null => {\n if (maxFileSize && file.size > maxFileSize) {\n return `File size exceeds ${formatFileSize(maxFileSize)}`;\n }\n if (acceptedTypes) {\n const fileExtension = `.${file.name.split(\".\").pop()}`;\n const isAccepted = acceptedTypes.some((type) => {\n if (type.endsWith(\"/*\")) {\n // Handle wildcard types like \"image/*\"\n const baseType = type.slice(0, -2);\n return file.type.startsWith(`${baseType}/`);\n }\n if (type.startsWith(\".\")) {\n // Handle file extensions like \".pdf\"\n return fileExtension.toLowerCase() === type.toLowerCase();\n }\n // Exact match for specific MIME types\n return file.type === type;\n });\n if (!isAccepted) {\n return \"File type not accepted\";\n }\n }\n return null;\n },\n [maxFileSize, acceptedTypes],\n );\n\n const addFiles = React.useCallback(\n async (newFiles: File[], skipUpload = false) => {\n setError(null);\n\n // Check max files limit\n if (maxFiles && files.length + newFiles.length > maxFiles) {\n const errorMessage = `Maximum ${maxFiles} files allowed`;\n setError(errorMessage);\n toast(errorMessage);\n setTimeout(() => {\n setError(null);\n }, 2000);\n return;\n }\n\n const validFiles: FileCellData[] = [];\n const rejectedFiles: Array<{ name: string; reason: string }> = [];\n\n for (const file of newFiles) {\n const validationError = validateFile(file);\n if (validationError) {\n rejectedFiles.push({ name: file.name, reason: validationError });\n continue;\n }\n\n // Create file data object with temporary ID\n const fileData: FileCellData = {\n id: crypto.randomUUID(),\n name: file.name,\n size: file.size,\n type: file.type,\n url: URL.createObjectURL(file),\n };\n validFiles.push(fileData);\n }\n\n if (rejectedFiles.length > 0) {\n const firstError = rejectedFiles[0];\n if (firstError) {\n setError(firstError.reason);\n\n const truncatedName =\n firstError.name.length > 20\n ? `${firstError.name.slice(0, 20)}...`\n : firstError.name;\n\n if (rejectedFiles.length === 1) {\n toast(firstError.reason, {\n description: `\"${truncatedName}\" has been rejected`,\n });\n } else {\n toast(firstError.reason, {\n description: `\"${truncatedName}\" and ${rejectedFiles.length - 1} more rejected`,\n });\n }\n\n setTimeout(() => {\n setError(null);\n }, 2000);\n }\n }\n\n if (validFiles.length > 0) {\n // If not skipping upload (dropped on cell), show skeletons first\n if (!skipUpload) {\n // Add temp files immediately (will show as skeletons)\n const tempFiles = validFiles.map((f) => ({ ...f, url: undefined }));\n const filesWithTemp = [...files, ...tempFiles];\n setFiles(filesWithTemp);\n\n // Mark as uploading\n const uploadingIds = new Set(validFiles.map((f) => f.id));\n setUploadingFiles(uploadingIds);\n\n // Simulate upload delay (in real app, this would be actual upload)\n await new Promise((resolve) => setTimeout(resolve, 800));\n\n // Replace temp files with real ones\n const finalFiles = filesWithTemp.map(\n (f) => validFiles.find((vf) => vf.id === f.id) || f,\n );\n setFiles(finalFiles);\n setUploadingFiles(new Set());\n meta?.onDataUpdate?.({ rowIndex, columnId, value: finalFiles });\n } else {\n // If from editor, add immediately without skeleton\n const updatedFiles = [...files, ...validFiles];\n setFiles(updatedFiles);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles });\n }\n }\n },\n [files, maxFiles, validateFile, meta, rowIndex, columnId],\n );\n\n const removeFile = React.useCallback(\n (fileId: string) => {\n setError(null);\n // Revoke object URL to prevent memory leak\n const fileToRemove = files.find((f) => f.id === fileId);\n if (fileToRemove?.url) {\n URL.revokeObjectURL(fileToRemove.url);\n }\n const updatedFiles = files.filter((f) => f.id !== fileId);\n setFiles(updatedFiles);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles });\n },\n [files, meta, rowIndex, columnId],\n );\n\n const clearAll = React.useCallback(() => {\n // Revoke all object URLs to prevent memory leak\n for (const file of files) {\n if (file.url) {\n URL.revokeObjectURL(file.url);\n }\n }\n setFiles([]);\n setError(null);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: [] });\n }, [files, meta, rowIndex, columnId]);\n\n const onDragEnter = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDragging(true);\n }, []);\n\n const onDragLeave = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n const rect = event.currentTarget.getBoundingClientRect();\n const x = event.clientX;\n const y = event.clientY;\n\n if (\n x <= rect.left ||\n x >= rect.right ||\n y <= rect.top ||\n y >= rect.bottom\n ) {\n setIsDragging(false);\n }\n }, []);\n\n const onDragOver = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n }, []);\n\n const onDrop = React.useCallback(\n (event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDragging(false);\n\n const droppedFiles = Array.from(event.dataTransfer.files);\n addFiles(droppedFiles, true); // Skip upload skeleton in editor\n },\n [addFiles],\n );\n\n const onFileInputChange = React.useCallback(\n (event: React.ChangeEvent) => {\n const selectedFiles = Array.from(event.target.files ?? []);\n addFiles(selectedFiles, true); // Skip upload skeleton for manual selection\n // Reset input so the same file can be selected again\n event.target.value = \"\";\n },\n [addFiles],\n );\n\n // Cell-level drag handlers (for dropping directly on cell)\n const onCellDragEnter = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n // Only show drop indicator if dragging files\n if (event.dataTransfer.types.includes(\"Files\")) {\n setIsDraggingOver(true);\n }\n }, []);\n\n const onCellDragLeave = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n const rect = event.currentTarget.getBoundingClientRect();\n const x = event.clientX;\n const y = event.clientY;\n\n if (\n x <= rect.left ||\n x >= rect.right ||\n y <= rect.top ||\n y >= rect.bottom\n ) {\n setIsDraggingOver(false);\n }\n }, []);\n\n const onCellDragOver = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n }, []);\n\n const onCellDrop = React.useCallback(\n (event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDraggingOver(false);\n\n const droppedFiles = Array.from(event.dataTransfer.files);\n if (droppedFiles.length > 0) {\n addFiles(droppedFiles, false); // Show skeleton for dropped files\n }\n },\n [addFiles],\n );\n\n const onDropzoneClick = React.useCallback(() => {\n fileInputRef.current?.click();\n }, []);\n\n const onDropzoneKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault();\n onDropzoneClick();\n }\n },\n [onDropzoneClick],\n );\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n if (isOpen) {\n setError(null);\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n setError(null);\n meta?.onCellEditingStop?.();\n }\n },\n [meta, rowIndex, columnId],\n );\n\n const onEscapeKeyDown: NonNullable<\n React.ComponentProps[\"onEscapeKeyDown\"]\n > = React.useCallback((event) => {\n // Prevent the escape key from propagating to the data grid's keyboard handler\n // which would call blurCell() and remove focus from the cell\n event.stopPropagation();\n }, []);\n\n const onOpenAutoFocus: NonNullable<\n React.ComponentProps[\"onOpenAutoFocus\"]\n > = React.useCallback((event) => {\n event.preventDefault();\n // Focus the dropzone for better keyboard UX - users can press Enter again to open file dialog\n queueMicrotask(() => {\n dropzoneRef.current?.focus();\n });\n }, []);\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Escape\") {\n event.preventDefault();\n setFiles(cellValue);\n setError(null);\n meta?.onCellEditingStop?.();\n } else if (event.key === \" \") {\n event.preventDefault();\n onDropzoneClick();\n }\n } else if (isFocused && event.key === \"Enter\") {\n // Handle Enter key to start editing when focused but not editing\n event.preventDefault();\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else if (!isEditing && isFocused && event.key === \"Tab\") {\n event.preventDefault();\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [\n isEditing,\n isFocused,\n cellValue,\n meta,\n onDropzoneClick,\n rowIndex,\n columnId,\n ],\n );\n\n React.useEffect(() => {\n return () => {\n for (const file of files) {\n if (file.url) {\n URL.revokeObjectURL(file.url);\n }\n }\n };\n }, [files]);\n\n const rowHeight = table.options.meta?.rowHeight ?? \"short\";\n const lineCount = getLineCount(rowHeight);\n\n const { visibleItems: visibleFiles, hiddenCount: hiddenFileCount } =\n useBadgeOverflow({\n items: files,\n getLabel: (file) => file.name,\n containerRef,\n lineCount,\n cacheKeyPrefix: \"file\",\n iconSize: 12,\n maxWidth: 100,\n });\n\n return (\n \n {isEditing ? (\n \n \n
\n \n \n
\n \n File upload\n \n \n \n
\n

\n {isDragging ? \"Drop files here\" : \"Drag files here\"}\n

\n

\n or click to browse\n

\n
\n

\n {maxFileSize\n ? `Max size: ${formatFileSize(maxFileSize)}${maxFiles ? ` • Max ${maxFiles} files` : \"\"}`\n : maxFiles\n ? `Max ${maxFiles} files`\n : \"Select files to upload\"}\n

\n
\n \n {files.length > 0 && (\n
\n
\n

\n {files.length} {files.length === 1 ? \"file\" : \"files\"}\n

\n \n Clear all\n \n
\n
\n {files.map((file) => {\n const FileIcon = getFileIcon(file.type);\n\n return (\n \n {FileIcon && (\n \n )}\n
\n

{file.name}

\n

\n {formatFileSize(file.size)}\n

\n
\n removeFile(file.id)}\n >\n \n \n
\n );\n })}\n
\n
\n )}\n \n \n
\n ) : null}\n {isDraggingOver ? (\n
\n \n Drop files here\n
\n ) : files.length > 0 ? (\n
\n {visibleFiles.map((file) => {\n const isUploading = uploadingFiles.has(file.id);\n\n if (isUploading) {\n // Show skeleton for uploading files\n return (\n \n );\n }\n\n return (\n \n {React.createElement(getFileIcon(file.type), {\n className: \"size-3 shrink-0\",\n })}\n {file.name}\n \n );\n })}\n {hiddenFileCount > 0 && (\n \n +{hiddenFileCount}\n \n )}\n
\n ) : null}\n \n );\n}\n", + "content": "\"use client\";\n\nimport {\n Check,\n File,\n FileArchive,\n FileAudio,\n FileImage,\n FileSpreadsheet,\n FileText,\n FileVideo,\n Presentation,\n Upload,\n X,\n} from \"lucide-react\";\nimport * as React from \"react\";\nimport { toast } from \"sonner\";\nimport { DataGridCellWrapper } from \"@/components/data-grid/data-grid-cell-wrapper\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n} from \"@/components/ui/command\";\nimport {\n Popover,\n PopoverAnchor,\n PopoverContent,\n} from \"@/components/ui/popover\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { useBadgeOverflow } from \"@/hooks/use-badge-overflow\";\nimport { useDebouncedCallback } from \"@/hooks/use-debounced-callback\";\nimport { getCellKey, getLineCount } from \"@/lib/data-grid\";\nimport { cn } from \"@/lib/utils\";\nimport type { CellVariantProps, FileCellData } from \"@/types/data-grid\";\n\nexport function ShortTextCell({\n cell,\n table,\n rowIndex,\n columnId,\n isEditing,\n isFocused,\n isSelected,\n readOnly,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue);\n const cellRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue);\n if (cellRef.current && !isEditing) {\n cellRef.current.textContent = initialValue;\n }\n }\n\n const onBlur = React.useCallback(() => {\n // Read the current value directly from the DOM to avoid stale state\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (!readOnly && currentValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: currentValue });\n }\n meta?.onCellEditingStop?.();\n }, [meta, rowIndex, columnId, initialValue, readOnly]);\n\n const onInput = React.useCallback(\n (event: React.FormEvent) => {\n const currentValue = event.currentTarget.textContent ?? \"\";\n setValue(currentValue);\n },\n [],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Enter\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (currentValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: currentValue });\n }\n meta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (currentValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: currentValue });\n }\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n cellRef.current?.blur();\n }\n } else if (\n isFocused &&\n event.key.length === 1 &&\n !event.ctrlKey &&\n !event.metaKey\n ) {\n // Handle typing to pre-fill the value when editing starts\n setValue(event.key);\n\n queueMicrotask(() => {\n if (cellRef.current && cellRef.current.contentEditable === \"true\") {\n cellRef.current.textContent = event.key;\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n });\n }\n },\n [isEditing, isFocused, initialValue, meta, rowIndex, columnId],\n );\n\n React.useEffect(() => {\n if (isEditing && cellRef.current) {\n cellRef.current.focus();\n\n if (!cellRef.current.textContent && value) {\n cellRef.current.textContent = value;\n }\n\n if (cellRef.current.textContent) {\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n }\n }, [isEditing, value]);\n\n const displayValue = !isEditing ? (value ?? \"\") : \"\";\n\n return (\n \n \n {displayValue}\n \n \n );\n}\n\nexport function LongTextCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n readOnly,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const textareaRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n const sideOffset = -(containerRef.current?.clientHeight ?? 0);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n }\n\n // Debounced auto-save (300ms delay)\n const debouncedSave = useDebouncedCallback((newValue: string) => {\n if (!readOnly) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: newValue });\n }\n }, 300);\n\n const onSave = React.useCallback(() => {\n // Immediately save any pending changes and close the popover\n if (!readOnly && value !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n meta?.onCellEditingStop?.();\n }, [meta, value, initialValue, rowIndex, columnId, readOnly]);\n\n const onCancel = React.useCallback(() => {\n // Restore the original value\n setValue(initialValue ?? \"\");\n if (!readOnly) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: initialValue });\n }\n meta?.onCellEditingStop?.();\n }, [meta, initialValue, rowIndex, columnId, readOnly]);\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n if (isOpen && !readOnly) {\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n // Immediately save any pending changes when closing\n if (!readOnly && value !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n meta?.onCellEditingStop?.();\n }\n },\n [meta, value, initialValue, rowIndex, columnId, readOnly],\n );\n\n const onOpenAutoFocus: NonNullable<\n React.ComponentProps[\"onOpenAutoFocus\"]\n > = React.useCallback((event) => {\n event.preventDefault();\n if (textareaRef.current) {\n textareaRef.current.focus();\n const length = textareaRef.current.value.length;\n textareaRef.current.setSelectionRange(length, length);\n }\n }, []);\n\n const onBlur = React.useCallback(() => {\n // Immediately save any pending changes on blur\n if (!readOnly && value !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n meta?.onCellEditingStop?.();\n }, [meta, value, initialValue, rowIndex, columnId, readOnly]);\n\n const onChange = React.useCallback(\n (event: React.ChangeEvent) => {\n const newValue = event.target.value;\n setValue(newValue);\n // Debounced auto-save\n debouncedSave(newValue);\n },\n [debouncedSave],\n );\n\n const onKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n onCancel();\n } else if (event.key === \"Enter\" && (event.ctrlKey || event.metaKey)) {\n event.preventDefault();\n onSave();\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n // Save any pending changes\n if (value !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n return;\n }\n // Stop propagation to prevent grid navigation\n event.stopPropagation();\n },\n [onSave, onCancel, value, initialValue, meta, rowIndex, columnId],\n );\n\n return (\n \n \n \n {value}\n \n \n \n \n \n \n );\n}\n\nexport function NumberCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n readOnly,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as number;\n const [value, setValue] = React.useState(String(initialValue ?? \"\"));\n const inputRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n const cellOpts = cell.column.columnDef.meta?.cell;\n const min = cellOpts?.variant === \"number\" ? cellOpts.min : undefined;\n const max = cellOpts?.variant === \"number\" ? cellOpts.max : undefined;\n const step = cellOpts?.variant === \"number\" ? cellOpts.step : undefined;\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(String(initialValue ?? \"\"));\n }\n\n const onBlur = React.useCallback(() => {\n const numValue = value === \"\" ? null : Number(value);\n if (!readOnly && numValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n meta?.onCellEditingStop?.();\n }, [meta, rowIndex, columnId, initialValue, value, readOnly]);\n\n const onChange = React.useCallback(\n (event: React.ChangeEvent) => {\n setValue(event.target.value);\n },\n [],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Enter\") {\n event.preventDefault();\n const numValue = value === \"\" ? null : Number(value);\n if (numValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n meta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const numValue = value === \"\" ? null : Number(value);\n if (numValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(String(initialValue ?? \"\"));\n inputRef.current?.blur();\n }\n } else if (isFocused) {\n // Handle Backspace to start editing with empty value\n if (event.key === \"Backspace\") {\n setValue(\"\");\n } else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {\n // Handle typing to pre-fill the value when editing starts\n setValue(event.key);\n }\n }\n },\n [isEditing, isFocused, initialValue, meta, rowIndex, columnId, value],\n );\n\n React.useEffect(() => {\n if (isEditing && inputRef.current) {\n inputRef.current.focus();\n inputRef.current.select();\n }\n }, [isEditing]);\n\n return (\n \n {isEditing ? (\n \n ) : (\n {value}\n )}\n \n );\n}\n\nfunction getUrlHref(urlString: string): string {\n if (!urlString || urlString.trim() === \"\") return \"\";\n\n const trimmed = urlString.trim();\n\n // Reject dangerous protocols (extra safety, though our http:// prefix would neutralize them)\n if (/^(javascript|data|vbscript|file):/i.test(trimmed)) {\n return \"\";\n }\n\n // Check if it already has a protocol\n if (trimmed.startsWith(\"http://\") || trimmed.startsWith(\"https://\")) {\n return trimmed;\n }\n\n // Add http:// prefix for links without protocol\n return `http://${trimmed}`;\n}\n\nexport function UrlCell({\n cell,\n table,\n rowIndex,\n columnId,\n isEditing,\n isFocused,\n isSelected,\n readOnly,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const cellRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n if (cellRef.current && !isEditing) {\n cellRef.current.textContent = initialValue ?? \"\";\n }\n }\n\n const onBlur = React.useCallback(() => {\n const currentValue = cellRef.current?.textContent?.trim() ?? \"\";\n\n if (!readOnly && currentValue !== initialValue) {\n meta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n meta?.onCellEditingStop?.();\n }, [meta, rowIndex, columnId, initialValue, readOnly]);\n\n const onInput = React.useCallback(\n (event: React.FormEvent) => {\n const currentValue = event.currentTarget.textContent ?? \"\";\n setValue(currentValue);\n },\n [],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Enter\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent?.trim() ?? \"\";\n if (!readOnly && currentValue !== initialValue) {\n meta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n meta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent?.trim() ?? \"\";\n if (!readOnly && currentValue !== initialValue) {\n meta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue ?? \"\");\n cellRef.current?.blur();\n }\n } else if (\n isFocused &&\n !readOnly &&\n event.key.length === 1 &&\n !event.ctrlKey &&\n !event.metaKey\n ) {\n // Handle typing to pre-fill the value when editing starts\n setValue(event.key);\n\n queueMicrotask(() => {\n if (cellRef.current && cellRef.current.contentEditable === \"true\") {\n cellRef.current.textContent = event.key;\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n });\n }\n },\n [isEditing, isFocused, initialValue, meta, rowIndex, columnId, readOnly],\n );\n\n const onLinkClick = React.useCallback(\n (event: React.MouseEvent) => {\n if (isEditing) {\n event.preventDefault();\n return;\n }\n\n // Check if URL was rejected due to dangerous protocol\n const href = getUrlHref(value);\n if (!href) {\n event.preventDefault();\n toast.error(\"Invalid URL\", {\n description:\n \"URL contains a dangerous protocol (javascript:, data:, vbscript:, or file:)\",\n });\n return;\n }\n\n // Stop propagation to prevent grid from interfering with link navigation\n event.stopPropagation();\n },\n [isEditing, value],\n );\n\n React.useEffect(() => {\n if (isEditing && cellRef.current) {\n cellRef.current.focus();\n\n if (!cellRef.current.textContent && value) {\n cellRef.current.textContent = value;\n }\n\n if (cellRef.current.textContent) {\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n }\n }, [isEditing, value]);\n\n const displayValue = !isEditing ? (value ?? \"\") : \"\";\n const urlHref = displayValue ? getUrlHref(displayValue) : \"\";\n const isDangerousUrl = displayValue && !urlHref;\n\n return (\n \n {!isEditing && displayValue ? (\n \n \n {displayValue}\n \n \n ) : (\n \n {displayValue}\n \n )}\n \n );\n}\n\nexport function CheckboxCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isSelected,\n readOnly,\n}: Omit, \"isEditing\">) {\n const initialValue = cell.getValue() as boolean;\n const [value, setValue] = React.useState(Boolean(initialValue));\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(Boolean(initialValue));\n }\n\n const onCheckedChange = React.useCallback(\n (checked: boolean) => {\n if (readOnly) return;\n setValue(checked);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: checked });\n },\n [meta, rowIndex, columnId, readOnly],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n isFocused &&\n !readOnly &&\n (event.key === \" \" || event.key === \"Enter\")\n ) {\n event.preventDefault();\n event.stopPropagation();\n onCheckedChange(!value);\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isFocused, value, onCheckedChange, meta, readOnly],\n );\n\n const onWrapperClick = React.useCallback(\n (event: React.MouseEvent) => {\n if (isFocused && !readOnly) {\n event.preventDefault();\n event.stopPropagation();\n onCheckedChange(!value);\n }\n },\n [isFocused, value, onCheckedChange, readOnly],\n );\n\n const onCheckboxClick = React.useCallback((event: React.MouseEvent) => {\n event.stopPropagation();\n }, []);\n\n const onCheckboxMouseDown = React.useCallback(\n (event: React.MouseEvent) => {\n event.stopPropagation();\n },\n [],\n );\n\n const onCheckboxDoubleClick = React.useCallback(\n (event: React.MouseEvent) => {\n event.stopPropagation();\n },\n [],\n );\n\n return (\n \n \n \n );\n}\n\nexport function SelectCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n readOnly,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue);\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n const cellOpts = cell.column.columnDef.meta?.cell;\n const options = cellOpts?.variant === \"select\" ? cellOpts.options : [];\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue);\n }\n\n const onValueChange = React.useCallback(\n (newValue: string) => {\n if (readOnly) return;\n setValue(newValue);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: newValue });\n meta?.onCellEditingStop?.();\n },\n [meta, rowIndex, columnId, readOnly],\n );\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n if (isOpen && !readOnly) {\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n meta?.onCellEditingStop?.();\n }\n },\n [meta, rowIndex, columnId, readOnly],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n meta?.onCellEditingStop?.();\n } else if (!isEditing && isFocused && event.key === \"Tab\") {\n event.preventDefault();\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, initialValue, meta],\n );\n\n const displayLabel =\n options.find((opt) => opt.value === value)?.label ?? value;\n\n return (\n \n {isEditing ? (\n \n \n \n \n \n {options.map((option) => (\n \n {option.label}\n \n ))}\n \n \n ) : (\n {displayLabel}\n )}\n \n );\n}\n\nexport function MultiSelectCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n readOnly,\n}: CellVariantProps) {\n const cellValue = React.useMemo(() => {\n const value = cell.getValue() as string[];\n return value ?? [];\n }, [cell]);\n\n const cellKey = getCellKey(rowIndex, columnId);\n const prevCellKeyRef = React.useRef(cellKey);\n\n const [selectedValues, setSelectedValues] =\n React.useState(cellValue);\n const [searchValue, setSearchValue] = React.useState(\"\");\n const containerRef = React.useRef(null);\n const inputRef = React.useRef(null);\n const meta = table.options.meta;\n const cellOpts = cell.column.columnDef.meta?.cell;\n const options = cellOpts?.variant === \"multi-select\" ? cellOpts.options : [];\n const sideOffset = -(containerRef.current?.clientHeight ?? 0);\n\n const prevCellValueRef = React.useRef(cellValue);\n if (cellValue !== prevCellValueRef.current) {\n prevCellValueRef.current = cellValue;\n setSelectedValues(cellValue);\n }\n\n if (prevCellKeyRef.current !== cellKey) {\n prevCellKeyRef.current = cellKey;\n setSearchValue(\"\");\n }\n\n const onValueChange = React.useCallback(\n (value: string) => {\n if (readOnly) return;\n const newValues = selectedValues.includes(value)\n ? selectedValues.filter((v) => v !== value)\n : [...selectedValues, value];\n\n setSelectedValues(newValues);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n setSearchValue(\"\");\n queueMicrotask(() => inputRef.current?.focus());\n },\n [selectedValues, meta, rowIndex, columnId, readOnly],\n );\n\n const removeValue = React.useCallback(\n (valueToRemove: string, event?: React.MouseEvent) => {\n if (readOnly) return;\n event?.stopPropagation();\n event?.preventDefault();\n const newValues = selectedValues.filter((v) => v !== valueToRemove);\n setSelectedValues(newValues);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n // Focus back on input after removing\n setTimeout(() => inputRef.current?.focus(), 0);\n },\n [selectedValues, meta, rowIndex, columnId, readOnly],\n );\n\n const clearAll = React.useCallback(() => {\n if (readOnly) return;\n setSelectedValues([]);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: [] });\n queueMicrotask(() => inputRef.current?.focus());\n }, [meta, rowIndex, columnId, readOnly]);\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n if (isOpen && !readOnly) {\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n setSearchValue(\"\");\n meta?.onCellEditingStop?.();\n }\n },\n [meta, rowIndex, columnId, readOnly],\n );\n\n const onOpenAutoFocus: NonNullable<\n React.ComponentProps[\"onOpenAutoFocus\"]\n > = React.useCallback((event) => {\n event.preventDefault();\n inputRef.current?.focus();\n }, []);\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && event.key === \"Escape\") {\n event.preventDefault();\n setSelectedValues(cellValue);\n setSearchValue(\"\");\n meta?.onCellEditingStop?.();\n } else if (!isEditing && isFocused && event.key === \"Tab\") {\n event.preventDefault();\n setSearchValue(\"\");\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, cellValue, meta],\n );\n\n const onInputKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n // Handle backspace when input is empty - remove last selected item\n if (\n event.key === \"Backspace\" &&\n searchValue === \"\" &&\n selectedValues.length > 0\n ) {\n event.preventDefault();\n const lastValue = selectedValues[selectedValues.length - 1];\n if (lastValue) {\n removeValue(lastValue);\n }\n }\n // Prevent escape from propagating to close the popover immediately\n // Let the command handle it first\n if (event.key === \"Escape\") {\n event.stopPropagation();\n }\n },\n [searchValue, selectedValues, removeValue],\n );\n\n const displayLabels = selectedValues\n .map((val) => options.find((opt) => opt.value === val)?.label ?? val)\n .filter(Boolean);\n\n const rowHeight = table.options.meta?.rowHeight ?? \"short\";\n const lineCount = getLineCount(rowHeight);\n\n const { visibleItems: visibleLabels, hiddenCount: hiddenBadgeCount } =\n useBadgeOverflow({\n items: displayLabels,\n getLabel: (label) => label,\n containerRef,\n lineCount,\n });\n\n return (\n \n {isEditing ? (\n \n \n
\n \n \n \n
\n {selectedValues.map((value) => {\n const option = options.find((opt) => opt.value === value);\n const label = option?.label ?? value;\n\n return (\n \n {label}\n removeValue(value, event)}\n onPointerDown={(event) => {\n event.preventDefault();\n event.stopPropagation();\n }}\n >\n \n \n \n );\n })}\n \n
\n \n No options found.\n \n {options.map((option) => {\n const isSelected = selectedValues.includes(option.value);\n\n return (\n onValueChange(option.value)}\n >\n \n \n
\n {option.label}\n \n );\n })}\n \n {selectedValues.length > 0 && (\n <>\n \n \n \n Clear all\n \n \n \n )}\n \n \n \n
\n ) : null}\n {displayLabels.length > 0 ? (\n
\n {visibleLabels.map((label, index) => (\n \n {label}\n \n ))}\n {hiddenBadgeCount > 0 && (\n \n +{hiddenBadgeCount}\n \n )}\n
\n ) : null}\n \n );\n}\n\nfunction formatDateForDisplay(dateStr: string) {\n if (!dateStr) return \"\";\n const date = new Date(dateStr);\n return date.toLocaleDateString();\n}\n\nexport function DateCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n readOnly,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n }\n\n const selectedDate = value ? new Date(value) : undefined;\n\n const onDateSelect = React.useCallback(\n (date: Date | undefined) => {\n if (!date || readOnly) return;\n\n const formattedDate = date.toISOString().split(\"T\")[0] ?? \"\";\n setValue(formattedDate);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: formattedDate });\n meta?.onCellEditingStop?.();\n },\n [meta, rowIndex, columnId, readOnly],\n );\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n if (isOpen && !readOnly) {\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n meta?.onCellEditingStop?.();\n }\n },\n [meta, rowIndex, columnId, readOnly],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n meta?.onCellEditingStop?.();\n } else if (!isEditing && isFocused && event.key === \"Tab\") {\n event.preventDefault();\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, initialValue, meta],\n );\n\n return (\n \n \n \n \n {formatDateForDisplay(value)}\n \n \n {isEditing && (\n \n \n \n )}\n \n \n );\n}\n\nfunction formatFileSize(bytes: number): string {\n if (bytes === 0) return \"0 B\";\n const k = 1024;\n const sizes = [\"B\", \"KB\", \"MB\", \"GB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;\n}\n\nfunction getFileIcon(\n type: string,\n): React.ComponentType> {\n if (type.startsWith(\"image/\")) return FileImage;\n if (type.startsWith(\"video/\")) return FileVideo;\n if (type.startsWith(\"audio/\")) return FileAudio;\n if (type.includes(\"pdf\")) return FileText;\n if (type.includes(\"zip\") || type.includes(\"rar\")) return FileArchive;\n if (\n type.includes(\"word\") ||\n type.includes(\"document\") ||\n type.includes(\"doc\")\n )\n return FileText;\n if (type.includes(\"sheet\") || type.includes(\"excel\") || type.includes(\"xls\"))\n return FileSpreadsheet;\n if (\n type.includes(\"presentation\") ||\n type.includes(\"powerpoint\") ||\n type.includes(\"ppt\")\n )\n return Presentation;\n return File;\n}\n\nexport function FileCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n readOnly,\n}: CellVariantProps) {\n const cellValue = React.useMemo(\n () => (cell.getValue() as FileCellData[]) ?? [],\n [cell],\n );\n\n const cellKey = getCellKey(rowIndex, columnId);\n const prevCellKeyRef = React.useRef(cellKey);\n\n const labelId = React.useId();\n const descriptionId = React.useId();\n\n const [files, setFiles] = React.useState(cellValue);\n const [uploadingFiles, setUploadingFiles] = React.useState>(\n new Set(),\n );\n const [isDraggingOver, setIsDraggingOver] = React.useState(false);\n const [isDragging, setIsDragging] = React.useState(false);\n const [error, setError] = React.useState(null);\n const containerRef = React.useRef(null);\n const fileInputRef = React.useRef(null);\n const dropzoneRef = React.useRef(null);\n const meta = table.options.meta;\n const cellOpts = cell.column.columnDef.meta?.cell;\n const sideOffset = -(containerRef.current?.clientHeight ?? 0);\n\n const fileCellOpts = cellOpts?.variant === \"file\" ? cellOpts : null;\n const maxFileSize = fileCellOpts?.maxFileSize ?? 10 * 1024 * 1024;\n const maxFiles = fileCellOpts?.maxFiles ?? 10;\n const accept = fileCellOpts?.accept;\n const multiple = fileCellOpts?.multiple ?? true;\n\n const acceptedTypes = React.useMemo(\n () => (accept ? accept.split(\",\").map((t) => t.trim()) : null),\n [accept],\n );\n\n // Sync files when cellValue changes (same pattern as other cells)\n const prevCellValueRef = React.useRef(cellValue);\n if (cellValue !== prevCellValueRef.current) {\n prevCellValueRef.current = cellValue;\n // Revoke old object URLs to prevent memory leaks\n for (const file of files) {\n if (file.url) {\n URL.revokeObjectURL(file.url);\n }\n }\n setFiles(cellValue);\n setError(null);\n }\n\n if (prevCellKeyRef.current !== cellKey) {\n prevCellKeyRef.current = cellKey;\n setError(null);\n }\n\n const validateFile = React.useCallback(\n (file: File): string | null => {\n if (maxFileSize && file.size > maxFileSize) {\n return `File size exceeds ${formatFileSize(maxFileSize)}`;\n }\n if (acceptedTypes) {\n const fileExtension = `.${file.name.split(\".\").pop()}`;\n const isAccepted = acceptedTypes.some((type) => {\n if (type.endsWith(\"/*\")) {\n // Handle wildcard types like \"image/*\"\n const baseType = type.slice(0, -2);\n return file.type.startsWith(`${baseType}/`);\n }\n if (type.startsWith(\".\")) {\n // Handle file extensions like \".pdf\"\n return fileExtension.toLowerCase() === type.toLowerCase();\n }\n // Exact match for specific MIME types\n return file.type === type;\n });\n if (!isAccepted) {\n return \"File type not accepted\";\n }\n }\n return null;\n },\n [maxFileSize, acceptedTypes],\n );\n\n const addFiles = React.useCallback(\n async (newFiles: File[], skipUpload = false) => {\n if (readOnly) return;\n setError(null);\n\n // Check max files limit\n if (maxFiles && files.length + newFiles.length > maxFiles) {\n const errorMessage = `Maximum ${maxFiles} files allowed`;\n setError(errorMessage);\n toast(errorMessage);\n setTimeout(() => {\n setError(null);\n }, 2000);\n return;\n }\n\n const validFiles: FileCellData[] = [];\n const rejectedFiles: Array<{ name: string; reason: string }> = [];\n\n for (const file of newFiles) {\n const validationError = validateFile(file);\n if (validationError) {\n rejectedFiles.push({ name: file.name, reason: validationError });\n continue;\n }\n\n // Create file data object with temporary ID\n const fileData: FileCellData = {\n id: crypto.randomUUID(),\n name: file.name,\n size: file.size,\n type: file.type,\n url: URL.createObjectURL(file),\n };\n validFiles.push(fileData);\n }\n\n if (rejectedFiles.length > 0) {\n const firstError = rejectedFiles[0];\n if (firstError) {\n setError(firstError.reason);\n\n const truncatedName =\n firstError.name.length > 20\n ? `${firstError.name.slice(0, 20)}...`\n : firstError.name;\n\n if (rejectedFiles.length === 1) {\n toast(firstError.reason, {\n description: `\"${truncatedName}\" has been rejected`,\n });\n } else {\n toast(firstError.reason, {\n description: `\"${truncatedName}\" and ${rejectedFiles.length - 1} more rejected`,\n });\n }\n\n setTimeout(() => {\n setError(null);\n }, 2000);\n }\n }\n\n if (validFiles.length > 0) {\n // If not skipping upload (dropped on cell), show skeletons first\n if (!skipUpload) {\n // Add temp files immediately (will show as skeletons)\n const tempFiles = validFiles.map((f) => ({ ...f, url: undefined }));\n const filesWithTemp = [...files, ...tempFiles];\n setFiles(filesWithTemp);\n\n // Mark as uploading\n const uploadingIds = new Set(validFiles.map((f) => f.id));\n setUploadingFiles(uploadingIds);\n\n // Simulate upload delay (in real app, this would be actual upload)\n await new Promise((resolve) => setTimeout(resolve, 800));\n\n // Replace temp files with real ones\n const finalFiles = filesWithTemp.map(\n (f) => validFiles.find((vf) => vf.id === f.id) || f,\n );\n setFiles(finalFiles);\n setUploadingFiles(new Set());\n meta?.onDataUpdate?.({ rowIndex, columnId, value: finalFiles });\n } else {\n // If from editor, add immediately without skeleton\n const updatedFiles = [...files, ...validFiles];\n setFiles(updatedFiles);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles });\n }\n }\n },\n [files, maxFiles, validateFile, meta, rowIndex, columnId, readOnly],\n );\n\n const removeFile = React.useCallback(\n (fileId: string) => {\n if (readOnly) return;\n setError(null);\n // Revoke object URL to prevent memory leak\n const fileToRemove = files.find((f) => f.id === fileId);\n if (fileToRemove?.url) {\n URL.revokeObjectURL(fileToRemove.url);\n }\n const updatedFiles = files.filter((f) => f.id !== fileId);\n setFiles(updatedFiles);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles });\n },\n [files, meta, rowIndex, columnId, readOnly],\n );\n\n const clearAll = React.useCallback(() => {\n if (readOnly) return;\n // Revoke all object URLs to prevent memory leak\n for (const file of files) {\n if (file.url) {\n URL.revokeObjectURL(file.url);\n }\n }\n setFiles([]);\n setError(null);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: [] });\n }, [files, meta, rowIndex, columnId, readOnly]);\n\n const onDragEnter = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDragging(true);\n }, []);\n\n const onDragLeave = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n const rect = event.currentTarget.getBoundingClientRect();\n const x = event.clientX;\n const y = event.clientY;\n\n if (\n x <= rect.left ||\n x >= rect.right ||\n y <= rect.top ||\n y >= rect.bottom\n ) {\n setIsDragging(false);\n }\n }, []);\n\n const onDragOver = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n }, []);\n\n const onDrop = React.useCallback(\n (event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDragging(false);\n\n const droppedFiles = Array.from(event.dataTransfer.files);\n addFiles(droppedFiles, true); // Skip upload skeleton in editor\n },\n [addFiles],\n );\n\n const onFileInputChange = React.useCallback(\n (event: React.ChangeEvent) => {\n const selectedFiles = Array.from(event.target.files ?? []);\n addFiles(selectedFiles, true); // Skip upload skeleton for manual selection\n // Reset input so the same file can be selected again\n event.target.value = \"\";\n },\n [addFiles],\n );\n\n // Cell-level drag handlers (for dropping directly on cell)\n const onCellDragEnter = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n // Only show drop indicator if dragging files\n if (event.dataTransfer.types.includes(\"Files\")) {\n setIsDraggingOver(true);\n }\n }, []);\n\n const onCellDragLeave = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n const rect = event.currentTarget.getBoundingClientRect();\n const x = event.clientX;\n const y = event.clientY;\n\n if (\n x <= rect.left ||\n x >= rect.right ||\n y <= rect.top ||\n y >= rect.bottom\n ) {\n setIsDraggingOver(false);\n }\n }, []);\n\n const onCellDragOver = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n }, []);\n\n const onCellDrop = React.useCallback(\n (event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDraggingOver(false);\n\n const droppedFiles = Array.from(event.dataTransfer.files);\n if (droppedFiles.length > 0) {\n addFiles(droppedFiles, false); // Show skeleton for dropped files\n }\n },\n [addFiles],\n );\n\n const onDropzoneClick = React.useCallback(() => {\n fileInputRef.current?.click();\n }, []);\n\n const onDropzoneKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault();\n onDropzoneClick();\n }\n },\n [onDropzoneClick],\n );\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n if (isOpen && !readOnly) {\n setError(null);\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n setError(null);\n meta?.onCellEditingStop?.();\n }\n },\n [meta, rowIndex, columnId, readOnly],\n );\n\n const onEscapeKeyDown: NonNullable<\n React.ComponentProps[\"onEscapeKeyDown\"]\n > = React.useCallback((event) => {\n // Prevent the escape key from propagating to the data grid's keyboard handler\n // which would call blurCell() and remove focus from the cell\n event.stopPropagation();\n }, []);\n\n const onOpenAutoFocus: NonNullable<\n React.ComponentProps[\"onOpenAutoFocus\"]\n > = React.useCallback((event) => {\n event.preventDefault();\n // Focus the dropzone for better keyboard UX - users can press Enter again to open file dialog\n queueMicrotask(() => {\n dropzoneRef.current?.focus();\n });\n }, []);\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Escape\") {\n event.preventDefault();\n setFiles(cellValue);\n setError(null);\n meta?.onCellEditingStop?.();\n } else if (event.key === \" \") {\n event.preventDefault();\n onDropzoneClick();\n }\n } else if (isFocused && event.key === \"Enter\") {\n // Handle Enter key to start editing when focused but not editing\n event.preventDefault();\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else if (!isEditing && isFocused && event.key === \"Tab\") {\n event.preventDefault();\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [\n isEditing,\n isFocused,\n cellValue,\n meta,\n onDropzoneClick,\n rowIndex,\n columnId,\n ],\n );\n\n React.useEffect(() => {\n return () => {\n for (const file of files) {\n if (file.url) {\n URL.revokeObjectURL(file.url);\n }\n }\n };\n }, [files]);\n\n const rowHeight = table.options.meta?.rowHeight ?? \"short\";\n const lineCount = getLineCount(rowHeight);\n\n const { visibleItems: visibleFiles, hiddenCount: hiddenFileCount } =\n useBadgeOverflow({\n items: files,\n getLabel: (file) => file.name,\n containerRef,\n lineCount,\n cacheKeyPrefix: \"file\",\n iconSize: 12,\n maxWidth: 100,\n });\n\n return (\n \n {isEditing ? (\n \n \n
\n \n \n
\n \n File upload\n \n \n \n
\n

\n {isDragging ? \"Drop files here\" : \"Drag files here\"}\n

\n

\n or click to browse\n

\n
\n

\n {maxFileSize\n ? `Max size: ${formatFileSize(maxFileSize)}${maxFiles ? ` • Max ${maxFiles} files` : \"\"}`\n : maxFiles\n ? `Max ${maxFiles} files`\n : \"Select files to upload\"}\n

\n
\n \n {files.length > 0 && (\n
\n
\n

\n {files.length} {files.length === 1 ? \"file\" : \"files\"}\n

\n \n Clear all\n \n
\n
\n {files.map((file) => {\n const FileIcon = getFileIcon(file.type);\n\n return (\n \n {FileIcon && (\n \n )}\n
\n

{file.name}

\n

\n {formatFileSize(file.size)}\n

\n
\n removeFile(file.id)}\n >\n \n \n
\n );\n })}\n
\n
\n )}\n \n \n
\n ) : null}\n {isDraggingOver ? (\n
\n \n Drop files here\n
\n ) : files.length > 0 ? (\n
\n {visibleFiles.map((file) => {\n const isUploading = uploadingFiles.has(file.id);\n\n if (isUploading) {\n // Show skeleton for uploading files\n return (\n \n );\n }\n\n return (\n \n {React.createElement(getFileIcon(file.type), {\n className: \"size-3 shrink-0\",\n })}\n {file.name}\n \n );\n })}\n {hiddenFileCount > 0 && (\n \n +{hiddenFileCount}\n \n )}\n
\n ) : null}\n \n );\n}\n", "type": "registry:component", "target": "src/components/data-grid/data-grid-cell-variants.tsx" }, @@ -93,7 +93,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 { toast } from \"sonner\";\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 PasteDialogState,\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 pasteDialog: PasteDialogState;\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 onRowsAdd?: (count: number) => void | Promise;\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 enablePaste?: boolean;\n}\n\nfunction useDataGrid({\n columns,\n data,\n onDataChange,\n onRowAdd: onRowAddProp,\n onRowsAdd,\n onRowsDelete: onRowsDeleteProp,\n rowHeight: rowHeightProp = DEFAULT_ROW_HEIGHT,\n overscan = OVERSCAN,\n initialState,\n autoFocus = false,\n enableColumnSelection = false,\n enableSearch = false,\n enablePaste = 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 cellMapRef = 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 pasteDialog: {\n open: false,\n rowsNeeded: 0,\n clipboardText: \"\",\n },\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 const pasteDialog = useStore(store, (state) => state.pasteDialog);\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\n // If row is not found in data (e.g., newly added), use the table row index\n const targetIndex =\n originalRowIndex !== -1 ? originalRowIndex : update.rowIndex;\n\n const existingUpdates = rowUpdatesMap.get(targetIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(targetIndex, existingUpdates);\n }\n }\n\n // Build new data array, ensuring we include all rows from the table\n const tableRowCount = rows?.length ?? data.length;\n const newData: TData[] = [];\n\n for (let i = 0; i < tableRowCount; i++) {\n const updates = rowUpdatesMap.get(i);\n const existingRow = data[i];\n const tableRow = rows?.[i];\n\n if (updates) {\n // Has updates - apply them\n const baseRow = existingRow ?? tableRow?.original ?? ({} as TData);\n const updatedRow = { ...baseRow } as Record;\n for (const { columnId, value } of updates) {\n updatedRow[columnId] = value;\n }\n newData.push(updatedRow as TData);\n } else {\n // No updates - use existing or table row\n newData.push(existingRow ?? tableRow?.original ?? ({} as TData));\n }\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 copyCells = React.useCallback(async () => {\n const currentState = store.getState();\n\n // If no selection, copy the focused cell\n let selectedCellsArray: string[];\n if (!currentState.selectionState.selectedCells.size) {\n if (!currentState.focusedCell) return;\n const focusedCellKey = getCellKey(\n currentState.focusedCell.rowIndex,\n currentState.focusedCell.columnId,\n );\n selectedCellsArray = [focusedCellKey];\n } else {\n selectedCellsArray = Array.from(\n currentState.selectionState.selectedCells,\n );\n }\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n if (!rows) return;\n\n const selectedColumnIds: string[] = [];\n\n for (const cellKey of selectedCellsArray) {\n const { columnId } = parseCellKey(cellKey);\n if (columnId && !selectedColumnIds.includes(columnId)) {\n selectedColumnIds.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 const cellVariant = cell.column.columnDef?.meta?.cell?.variant;\n\n let serializedValue = \"\";\n if (cellVariant === \"file\" || cellVariant === \"multi-select\") {\n serializedValue = value ? JSON.stringify(value) : \"\";\n } else if (value instanceof Date) {\n serializedValue = value.toISOString();\n } else {\n serializedValue = String(value ?? \"\");\n }\n\n cellData.set(cellKey, serializedValue);\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 = selectedColumnIds.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) => selectedColumnIds[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 try {\n await navigator.clipboard.writeText(tsvData);\n toast.success(\n `${selectedCellsArray.length} cell${selectedCellsArray.length !== 1 ? \"s\" : \"\"} copied`,\n );\n } catch (error) {\n console.error(\"Copy failed:\", error);\n toast.error(\"Failed to copy to clipboard\");\n }\n }, [store]);\n\n const pasteCells = React.useCallback(\n async (expandRows = false) => {\n const currentState = store.getState();\n if (!currentState.focusedCell) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n if (!rows) return;\n\n try {\n let clipboardText = currentState.pasteDialog.clipboardText;\n\n if (!clipboardText) {\n clipboardText = await navigator.clipboard.readText();\n if (!clipboardText) return;\n }\n\n // Parse TSV data (tab-separated values with newlines for rows)\n const pastedRows = clipboardText\n .split(\"\\n\")\n .filter((row) => row.length > 0);\n const pastedData = pastedRows.map((row) => row.split(\"\\t\"));\n\n const startRowIndex = currentState.focusedCell.rowIndex;\n const startColIndex = navigableColumnIds.indexOf(\n currentState.focusedCell.columnId,\n );\n\n if (startColIndex === -1) return;\n\n const rowCount = rows.length ?? data.length;\n const rowsNeeded = startRowIndex + pastedData.length - rowCount;\n\n if (\n rowsNeeded > 0 &&\n !expandRows &&\n onRowAddProp &&\n !currentState.pasteDialog.clipboardText\n ) {\n store.setState(\"pasteDialog\", {\n open: true,\n rowsNeeded,\n clipboardText,\n });\n return;\n }\n\n if (expandRows && rowsNeeded > 0) {\n const expectedRowCount = rowCount + rowsNeeded;\n\n if (onRowsAdd) {\n await onRowsAdd(rowsNeeded);\n } else if (onRowAddProp) {\n for (let i = 0; i < rowsNeeded; i++) {\n await onRowAddProp();\n }\n }\n\n let attempts = 0;\n const maxAttempts = 50;\n let currentTableRowCount =\n tableRef.current?.getRowModel().rows.length ?? 0;\n\n while (\n currentTableRowCount < expectedRowCount &&\n attempts < maxAttempts\n ) {\n await new Promise((resolve) => setTimeout(resolve, 100));\n currentTableRowCount =\n tableRef.current?.getRowModel().rows.length ?? 0;\n attempts++;\n }\n }\n\n const updates: Array = [];\n const tableColumns = currentTable?.getAllColumns() ?? [];\n let cellsUpdated = 0;\n let endRowIndex = startRowIndex;\n let endColIndex = startColIndex;\n\n const updatedTable = tableRef.current;\n const updatedRows = updatedTable?.getRowModel().rows;\n const currentRowCount = updatedRows?.length ?? 0;\n\n for (\n let pasteRowIdx = 0;\n pasteRowIdx < pastedData.length;\n pasteRowIdx++\n ) {\n const pasteRow = pastedData[pasteRowIdx];\n if (!pasteRow) continue;\n\n const targetRowIndex = startRowIndex + pasteRowIdx;\n if (targetRowIndex >= currentRowCount) break; // Don't paste beyond available rows\n\n for (\n let pasteColIdx = 0;\n pasteColIdx < pasteRow.length;\n pasteColIdx++\n ) {\n const targetColIndex = startColIndex + pasteColIdx;\n if (targetColIndex >= navigableColumnIds.length) break; // Don't paste beyond available columns\n\n const targetColumnId = navigableColumnIds[targetColIndex];\n if (!targetColumnId) continue;\n\n const pastedValue = pasteRow[pasteColIdx] ?? \"\";\n\n const column = tableColumns.find(\n (col) => col.id === targetColumnId,\n );\n const cellVariant = column?.columnDef?.meta?.cell?.variant;\n\n let processedValue: unknown = pastedValue;\n\n if (cellVariant === \"number\") {\n const numValue = Number.parseFloat(pastedValue);\n processedValue = Number.isNaN(numValue) ? null : numValue;\n } else if (cellVariant === \"checkbox\") {\n if (!pastedValue) {\n processedValue = false;\n } else {\n processedValue =\n pastedValue.toLowerCase() === \"true\" ||\n pastedValue === \"1\" ||\n pastedValue.toLowerCase() === \"yes\";\n }\n } else if (cellVariant === \"date\") {\n if (pastedValue) {\n const date = new Date(pastedValue);\n processedValue = Number.isNaN(date.getTime()) ? null : date;\n } else {\n processedValue = null;\n }\n } else if (cellVariant === \"multi-select\") {\n try {\n processedValue = JSON.parse(pastedValue);\n } catch {\n processedValue = pastedValue\n ? pastedValue.split(\",\").map((v) => v.trim())\n : [];\n }\n } else if (cellVariant === \"file\") {\n try {\n const parsed = JSON.parse(pastedValue);\n if (Array.isArray(parsed)) {\n processedValue = parsed.filter(\n (item) =>\n item &&\n typeof item === \"object\" &&\n \"id\" in item &&\n \"name\" in item &&\n \"size\" in item &&\n \"type\" in item,\n );\n } else {\n processedValue = [];\n }\n } catch {\n processedValue = [];\n }\n }\n\n updates.push({\n rowIndex: targetRowIndex,\n columnId: targetColumnId,\n value: processedValue,\n });\n cellsUpdated++;\n\n endRowIndex = Math.max(endRowIndex, targetRowIndex);\n endColIndex = Math.max(endColIndex, targetColIndex);\n }\n }\n\n if (updates.length > 0) {\n onDataUpdate(updates);\n toast.success(\n `${cellsUpdated} cell${cellsUpdated !== 1 ? \"s\" : \"\"} pasted`,\n );\n\n const endColumnId = navigableColumnIds[endColIndex];\n if (endColumnId) {\n selectRange(\n {\n rowIndex: startRowIndex,\n columnId: currentState.focusedCell.columnId,\n },\n { rowIndex: endRowIndex, columnId: endColumnId },\n );\n }\n }\n\n if (currentState.pasteDialog.open) {\n store.setState(\"pasteDialog\", {\n open: false,\n rowsNeeded: 0,\n clipboardText: \"\",\n });\n }\n } catch (error) {\n toast.error(\n error instanceof Error\n ? error.message\n : \"Failed to paste. Please try again.\",\n );\n }\n },\n [\n store,\n navigableColumnIds,\n data.length,\n onDataUpdate,\n onRowAddProp,\n onRowsAdd,\n selectRange,\n ],\n );\n\n const focusCellWrapper = React.useCallback(\n (rowIndex: number, columnId: string) => {\n requestAnimationFrame(() => {\n const cellKey = getCellKey(rowIndex, columnId);\n const cellWrapperElement = cellMapRef.current.get(cellKey);\n\n if (!cellWrapperElement) return;\n\n cellWrapperElement.focus();\n });\n },\n [],\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 focusCellWrapper(rowIndex, columnId);\n },\n [store, focusCellWrapper],\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 } else if (currentEditing) {\n // No navigation - just refocus the current cell (e.g., when pressing Escape)\n const { rowIndex, columnId } = currentEditing;\n focusCellWrapper(rowIndex, columnId);\n }\n },\n [store, data.length, focusCell, navigateCell, focusCellWrapper],\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 (isCtrlPressed && key === \"c\") {\n event.preventDefault();\n copyCells();\n return;\n }\n\n if (enablePaste && isCtrlPressed && key === \"v\") {\n event.preventDefault();\n pasteCells();\n return;\n }\n\n if (key === \"Delete\" || key === \"Backspace\") {\n if (currentState.selectionState.selectedCells.size > 0) {\n event.preventDefault();\n\n const updates: Array<{\n rowIndex: number;\n columnId: string;\n value: unknown;\n }> = [];\n\n const currentTable = tableRef.current;\n const tableColumns = currentTable?.getAllColumns() ?? [];\n\n currentState.selectionState.selectedCells.forEach((cellKey) => {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n\n const column = tableColumns.find((col) => col.id === columnId);\n const cellVariant = column?.columnDef?.meta?.cell?.variant;\n\n let emptyValue: unknown = \"\";\n if (cellVariant === \"multi-select\" || cellVariant === \"file\") {\n emptyValue = [];\n } else if (cellVariant === \"number\" || cellVariant === \"date\") {\n emptyValue = null;\n } else if (cellVariant === \"checkbox\") {\n emptyValue = false;\n }\n\n updates.push({ rowIndex, columnId, value: emptyValue });\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 copyCells,\n pasteCells,\n onDataUpdate,\n clearSelection,\n navigableColumnIds,\n data.length,\n selectRange,\n focusCell,\n onSearchOpenChange,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n enableSearch,\n enablePaste,\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 onPasteDialogOpenChange = React.useCallback(\n (open: boolean) => {\n if (!open) {\n store.setState(\"pasteDialog\", {\n open: false,\n rowsNeeded: 0,\n clipboardText: \"\",\n });\n }\n },\n [store],\n );\n\n const onPasteWithExpansion = React.useCallback(() => {\n pasteCells(true);\n }, [pasteCells]);\n\n const onPasteWithoutExpansion = React.useCallback(() => {\n // Call pasteCells without expansion - it will paste only what fits\n pasteCells(false);\n }, [pasteCells]);\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 cellMapRef,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n rowHeight,\n isScrolling,\n pasteDialog,\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 onPasteDialogOpenChange,\n onPasteWithExpansion,\n onPasteWithoutExpansion,\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 pasteDialog,\n onPasteDialogOpenChange,\n onPasteWithExpansion,\n onPasteWithoutExpansion,\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 requestAnimationFrame(() => {\n store.batch(() => {\n store.setState(\"focusedCell\", {\n rowIndex,\n columnId: targetColumnId,\n });\n store.setState(\"editingCell\", null);\n });\n\n focusCellWrapper(rowIndex, targetColumnId);\n });\n },\n [rowVirtualizer, navigableColumnIds, store, focusCellWrapper],\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", + "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 { toast } from \"sonner\";\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 PasteDialogState,\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 pasteDialog: PasteDialogState;\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 onRowsAdd?: (count: number) => void | Promise;\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 enablePaste?: boolean;\n readOnly?: boolean;\n}\n\nfunction useDataGrid({\n columns,\n data,\n onDataChange,\n onRowAdd: onRowAddProp,\n onRowsAdd,\n onRowsDelete: onRowsDeleteProp,\n rowHeight: rowHeightProp = DEFAULT_ROW_HEIGHT,\n overscan = OVERSCAN,\n initialState,\n autoFocus = false,\n enableColumnSelection = false,\n enableSearch = false,\n enablePaste = false,\n readOnly = 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 cellMapRef = 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 pasteDialog: {\n open: false,\n rowsNeeded: 0,\n clipboardText: \"\",\n },\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 const pasteDialog = useStore(store, (state) => state.pasteDialog);\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 // Block data updates in read-only mode\n if (readOnly) return;\n\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\n // If row is not found in data (e.g., newly added), use the table row index\n const targetIndex =\n originalRowIndex !== -1 ? originalRowIndex : update.rowIndex;\n\n const existingUpdates = rowUpdatesMap.get(targetIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(targetIndex, existingUpdates);\n }\n }\n\n // Build new data array, ensuring we include all rows from the table\n const tableRowCount = rows?.length ?? data.length;\n const newData: TData[] = [];\n\n for (let i = 0; i < tableRowCount; i++) {\n const updates = rowUpdatesMap.get(i);\n const existingRow = data[i];\n const tableRow = rows?.[i];\n\n if (updates) {\n // Has updates - apply them\n const baseRow = existingRow ?? tableRow?.original ?? ({} as TData);\n const updatedRow = { ...baseRow } as Record;\n for (const { columnId, value } of updates) {\n updatedRow[columnId] = value;\n }\n newData.push(updatedRow as TData);\n } else {\n // No updates - use existing or table row\n newData.push(existingRow ?? tableRow?.original ?? ({} as TData));\n }\n }\n\n onDataChange?.(newData);\n },\n [data, onDataChange, readOnly],\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 copyCells = React.useCallback(async () => {\n const currentState = store.getState();\n\n // If no selection, copy the focused cell\n let selectedCellsArray: string[];\n if (!currentState.selectionState.selectedCells.size) {\n if (!currentState.focusedCell) return;\n const focusedCellKey = getCellKey(\n currentState.focusedCell.rowIndex,\n currentState.focusedCell.columnId,\n );\n selectedCellsArray = [focusedCellKey];\n } else {\n selectedCellsArray = Array.from(\n currentState.selectionState.selectedCells,\n );\n }\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n if (!rows) return;\n\n const selectedColumnIds: string[] = [];\n\n for (const cellKey of selectedCellsArray) {\n const { columnId } = parseCellKey(cellKey);\n if (columnId && !selectedColumnIds.includes(columnId)) {\n selectedColumnIds.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 const cellVariant = cell.column.columnDef?.meta?.cell?.variant;\n\n let serializedValue = \"\";\n if (cellVariant === \"file\" || cellVariant === \"multi-select\") {\n serializedValue = value ? JSON.stringify(value) : \"\";\n } else if (value instanceof Date) {\n serializedValue = value.toISOString();\n } else {\n serializedValue = String(value ?? \"\");\n }\n\n cellData.set(cellKey, serializedValue);\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 = selectedColumnIds.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) => selectedColumnIds[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 try {\n await navigator.clipboard.writeText(tsvData);\n toast.success(\n `${selectedCellsArray.length} cell${selectedCellsArray.length !== 1 ? \"s\" : \"\"} copied`,\n );\n } catch (error) {\n console.error(\"Copy failed:\", error);\n toast.error(\"Failed to copy to clipboard\");\n }\n }, [store]);\n\n const pasteCells = React.useCallback(\n async (expandRows = false) => {\n // Block paste in read-only mode\n if (readOnly) return;\n\n const currentState = store.getState();\n if (!currentState.focusedCell) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n if (!rows) return;\n\n try {\n let clipboardText = currentState.pasteDialog.clipboardText;\n\n if (!clipboardText) {\n clipboardText = await navigator.clipboard.readText();\n if (!clipboardText) return;\n }\n\n // Parse TSV data (tab-separated values with newlines for rows)\n const pastedRows = clipboardText\n .split(\"\\n\")\n .filter((row) => row.length > 0);\n const pastedData = pastedRows.map((row) => row.split(\"\\t\"));\n\n const startRowIndex = currentState.focusedCell.rowIndex;\n const startColIndex = navigableColumnIds.indexOf(\n currentState.focusedCell.columnId,\n );\n\n if (startColIndex === -1) return;\n\n const rowCount = rows.length ?? data.length;\n const rowsNeeded = startRowIndex + pastedData.length - rowCount;\n\n if (\n rowsNeeded > 0 &&\n !expandRows &&\n onRowAddProp &&\n !currentState.pasteDialog.clipboardText\n ) {\n store.setState(\"pasteDialog\", {\n open: true,\n rowsNeeded,\n clipboardText,\n });\n return;\n }\n\n if (expandRows && rowsNeeded > 0) {\n const expectedRowCount = rowCount + rowsNeeded;\n\n if (onRowsAdd) {\n await onRowsAdd(rowsNeeded);\n } else if (onRowAddProp) {\n for (let i = 0; i < rowsNeeded; i++) {\n await onRowAddProp();\n }\n }\n\n let attempts = 0;\n const maxAttempts = 50;\n let currentTableRowCount =\n tableRef.current?.getRowModel().rows.length ?? 0;\n\n while (\n currentTableRowCount < expectedRowCount &&\n attempts < maxAttempts\n ) {\n await new Promise((resolve) => setTimeout(resolve, 100));\n currentTableRowCount =\n tableRef.current?.getRowModel().rows.length ?? 0;\n attempts++;\n }\n }\n\n const updates: Array = [];\n const tableColumns = currentTable?.getAllColumns() ?? [];\n let cellsUpdated = 0;\n let endRowIndex = startRowIndex;\n let endColIndex = startColIndex;\n\n const updatedTable = tableRef.current;\n const updatedRows = updatedTable?.getRowModel().rows;\n const currentRowCount = updatedRows?.length ?? 0;\n\n for (\n let pasteRowIdx = 0;\n pasteRowIdx < pastedData.length;\n pasteRowIdx++\n ) {\n const pasteRow = pastedData[pasteRowIdx];\n if (!pasteRow) continue;\n\n const targetRowIndex = startRowIndex + pasteRowIdx;\n if (targetRowIndex >= currentRowCount) break; // Don't paste beyond available rows\n\n for (\n let pasteColIdx = 0;\n pasteColIdx < pasteRow.length;\n pasteColIdx++\n ) {\n const targetColIndex = startColIndex + pasteColIdx;\n if (targetColIndex >= navigableColumnIds.length) break; // Don't paste beyond available columns\n\n const targetColumnId = navigableColumnIds[targetColIndex];\n if (!targetColumnId) continue;\n\n const pastedValue = pasteRow[pasteColIdx] ?? \"\";\n\n const column = tableColumns.find(\n (col) => col.id === targetColumnId,\n );\n const cellVariant = column?.columnDef?.meta?.cell?.variant;\n\n let processedValue: unknown = pastedValue;\n\n if (cellVariant === \"number\") {\n const numValue = Number.parseFloat(pastedValue);\n processedValue = Number.isNaN(numValue) ? null : numValue;\n } else if (cellVariant === \"checkbox\") {\n if (!pastedValue) {\n processedValue = false;\n } else {\n processedValue =\n pastedValue.toLowerCase() === \"true\" ||\n pastedValue === \"1\" ||\n pastedValue.toLowerCase() === \"yes\";\n }\n } else if (cellVariant === \"date\") {\n if (pastedValue) {\n const date = new Date(pastedValue);\n processedValue = Number.isNaN(date.getTime()) ? null : date;\n } else {\n processedValue = null;\n }\n } else if (cellVariant === \"multi-select\") {\n try {\n processedValue = JSON.parse(pastedValue);\n } catch {\n processedValue = pastedValue\n ? pastedValue.split(\",\").map((v) => v.trim())\n : [];\n }\n } else if (cellVariant === \"file\") {\n try {\n const parsed = JSON.parse(pastedValue);\n if (Array.isArray(parsed)) {\n processedValue = parsed.filter(\n (item) =>\n item &&\n typeof item === \"object\" &&\n \"id\" in item &&\n \"name\" in item &&\n \"size\" in item &&\n \"type\" in item,\n );\n } else {\n processedValue = [];\n }\n } catch {\n processedValue = [];\n }\n }\n\n updates.push({\n rowIndex: targetRowIndex,\n columnId: targetColumnId,\n value: processedValue,\n });\n cellsUpdated++;\n\n endRowIndex = Math.max(endRowIndex, targetRowIndex);\n endColIndex = Math.max(endColIndex, targetColIndex);\n }\n }\n\n if (updates.length > 0) {\n onDataUpdate(updates);\n toast.success(\n `${cellsUpdated} cell${cellsUpdated !== 1 ? \"s\" : \"\"} pasted`,\n );\n\n const endColumnId = navigableColumnIds[endColIndex];\n if (endColumnId) {\n selectRange(\n {\n rowIndex: startRowIndex,\n columnId: currentState.focusedCell.columnId,\n },\n { rowIndex: endRowIndex, columnId: endColumnId },\n );\n }\n }\n\n if (currentState.pasteDialog.open) {\n store.setState(\"pasteDialog\", {\n open: false,\n rowsNeeded: 0,\n clipboardText: \"\",\n });\n }\n } catch (error) {\n toast.error(\n error instanceof Error\n ? error.message\n : \"Failed to paste. Please try again.\",\n );\n }\n },\n [\n store,\n navigableColumnIds,\n data.length,\n onDataUpdate,\n onRowAddProp,\n onRowsAdd,\n selectRange,\n readOnly,\n ],\n );\n\n const focusCellWrapper = React.useCallback(\n (rowIndex: number, columnId: string) => {\n requestAnimationFrame(() => {\n const cellKey = getCellKey(rowIndex, columnId);\n const cellWrapperElement = cellMapRef.current.get(cellKey);\n\n if (!cellWrapperElement) return;\n\n cellWrapperElement.focus();\n });\n },\n [],\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 focusCellWrapper(rowIndex, columnId);\n },\n [store, focusCellWrapper],\n );\n\n const onRowsDelete = React.useCallback(\n async (rowIndices: number[]) => {\n // Block deletion in read-only mode\n if (readOnly || !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 [\n onRowsDeleteProp,\n data.length,\n store,\n navigableColumnIds,\n focusCell,\n readOnly,\n ],\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 // Block editing in read-only mode\n if (readOnly) return;\n\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", { rowIndex, columnId });\n });\n },\n [store, readOnly],\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 } else if (currentEditing) {\n // No navigation - just refocus the current cell (e.g., when pressing Escape)\n const { rowIndex, columnId } = currentEditing;\n focusCellWrapper(rowIndex, columnId);\n }\n },\n [store, data.length, focusCell, navigateCell, focusCellWrapper],\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 (isCtrlPressed && key === \"c\") {\n event.preventDefault();\n copyCells();\n return;\n }\n\n if (enablePaste && isCtrlPressed && key === \"v\" && !readOnly) {\n event.preventDefault();\n pasteCells();\n return;\n }\n\n if ((key === \"Delete\" || key === \"Backspace\") && !readOnly) {\n if (currentState.selectionState.selectedCells.size > 0) {\n event.preventDefault();\n\n const updates: Array<{\n rowIndex: number;\n columnId: string;\n value: unknown;\n }> = [];\n\n const currentTable = tableRef.current;\n const tableColumns = currentTable?.getAllColumns() ?? [];\n\n currentState.selectionState.selectedCells.forEach((cellKey) => {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n\n const column = tableColumns.find((col) => col.id === columnId);\n const cellVariant = column?.columnDef?.meta?.cell?.variant;\n\n let emptyValue: unknown = \"\";\n if (cellVariant === \"multi-select\" || cellVariant === \"file\") {\n emptyValue = [];\n } else if (cellVariant === \"number\" || cellVariant === \"date\") {\n emptyValue = null;\n } else if (cellVariant === \"checkbox\") {\n emptyValue = false;\n }\n\n updates.push({ rowIndex, columnId, value: emptyValue });\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 copyCells,\n pasteCells,\n onDataUpdate,\n clearSelection,\n navigableColumnIds,\n data.length,\n selectRange,\n focusCell,\n onSearchOpenChange,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n enableSearch,\n enablePaste,\n readOnly,\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 onPasteDialogOpenChange = React.useCallback(\n (open: boolean) => {\n if (!open) {\n store.setState(\"pasteDialog\", {\n open: false,\n rowsNeeded: 0,\n clipboardText: \"\",\n });\n }\n },\n [store],\n );\n\n const onPasteWithExpansion = React.useCallback(() => {\n pasteCells(true);\n }, [pasteCells]);\n\n const onPasteWithoutExpansion = React.useCallback(() => {\n // Call pasteCells without expansion - it will paste only what fits\n pasteCells(false);\n }, [pasteCells]);\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 cellMapRef,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n rowHeight,\n isScrolling,\n readOnly,\n pasteDialog,\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 onPasteDialogOpenChange,\n onPasteWithExpansion,\n onPasteWithoutExpansion,\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 readOnly,\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 pasteDialog,\n onPasteDialogOpenChange,\n onPasteWithExpansion,\n onPasteWithoutExpansion,\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 requestAnimationFrame(() => {\n store.batch(() => {\n store.setState(\"focusedCell\", {\n rowIndex,\n columnId: targetColumnId,\n });\n store.setState(\"editingCell\", null);\n });\n\n focusCellWrapper(rowIndex, targetColumnId);\n });\n },\n [rowVirtualizer, navigableColumnIds, store, focusCellWrapper],\n );\n\n const onRowAdd = React.useCallback(\n async (event?: React.MouseEvent) => {\n // Block row addition in read-only mode\n if (readOnly || !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, readOnly],\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" }, { @@ -113,7 +113,7 @@ }, { "path": "src/types/data-grid.ts", - "content": "import type { Cell, RowData, Table } 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 CellOpts =\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 | {\n variant: \"url\";\n }\n | {\n variant: \"file\";\n maxFileSize?: number;\n maxFiles?: number;\n accept?: string;\n multiple?: boolean;\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?: CellOpts;\n }\n\n // biome-ignore lint/correctness/noUnusedVariables: TData is used in the TableMeta interface\n interface TableMeta {\n dataGridRef?: React.RefObject;\n cellMapRef?: 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 pasteDialog?: PasteDialogState;\n onPasteDialogOpenChange?: (open: boolean) => void;\n onPasteWithExpansion?: () => void;\n onPasteWithoutExpansion?: () => 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 interface PasteDialogState {\n open: boolean;\n rowsNeeded: number;\n clipboardText: string;\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\nexport interface CellVariantProps {\n cell: Cell;\n table: Table;\n rowIndex: number;\n columnId: string;\n isEditing: boolean;\n isFocused: boolean;\n isSelected: boolean;\n}\n\nexport interface FileCellData {\n id: string;\n name: string;\n size: number;\n type: string;\n url?: string;\n}\n", + "content": "import type { Cell, RowData, Table } 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 CellOpts =\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 | {\n variant: \"url\";\n }\n | {\n variant: \"file\";\n maxFileSize?: number;\n maxFiles?: number;\n accept?: string;\n multiple?: boolean;\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?: CellOpts;\n }\n\n // biome-ignore lint/correctness/noUnusedVariables: TData is used in the TableMeta interface\n interface TableMeta {\n dataGridRef?: React.RefObject;\n cellMapRef?: React.RefObject>;\n focusedCell?: CellPosition | null;\n editingCell?: CellPosition | null;\n selectionState?: SelectionState;\n searchOpen?: boolean;\n isScrolling?: boolean;\n readOnly?: 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 pasteDialog?: PasteDialogState;\n onPasteDialogOpenChange?: (open: boolean) => void;\n onPasteWithExpansion?: () => void;\n onPasteWithoutExpansion?: () => 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 interface PasteDialogState {\n open: boolean;\n rowsNeeded: number;\n clipboardText: string;\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\nexport interface CellVariantProps {\n cell: Cell;\n table: Table;\n rowIndex: number;\n columnId: string;\n isEditing: boolean;\n isFocused: boolean;\n isSelected: boolean;\n readOnly?: boolean;\n}\n\nexport interface FileCellData {\n id: string;\n name: string;\n size: number;\n type: string;\n url?: string;\n}\n", "type": "registry:file", "target": "src/types/data-grid.ts" } diff --git a/src/components/data-grid/data-grid-cell-variants.tsx b/src/components/data-grid/data-grid-cell-variants.tsx index 596a6a428..fbf3cbe9d 100644 --- a/src/components/data-grid/data-grid-cell-variants.tsx +++ b/src/components/data-grid/data-grid-cell-variants.tsx @@ -57,6 +57,7 @@ export function ShortTextCell({ isEditing, isFocused, isSelected, + readOnly, }: CellVariantProps) { const initialValue = cell.getValue() as string; const [value, setValue] = React.useState(initialValue); @@ -76,11 +77,11 @@ export function ShortTextCell({ const onBlur = React.useCallback(() => { // Read the current value directly from the DOM to avoid stale state const currentValue = cellRef.current?.textContent ?? ""; - if (currentValue !== initialValue) { + if (!readOnly && currentValue !== initialValue) { meta?.onDataUpdate?.({ rowIndex, columnId, value: currentValue }); } meta?.onCellEditingStop?.(); - }, [meta, rowIndex, columnId, initialValue]); + }, [meta, rowIndex, columnId, initialValue, readOnly]); const onInput = React.useCallback( (event: React.FormEvent) => { @@ -200,6 +201,7 @@ export function LongTextCell({ isFocused, isEditing, isSelected, + readOnly, }: CellVariantProps) { const initialValue = cell.getValue() as string; const [value, setValue] = React.useState(initialValue ?? ""); @@ -216,37 +218,41 @@ export function LongTextCell({ // Debounced auto-save (300ms delay) const debouncedSave = useDebouncedCallback((newValue: string) => { - meta?.onDataUpdate?.({ rowIndex, columnId, value: newValue }); + if (!readOnly) { + meta?.onDataUpdate?.({ rowIndex, columnId, value: newValue }); + } }, 300); const onSave = React.useCallback(() => { // Immediately save any pending changes and close the popover - if (value !== initialValue) { + if (!readOnly && value !== initialValue) { meta?.onDataUpdate?.({ rowIndex, columnId, value }); } meta?.onCellEditingStop?.(); - }, [meta, value, initialValue, rowIndex, columnId]); + }, [meta, value, initialValue, rowIndex, columnId, readOnly]); const onCancel = React.useCallback(() => { // Restore the original value setValue(initialValue ?? ""); - meta?.onDataUpdate?.({ rowIndex, columnId, value: initialValue }); + if (!readOnly) { + meta?.onDataUpdate?.({ rowIndex, columnId, value: initialValue }); + } meta?.onCellEditingStop?.(); - }, [meta, initialValue, rowIndex, columnId]); + }, [meta, initialValue, rowIndex, columnId, readOnly]); const onOpenChange = React.useCallback( (isOpen: boolean) => { - if (isOpen) { + if (isOpen && !readOnly) { meta?.onCellEditingStart?.(rowIndex, columnId); } else { // Immediately save any pending changes when closing - if (value !== initialValue) { + if (!readOnly && value !== initialValue) { meta?.onDataUpdate?.({ rowIndex, columnId, value }); } meta?.onCellEditingStop?.(); } }, - [meta, value, initialValue, rowIndex, columnId], + [meta, value, initialValue, rowIndex, columnId, readOnly], ); const onOpenAutoFocus: NonNullable< @@ -262,11 +268,11 @@ export function LongTextCell({ const onBlur = React.useCallback(() => { // Immediately save any pending changes on blur - if (value !== initialValue) { + if (!readOnly && value !== initialValue) { meta?.onDataUpdate?.({ rowIndex, columnId, value }); } meta?.onCellEditingStop?.(); - }, [meta, value, initialValue, rowIndex, columnId]); + }, [meta, value, initialValue, rowIndex, columnId, readOnly]); const onChange = React.useCallback( (event: React.ChangeEvent) => { @@ -349,6 +355,7 @@ export function NumberCell({ isFocused, isEditing, isSelected, + readOnly, }: CellVariantProps) { const initialValue = cell.getValue() as number; const [value, setValue] = React.useState(String(initialValue ?? "")); @@ -368,11 +375,11 @@ export function NumberCell({ const onBlur = React.useCallback(() => { const numValue = value === "" ? null : Number(value); - if (numValue !== initialValue) { + if (!readOnly && numValue !== initialValue) { meta?.onDataUpdate?.({ rowIndex, columnId, value: numValue }); } meta?.onCellEditingStop?.(); - }, [meta, rowIndex, columnId, initialValue, value]); + }, [meta, rowIndex, columnId, initialValue, value, readOnly]); const onChange = React.useCallback( (event: React.ChangeEvent) => { @@ -483,6 +490,7 @@ export function UrlCell({ isEditing, isFocused, isSelected, + readOnly, }: CellVariantProps) { const initialValue = cell.getValue() as string; const [value, setValue] = React.useState(initialValue ?? ""); @@ -502,7 +510,7 @@ export function UrlCell({ const onBlur = React.useCallback(() => { const currentValue = cellRef.current?.textContent?.trim() ?? ""; - if (currentValue !== initialValue) { + if (!readOnly && currentValue !== initialValue) { meta?.onDataUpdate?.({ rowIndex, columnId, @@ -510,7 +518,7 @@ export function UrlCell({ }); } meta?.onCellEditingStop?.(); - }, [meta, rowIndex, columnId, initialValue]); + }, [meta, rowIndex, columnId, initialValue, readOnly]); const onInput = React.useCallback( (event: React.FormEvent) => { @@ -526,7 +534,7 @@ export function UrlCell({ if (event.key === "Enter") { event.preventDefault(); const currentValue = cellRef.current?.textContent?.trim() ?? ""; - if (currentValue !== initialValue) { + if (!readOnly && currentValue !== initialValue) { meta?.onDataUpdate?.({ rowIndex, columnId, @@ -537,7 +545,7 @@ export function UrlCell({ } else if (event.key === "Tab") { event.preventDefault(); const currentValue = cellRef.current?.textContent?.trim() ?? ""; - if (currentValue !== initialValue) { + if (!readOnly && currentValue !== initialValue) { meta?.onDataUpdate?.({ rowIndex, columnId, @@ -554,6 +562,7 @@ export function UrlCell({ } } else if ( isFocused && + !readOnly && event.key.length === 1 && !event.ctrlKey && !event.metaKey @@ -574,7 +583,7 @@ export function UrlCell({ }); } }, - [isEditing, isFocused, initialValue, meta, rowIndex, columnId], + [isEditing, isFocused, initialValue, meta, rowIndex, columnId, readOnly], ); const onLinkClick = React.useCallback( @@ -682,7 +691,8 @@ export function CheckboxCell({ columnId, isFocused, isSelected, -}: CellVariantProps) { + readOnly, +}: Omit, "isEditing">) { const initialValue = cell.getValue() as boolean; const [value, setValue] = React.useState(Boolean(initialValue)); const containerRef = React.useRef(null); @@ -696,15 +706,20 @@ export function CheckboxCell({ const onCheckedChange = React.useCallback( (checked: boolean) => { + if (readOnly) return; setValue(checked); meta?.onDataUpdate?.({ rowIndex, columnId, value: checked }); }, - [meta, rowIndex, columnId], + [meta, rowIndex, columnId, readOnly], ); const onWrapperKeyDown = React.useCallback( (event: React.KeyboardEvent) => { - if (isFocused && (event.key === " " || event.key === "Enter")) { + if ( + isFocused && + !readOnly && + (event.key === " " || event.key === "Enter") + ) { event.preventDefault(); event.stopPropagation(); onCheckedChange(!value); @@ -715,18 +730,18 @@ export function CheckboxCell({ }); } }, - [isFocused, value, onCheckedChange, meta], + [isFocused, value, onCheckedChange, meta, readOnly], ); const onWrapperClick = React.useCallback( (event: React.MouseEvent) => { - if (isFocused) { + if (isFocused && !readOnly) { event.preventDefault(); event.stopPropagation(); onCheckedChange(!value); } }, - [isFocused, value, onCheckedChange], + [isFocused, value, onCheckedChange, readOnly], ); const onCheckboxClick = React.useCallback((event: React.MouseEvent) => { @@ -764,6 +779,7 @@ export function CheckboxCell({ ({ isFocused, isEditing, isSelected, + readOnly, }: CellVariantProps) { const initialValue = cell.getValue() as string; const [value, setValue] = React.useState(initialValue); @@ -797,22 +814,23 @@ export function SelectCell({ const onValueChange = React.useCallback( (newValue: string) => { + if (readOnly) return; setValue(newValue); meta?.onDataUpdate?.({ rowIndex, columnId, value: newValue }); meta?.onCellEditingStop?.(); }, - [meta, rowIndex, columnId], + [meta, rowIndex, columnId, readOnly], ); const onOpenChange = React.useCallback( (isOpen: boolean) => { - if (isOpen) { + if (isOpen && !readOnly) { meta?.onCellEditingStart?.(rowIndex, columnId); } else { meta?.onCellEditingStop?.(); } }, - [meta, rowIndex, columnId], + [meta, rowIndex, columnId, readOnly], ); const onWrapperKeyDown = React.useCallback( @@ -889,6 +907,7 @@ export function MultiSelectCell({ isFocused, isEditing, isSelected, + readOnly, }: CellVariantProps) { const cellValue = React.useMemo(() => { const value = cell.getValue() as string[]; @@ -921,6 +940,7 @@ export function MultiSelectCell({ const onValueChange = React.useCallback( (value: string) => { + if (readOnly) return; const newValues = selectedValues.includes(value) ? selectedValues.filter((v) => v !== value) : [...selectedValues, value]; @@ -930,11 +950,12 @@ export function MultiSelectCell({ setSearchValue(""); queueMicrotask(() => inputRef.current?.focus()); }, - [selectedValues, meta, rowIndex, columnId], + [selectedValues, meta, rowIndex, columnId, readOnly], ); const removeValue = React.useCallback( (valueToRemove: string, event?: React.MouseEvent) => { + if (readOnly) return; event?.stopPropagation(); event?.preventDefault(); const newValues = selectedValues.filter((v) => v !== valueToRemove); @@ -943,25 +964,26 @@ export function MultiSelectCell({ // Focus back on input after removing setTimeout(() => inputRef.current?.focus(), 0); }, - [selectedValues, meta, rowIndex, columnId], + [selectedValues, meta, rowIndex, columnId, readOnly], ); const clearAll = React.useCallback(() => { + if (readOnly) return; setSelectedValues([]); meta?.onDataUpdate?.({ rowIndex, columnId, value: [] }); queueMicrotask(() => inputRef.current?.focus()); - }, [meta, rowIndex, columnId]); + }, [meta, rowIndex, columnId, readOnly]); const onOpenChange = React.useCallback( (isOpen: boolean) => { - if (isOpen) { + if (isOpen && !readOnly) { meta?.onCellEditingStart?.(rowIndex, columnId); } else { setSearchValue(""); meta?.onCellEditingStop?.(); } }, - [meta, rowIndex, columnId], + [meta, rowIndex, columnId, readOnly], ); const onOpenAutoFocus: NonNullable< @@ -1170,6 +1192,7 @@ export function DateCell({ isFocused, isEditing, isSelected, + readOnly, }: CellVariantProps) { const initialValue = cell.getValue() as string; const [value, setValue] = React.useState(initialValue ?? ""); @@ -1186,25 +1209,25 @@ export function DateCell({ const onDateSelect = React.useCallback( (date: Date | undefined) => { - if (!date) return; + if (!date || readOnly) return; const formattedDate = date.toISOString().split("T")[0] ?? ""; setValue(formattedDate); meta?.onDataUpdate?.({ rowIndex, columnId, value: formattedDate }); meta?.onCellEditingStop?.(); }, - [meta, rowIndex, columnId], + [meta, rowIndex, columnId, readOnly], ); const onOpenChange = React.useCallback( (isOpen: boolean) => { - if (isOpen) { + if (isOpen && !readOnly) { meta?.onCellEditingStart?.(rowIndex, columnId); } else { meta?.onCellEditingStop?.(); } }, - [meta, rowIndex, columnId], + [meta, rowIndex, columnId, readOnly], ); const onWrapperKeyDown = React.useCallback( @@ -1304,6 +1327,7 @@ export function FileCell({ isFocused, isEditing, isSelected, + readOnly, }: CellVariantProps) { const cellValue = React.useMemo( () => (cell.getValue() as FileCellData[]) ?? [], @@ -1391,6 +1415,7 @@ export function FileCell({ const addFiles = React.useCallback( async (newFiles: File[], skipUpload = false) => { + if (readOnly) return; setError(null); // Check max files limit @@ -1481,11 +1506,12 @@ export function FileCell({ } } }, - [files, maxFiles, validateFile, meta, rowIndex, columnId], + [files, maxFiles, validateFile, meta, rowIndex, columnId, readOnly], ); const removeFile = React.useCallback( (fileId: string) => { + if (readOnly) return; setError(null); // Revoke object URL to prevent memory leak const fileToRemove = files.find((f) => f.id === fileId); @@ -1496,10 +1522,11 @@ export function FileCell({ setFiles(updatedFiles); meta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles }); }, - [files, meta, rowIndex, columnId], + [files, meta, rowIndex, columnId, readOnly], ); const clearAll = React.useCallback(() => { + if (readOnly) return; // Revoke all object URLs to prevent memory leak for (const file of files) { if (file.url) { @@ -1509,7 +1536,7 @@ export function FileCell({ setFiles([]); setError(null); meta?.onDataUpdate?.({ rowIndex, columnId, value: [] }); - }, [files, meta, rowIndex, columnId]); + }, [files, meta, rowIndex, columnId, readOnly]); const onDragEnter = React.useCallback((event: React.DragEvent) => { event.preventDefault(); @@ -1623,7 +1650,7 @@ export function FileCell({ const onOpenChange = React.useCallback( (isOpen: boolean) => { - if (isOpen) { + if (isOpen && !readOnly) { setError(null); meta?.onCellEditingStart?.(rowIndex, columnId); } else { @@ -1631,7 +1658,7 @@ export function FileCell({ meta?.onCellEditingStop?.(); } }, - [meta, rowIndex, columnId], + [meta, rowIndex, columnId, readOnly], ); const onEscapeKeyDown: NonNullable< diff --git a/src/components/data-grid/data-grid-cell.tsx b/src/components/data-grid/data-grid-cell.tsx index 64c077d7a..2470cecf7 100644 --- a/src/components/data-grid/data-grid-cell.tsx +++ b/src/components/data-grid/data-grid-cell.tsx @@ -38,6 +38,7 @@ export function DataGridCell({ cell, table }: DataGridCellProps) { meta?.editingCell?.rowIndex === rowIndex && meta?.editingCell?.columnId === columnId; const isSelected = meta?.getIsCellSelected?.(rowIndex, columnId) ?? false; + const readOnly = meta?.readOnly ?? false; const cellOpts = cell.column.columnDef.meta?.cell; const variant = cellOpts?.variant ?? "text"; @@ -53,6 +54,7 @@ export function DataGridCell({ cell, table }: DataGridCellProps) { isEditing={isEditing} isFocused={isFocused} isSelected={isSelected} + readOnly={readOnly} /> ); case "long-text": @@ -65,6 +67,7 @@ export function DataGridCell({ cell, table }: DataGridCellProps) { isEditing={isEditing} isFocused={isFocused} isSelected={isSelected} + readOnly={readOnly} /> ); case "number": @@ -77,6 +80,7 @@ export function DataGridCell({ cell, table }: DataGridCellProps) { isEditing={isEditing} isFocused={isFocused} isSelected={isSelected} + readOnly={readOnly} /> ); case "url": @@ -89,6 +93,7 @@ export function DataGridCell({ cell, table }: DataGridCellProps) { isEditing={isEditing} isFocused={isFocused} isSelected={isSelected} + readOnly={readOnly} /> ); case "checkbox": @@ -98,9 +103,9 @@ export function DataGridCell({ cell, table }: DataGridCellProps) { table={table} rowIndex={rowIndex} columnId={columnId} - isEditing={isEditing} isFocused={isFocused} isSelected={isSelected} + readOnly={readOnly} /> ); case "select": @@ -113,6 +118,7 @@ export function DataGridCell({ cell, table }: DataGridCellProps) { isEditing={isEditing} isFocused={isFocused} isSelected={isSelected} + readOnly={readOnly} /> ); case "multi-select": @@ -125,6 +131,7 @@ export function DataGridCell({ cell, table }: DataGridCellProps) { isEditing={isEditing} isFocused={isFocused} isSelected={isSelected} + readOnly={readOnly} /> ); case "date": @@ -137,6 +144,7 @@ export function DataGridCell({ cell, table }: DataGridCellProps) { isEditing={isEditing} isFocused={isFocused} isSelected={isSelected} + readOnly={readOnly} /> ); case "file": @@ -149,6 +157,7 @@ export function DataGridCell({ cell, table }: DataGridCellProps) { isEditing={isEditing} isFocused={isFocused} isSelected={isSelected} + readOnly={readOnly} /> ); @@ -162,6 +171,7 @@ export function DataGridCell({ cell, table }: DataGridCellProps) { isEditing={isEditing} isFocused={isFocused} isSelected={isSelected} + readOnly={readOnly} /> ); } diff --git a/src/hooks/use-data-grid.tsx b/src/hooks/use-data-grid.tsx index d55cf119d..403d818b9 100644 --- a/src/hooks/use-data-grid.tsx +++ b/src/hooks/use-data-grid.tsx @@ -112,6 +112,7 @@ interface UseDataGridProps enableColumnSelection?: boolean; enableSearch?: boolean; enablePaste?: boolean; + readOnly?: boolean; } function useDataGrid({ @@ -128,6 +129,7 @@ function useDataGrid({ enableColumnSelection = false, enableSearch = false, enablePaste = false, + readOnly = false, ...dataGridProps }: UseDataGridProps) { const dataGridRef = React.useRef(null); @@ -259,6 +261,9 @@ function useDataGrid({ const onDataUpdate = React.useCallback( (updates: UpdateCell | Array) => { + // Block data updates in read-only mode + if (readOnly) return; + const updateArray = Array.isArray(updates) ? updates : [updates]; if (updateArray.length === 0) return; @@ -324,7 +329,7 @@ function useDataGrid({ onDataChange?.(newData); }, - [data, onDataChange], + [data, onDataChange, readOnly], ); const getIsCellSelected = React.useCallback( @@ -526,6 +531,9 @@ function useDataGrid({ const pasteCells = React.useCallback( async (expandRows = false) => { + // Block paste in read-only mode + if (readOnly) return; + const currentState = store.getState(); if (!currentState.focusedCell) return; @@ -740,6 +748,7 @@ function useDataGrid({ onRowAddProp, onRowsAdd, selectRange, + readOnly, ], ); @@ -775,7 +784,8 @@ function useDataGrid({ const onRowsDelete = React.useCallback( async (rowIndices: number[]) => { - if (!onRowsDeleteProp || rowIndices.length === 0) return; + // Block deletion in read-only mode + if (readOnly || !onRowsDeleteProp || rowIndices.length === 0) return; const currentTable = tableRef.current; const rows = currentTable?.getRowModel().rows; @@ -819,7 +829,14 @@ function useDataGrid({ } }); }, - [onRowsDeleteProp, data.length, store, navigableColumnIds, focusCell], + [ + onRowsDeleteProp, + data.length, + store, + navigableColumnIds, + focusCell, + readOnly, + ], ); const navigateCell = React.useCallback( @@ -1000,12 +1017,15 @@ function useDataGrid({ const onCellEditingStart = React.useCallback( (rowIndex: number, columnId: string) => { + // Block editing in read-only mode + if (readOnly) return; + store.batch(() => { store.setState("focusedCell", { rowIndex, columnId }); store.setState("editingCell", { rowIndex, columnId }); }); }, - [store], + [store, readOnly], ); const onCellEditingStop = React.useCallback( @@ -1467,13 +1487,13 @@ function useDataGrid({ return; } - if (enablePaste && isCtrlPressed && key === "v") { + if (enablePaste && isCtrlPressed && key === "v" && !readOnly) { event.preventDefault(); pasteCells(); return; } - if (key === "Delete" || key === "Backspace") { + if ((key === "Delete" || key === "Backspace") && !readOnly) { if (currentState.selectionState.selectedCells.size > 0) { event.preventDefault(); @@ -1622,6 +1642,7 @@ function useDataGrid({ onNavigateToPrevMatch, enableSearch, enablePaste, + readOnly, ], ); @@ -1790,6 +1811,7 @@ function useDataGrid({ searchOpen, rowHeight, isScrolling, + readOnly, pasteDialog, getIsCellSelected, getIsSearchMatch, @@ -1829,6 +1851,7 @@ function useDataGrid({ selectionState, searchOpen, isScrolling, + readOnly, getIsCellSelected, getIsSearchMatch, getIsActiveSearchMatch, @@ -1943,7 +1966,8 @@ function useDataGrid({ const onRowAdd = React.useCallback( async (event?: React.MouseEvent) => { - if (!onRowAddProp) return; + // Block row addition in read-only mode + if (readOnly || !onRowAddProp) return; const result = await onRowAddProp(event); @@ -1965,7 +1989,7 @@ function useDataGrid({ onScrollToRow({ rowIndex: rows.length }); }, - [onRowAddProp, onScrollToRow], + [onRowAddProp, onScrollToRow, readOnly], ); const searchState = React.useMemo(() => { diff --git a/src/types/data-grid.ts b/src/types/data-grid.ts index 3422194c0..472f9fb80 100644 --- a/src/types/data-grid.ts +++ b/src/types/data-grid.ts @@ -67,6 +67,7 @@ declare module "@tanstack/react-table" { selectionState?: SelectionState; searchOpen?: boolean; isScrolling?: boolean; + readOnly?: boolean; getIsCellSelected?: (rowIndex: number, columnId: string) => boolean; getIsSearchMatch?: (rowIndex: number, columnId: string) => boolean; getIsActiveSearchMatch?: (rowIndex: number, columnId: string) => boolean; @@ -176,6 +177,7 @@ export interface CellVariantProps { isEditing: boolean; isFocused: boolean; isSelected: boolean; + readOnly?: boolean; } export interface FileCellData {