diff --git a/public/r/data-grid.json b/public/r/data-grid.json index b98afec3..e7aa49f4 100644 --- a/public/r/data-grid.json +++ b/public/r/data-grid.json @@ -47,7 +47,7 @@ }, { "path": "src/components/data-grid/data-grid-cell-variants.tsx", - "content": "\"use client\";\n\nimport { Check, Upload, X } 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 {\n formatDateForDisplay,\n formatDateToString,\n formatFileSize,\n getCellKey,\n getFileIcon,\n getLineCount,\n getUrlHref,\n parseLocalDate,\n} from \"@/lib/data-grid\";\nimport { cn } from \"@/lib/utils\";\nimport type { DataGridCellProps, FileCellData } from \"@/types/data-grid\";\n\nexport function ShortTextCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isEditing,\n isFocused,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\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 prevIsEditingRef = React.useRef(isEditing);\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 tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: currentValue });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, 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 tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue,\n });\n }\n tableMeta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (currentValue !== initialValue) {\n tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue,\n });\n }\n tableMeta?.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, tableMeta, rowIndex, columnId],\n );\n\n React.useEffect(() => {\n const wasEditing = prevIsEditingRef.current;\n prevIsEditingRef.current = isEditing;\n\n if (isEditing && !wasEditing && 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 ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n \n {displayValue}\n \n \n );\n}\n\nexport function LongTextCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\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 pendingCharRef = React.useRef(null);\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 const debouncedSave = useDebouncedCallback((newValue: string) => {\n if (!readOnly) {\n tableMeta?.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 tableMeta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, value, initialValue, rowIndex, columnId, readOnly]);\n\n const onCancel = React.useCallback(() => {\n // Restore the original value\n setValue(initialValue ?? \"\");\n if (!readOnly) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: initialValue });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, initialValue, rowIndex, columnId, readOnly]);\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n // Immediately save any pending changes when closing\n if (!readOnly && value !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, 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 // Insert pending character using execCommand so it's part of undo history\n // Use requestAnimationFrame to ensure focus has fully settled\n if (pendingCharRef.current) {\n const char = pendingCharRef.current;\n pendingCharRef.current = null;\n requestAnimationFrame(() => {\n if (\n textareaRef.current &&\n document.activeElement === textareaRef.current\n ) {\n document.execCommand(\"insertText\", false, char);\n textareaRef.current.scrollTop = textareaRef.current.scrollHeight;\n }\n });\n } else {\n textareaRef.current.scrollTop = textareaRef.current.scrollHeight;\n }\n }\n }, []);\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n isFocused &&\n !isEditing &&\n !readOnly &&\n event.key.length === 1 &&\n !event.ctrlKey &&\n !event.metaKey\n ) {\n // Store the character to be inserted after textarea focuses\n // This ensures it's part of the textarea's undo history\n pendingCharRef.current = event.key;\n }\n },\n [isFocused, isEditing, readOnly],\n );\n\n const onBlur = React.useCallback(() => {\n // Immediately save any pending changes on blur\n if (!readOnly && value !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, 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 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 tableMeta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n tableMeta?.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, tableMeta, rowIndex, columnId],\n );\n\n return (\n \n \n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {value}\n \n \n \n \n \n \n );\n}\n\nexport function NumberCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\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\n const cellOpts = cell.column.columnDef.meta?.cell;\n const numberCellOpts = cellOpts?.variant === \"number\" ? cellOpts : null;\n const min = numberCellOpts?.min;\n const max = numberCellOpts?.max;\n const step = numberCellOpts?.step;\n\n const prevIsEditingRef = React.useRef(isEditing);\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 tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, 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 tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n tableMeta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const numValue = value === \"\" ? null : Number(value);\n if (numValue !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n tableMeta?.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, tableMeta, rowIndex, columnId, value],\n );\n\n React.useEffect(() => {\n const wasEditing = prevIsEditingRef.current;\n prevIsEditingRef.current = isEditing;\n\n // Only focus when we start editing (transition from false to true)\n if (isEditing && !wasEditing && inputRef.current) {\n inputRef.current.focus();\n }\n }, [isEditing]);\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {isEditing ? (\n \n ) : (\n {value}\n )}\n \n );\n}\n\nexport function UrlCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isEditing,\n isFocused,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\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 prevIsEditingRef = React.useRef(isEditing);\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 tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, 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 tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n tableMeta?.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 tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n tableMeta?.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 [\n isEditing,\n isFocused,\n initialValue,\n tableMeta,\n rowIndex,\n columnId,\n readOnly,\n ],\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 const wasEditing = prevIsEditingRef.current;\n prevIsEditingRef.current = isEditing;\n\n if (isEditing && !wasEditing && 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 ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\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 tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\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\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 tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: checked });\n },\n [tableMeta, 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 tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isFocused, value, onCheckedChange, tableMeta, 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 ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={false}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n className=\"flex size-full justify-center\"\n onClick={onWrapperClick}\n onKeyDown={onWrapperKeyDown}\n >\n \n \n );\n}\n\nexport function SelectCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue);\n const containerRef = React.useRef(null);\n const cellOpts = cell.column.columnDef.meta?.cell;\n const options = React.useMemo(\n () => (cellOpts?.variant === \"select\" ? cellOpts.options : []),\n [cellOpts],\n );\n const optionByValue = React.useMemo(\n () => new Map(options.map((option) => [option.value, option])),\n [options],\n );\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 tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValue });\n tableMeta?.onCellEditingStop?.();\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, 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 tableMeta?.onCellEditingStop?.();\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, initialValue, tableMeta],\n );\n\n const displayLabel = optionByValue.get(value)?.label ?? value;\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {isEditing ? (\n \n \n {displayLabel ? (\n \n \n \n ) : (\n \n )}\n \n \n {options.map((option) => (\n \n {option.label}\n \n ))}\n \n \n ) : displayLabel ? (\n \n {displayLabel}\n \n ) : null}\n \n );\n}\n\nexport function MultiSelectCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\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 cellOpts = cell.column.columnDef.meta?.cell;\n const options = React.useMemo(\n () => (cellOpts?.variant === \"multi-select\" ? cellOpts.options : []),\n [cellOpts],\n );\n const optionByValue = React.useMemo(\n () => new Map(options.map((option) => [option.value, option])),\n [options],\n );\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 let newValues: string[] = [];\n setSelectedValues((curr) => {\n newValues = curr.includes(value)\n ? curr.filter((v) => v !== value)\n : [...curr, value];\n return newValues;\n });\n queueMicrotask(() => {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n inputRef.current?.focus();\n });\n setSearchValue(\"\");\n },\n [tableMeta, 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 let newValues: string[] = [];\n setSelectedValues((curr) => {\n newValues = curr.filter((v) => v !== valueToRemove);\n return newValues;\n });\n queueMicrotask(() => {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n inputRef.current?.focus();\n });\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const clearAll = React.useCallback(() => {\n if (readOnly) return;\n setSelectedValues([]);\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: [] });\n queueMicrotask(() => inputRef.current?.focus());\n }, [tableMeta, rowIndex, columnId, readOnly]);\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n setSearchValue(\"\");\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, 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 tableMeta?.onCellEditingStop?.();\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n setSearchValue(\"\");\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, cellValue, tableMeta],\n );\n\n const onInputKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === \"Backspace\" && searchValue === \"\") {\n event.preventDefault();\n let newValues: string[] | null = null;\n setSelectedValues((curr) => {\n if (curr.length === 0) return curr;\n newValues = curr.slice(0, -1);\n return newValues;\n });\n queueMicrotask(() => {\n if (newValues !== null) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n }\n inputRef.current?.focus();\n });\n }\n if (event.key === \"Escape\") {\n event.stopPropagation();\n }\n },\n [searchValue, tableMeta, rowIndex, columnId],\n );\n\n const displayLabels = selectedValues\n .map((val) => optionByValue.get(val)?.label ?? val)\n .filter(Boolean);\n\n const selectedValuesSet = React.useMemo(\n () => new Set(selectedValues),\n [selectedValues],\n );\n\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 ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {isEditing ? (\n \n \n
\n \n \n \n
\n {selectedValues.map((value) => {\n const label = optionByValue.get(value)?.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 = selectedValuesSet.has(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\nexport function DateCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const containerRef = React.useRef(null);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n }\n\n // Parse date as local time to avoid timezone shifts\n const selectedDate = value ? (parseLocalDate(value) ?? undefined) : undefined;\n\n const onDateSelect = React.useCallback(\n (date: Date | undefined) => {\n if (!date || readOnly) return;\n\n // Format using local date components to avoid timezone issues\n const formattedDate = formatDateToString(date);\n setValue(formattedDate);\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: formattedDate });\n tableMeta?.onCellEditingStop?.();\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, 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 tableMeta?.onCellEditingStop?.();\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, initialValue, tableMeta],\n );\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n \n \n \n {formatDateForDisplay(value)}\n \n \n {isEditing && (\n \n \n \n )}\n \n \n );\n}\n\nexport function FileCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\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 [deletingFiles, setDeletingFiles] = 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\n const isUploading = uploadingFiles.size > 0;\n const isDeleting = deletingFiles.size > 0;\n const isPending = isUploading || isDeleting;\n const containerRef = React.useRef(null);\n const fileInputRef = React.useRef(null);\n const dropzoneRef = React.useRef(null);\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 ?? false;\n\n const acceptedTypes = React.useMemo(\n () => (accept ? accept.split(\",\").map((t) => t.trim()) : null),\n [accept],\n );\n\n const prevCellValueRef = React.useRef(cellValue);\n if (cellValue !== prevCellValueRef.current) {\n prevCellValueRef.current = cellValue;\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 const baseType = type.slice(0, -2);\n return file.type.startsWith(`${baseType}/`);\n }\n if (type.startsWith(\".\")) {\n return fileExtension.toLowerCase() === type.toLowerCase();\n }\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 || isPending) return;\n setError(null);\n\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 rejectedFiles: Array<{ name: string; reason: string }> = [];\n const filesToValidate: File[] = [];\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 filesToValidate.push(file);\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 (filesToValidate.length > 0) {\n if (!skipUpload) {\n const tempFiles = filesToValidate.map((f) => ({\n id: crypto.randomUUID(),\n name: f.name,\n size: f.size,\n type: f.type,\n url: undefined,\n }));\n const filesWithTemp = [...files, ...tempFiles];\n setFiles(filesWithTemp);\n\n const uploadingIds = new Set(tempFiles.map((f) => f.id));\n setUploadingFiles(uploadingIds);\n\n let uploadedFiles: FileCellData[] = [];\n\n if (tableMeta?.onFilesUpload) {\n try {\n uploadedFiles = await tableMeta.onFilesUpload({\n files: filesToValidate,\n rowIndex,\n columnId,\n });\n } catch (error) {\n toast.error(\n error instanceof Error\n ? error.message\n : `Failed to upload ${filesToValidate.length} file${filesToValidate.length !== 1 ? \"s\" : \"\"}`,\n );\n setFiles((prev) => prev.filter((f) => !uploadingIds.has(f.id)));\n setUploadingFiles(new Set());\n return;\n }\n } else {\n uploadedFiles = filesToValidate.map((f, i) => ({\n id: tempFiles[i]?.id ?? crypto.randomUUID(),\n name: f.name,\n size: f.size,\n type: f.type,\n url: URL.createObjectURL(f),\n }));\n }\n\n const finalFiles = filesWithTemp\n .map((f) => {\n if (uploadingIds.has(f.id)) {\n return uploadedFiles.find((uf) => uf.name === f.name) ?? f;\n }\n return f;\n })\n .filter((f) => f.url !== undefined);\n\n setFiles(finalFiles);\n setUploadingFiles(new Set());\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: finalFiles });\n } else {\n const newFilesData: FileCellData[] = filesToValidate.map((f) => ({\n id: crypto.randomUUID(),\n name: f.name,\n size: f.size,\n type: f.type,\n url: URL.createObjectURL(f),\n }));\n const updatedFiles = [...files, ...newFilesData];\n setFiles(updatedFiles);\n tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: updatedFiles,\n });\n }\n }\n },\n [\n files,\n maxFiles,\n validateFile,\n tableMeta,\n rowIndex,\n columnId,\n readOnly,\n isPending,\n ],\n );\n\n const removeFile = React.useCallback(\n async (fileId: string) => {\n if (readOnly || isPending) return;\n setError(null);\n\n const fileToRemove = files.find((f) => f.id === fileId);\n if (!fileToRemove) return;\n\n setDeletingFiles((prev) => new Set(prev).add(fileId));\n\n if (tableMeta?.onFilesDelete) {\n try {\n await tableMeta.onFilesDelete({\n fileIds: [fileId],\n rowIndex,\n columnId,\n });\n } catch (error) {\n toast.error(\n error instanceof Error\n ? error.message\n : `Failed to delete ${fileToRemove.name}`,\n );\n setDeletingFiles((prev) => {\n const next = new Set(prev);\n next.delete(fileId);\n return next;\n });\n return;\n }\n }\n\n if (fileToRemove.url?.startsWith(\"blob:\")) {\n URL.revokeObjectURL(fileToRemove.url);\n }\n\n const updatedFiles = files.filter((f) => f.id !== fileId);\n setFiles(updatedFiles);\n setDeletingFiles((prev) => {\n const next = new Set(prev);\n next.delete(fileId);\n return next;\n });\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles });\n },\n [files, tableMeta, rowIndex, columnId, readOnly, isPending],\n );\n\n const clearAll = React.useCallback(async () => {\n if (readOnly || isPending) return;\n setError(null);\n\n const fileIds = files.map((f) => f.id);\n setDeletingFiles(new Set(fileIds));\n\n if (tableMeta?.onFilesDelete && files.length > 0) {\n try {\n await tableMeta.onFilesDelete({\n fileIds,\n rowIndex,\n columnId,\n });\n } catch (error) {\n toast.error(\n error instanceof Error ? error.message : \"Failed to delete files\",\n );\n setDeletingFiles(new Set());\n return;\n }\n }\n\n for (const file of files) {\n if (file.url?.startsWith(\"blob:\")) {\n URL.revokeObjectURL(file.url);\n }\n }\n setFiles([]);\n setDeletingFiles(new Set());\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: [] });\n }, [files, tableMeta, rowIndex, columnId, readOnly, isPending]);\n\n const onCellDragEnter = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\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);\n }\n },\n [addFiles],\n );\n\n const onDropzoneDragEnter = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDragging(true);\n }, []);\n\n const onDropzoneDragLeave = 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 onDropzoneDragOver = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n }, []);\n\n const onDropzoneDrop = 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, false);\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 onFileInputChange = React.useCallback(\n (event: React.ChangeEvent) => {\n const selectedFiles = Array.from(event.target.files ?? []);\n addFiles(selectedFiles, false);\n event.target.value = \"\";\n },\n [addFiles],\n );\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n setError(null);\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n setError(null);\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, 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 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 tableMeta?.onCellEditingStop?.();\n } else if (event.key === \" \") {\n event.preventDefault();\n onDropzoneClick();\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n } else if (isFocused && event.key === \"Enter\") {\n event.preventDefault();\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [\n isEditing,\n isFocused,\n cellValue,\n tableMeta,\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 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 ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n className={cn({\n \"ring-1 ring-primary/80 ring-inset\": isDraggingOver,\n })}\n onDragEnter={onCellDragEnter}\n onDragLeave={onCellDragLeave}\n onDragOver={onCellDragOver}\n onDrop={onCellDrop}\n onKeyDown={onWrapperKeyDown}\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 const isFileUploading = uploadingFiles.has(file.id);\n const isFileDeleting = deletingFiles.has(file.id);\n const isFilePending = isFileUploading || isFileDeleting;\n\n return (\n \n {FileIcon && (\n \n )}\n
\n

{file.name}

\n

\n {isFileUploading\n ? \"Uploading...\"\n : isFileDeleting\n ? \"Deleting...\"\n : formatFileSize(file.size)}\n

\n
\n removeFile(file.id)}\n disabled={isPending}\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 return (\n \n );\n }\n\n const FileIcon = getFileIcon(file.type);\n\n return (\n \n {FileIcon && }\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 { Check, Upload, X } 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 {\n formatDateForDisplay,\n formatDateToString,\n formatFileSize,\n getCellKey,\n getFileIcon,\n getLineCount,\n getUrlHref,\n parseLocalDate,\n} from \"@/lib/data-grid\";\nimport { cn } from \"@/lib/utils\";\nimport type { DataGridCellProps, FileCellData } from \"@/types/data-grid\";\n\nexport function ShortTextCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isEditing,\n isFocused,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\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 prevIsEditingRef = React.useRef(false);\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 tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: currentValue });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, 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 tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue,\n });\n }\n tableMeta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (currentValue !== initialValue) {\n tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue,\n });\n }\n tableMeta?.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, tableMeta, rowIndex, columnId],\n );\n\n React.useEffect(() => {\n const wasEditing = prevIsEditingRef.current;\n prevIsEditingRef.current = isEditing;\n\n if (isEditing && !wasEditing && 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 ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n \n {displayValue}\n \n \n );\n}\n\nexport function LongTextCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\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 pendingCharRef = React.useRef(null);\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 const debouncedSave = useDebouncedCallback((newValue: string) => {\n if (!readOnly) {\n tableMeta?.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 tableMeta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, value, initialValue, rowIndex, columnId, readOnly]);\n\n const onCancel = React.useCallback(() => {\n // Restore the original value\n setValue(initialValue ?? \"\");\n if (!readOnly) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: initialValue });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, initialValue, rowIndex, columnId, readOnly]);\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n // Immediately save any pending changes when closing\n if (!readOnly && value !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, 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 // Insert pending character using execCommand so it's part of undo history\n // Use requestAnimationFrame to ensure focus has fully settled\n if (pendingCharRef.current) {\n const char = pendingCharRef.current;\n pendingCharRef.current = null;\n requestAnimationFrame(() => {\n if (\n textareaRef.current &&\n document.activeElement === textareaRef.current\n ) {\n document.execCommand(\"insertText\", false, char);\n textareaRef.current.scrollTop = textareaRef.current.scrollHeight;\n }\n });\n } else {\n textareaRef.current.scrollTop = textareaRef.current.scrollHeight;\n }\n }\n }, []);\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (\n isFocused &&\n !isEditing &&\n !readOnly &&\n event.key.length === 1 &&\n !event.ctrlKey &&\n !event.metaKey\n ) {\n // Store the character to be inserted after textarea focuses\n // This ensures it's part of the textarea's undo history\n pendingCharRef.current = event.key;\n }\n },\n [isFocused, isEditing, readOnly],\n );\n\n const onBlur = React.useCallback(() => {\n // Immediately save any pending changes on blur\n if (!readOnly && value !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, 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 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 tableMeta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n tableMeta?.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, tableMeta, rowIndex, columnId],\n );\n\n return (\n \n \n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {value}\n \n \n \n \n \n \n );\n}\n\nexport function NumberCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\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\n const cellOpts = cell.column.columnDef.meta?.cell;\n const numberCellOpts = cellOpts?.variant === \"number\" ? cellOpts : null;\n const min = numberCellOpts?.min;\n const max = numberCellOpts?.max;\n const step = numberCellOpts?.step;\n\n const prevIsEditingRef = React.useRef(false);\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 tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, 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 tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n tableMeta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const numValue = value === \"\" ? null : Number(value);\n if (numValue !== initialValue) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n tableMeta?.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, tableMeta, rowIndex, columnId, value],\n );\n\n React.useEffect(() => {\n const wasEditing = prevIsEditingRef.current;\n prevIsEditingRef.current = isEditing;\n\n // Only focus when we start editing (transition from false to true)\n if (isEditing && !wasEditing && inputRef.current) {\n inputRef.current.focus();\n }\n }, [isEditing]);\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {isEditing ? (\n \n ) : (\n {value}\n )}\n \n );\n}\n\nexport function UrlCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isEditing,\n isFocused,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\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 prevIsEditingRef = React.useRef(false);\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 tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n tableMeta?.onCellEditingStop?.();\n }, [tableMeta, 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 tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n tableMeta?.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 tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n tableMeta?.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 [\n isEditing,\n isFocused,\n initialValue,\n tableMeta,\n rowIndex,\n columnId,\n readOnly,\n ],\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 const wasEditing = prevIsEditingRef.current;\n prevIsEditingRef.current = isEditing;\n\n if (isEditing && !wasEditing && 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 ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\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 tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\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\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 tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: checked });\n },\n [tableMeta, 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 tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isFocused, value, onCheckedChange, tableMeta, 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 ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={false}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n className=\"flex size-full justify-center\"\n onClick={onWrapperClick}\n onKeyDown={onWrapperKeyDown}\n >\n \n \n );\n}\n\nexport function SelectCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue);\n const containerRef = React.useRef(null);\n const cellOpts = cell.column.columnDef.meta?.cell;\n const options = React.useMemo(\n () => (cellOpts?.variant === \"select\" ? cellOpts.options : []),\n [cellOpts],\n );\n const optionByValue = React.useMemo(\n () => new Map(options.map((option) => [option.value, option])),\n [options],\n );\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 tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValue });\n tableMeta?.onCellEditingStop?.();\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, 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 tableMeta?.onCellEditingStop?.();\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, initialValue, tableMeta],\n );\n\n const displayLabel = optionByValue.get(value)?.label ?? value;\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {isEditing ? (\n \n \n {displayLabel ? (\n \n \n \n ) : (\n \n )}\n \n \n {options.map((option) => (\n \n {option.label}\n \n ))}\n \n \n ) : displayLabel ? (\n \n {displayLabel}\n \n ) : null}\n \n );\n}\n\nexport function MultiSelectCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\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 cellOpts = cell.column.columnDef.meta?.cell;\n const options = React.useMemo(\n () => (cellOpts?.variant === \"multi-select\" ? cellOpts.options : []),\n [cellOpts],\n );\n const optionByValue = React.useMemo(\n () => new Map(options.map((option) => [option.value, option])),\n [options],\n );\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 let newValues: string[] = [];\n setSelectedValues((curr) => {\n newValues = curr.includes(value)\n ? curr.filter((v) => v !== value)\n : [...curr, value];\n return newValues;\n });\n queueMicrotask(() => {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n inputRef.current?.focus();\n });\n setSearchValue(\"\");\n },\n [tableMeta, 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 let newValues: string[] = [];\n setSelectedValues((curr) => {\n newValues = curr.filter((v) => v !== valueToRemove);\n return newValues;\n });\n queueMicrotask(() => {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n inputRef.current?.focus();\n });\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const clearAll = React.useCallback(() => {\n if (readOnly) return;\n setSelectedValues([]);\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: [] });\n queueMicrotask(() => inputRef.current?.focus());\n }, [tableMeta, rowIndex, columnId, readOnly]);\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n setSearchValue(\"\");\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, 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 tableMeta?.onCellEditingStop?.();\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n setSearchValue(\"\");\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, cellValue, tableMeta],\n );\n\n const onInputKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === \"Backspace\" && searchValue === \"\") {\n event.preventDefault();\n let newValues: string[] | null = null;\n setSelectedValues((curr) => {\n if (curr.length === 0) return curr;\n newValues = curr.slice(0, -1);\n return newValues;\n });\n queueMicrotask(() => {\n if (newValues !== null) {\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n }\n inputRef.current?.focus();\n });\n }\n if (event.key === \"Escape\") {\n event.stopPropagation();\n }\n },\n [searchValue, tableMeta, rowIndex, columnId],\n );\n\n const displayLabels = selectedValues\n .map((val) => optionByValue.get(val)?.label ?? val)\n .filter(Boolean);\n\n const selectedValuesSet = React.useMemo(\n () => new Set(selectedValues),\n [selectedValues],\n );\n\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 ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n {isEditing ? (\n \n \n
\n \n \n \n
\n {selectedValues.map((value) => {\n const label = optionByValue.get(value)?.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 = selectedValuesSet.has(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\nexport function DateCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const containerRef = React.useRef(null);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n }\n\n // Parse date as local time to avoid timezone shifts\n const selectedDate = value ? (parseLocalDate(value) ?? undefined) : undefined;\n\n const onDateSelect = React.useCallback(\n (date: Date | undefined) => {\n if (!date || readOnly) return;\n\n // Format using local date components to avoid timezone issues\n const formattedDate = formatDateToString(date);\n setValue(formattedDate);\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: formattedDate });\n tableMeta?.onCellEditingStop?.();\n },\n [tableMeta, rowIndex, columnId, readOnly],\n );\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, 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 tableMeta?.onCellEditingStop?.();\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, initialValue, tableMeta],\n );\n\n return (\n \n ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n onKeyDown={onWrapperKeyDown}\n >\n \n \n \n {formatDateForDisplay(value)}\n \n \n {isEditing && (\n \n \n \n )}\n \n \n );\n}\n\nexport function FileCell({\n cell,\n tableMeta,\n rowIndex,\n columnId,\n rowHeight,\n isFocused,\n isEditing,\n isSelected,\n isSearchMatch,\n isActiveSearchMatch,\n readOnly,\n}: DataGridCellProps) {\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 [deletingFiles, setDeletingFiles] = 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\n const isUploading = uploadingFiles.size > 0;\n const isDeleting = deletingFiles.size > 0;\n const isPending = isUploading || isDeleting;\n const containerRef = React.useRef(null);\n const fileInputRef = React.useRef(null);\n const dropzoneRef = React.useRef(null);\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 ?? false;\n\n const acceptedTypes = React.useMemo(\n () => (accept ? accept.split(\",\").map((t) => t.trim()) : null),\n [accept],\n );\n\n const prevCellValueRef = React.useRef(cellValue);\n if (cellValue !== prevCellValueRef.current) {\n prevCellValueRef.current = cellValue;\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 const baseType = type.slice(0, -2);\n return file.type.startsWith(`${baseType}/`);\n }\n if (type.startsWith(\".\")) {\n return fileExtension.toLowerCase() === type.toLowerCase();\n }\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 || isPending) return;\n setError(null);\n\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 rejectedFiles: Array<{ name: string; reason: string }> = [];\n const filesToValidate: File[] = [];\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 filesToValidate.push(file);\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 (filesToValidate.length > 0) {\n if (!skipUpload) {\n const tempFiles = filesToValidate.map((f) => ({\n id: crypto.randomUUID(),\n name: f.name,\n size: f.size,\n type: f.type,\n url: undefined,\n }));\n const filesWithTemp = [...files, ...tempFiles];\n setFiles(filesWithTemp);\n\n const uploadingIds = new Set(tempFiles.map((f) => f.id));\n setUploadingFiles(uploadingIds);\n\n let uploadedFiles: FileCellData[] = [];\n\n if (tableMeta?.onFilesUpload) {\n try {\n uploadedFiles = await tableMeta.onFilesUpload({\n files: filesToValidate,\n rowIndex,\n columnId,\n });\n } catch (error) {\n toast.error(\n error instanceof Error\n ? error.message\n : `Failed to upload ${filesToValidate.length} file${filesToValidate.length !== 1 ? \"s\" : \"\"}`,\n );\n setFiles((prev) => prev.filter((f) => !uploadingIds.has(f.id)));\n setUploadingFiles(new Set());\n return;\n }\n } else {\n uploadedFiles = filesToValidate.map((f, i) => ({\n id: tempFiles[i]?.id ?? crypto.randomUUID(),\n name: f.name,\n size: f.size,\n type: f.type,\n url: URL.createObjectURL(f),\n }));\n }\n\n const finalFiles = filesWithTemp\n .map((f) => {\n if (uploadingIds.has(f.id)) {\n return uploadedFiles.find((uf) => uf.name === f.name) ?? f;\n }\n return f;\n })\n .filter((f) => f.url !== undefined);\n\n setFiles(finalFiles);\n setUploadingFiles(new Set());\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: finalFiles });\n } else {\n const newFilesData: FileCellData[] = filesToValidate.map((f) => ({\n id: crypto.randomUUID(),\n name: f.name,\n size: f.size,\n type: f.type,\n url: URL.createObjectURL(f),\n }));\n const updatedFiles = [...files, ...newFilesData];\n setFiles(updatedFiles);\n tableMeta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: updatedFiles,\n });\n }\n }\n },\n [\n files,\n maxFiles,\n validateFile,\n tableMeta,\n rowIndex,\n columnId,\n readOnly,\n isPending,\n ],\n );\n\n const removeFile = React.useCallback(\n async (fileId: string) => {\n if (readOnly || isPending) return;\n setError(null);\n\n const fileToRemove = files.find((f) => f.id === fileId);\n if (!fileToRemove) return;\n\n setDeletingFiles((prev) => new Set(prev).add(fileId));\n\n if (tableMeta?.onFilesDelete) {\n try {\n await tableMeta.onFilesDelete({\n fileIds: [fileId],\n rowIndex,\n columnId,\n });\n } catch (error) {\n toast.error(\n error instanceof Error\n ? error.message\n : `Failed to delete ${fileToRemove.name}`,\n );\n setDeletingFiles((prev) => {\n const next = new Set(prev);\n next.delete(fileId);\n return next;\n });\n return;\n }\n }\n\n if (fileToRemove.url?.startsWith(\"blob:\")) {\n URL.revokeObjectURL(fileToRemove.url);\n }\n\n const updatedFiles = files.filter((f) => f.id !== fileId);\n setFiles(updatedFiles);\n setDeletingFiles((prev) => {\n const next = new Set(prev);\n next.delete(fileId);\n return next;\n });\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles });\n },\n [files, tableMeta, rowIndex, columnId, readOnly, isPending],\n );\n\n const clearAll = React.useCallback(async () => {\n if (readOnly || isPending) return;\n setError(null);\n\n const fileIds = files.map((f) => f.id);\n setDeletingFiles(new Set(fileIds));\n\n if (tableMeta?.onFilesDelete && files.length > 0) {\n try {\n await tableMeta.onFilesDelete({\n fileIds,\n rowIndex,\n columnId,\n });\n } catch (error) {\n toast.error(\n error instanceof Error ? error.message : \"Failed to delete files\",\n );\n setDeletingFiles(new Set());\n return;\n }\n }\n\n for (const file of files) {\n if (file.url?.startsWith(\"blob:\")) {\n URL.revokeObjectURL(file.url);\n }\n }\n setFiles([]);\n setDeletingFiles(new Set());\n tableMeta?.onDataUpdate?.({ rowIndex, columnId, value: [] });\n }, [files, tableMeta, rowIndex, columnId, readOnly, isPending]);\n\n const onCellDragEnter = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\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);\n }\n },\n [addFiles],\n );\n\n const onDropzoneDragEnter = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDragging(true);\n }, []);\n\n const onDropzoneDragLeave = 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 onDropzoneDragOver = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n }, []);\n\n const onDropzoneDrop = 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, false);\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 onFileInputChange = React.useCallback(\n (event: React.ChangeEvent) => {\n const selectedFiles = Array.from(event.target.files ?? []);\n addFiles(selectedFiles, false);\n event.target.value = \"\";\n },\n [addFiles],\n );\n\n const onOpenChange = React.useCallback(\n (open: boolean) => {\n if (open && !readOnly) {\n setError(null);\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n setError(null);\n tableMeta?.onCellEditingStop?.();\n }\n },\n [tableMeta, 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 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 tableMeta?.onCellEditingStop?.();\n } else if (event.key === \" \") {\n event.preventDefault();\n onDropzoneClick();\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n } else if (isFocused && event.key === \"Enter\") {\n event.preventDefault();\n tableMeta?.onCellEditingStart?.(rowIndex, columnId);\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n tableMeta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [\n isEditing,\n isFocused,\n cellValue,\n tableMeta,\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 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 ref={containerRef}\n cell={cell}\n tableMeta={tableMeta}\n rowIndex={rowIndex}\n columnId={columnId}\n rowHeight={rowHeight}\n isEditing={isEditing}\n isFocused={isFocused}\n isSelected={isSelected}\n isSearchMatch={isSearchMatch}\n isActiveSearchMatch={isActiveSearchMatch}\n readOnly={readOnly}\n className={cn({\n \"ring-1 ring-primary/80 ring-inset\": isDraggingOver,\n })}\n onDragEnter={onCellDragEnter}\n onDragLeave={onCellDragLeave}\n onDragOver={onCellDragOver}\n onDrop={onCellDrop}\n onKeyDown={onWrapperKeyDown}\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 const isFileUploading = uploadingFiles.has(file.id);\n const isFileDeleting = deletingFiles.has(file.id);\n const isFilePending = isFileUploading || isFileDeleting;\n\n return (\n \n {FileIcon && (\n \n )}\n
\n

{file.name}

\n

\n {isFileUploading\n ? \"Uploading...\"\n : isFileDeleting\n ? \"Deleting...\"\n : formatFileSize(file.size)}\n

\n
\n removeFile(file.id)}\n disabled={isPending}\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 return (\n \n );\n }\n\n const FileIcon = getFileIcon(file.type);\n\n return (\n \n {FileIcon && }\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" }, @@ -98,7 +98,7 @@ }, { "path": "src/hooks/use-data-grid.ts", - "content": "import { useDirection } from \"@radix-ui/react-direction\";\nimport {\n type ColumnDef,\n type ColumnFiltersState,\n getCoreRowModel,\n getFilteredRowModel,\n getSortedRowModel,\n type Row,\n type RowSelectionState,\n type SortingState,\n type TableMeta,\n type TableOptions,\n type TableState,\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\";\n\nimport { useAsRef } from \"@/hooks/use-as-ref\";\nimport { useIsomorphicLayoutEffect } from \"@/hooks/use-isomorphic-layout-effect\";\nimport { useLazyRef } from \"@/hooks/use-lazy-ref\";\nimport {\n getCellKey,\n getEmptyCellValue,\n getIsFileCellData,\n getIsInPopover,\n getRowHeightValue,\n getScrollDirection,\n matchSelectOption,\n parseCellKey,\n parseTsv,\n scrollCellIntoView,\n} from \"@/lib/data-grid\";\nimport type {\n CellPosition,\n CellUpdate,\n ContextMenuState,\n Direction,\n FileCellData,\n NavigationDirection,\n PasteDialogState,\n RowHeightValue,\n SearchState,\n SelectionState,\n} from \"@/types/data-grid\";\n\nconst DEFAULT_ROW_HEIGHT = \"short\";\nconst OVERSCAN = 6;\nconst VIEWPORT_OFFSET = 1;\nconst HORIZONTAL_PAGE_SIZE = 5;\nconst SCROLL_SYNC_RETRY_COUNT = 16;\nconst MIN_COLUMN_SIZE = 60;\nconst MAX_COLUMN_SIZE = 800;\nconst SEARCH_SHORTCUT_KEY = \"f\";\nconst NON_NAVIGABLE_COLUMN_IDS = new Set([\"select\", \"actions\"]);\n\nconst DOMAIN_REGEX = /^[\\w.-]+\\.[a-z]{2,}(\\/\\S*)?$/i;\nconst ISO_DATE_REGEX = /^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}:\\d{2}.*)?$/;\nconst TRUTHY_BOOLEANS = new Set([\"true\", \"1\", \"yes\", \"checked\"]);\nconst VALID_BOOLEANS = new Set([\n \"true\",\n \"false\",\n \"1\",\n \"0\",\n \"yes\",\n \"no\",\n \"checked\",\n \"unchecked\",\n]);\n\ninterface DataGridState {\n sorting: SortingState;\n columnFilters: ColumnFiltersState;\n rowHeight: RowHeightValue;\n rowSelection: RowSelectionState;\n selectionState: SelectionState;\n focusedCell: CellPosition | null;\n editingCell: CellPosition | null;\n cutCells: Set;\n contextMenu: ContextMenuState;\n searchQuery: string;\n searchMatches: CellPosition[];\n matchIndex: number;\n searchOpen: boolean;\n lastClickedRowId: string | null;\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?: (\n event?: React.MouseEvent,\n ) => Partial | Promise | null> | null;\n onRowsAdd?: (count: number) => void | Promise;\n onRowsDelete?: (rows: TData[], rowIndices: number[]) => void | Promise;\n onPaste?: (updates: Array) => void | Promise;\n onFilesUpload?: (params: {\n files: File[];\n rowIndex: number;\n columnId: string;\n }) => Promise;\n onFilesDelete?: (params: {\n fileIds: string[];\n rowIndex: number;\n columnId: string;\n }) => void | Promise;\n rowHeight?: RowHeightValue;\n onRowHeightChange?: (rowHeight: RowHeightValue) => void;\n overscan?: number;\n dir?: Direction;\n autoFocus?: boolean | Partial;\n enableSingleCellSelection?: boolean;\n enableColumnSelection?: boolean;\n enableSearch?: boolean;\n enablePaste?: boolean;\n readOnly?: boolean;\n}\n\nfunction useDataGrid({\n data,\n columns,\n rowHeight: rowHeightProp = DEFAULT_ROW_HEIGHT,\n overscan = OVERSCAN,\n dir: dirProp,\n initialState,\n ...props\n}: UseDataGridProps) {\n const dir = useDirection(dirProp);\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 const focusGuardRef = React.useRef(false);\n\n const propsRef = useAsRef({\n ...props,\n data,\n columns,\n initialState,\n });\n\n const listenersRef = useLazyRef(() => new Set<() => void>());\n\n const stateRef = useLazyRef(() => {\n return {\n sorting: initialState?.sorting ?? [],\n columnFilters: initialState?.columnFilters ?? [],\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 cutCells: new Set(),\n contextMenu: {\n open: false,\n x: 0,\n y: 0,\n },\n searchQuery: \"\",\n searchMatches: [],\n matchIndex: -1,\n searchOpen: false,\n lastClickedRowId: null,\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 columnFilters = useStore(store, (state) => state.columnFilters);\n const rowSelection = useStore(store, (state) => state.rowSelection);\n const rowHeight = useStore(store, (state) => state.rowHeight);\n const contextMenu = useStore(store, (state) => state.contextMenu);\n const pasteDialog = useStore(store, (state) => state.pasteDialog);\n\n const rowHeightValue = getRowHeightValue(rowHeight);\n\n const prevCellSelectionMapRef = useLazyRef(\n () => new Map>(),\n );\n\n // Memoize per-row selection sets to prevent unnecessary row re-renders\n // Each row gets a stable Set reference that only changes when its cells' selection changes\n const cellSelectionMap = React.useMemo(() => {\n const selectedCells = selectionState.selectedCells;\n\n if (selectedCells.size === 0) {\n prevCellSelectionMapRef.current.clear();\n return null;\n }\n\n const newRowCells = new Map>();\n for (const cellKey of selectedCells) {\n const { rowIndex } = parseCellKey(cellKey);\n let rowSet = newRowCells.get(rowIndex);\n if (!rowSet) {\n rowSet = new Set();\n newRowCells.set(rowIndex, rowSet);\n }\n rowSet.add(cellKey);\n }\n\n const stableMap = new Map>();\n for (const [rowIndex, newSet] of newRowCells) {\n const prevSet = prevCellSelectionMapRef.current.get(rowIndex);\n if (\n prevSet &&\n prevSet.size === newSet.size &&\n [...newSet].every((key) => prevSet.has(key))\n ) {\n stableMap.set(rowIndex, prevSet);\n } else {\n stableMap.set(rowIndex, newSet);\n }\n }\n\n prevCellSelectionMapRef.current = stableMap;\n return stableMap;\n }, [selectionState.selectedCells, prevCellSelectionMapRef]);\n\n const visualRowIndexCacheRef = React.useRef<{\n rows: Row[] | null;\n map: Map;\n } | null>(null);\n\n // Pre-compute visual row index map for O(1) lookups (used by select column)\n // Cache is invalidated when row model identity changes (sorting/filtering)\n const getVisualRowIndex = React.useCallback(\n (rowId: string): number | undefined => {\n const rows = tableRef.current?.getRowModel().rows;\n if (!rows) return undefined;\n\n if (visualRowIndexCacheRef.current?.rows !== rows) {\n const map = new Map();\n for (const [i, row] of rows.entries()) {\n map.set(row.id, i + 1);\n }\n visualRowIndexCacheRef.current = { rows, map };\n }\n\n return visualRowIndexCacheRef.current.map.get(rowId);\n },\n [],\n );\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.has(c));\n }, [columnIds]);\n\n const onDataUpdate = React.useCallback(\n (updates: CellUpdate | Array) => {\n if (propsRef.current.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 currentData = propsRef.current.data;\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 = currentData.indexOf(originalData);\n\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 const newData: TData[] = new Array(currentData.length);\n\n for (let i = 0; i < currentData.length; i++) {\n const updates = rowUpdatesMap.get(i);\n const existingRow = currentData[i];\n\n if (!existingRow) {\n newData[i] = {} as TData;\n continue;\n }\n\n if (updates) {\n const updatedRow = { ...existingRow } as Record;\n for (const { columnId, value } of updates) {\n updatedRow[columnId] = value;\n }\n newData[i] = updatedRow as TData;\n } else {\n newData[i] = existingRow;\n }\n }\n\n propsRef.current.onDataChange?.(newData);\n },\n [propsRef],\n );\n\n const getIsCellSelected = React.useCallback(\n (rowIndex: number, columnId: string) => {\n const currentSelectionState = store.getState().selectionState;\n return currentSelectionState.selectedCells.has(\n getCellKey(rowIndex, columnId),\n );\n },\n [store],\n );\n\n const onSelectionClear = 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 ?? propsRef.current.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, propsRef, 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 ?? propsRef.current.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 [propsRef, 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 serializeCellsToTsv = React.useCallback(() => {\n const currentState = store.getState();\n\n let selectedCellsArray: string[];\n if (!currentState.selectionState.selectedCells.size) {\n if (!currentState.focusedCell) return null;\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 null;\n\n const selectedColumnIds: string[] = [];\n const seenColumnIds = new Set();\n const cellData = new Map();\n const rowIndices = new Set();\n const rowCellMaps = new Map<\n number,\n Map[\"getVisibleCells\"]>[number]>\n >();\n const navigableCells: string[] = [];\n\n for (const cellKey of selectedCellsArray) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n\n if (columnId && NON_NAVIGABLE_COLUMN_IDS.has(columnId)) {\n continue;\n }\n\n navigableCells.push(cellKey);\n\n if (columnId && !seenColumnIds.has(columnId)) {\n seenColumnIds.add(columnId);\n selectedColumnIds.push(columnId);\n }\n\n rowIndices.add(rowIndex);\n\n const row = rows[rowIndex];\n if (row) {\n let cellMap = rowCellMaps.get(rowIndex);\n if (!cellMap) {\n cellMap = new Map(row.getVisibleCells().map((c) => [c.column.id, c]));\n rowCellMaps.set(rowIndex, cellMap);\n }\n const cell = cellMap.get(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 colIndices = new Set();\n for (const cellKey of navigableCells) {\n const { columnId } = parseCellKey(cellKey);\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 return { tsvData, selectedCellsArray: navigableCells };\n }, [store]);\n\n const onCellsCopy = React.useCallback(async () => {\n const result = serializeCellsToTsv();\n if (!result) return;\n\n const { tsvData, selectedCellsArray } = result;\n\n try {\n await navigator.clipboard.writeText(tsvData);\n\n const currentState = store.getState();\n if (currentState.cutCells.size > 0) {\n store.setState(\"cutCells\", new Set());\n }\n\n toast.success(\n `${selectedCellsArray.length} cell${\n selectedCellsArray.length !== 1 ? \"s\" : \"\"\n } copied`,\n );\n } catch (error) {\n toast.error(\n error instanceof Error ? error.message : \"Failed to copy to clipboard\",\n );\n }\n }, [store, serializeCellsToTsv]);\n\n const onCellsCut = React.useCallback(async () => {\n if (propsRef.current.readOnly) return;\n\n const result = serializeCellsToTsv();\n if (!result) return;\n\n const { tsvData, selectedCellsArray } = result;\n\n try {\n await navigator.clipboard.writeText(tsvData);\n\n store.setState(\"cutCells\", new Set(selectedCellsArray));\n\n toast.success(\n `${selectedCellsArray.length} cell${\n selectedCellsArray.length !== 1 ? \"s\" : \"\"\n } cut`,\n );\n } catch (error) {\n toast.error(\n error instanceof Error ? error.message : \"Failed to cut to clipboard\",\n );\n }\n }, [store, propsRef, serializeCellsToTsv]);\n\n const restoreFocus = React.useCallback((element: HTMLDivElement | null) => {\n if (element && document.activeElement !== element) {\n requestAnimationFrame(() => {\n element.focus();\n });\n }\n }, []);\n\n const onCellsPaste = React.useCallback(\n async (expandRows = false) => {\n if (propsRef.current.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 const pastedData = parseTsv(clipboardText, navigableColumnIds.length);\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 ?? propsRef.current.data.length;\n const rowsNeeded = startRowIndex + pastedData.length - rowCount;\n\n if (\n rowsNeeded > 0 &&\n !expandRows &&\n propsRef.current.onRowAdd &&\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 (propsRef.current.onRowsAdd) {\n await propsRef.current.onRowsAdd(rowsNeeded);\n } else if (propsRef.current.onRowAdd) {\n for (let i = 0; i < rowsNeeded; i++) {\n await propsRef.current.onRowAdd();\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 let cellsSkipped = 0;\n\n const columnMap = new Map(tableColumns.map((c) => [c.id, c]));\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;\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;\n\n const targetColumnId = navigableColumnIds[targetColIndex];\n if (!targetColumnId) continue;\n\n const pastedValue = pasteRow[pasteColIdx] ?? \"\";\n const column = columnMap.get(targetColumnId);\n const cellOpts = column?.columnDef?.meta?.cell;\n const cellVariant = cellOpts?.variant;\n\n let processedValue: unknown = pastedValue;\n let shouldSkip = false;\n\n switch (cellVariant) {\n case \"number\": {\n if (!pastedValue) {\n processedValue = null;\n } else {\n const num = Number.parseFloat(pastedValue);\n if (Number.isNaN(num)) shouldSkip = true;\n else processedValue = num;\n }\n break;\n }\n\n case \"checkbox\": {\n if (!pastedValue) {\n processedValue = false;\n } else {\n const lower = pastedValue.toLowerCase();\n if (VALID_BOOLEANS.has(lower)) {\n processedValue = TRUTHY_BOOLEANS.has(lower);\n } else {\n shouldSkip = true;\n }\n }\n break;\n }\n\n case \"date\": {\n if (!pastedValue) {\n processedValue = null;\n } else {\n const date = new Date(pastedValue);\n if (Number.isNaN(date.getTime())) shouldSkip = true;\n else processedValue = date;\n }\n break;\n }\n\n case \"select\": {\n const options = cellOpts?.options ?? [];\n if (!pastedValue) {\n processedValue = \"\";\n } else {\n const matched = matchSelectOption(pastedValue, options);\n if (matched) processedValue = matched;\n else shouldSkip = true;\n }\n break;\n }\n\n case \"multi-select\": {\n const options = cellOpts?.options ?? [];\n let values: string[] = [];\n try {\n const parsed = JSON.parse(pastedValue);\n if (Array.isArray(parsed)) {\n values = parsed.filter(\n (v): v is string => typeof v === \"string\",\n );\n }\n } catch {\n values = pastedValue\n ? pastedValue.split(\",\").map((v) => v.trim())\n : [];\n }\n\n const validated = values\n .map((v) => matchSelectOption(v, options))\n .filter(Boolean) as string[];\n\n if (values.length > 0 && validated.length === 0) {\n shouldSkip = true;\n } else {\n processedValue = validated;\n }\n break;\n }\n\n case \"file\": {\n if (!pastedValue) {\n processedValue = [];\n } else {\n try {\n const parsed = JSON.parse(pastedValue);\n if (!Array.isArray(parsed)) {\n shouldSkip = true;\n } else {\n const validFiles = parsed.filter(getIsFileCellData);\n if (parsed.length > 0 && validFiles.length === 0) {\n shouldSkip = true;\n } else {\n processedValue = validFiles;\n }\n }\n } catch {\n shouldSkip = true;\n }\n }\n break;\n }\n\n case \"url\": {\n if (!pastedValue) {\n processedValue = \"\";\n } else {\n const firstChar = pastedValue[0];\n if (firstChar === \"[\" || firstChar === \"{\") {\n shouldSkip = true;\n } else {\n try {\n new URL(pastedValue);\n processedValue = pastedValue;\n } catch {\n if (DOMAIN_REGEX.test(pastedValue)) {\n processedValue = pastedValue;\n } else {\n shouldSkip = true;\n }\n }\n }\n }\n break;\n }\n\n default: {\n if (!pastedValue) {\n processedValue = \"\";\n break;\n }\n\n if (ISO_DATE_REGEX.test(pastedValue)) {\n const date = new Date(pastedValue);\n if (!Number.isNaN(date.getTime())) {\n processedValue = date.toLocaleDateString();\n break;\n }\n }\n\n const firstChar = pastedValue[0];\n if (\n firstChar === \"[\" ||\n firstChar === \"{\" ||\n firstChar === \"t\" ||\n firstChar === \"f\"\n ) {\n try {\n const parsed = JSON.parse(pastedValue);\n\n if (Array.isArray(parsed)) {\n if (\n parsed.length > 0 &&\n parsed.every(getIsFileCellData)\n ) {\n processedValue = parsed.map((f) => f.name).join(\", \");\n } else if (parsed.every((v) => typeof v === \"string\")) {\n processedValue = (parsed as string[]).join(\", \");\n }\n } else if (typeof parsed === \"boolean\") {\n processedValue = parsed ? \"Checked\" : \"Unchecked\";\n }\n } catch {\n const lower = pastedValue.toLowerCase();\n if (lower === \"true\" || lower === \"false\") {\n processedValue =\n lower === \"true\" ? \"Checked\" : \"Unchecked\";\n }\n }\n }\n }\n }\n\n if (shouldSkip) {\n cellsSkipped++;\n endRowIndex = Math.max(endRowIndex, targetRowIndex);\n endColIndex = Math.max(endColIndex, targetColIndex);\n continue;\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 if (propsRef.current.onPaste) {\n await propsRef.current.onPaste(updates);\n }\n\n const allUpdates = [...updates];\n\n if (currentState.cutCells.size > 0) {\n const columnById = new Map(tableColumns.map((c) => [c.id, c]));\n\n for (const cellKey of currentState.cutCells) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n const column = columnById.get(columnId);\n const cellVariant = column?.columnDef?.meta?.cell?.variant;\n const emptyValue = getEmptyCellValue(cellVariant);\n allUpdates.push({ rowIndex, columnId, value: emptyValue });\n }\n\n store.setState(\"cutCells\", new Set());\n }\n\n onDataUpdate(allUpdates);\n\n if (cellsSkipped > 0) {\n toast.success(\n `${cellsUpdated} cell${\n cellsUpdated !== 1 ? \"s\" : \"\"\n } pasted, ${cellsSkipped} skipped`,\n );\n } else {\n toast.success(\n `${cellsUpdated} cell${cellsUpdated !== 1 ? \"s\" : \"\"} pasted`,\n );\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 restoreFocus(dataGridRef.current);\n } else if (cellsSkipped > 0) {\n toast.error(\n `${cellsSkipped} cell${\n cellsSkipped !== 1 ? \"s\" : \"\"\n } skipped pasting for invalid data`,\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 propsRef,\n onDataUpdate,\n selectRange,\n restoreFocus,\n ],\n );\n\n // Release focus guard after delay to allow async data re-renders to settle.\n // 300ms accounts for db sync and virtualized cell mounting.\n const releaseFocusGuard = React.useCallback((immediate = false) => {\n if (immediate) {\n focusGuardRef.current = false;\n return;\n }\n\n setTimeout(() => {\n focusGuardRef.current = false;\n }, 300);\n }, []);\n\n const focusCellWrapper = React.useCallback(\n (rowIndex: number, columnId: string) => {\n focusGuardRef.current = true;\n\n requestAnimationFrame(() => {\n const cellKey = getCellKey(rowIndex, columnId);\n const cellWrapperElement = cellMapRef.current.get(cellKey);\n\n if (!cellWrapperElement) {\n const container = dataGridRef.current;\n if (container) {\n container.focus();\n }\n releaseFocusGuard();\n return;\n }\n\n cellWrapperElement.focus();\n releaseFocusGuard();\n });\n },\n [releaseFocusGuard],\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 (\n propsRef.current.readOnly ||\n !propsRef.current.onRowsDelete ||\n rowIndices.length === 0\n )\n 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 propsRef.current.onRowsDelete(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 ?? propsRef.current.data.length;\n\n if (newRowCount > 0 && currentFocusedColumn) {\n const targetRowIndex = Math.min(minDeletedRowIndex, newRowCount - 1);\n focusCell(targetRowIndex, currentFocusedColumn);\n }\n });\n },\n [propsRef, 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 ?? propsRef.current.data.length;\n\n let newRowIndex = rowIndex;\n let newColumnId = columnId;\n\n const isRtl = dir === \"rtl\";\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 (isRtl) {\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n } else {\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n }\n break;\n case \"right\":\n if (isRtl) {\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n } else {\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\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 \"ctrl+up\":\n newRowIndex = 0;\n break;\n case \"ctrl+down\":\n newRowIndex = Math.max(0, rowCount - 1);\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 case \"pageleft\":\n if (currentColIndex > 0) {\n const targetIndex = Math.max(\n 0,\n currentColIndex - HORIZONTAL_PAGE_SIZE,\n );\n const targetColumnId = navigableColumnIds[targetIndex];\n if (targetColumnId) newColumnId = targetColumnId;\n }\n break;\n case \"pageright\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const targetIndex = Math.min(\n navigableColumnIds.length - 1,\n currentColIndex + HORIZONTAL_PAGE_SIZE,\n );\n const targetColumnId = navigableColumnIds[targetIndex];\n if (targetColumnId) newColumnId = targetColumnId;\n }\n break;\n }\n\n if (newRowIndex !== rowIndex || newColumnId !== columnId) {\n focusCell(newRowIndex, newColumnId);\n\n // Calculate and apply scrolls synchronously to avoid flashing\n const container = dataGridRef.current;\n if (!container) return;\n\n const targetRow = rowMapRef.current.get(newRowIndex);\n const cellKey = getCellKey(newRowIndex, newColumnId);\n const targetCell = cellMapRef.current.get(cellKey);\n\n // If target row is not rendered, scroll it into view first\n if (!targetRow) {\n if (rowVirtualizer) {\n const align =\n direction === \"up\" ||\n direction === \"pageup\" ||\n direction === \"ctrl+up\" ||\n direction === \"ctrl+home\"\n ? \"start\"\n : direction === \"down\" ||\n direction === \"pagedown\" ||\n direction === \"ctrl+down\" ||\n direction === \"ctrl+end\"\n ? \"end\"\n : \"center\";\n\n rowVirtualizer.scrollToIndex(newRowIndex, { align });\n\n // Wait for row to render before horizontal scroll\n if (newColumnId !== columnId) {\n requestAnimationFrame(() => {\n const cellKeyRetry = getCellKey(newRowIndex, newColumnId);\n const targetCellRetry = cellMapRef.current.get(cellKeyRetry);\n\n if (targetCellRetry) {\n const scrollDirection = getScrollDirection(direction);\n\n scrollCellIntoView({\n container,\n targetCell: targetCellRetry,\n tableRef,\n viewportOffset: VIEWPORT_OFFSET,\n direction: scrollDirection,\n isRtl: dir === \"rtl\",\n });\n }\n });\n }\n } else {\n // Fallback: use direct scroll calculation when virtualizer is not available\n const rowHeightValue = getRowHeightValue(rowHeight);\n const estimatedScrollTop = newRowIndex * rowHeightValue;\n container.scrollTop = estimatedScrollTop;\n }\n\n return;\n }\n\n // Vertical scrolling for rendered rows that changed\n if (newRowIndex !== rowIndex && targetRow) {\n requestAnimationFrame(() => {\n const containerRect = container.getBoundingClientRect();\n const headerHeight =\n headerRef.current?.getBoundingClientRect().height ?? 0;\n const footerHeight =\n footerRef.current?.getBoundingClientRect().height ?? 0;\n const viewportTop =\n containerRect.top + headerHeight + VIEWPORT_OFFSET;\n const viewportBottom =\n containerRect.bottom - footerHeight - VIEWPORT_OFFSET;\n\n const rowRect = targetRow.getBoundingClientRect();\n const isFullyVisible =\n rowRect.top >= viewportTop && rowRect.bottom <= viewportBottom;\n\n if (!isFullyVisible) {\n // Only apply vertical scroll for vertical navigation\n const isVerticalNavigation =\n direction === \"up\" ||\n direction === \"down\" ||\n direction === \"pageup\" ||\n direction === \"pagedown\" ||\n direction === \"ctrl+up\" ||\n direction === \"ctrl+down\" ||\n direction === \"ctrl+home\" ||\n direction === \"ctrl+end\";\n\n if (isVerticalNavigation) {\n if (\n direction === \"down\" ||\n direction === \"pagedown\" ||\n direction === \"ctrl+down\" ||\n direction === \"ctrl+end\"\n ) {\n container.scrollTop += rowRect.bottom - viewportBottom;\n } else {\n container.scrollTop -= viewportTop - rowRect.top;\n }\n }\n }\n });\n }\n\n // Horizontal scrolling for rendered cells\n if (newColumnId !== columnId && targetCell) {\n requestAnimationFrame(() => {\n const scrollDirection = getScrollDirection(direction);\n\n scrollCellIntoView({\n container,\n targetCell,\n tableRef,\n viewportOffset: VIEWPORT_OFFSET,\n direction: scrollDirection,\n isRtl: dir === \"rtl\",\n });\n });\n }\n }\n },\n [dir, store, navigableColumnIds, focusCell, propsRef, rowHeight],\n );\n\n const onCellEditingStart = React.useCallback(\n (rowIndex: number, columnId: string) => {\n if (propsRef.current.readOnly) return;\n\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", { rowIndex, columnId });\n });\n },\n [store, propsRef],\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 ?? propsRef.current.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 const { rowIndex, columnId } = currentEditing;\n focusCell(rowIndex, columnId);\n requestAnimationFrame(() => {\n navigateCell(opts.direction ?? \"right\");\n });\n } else if (currentEditing) {\n const { rowIndex, columnId } = currentEditing;\n focusCellWrapper(rowIndex, columnId);\n }\n },\n [store, propsRef, 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 const cellById = new Map(\n row.getVisibleCells().map((c) => [c.column.id, c]),\n );\n\n for (const columnId of columnIds) {\n const cell = cellById.get(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 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 searchMatchSet = React.useMemo(() => {\n return new Set(\n searchMatches.map((m) => getCellKey(m.rowIndex, m.columnId)),\n );\n }, [searchMatches]);\n\n const getIsSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return searchMatchSet.has(getCellKey(rowIndex, columnId));\n },\n [searchMatchSet],\n );\n\n const getIsActiveSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n const currentState = store.getState();\n if (currentState.matchIndex < 0) return false;\n const currentMatch = currentState.searchMatches[currentState.matchIndex];\n return (\n currentMatch?.rowIndex === rowIndex &&\n currentMatch?.columnId === columnId\n );\n },\n [store],\n );\n\n // Compute search match data for targeted row re-renders\n // Maps rowIndex -> Set of columnIds that have matches in that row\n const searchMatchesByRow = React.useMemo(() => {\n if (searchMatches.length === 0) return null;\n const rowMap = new Map>();\n for (const match of searchMatches) {\n let columnSet = rowMap.get(match.rowIndex);\n if (!columnSet) {\n columnSet = new Set();\n rowMap.set(match.rowIndex, columnSet);\n }\n columnSet.add(match.columnId);\n }\n return rowMap;\n }, [searchMatches]);\n\n const activeSearchMatch = React.useMemo(() => {\n if (matchIndex < 0 || searchMatches.length === 0) return null;\n return searchMatches[matchIndex] ?? null;\n }, [searchMatches, matchIndex]);\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 if (event?.button === 2) {\n return;\n }\n\n const currentState = store.getState();\n const currentFocused = currentState.focusedCell;\n\n function scrollToCell() {\n requestAnimationFrame(() => {\n const container = dataGridRef.current;\n const cellKey = getCellKey(rowIndex, columnId);\n const targetCell = cellMapRef.current.get(cellKey);\n\n if (container && targetCell) {\n scrollCellIntoView({\n container,\n targetCell,\n tableRef,\n viewportOffset: VIEWPORT_OFFSET,\n isRtl: dir === \"rtl\",\n });\n }\n });\n }\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 scrollToCell();\n return;\n }\n\n if (event.shiftKey && currentState.focusedCell) {\n event.preventDefault();\n selectRange(currentState.focusedCell, { rowIndex, columnId });\n scrollToCell();\n return;\n }\n }\n\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 const cellKey = getCellKey(rowIndex, columnId);\n const isClickingSelectedCell =\n currentState.selectionState.selectedCells.has(cellKey);\n\n if (!isClickingSelectedCell) {\n onSelectionClear();\n } else {\n focusCell(rowIndex, columnId);\n scrollToCell();\n return;\n }\n } else if (hasSelectedRows && columnId !== \"select\") {\n onSelectionClear();\n }\n\n if (\n currentFocused?.rowIndex === rowIndex &&\n currentFocused?.columnId === columnId\n ) {\n onCellEditingStart(rowIndex, columnId);\n } else {\n focusCell(rowIndex, columnId);\n scrollToCell();\n }\n },\n [store, focusCell, onCellEditingStart, selectRange, onSelectionClear, dir],\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 if (event.button === 2) {\n return;\n }\n\n event.preventDefault();\n\n if (!event.ctrlKey && !event.metaKey && !event.shiftKey) {\n const cellKey = getCellKey(rowIndex, columnId);\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: propsRef.current.enableSingleCellSelection\n ? new Set([cellKey])\n : 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, propsRef],\n );\n\n const onCellMouseEnter = React.useCallback(\n (rowIndex: number, columnId: string) => {\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 (!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 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 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 propsRef.current.onSortingChange?.(newSorting);\n },\n [store, propsRef],\n );\n\n const onColumnFiltersChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newColumnFilters =\n typeof updater === \"function\"\n ? updater(currentState.columnFilters)\n : updater;\n store.setState(\"columnFilters\", newColumnFilters);\n\n propsRef.current.onColumnFiltersChange?.(newColumnFilters);\n },\n [store, propsRef],\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 propsRef.current.onRowSelectionChange?.(updater);\n },\n [store, columnIds, propsRef],\n );\n\n const onRowSelect = React.useCallback(\n (rowId: string, selected: boolean, shiftKey: boolean) => {\n const currentState = store.getState();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n const currentRowIndex = rows.findIndex((r) => r.id === rowId);\n const currentRow = currentRowIndex >= 0 ? rows[currentRowIndex] : null;\n if (!currentRow) return;\n\n if (shiftKey && currentState.lastClickedRowId !== null) {\n const lastClickedRowIndex = rows.findIndex(\n (r) => r.id === currentState.lastClickedRowId,\n );\n if (lastClickedRowIndex >= 0) {\n const startIndex = Math.min(lastClickedRowIndex, currentRowIndex);\n const endIndex = Math.max(lastClickedRowIndex, currentRowIndex);\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 } else {\n onRowSelectionChange({\n ...currentState.rowSelection,\n [currentRow.id]: selected,\n });\n }\n\n store.setState(\"lastClickedRowId\", rowId);\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 propsRef.current.onRowHeightChange?.(newRowHeight);\n },\n [store, propsRef],\n );\n\n const onColumnClick = React.useCallback(\n (columnId: string) => {\n if (!propsRef.current.enableColumnSelection) {\n onSelectionClear();\n return;\n }\n\n selectColumn(columnId);\n },\n [propsRef, selectColumn, onSelectionClear],\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 defaultColumn: Partial> = React.useMemo(\n () => ({\n // Note: cell is rendered directly in DataGridRow to bypass flexRender's\n // unstable cell.getContext() (see TanStack Table issue #4794)\n minSize: MIN_COLUMN_SIZE,\n maxSize: MAX_COLUMN_SIZE,\n }),\n [],\n );\n\n const tableMeta = React.useMemo>(() => {\n return {\n ...propsRef.current.meta,\n dataGridRef,\n cellMapRef,\n get focusedCell() {\n return store.getState().focusedCell;\n },\n get editingCell() {\n return store.getState().editingCell;\n },\n get selectionState() {\n return store.getState().selectionState;\n },\n get searchOpen() {\n return store.getState().searchOpen;\n },\n get contextMenu() {\n return store.getState().contextMenu;\n },\n get pasteDialog() {\n return store.getState().pasteDialog;\n },\n get rowHeight() {\n return store.getState().rowHeight;\n },\n get readOnly() {\n return propsRef.current.readOnly;\n },\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n getVisualRowIndex,\n onRowHeightChange,\n onRowSelect,\n onDataUpdate,\n onRowsDelete: propsRef.current.onRowsDelete ? onRowsDelete : undefined,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n onCellsCopy,\n onCellsCut,\n onCellsPaste,\n onSelectionClear,\n onFilesUpload: propsRef.current.onFilesUpload\n ? propsRef.current.onFilesUpload\n : undefined,\n onFilesDelete: propsRef.current.onFilesDelete\n ? propsRef.current.onFilesDelete\n : undefined,\n onContextMenuOpenChange,\n onPasteDialogOpenChange,\n };\n }, [\n propsRef,\n store,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n getVisualRowIndex,\n onRowHeightChange,\n onRowSelect,\n onDataUpdate,\n onRowsDelete,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n onCellsCopy,\n onCellsCut,\n onCellsPaste,\n onSelectionClear,\n onContextMenuOpenChange,\n onPasteDialogOpenChange,\n ]);\n\n const getMemoizedCoreRowModel = React.useMemo(() => getCoreRowModel(), []);\n const getMemoizedFilteredRowModel = React.useMemo(\n () => getFilteredRowModel(),\n [],\n );\n const getMemoizedSortedRowModel = React.useMemo(\n () => getSortedRowModel(),\n [],\n );\n\n // Memoize state object to reduce shallow equality checks\n const tableState = React.useMemo>(\n () => ({\n ...propsRef.current.state,\n sorting,\n columnFilters,\n rowSelection,\n }),\n [propsRef, sorting, columnFilters, rowSelection],\n );\n\n const tableOptions = React.useMemo>(() => {\n return {\n ...propsRef.current,\n data,\n columns,\n defaultColumn,\n initialState: propsRef.current.initialState,\n state: tableState,\n onRowSelectionChange,\n onSortingChange,\n onColumnFiltersChange,\n columnResizeMode: \"onChange\",\n columnResizeDirection: dir,\n getCoreRowModel: getMemoizedCoreRowModel,\n getFilteredRowModel: getMemoizedFilteredRowModel,\n getSortedRowModel: getMemoizedSortedRowModel,\n meta: tableMeta,\n };\n }, [\n propsRef,\n data,\n columns,\n defaultColumn,\n tableState,\n dir,\n onRowSelectionChange,\n onSortingChange,\n onColumnFiltersChange,\n getMemoizedCoreRowModel,\n getMemoizedFilteredRowModel,\n getMemoizedSortedRowModel,\n tableMeta,\n ]);\n\n const table = useReactTable(tableOptions);\n\n if (!tableRef.current) {\n tableRef.current = table;\n }\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: columnSizingInfo and columnSizing are used for calculating 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 isFirefox = React.useSyncExternalStore(\n React.useCallback(() => () => {}, []),\n React.useCallback(() => {\n if (typeof window === \"undefined\" || typeof navigator === \"undefined\") {\n return false;\n }\n return navigator.userAgent.indexOf(\"Firefox\") !== -1;\n }, []),\n React.useCallback(() => false, []),\n );\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: columnPinning is used for calculating the adjustLayout\n const adjustLayout = React.useMemo(() => {\n const columnPinning = table.getState().columnPinning;\n return (\n isFirefox &&\n ((columnPinning.left?.length ?? 0) > 0 ||\n (columnPinning.right?.length ?? 0) > 0)\n );\n }, [isFirefox, table.getState().columnPinning]);\n\n const rowVirtualizer = useVirtualizer({\n count: table.getRowModel().rows.length,\n getScrollElement: () => dataGridRef.current,\n estimateSize: () => rowHeightValue,\n overscan,\n measureElement: !isFirefox\n ? (element) => element?.getBoundingClientRect().height\n : undefined,\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 focusGuardRef.current = true;\n\n const navigableIds = propsRef.current.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 .filter((c) => !NON_NAVIGABLE_COLUMN_IDS.has(c));\n\n const targetColumnId = columnId ?? navigableIds[0];\n\n if (!targetColumnId) {\n releaseFocusGuard(true);\n return;\n }\n\n async function onScrollAndFocus(retryCount: number) {\n if (!targetColumnId) return;\n const currentRowCount = propsRef.current.data.length;\n\n // If the requested row doesn't exist yet, wait for data to update\n if (rowIndex >= currentRowCount && retryCount > 0) {\n await new Promise((resolve) => setTimeout(resolve, 50));\n await onScrollAndFocus(retryCount - 1);\n return;\n }\n\n const safeRowIndex = Math.min(\n rowIndex,\n Math.max(0, currentRowCount - 1),\n );\n\n const isBottomHalf = safeRowIndex > currentRowCount / 2;\n rowVirtualizer.scrollToIndex(safeRowIndex, {\n align: isBottomHalf ? \"end\" : \"start\",\n });\n\n await new Promise((resolve) => requestAnimationFrame(resolve));\n\n // Adjust scroll position to account for sticky header/footer\n const container = dataGridRef.current;\n const targetRow = rowMapRef.current.get(safeRowIndex);\n\n if (container && targetRow) {\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 const rowRect = targetRow.getBoundingClientRect();\n const isFullyVisible =\n rowRect.top >= viewportTop && rowRect.bottom <= viewportBottom;\n\n if (!isFullyVisible) {\n if (rowRect.top < viewportTop) {\n // Row is partially hidden by header - scroll up\n container.scrollTop -= viewportTop - rowRect.top;\n } else if (rowRect.bottom > viewportBottom) {\n // Row is partially hidden by footer - scroll down\n container.scrollTop += rowRect.bottom - viewportBottom;\n }\n }\n }\n\n store.batch(() => {\n store.setState(\"focusedCell\", {\n rowIndex: safeRowIndex,\n columnId: targetColumnId,\n });\n store.setState(\"editingCell\", null);\n });\n\n const cellKey = getCellKey(safeRowIndex, targetColumnId);\n const cellElement = cellMapRef.current.get(cellKey);\n\n if (cellElement) {\n cellElement.focus();\n releaseFocusGuard();\n } else if (retryCount > 0) {\n await new Promise((resolve) => requestAnimationFrame(resolve));\n await onScrollAndFocus(retryCount - 1);\n } else {\n dataGridRef.current?.focus();\n releaseFocusGuard();\n }\n }\n\n await onScrollAndFocus(SCROLL_SYNC_RETRY_COUNT);\n },\n [rowVirtualizer, propsRef, store, releaseFocusGuard],\n );\n\n const onRowAdd = React.useCallback(\n async (event?: React.MouseEvent) => {\n if (propsRef.current.readOnly || !propsRef.current.onRowAdd) return;\n\n const initialRowCount = propsRef.current.data.length;\n\n let result: Partial | null;\n try {\n result = await propsRef.current.onRowAdd(event);\n } catch {\n // Callback threw an error, don't proceed with scroll/focus\n return;\n }\n\n if (result === null || event?.defaultPrevented) return;\n\n onSelectionClear();\n\n // Trust the returned rowIndex from the callback\n // onScrollToRow will handle retries if the row isn't rendered yet\n const targetRowIndex = result.rowIndex ?? initialRowCount;\n const targetColumnId = result.columnId;\n\n onScrollToRow({\n rowIndex: targetRowIndex,\n columnId: targetColumnId,\n });\n },\n [propsRef, onScrollToRow, onSelectionClear],\n );\n\n const onDataGridKeyDown = React.useCallback(\n (event: KeyboardEvent) => {\n const currentState = store.getState();\n const { key, ctrlKey, metaKey, shiftKey, altKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n if (\n propsRef.current.enableSearch &&\n isCtrlPressed &&\n !shiftKey &&\n key === SEARCH_SHORTCUT_KEY\n ) {\n event.preventDefault();\n onSearchOpenChange(true);\n return;\n }\n\n if (\n propsRef.current.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 return;\n }\n\n // Cell editing keyboard events (Enter, Tab, Escape) are handled by the cell variants\n // to ensure proper value commitment before navigation\n if (currentState.editingCell) return;\n\n if (\n isCtrlPressed &&\n (key === \"Backspace\" || key === \"Delete\") &&\n !propsRef.current.readOnly &&\n propsRef.current.onRowsDelete\n ) {\n const rowIndices = new Set();\n\n const selectedRowIds = Object.keys(currentState.rowSelection);\n if (selectedRowIds.length > 0) {\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n for (const row of rows) {\n if (currentState.rowSelection[row.id]) {\n rowIndices.add(row.index);\n }\n }\n } else if (currentState.selectionState.selectedCells.size > 0) {\n for (const cellKey of currentState.selectionState.selectedCells) {\n const { rowIndex } = parseCellKey(cellKey);\n rowIndices.add(rowIndex);\n }\n } else if (currentState.focusedCell) {\n rowIndices.add(currentState.focusedCell.rowIndex);\n }\n\n if (rowIndices.size > 0) {\n event.preventDefault();\n onRowsDelete(Array.from(rowIndices));\n }\n return;\n }\n\n if (!currentState.focusedCell) return;\n\n let direction: NavigationDirection | null = null;\n\n if (isCtrlPressed && !shiftKey && key === \"a\") {\n event.preventDefault();\n selectAll();\n return;\n }\n\n if (isCtrlPressed && !shiftKey && key === \"c\") {\n event.preventDefault();\n onCellsCopy();\n return;\n }\n\n if (\n isCtrlPressed &&\n !shiftKey &&\n key === \"x\" &&\n !propsRef.current.readOnly\n ) {\n event.preventDefault();\n onCellsCut();\n return;\n }\n\n if (\n propsRef.current.enablePaste &&\n isCtrlPressed &&\n !shiftKey &&\n key === \"v\" &&\n !propsRef.current.readOnly\n ) {\n event.preventDefault();\n onCellsPaste();\n return;\n }\n\n if (\n (key === \"Delete\" || key === \"Backspace\") &&\n !isCtrlPressed &&\n !propsRef.current.readOnly\n ) {\n const cellsToClear =\n currentState.selectionState.selectedCells.size > 0\n ? Array.from(currentState.selectionState.selectedCells)\n : currentState.focusedCell\n ? [\n getCellKey(\n currentState.focusedCell.rowIndex,\n currentState.focusedCell.columnId,\n ),\n ]\n : [];\n\n if (cellsToClear.length > 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 const columnById = new Map(tableColumns.map((c) => [c.id, c]));\n\n for (const cellKey of cellsToClear) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n const column = columnById.get(columnId);\n const cellVariant = column?.columnDef?.meta?.cell?.variant;\n const emptyValue = getEmptyCellValue(cellVariant);\n updates.push({ rowIndex, columnId, value: emptyValue });\n }\n\n onDataUpdate(updates);\n\n if (currentState.selectionState.selectedCells.size > 0) {\n onSelectionClear();\n }\n\n if (currentState.cutCells.size > 0) {\n store.setState(\"cutCells\", new Set());\n }\n }\n return;\n }\n\n if (\n key === \"Enter\" &&\n shiftKey &&\n !propsRef.current.readOnly &&\n propsRef.current.onRowAdd\n ) {\n event.preventDefault();\n const initialRowCount = propsRef.current.data.length;\n const currentColumnId = currentState.focusedCell.columnId;\n\n Promise.resolve(propsRef.current.onRowAdd())\n .then(async (result) => {\n if (result === null) return;\n\n onSelectionClear();\n\n const targetRowIndex = result.rowIndex ?? initialRowCount;\n const targetColumnId = result.columnId ?? currentColumnId;\n\n onScrollToRow({\n rowIndex: targetRowIndex,\n columnId: targetColumnId,\n });\n })\n .catch(() => {\n // Callback threw an error, don't proceed with scroll/focus\n });\n return;\n }\n\n switch (key) {\n case \"ArrowUp\":\n if (altKey && !isCtrlPressed && !shiftKey) {\n direction = \"pageup\";\n } else if (isCtrlPressed && shiftKey) {\n const selectionEdge =\n currentState.selectionState.selectionRange?.end ||\n currentState.focusedCell;\n const currentColIndex = navigableColumnIds.indexOf(\n selectionEdge.columnId,\n );\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n\n selectRange(selectionStart, {\n rowIndex: 0,\n columnId:\n navigableColumnIds[currentColIndex] ?? selectionEdge.columnId,\n });\n\n const rowVirtualizer = rowVirtualizerRef.current;\n if (rowVirtualizer) {\n rowVirtualizer.scrollToIndex(0, { align: \"start\" });\n }\n\n restoreFocus(dataGridRef.current);\n\n event.preventDefault();\n return;\n } else if (isCtrlPressed && !shiftKey) {\n direction = \"ctrl+up\";\n } else {\n direction = \"up\";\n }\n break;\n case \"ArrowDown\":\n if (altKey && !isCtrlPressed && !shiftKey) {\n direction = \"pagedown\";\n } else if (isCtrlPressed && shiftKey) {\n const rowCount =\n tableRef.current?.getRowModel().rows.length ||\n propsRef.current.data.length;\n const selectionEdge =\n currentState.selectionState.selectionRange?.end ||\n currentState.focusedCell;\n const currentColIndex = navigableColumnIds.indexOf(\n selectionEdge.columnId,\n );\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n\n selectRange(selectionStart, {\n rowIndex: Math.max(0, rowCount - 1),\n columnId:\n navigableColumnIds[currentColIndex] ?? selectionEdge.columnId,\n });\n\n const rowVirtualizer = rowVirtualizerRef.current;\n if (rowVirtualizer) {\n rowVirtualizer.scrollToIndex(Math.max(0, rowCount - 1), {\n align: \"end\",\n });\n }\n\n restoreFocus(dataGridRef.current);\n\n event.preventDefault();\n return;\n } else if (isCtrlPressed && !shiftKey) {\n direction = \"ctrl+down\";\n } else {\n direction = \"down\";\n }\n break;\n case \"ArrowLeft\":\n if (isCtrlPressed && shiftKey) {\n const selectionEdge =\n currentState.selectionState.selectionRange?.end ||\n currentState.focusedCell;\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n const targetColumnId =\n dir === \"rtl\"\n ? navigableColumnIds[navigableColumnIds.length - 1]\n : navigableColumnIds[0];\n\n if (targetColumnId) {\n selectRange(selectionStart, {\n rowIndex: selectionEdge.rowIndex,\n columnId: targetColumnId,\n });\n\n const container = dataGridRef.current;\n const cellKey = getCellKey(\n selectionEdge.rowIndex,\n targetColumnId,\n );\n const targetCell = cellMapRef.current.get(cellKey);\n if (container && targetCell) {\n scrollCellIntoView({\n container,\n targetCell,\n tableRef,\n viewportOffset: VIEWPORT_OFFSET,\n direction: \"home\",\n isRtl: dir === \"rtl\",\n });\n }\n\n restoreFocus(container);\n }\n event.preventDefault();\n return;\n } else if (isCtrlPressed && !shiftKey) {\n direction = \"home\";\n } else {\n direction = \"left\";\n }\n break;\n case \"ArrowRight\":\n if (isCtrlPressed && shiftKey) {\n const selectionEdge =\n currentState.selectionState.selectionRange?.end ||\n currentState.focusedCell;\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n const targetColumnId =\n dir === \"rtl\"\n ? navigableColumnIds[0]\n : navigableColumnIds[navigableColumnIds.length - 1];\n\n if (targetColumnId) {\n selectRange(selectionStart, {\n rowIndex: selectionEdge.rowIndex,\n columnId: targetColumnId,\n });\n\n const container = dataGridRef.current;\n const cellKey = getCellKey(\n selectionEdge.rowIndex,\n targetColumnId,\n );\n const targetCell = cellMapRef.current.get(cellKey);\n if (container && targetCell) {\n scrollCellIntoView({\n container,\n targetCell,\n tableRef,\n viewportOffset: VIEWPORT_OFFSET,\n direction: \"end\",\n isRtl: dir === \"rtl\",\n });\n }\n\n restoreFocus(container);\n }\n event.preventDefault();\n return;\n } else if (isCtrlPressed && !shiftKey) {\n direction = \"end\";\n } else {\n direction = \"right\";\n }\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 = altKey ? \"pageleft\" : \"pageup\";\n break;\n case \"PageDown\":\n direction = altKey ? \"pageright\" : \"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 onSelectionClear();\n } else {\n blurCell();\n }\n return;\n case \"Tab\":\n event.preventDefault();\n if (dir === \"rtl\") {\n direction = event.shiftKey ? \"right\" : \"left\";\n } else {\n direction = event.shiftKey ? \"left\" : \"right\";\n }\n break;\n }\n\n if (direction) {\n event.preventDefault();\n\n if (shiftKey && key !== \"Tab\" && currentState.focusedCell) {\n const selectionEdge =\n currentState.selectionState.selectionRange?.end ||\n currentState.focusedCell;\n\n const currentColIndex = navigableColumnIds.indexOf(\n selectionEdge.columnId,\n );\n let newRowIndex = selectionEdge.rowIndex;\n let newColumnId = selectionEdge.columnId;\n\n const isRtl = dir === \"rtl\";\n\n const rowCount =\n tableRef.current?.getRowModel().rows.length ||\n propsRef.current.data.length;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, selectionEdge.rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(rowCount - 1, selectionEdge.rowIndex + 1);\n break;\n case \"left\":\n if (isRtl) {\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n } else {\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n }\n break;\n case \"right\":\n if (isRtl) {\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n } else {\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n }\n break;\n case \"home\":\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? newColumnId;\n }\n break;\n case \"end\":\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ??\n newColumnId;\n }\n break;\n }\n\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n\n selectRange(selectionStart, {\n rowIndex: newRowIndex,\n columnId: newColumnId,\n });\n\n const container = dataGridRef.current;\n const targetRow = rowMapRef.current.get(newRowIndex);\n const cellKey = getCellKey(newRowIndex, newColumnId);\n const targetCell = cellMapRef.current.get(cellKey);\n\n if (\n newRowIndex !== selectionEdge.rowIndex &&\n (direction === \"up\" || direction === \"down\")\n ) {\n if (container && targetRow) {\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 const rowRect = targetRow.getBoundingClientRect();\n const isFullyVisible =\n rowRect.top >= viewportTop && rowRect.bottom <= viewportBottom;\n\n if (!isFullyVisible) {\n const scrollNeeded =\n direction === \"down\"\n ? rowRect.bottom - viewportBottom\n : viewportTop - rowRect.top;\n\n if (direction === \"down\") {\n container.scrollTop += scrollNeeded;\n } else {\n container.scrollTop -= scrollNeeded;\n }\n\n restoreFocus(container);\n }\n } else {\n const rowVirtualizer = rowVirtualizerRef.current;\n if (rowVirtualizer) {\n const align = direction === \"up\" ? \"start\" : \"end\";\n rowVirtualizer.scrollToIndex(newRowIndex, { align });\n\n restoreFocus(container);\n }\n }\n }\n\n if (\n newColumnId !== selectionEdge.columnId &&\n (direction === \"left\" ||\n direction === \"right\" ||\n direction === \"home\" ||\n direction === \"end\")\n ) {\n if (container && targetCell) {\n scrollCellIntoView({\n container,\n targetCell,\n tableRef,\n viewportOffset: VIEWPORT_OFFSET,\n direction,\n isRtl,\n });\n }\n }\n } else {\n if (currentState.selectionState.selectedCells.size > 0) {\n onSelectionClear();\n }\n navigateCell(direction);\n }\n }\n },\n [\n dir,\n store,\n propsRef,\n blurCell,\n navigateCell,\n selectAll,\n onCellsCopy,\n onCellsCut,\n onCellsPaste,\n onDataUpdate,\n onSelectionClear,\n navigableColumnIds,\n selectRange,\n onSearchOpenChange,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n onRowsDelete,\n restoreFocus,\n onScrollToRow,\n ],\n );\n\n const searchState = React.useMemo(() => {\n if (!propsRef.current.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 propsRef,\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, shiftKey } = event;\n const isCommandPressed = ctrlKey || metaKey;\n\n if (\n propsRef.current.enableSearch &&\n isCommandPressed &&\n !shiftKey &&\n key === SEARCH_SHORTCUT_KEY\n ) {\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\n const nextSearchOpen = !store.getState().searchOpen;\n onSearchOpenChange(nextSearchOpen);\n\n if (nextSearchOpen && !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 onSelectionClear();\n }\n }\n }\n\n window.addEventListener(\"keydown\", onGlobalKeyDown, true);\n return () => {\n window.removeEventListener(\"keydown\", onGlobalKeyDown, true);\n };\n }, [propsRef, onSearchOpenChange, store, onSelectionClear]);\n\n React.useEffect(() => {\n const currentState = store.getState();\n const autoFocus = propsRef.current.autoFocus;\n\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 }, [store, propsRef, data, columns, navigableColumnIds, focusCell]);\n\n // Restore focus to container when virtualized cells are unmounted\n React.useEffect(() => {\n const container = dataGridRef.current;\n if (!container) return;\n\n function onFocusOut(event: FocusEvent) {\n if (focusGuardRef.current) return;\n\n const currentContainer = dataGridRef.current;\n if (!currentContainer) return;\n\n const currentState = store.getState();\n\n if (!currentState.focusedCell || currentState.editingCell) return;\n\n const relatedTarget = event.relatedTarget;\n\n const isFocusMovingOutsideGrid =\n !relatedTarget || !currentContainer.contains(relatedTarget as Node);\n\n const isFocusMovingToPopover = getIsInPopover(relatedTarget);\n\n if (isFocusMovingOutsideGrid && !isFocusMovingToPopover) {\n const { rowIndex, columnId } = currentState.focusedCell;\n const cellKey = getCellKey(rowIndex, columnId);\n const cellElement = cellMapRef.current.get(cellKey);\n\n requestAnimationFrame(() => {\n if (focusGuardRef.current) return;\n\n if (cellElement && document.body.contains(cellElement)) {\n cellElement.focus();\n } else {\n currentContainer.focus();\n }\n });\n }\n }\n\n container.addEventListener(\"focusout\", onFocusOut);\n\n return () => {\n container.removeEventListener(\"focusout\", onFocusOut);\n };\n }, [store]);\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 elements = document.elementsFromPoint(\n event.clientX,\n event.clientY,\n );\n\n // Compensate for event.target bubbling up\n const isInsidePopover = elements.some((element) =>\n getIsInPopover(element),\n );\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 onSelectionClear();\n }\n }\n }\n }\n\n document.addEventListener(\"mousedown\", onOutsideClick);\n return () => {\n document.removeEventListener(\"mousedown\", onOutsideClick);\n };\n }, [store, blurCell, onSelectionClear]);\n\n React.useEffect(() => {\n function onSelectStart(event: Event) {\n event.preventDefault();\n }\n\n function onContextMenu(event: Event) {\n event.preventDefault();\n }\n\n function onCleanup() {\n document.removeEventListener(\"selectstart\", onSelectStart);\n document.removeEventListener(\"contextmenu\", onContextMenu);\n document.body.style.userSelect = \"\";\n }\n\n const onUnsubscribe = store.subscribe(() => {\n const currentState = store.getState();\n if (currentState.selectionState.isSelecting) {\n document.addEventListener(\"selectstart\", onSelectStart);\n document.addEventListener(\"contextmenu\", onContextMenu);\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 rowHeight,\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 ]);\n\n // Calculate virtual values outside of child render to avoid flushSync issues\n const virtualTotalSize = rowVirtualizer.getTotalSize();\n const virtualItems = rowVirtualizer.getVirtualItems();\n const measureElement = rowVirtualizer.measureElement;\n\n return React.useMemo(\n () => ({\n dataGridRef,\n headerRef,\n rowMapRef,\n footerRef,\n dir,\n table,\n tableMeta,\n virtualTotalSize,\n virtualItems,\n measureElement,\n columns,\n columnSizeVars,\n searchState,\n searchMatchesByRow,\n activeSearchMatch,\n cellSelectionMap,\n focusedCell,\n editingCell,\n rowHeight,\n contextMenu,\n pasteDialog,\n onRowAdd: propsRef.current.onRowAdd ? onRowAdd : undefined,\n adjustLayout,\n }),\n [\n propsRef,\n dir,\n table,\n tableMeta,\n virtualTotalSize,\n virtualItems,\n measureElement,\n columns,\n columnSizeVars,\n searchState,\n searchMatchesByRow,\n activeSearchMatch,\n cellSelectionMap,\n focusedCell,\n editingCell,\n rowHeight,\n contextMenu,\n pasteDialog,\n onRowAdd,\n adjustLayout,\n ],\n );\n}\n\nexport {\n useDataGrid,\n //\n type UseDataGridProps,\n};\n", + "content": "import { useDirection } from \"@radix-ui/react-direction\";\nimport {\n type ColumnDef,\n type ColumnFiltersState,\n getCoreRowModel,\n getFilteredRowModel,\n getSortedRowModel,\n type Row,\n type RowSelectionState,\n type SortingState,\n type TableMeta,\n type TableOptions,\n type TableState,\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\";\n\nimport { useAsRef } from \"@/hooks/use-as-ref\";\nimport { useIsomorphicLayoutEffect } from \"@/hooks/use-isomorphic-layout-effect\";\nimport { useLazyRef } from \"@/hooks/use-lazy-ref\";\nimport {\n getCellKey,\n getEmptyCellValue,\n getIsFileCellData,\n getIsInPopover,\n getRowHeightValue,\n getScrollDirection,\n matchSelectOption,\n parseCellKey,\n parseTsv,\n scrollCellIntoView,\n} from \"@/lib/data-grid\";\nimport type {\n CellPosition,\n CellUpdate,\n ContextMenuState,\n Direction,\n FileCellData,\n NavigationDirection,\n PasteDialogState,\n RowHeightValue,\n SearchState,\n SelectionState,\n} from \"@/types/data-grid\";\n\nconst DEFAULT_ROW_HEIGHT = \"short\";\nconst OVERSCAN = 6;\nconst VIEWPORT_OFFSET = 1;\nconst HORIZONTAL_PAGE_SIZE = 5;\nconst SCROLL_SYNC_RETRY_COUNT = 16;\nconst MIN_COLUMN_SIZE = 60;\nconst MAX_COLUMN_SIZE = 800;\nconst SEARCH_SHORTCUT_KEY = \"f\";\nconst NON_NAVIGABLE_COLUMN_IDS = new Set([\"select\", \"actions\"]);\n\nconst DOMAIN_REGEX = /^[\\w.-]+\\.[a-z]{2,}(\\/\\S*)?$/i;\nconst ISO_DATE_REGEX = /^\\d{4}-\\d{2}-\\d{2}(T\\d{2}:\\d{2}:\\d{2}.*)?$/;\nconst TRUTHY_BOOLEANS = new Set([\"true\", \"1\", \"yes\", \"checked\"]);\nconst VALID_BOOLEANS = new Set([\n \"true\",\n \"false\",\n \"1\",\n \"0\",\n \"yes\",\n \"no\",\n \"checked\",\n \"unchecked\",\n]);\n\ninterface DataGridState {\n sorting: SortingState;\n columnFilters: ColumnFiltersState;\n rowHeight: RowHeightValue;\n rowSelection: RowSelectionState;\n selectionState: SelectionState;\n focusedCell: CellPosition | null;\n editingCell: CellPosition | null;\n cutCells: Set;\n contextMenu: ContextMenuState;\n searchQuery: string;\n searchMatches: CellPosition[];\n matchIndex: number;\n searchOpen: boolean;\n lastClickedRowId: string | null;\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?: (\n event?: React.MouseEvent,\n ) => Partial | Promise | null> | null;\n onRowsAdd?: (count: number) => void | Promise;\n onRowsDelete?: (rows: TData[], rowIndices: number[]) => void | Promise;\n onPaste?: (updates: Array) => void | Promise;\n onFilesUpload?: (params: {\n files: File[];\n rowIndex: number;\n columnId: string;\n }) => Promise;\n onFilesDelete?: (params: {\n fileIds: string[];\n rowIndex: number;\n columnId: string;\n }) => void | Promise;\n rowHeight?: RowHeightValue;\n onRowHeightChange?: (rowHeight: RowHeightValue) => void;\n overscan?: number;\n dir?: Direction;\n autoFocus?: boolean | Partial;\n enableSingleCellSelection?: boolean;\n enableColumnSelection?: boolean;\n enableSearch?: boolean;\n enablePaste?: boolean;\n readOnly?: boolean;\n}\n\nfunction useDataGrid({\n data,\n columns,\n rowHeight: rowHeightProp = DEFAULT_ROW_HEIGHT,\n overscan = OVERSCAN,\n dir: dirProp,\n initialState,\n ...props\n}: UseDataGridProps) {\n const dir = useDirection(dirProp);\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 const focusGuardRef = React.useRef(false);\n\n const propsRef = useAsRef({\n ...props,\n data,\n columns,\n initialState,\n });\n\n const listenersRef = useLazyRef(() => new Set<() => void>());\n\n const stateRef = useLazyRef(() => {\n return {\n sorting: initialState?.sorting ?? [],\n columnFilters: initialState?.columnFilters ?? [],\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 cutCells: new Set(),\n contextMenu: {\n open: false,\n x: 0,\n y: 0,\n },\n searchQuery: \"\",\n searchMatches: [],\n matchIndex: -1,\n searchOpen: false,\n lastClickedRowId: null,\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 columnFilters = useStore(store, (state) => state.columnFilters);\n const rowSelection = useStore(store, (state) => state.rowSelection);\n const rowHeight = useStore(store, (state) => state.rowHeight);\n const contextMenu = useStore(store, (state) => state.contextMenu);\n const pasteDialog = useStore(store, (state) => state.pasteDialog);\n\n const rowHeightValue = getRowHeightValue(rowHeight);\n\n const prevCellSelectionMapRef = useLazyRef(\n () => new Map>(),\n );\n\n // Memoize per-row selection sets to prevent unnecessary row re-renders\n // Each row gets a stable Set reference that only changes when its cells' selection changes\n const cellSelectionMap = React.useMemo(() => {\n const selectedCells = selectionState.selectedCells;\n\n if (selectedCells.size === 0) {\n prevCellSelectionMapRef.current.clear();\n return null;\n }\n\n const newRowCells = new Map>();\n for (const cellKey of selectedCells) {\n const { rowIndex } = parseCellKey(cellKey);\n let rowSet = newRowCells.get(rowIndex);\n if (!rowSet) {\n rowSet = new Set();\n newRowCells.set(rowIndex, rowSet);\n }\n rowSet.add(cellKey);\n }\n\n const stableMap = new Map>();\n for (const [rowIndex, newSet] of newRowCells) {\n const prevSet = prevCellSelectionMapRef.current.get(rowIndex);\n if (\n prevSet &&\n prevSet.size === newSet.size &&\n [...newSet].every((key) => prevSet.has(key))\n ) {\n stableMap.set(rowIndex, prevSet);\n } else {\n stableMap.set(rowIndex, newSet);\n }\n }\n\n prevCellSelectionMapRef.current = stableMap;\n return stableMap;\n }, [selectionState.selectedCells, prevCellSelectionMapRef]);\n\n const visualRowIndexCacheRef = React.useRef<{\n rows: Row[] | null;\n map: Map;\n } | null>(null);\n\n // Pre-compute visual row index map for O(1) lookups (used by select column)\n // Cache is invalidated when row model identity changes (sorting/filtering)\n const getVisualRowIndex = React.useCallback(\n (rowId: string): number | undefined => {\n const rows = tableRef.current?.getRowModel().rows;\n if (!rows) return undefined;\n\n if (visualRowIndexCacheRef.current?.rows !== rows) {\n const map = new Map();\n for (const [i, row] of rows.entries()) {\n map.set(row.id, i + 1);\n }\n visualRowIndexCacheRef.current = { rows, map };\n }\n\n return visualRowIndexCacheRef.current.map.get(rowId);\n },\n [],\n );\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.has(c));\n }, [columnIds]);\n\n const onDataUpdate = React.useCallback(\n (updates: CellUpdate | Array) => {\n if (propsRef.current.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 currentData = propsRef.current.data;\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 = currentData.indexOf(originalData);\n\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 const newData: TData[] = new Array(currentData.length);\n\n for (let i = 0; i < currentData.length; i++) {\n const updates = rowUpdatesMap.get(i);\n const existingRow = currentData[i];\n\n if (!existingRow) {\n newData[i] = existingRow as TData;\n continue;\n }\n\n if (updates) {\n const updatedRow = { ...existingRow } as Record;\n for (const { columnId, value } of updates) {\n updatedRow[columnId] = value;\n }\n newData[i] = updatedRow as TData;\n } else {\n newData[i] = existingRow;\n }\n }\n\n propsRef.current.onDataChange?.(newData);\n },\n [propsRef],\n );\n\n const getIsCellSelected = React.useCallback(\n (rowIndex: number, columnId: string) => {\n const currentSelectionState = store.getState().selectionState;\n return currentSelectionState.selectedCells.has(\n getCellKey(rowIndex, columnId),\n );\n },\n [store],\n );\n\n const onSelectionClear = 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 ?? propsRef.current.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, propsRef, 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 ?? propsRef.current.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 [propsRef, 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 serializeCellsToTsv = React.useCallback(() => {\n const currentState = store.getState();\n\n let selectedCellsArray: string[];\n if (!currentState.selectionState.selectedCells.size) {\n if (!currentState.focusedCell) return null;\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 null;\n\n const selectedColumnIds: string[] = [];\n const seenColumnIds = new Set();\n const cellData = new Map();\n const rowIndices = new Set();\n const rowCellMaps = new Map<\n number,\n Map[\"getVisibleCells\"]>[number]>\n >();\n const navigableCells: string[] = [];\n\n for (const cellKey of selectedCellsArray) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n\n if (columnId && NON_NAVIGABLE_COLUMN_IDS.has(columnId)) {\n continue;\n }\n\n navigableCells.push(cellKey);\n\n if (columnId && !seenColumnIds.has(columnId)) {\n seenColumnIds.add(columnId);\n selectedColumnIds.push(columnId);\n }\n\n rowIndices.add(rowIndex);\n\n const row = rows[rowIndex];\n if (row) {\n let cellMap = rowCellMaps.get(rowIndex);\n if (!cellMap) {\n cellMap = new Map(row.getVisibleCells().map((c) => [c.column.id, c]));\n rowCellMaps.set(rowIndex, cellMap);\n }\n const cell = cellMap.get(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 colIndices = new Set();\n for (const cellKey of navigableCells) {\n const { columnId } = parseCellKey(cellKey);\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 return { tsvData, selectedCellsArray: navigableCells };\n }, [store]);\n\n const onCellsCopy = React.useCallback(async () => {\n const result = serializeCellsToTsv();\n if (!result) return;\n\n const { tsvData, selectedCellsArray } = result;\n\n try {\n await navigator.clipboard.writeText(tsvData);\n\n const currentState = store.getState();\n if (currentState.cutCells.size > 0) {\n store.setState(\"cutCells\", new Set());\n }\n\n toast.success(\n `${selectedCellsArray.length} cell${\n selectedCellsArray.length !== 1 ? \"s\" : \"\"\n } copied`,\n );\n } catch (error) {\n toast.error(\n error instanceof Error ? error.message : \"Failed to copy to clipboard\",\n );\n }\n }, [store, serializeCellsToTsv]);\n\n const onCellsCut = React.useCallback(async () => {\n if (propsRef.current.readOnly) return;\n\n const result = serializeCellsToTsv();\n if (!result) return;\n\n const { tsvData, selectedCellsArray } = result;\n\n try {\n await navigator.clipboard.writeText(tsvData);\n\n store.setState(\"cutCells\", new Set(selectedCellsArray));\n\n toast.success(\n `${selectedCellsArray.length} cell${\n selectedCellsArray.length !== 1 ? \"s\" : \"\"\n } cut`,\n );\n } catch (error) {\n toast.error(\n error instanceof Error ? error.message : \"Failed to cut to clipboard\",\n );\n }\n }, [store, propsRef, serializeCellsToTsv]);\n\n const restoreFocus = React.useCallback((element: HTMLDivElement | null) => {\n if (element && document.activeElement !== element) {\n requestAnimationFrame(() => {\n element.focus();\n });\n }\n }, []);\n\n const onCellsPaste = React.useCallback(\n async (expandRows = false) => {\n if (propsRef.current.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 const pastedData = parseTsv(clipboardText, navigableColumnIds.length);\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 ?? propsRef.current.data.length;\n const rowsNeeded = startRowIndex + pastedData.length - rowCount;\n\n if (\n rowsNeeded > 0 &&\n !expandRows &&\n propsRef.current.onRowAdd &&\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 (propsRef.current.onRowsAdd) {\n await propsRef.current.onRowsAdd(rowsNeeded);\n } else if (propsRef.current.onRowAdd) {\n for (let i = 0; i < rowsNeeded; i++) {\n await propsRef.current.onRowAdd();\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 let cellsSkipped = 0;\n\n const columnMap = new Map(tableColumns.map((c) => [c.id, c]));\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;\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;\n\n const targetColumnId = navigableColumnIds[targetColIndex];\n if (!targetColumnId) continue;\n\n const pastedValue = pasteRow[pasteColIdx] ?? \"\";\n const column = columnMap.get(targetColumnId);\n const cellOpts = column?.columnDef?.meta?.cell;\n const cellVariant = cellOpts?.variant;\n\n let processedValue: unknown = pastedValue;\n let shouldSkip = false;\n\n switch (cellVariant) {\n case \"number\": {\n if (!pastedValue) {\n processedValue = null;\n } else {\n const num = Number.parseFloat(pastedValue);\n if (Number.isNaN(num)) shouldSkip = true;\n else processedValue = num;\n }\n break;\n }\n\n case \"checkbox\": {\n if (!pastedValue) {\n processedValue = false;\n } else {\n const lower = pastedValue.toLowerCase();\n if (VALID_BOOLEANS.has(lower)) {\n processedValue = TRUTHY_BOOLEANS.has(lower);\n } else {\n shouldSkip = true;\n }\n }\n break;\n }\n\n case \"date\": {\n if (!pastedValue) {\n processedValue = null;\n } else {\n const date = new Date(pastedValue);\n if (Number.isNaN(date.getTime())) shouldSkip = true;\n else processedValue = date;\n }\n break;\n }\n\n case \"select\": {\n const options = cellOpts?.options ?? [];\n if (!pastedValue) {\n processedValue = \"\";\n } else {\n const matched = matchSelectOption(pastedValue, options);\n if (matched) processedValue = matched;\n else shouldSkip = true;\n }\n break;\n }\n\n case \"multi-select\": {\n const options = cellOpts?.options ?? [];\n let values: string[] = [];\n try {\n const parsed = JSON.parse(pastedValue);\n if (Array.isArray(parsed)) {\n values = parsed.filter(\n (v): v is string => typeof v === \"string\",\n );\n }\n } catch {\n values = pastedValue\n ? pastedValue.split(\",\").map((v) => v.trim())\n : [];\n }\n\n const validated = values\n .map((v) => matchSelectOption(v, options))\n .filter(Boolean) as string[];\n\n if (values.length > 0 && validated.length === 0) {\n shouldSkip = true;\n } else {\n processedValue = validated;\n }\n break;\n }\n\n case \"file\": {\n if (!pastedValue) {\n processedValue = [];\n } else {\n try {\n const parsed = JSON.parse(pastedValue);\n if (!Array.isArray(parsed)) {\n shouldSkip = true;\n } else {\n const validFiles = parsed.filter(getIsFileCellData);\n if (parsed.length > 0 && validFiles.length === 0) {\n shouldSkip = true;\n } else {\n processedValue = validFiles;\n }\n }\n } catch {\n shouldSkip = true;\n }\n }\n break;\n }\n\n case \"url\": {\n if (!pastedValue) {\n processedValue = \"\";\n } else {\n const firstChar = pastedValue[0];\n if (firstChar === \"[\" || firstChar === \"{\") {\n shouldSkip = true;\n } else {\n try {\n new URL(pastedValue);\n processedValue = pastedValue;\n } catch {\n if (DOMAIN_REGEX.test(pastedValue)) {\n processedValue = pastedValue;\n } else {\n shouldSkip = true;\n }\n }\n }\n }\n break;\n }\n\n default: {\n if (!pastedValue) {\n processedValue = \"\";\n break;\n }\n\n if (ISO_DATE_REGEX.test(pastedValue)) {\n const date = new Date(pastedValue);\n if (!Number.isNaN(date.getTime())) {\n processedValue = date.toLocaleDateString();\n break;\n }\n }\n\n const firstChar = pastedValue[0];\n if (\n firstChar === \"[\" ||\n firstChar === \"{\" ||\n firstChar === \"t\" ||\n firstChar === \"f\"\n ) {\n try {\n const parsed = JSON.parse(pastedValue);\n\n if (Array.isArray(parsed)) {\n if (\n parsed.length > 0 &&\n parsed.every(getIsFileCellData)\n ) {\n processedValue = parsed.map((f) => f.name).join(\", \");\n } else if (parsed.every((v) => typeof v === \"string\")) {\n processedValue = (parsed as string[]).join(\", \");\n }\n } else if (typeof parsed === \"boolean\") {\n processedValue = parsed ? \"Checked\" : \"Unchecked\";\n }\n } catch {\n const lower = pastedValue.toLowerCase();\n if (lower === \"true\" || lower === \"false\") {\n processedValue =\n lower === \"true\" ? \"Checked\" : \"Unchecked\";\n }\n }\n }\n }\n }\n\n if (shouldSkip) {\n cellsSkipped++;\n endRowIndex = Math.max(endRowIndex, targetRowIndex);\n endColIndex = Math.max(endColIndex, targetColIndex);\n continue;\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 if (propsRef.current.onPaste) {\n await propsRef.current.onPaste(updates);\n }\n\n const allUpdates = [...updates];\n\n if (currentState.cutCells.size > 0) {\n const columnById = new Map(tableColumns.map((c) => [c.id, c]));\n\n for (const cellKey of currentState.cutCells) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n const column = columnById.get(columnId);\n const cellVariant = column?.columnDef?.meta?.cell?.variant;\n const emptyValue = getEmptyCellValue(cellVariant);\n allUpdates.push({ rowIndex, columnId, value: emptyValue });\n }\n\n store.setState(\"cutCells\", new Set());\n }\n\n onDataUpdate(allUpdates);\n\n if (cellsSkipped > 0) {\n toast.success(\n `${cellsUpdated} cell${\n cellsUpdated !== 1 ? \"s\" : \"\"\n } pasted, ${cellsSkipped} skipped`,\n );\n } else {\n toast.success(\n `${cellsUpdated} cell${cellsUpdated !== 1 ? \"s\" : \"\"} pasted`,\n );\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 restoreFocus(dataGridRef.current);\n } else if (cellsSkipped > 0) {\n toast.error(\n `${cellsSkipped} cell${\n cellsSkipped !== 1 ? \"s\" : \"\"\n } skipped pasting for invalid data`,\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 propsRef,\n onDataUpdate,\n selectRange,\n restoreFocus,\n ],\n );\n\n // Release focus guard after delay to allow async data re-renders to settle.\n // 300ms accounts for db sync and virtualized cell mounting.\n const releaseFocusGuard = React.useCallback((immediate = false) => {\n if (immediate) {\n focusGuardRef.current = false;\n return;\n }\n\n setTimeout(() => {\n focusGuardRef.current = false;\n }, 300);\n }, []);\n\n const focusCellWrapper = React.useCallback(\n (rowIndex: number, columnId: string) => {\n focusGuardRef.current = true;\n\n requestAnimationFrame(() => {\n const cellKey = getCellKey(rowIndex, columnId);\n const cellWrapperElement = cellMapRef.current.get(cellKey);\n\n if (!cellWrapperElement) {\n const container = dataGridRef.current;\n if (container) {\n container.focus();\n }\n releaseFocusGuard();\n return;\n }\n\n cellWrapperElement.focus();\n releaseFocusGuard();\n });\n },\n [releaseFocusGuard],\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 (\n propsRef.current.readOnly ||\n !propsRef.current.onRowsDelete ||\n rowIndices.length === 0\n )\n 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 propsRef.current.onRowsDelete(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 ?? propsRef.current.data.length;\n\n if (newRowCount > 0 && currentFocusedColumn) {\n const targetRowIndex = Math.min(minDeletedRowIndex, newRowCount - 1);\n focusCell(targetRowIndex, currentFocusedColumn);\n }\n });\n },\n [propsRef, 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 ?? propsRef.current.data.length;\n\n let newRowIndex = rowIndex;\n let newColumnId = columnId;\n\n const isRtl = dir === \"rtl\";\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 (isRtl) {\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n } else {\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n }\n break;\n case \"right\":\n if (isRtl) {\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n } else {\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\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 \"ctrl+up\":\n newRowIndex = 0;\n break;\n case \"ctrl+down\":\n newRowIndex = Math.max(0, rowCount - 1);\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 case \"pageleft\":\n if (currentColIndex > 0) {\n const targetIndex = Math.max(\n 0,\n currentColIndex - HORIZONTAL_PAGE_SIZE,\n );\n const targetColumnId = navigableColumnIds[targetIndex];\n if (targetColumnId) newColumnId = targetColumnId;\n }\n break;\n case \"pageright\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const targetIndex = Math.min(\n navigableColumnIds.length - 1,\n currentColIndex + HORIZONTAL_PAGE_SIZE,\n );\n const targetColumnId = navigableColumnIds[targetIndex];\n if (targetColumnId) newColumnId = targetColumnId;\n }\n break;\n }\n\n if (newRowIndex !== rowIndex || newColumnId !== columnId) {\n focusCell(newRowIndex, newColumnId);\n\n // Calculate and apply scrolls synchronously to avoid flashing\n const container = dataGridRef.current;\n if (!container) return;\n\n const targetRow = rowMapRef.current.get(newRowIndex);\n const cellKey = getCellKey(newRowIndex, newColumnId);\n const targetCell = cellMapRef.current.get(cellKey);\n\n // If target row is not rendered, scroll it into view first\n if (!targetRow) {\n if (rowVirtualizer) {\n const align =\n direction === \"up\" ||\n direction === \"pageup\" ||\n direction === \"ctrl+up\" ||\n direction === \"ctrl+home\"\n ? \"start\"\n : direction === \"down\" ||\n direction === \"pagedown\" ||\n direction === \"ctrl+down\" ||\n direction === \"ctrl+end\"\n ? \"end\"\n : \"center\";\n\n rowVirtualizer.scrollToIndex(newRowIndex, { align });\n\n // Wait for row to render before horizontal scroll\n if (newColumnId !== columnId) {\n requestAnimationFrame(() => {\n const cellKeyRetry = getCellKey(newRowIndex, newColumnId);\n const targetCellRetry = cellMapRef.current.get(cellKeyRetry);\n\n if (targetCellRetry) {\n const scrollDirection = getScrollDirection(direction);\n\n scrollCellIntoView({\n container,\n targetCell: targetCellRetry,\n tableRef,\n viewportOffset: VIEWPORT_OFFSET,\n direction: scrollDirection,\n isRtl: dir === \"rtl\",\n });\n }\n });\n }\n } else {\n // Fallback: use direct scroll calculation when virtualizer is not available\n const rowHeightValue = getRowHeightValue(rowHeight);\n const estimatedScrollTop = newRowIndex * rowHeightValue;\n container.scrollTop = estimatedScrollTop;\n }\n\n return;\n }\n\n // Vertical scrolling for rendered rows that changed\n if (newRowIndex !== rowIndex && targetRow) {\n requestAnimationFrame(() => {\n const containerRect = container.getBoundingClientRect();\n const headerHeight =\n headerRef.current?.getBoundingClientRect().height ?? 0;\n const footerHeight =\n footerRef.current?.getBoundingClientRect().height ?? 0;\n const viewportTop =\n containerRect.top + headerHeight + VIEWPORT_OFFSET;\n const viewportBottom =\n containerRect.bottom - footerHeight - VIEWPORT_OFFSET;\n\n const rowRect = targetRow.getBoundingClientRect();\n const isFullyVisible =\n rowRect.top >= viewportTop && rowRect.bottom <= viewportBottom;\n\n if (!isFullyVisible) {\n // Only apply vertical scroll for vertical navigation\n const isVerticalNavigation =\n direction === \"up\" ||\n direction === \"down\" ||\n direction === \"pageup\" ||\n direction === \"pagedown\" ||\n direction === \"ctrl+up\" ||\n direction === \"ctrl+down\" ||\n direction === \"ctrl+home\" ||\n direction === \"ctrl+end\";\n\n if (isVerticalNavigation) {\n if (\n direction === \"down\" ||\n direction === \"pagedown\" ||\n direction === \"ctrl+down\" ||\n direction === \"ctrl+end\"\n ) {\n container.scrollTop += rowRect.bottom - viewportBottom;\n } else {\n container.scrollTop -= viewportTop - rowRect.top;\n }\n }\n }\n });\n }\n\n // Horizontal scrolling for rendered cells\n if (newColumnId !== columnId && targetCell) {\n requestAnimationFrame(() => {\n const scrollDirection = getScrollDirection(direction);\n\n scrollCellIntoView({\n container,\n targetCell,\n tableRef,\n viewportOffset: VIEWPORT_OFFSET,\n direction: scrollDirection,\n isRtl: dir === \"rtl\",\n });\n });\n }\n }\n },\n [dir, store, navigableColumnIds, focusCell, propsRef, rowHeight],\n );\n\n const onCellEditingStart = React.useCallback(\n (rowIndex: number, columnId: string) => {\n if (propsRef.current.readOnly) return;\n\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", { rowIndex, columnId });\n });\n },\n [store, propsRef],\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 ?? propsRef.current.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 const { rowIndex, columnId } = currentEditing;\n focusCell(rowIndex, columnId);\n requestAnimationFrame(() => {\n navigateCell(opts.direction ?? \"right\");\n });\n } else if (currentEditing) {\n const { rowIndex, columnId } = currentEditing;\n focusCellWrapper(rowIndex, columnId);\n }\n },\n [store, propsRef, 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 const cellById = new Map(\n row.getVisibleCells().map((c) => [c.column.id, c]),\n );\n\n for (const columnId of columnIds) {\n const cell = cellById.get(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 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 searchMatchSet = React.useMemo(() => {\n return new Set(\n searchMatches.map((m) => getCellKey(m.rowIndex, m.columnId)),\n );\n }, [searchMatches]);\n\n const getIsSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return searchMatchSet.has(getCellKey(rowIndex, columnId));\n },\n [searchMatchSet],\n );\n\n const getIsActiveSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n const currentState = store.getState();\n if (currentState.matchIndex < 0) return false;\n const currentMatch = currentState.searchMatches[currentState.matchIndex];\n return (\n currentMatch?.rowIndex === rowIndex &&\n currentMatch?.columnId === columnId\n );\n },\n [store],\n );\n\n // Compute search match data for targeted row re-renders\n // Maps rowIndex -> Set of columnIds that have matches in that row\n const searchMatchesByRow = React.useMemo(() => {\n if (searchMatches.length === 0) return null;\n const rowMap = new Map>();\n for (const match of searchMatches) {\n let columnSet = rowMap.get(match.rowIndex);\n if (!columnSet) {\n columnSet = new Set();\n rowMap.set(match.rowIndex, columnSet);\n }\n columnSet.add(match.columnId);\n }\n return rowMap;\n }, [searchMatches]);\n\n const activeSearchMatch = React.useMemo(() => {\n if (matchIndex < 0 || searchMatches.length === 0) return null;\n return searchMatches[matchIndex] ?? null;\n }, [searchMatches, matchIndex]);\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 if (event?.button === 2) {\n return;\n }\n\n const currentState = store.getState();\n const currentFocused = currentState.focusedCell;\n\n function scrollToCell() {\n requestAnimationFrame(() => {\n const container = dataGridRef.current;\n const cellKey = getCellKey(rowIndex, columnId);\n const targetCell = cellMapRef.current.get(cellKey);\n\n if (container && targetCell) {\n scrollCellIntoView({\n container,\n targetCell,\n tableRef,\n viewportOffset: VIEWPORT_OFFSET,\n isRtl: dir === \"rtl\",\n });\n }\n });\n }\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 scrollToCell();\n return;\n }\n\n if (event.shiftKey && currentState.focusedCell) {\n event.preventDefault();\n selectRange(currentState.focusedCell, { rowIndex, columnId });\n scrollToCell();\n return;\n }\n }\n\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 const cellKey = getCellKey(rowIndex, columnId);\n const isClickingSelectedCell =\n currentState.selectionState.selectedCells.has(cellKey);\n\n if (!isClickingSelectedCell) {\n onSelectionClear();\n } else {\n focusCell(rowIndex, columnId);\n scrollToCell();\n return;\n }\n } else if (hasSelectedRows && columnId !== \"select\") {\n onSelectionClear();\n }\n\n if (\n currentFocused?.rowIndex === rowIndex &&\n currentFocused?.columnId === columnId\n ) {\n onCellEditingStart(rowIndex, columnId);\n } else {\n focusCell(rowIndex, columnId);\n scrollToCell();\n }\n },\n [store, focusCell, onCellEditingStart, selectRange, onSelectionClear, dir],\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 if (event.button === 2) {\n return;\n }\n\n event.preventDefault();\n\n if (!event.ctrlKey && !event.metaKey && !event.shiftKey) {\n const cellKey = getCellKey(rowIndex, columnId);\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: propsRef.current.enableSingleCellSelection\n ? new Set([cellKey])\n : 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, propsRef],\n );\n\n const onCellMouseEnter = React.useCallback(\n (rowIndex: number, columnId: string) => {\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 (!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 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 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 propsRef.current.onSortingChange?.(newSorting);\n },\n [store, propsRef],\n );\n\n const onColumnFiltersChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newColumnFilters =\n typeof updater === \"function\"\n ? updater(currentState.columnFilters)\n : updater;\n store.setState(\"columnFilters\", newColumnFilters);\n\n propsRef.current.onColumnFiltersChange?.(newColumnFilters);\n },\n [store, propsRef],\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 propsRef.current.onRowSelectionChange?.(updater);\n },\n [store, columnIds, propsRef],\n );\n\n const onRowSelect = React.useCallback(\n (rowId: string, selected: boolean, shiftKey: boolean) => {\n const currentState = store.getState();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n const currentRowIndex = rows.findIndex((r) => r.id === rowId);\n if (currentRowIndex === -1) return;\n\n if (shiftKey && currentState.lastClickedRowId !== null) {\n const lastClickedRowIndex = rows.findIndex(\n (r) => r.id === currentState.lastClickedRowId,\n );\n if (lastClickedRowIndex === -1) {\n onRowSelectionChange({\n ...currentState.rowSelection,\n [rowId]: selected,\n });\n } else {\n const startIndex = Math.min(lastClickedRowIndex, currentRowIndex);\n const endIndex = Math.max(lastClickedRowIndex, currentRowIndex);\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 }\n } else {\n onRowSelectionChange({\n ...currentState.rowSelection,\n [rowId]: selected,\n });\n }\n\n store.setState(\"lastClickedRowId\", rowId);\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 propsRef.current.onRowHeightChange?.(newRowHeight);\n },\n [store, propsRef],\n );\n\n const onColumnClick = React.useCallback(\n (columnId: string) => {\n if (!propsRef.current.enableColumnSelection) {\n onSelectionClear();\n return;\n }\n\n selectColumn(columnId);\n },\n [propsRef, selectColumn, onSelectionClear],\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 defaultColumn: Partial> = React.useMemo(\n () => ({\n // Note: cell is rendered directly in DataGridRow to bypass flexRender's\n // unstable cell.getContext() (see TanStack Table issue #4794)\n minSize: MIN_COLUMN_SIZE,\n maxSize: MAX_COLUMN_SIZE,\n }),\n [],\n );\n\n const tableMeta = React.useMemo>(() => {\n return {\n ...propsRef.current.meta,\n dataGridRef,\n cellMapRef,\n get focusedCell() {\n return store.getState().focusedCell;\n },\n get editingCell() {\n return store.getState().editingCell;\n },\n get selectionState() {\n return store.getState().selectionState;\n },\n get searchOpen() {\n return store.getState().searchOpen;\n },\n get contextMenu() {\n return store.getState().contextMenu;\n },\n get pasteDialog() {\n return store.getState().pasteDialog;\n },\n get rowHeight() {\n return store.getState().rowHeight;\n },\n get readOnly() {\n return propsRef.current.readOnly;\n },\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n getVisualRowIndex,\n onRowHeightChange,\n onRowSelect,\n onDataUpdate,\n onRowsDelete: propsRef.current.onRowsDelete ? onRowsDelete : undefined,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n onCellsCopy,\n onCellsCut,\n onCellsPaste,\n onSelectionClear,\n onFilesUpload: propsRef.current.onFilesUpload\n ? propsRef.current.onFilesUpload\n : undefined,\n onFilesDelete: propsRef.current.onFilesDelete\n ? propsRef.current.onFilesDelete\n : undefined,\n onContextMenuOpenChange,\n onPasteDialogOpenChange,\n };\n }, [\n propsRef,\n store,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n getVisualRowIndex,\n onRowHeightChange,\n onRowSelect,\n onDataUpdate,\n onRowsDelete,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n onCellsCopy,\n onCellsCut,\n onCellsPaste,\n onSelectionClear,\n onContextMenuOpenChange,\n onPasteDialogOpenChange,\n ]);\n\n const getMemoizedCoreRowModel = React.useMemo(() => getCoreRowModel(), []);\n const getMemoizedFilteredRowModel = React.useMemo(\n () => getFilteredRowModel(),\n [],\n );\n const getMemoizedSortedRowModel = React.useMemo(\n () => getSortedRowModel(),\n [],\n );\n\n // Memoize state object to reduce shallow equality checks\n const tableState = React.useMemo>(\n () => ({\n ...propsRef.current.state,\n sorting,\n columnFilters,\n rowSelection,\n }),\n [propsRef, sorting, columnFilters, rowSelection],\n );\n\n const tableOptions = React.useMemo>(() => {\n return {\n ...propsRef.current,\n data,\n columns,\n defaultColumn,\n initialState: propsRef.current.initialState,\n state: tableState,\n onRowSelectionChange,\n onSortingChange,\n onColumnFiltersChange,\n columnResizeMode: \"onChange\",\n columnResizeDirection: dir,\n getCoreRowModel: getMemoizedCoreRowModel,\n getFilteredRowModel: getMemoizedFilteredRowModel,\n getSortedRowModel: getMemoizedSortedRowModel,\n meta: tableMeta,\n };\n }, [\n propsRef,\n data,\n columns,\n defaultColumn,\n tableState,\n dir,\n onRowSelectionChange,\n onSortingChange,\n onColumnFiltersChange,\n getMemoizedCoreRowModel,\n getMemoizedFilteredRowModel,\n getMemoizedSortedRowModel,\n tableMeta,\n ]);\n\n const table = useReactTable(tableOptions);\n\n if (!tableRef.current) {\n tableRef.current = table;\n }\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: columnSizingInfo and columnSizing are used for calculating 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 isFirefox = React.useSyncExternalStore(\n React.useCallback(() => () => {}, []),\n React.useCallback(() => {\n if (typeof window === \"undefined\" || typeof navigator === \"undefined\") {\n return false;\n }\n return navigator.userAgent.indexOf(\"Firefox\") !== -1;\n }, []),\n React.useCallback(() => false, []),\n );\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: columnPinning is used for calculating the adjustLayout\n const adjustLayout = React.useMemo(() => {\n const columnPinning = table.getState().columnPinning;\n return (\n isFirefox &&\n ((columnPinning.left?.length ?? 0) > 0 ||\n (columnPinning.right?.length ?? 0) > 0)\n );\n }, [isFirefox, table.getState().columnPinning]);\n\n const rowVirtualizer = useVirtualizer({\n count: table.getRowModel().rows.length,\n getScrollElement: () => dataGridRef.current,\n estimateSize: () => rowHeightValue,\n overscan,\n measureElement: !isFirefox\n ? (element) => element?.getBoundingClientRect().height\n : undefined,\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 focusGuardRef.current = true;\n\n const navigableIds = propsRef.current.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 .filter((c) => !NON_NAVIGABLE_COLUMN_IDS.has(c));\n\n const targetColumnId = columnId ?? navigableIds[0];\n\n if (!targetColumnId) {\n releaseFocusGuard(true);\n return;\n }\n\n async function onScrollAndFocus(retryCount: number) {\n if (!targetColumnId) return;\n const currentRowCount = propsRef.current.data.length;\n\n // If the requested row doesn't exist yet, wait for data to update\n if (rowIndex >= currentRowCount && retryCount > 0) {\n await new Promise((resolve) => setTimeout(resolve, 50));\n await onScrollAndFocus(retryCount - 1);\n return;\n }\n\n const safeRowIndex = Math.min(\n rowIndex,\n Math.max(0, currentRowCount - 1),\n );\n\n const isBottomHalf = safeRowIndex > currentRowCount / 2;\n rowVirtualizer.scrollToIndex(safeRowIndex, {\n align: isBottomHalf ? \"end\" : \"start\",\n });\n\n await new Promise((resolve) => requestAnimationFrame(resolve));\n\n // Adjust scroll position to account for sticky header/footer\n const container = dataGridRef.current;\n const targetRow = rowMapRef.current.get(safeRowIndex);\n\n if (container && targetRow) {\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 const rowRect = targetRow.getBoundingClientRect();\n const isFullyVisible =\n rowRect.top >= viewportTop && rowRect.bottom <= viewportBottom;\n\n if (!isFullyVisible) {\n if (rowRect.top < viewportTop) {\n // Row is partially hidden by header - scroll up\n container.scrollTop -= viewportTop - rowRect.top;\n } else if (rowRect.bottom > viewportBottom) {\n // Row is partially hidden by footer - scroll down\n container.scrollTop += rowRect.bottom - viewportBottom;\n }\n }\n }\n\n store.batch(() => {\n store.setState(\"focusedCell\", {\n rowIndex: safeRowIndex,\n columnId: targetColumnId,\n });\n store.setState(\"editingCell\", null);\n });\n\n const cellKey = getCellKey(safeRowIndex, targetColumnId);\n const cellElement = cellMapRef.current.get(cellKey);\n\n if (cellElement) {\n cellElement.focus();\n releaseFocusGuard();\n } else if (retryCount > 0) {\n await new Promise((resolve) => requestAnimationFrame(resolve));\n await onScrollAndFocus(retryCount - 1);\n } else {\n dataGridRef.current?.focus();\n releaseFocusGuard();\n }\n }\n\n await onScrollAndFocus(SCROLL_SYNC_RETRY_COUNT);\n },\n [rowVirtualizer, propsRef, store, releaseFocusGuard],\n );\n\n const onRowAdd = React.useCallback(\n async (event?: React.MouseEvent) => {\n if (propsRef.current.readOnly || !propsRef.current.onRowAdd) return;\n\n const initialRowCount = propsRef.current.data.length;\n\n let result: Partial | null;\n try {\n result = await propsRef.current.onRowAdd(event);\n } catch {\n // Callback threw an error, don't proceed with scroll/focus\n return;\n }\n\n if (result === null || event?.defaultPrevented) return;\n\n onSelectionClear();\n\n // Trust the returned rowIndex from the callback\n // onScrollToRow will handle retries if the row isn't rendered yet\n const targetRowIndex = result.rowIndex ?? initialRowCount;\n const targetColumnId = result.columnId;\n\n onScrollToRow({\n rowIndex: targetRowIndex,\n columnId: targetColumnId,\n });\n },\n [propsRef, onScrollToRow, onSelectionClear],\n );\n\n const onDataGridKeyDown = React.useCallback(\n (event: KeyboardEvent) => {\n const currentState = store.getState();\n const { key, ctrlKey, metaKey, shiftKey, altKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n if (\n propsRef.current.enableSearch &&\n isCtrlPressed &&\n !shiftKey &&\n key === SEARCH_SHORTCUT_KEY\n ) {\n event.preventDefault();\n onSearchOpenChange(true);\n return;\n }\n\n if (\n propsRef.current.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 return;\n }\n\n // Cell editing keyboard events (Enter, Tab, Escape) are handled by the cell variants\n // to ensure proper value commitment before navigation\n if (currentState.editingCell) return;\n\n if (\n isCtrlPressed &&\n (key === \"Backspace\" || key === \"Delete\") &&\n !propsRef.current.readOnly &&\n propsRef.current.onRowsDelete\n ) {\n const rowIndices = new Set();\n\n const selectedRowIds = Object.keys(currentState.rowSelection);\n if (selectedRowIds.length > 0) {\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n for (const row of rows) {\n if (currentState.rowSelection[row.id]) {\n rowIndices.add(row.index);\n }\n }\n } else if (currentState.selectionState.selectedCells.size > 0) {\n for (const cellKey of currentState.selectionState.selectedCells) {\n const { rowIndex } = parseCellKey(cellKey);\n rowIndices.add(rowIndex);\n }\n } else if (currentState.focusedCell) {\n rowIndices.add(currentState.focusedCell.rowIndex);\n }\n\n if (rowIndices.size > 0) {\n event.preventDefault();\n onRowsDelete(Array.from(rowIndices));\n }\n return;\n }\n\n if (!currentState.focusedCell) return;\n\n let direction: NavigationDirection | null = null;\n\n if (isCtrlPressed && !shiftKey && key === \"a\") {\n event.preventDefault();\n selectAll();\n return;\n }\n\n if (isCtrlPressed && !shiftKey && key === \"c\") {\n event.preventDefault();\n onCellsCopy();\n return;\n }\n\n if (\n isCtrlPressed &&\n !shiftKey &&\n key === \"x\" &&\n !propsRef.current.readOnly\n ) {\n event.preventDefault();\n onCellsCut();\n return;\n }\n\n if (\n propsRef.current.enablePaste &&\n isCtrlPressed &&\n !shiftKey &&\n key === \"v\" &&\n !propsRef.current.readOnly\n ) {\n event.preventDefault();\n onCellsPaste();\n return;\n }\n\n if (\n (key === \"Delete\" || key === \"Backspace\") &&\n !isCtrlPressed &&\n !propsRef.current.readOnly\n ) {\n const cellsToClear =\n currentState.selectionState.selectedCells.size > 0\n ? Array.from(currentState.selectionState.selectedCells)\n : currentState.focusedCell\n ? [\n getCellKey(\n currentState.focusedCell.rowIndex,\n currentState.focusedCell.columnId,\n ),\n ]\n : [];\n\n if (cellsToClear.length > 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 const columnById = new Map(tableColumns.map((c) => [c.id, c]));\n\n for (const cellKey of cellsToClear) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n const column = columnById.get(columnId);\n const cellVariant = column?.columnDef?.meta?.cell?.variant;\n const emptyValue = getEmptyCellValue(cellVariant);\n updates.push({ rowIndex, columnId, value: emptyValue });\n }\n\n onDataUpdate(updates);\n\n if (currentState.selectionState.selectedCells.size > 0) {\n onSelectionClear();\n }\n\n if (currentState.cutCells.size > 0) {\n store.setState(\"cutCells\", new Set());\n }\n }\n return;\n }\n\n if (\n key === \"Enter\" &&\n shiftKey &&\n !propsRef.current.readOnly &&\n propsRef.current.onRowAdd\n ) {\n event.preventDefault();\n const initialRowCount = propsRef.current.data.length;\n const currentColumnId = currentState.focusedCell.columnId;\n\n Promise.resolve(propsRef.current.onRowAdd())\n .then(async (result) => {\n if (result === null) return;\n\n onSelectionClear();\n\n const targetRowIndex = result.rowIndex ?? initialRowCount;\n const targetColumnId = result.columnId ?? currentColumnId;\n\n onScrollToRow({\n rowIndex: targetRowIndex,\n columnId: targetColumnId,\n });\n })\n .catch(() => {\n // Callback threw an error, don't proceed with scroll/focus\n });\n return;\n }\n\n switch (key) {\n case \"ArrowUp\":\n if (altKey && !isCtrlPressed && !shiftKey) {\n direction = \"pageup\";\n } else if (isCtrlPressed && shiftKey) {\n const selectionEdge =\n currentState.selectionState.selectionRange?.end ||\n currentState.focusedCell;\n const currentColIndex = navigableColumnIds.indexOf(\n selectionEdge.columnId,\n );\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n\n selectRange(selectionStart, {\n rowIndex: 0,\n columnId:\n navigableColumnIds[currentColIndex] ?? selectionEdge.columnId,\n });\n\n const rowVirtualizer = rowVirtualizerRef.current;\n if (rowVirtualizer) {\n rowVirtualizer.scrollToIndex(0, { align: \"start\" });\n }\n\n restoreFocus(dataGridRef.current);\n\n event.preventDefault();\n return;\n } else if (isCtrlPressed && !shiftKey) {\n direction = \"ctrl+up\";\n } else {\n direction = \"up\";\n }\n break;\n case \"ArrowDown\":\n if (altKey && !isCtrlPressed && !shiftKey) {\n direction = \"pagedown\";\n } else if (isCtrlPressed && shiftKey) {\n const rowCount =\n tableRef.current?.getRowModel().rows.length ||\n propsRef.current.data.length;\n const selectionEdge =\n currentState.selectionState.selectionRange?.end ||\n currentState.focusedCell;\n const currentColIndex = navigableColumnIds.indexOf(\n selectionEdge.columnId,\n );\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n\n selectRange(selectionStart, {\n rowIndex: Math.max(0, rowCount - 1),\n columnId:\n navigableColumnIds[currentColIndex] ?? selectionEdge.columnId,\n });\n\n const rowVirtualizer = rowVirtualizerRef.current;\n if (rowVirtualizer) {\n rowVirtualizer.scrollToIndex(Math.max(0, rowCount - 1), {\n align: \"end\",\n });\n }\n\n restoreFocus(dataGridRef.current);\n\n event.preventDefault();\n return;\n } else if (isCtrlPressed && !shiftKey) {\n direction = \"ctrl+down\";\n } else {\n direction = \"down\";\n }\n break;\n case \"ArrowLeft\":\n if (isCtrlPressed && shiftKey) {\n const selectionEdge =\n currentState.selectionState.selectionRange?.end ||\n currentState.focusedCell;\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n const targetColumnId =\n dir === \"rtl\"\n ? navigableColumnIds[navigableColumnIds.length - 1]\n : navigableColumnIds[0];\n\n if (targetColumnId) {\n selectRange(selectionStart, {\n rowIndex: selectionEdge.rowIndex,\n columnId: targetColumnId,\n });\n\n const container = dataGridRef.current;\n const cellKey = getCellKey(\n selectionEdge.rowIndex,\n targetColumnId,\n );\n const targetCell = cellMapRef.current.get(cellKey);\n if (container && targetCell) {\n scrollCellIntoView({\n container,\n targetCell,\n tableRef,\n viewportOffset: VIEWPORT_OFFSET,\n direction: \"home\",\n isRtl: dir === \"rtl\",\n });\n }\n\n restoreFocus(container);\n }\n event.preventDefault();\n return;\n } else if (isCtrlPressed && !shiftKey) {\n direction = \"home\";\n } else {\n direction = \"left\";\n }\n break;\n case \"ArrowRight\":\n if (isCtrlPressed && shiftKey) {\n const selectionEdge =\n currentState.selectionState.selectionRange?.end ||\n currentState.focusedCell;\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n const targetColumnId =\n dir === \"rtl\"\n ? navigableColumnIds[0]\n : navigableColumnIds[navigableColumnIds.length - 1];\n\n if (targetColumnId) {\n selectRange(selectionStart, {\n rowIndex: selectionEdge.rowIndex,\n columnId: targetColumnId,\n });\n\n const container = dataGridRef.current;\n const cellKey = getCellKey(\n selectionEdge.rowIndex,\n targetColumnId,\n );\n const targetCell = cellMapRef.current.get(cellKey);\n if (container && targetCell) {\n scrollCellIntoView({\n container,\n targetCell,\n tableRef,\n viewportOffset: VIEWPORT_OFFSET,\n direction: \"end\",\n isRtl: dir === \"rtl\",\n });\n }\n\n restoreFocus(container);\n }\n event.preventDefault();\n return;\n } else if (isCtrlPressed && !shiftKey) {\n direction = \"end\";\n } else {\n direction = \"right\";\n }\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 = altKey ? \"pageleft\" : \"pageup\";\n break;\n case \"PageDown\":\n direction = altKey ? \"pageright\" : \"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 onSelectionClear();\n } else {\n blurCell();\n }\n return;\n case \"Tab\":\n event.preventDefault();\n if (dir === \"rtl\") {\n direction = event.shiftKey ? \"right\" : \"left\";\n } else {\n direction = event.shiftKey ? \"left\" : \"right\";\n }\n break;\n }\n\n if (direction) {\n event.preventDefault();\n\n if (shiftKey && key !== \"Tab\" && currentState.focusedCell) {\n const selectionEdge =\n currentState.selectionState.selectionRange?.end ||\n currentState.focusedCell;\n\n const currentColIndex = navigableColumnIds.indexOf(\n selectionEdge.columnId,\n );\n let newRowIndex = selectionEdge.rowIndex;\n let newColumnId = selectionEdge.columnId;\n\n const isRtl = dir === \"rtl\";\n\n const rowCount =\n tableRef.current?.getRowModel().rows.length ||\n propsRef.current.data.length;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, selectionEdge.rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(rowCount - 1, selectionEdge.rowIndex + 1);\n break;\n case \"left\":\n if (isRtl) {\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n } else {\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n }\n break;\n case \"right\":\n if (isRtl) {\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n } else {\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n }\n break;\n case \"home\":\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? newColumnId;\n }\n break;\n case \"end\":\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ??\n newColumnId;\n }\n break;\n }\n\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n\n selectRange(selectionStart, {\n rowIndex: newRowIndex,\n columnId: newColumnId,\n });\n\n const container = dataGridRef.current;\n const targetRow = rowMapRef.current.get(newRowIndex);\n const cellKey = getCellKey(newRowIndex, newColumnId);\n const targetCell = cellMapRef.current.get(cellKey);\n\n if (\n newRowIndex !== selectionEdge.rowIndex &&\n (direction === \"up\" || direction === \"down\")\n ) {\n if (container && targetRow) {\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 const rowRect = targetRow.getBoundingClientRect();\n const isFullyVisible =\n rowRect.top >= viewportTop && rowRect.bottom <= viewportBottom;\n\n if (!isFullyVisible) {\n const scrollNeeded =\n direction === \"down\"\n ? rowRect.bottom - viewportBottom\n : viewportTop - rowRect.top;\n\n if (direction === \"down\") {\n container.scrollTop += scrollNeeded;\n } else {\n container.scrollTop -= scrollNeeded;\n }\n\n restoreFocus(container);\n }\n } else {\n const rowVirtualizer = rowVirtualizerRef.current;\n if (rowVirtualizer) {\n const align = direction === \"up\" ? \"start\" : \"end\";\n rowVirtualizer.scrollToIndex(newRowIndex, { align });\n\n restoreFocus(container);\n }\n }\n }\n\n if (\n newColumnId !== selectionEdge.columnId &&\n (direction === \"left\" ||\n direction === \"right\" ||\n direction === \"home\" ||\n direction === \"end\")\n ) {\n if (container && targetCell) {\n scrollCellIntoView({\n container,\n targetCell,\n tableRef,\n viewportOffset: VIEWPORT_OFFSET,\n direction,\n isRtl,\n });\n }\n }\n } else {\n if (currentState.selectionState.selectedCells.size > 0) {\n onSelectionClear();\n }\n navigateCell(direction);\n }\n }\n },\n [\n dir,\n store,\n propsRef,\n blurCell,\n navigateCell,\n selectAll,\n onCellsCopy,\n onCellsCut,\n onCellsPaste,\n onDataUpdate,\n onSelectionClear,\n navigableColumnIds,\n selectRange,\n onSearchOpenChange,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n onRowsDelete,\n restoreFocus,\n onScrollToRow,\n ],\n );\n\n const searchState = React.useMemo(() => {\n if (!propsRef.current.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 propsRef,\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, shiftKey } = event;\n const isCommandPressed = ctrlKey || metaKey;\n\n if (\n propsRef.current.enableSearch &&\n isCommandPressed &&\n !shiftKey &&\n key === SEARCH_SHORTCUT_KEY\n ) {\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\n const nextSearchOpen = !store.getState().searchOpen;\n onSearchOpenChange(nextSearchOpen);\n\n if (nextSearchOpen && !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 onSelectionClear();\n }\n }\n }\n\n window.addEventListener(\"keydown\", onGlobalKeyDown, true);\n return () => {\n window.removeEventListener(\"keydown\", onGlobalKeyDown, true);\n };\n }, [propsRef, onSearchOpenChange, store, onSelectionClear]);\n\n React.useEffect(() => {\n const currentState = store.getState();\n const autoFocus = propsRef.current.autoFocus;\n\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 }, [store, propsRef, data, columns, navigableColumnIds, focusCell]);\n\n // Restore focus to container when virtualized cells are unmounted\n React.useEffect(() => {\n const container = dataGridRef.current;\n if (!container) return;\n\n function onFocusOut(event: FocusEvent) {\n if (focusGuardRef.current) return;\n\n const currentContainer = dataGridRef.current;\n if (!currentContainer) return;\n\n const currentState = store.getState();\n\n if (!currentState.focusedCell || currentState.editingCell) return;\n\n const relatedTarget = event.relatedTarget;\n\n const isFocusMovingOutsideGrid =\n !relatedTarget || !currentContainer.contains(relatedTarget as Node);\n\n const isFocusMovingToPopover = getIsInPopover(relatedTarget);\n\n if (isFocusMovingOutsideGrid && !isFocusMovingToPopover) {\n const { rowIndex, columnId } = currentState.focusedCell;\n const cellKey = getCellKey(rowIndex, columnId);\n const cellElement = cellMapRef.current.get(cellKey);\n\n requestAnimationFrame(() => {\n if (focusGuardRef.current) return;\n\n if (cellElement && document.body.contains(cellElement)) {\n cellElement.focus();\n } else {\n currentContainer.focus();\n }\n });\n }\n }\n\n container.addEventListener(\"focusout\", onFocusOut);\n\n return () => {\n container.removeEventListener(\"focusout\", onFocusOut);\n };\n }, [store]);\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 elements = document.elementsFromPoint(\n event.clientX,\n event.clientY,\n );\n\n // Compensate for event.target bubbling up\n const isInsidePopover = elements.some((element) =>\n getIsInPopover(element),\n );\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 onSelectionClear();\n }\n }\n }\n }\n\n document.addEventListener(\"mousedown\", onOutsideClick);\n return () => {\n document.removeEventListener(\"mousedown\", onOutsideClick);\n };\n }, [store, blurCell, onSelectionClear]);\n\n React.useEffect(() => {\n function onSelectStart(event: Event) {\n event.preventDefault();\n }\n\n function onContextMenu(event: Event) {\n event.preventDefault();\n }\n\n function onCleanup() {\n document.removeEventListener(\"selectstart\", onSelectStart);\n document.removeEventListener(\"contextmenu\", onContextMenu);\n document.body.style.userSelect = \"\";\n }\n\n const onUnsubscribe = store.subscribe(() => {\n const currentState = store.getState();\n if (currentState.selectionState.isSelecting) {\n document.addEventListener(\"selectstart\", onSelectStart);\n document.addEventListener(\"contextmenu\", onContextMenu);\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 rowHeight,\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 ]);\n\n // Calculate virtual values outside of child render to avoid flushSync issues\n const virtualTotalSize = rowVirtualizer.getTotalSize();\n const virtualItems = rowVirtualizer.getVirtualItems();\n const measureElement = rowVirtualizer.measureElement;\n\n return React.useMemo(\n () => ({\n dataGridRef,\n headerRef,\n rowMapRef,\n footerRef,\n dir,\n table,\n tableMeta,\n virtualTotalSize,\n virtualItems,\n measureElement,\n columns,\n columnSizeVars,\n searchState,\n searchMatchesByRow,\n activeSearchMatch,\n cellSelectionMap,\n focusedCell,\n editingCell,\n rowHeight,\n contextMenu,\n pasteDialog,\n onRowAdd: propsRef.current.onRowAdd ? onRowAdd : undefined,\n adjustLayout,\n }),\n [\n propsRef,\n dir,\n table,\n tableMeta,\n virtualTotalSize,\n virtualItems,\n measureElement,\n columns,\n columnSizeVars,\n searchState,\n searchMatchesByRow,\n activeSearchMatch,\n cellSelectionMap,\n focusedCell,\n editingCell,\n rowHeight,\n contextMenu,\n pasteDialog,\n onRowAdd,\n adjustLayout,\n ],\n );\n}\n\nexport {\n useDataGrid,\n //\n type UseDataGridProps,\n};\n", "type": "registry:hook" }, { @@ -123,7 +123,7 @@ }, { "path": "src/lib/data-grid.ts", - "content": "import type { Column, Table } from \"@tanstack/react-table\";\nimport {\n BaselineIcon,\n CalendarIcon,\n CheckSquareIcon,\n File,\n FileArchive,\n FileAudio,\n FileIcon,\n FileImage,\n FileSpreadsheet,\n FileText,\n FileVideo,\n HashIcon,\n LinkIcon,\n ListChecksIcon,\n ListIcon,\n Presentation,\n TextInitialIcon,\n} from \"lucide-react\";\nimport type * as React from \"react\";\nimport type {\n CellOpts,\n CellPosition,\n Direction,\n FileCellData,\n RowHeightValue,\n} from \"@/types/data-grid\";\n\nexport function flexRender(\n Comp: ((props: TProps) => React.ReactNode) | string | undefined,\n props: TProps,\n): React.ReactNode {\n if (typeof Comp === \"string\") {\n return Comp;\n }\n return Comp?.(props);\n}\n\nexport function getIsFileCellData(item: unknown): item is FileCellData {\n return (\n !!item &&\n typeof item === \"object\" &&\n \"id\" in item &&\n \"name\" in item &&\n \"size\" in item &&\n \"type\" in item\n );\n}\n\nexport function matchSelectOption(\n value: string,\n options: { value: string; label: string }[],\n): string | undefined {\n return options.find(\n (o) =>\n o.value === value ||\n o.value.toLowerCase() === value.toLowerCase() ||\n o.label.toLowerCase() === value.toLowerCase(),\n )?.value;\n}\n\nexport function getCellKey(rowIndex: number, columnId: string) {\n return `${rowIndex}:${columnId}`;\n}\n\nexport function parseCellKey(cellKey: string): Required {\n const parts = cellKey.split(\":\");\n const rowIndexStr = parts[0];\n const columnId = parts[1];\n if (rowIndexStr && columnId) {\n const rowIndex = parseInt(rowIndexStr, 10);\n if (!Number.isNaN(rowIndex)) {\n return { rowIndex, columnId };\n }\n }\n return { rowIndex: 0, columnId: \"\" };\n}\n\nexport function getRowHeightValue(rowHeight: RowHeightValue): number {\n const rowHeightMap: Record = {\n short: 36,\n medium: 56,\n tall: 76,\n \"extra-tall\": 96,\n };\n\n return rowHeightMap[rowHeight];\n}\n\nexport function getLineCount(rowHeight: RowHeightValue): number {\n const lineCountMap: Record = {\n short: 1,\n medium: 2,\n tall: 3,\n \"extra-tall\": 4,\n };\n\n return lineCountMap[rowHeight];\n}\n\nexport function getColumnBorderVisibility(params: {\n column: Column;\n nextColumn?: Column;\n isLastColumn: boolean;\n}): {\n showEndBorder: boolean;\n showStartBorder: boolean;\n} {\n const { column, nextColumn, isLastColumn } = params;\n\n const isPinned = column.getIsPinned();\n const isFirstRightPinnedColumn =\n isPinned === \"right\" && column.getIsFirstColumn(\"right\");\n const isLastRightPinnedColumn =\n isPinned === \"right\" && column.getIsLastColumn(\"right\");\n\n const nextIsPinned = nextColumn?.getIsPinned();\n const isBeforeRightPinned =\n nextIsPinned === \"right\" && nextColumn?.getIsFirstColumn(\"right\");\n\n const showEndBorder =\n !isBeforeRightPinned && (isLastColumn || !isLastRightPinnedColumn);\n\n const showStartBorder = isFirstRightPinnedColumn;\n\n return {\n showEndBorder,\n showStartBorder,\n };\n}\n\nexport function getColumnPinningStyle(params: {\n column: Column;\n withBorder?: boolean;\n dir?: Direction;\n}): React.CSSProperties {\n const { column, dir = \"ltr\", withBorder = false } = params;\n\n const isPinned = column.getIsPinned();\n const isLastLeftPinnedColumn =\n isPinned === \"left\" && column.getIsLastColumn(\"left\");\n const isFirstRightPinnedColumn =\n isPinned === \"right\" && column.getIsFirstColumn(\"right\");\n\n const isRtl = dir === \"rtl\";\n\n const leftPosition =\n isPinned === \"left\" ? `${column.getStart(\"left\")}px` : undefined;\n const rightPosition =\n isPinned === \"right\" ? `${column.getAfter(\"right\")}px` : undefined;\n\n return {\n boxShadow: withBorder\n ? isLastLeftPinnedColumn\n ? isRtl\n ? \"4px 0 4px -4px var(--border) inset\"\n : \"-4px 0 4px -4px var(--border) inset\"\n : isFirstRightPinnedColumn\n ? isRtl\n ? \"-4px 0 4px -4px var(--border) inset\"\n : \"4px 0 4px -4px var(--border) inset\"\n : undefined\n : undefined,\n left: isRtl ? rightPosition : leftPosition,\n right: isRtl ? leftPosition : rightPosition,\n opacity: isPinned ? 0.97 : 1,\n position: isPinned ? \"sticky\" : \"relative\",\n background: isPinned ? \"var(--background)\" : \"var(--background)\",\n width: column.getSize(),\n zIndex: isPinned ? 1 : undefined,\n };\n}\n\nexport function getScrollDirection(\n direction: string,\n): \"left\" | \"right\" | \"home\" | \"end\" | undefined {\n if (\n direction === \"left\" ||\n direction === \"right\" ||\n direction === \"home\" ||\n direction === \"end\"\n ) {\n return direction as \"left\" | \"right\" | \"home\" | \"end\";\n }\n if (direction === \"pageleft\") return \"left\";\n if (direction === \"pageright\") return \"right\";\n return undefined;\n}\n\nexport function scrollCellIntoView(params: {\n container: HTMLDivElement;\n targetCell: HTMLDivElement;\n tableRef: React.RefObject | null>;\n viewportOffset: number;\n direction?: \"left\" | \"right\" | \"home\" | \"end\";\n isRtl: boolean;\n}): void {\n const { container, targetCell, tableRef, direction, viewportOffset, isRtl } =\n params;\n\n const containerRect = container.getBoundingClientRect();\n const cellRect = targetCell.getBoundingClientRect();\n\n const hasNegativeScroll = container.scrollLeft < 0;\n const isActuallyRtl = isRtl || hasNegativeScroll;\n\n const currentTable = tableRef.current;\n const leftPinnedColumns = currentTable?.getLeftVisibleLeafColumns() ?? [];\n const rightPinnedColumns = currentTable?.getRightVisibleLeafColumns() ?? [];\n\n const leftPinnedWidth = leftPinnedColumns.reduce(\n (sum, c) => sum + c.getSize(),\n 0,\n );\n const rightPinnedWidth = rightPinnedColumns.reduce(\n (sum, c) => sum + c.getSize(),\n 0,\n );\n\n const viewportLeft = isActuallyRtl\n ? containerRect.left + rightPinnedWidth + viewportOffset\n : containerRect.left + leftPinnedWidth + viewportOffset;\n const viewportRight = isActuallyRtl\n ? containerRect.right - leftPinnedWidth - viewportOffset\n : containerRect.right - rightPinnedWidth - viewportOffset;\n\n const isFullyVisible =\n cellRect.left >= viewportLeft && cellRect.right <= viewportRight;\n\n if (isFullyVisible) return;\n\n const isClippedLeft = cellRect.left < viewportLeft;\n const isClippedRight = cellRect.right > viewportRight;\n\n let scrollDelta = 0;\n\n if (!direction) {\n if (isClippedRight) {\n scrollDelta = cellRect.right - viewportRight;\n } else if (isClippedLeft) {\n scrollDelta = -(viewportLeft - cellRect.left);\n }\n } else {\n const shouldScrollRight = isActuallyRtl\n ? direction === \"right\" || direction === \"home\"\n : direction === \"right\" || direction === \"end\";\n\n if (shouldScrollRight) {\n scrollDelta = cellRect.right - viewportRight;\n } else {\n scrollDelta = -(viewportLeft - cellRect.left);\n }\n }\n\n container.scrollLeft += scrollDelta;\n}\n\nfunction countTabs(s: string): number {\n let n = 0;\n for (let i = 0; i < s.length; i++) if (s[i] === \"\\t\") n++;\n return n;\n}\n\nexport function parseTsv(\n text: string,\n fallbackColumnCount: number,\n): string[][] {\n const lines = text.split(\"\\n\");\n let maxTabs = 0;\n for (const line of lines) {\n const n = countTabs(line);\n if (n > maxTabs) maxTabs = n;\n }\n const cols = maxTabs > 0 ? maxTabs + 1 : fallbackColumnCount;\n if (cols <= 0) return [];\n\n const rows: string[][] = [];\n let buf = \"\";\n\n for (const line of lines) {\n const tc = countTabs(line);\n\n if (tc === cols - 1) {\n if (buf && countTabs(buf) === cols - 1) rows.push(buf.split(\"\\t\"));\n buf = \"\";\n rows.push(line.split(\"\\t\"));\n } else if (tc === 0 && rows.length > 0 && !buf) {\n const last = rows[rows.length - 1];\n if (last) {\n const cell = last[cols - 1];\n if (cell !== undefined) last[cols - 1] = `${cell}\\n${line}`;\n }\n } else {\n buf = buf ? `${buf}\\n${line}` : line;\n }\n }\n\n if (buf && countTabs(buf) === cols - 1) rows.push(buf.split(\"\\t\"));\n\n return rows.length > 0\n ? rows\n : lines.filter((l) => l.length > 0).map((l) => l.split(\"\\t\"));\n}\n\nexport function getIsInPopover(element: unknown): boolean {\n if (!(element instanceof Element)) return false;\n\n return (\n element.closest(\"[data-grid-cell-editor]\") !== null ||\n element.closest(\"[data-grid-popover]\") !== null ||\n element.closest(\"[data-slot='dropdown-menu-content']\") !== null ||\n element.closest(\"[data-slot='popover-content']\") !== null\n );\n}\n\nexport function getColumnVariant(variant?: CellOpts[\"variant\"]): {\n icon: React.ComponentType>;\n label: string;\n} | null {\n switch (variant) {\n case \"short-text\":\n return { label: \"Short text\", icon: BaselineIcon };\n case \"long-text\":\n return { label: \"Long text\", icon: TextInitialIcon };\n case \"number\":\n return { label: \"Number\", icon: HashIcon };\n case \"url\":\n return { label: \"URL\", icon: LinkIcon };\n case \"checkbox\":\n return { label: \"Checkbox\", icon: CheckSquareIcon };\n case \"select\":\n return { label: \"Select\", icon: ListIcon };\n case \"multi-select\":\n return { label: \"Multi-select\", icon: ListChecksIcon };\n case \"date\":\n return { label: \"Date\", icon: CalendarIcon };\n case \"file\":\n return { label: \"File\", icon: FileIcon };\n default:\n return null;\n }\n}\n\nexport function getEmptyCellValue(\n variant: CellOpts[\"variant\"] | undefined,\n): unknown {\n if (variant === \"multi-select\" || variant === \"file\") return [];\n if (variant === \"number\" || variant === \"date\") return null;\n if (variant === \"checkbox\") return false;\n return \"\";\n}\n\nexport function 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 if (trimmed.startsWith(\"http://\") || trimmed.startsWith(\"https://\")) {\n return trimmed;\n }\n\n return `http://${trimmed}`;\n}\n\nexport function parseLocalDate(dateStr: unknown): Date | null {\n if (!dateStr) return null;\n if (dateStr instanceof Date) return dateStr;\n if (typeof dateStr !== \"string\") return null;\n const [year, month, day] = dateStr.split(\"-\").map(Number);\n if (!year || !month || !day) return null;\n const date = new Date(year, month - 1, day);\n // Verify date wasn't auto-corrected (e.g. Feb 30 -> Mar 1)\n if (\n date.getFullYear() !== year ||\n date.getMonth() !== month - 1 ||\n date.getDate() !== day\n ) {\n return null;\n }\n return date;\n}\n\nexport function formatDateToString(date: Date): string {\n const year = date.getFullYear();\n const month = String(date.getMonth() + 1).padStart(2, \"0\");\n const day = String(date.getDate()).padStart(2, \"0\");\n return `${year}-${month}-${day}`;\n}\n\nexport function formatDateForDisplay(dateStr: unknown): string {\n if (!dateStr) return \"\";\n const date = parseLocalDate(dateStr);\n if (!date) return typeof dateStr === \"string\" ? dateStr : \"\";\n return date.toLocaleDateString();\n}\n\nexport function formatFileSize(bytes: number): string {\n if (bytes <= 0 || !Number.isFinite(bytes)) return \"0 B\";\n const k = 1024;\n const sizes = [\"B\", \"KB\", \"MB\", \"GB\"];\n const i = Math.min(\n sizes.length - 1,\n Math.floor(Math.log(bytes) / Math.log(k)),\n );\n return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;\n}\n\nexport function 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", + "content": "import type { Column, Table } from \"@tanstack/react-table\";\nimport {\n BaselineIcon,\n CalendarIcon,\n CheckSquareIcon,\n File,\n FileArchive,\n FileAudio,\n FileIcon,\n FileImage,\n FileSpreadsheet,\n FileText,\n FileVideo,\n HashIcon,\n LinkIcon,\n ListChecksIcon,\n ListIcon,\n Presentation,\n TextInitialIcon,\n} from \"lucide-react\";\nimport type * as React from \"react\";\nimport type {\n CellOpts,\n CellPosition,\n Direction,\n FileCellData,\n RowHeightValue,\n} from \"@/types/data-grid\";\n\nexport function flexRender(\n Comp: ((props: TProps) => React.ReactNode) | string | undefined,\n props: TProps,\n): React.ReactNode {\n if (typeof Comp === \"string\") {\n return Comp;\n }\n return Comp?.(props);\n}\n\nexport function getIsFileCellData(item: unknown): item is FileCellData {\n return (\n !!item &&\n typeof item === \"object\" &&\n \"id\" in item &&\n \"name\" in item &&\n \"size\" in item &&\n \"type\" in item\n );\n}\n\nexport function matchSelectOption(\n value: string,\n options: { value: string; label: string }[],\n): string | undefined {\n return options.find(\n (o) =>\n o.value === value ||\n o.value.toLowerCase() === value.toLowerCase() ||\n o.label.toLowerCase() === value.toLowerCase(),\n )?.value;\n}\n\nexport function getCellKey(rowIndex: number, columnId: string) {\n return `${rowIndex}:${columnId}`;\n}\n\nexport function parseCellKey(cellKey: string): Required {\n const parts = cellKey.split(\":\");\n const rowIndexStr = parts[0];\n const columnId = parts[1];\n if (rowIndexStr && columnId) {\n const rowIndex = parseInt(rowIndexStr, 10);\n if (!Number.isNaN(rowIndex)) {\n return { rowIndex, columnId };\n }\n }\n return { rowIndex: 0, columnId: \"\" };\n}\n\nexport function getRowHeightValue(rowHeight: RowHeightValue): number {\n const rowHeightMap: Record = {\n short: 36,\n medium: 56,\n tall: 76,\n \"extra-tall\": 96,\n };\n\n return rowHeightMap[rowHeight];\n}\n\nexport function getLineCount(rowHeight: RowHeightValue): number {\n const lineCountMap: Record = {\n short: 1,\n medium: 2,\n tall: 3,\n \"extra-tall\": 4,\n };\n\n return lineCountMap[rowHeight];\n}\n\nexport function getColumnBorderVisibility(params: {\n column: Column;\n nextColumn?: Column;\n isLastColumn: boolean;\n}): {\n showEndBorder: boolean;\n showStartBorder: boolean;\n} {\n const { column, nextColumn, isLastColumn } = params;\n\n const isPinned = column.getIsPinned();\n const isFirstRightPinnedColumn =\n isPinned === \"right\" && column.getIsFirstColumn(\"right\");\n const isLastRightPinnedColumn =\n isPinned === \"right\" && column.getIsLastColumn(\"right\");\n\n const nextIsPinned = nextColumn?.getIsPinned();\n const isBeforeRightPinned =\n nextIsPinned === \"right\" && nextColumn?.getIsFirstColumn(\"right\");\n\n const showEndBorder =\n !isBeforeRightPinned && (isLastColumn || !isLastRightPinnedColumn);\n\n const showStartBorder = isFirstRightPinnedColumn;\n\n return {\n showEndBorder,\n showStartBorder,\n };\n}\n\nexport function getColumnPinningStyle(params: {\n column: Column;\n withBorder?: boolean;\n dir?: Direction;\n}): React.CSSProperties {\n const { column, dir = \"ltr\", withBorder = false } = params;\n\n const isPinned = column.getIsPinned();\n const isLastLeftPinnedColumn =\n isPinned === \"left\" && column.getIsLastColumn(\"left\");\n const isFirstRightPinnedColumn =\n isPinned === \"right\" && column.getIsFirstColumn(\"right\");\n\n const isRtl = dir === \"rtl\";\n\n const leftPosition =\n isPinned === \"left\" ? `${column.getStart(\"left\")}px` : undefined;\n const rightPosition =\n isPinned === \"right\" ? `${column.getAfter(\"right\")}px` : undefined;\n\n return {\n boxShadow: withBorder\n ? isLastLeftPinnedColumn\n ? isRtl\n ? \"4px 0 4px -4px var(--border) inset\"\n : \"-4px 0 4px -4px var(--border) inset\"\n : isFirstRightPinnedColumn\n ? isRtl\n ? \"-4px 0 4px -4px var(--border) inset\"\n : \"4px 0 4px -4px var(--border) inset\"\n : undefined\n : undefined,\n left: isRtl ? rightPosition : leftPosition,\n right: isRtl ? leftPosition : rightPosition,\n opacity: isPinned ? 0.97 : 1,\n position: isPinned ? \"sticky\" : \"relative\",\n background: isPinned ? \"var(--background)\" : \"var(--background)\",\n width: column.getSize(),\n zIndex: isPinned ? 1 : undefined,\n };\n}\n\nexport function getScrollDirection(\n direction: string,\n): \"left\" | \"right\" | \"home\" | \"end\" | undefined {\n if (\n direction === \"left\" ||\n direction === \"right\" ||\n direction === \"home\" ||\n direction === \"end\"\n ) {\n return direction as \"left\" | \"right\" | \"home\" | \"end\";\n }\n if (direction === \"pageleft\") return \"left\";\n if (direction === \"pageright\") return \"right\";\n return undefined;\n}\n\nexport function scrollCellIntoView(params: {\n container: HTMLDivElement;\n targetCell: HTMLDivElement;\n tableRef: React.RefObject | null>;\n viewportOffset: number;\n direction?: \"left\" | \"right\" | \"home\" | \"end\";\n isRtl: boolean;\n}): void {\n const { container, targetCell, tableRef, direction, viewportOffset, isRtl } =\n params;\n\n const containerRect = container.getBoundingClientRect();\n const cellRect = targetCell.getBoundingClientRect();\n\n const hasNegativeScroll = container.scrollLeft < 0;\n const isActuallyRtl = isRtl || hasNegativeScroll;\n\n const currentTable = tableRef.current;\n const leftPinnedColumns = currentTable?.getLeftVisibleLeafColumns() ?? [];\n const rightPinnedColumns = currentTable?.getRightVisibleLeafColumns() ?? [];\n\n const leftPinnedWidth = leftPinnedColumns.reduce(\n (sum, c) => sum + c.getSize(),\n 0,\n );\n const rightPinnedWidth = rightPinnedColumns.reduce(\n (sum, c) => sum + c.getSize(),\n 0,\n );\n\n const viewportLeft = isActuallyRtl\n ? containerRect.left + rightPinnedWidth + viewportOffset\n : containerRect.left + leftPinnedWidth + viewportOffset;\n const viewportRight = isActuallyRtl\n ? containerRect.right - leftPinnedWidth - viewportOffset\n : containerRect.right - rightPinnedWidth - viewportOffset;\n\n const isFullyVisible =\n cellRect.left >= viewportLeft && cellRect.right <= viewportRight;\n\n if (isFullyVisible) return;\n\n const isClippedLeft = cellRect.left < viewportLeft;\n const isClippedRight = cellRect.right > viewportRight;\n\n let scrollDelta = 0;\n\n if (!direction) {\n if (isClippedRight) {\n scrollDelta = cellRect.right - viewportRight;\n } else if (isClippedLeft) {\n scrollDelta = -(viewportLeft - cellRect.left);\n }\n } else {\n const shouldScrollRight = isActuallyRtl\n ? direction === \"right\" || direction === \"home\"\n : direction === \"right\" || direction === \"end\";\n\n if (shouldScrollRight) {\n scrollDelta = cellRect.right - viewportRight;\n } else {\n scrollDelta = -(viewportLeft - cellRect.left);\n }\n }\n\n container.scrollLeft += scrollDelta;\n}\n\nfunction countTabs(s: string): number {\n let n = 0;\n for (let i = 0; i < s.length; i++) if (s[i] === \"\\t\") n++;\n return n;\n}\n\nexport function parseTsv(\n text: string,\n fallbackColumnCount: number,\n): string[][] {\n if (text.startsWith('\"') || text.includes('\\t\"')) {\n const rows: string[][] = [];\n let currentRow: string[] = [];\n let currentField = \"\";\n let inQuotes = false;\n let i = 0;\n\n while (i < text.length) {\n const char = text[i];\n const nextChar = text[i + 1];\n\n if (inQuotes) {\n if (char === '\"' && nextChar === '\"') {\n currentField += '\"';\n i += 2;\n } else if (char === '\"') {\n inQuotes = false;\n i++;\n } else {\n currentField += char;\n i++;\n }\n } else {\n if (char === '\"' && currentField === \"\") {\n inQuotes = true;\n i++;\n } else if (char === \"\\t\") {\n currentRow.push(currentField);\n currentField = \"\";\n i++;\n } else if (char === \"\\n\") {\n currentRow.push(currentField);\n if (currentRow.length > 1 || currentRow.some((f) => f.length > 0)) {\n rows.push(currentRow);\n }\n currentRow = [];\n currentField = \"\";\n i++;\n } else if (char === \"\\r\" && nextChar === \"\\n\") {\n currentRow.push(currentField);\n if (currentRow.length > 1 || currentRow.some((f) => f.length > 0)) {\n rows.push(currentRow);\n }\n currentRow = [];\n currentField = \"\";\n i += 2;\n } else {\n currentField += char;\n i++;\n }\n }\n }\n\n currentRow.push(currentField);\n if (currentRow.length > 1 || currentRow.some((f) => f.length > 0)) {\n rows.push(currentRow);\n }\n\n return rows;\n }\n\n const lines = text.split(\"\\n\");\n let maxTabs = 0;\n for (const line of lines) {\n const n = countTabs(line);\n if (n > maxTabs) maxTabs = n;\n }\n const cols = maxTabs > 0 ? maxTabs + 1 : fallbackColumnCount;\n if (cols <= 0) return [];\n\n const rows: string[][] = [];\n let buf = \"\";\n let bufTabs = 0;\n\n for (const line of lines) {\n const tc = countTabs(line);\n\n if (tc === cols - 1) {\n if (buf && bufTabs === cols - 1) rows.push(buf.split(\"\\t\"));\n buf = \"\";\n bufTabs = 0;\n rows.push(line.split(\"\\t\"));\n } else {\n buf = buf ? `${buf}\\n${line}` : line;\n bufTabs += tc;\n if (bufTabs === cols - 1) {\n rows.push(buf.split(\"\\t\"));\n buf = \"\";\n bufTabs = 0;\n }\n }\n }\n\n if (buf && bufTabs === cols - 1) rows.push(buf.split(\"\\t\"));\n\n return rows.length > 0\n ? rows\n : lines.filter((l) => l.length > 0).map((l) => l.split(\"\\t\"));\n}\n\nexport function getIsInPopover(element: unknown): boolean {\n if (!(element instanceof Element)) return false;\n\n return (\n element.closest(\"[data-grid-cell-editor]\") !== null ||\n element.closest(\"[data-grid-popover]\") !== null ||\n element.closest(\"[data-slot='dropdown-menu-content']\") !== null ||\n element.closest(\"[data-slot='popover-content']\") !== null\n );\n}\n\nexport function getColumnVariant(variant?: CellOpts[\"variant\"]): {\n icon: React.ComponentType>;\n label: string;\n} | null {\n switch (variant) {\n case \"short-text\":\n return { label: \"Short text\", icon: BaselineIcon };\n case \"long-text\":\n return { label: \"Long text\", icon: TextInitialIcon };\n case \"number\":\n return { label: \"Number\", icon: HashIcon };\n case \"url\":\n return { label: \"URL\", icon: LinkIcon };\n case \"checkbox\":\n return { label: \"Checkbox\", icon: CheckSquareIcon };\n case \"select\":\n return { label: \"Select\", icon: ListIcon };\n case \"multi-select\":\n return { label: \"Multi-select\", icon: ListChecksIcon };\n case \"date\":\n return { label: \"Date\", icon: CalendarIcon };\n case \"file\":\n return { label: \"File\", icon: FileIcon };\n default:\n return null;\n }\n}\n\nexport function getEmptyCellValue(\n variant: CellOpts[\"variant\"] | undefined,\n): unknown {\n if (variant === \"multi-select\" || variant === \"file\") return [];\n if (variant === \"number\" || variant === \"date\") return null;\n if (variant === \"checkbox\") return false;\n return \"\";\n}\n\nexport function 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 if (trimmed.startsWith(\"http://\") || trimmed.startsWith(\"https://\")) {\n return trimmed;\n }\n\n return `http://${trimmed}`;\n}\n\nexport function parseLocalDate(dateStr: unknown): Date | null {\n if (!dateStr) return null;\n if (dateStr instanceof Date) return dateStr;\n if (typeof dateStr !== \"string\") return null;\n const [year, month, day] = dateStr.split(\"-\").map(Number);\n if (!year || !month || !day) return null;\n const date = new Date(year, month - 1, day);\n // Verify date wasn't auto-corrected (e.g. Feb 30 -> Mar 1)\n if (\n date.getFullYear() !== year ||\n date.getMonth() !== month - 1 ||\n date.getDate() !== day\n ) {\n return null;\n }\n return date;\n}\n\nexport function formatDateToString(date: Date): string {\n const year = date.getFullYear();\n const month = String(date.getMonth() + 1).padStart(2, \"0\");\n const day = String(date.getDate()).padStart(2, \"0\");\n return `${year}-${month}-${day}`;\n}\n\nexport function formatDateForDisplay(dateStr: unknown): string {\n if (!dateStr) return \"\";\n const date = parseLocalDate(dateStr);\n if (!date) return typeof dateStr === \"string\" ? dateStr : \"\";\n return date.toLocaleDateString();\n}\n\nexport function formatFileSize(bytes: number): string {\n if (bytes <= 0 || !Number.isFinite(bytes)) return \"0 B\";\n const k = 1024;\n const sizes = [\"B\", \"KB\", \"MB\", \"GB\"];\n const i = Math.min(\n sizes.length - 1,\n Math.floor(Math.log(bytes) / Math.log(k)),\n );\n return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;\n}\n\nexport function 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", "type": "registry:lib" }, { diff --git a/src/components/data-grid/data-grid-cell-variants.tsx b/src/components/data-grid/data-grid-cell-variants.tsx index 3a7ff8fb..8486daeb 100644 --- a/src/components/data-grid/data-grid-cell-variants.tsx +++ b/src/components/data-grid/data-grid-cell-variants.tsx @@ -63,7 +63,7 @@ export function ShortTextCell({ const [value, setValue] = React.useState(initialValue); const cellRef = React.useRef(null); const containerRef = React.useRef(null); - const prevIsEditingRef = React.useRef(isEditing); + const prevIsEditingRef = React.useRef(false); const prevInitialValueRef = React.useRef(initialValue); if (initialValue !== prevInitialValueRef.current) { @@ -428,7 +428,7 @@ export function NumberCell({ const max = numberCellOpts?.max; const step = numberCellOpts?.step; - const prevIsEditingRef = React.useRef(isEditing); + const prevIsEditingRef = React.useRef(false); const prevInitialValueRef = React.useRef(initialValue); if (initialValue !== prevInitialValueRef.current) { @@ -550,7 +550,7 @@ export function UrlCell({ const [value, setValue] = React.useState(initialValue ?? ""); const cellRef = React.useRef(null); const containerRef = React.useRef(null); - const prevIsEditingRef = React.useRef(isEditing); + const prevIsEditingRef = React.useRef(false); const prevInitialValueRef = React.useRef(initialValue); if (initialValue !== prevInitialValueRef.current) { diff --git a/src/hooks/test/use-data-grid.test.tsx b/src/hooks/test/use-data-grid.test.tsx index 76a3e1e4..543873e7 100644 --- a/src/hooks/test/use-data-grid.test.tsx +++ b/src/hooks/test/use-data-grid.test.tsx @@ -1166,6 +1166,51 @@ describe("useDataGrid", () => { }); it("should preserve multiline content within cells when pasting", async () => { + const onPaste = vi.fn().mockResolvedValue(undefined); + mockClipboard.readText.mockResolvedValue( + 'Alice\tKickflip\t95\nBob\t"Trick with\nmultiple\nlines"\t98', + ); + + const { result } = renderHook( + () => + useDataGrid({ + data: testData, + columns: testColumns, + onPaste, + }), + { wrapper: createWrapper() }, + ); + + act(() => { + result.current.tableMeta.onCellClick?.(0, "name"); + }); + + await act(async () => { + await result.current.tableMeta.onCellsPaste?.(); + }); + + expect(onPaste).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + rowIndex: 0, + columnId: "score", + value: 95, + }), + expect.objectContaining({ + rowIndex: 1, + columnId: "trick", + value: "Trick with\nmultiple\nlines", + }), + expect.objectContaining({ + rowIndex: 1, + columnId: "score", + value: 98, + }), + ]), + ); + }); + + it("should preserve unquoted multiline content when pasting (Excel format)", async () => { const onPaste = vi.fn().mockResolvedValue(undefined); mockClipboard.readText.mockResolvedValue( "Alice\tKickflip\t95\nBob\tTrick with\nmultiple\nlines\t98", diff --git a/src/hooks/use-data-grid.ts b/src/hooks/use-data-grid.ts index c3b47b86..51893d7e 100644 --- a/src/hooks/use-data-grid.ts +++ b/src/hooks/use-data-grid.ts @@ -406,7 +406,7 @@ function useDataGrid({ const existingRow = currentData[i]; if (!existingRow) { - newData[i] = {} as TData; + newData[i] = existingRow as TData; continue; } @@ -1950,14 +1950,18 @@ function useDataGrid({ const currentState = store.getState(); const rows = tableRef.current?.getRowModel().rows ?? []; const currentRowIndex = rows.findIndex((r) => r.id === rowId); - const currentRow = currentRowIndex >= 0 ? rows[currentRowIndex] : null; - if (!currentRow) return; + if (currentRowIndex === -1) return; if (shiftKey && currentState.lastClickedRowId !== null) { const lastClickedRowIndex = rows.findIndex( (r) => r.id === currentState.lastClickedRowId, ); - if (lastClickedRowIndex >= 0) { + if (lastClickedRowIndex === -1) { + onRowSelectionChange({ + ...currentState.rowSelection, + [rowId]: selected, + }); + } else { const startIndex = Math.min(lastClickedRowIndex, currentRowIndex); const endIndex = Math.max(lastClickedRowIndex, currentRowIndex); @@ -1973,16 +1977,11 @@ function useDataGrid({ } onRowSelectionChange(newRowSelection); - } else { - onRowSelectionChange({ - ...currentState.rowSelection, - [currentRow.id]: selected, - }); } } else { onRowSelectionChange({ ...currentState.rowSelection, - [currentRow.id]: selected, + [rowId]: selected, }); } diff --git a/src/lib/data-grid.ts b/src/lib/data-grid.ts index 3056b36e..ccaa7d81 100644 --- a/src/lib/data-grid.ts +++ b/src/lib/data-grid.ts @@ -266,6 +266,67 @@ export function parseTsv( text: string, fallbackColumnCount: number, ): string[][] { + if (text.startsWith('"') || text.includes('\t"')) { + const rows: string[][] = []; + let currentRow: string[] = []; + let currentField = ""; + let inQuotes = false; + let i = 0; + + while (i < text.length) { + const char = text[i]; + const nextChar = text[i + 1]; + + if (inQuotes) { + if (char === '"' && nextChar === '"') { + currentField += '"'; + i += 2; + } else if (char === '"') { + inQuotes = false; + i++; + } else { + currentField += char; + i++; + } + } else { + if (char === '"' && currentField === "") { + inQuotes = true; + i++; + } else if (char === "\t") { + currentRow.push(currentField); + currentField = ""; + i++; + } else if (char === "\n") { + currentRow.push(currentField); + if (currentRow.length > 1 || currentRow.some((f) => f.length > 0)) { + rows.push(currentRow); + } + currentRow = []; + currentField = ""; + i++; + } else if (char === "\r" && nextChar === "\n") { + currentRow.push(currentField); + if (currentRow.length > 1 || currentRow.some((f) => f.length > 0)) { + rows.push(currentRow); + } + currentRow = []; + currentField = ""; + i += 2; + } else { + currentField += char; + i++; + } + } + } + + currentRow.push(currentField); + if (currentRow.length > 1 || currentRow.some((f) => f.length > 0)) { + rows.push(currentRow); + } + + return rows; + } + const lines = text.split("\n"); let maxTabs = 0; for (const line of lines) { @@ -277,26 +338,28 @@ export function parseTsv( const rows: string[][] = []; let buf = ""; + let bufTabs = 0; for (const line of lines) { const tc = countTabs(line); if (tc === cols - 1) { - if (buf && countTabs(buf) === cols - 1) rows.push(buf.split("\t")); + if (buf && bufTabs === cols - 1) rows.push(buf.split("\t")); buf = ""; + bufTabs = 0; rows.push(line.split("\t")); - } else if (tc === 0 && rows.length > 0 && !buf) { - const last = rows[rows.length - 1]; - if (last) { - const cell = last[cols - 1]; - if (cell !== undefined) last[cols - 1] = `${cell}\n${line}`; - } } else { buf = buf ? `${buf}\n${line}` : line; + bufTabs += tc; + if (bufTabs === cols - 1) { + rows.push(buf.split("\t")); + buf = ""; + bufTabs = 0; + } } } - if (buf && countTabs(buf) === cols - 1) rows.push(buf.split("\t")); + if (buf && bufTabs === cols - 1) rows.push(buf.split("\t")); return rows.length > 0 ? rows diff --git a/src/lib/test/data-grid.test.ts b/src/lib/test/data-grid.test.ts new file mode 100644 index 00000000..62ee6c89 --- /dev/null +++ b/src/lib/test/data-grid.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from "vitest"; +import { parseTsv } from "@/lib/data-grid"; + +describe("parseTsv", () => { + describe("basic parsing", () => { + it("should parse simple single-row TSV", () => { + expect(parseTsv("Alice\tKickflip\t95", 3)).toEqual([ + ["Alice", "Kickflip", "95"], + ]); + }); + + it("should parse multiple rows", () => { + expect(parseTsv("Alice\tKickflip\t95\nBob\tOllie\t88", 3)).toEqual([ + ["Alice", "Kickflip", "95"], + ["Bob", "Ollie", "88"], + ]); + }); + + it("should handle single-column paste", () => { + expect(parseTsv("Alice\nBob\nCharlie", 1)).toEqual([ + ["Alice"], + ["Bob"], + ["Charlie"], + ]); + }); + + it("should skip empty rows", () => { + expect(parseTsv("Alice\tKickflip\t95\n\nBob\tOllie\t88", 3)).toEqual([ + ["Alice", "Kickflip", "95"], + ["Bob", "Ollie", "88"], + ]); + }); + }); + + describe("quoted fields (standard TSV)", () => { + it("should handle quoted multiline content", () => { + const text = + 'Alice\tKickflip\t95\nBob\t"Trick with\nmultiple\nlines"\t98'; + expect(parseTsv(text, 3)).toEqual([ + ["Alice", "Kickflip", "95"], + ["Bob", "Trick with\nmultiple\nlines", "98"], + ]); + }); + + it("should handle escaped quotes", () => { + const text = '"She said ""hello"""\t42'; + expect(parseTsv(text, 2)).toEqual([['She said "hello"', "42"]]); + }); + + it("should handle Windows line endings", () => { + const text = '"Line 1\r\nLine 2"\tvalue'; + expect(parseTsv(text, 2)).toEqual([["Line 1\r\nLine 2", "value"]]); + }); + + it("should handle mixed quoted and unquoted fields", () => { + const text = 'plain\t"quoted\nfield"\t123'; + expect(parseTsv(text, 3)).toEqual([["plain", "quoted\nfield", "123"]]); + }); + }); + + describe("unquoted multiline (tab counting)", () => { + it("should handle multiline in last column", () => { + const text = "Alice\tKickflip\t95\nBob\tTrick with\nmultiple\nlines\t98"; + expect(parseTsv(text, 3)).toEqual([ + ["Alice", "Kickflip", "95"], + ["Bob", "Trick with\nmultiple\nlines", "98"], + ]); + }); + + it("should handle multiline in middle column", () => { + const text = + "Alice\tShort note\t95\nBob\tLine 1\nLine 2\nLine 3\t88\nCharlie\tSimple\t77"; + expect(parseTsv(text, 3)).toEqual([ + ["Alice", "Short note", "95"], + ["Bob", "Line 1\nLine 2\nLine 3", "88"], + ["Charlie", "Simple", "77"], + ]); + }); + + it("should handle multiple rows with multiline in middle columns", () => { + const text = [ + "Alice\tShort\t1", + "Bob\tMulti", + "line", + "content\t2", + "Charlie\tAnother", + "multi\t3", + "Dave\tPlain\t4", + ].join("\n"); + expect(parseTsv(text, 3)).toEqual([ + ["Alice", "Short", "1"], + ["Bob", "Multi\nline\ncontent", "2"], + ["Charlie", "Another\nmulti", "3"], + ["Dave", "Plain", "4"], + ]); + }); + }); + + describe("data with JSON values (no false positives)", () => { + it("should use tab counting when quotes are inside field values not delimiters", () => { + const text = 'Alice\t["React","Node.js"]\t95\nBob\t["Python"]\t88'; + expect(parseTsv(text, 3)).toEqual([ + ["Alice", '["React","Node.js"]', "95"], + ["Bob", '["Python"]', "88"], + ]); + }); + + it("should handle JSON values with unquoted multiline", () => { + const text = [ + 'Alice\tShort note\t["React"]\t1', + "Bob\tLine 1", + 'Line 2\t["Python"]\t2', + 'Charlie\tPlain\t["SQL"]\t3', + ].join("\n"); + expect(parseTsv(text, 4)).toEqual([ + ["Alice", "Short note", '["React"]', "1"], + ["Bob", "Line 1\nLine 2", '["Python"]', "2"], + ["Charlie", "Plain", '["SQL"]', "3"], + ]); + }); + }); + + describe("edge cases", () => { + it("should return empty array for empty string", () => { + expect(parseTsv("", 0)).toEqual([]); + }); + + it("should handle single cell", () => { + expect(parseTsv("hello", 1)).toEqual([["hello"]]); + }); + + it("should fallback to simple split when no tabs detected", () => { + expect(parseTsv("line1\nline2\nline3", 1)).toEqual([ + ["line1"], + ["line2"], + ["line3"], + ]); + }); + }); +});