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

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

\n

\n or click to browse\n

\n
\n

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

\n
\n \n {error && (\n
\n {error}\n
\n )}\n {files.length > 0 && (\n
\n
\n

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

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

{file.name}

\n

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

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

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

\n

\n or click to browse\n

\n
\n

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

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

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

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

{file.name}

\n

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

\n
\n removeFile(file.id)}\n >\n \n \n
\n );\n })}\n
\n
\n )}\n \n \n
\n ) : null}\n {isDraggingOver ? (\n
\n \n Drop files here\n
\n ) : files.length > 0 ? (\n
\n {visibleFiles.map((file) => {\n const isUploading = uploadingFiles.has(file.id);\n\n if (isUploading) {\n // Show skeleton for uploading files\n return (\n \n );\n }\n\n return (\n \n {React.createElement(getFileIcon(file.type), {\n className: \"size-3 shrink-0\",\n })}\n {file.name}\n \n );\n })}\n {hiddenFileCount > 0 && (\n \n +{hiddenFileCount}\n \n )}\n
\n ) : null}\n \n );\n}\n", "type": "registry:component", "target": "src/components/data-grid/data-grid-cell-variants.tsx" }, @@ -58,7 +58,7 @@ }, { "path": "src/components/data-grid/data-grid-context-menu.tsx", - "content": "\"use client\";\n\nimport type { Table, TableMeta } from \"@tanstack/react-table\";\nimport { CopyIcon, EraserIcon, Trash2Icon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { toast } from \"sonner\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { parseCellKey } from \"@/lib/data-grid\";\nimport type { UpdateCell } from \"@/types/data-grid\";\n\ninterface DataGridContextMenuProps {\n table: Table;\n}\n\nexport function DataGridContextMenu({\n table,\n}: DataGridContextMenuProps) {\n const meta = table.options.meta;\n const contextMenu = meta?.contextMenu;\n const onContextMenuOpenChange = meta?.onContextMenuOpenChange;\n const selectionState = meta?.selectionState;\n const dataGridRef = meta?.dataGridRef;\n const onDataUpdate = meta?.onDataUpdate;\n const onRowsDelete = meta?.onRowsDelete;\n\n if (!contextMenu) return null;\n\n return (\n \n );\n}\n\ninterface ContextMenuProps\n extends Pick<\n TableMeta,\n | \"dataGridRef\"\n | \"onContextMenuOpenChange\"\n | \"selectionState\"\n | \"onDataUpdate\"\n | \"onRowsDelete\"\n >,\n Required, \"contextMenu\">> {\n table: Table;\n}\n\nconst ContextMenu = React.memo(ContextMenuImpl, (prev, next) => {\n if (prev.contextMenu.open !== next.contextMenu.open) return false;\n if (!next.contextMenu.open) return true;\n if (prev.contextMenu.x !== next.contextMenu.x) return false;\n if (prev.contextMenu.y !== next.contextMenu.y) return false;\n\n const prevSize = prev.selectionState?.selectedCells?.size ?? 0;\n const nextSize = next.selectionState?.selectedCells?.size ?? 0;\n if (prevSize !== nextSize) return false;\n\n return true;\n}) as typeof ContextMenuImpl;\n\nfunction ContextMenuImpl({\n table,\n dataGridRef,\n contextMenu,\n onContextMenuOpenChange,\n selectionState,\n onDataUpdate,\n onRowsDelete,\n}: ContextMenuProps) {\n const triggerStyle = React.useMemo(\n () => ({\n position: \"fixed\",\n left: `${contextMenu.x}px`,\n top: `${contextMenu.y}px`,\n width: \"1px\",\n height: \"1px\",\n padding: 0,\n margin: 0,\n border: \"none\",\n background: \"transparent\",\n pointerEvents: \"none\",\n opacity: 0,\n }),\n [contextMenu.x, contextMenu.y],\n );\n\n const onCloseAutoFocus: NonNullable<\n React.ComponentProps[\"onCloseAutoFocus\"]\n > = React.useCallback(\n (event) => {\n event.preventDefault();\n dataGridRef?.current?.focus();\n },\n [dataGridRef],\n );\n\n const onCopy = React.useCallback(() => {\n if (\n !selectionState?.selectedCells ||\n selectionState.selectedCells.size === 0\n )\n return;\n\n const rows = table.getRowModel().rows;\n const columnIds: string[] = [];\n\n const selectedCellsArray = Array.from(selectionState.selectedCells);\n for (const cellKey of selectedCellsArray) {\n const { columnId } = parseCellKey(cellKey);\n if (columnId && !columnIds.includes(columnId)) {\n columnIds.push(columnId);\n }\n }\n\n const cellData = new Map();\n for (const cellKey of selectedCellsArray) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n const row = rows[rowIndex];\n if (row) {\n const cell = row\n .getVisibleCells()\n .find((c) => c.column.id === columnId);\n if (cell) {\n const value = cell.getValue();\n cellData.set(cellKey, String(value ?? \"\"));\n }\n }\n }\n\n const rowIndices = new Set();\n const colIndices = new Set();\n\n for (const cellKey of selectedCellsArray) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n rowIndices.add(rowIndex);\n const colIndex = columnIds.indexOf(columnId);\n if (colIndex >= 0) {\n colIndices.add(colIndex);\n }\n }\n\n const sortedRowIndices = Array.from(rowIndices).sort((a, b) => a - b);\n const sortedColIndices = Array.from(colIndices).sort((a, b) => a - b);\n const sortedColumnIds = sortedColIndices.map((i) => columnIds[i]);\n\n const tsvData = sortedRowIndices\n .map((rowIndex) =>\n sortedColumnIds\n .map((columnId) => {\n const cellKey = `${rowIndex}:${columnId}`;\n return cellData.get(cellKey) ?? \"\";\n })\n .join(\"\\t\"),\n )\n .join(\"\\n\");\n\n navigator.clipboard.writeText(tsvData);\n toast.success(\n `${selectionState.selectedCells.size} cell${selectionState.selectedCells.size !== 1 ? \"s\" : \"\"} copied`,\n );\n }, [table, selectionState]);\n\n const onClear = React.useCallback(() => {\n if (\n !selectionState?.selectedCells ||\n selectionState.selectedCells.size === 0\n )\n return;\n\n const updates: Array = [];\n\n for (const cellKey of selectionState.selectedCells) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n updates.push({ rowIndex, columnId, value: \"\" });\n }\n\n onDataUpdate?.(updates);\n\n toast.success(\n `${updates.length} cell${updates.length !== 1 ? \"s\" : \"\"} cleared`,\n );\n }, [onDataUpdate, selectionState]);\n\n const onDelete = React.useCallback(async () => {\n if (\n !selectionState?.selectedCells ||\n selectionState.selectedCells.size === 0\n )\n return;\n\n const rowIndices = new Set();\n for (const cellKey of selectionState.selectedCells) {\n const { rowIndex } = parseCellKey(cellKey);\n rowIndices.add(rowIndex);\n }\n\n const rowIndicesArray = Array.from(rowIndices).sort((a, b) => a - b);\n const rowCount = rowIndicesArray.length;\n\n await onRowsDelete?.(rowIndicesArray);\n\n toast.success(`${rowCount} row${rowCount !== 1 ? \"s\" : \"\"} deleted`);\n }, [onRowsDelete, selectionState]);\n\n return (\n \n \n \n \n \n Copy\n \n \n \n Clear\n \n {onRowsDelete && (\n <>\n \n \n \n Delete rows\n \n \n )}\n \n \n );\n}\n", + "content": "\"use client\";\n\nimport type { Table, TableMeta } from \"@tanstack/react-table\";\nimport { CopyIcon, EraserIcon, Trash2Icon } from \"lucide-react\";\nimport * as React from \"react\";\nimport { toast } from \"sonner\";\nimport {\n DropdownMenu,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport { parseCellKey } from \"@/lib/data-grid\";\nimport type { UpdateCell } from \"@/types/data-grid\";\n\ninterface DataGridContextMenuProps {\n table: Table;\n}\n\nexport function DataGridContextMenu({\n table,\n}: DataGridContextMenuProps) {\n const meta = table.options.meta;\n const contextMenu = meta?.contextMenu;\n const onContextMenuOpenChange = meta?.onContextMenuOpenChange;\n const selectionState = meta?.selectionState;\n const dataGridRef = meta?.dataGridRef;\n const onDataUpdate = meta?.onDataUpdate;\n const onRowsDelete = meta?.onRowsDelete;\n\n if (!contextMenu) return null;\n\n return (\n \n );\n}\n\ninterface ContextMenuProps\n extends Pick<\n TableMeta,\n | \"dataGridRef\"\n | \"onContextMenuOpenChange\"\n | \"selectionState\"\n | \"onDataUpdate\"\n | \"onRowsDelete\"\n >,\n Required, \"contextMenu\">> {\n table: Table;\n}\n\nconst ContextMenu = React.memo(ContextMenuImpl, (prev, next) => {\n if (prev.contextMenu.open !== next.contextMenu.open) return false;\n if (!next.contextMenu.open) return true;\n if (prev.contextMenu.x !== next.contextMenu.x) return false;\n if (prev.contextMenu.y !== next.contextMenu.y) return false;\n\n const prevSize = prev.selectionState?.selectedCells?.size ?? 0;\n const nextSize = next.selectionState?.selectedCells?.size ?? 0;\n if (prevSize !== nextSize) return false;\n\n return true;\n}) as typeof ContextMenuImpl;\n\nfunction ContextMenuImpl({\n table,\n dataGridRef,\n contextMenu,\n onContextMenuOpenChange,\n selectionState,\n onDataUpdate,\n onRowsDelete,\n}: ContextMenuProps) {\n const triggerStyle = React.useMemo(\n () => ({\n position: \"fixed\",\n left: `${contextMenu.x}px`,\n top: `${contextMenu.y}px`,\n width: \"1px\",\n height: \"1px\",\n padding: 0,\n margin: 0,\n border: \"none\",\n background: \"transparent\",\n pointerEvents: \"none\",\n opacity: 0,\n }),\n [contextMenu.x, contextMenu.y],\n );\n\n const onCloseAutoFocus: NonNullable<\n React.ComponentProps[\"onCloseAutoFocus\"]\n > = React.useCallback(\n (event) => {\n event.preventDefault();\n dataGridRef?.current?.focus();\n },\n [dataGridRef],\n );\n\n const onCopy = React.useCallback(() => {\n if (\n !selectionState?.selectedCells ||\n selectionState.selectedCells.size === 0\n )\n return;\n\n const rows = table.getRowModel().rows;\n const columnIds: string[] = [];\n\n const selectedCellsArray = Array.from(selectionState.selectedCells);\n for (const cellKey of selectedCellsArray) {\n const { columnId } = parseCellKey(cellKey);\n if (columnId && !columnIds.includes(columnId)) {\n columnIds.push(columnId);\n }\n }\n\n const cellData = new Map();\n for (const cellKey of selectedCellsArray) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n const row = rows[rowIndex];\n if (row) {\n const cell = row\n .getVisibleCells()\n .find((c) => c.column.id === columnId);\n if (cell) {\n const value = cell.getValue();\n cellData.set(cellKey, String(value ?? \"\"));\n }\n }\n }\n\n const rowIndices = new Set();\n const colIndices = new Set();\n\n for (const cellKey of selectedCellsArray) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n rowIndices.add(rowIndex);\n const colIndex = columnIds.indexOf(columnId);\n if (colIndex >= 0) {\n colIndices.add(colIndex);\n }\n }\n\n const sortedRowIndices = Array.from(rowIndices).sort((a, b) => a - b);\n const sortedColIndices = Array.from(colIndices).sort((a, b) => a - b);\n const sortedColumnIds = sortedColIndices.map((i) => columnIds[i]);\n\n const tsvData = sortedRowIndices\n .map((rowIndex) =>\n sortedColumnIds\n .map((columnId) => {\n const cellKey = `${rowIndex}:${columnId}`;\n return cellData.get(cellKey) ?? \"\";\n })\n .join(\"\\t\"),\n )\n .join(\"\\n\");\n\n navigator.clipboard.writeText(tsvData);\n toast.success(\n `${selectionState.selectedCells.size} cell${selectionState.selectedCells.size !== 1 ? \"s\" : \"\"} copied`,\n );\n }, [table, selectionState]);\n\n const onClear = React.useCallback(() => {\n if (\n !selectionState?.selectedCells ||\n selectionState.selectedCells.size === 0\n )\n return;\n\n const updates: Array = [];\n const tableColumns = table.getAllColumns();\n\n for (const cellKey of selectionState.selectedCells) {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n\n const column = tableColumns.find((col) => col.id === columnId);\n const cellVariant = column?.columnDef?.meta?.cell?.variant;\n\n let emptyValue: unknown = \"\";\n if (cellVariant === \"multi-select\" || cellVariant === \"file\") {\n emptyValue = [];\n } else if (cellVariant === \"number\" || cellVariant === \"date\") {\n emptyValue = null;\n } else if (cellVariant === \"checkbox\") {\n emptyValue = false;\n }\n\n updates.push({ rowIndex, columnId, value: emptyValue });\n }\n\n onDataUpdate?.(updates);\n\n toast.success(\n `${updates.length} cell${updates.length !== 1 ? \"s\" : \"\"} cleared`,\n );\n }, [onDataUpdate, selectionState, table]);\n\n const onDelete = React.useCallback(async () => {\n if (\n !selectionState?.selectedCells ||\n selectionState.selectedCells.size === 0\n )\n return;\n\n const rowIndices = new Set();\n for (const cellKey of selectionState.selectedCells) {\n const { rowIndex } = parseCellKey(cellKey);\n rowIndices.add(rowIndex);\n }\n\n const rowIndicesArray = Array.from(rowIndices).sort((a, b) => a - b);\n const rowCount = rowIndicesArray.length;\n\n await onRowsDelete?.(rowIndicesArray);\n\n toast.success(`${rowCount} row${rowCount !== 1 ? \"s\" : \"\"} deleted`);\n }, [onRowsDelete, selectionState]);\n\n return (\n \n \n \n \n \n Copy\n \n \n \n Clear\n \n {onRowsDelete && (\n <>\n \n \n \n Delete rows\n \n \n )}\n \n \n );\n}\n", "type": "registry:component", "target": "src/components/data-grid/data-grid-context-menu.tsx" }, @@ -86,7 +86,7 @@ }, { "path": "src/hooks/use-data-grid.tsx", - "content": "\"use client\";\n\nimport {\n type ColumnDef,\n getCoreRowModel,\n getSortedRowModel,\n type RowSelectionState,\n type SortingState,\n type TableOptions,\n type Updater,\n useReactTable,\n} from \"@tanstack/react-table\";\nimport { useVirtualizer, type Virtualizer } from \"@tanstack/react-virtual\";\nimport * as React from \"react\";\nimport { DataGridCell } from \"@/components/data-grid/data-grid-cell\";\nimport { getCellKey, getRowHeightValue, parseCellKey } from \"@/lib/data-grid\";\nimport type {\n CellPosition,\n ContextMenuState,\n NavigationDirection,\n RowHeightValue,\n SearchState,\n SelectionState,\n UpdateCell,\n} from \"@/types/data-grid\";\n\nconst DEFAULT_ROW_HEIGHT = \"short\";\nconst OVERSCAN = 3;\nconst VIEWPORT_OFFSET = 1;\nconst MIN_COLUMN_SIZE = 60;\nconst MAX_COLUMN_SIZE = 800;\nconst SEARCH_SHORTCUT_KEY = \"f\";\nconst NON_NAVIGABLE_COLUMN_IDS = [\"select\", \"actions\"];\n\nconst useIsomorphicLayoutEffect =\n typeof window !== \"undefined\" ? React.useLayoutEffect : React.useEffect;\n\nfunction useLazyRef(fn: () => T): React.RefObject {\n const ref = React.useRef(null);\n if (ref.current === null) {\n ref.current = fn();\n }\n return ref as React.RefObject;\n}\n\nfunction useAsRef(data: T) {\n const ref = React.useRef(data);\n\n useIsomorphicLayoutEffect(() => {\n ref.current = data;\n });\n\n return ref;\n}\n\ninterface DataGridState {\n sorting: SortingState;\n rowHeight: RowHeightValue;\n rowSelection: RowSelectionState;\n selectionState: SelectionState;\n focusedCell: CellPosition | null;\n editingCell: CellPosition | null;\n contextMenu: ContextMenuState;\n searchQuery: string;\n searchMatches: CellPosition[];\n matchIndex: number;\n searchOpen: boolean;\n lastClickedRowIndex: number | null;\n isScrolling: boolean;\n}\n\ninterface DataGridStore {\n subscribe: (callback: () => void) => () => void;\n getState: () => DataGridState;\n setState: (\n key: K,\n value: DataGridState[K],\n ) => void;\n notify: () => void;\n batch: (fn: () => void) => void;\n}\n\nfunction useStore(\n store: DataGridStore,\n selector: (state: DataGridState) => T,\n): T {\n const getSnapshot = React.useCallback(\n () => selector(store.getState()),\n [store, selector],\n );\n\n return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);\n}\n\ninterface UseDataGridProps\n extends Omit, \"pageCount\" | \"getCoreRowModel\"> {\n onDataChange?: (data: TData[]) => void;\n onRowAdd?: (event?: React.MouseEvent) =>\n | Partial\n | Promise>\n | null\n // biome-ignore lint/suspicious/noConfusingVoidType: void is needed here to allow functions without explicit return\n | void;\n onRowsDelete?: (rows: TData[], rowIndices: number[]) => void | Promise;\n rowHeight?: RowHeightValue;\n overscan?: number;\n autoFocus?: boolean | Partial;\n enableColumnSelection?: boolean;\n enableSearch?: boolean;\n}\n\nfunction useDataGrid({\n columns,\n data,\n onDataChange,\n onRowAdd: onRowAddProp,\n onRowsDelete: onRowsDeleteProp,\n rowHeight: rowHeightProp = DEFAULT_ROW_HEIGHT,\n overscan = OVERSCAN,\n initialState,\n autoFocus = false,\n enableColumnSelection = false,\n enableSearch = false,\n ...dataGridProps\n}: UseDataGridProps) {\n const dataGridRef = React.useRef(null);\n const tableRef = React.useRef>>(null);\n const rowVirtualizerRef =\n React.useRef>(null);\n const headerRef = React.useRef(null);\n const rowMapRef = React.useRef>(new Map());\n const cellMapRef = React.useRef>(new Map());\n const footerRef = React.useRef(null);\n\n const dataGridPropsRef = useAsRef(dataGridProps);\n const listenersRef = useLazyRef(() => new Set<() => void>());\n\n const stateRef = useLazyRef(() => {\n return {\n sorting: initialState?.sorting ?? [],\n rowHeight: rowHeightProp,\n rowSelection: initialState?.rowSelection ?? {},\n selectionState: {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n },\n focusedCell: null,\n editingCell: null,\n contextMenu: {\n open: false,\n x: 0,\n y: 0,\n },\n searchQuery: \"\",\n searchMatches: [],\n matchIndex: -1,\n searchOpen: false,\n lastClickedRowIndex: null,\n isScrolling: false,\n };\n });\n\n const store = React.useMemo(() => {\n let isBatching = false;\n let pendingNotification = false;\n\n return {\n subscribe: (callback) => {\n listenersRef.current.add(callback);\n return () => listenersRef.current.delete(callback);\n },\n getState: () => stateRef.current,\n setState: (key, value) => {\n if (Object.is(stateRef.current[key], value)) return;\n stateRef.current[key] = value;\n\n if (isBatching) {\n pendingNotification = true;\n } else {\n if (!pendingNotification) {\n pendingNotification = true;\n queueMicrotask(() => {\n pendingNotification = false;\n store.notify();\n });\n }\n }\n },\n notify: () => {\n for (const listener of listenersRef.current) {\n listener();\n }\n },\n batch: (fn) => {\n if (isBatching) {\n fn();\n return;\n }\n\n isBatching = true;\n const wasPending = pendingNotification;\n pendingNotification = false;\n\n try {\n fn();\n } finally {\n isBatching = false;\n if (pendingNotification || wasPending) {\n pendingNotification = false;\n store.notify();\n }\n }\n },\n };\n }, [listenersRef, stateRef]);\n\n const focusedCell = useStore(store, (state) => state.focusedCell);\n const editingCell = useStore(store, (state) => state.editingCell);\n const selectionState = useStore(store, (state) => state.selectionState);\n const searchQuery = useStore(store, (state) => state.searchQuery);\n const searchMatches = useStore(store, (state) => state.searchMatches);\n const matchIndex = useStore(store, (state) => state.matchIndex);\n const searchOpen = useStore(store, (state) => state.searchOpen);\n const sorting = useStore(store, (state) => state.sorting);\n const rowSelection = useStore(store, (state) => state.rowSelection);\n const contextMenu = useStore(store, (state) => state.contextMenu);\n const rowHeight = useStore(store, (state) => state.rowHeight);\n const isScrolling = useStore(store, (state) => state.isScrolling);\n\n const rowHeightValue = getRowHeightValue(rowHeight);\n\n const columnIds = React.useMemo(() => {\n return columns\n .map((c) => {\n if (c.id) return c.id;\n if (\"accessorKey\" in c) return c.accessorKey as string;\n return undefined;\n })\n .filter((id): id is string => Boolean(id));\n }, [columns]);\n\n const navigableColumnIds = React.useMemo(() => {\n return columnIds.filter((c) => !NON_NAVIGABLE_COLUMN_IDS.includes(c));\n }, [columnIds]);\n\n const onDataUpdate = React.useCallback(\n (updates: UpdateCell | Array) => {\n const updateArray = Array.isArray(updates) ? updates : [updates];\n\n if (updateArray.length === 0) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n\n const rowUpdatesMap = new Map<\n number,\n Array>\n >();\n\n for (const update of updateArray) {\n if (!rows || !currentTable) {\n const existingUpdates = rowUpdatesMap.get(update.rowIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(update.rowIndex, existingUpdates);\n } else {\n const row = rows[update.rowIndex];\n if (!row) continue;\n\n const originalData = row.original;\n const originalRowIndex = data.indexOf(originalData);\n if (originalRowIndex === -1) continue;\n\n const existingUpdates = rowUpdatesMap.get(originalRowIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(originalRowIndex, existingUpdates);\n }\n }\n\n const newData = data.map((row, index) => {\n const updates = rowUpdatesMap.get(index);\n if (!updates) return row;\n\n const updatedRow = { ...row } as Record;\n for (const { columnId, value } of updates) {\n updatedRow[columnId] = value;\n }\n return updatedRow as TData;\n });\n\n onDataChange?.(newData);\n },\n [data, onDataChange],\n );\n\n const getIsCellSelected = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return selectionState.selectedCells.has(getCellKey(rowIndex, columnId));\n },\n [selectionState.selectedCells],\n );\n\n const clearSelection = React.useCallback(() => {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"rowSelection\", {});\n });\n }, [store]);\n\n const selectAll = React.useCallback(() => {\n const allCells = new Set();\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {\n for (const columnId of columnIds) {\n allCells.add(getCellKey(rowIndex, columnId));\n }\n }\n\n const firstColumnId = columnIds[0];\n const lastColumnId = columnIds[columnIds.length - 1];\n\n store.setState(\"selectionState\", {\n selectedCells: allCells,\n selectionRange:\n columnIds.length > 0 && rowCount > 0 && firstColumnId && lastColumnId\n ? {\n start: { rowIndex: 0, columnId: firstColumnId },\n end: { rowIndex: rowCount - 1, columnId: lastColumnId },\n }\n : null,\n isSelecting: false,\n });\n }, [columnIds, data.length, store]);\n\n const selectColumn = React.useCallback(\n (columnId: string) => {\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n if (rowCount === 0) return;\n\n const selectedCells = new Set();\n\n for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: {\n start: { rowIndex: 0, columnId },\n end: { rowIndex: rowCount - 1, columnId },\n },\n isSelecting: false,\n });\n },\n [data.length, store],\n );\n\n const selectRange = React.useCallback(\n (start: CellPosition, end: CellPosition, isSelecting = false) => {\n const startColIndex = columnIds.indexOf(start.columnId);\n const endColIndex = columnIds.indexOf(end.columnId);\n\n const minRow = Math.min(start.rowIndex, end.rowIndex);\n const maxRow = Math.max(start.rowIndex, end.rowIndex);\n const minCol = Math.min(startColIndex, endColIndex);\n const maxCol = Math.max(startColIndex, endColIndex);\n\n const selectedCells = new Set();\n\n for (let rowIndex = minRow; rowIndex <= maxRow; rowIndex++) {\n for (let colIndex = minCol; colIndex <= maxCol; colIndex++) {\n const columnId = columnIds[colIndex];\n if (columnId) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n }\n }\n\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: { start, end },\n isSelecting,\n });\n },\n [columnIds, store],\n );\n\n const focusCellWrapper = React.useCallback(\n (rowIndex: number, columnId: string) => {\n requestAnimationFrame(() => {\n const cellKey = getCellKey(rowIndex, columnId);\n const cellWrapperElement = cellMapRef.current.get(cellKey);\n\n if (!cellWrapperElement) return;\n\n cellWrapperElement.focus();\n });\n },\n [],\n );\n\n const focusCell = React.useCallback(\n (rowIndex: number, columnId: string) => {\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", null);\n });\n\n const currentState = store.getState();\n\n if (currentState.searchOpen) return;\n\n focusCellWrapper(rowIndex, columnId);\n },\n [store, focusCellWrapper],\n );\n\n const onRowsDelete = React.useCallback(\n async (rowIndices: number[]) => {\n if (!onRowsDeleteProp || rowIndices.length === 0) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n\n if (!rows || rows.length === 0) return;\n\n const currentState = store.getState();\n const currentFocusedColumn =\n currentState.focusedCell?.columnId ?? navigableColumnIds[0];\n\n const minDeletedRowIndex = Math.min(...rowIndices);\n\n const rowsToDelete: TData[] = [];\n for (const rowIndex of rowIndices) {\n const row = rows[rowIndex];\n if (row) {\n rowsToDelete.push(row.original);\n }\n }\n\n await onRowsDeleteProp(rowsToDelete, rowIndices);\n\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"rowSelection\", {});\n store.setState(\"editingCell\", null);\n });\n\n requestAnimationFrame(() => {\n const currentTable = tableRef.current;\n const currentRows = currentTable?.getRowModel().rows ?? [];\n const newRowCount = currentRows.length ?? data.length;\n\n if (newRowCount > 0 && currentFocusedColumn) {\n const targetRowIndex = Math.min(minDeletedRowIndex, newRowCount - 1);\n focusCell(targetRowIndex, currentFocusedColumn);\n }\n });\n },\n [onRowsDeleteProp, data.length, store, navigableColumnIds, focusCell],\n );\n\n const navigateCell = React.useCallback(\n (direction: NavigationDirection) => {\n const currentState = store.getState();\n if (!currentState.focusedCell) return;\n\n const { rowIndex, columnId } = currentState.focusedCell;\n const currentColIndex = navigableColumnIds.indexOf(columnId);\n const rowVirtualizer = rowVirtualizerRef.current;\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n let newRowIndex = rowIndex;\n let newColumnId = columnId;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(rowCount - 1, rowIndex + 1);\n break;\n case \"left\":\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n break;\n case \"right\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n break;\n case \"home\":\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? columnId;\n }\n break;\n case \"end\":\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;\n }\n break;\n case \"ctrl+home\":\n newRowIndex = 0;\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? columnId;\n }\n break;\n case \"ctrl+end\":\n newRowIndex = Math.max(0, rowCount - 1);\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;\n }\n break;\n case \"pageup\":\n if (rowVirtualizer) {\n const visibleRange = rowVirtualizer.getVirtualItems();\n const pageSize = visibleRange.length ?? 10;\n newRowIndex = Math.max(0, rowIndex - pageSize);\n } else {\n newRowIndex = Math.max(0, rowIndex - 10);\n }\n break;\n case \"pagedown\":\n if (rowVirtualizer) {\n const visibleRange = rowVirtualizer.getVirtualItems();\n const pageSize = visibleRange.length ?? 10;\n newRowIndex = Math.min(rowCount - 1, rowIndex + pageSize);\n } else {\n newRowIndex = Math.min(rowCount - 1, rowIndex + 10);\n }\n break;\n }\n\n if (newRowIndex !== rowIndex || newColumnId !== columnId) {\n const rowDiff = newRowIndex - rowIndex;\n\n // For single-row vertical navigation (up/down arrows)\n if (\n Math.abs(rowDiff) === 1 &&\n (direction === \"up\" || direction === \"down\")\n ) {\n const container = dataGridRef.current;\n const currentRow = rowMapRef.current.get(rowIndex);\n const targetRow = rowMapRef.current.get(newRowIndex);\n\n if (!container || !currentRow) {\n // Fallback to simple focus if we can't find elements\n focusCell(newRowIndex, newColumnId);\n return;\n }\n\n // Check viewport boundaries\n const containerRect = container.getBoundingClientRect();\n const headerHeight =\n headerRef.current?.getBoundingClientRect().height ?? 0;\n const footerHeight =\n footerRef.current?.getBoundingClientRect().height ?? 0;\n\n const viewportTop =\n containerRect.top + headerHeight + VIEWPORT_OFFSET;\n const viewportBottom =\n containerRect.bottom - footerHeight - VIEWPORT_OFFSET;\n\n // If target row already exists, check if it's visible\n if (targetRow) {\n const rowRect = targetRow.getBoundingClientRect();\n const isFullyVisible =\n rowRect.top >= viewportTop && rowRect.bottom <= viewportBottom;\n\n if (isFullyVisible) {\n // Row is fully visible, just focus it\n focusCell(newRowIndex, newColumnId);\n return;\n }\n\n // Row exists but not fully visible, scroll it into view\n focusCell(newRowIndex, newColumnId);\n\n if (direction === \"down\") {\n // Scroll just enough to show the row at the bottom\n const scrollNeeded = rowRect.bottom - viewportBottom;\n container.scrollTop += scrollNeeded;\n } else {\n // Scroll just enough to show the row at the top\n const scrollNeeded = viewportTop - rowRect.top;\n container.scrollTop -= scrollNeeded;\n }\n return;\n }\n\n // Target row is not rendered yet\n // Focus immediately so the ring appears as the row is revealed\n focusCell(newRowIndex, newColumnId);\n\n // Scroll by exactly one row height to reveal it smoothly\n if (direction === \"down\") {\n container.scrollTop += rowHeightValue;\n } else {\n // For arrow up, ensure we don't go below 0\n const currentScrollTop = container.scrollTop;\n const targetScrollTop = Math.max(\n 0,\n currentScrollTop - rowHeightValue,\n );\n container.scrollTop = targetScrollTop;\n }\n return;\n }\n\n // For larger jumps (page up/down, ctrl+home/end, etc.)\n if (rowVirtualizer && Math.abs(rowDiff) > 1) {\n const align =\n direction === \"pageup\" || direction === \"ctrl+home\"\n ? \"start\"\n : direction === \"pagedown\" || direction === \"ctrl+end\"\n ? \"end\"\n : \"center\";\n rowVirtualizer.scrollToIndex(newRowIndex, { align });\n requestAnimationFrame(() => {\n focusCell(newRowIndex, newColumnId);\n });\n return;\n }\n\n // For horizontal navigation or when row is already visible\n focusCell(newRowIndex, newColumnId);\n }\n },\n [store, navigableColumnIds, focusCell, data.length, rowHeightValue],\n );\n\n const onCellEditingStart = React.useCallback(\n (rowIndex: number, columnId: string) => {\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", { rowIndex, columnId });\n });\n },\n [store],\n );\n\n const onCellEditingStop = React.useCallback(\n (opts?: { moveToNextRow?: boolean; direction?: NavigationDirection }) => {\n const currentState = store.getState();\n const currentEditing = currentState.editingCell;\n\n store.setState(\"editingCell\", null);\n\n if (opts?.moveToNextRow && currentEditing) {\n const { rowIndex, columnId } = currentEditing;\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n const nextRowIndex = rowIndex + 1;\n if (nextRowIndex < rowCount) {\n requestAnimationFrame(() => {\n focusCell(nextRowIndex, columnId);\n });\n }\n } else if (opts?.direction && currentEditing) {\n // Focus the current editing cell first, then navigate\n const { rowIndex, columnId } = currentEditing;\n focusCell(rowIndex, columnId);\n requestAnimationFrame(() => {\n navigateCell(opts.direction ?? \"right\");\n });\n } else if (currentEditing) {\n // No navigation - just refocus the current cell (e.g., when pressing Escape)\n const { rowIndex, columnId } = currentEditing;\n focusCellWrapper(rowIndex, columnId);\n }\n },\n [store, data.length, focusCell, navigateCell, focusCellWrapper],\n );\n\n const onSearchOpenChange = React.useCallback(\n (open: boolean) => {\n if (open) {\n store.setState(\"searchOpen\", true);\n return;\n }\n\n const currentState = store.getState();\n const currentMatch =\n currentState.matchIndex >= 0 &&\n currentState.searchMatches[currentState.matchIndex];\n\n store.batch(() => {\n store.setState(\"searchOpen\", false);\n store.setState(\"searchQuery\", \"\");\n store.setState(\"searchMatches\", []);\n store.setState(\"matchIndex\", -1);\n\n if (currentMatch) {\n store.setState(\"focusedCell\", {\n rowIndex: currentMatch.rowIndex,\n columnId: currentMatch.columnId,\n });\n }\n });\n\n if (\n dataGridRef.current &&\n document.activeElement !== dataGridRef.current\n ) {\n dataGridRef.current.focus();\n }\n },\n [store],\n );\n\n const onSearch = React.useCallback(\n (query: string) => {\n if (!query.trim()) {\n store.batch(() => {\n store.setState(\"searchMatches\", []);\n store.setState(\"matchIndex\", -1);\n });\n return;\n }\n\n const matches: CellPosition[] = [];\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n\n const lowerQuery = query.toLowerCase();\n\n for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {\n const row = rows[rowIndex];\n if (!row) continue;\n\n for (const columnId of columnIds) {\n const cell = row\n .getVisibleCells()\n .find((c) => c.column.id === columnId);\n if (!cell) continue;\n\n const value = cell.getValue();\n const stringValue = String(value ?? \"\").toLowerCase();\n\n if (stringValue.includes(lowerQuery)) {\n matches.push({ rowIndex, columnId });\n }\n }\n }\n\n store.batch(() => {\n store.setState(\"searchMatches\", matches);\n store.setState(\"matchIndex\", matches.length > 0 ? 0 : -1);\n });\n\n // Scroll to first match but don't focus it (to keep focus in search input)\n if (matches.length > 0 && matches[0]) {\n const firstMatch = matches[0];\n rowVirtualizerRef.current?.scrollToIndex(firstMatch.rowIndex, {\n align: \"center\",\n });\n }\n },\n [columnIds, store],\n );\n\n const onSearchQueryChange = React.useCallback(\n (query: string) => store.setState(\"searchQuery\", query),\n [store],\n );\n\n const onNavigateToPrevMatch = React.useCallback(() => {\n const currentState = store.getState();\n if (currentState.searchMatches.length === 0) return;\n\n const prevIndex =\n currentState.matchIndex - 1 < 0\n ? currentState.searchMatches.length - 1\n : currentState.matchIndex - 1;\n const match = currentState.searchMatches[prevIndex];\n\n if (match) {\n rowVirtualizerRef.current?.scrollToIndex(match.rowIndex, {\n align: \"center\",\n });\n\n requestAnimationFrame(() => {\n store.setState(\"matchIndex\", prevIndex);\n requestAnimationFrame(() => {\n focusCell(match.rowIndex, match.columnId);\n });\n });\n }\n }, [store, focusCell]);\n\n const onNavigateToNextMatch = React.useCallback(() => {\n const currentState = store.getState();\n if (currentState.searchMatches.length === 0) return;\n\n const nextIndex =\n (currentState.matchIndex + 1) % currentState.searchMatches.length;\n const match = currentState.searchMatches[nextIndex];\n\n if (match) {\n rowVirtualizerRef.current?.scrollToIndex(match.rowIndex, {\n align: \"center\",\n });\n\n requestAnimationFrame(() => {\n store.setState(\"matchIndex\", nextIndex);\n requestAnimationFrame(() => {\n focusCell(match.rowIndex, match.columnId);\n });\n });\n }\n }, [store, focusCell]);\n\n const getIsSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return searchMatches.some(\n (match) => match.rowIndex === rowIndex && match.columnId === columnId,\n );\n },\n [searchMatches],\n );\n\n const getIsActiveSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n if (matchIndex < 0) return false;\n const currentMatch = searchMatches[matchIndex];\n return (\n currentMatch?.rowIndex === rowIndex &&\n currentMatch?.columnId === columnId\n );\n },\n [searchMatches, matchIndex],\n );\n\n const blurCell = React.useCallback(() => {\n const currentState = store.getState();\n if (\n currentState.editingCell &&\n document.activeElement instanceof HTMLElement\n ) {\n document.activeElement.blur();\n }\n\n store.batch(() => {\n store.setState(\"focusedCell\", null);\n store.setState(\"editingCell\", null);\n });\n }, [store]);\n\n const onCellClick = React.useCallback(\n (rowIndex: number, columnId: string, event?: React.MouseEvent) => {\n // Ignore right-click (button 2) - let onCellContextMenu handle it\n if (event?.button === 2) {\n return;\n }\n\n const currentState = store.getState();\n const currentFocused = currentState.focusedCell;\n\n if (event) {\n if (event.ctrlKey || event.metaKey) {\n event.preventDefault();\n const cellKey = getCellKey(rowIndex, columnId);\n const newSelectedCells = new Set(\n currentState.selectionState.selectedCells,\n );\n\n if (newSelectedCells.has(cellKey)) {\n newSelectedCells.delete(cellKey);\n } else {\n newSelectedCells.add(cellKey);\n }\n\n store.setState(\"selectionState\", {\n selectedCells: newSelectedCells,\n selectionRange: null,\n isSelecting: false,\n });\n focusCell(rowIndex, columnId);\n return;\n }\n\n if (event.shiftKey && currentState.focusedCell) {\n event.preventDefault();\n selectRange(currentState.focusedCell, { rowIndex, columnId });\n return;\n }\n }\n\n // Clear selection if there are selected cells or rows\n const hasSelectedCells =\n currentState.selectionState.selectedCells.size > 0;\n const hasSelectedRows = Object.keys(currentState.rowSelection).length > 0;\n\n if (hasSelectedCells && !currentState.selectionState.isSelecting) {\n // If there's a cell selection but we're not actively selecting (drag just finished),\n // don't clear it - keep the selection\n // Only clear if clicking elsewhere\n const cellKey = getCellKey(rowIndex, columnId);\n const isClickingSelectedCell =\n currentState.selectionState.selectedCells.has(cellKey);\n\n if (!isClickingSelectedCell) {\n clearSelection();\n } else {\n // Clicking on an already selected cell - just focus it\n focusCell(rowIndex, columnId);\n return;\n }\n } else if (hasSelectedRows && columnId !== \"select\") {\n // If there are selected rows but we're clicking on a non-checkbox cell, clear selections\n clearSelection();\n }\n\n if (\n currentFocused?.rowIndex === rowIndex &&\n currentFocused?.columnId === columnId\n ) {\n onCellEditingStart(rowIndex, columnId);\n } else {\n focusCell(rowIndex, columnId);\n }\n },\n [store, focusCell, onCellEditingStart, selectRange, clearSelection],\n );\n\n const onCellDoubleClick = React.useCallback(\n (rowIndex: number, columnId: string, event?: React.MouseEvent) => {\n if (event?.defaultPrevented) return;\n\n onCellEditingStart(rowIndex, columnId);\n },\n [onCellEditingStart],\n );\n\n const onCellMouseDown = React.useCallback(\n (rowIndex: number, columnId: string, event: React.MouseEvent) => {\n // Ignore right-click (button 2) - let onCellContextMenu handle it\n if (event.button === 2) {\n return;\n }\n\n event.preventDefault();\n\n // Only start drag selection if no modifier keys are pressed\n // Clear any existing selection and prepare for potential drag\n if (!event.ctrlKey && !event.metaKey && !event.shiftKey) {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: {\n start: { rowIndex, columnId },\n end: { rowIndex, columnId },\n },\n isSelecting: true,\n });\n store.setState(\"rowSelection\", {});\n });\n }\n },\n [store],\n );\n\n const onCellMouseEnter = React.useCallback(\n (rowIndex: number, columnId: string, _event: React.MouseEvent) => {\n const currentState = store.getState();\n if (\n currentState.selectionState.isSelecting &&\n currentState.selectionState.selectionRange\n ) {\n const start = currentState.selectionState.selectionRange.start;\n const end = { rowIndex, columnId };\n\n if (\n currentState.focusedCell?.rowIndex !== start.rowIndex ||\n currentState.focusedCell?.columnId !== start.columnId\n ) {\n focusCell(start.rowIndex, start.columnId);\n }\n\n selectRange(start, end, true);\n }\n },\n [store, selectRange, focusCell],\n );\n\n const onCellMouseUp = React.useCallback(() => {\n const currentState = store.getState();\n store.setState(\"selectionState\", {\n ...currentState.selectionState,\n isSelecting: false,\n });\n }, [store]);\n\n const onCellContextMenu = React.useCallback(\n (rowIndex: number, columnId: string, event: React.MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n\n const currentState = store.getState();\n const cellKey = getCellKey(rowIndex, columnId);\n const isTargetCellSelected =\n currentState.selectionState.selectedCells.has(cellKey);\n\n // If right-clicking on a non-selected cell, select only that cell\n if (!isTargetCellSelected) {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set([cellKey]),\n selectionRange: {\n start: { rowIndex, columnId },\n end: { rowIndex, columnId },\n },\n isSelecting: false,\n });\n store.setState(\"focusedCell\", { rowIndex, columnId });\n });\n }\n\n // Open context menu at cursor position\n store.setState(\"contextMenu\", {\n open: true,\n x: event.clientX,\n y: event.clientY,\n });\n },\n [store],\n );\n\n const onContextMenuOpenChange = React.useCallback(\n (open: boolean) => {\n if (!open) {\n const currentMenu = store.getState().contextMenu;\n store.setState(\"contextMenu\", {\n open: false,\n x: currentMenu.x,\n y: currentMenu.y,\n });\n }\n },\n [store],\n );\n\n const onDataGridKeyDown = React.useCallback(\n (event: KeyboardEvent) => {\n const currentState = store.getState();\n const { key, ctrlKey, metaKey, shiftKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n // Handle Cmd+F / Ctrl+F to open search (highest priority, works even when editing)\n if (enableSearch && isCtrlPressed && key === SEARCH_SHORTCUT_KEY) {\n event.preventDefault();\n onSearchOpenChange(true);\n return;\n }\n\n // Handle search navigation when search is open\n if (\n enableSearch &&\n currentState.searchOpen &&\n !currentState.editingCell\n ) {\n if (key === \"Enter\") {\n event.preventDefault();\n if (shiftKey) {\n onNavigateToPrevMatch();\n } else {\n onNavigateToNextMatch();\n }\n return;\n }\n if (key === \"Escape\") {\n event.preventDefault();\n onSearchOpenChange(false);\n return;\n }\n // When search is open, don't let data grid handle any other keys\n // (they should only affect the search input)\n return;\n }\n\n if (currentState.editingCell) return;\n\n if (!currentState.focusedCell) return;\n\n let direction: NavigationDirection | null = null;\n\n if (isCtrlPressed && key === \"a\") {\n event.preventDefault();\n selectAll();\n return;\n }\n\n if (key === \"Delete\" || key === \"Backspace\") {\n if (currentState.selectionState.selectedCells.size > 0) {\n event.preventDefault();\n const updates: Array<{\n rowIndex: number;\n columnId: string;\n value: unknown;\n }> = [];\n\n currentState.selectionState.selectedCells.forEach((cellKey) => {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n updates.push({ rowIndex, columnId, value: \"\" });\n });\n\n onDataUpdate(updates);\n clearSelection();\n }\n return;\n }\n\n switch (key) {\n case \"ArrowUp\":\n direction = \"up\";\n break;\n case \"ArrowDown\":\n direction = \"down\";\n break;\n case \"ArrowLeft\":\n direction = \"left\";\n break;\n case \"ArrowRight\":\n direction = \"right\";\n break;\n case \"Home\":\n direction = isCtrlPressed ? \"ctrl+home\" : \"home\";\n break;\n case \"End\":\n direction = isCtrlPressed ? \"ctrl+end\" : \"end\";\n break;\n case \"PageUp\":\n direction = \"pageup\";\n break;\n case \"PageDown\":\n direction = \"pagedown\";\n break;\n case \"Escape\":\n event.preventDefault();\n if (\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0\n ) {\n clearSelection();\n } else {\n blurCell();\n }\n return;\n case \"Tab\":\n event.preventDefault();\n direction = event.shiftKey ? \"left\" : \"right\";\n break;\n }\n\n if (direction) {\n event.preventDefault();\n\n // Tab navigation should not trigger selection, even with Shift\n if (shiftKey && key !== \"Tab\" && currentState.focusedCell) {\n const currentColIndex = navigableColumnIds.indexOf(\n currentState.focusedCell.columnId,\n );\n let newRowIndex = currentState.focusedCell.rowIndex;\n let newColumnId = currentState.focusedCell.columnId;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, currentState.focusedCell.rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(\n (tableRef.current?.getRowModel().rows.length || data.length) -\n 1,\n currentState.focusedCell.rowIndex + 1,\n );\n break;\n case \"left\":\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n break;\n case \"right\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n break;\n }\n\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n selectRange(selectionStart, {\n rowIndex: newRowIndex,\n columnId: newColumnId,\n });\n focusCell(newRowIndex, newColumnId);\n } else {\n if (currentState.selectionState.selectedCells.size > 0) {\n clearSelection();\n }\n navigateCell(direction);\n }\n }\n },\n [\n store,\n blurCell,\n navigateCell,\n selectAll,\n onDataUpdate,\n clearSelection,\n navigableColumnIds,\n data.length,\n selectRange,\n focusCell,\n onSearchOpenChange,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n enableSearch,\n ],\n );\n\n const onSortingChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newSorting =\n typeof updater === \"function\" ? updater(currentState.sorting) : updater;\n store.setState(\"sorting\", newSorting);\n },\n [store],\n );\n\n const onRowSelectionChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newRowSelection =\n typeof updater === \"function\"\n ? updater(currentState.rowSelection)\n : updater;\n\n const selectedRows = Object.keys(newRowSelection).filter(\n (key) => newRowSelection[key],\n );\n\n const selectedCells = new Set();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n\n for (const rowId of selectedRows) {\n const rowIndex = rows.findIndex((r) => r.id === rowId);\n if (rowIndex === -1) continue;\n\n for (const columnId of columnIds) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n }\n\n store.batch(() => {\n store.setState(\"rowSelection\", newRowSelection);\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"focusedCell\", null);\n store.setState(\"editingCell\", null);\n });\n },\n [store, columnIds],\n );\n\n const onRowSelect = React.useCallback(\n (rowIndex: number, selected: boolean, shiftKey: boolean) => {\n const currentState = store.getState();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n const currentRow = rows[rowIndex];\n if (!currentRow) return;\n\n if (shiftKey && currentState.lastClickedRowIndex !== null) {\n const startIndex = Math.min(currentState.lastClickedRowIndex, rowIndex);\n const endIndex = Math.max(currentState.lastClickedRowIndex, rowIndex);\n\n const newRowSelection: RowSelectionState = {\n ...currentState.rowSelection,\n };\n\n for (let i = startIndex; i <= endIndex; i++) {\n const row = rows[i];\n if (row) {\n newRowSelection[row.id] = selected;\n }\n }\n\n onRowSelectionChange(newRowSelection);\n } else {\n onRowSelectionChange({\n ...currentState.rowSelection,\n [currentRow.id]: selected,\n });\n }\n\n store.setState(\"lastClickedRowIndex\", rowIndex);\n },\n [store, onRowSelectionChange],\n );\n\n const onRowHeightChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newRowHeight =\n typeof updater === \"function\"\n ? updater(currentState.rowHeight)\n : updater;\n store.setState(\"rowHeight\", newRowHeight);\n },\n [store],\n );\n\n const onColumnClick = React.useCallback(\n (columnId: string) => {\n if (!enableColumnSelection) {\n clearSelection();\n return;\n }\n\n selectColumn(columnId);\n },\n [enableColumnSelection, selectColumn, clearSelection],\n );\n\n const defaultColumn: Partial> = React.useMemo(\n () => ({\n cell: DataGridCell,\n minSize: MIN_COLUMN_SIZE,\n maxSize: MAX_COLUMN_SIZE,\n }),\n [],\n );\n\n const tableOptions = React.useMemo>(\n () => ({\n ...dataGridPropsRef.current,\n data,\n columns,\n defaultColumn,\n initialState,\n state: {\n ...dataGridPropsRef.current.state,\n sorting,\n rowSelection,\n },\n onRowSelectionChange,\n onSortingChange,\n columnResizeMode: \"onChange\",\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n meta: {\n ...dataGridPropsRef.current.meta,\n dataGridRef,\n cellMapRef,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n rowHeight,\n isScrolling,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n onRowHeightChange,\n onRowSelect,\n onRowsDelete: onRowsDeleteProp ? onRowsDelete : undefined,\n onDataUpdate,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n contextMenu,\n onContextMenuOpenChange,\n },\n }),\n [\n dataGridPropsRef,\n data,\n columns,\n defaultColumn,\n initialState,\n sorting,\n rowSelection,\n onRowSelectionChange,\n onSortingChange,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n isScrolling,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n onDataUpdate,\n onRowsDeleteProp,\n onRowsDelete,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n contextMenu,\n onContextMenuOpenChange,\n rowHeight,\n onRowHeightChange,\n onRowSelect,\n ],\n );\n\n const table = useReactTable(tableOptions);\n\n if (!tableRef.current) {\n tableRef.current = table;\n }\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: we need to memoize the column size vars\n const columnSizeVars = React.useMemo(() => {\n const headers = table.getFlatHeaders();\n const colSizes: { [key: string]: number } = {};\n for (const header of headers) {\n colSizes[`--header-${header.id}-size`] = header.getSize();\n colSizes[`--col-${header.column.id}-size`] = header.column.getSize();\n }\n return colSizes;\n }, [table.getState().columnSizingInfo, table.getState().columnSizing]);\n\n const rowVirtualizer = useVirtualizer({\n count: table.getRowModel().rows.length,\n getScrollElement: () => dataGridRef.current,\n estimateSize: () => rowHeightValue,\n overscan,\n measureElement:\n typeof window !== \"undefined\" &&\n navigator.userAgent.indexOf(\"Firefox\") === -1\n ? (element) => element?.getBoundingClientRect().height\n : undefined,\n onChange: (instance) => {\n // Sync virtualizer's isScrolling state to our store\n const virtualizerIsScrolling = instance.isScrolling;\n const currentIsScrolling = store.getState().isScrolling;\n\n if (virtualizerIsScrolling !== currentIsScrolling) {\n store.setState(\"isScrolling\", virtualizerIsScrolling);\n }\n\n // Batch DOM updates in a single animation frame\n const virtualItems = instance.getVirtualItems();\n if (virtualItems.length === 0) return;\n\n requestAnimationFrame(() => {\n for (let i = 0; i < virtualItems.length; i++) {\n const virtualRow = virtualItems[i];\n if (!virtualRow) continue;\n const rowRef = rowMapRef.current.get(virtualRow.index);\n if (rowRef) {\n rowRef.style.transform = `translateY(${virtualRow.start}px)`;\n }\n }\n });\n },\n });\n\n if (!rowVirtualizerRef.current) {\n rowVirtualizerRef.current = rowVirtualizer;\n }\n\n const onScrollToRow = React.useCallback(\n async (opts: Partial) => {\n const rowIndex = opts?.rowIndex ?? 0;\n const columnId = opts?.columnId;\n\n rowVirtualizer.scrollToIndex(rowIndex, {\n align: \"center\",\n });\n\n const targetColumnId = columnId ?? navigableColumnIds[0];\n\n if (!targetColumnId) return;\n\n requestAnimationFrame(() => {\n store.batch(() => {\n store.setState(\"focusedCell\", {\n rowIndex,\n columnId: targetColumnId,\n });\n store.setState(\"editingCell\", null);\n });\n\n focusCellWrapper(rowIndex, targetColumnId);\n });\n },\n [rowVirtualizer, navigableColumnIds, store, focusCellWrapper],\n );\n\n const onRowAdd = React.useCallback(\n async (event?: React.MouseEvent) => {\n if (!onRowAddProp) return;\n\n const result = await onRowAddProp(event);\n\n if (event?.defaultPrevented || result === null) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n\n if (result) {\n const adjustedRowIndex =\n (result.rowIndex ?? 0) >= rows.length ? rows.length : result.rowIndex;\n\n onScrollToRow({\n rowIndex: adjustedRowIndex,\n columnId: result.columnId,\n });\n return;\n }\n\n onScrollToRow({ rowIndex: rows.length });\n },\n [onRowAddProp, onScrollToRow],\n );\n\n const searchState = React.useMemo(() => {\n if (!enableSearch) return undefined;\n\n return {\n searchMatches,\n matchIndex,\n searchOpen,\n onSearchOpenChange,\n searchQuery,\n onSearchQueryChange,\n onSearch,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n };\n }, [\n enableSearch,\n searchMatches,\n matchIndex,\n searchOpen,\n onSearchOpenChange,\n searchQuery,\n onSearchQueryChange,\n onSearch,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n ]);\n\n React.useEffect(() => {\n const dataGridElement = dataGridRef.current;\n if (!dataGridElement) return;\n\n dataGridElement.addEventListener(\"keydown\", onDataGridKeyDown);\n return () => {\n dataGridElement.removeEventListener(\"keydown\", onDataGridKeyDown);\n };\n }, [onDataGridKeyDown]);\n\n React.useEffect(() => {\n function onGlobalKeyDown(event: KeyboardEvent) {\n const dataGridElement = dataGridRef.current;\n if (!dataGridElement) return;\n\n const target = event.target;\n if (!(target instanceof HTMLElement)) return;\n\n const { key, ctrlKey, metaKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n if (enableSearch && isCtrlPressed && key === SEARCH_SHORTCUT_KEY) {\n const isInInput =\n target.tagName === \"INPUT\" || target.tagName === \"TEXTAREA\";\n const isInDataGrid = dataGridElement.contains(target);\n const isInSearchInput = target.closest('[role=\"search\"]') !== null;\n\n if (isInDataGrid || isInSearchInput || !isInInput) {\n event.preventDefault();\n event.stopPropagation();\n onSearchOpenChange(true);\n\n if (!isInDataGrid && !isInSearchInput) {\n requestAnimationFrame(() => {\n dataGridElement.focus();\n });\n }\n return;\n }\n }\n\n const isInDataGrid = dataGridElement.contains(target);\n if (!isInDataGrid) return;\n\n if (key === \"Escape\") {\n const currentState = store.getState();\n const hasSelections =\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0;\n\n if (hasSelections) {\n event.preventDefault();\n event.stopPropagation();\n clearSelection();\n }\n }\n }\n\n window.addEventListener(\"keydown\", onGlobalKeyDown, true);\n return () => {\n window.removeEventListener(\"keydown\", onGlobalKeyDown, true);\n };\n }, [enableSearch, onSearchOpenChange, store, clearSelection]);\n\n React.useEffect(() => {\n const currentState = store.getState();\n if (\n autoFocus &&\n data.length > 0 &&\n columns.length > 0 &&\n !currentState.focusedCell\n ) {\n if (navigableColumnIds.length > 0) {\n const rafId = requestAnimationFrame(() => {\n if (typeof autoFocus === \"object\") {\n const { rowIndex, columnId } = autoFocus;\n if (columnId) {\n focusCell(rowIndex ?? 0, columnId);\n }\n return;\n }\n\n const firstColumnId = navigableColumnIds[0];\n if (firstColumnId) {\n focusCell(0, firstColumnId);\n }\n });\n return () => cancelAnimationFrame(rafId);\n }\n }\n }, [\n autoFocus,\n data.length,\n columns.length,\n store,\n navigableColumnIds,\n focusCell,\n ]);\n\n React.useEffect(() => {\n function onOutsideClick(event: MouseEvent) {\n if (event.button === 2) {\n return;\n }\n\n if (\n dataGridRef.current &&\n !dataGridRef.current.contains(event.target as Node)\n ) {\n const target = event.target;\n const isInsidePopover =\n target instanceof HTMLElement &&\n (target.closest(\"[data-grid-cell-editor]\") ||\n target.closest(\"[data-grid-popover]\"));\n\n if (!isInsidePopover) {\n blurCell();\n const currentState = store.getState();\n if (\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0\n ) {\n clearSelection();\n }\n }\n }\n }\n\n document.addEventListener(\"mousedown\", onOutsideClick);\n return () => {\n document.removeEventListener(\"mousedown\", onOutsideClick);\n };\n }, [store, blurCell, clearSelection]);\n\n React.useEffect(() => {\n function onCleanup() {\n document.removeEventListener(\"selectstart\", preventSelection);\n document.removeEventListener(\"contextmenu\", preventContextMenu);\n document.body.style.userSelect = \"\";\n }\n\n function preventSelection(event: Event) {\n event.preventDefault();\n }\n function preventContextMenu(event: Event) {\n event.preventDefault();\n }\n\n const onUnsubscribe = store.subscribe(() => {\n const currentState = store.getState();\n if (currentState.selectionState.isSelecting) {\n document.addEventListener(\"selectstart\", preventSelection);\n document.addEventListener(\"contextmenu\", preventContextMenu);\n document.body.style.userSelect = \"none\";\n } else {\n onCleanup();\n }\n });\n\n return () => {\n onCleanup();\n onUnsubscribe();\n };\n }, [store]);\n\n useIsomorphicLayoutEffect(() => {\n const rafId = requestAnimationFrame(() => {\n rowVirtualizer.measure();\n });\n return () => cancelAnimationFrame(rafId);\n }, [\n table.getState().columnFilters,\n table.getState().columnOrder,\n table.getState().columnPinning,\n table.getState().columnSizing,\n table.getState().columnVisibility,\n table.getState().expanded,\n table.getState().globalFilter,\n table.getState().grouping,\n table.getState().rowSelection,\n table.getState().sorting,\n rowHeight,\n ]);\n\n return {\n dataGridRef,\n headerRef,\n rowMapRef,\n footerRef,\n table,\n rowVirtualizer,\n searchState,\n columnSizeVars,\n onRowAdd: onRowAddProp ? onRowAdd : undefined,\n };\n}\n\nexport { useDataGrid, type UseDataGridProps };\n", + "content": "\"use client\";\n\nimport {\n type ColumnDef,\n getCoreRowModel,\n getSortedRowModel,\n type RowSelectionState,\n type SortingState,\n type TableOptions,\n type Updater,\n useReactTable,\n} from \"@tanstack/react-table\";\nimport { useVirtualizer, type Virtualizer } from \"@tanstack/react-virtual\";\nimport * as React from \"react\";\nimport { DataGridCell } from \"@/components/data-grid/data-grid-cell\";\nimport { getCellKey, getRowHeightValue, parseCellKey } from \"@/lib/data-grid\";\nimport type {\n CellPosition,\n ContextMenuState,\n NavigationDirection,\n RowHeightValue,\n SearchState,\n SelectionState,\n UpdateCell,\n} from \"@/types/data-grid\";\n\nconst DEFAULT_ROW_HEIGHT = \"short\";\nconst OVERSCAN = 3;\nconst VIEWPORT_OFFSET = 1;\nconst MIN_COLUMN_SIZE = 60;\nconst MAX_COLUMN_SIZE = 800;\nconst SEARCH_SHORTCUT_KEY = \"f\";\nconst NON_NAVIGABLE_COLUMN_IDS = [\"select\", \"actions\"];\n\nconst useIsomorphicLayoutEffect =\n typeof window !== \"undefined\" ? React.useLayoutEffect : React.useEffect;\n\nfunction useLazyRef(fn: () => T): React.RefObject {\n const ref = React.useRef(null);\n if (ref.current === null) {\n ref.current = fn();\n }\n return ref as React.RefObject;\n}\n\nfunction useAsRef(data: T) {\n const ref = React.useRef(data);\n\n useIsomorphicLayoutEffect(() => {\n ref.current = data;\n });\n\n return ref;\n}\n\ninterface DataGridState {\n sorting: SortingState;\n rowHeight: RowHeightValue;\n rowSelection: RowSelectionState;\n selectionState: SelectionState;\n focusedCell: CellPosition | null;\n editingCell: CellPosition | null;\n contextMenu: ContextMenuState;\n searchQuery: string;\n searchMatches: CellPosition[];\n matchIndex: number;\n searchOpen: boolean;\n lastClickedRowIndex: number | null;\n isScrolling: boolean;\n}\n\ninterface DataGridStore {\n subscribe: (callback: () => void) => () => void;\n getState: () => DataGridState;\n setState: (\n key: K,\n value: DataGridState[K],\n ) => void;\n notify: () => void;\n batch: (fn: () => void) => void;\n}\n\nfunction useStore(\n store: DataGridStore,\n selector: (state: DataGridState) => T,\n): T {\n const getSnapshot = React.useCallback(\n () => selector(store.getState()),\n [store, selector],\n );\n\n return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);\n}\n\ninterface UseDataGridProps\n extends Omit, \"pageCount\" | \"getCoreRowModel\"> {\n onDataChange?: (data: TData[]) => void;\n onRowAdd?: (event?: React.MouseEvent) =>\n | Partial\n | Promise>\n | null\n // biome-ignore lint/suspicious/noConfusingVoidType: void is needed here to allow functions without explicit return\n | void;\n onRowsDelete?: (rows: TData[], rowIndices: number[]) => void | Promise;\n rowHeight?: RowHeightValue;\n overscan?: number;\n autoFocus?: boolean | Partial;\n enableColumnSelection?: boolean;\n enableSearch?: boolean;\n}\n\nfunction useDataGrid({\n columns,\n data,\n onDataChange,\n onRowAdd: onRowAddProp,\n onRowsDelete: onRowsDeleteProp,\n rowHeight: rowHeightProp = DEFAULT_ROW_HEIGHT,\n overscan = OVERSCAN,\n initialState,\n autoFocus = false,\n enableColumnSelection = false,\n enableSearch = false,\n ...dataGridProps\n}: UseDataGridProps) {\n const dataGridRef = React.useRef(null);\n const tableRef = React.useRef>>(null);\n const rowVirtualizerRef =\n React.useRef>(null);\n const headerRef = React.useRef(null);\n const rowMapRef = React.useRef>(new Map());\n const cellMapRef = React.useRef>(new Map());\n const footerRef = React.useRef(null);\n\n const dataGridPropsRef = useAsRef(dataGridProps);\n const listenersRef = useLazyRef(() => new Set<() => void>());\n\n const stateRef = useLazyRef(() => {\n return {\n sorting: initialState?.sorting ?? [],\n rowHeight: rowHeightProp,\n rowSelection: initialState?.rowSelection ?? {},\n selectionState: {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n },\n focusedCell: null,\n editingCell: null,\n contextMenu: {\n open: false,\n x: 0,\n y: 0,\n },\n searchQuery: \"\",\n searchMatches: [],\n matchIndex: -1,\n searchOpen: false,\n lastClickedRowIndex: null,\n isScrolling: false,\n };\n });\n\n const store = React.useMemo(() => {\n let isBatching = false;\n let pendingNotification = false;\n\n return {\n subscribe: (callback) => {\n listenersRef.current.add(callback);\n return () => listenersRef.current.delete(callback);\n },\n getState: () => stateRef.current,\n setState: (key, value) => {\n if (Object.is(stateRef.current[key], value)) return;\n stateRef.current[key] = value;\n\n if (isBatching) {\n pendingNotification = true;\n } else {\n if (!pendingNotification) {\n pendingNotification = true;\n queueMicrotask(() => {\n pendingNotification = false;\n store.notify();\n });\n }\n }\n },\n notify: () => {\n for (const listener of listenersRef.current) {\n listener();\n }\n },\n batch: (fn) => {\n if (isBatching) {\n fn();\n return;\n }\n\n isBatching = true;\n const wasPending = pendingNotification;\n pendingNotification = false;\n\n try {\n fn();\n } finally {\n isBatching = false;\n if (pendingNotification || wasPending) {\n pendingNotification = false;\n store.notify();\n }\n }\n },\n };\n }, [listenersRef, stateRef]);\n\n const focusedCell = useStore(store, (state) => state.focusedCell);\n const editingCell = useStore(store, (state) => state.editingCell);\n const selectionState = useStore(store, (state) => state.selectionState);\n const searchQuery = useStore(store, (state) => state.searchQuery);\n const searchMatches = useStore(store, (state) => state.searchMatches);\n const matchIndex = useStore(store, (state) => state.matchIndex);\n const searchOpen = useStore(store, (state) => state.searchOpen);\n const sorting = useStore(store, (state) => state.sorting);\n const rowSelection = useStore(store, (state) => state.rowSelection);\n const contextMenu = useStore(store, (state) => state.contextMenu);\n const rowHeight = useStore(store, (state) => state.rowHeight);\n const isScrolling = useStore(store, (state) => state.isScrolling);\n\n const rowHeightValue = getRowHeightValue(rowHeight);\n\n const columnIds = React.useMemo(() => {\n return columns\n .map((c) => {\n if (c.id) return c.id;\n if (\"accessorKey\" in c) return c.accessorKey as string;\n return undefined;\n })\n .filter((id): id is string => Boolean(id));\n }, [columns]);\n\n const navigableColumnIds = React.useMemo(() => {\n return columnIds.filter((c) => !NON_NAVIGABLE_COLUMN_IDS.includes(c));\n }, [columnIds]);\n\n const onDataUpdate = React.useCallback(\n (updates: UpdateCell | Array) => {\n const updateArray = Array.isArray(updates) ? updates : [updates];\n\n if (updateArray.length === 0) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n\n const rowUpdatesMap = new Map<\n number,\n Array>\n >();\n\n for (const update of updateArray) {\n if (!rows || !currentTable) {\n const existingUpdates = rowUpdatesMap.get(update.rowIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(update.rowIndex, existingUpdates);\n } else {\n const row = rows[update.rowIndex];\n if (!row) continue;\n\n const originalData = row.original;\n const originalRowIndex = data.indexOf(originalData);\n if (originalRowIndex === -1) continue;\n\n const existingUpdates = rowUpdatesMap.get(originalRowIndex) ?? [];\n existingUpdates.push({\n columnId: update.columnId,\n value: update.value,\n });\n rowUpdatesMap.set(originalRowIndex, existingUpdates);\n }\n }\n\n const newData = data.map((row, index) => {\n const updates = rowUpdatesMap.get(index);\n if (!updates) return row;\n\n const updatedRow = { ...row } as Record;\n for (const { columnId, value } of updates) {\n updatedRow[columnId] = value;\n }\n return updatedRow as TData;\n });\n\n onDataChange?.(newData);\n },\n [data, onDataChange],\n );\n\n const getIsCellSelected = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return selectionState.selectedCells.has(getCellKey(rowIndex, columnId));\n },\n [selectionState.selectedCells],\n );\n\n const clearSelection = React.useCallback(() => {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"rowSelection\", {});\n });\n }, [store]);\n\n const selectAll = React.useCallback(() => {\n const allCells = new Set();\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {\n for (const columnId of columnIds) {\n allCells.add(getCellKey(rowIndex, columnId));\n }\n }\n\n const firstColumnId = columnIds[0];\n const lastColumnId = columnIds[columnIds.length - 1];\n\n store.setState(\"selectionState\", {\n selectedCells: allCells,\n selectionRange:\n columnIds.length > 0 && rowCount > 0 && firstColumnId && lastColumnId\n ? {\n start: { rowIndex: 0, columnId: firstColumnId },\n end: { rowIndex: rowCount - 1, columnId: lastColumnId },\n }\n : null,\n isSelecting: false,\n });\n }, [columnIds, data.length, store]);\n\n const selectColumn = React.useCallback(\n (columnId: string) => {\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n if (rowCount === 0) return;\n\n const selectedCells = new Set();\n\n for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: {\n start: { rowIndex: 0, columnId },\n end: { rowIndex: rowCount - 1, columnId },\n },\n isSelecting: false,\n });\n },\n [data.length, store],\n );\n\n const selectRange = React.useCallback(\n (start: CellPosition, end: CellPosition, isSelecting = false) => {\n const startColIndex = columnIds.indexOf(start.columnId);\n const endColIndex = columnIds.indexOf(end.columnId);\n\n const minRow = Math.min(start.rowIndex, end.rowIndex);\n const maxRow = Math.max(start.rowIndex, end.rowIndex);\n const minCol = Math.min(startColIndex, endColIndex);\n const maxCol = Math.max(startColIndex, endColIndex);\n\n const selectedCells = new Set();\n\n for (let rowIndex = minRow; rowIndex <= maxRow; rowIndex++) {\n for (let colIndex = minCol; colIndex <= maxCol; colIndex++) {\n const columnId = columnIds[colIndex];\n if (columnId) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n }\n }\n\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: { start, end },\n isSelecting,\n });\n },\n [columnIds, store],\n );\n\n const focusCellWrapper = React.useCallback(\n (rowIndex: number, columnId: string) => {\n requestAnimationFrame(() => {\n const cellKey = getCellKey(rowIndex, columnId);\n const cellWrapperElement = cellMapRef.current.get(cellKey);\n\n if (!cellWrapperElement) return;\n\n cellWrapperElement.focus();\n });\n },\n [],\n );\n\n const focusCell = React.useCallback(\n (rowIndex: number, columnId: string) => {\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", null);\n });\n\n const currentState = store.getState();\n\n if (currentState.searchOpen) return;\n\n focusCellWrapper(rowIndex, columnId);\n },\n [store, focusCellWrapper],\n );\n\n const onRowsDelete = React.useCallback(\n async (rowIndices: number[]) => {\n if (!onRowsDeleteProp || rowIndices.length === 0) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows;\n\n if (!rows || rows.length === 0) return;\n\n const currentState = store.getState();\n const currentFocusedColumn =\n currentState.focusedCell?.columnId ?? navigableColumnIds[0];\n\n const minDeletedRowIndex = Math.min(...rowIndices);\n\n const rowsToDelete: TData[] = [];\n for (const rowIndex of rowIndices) {\n const row = rows[rowIndex];\n if (row) {\n rowsToDelete.push(row.original);\n }\n }\n\n await onRowsDeleteProp(rowsToDelete, rowIndices);\n\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"rowSelection\", {});\n store.setState(\"editingCell\", null);\n });\n\n requestAnimationFrame(() => {\n const currentTable = tableRef.current;\n const currentRows = currentTable?.getRowModel().rows ?? [];\n const newRowCount = currentRows.length ?? data.length;\n\n if (newRowCount > 0 && currentFocusedColumn) {\n const targetRowIndex = Math.min(minDeletedRowIndex, newRowCount - 1);\n focusCell(targetRowIndex, currentFocusedColumn);\n }\n });\n },\n [onRowsDeleteProp, data.length, store, navigableColumnIds, focusCell],\n );\n\n const navigateCell = React.useCallback(\n (direction: NavigationDirection) => {\n const currentState = store.getState();\n if (!currentState.focusedCell) return;\n\n const { rowIndex, columnId } = currentState.focusedCell;\n const currentColIndex = navigableColumnIds.indexOf(columnId);\n const rowVirtualizer = rowVirtualizerRef.current;\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n let newRowIndex = rowIndex;\n let newColumnId = columnId;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(rowCount - 1, rowIndex + 1);\n break;\n case \"left\":\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n break;\n case \"right\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n break;\n case \"home\":\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? columnId;\n }\n break;\n case \"end\":\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;\n }\n break;\n case \"ctrl+home\":\n newRowIndex = 0;\n if (navigableColumnIds.length > 0) {\n newColumnId = navigableColumnIds[0] ?? columnId;\n }\n break;\n case \"ctrl+end\":\n newRowIndex = Math.max(0, rowCount - 1);\n if (navigableColumnIds.length > 0) {\n newColumnId =\n navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;\n }\n break;\n case \"pageup\":\n if (rowVirtualizer) {\n const visibleRange = rowVirtualizer.getVirtualItems();\n const pageSize = visibleRange.length ?? 10;\n newRowIndex = Math.max(0, rowIndex - pageSize);\n } else {\n newRowIndex = Math.max(0, rowIndex - 10);\n }\n break;\n case \"pagedown\":\n if (rowVirtualizer) {\n const visibleRange = rowVirtualizer.getVirtualItems();\n const pageSize = visibleRange.length ?? 10;\n newRowIndex = Math.min(rowCount - 1, rowIndex + pageSize);\n } else {\n newRowIndex = Math.min(rowCount - 1, rowIndex + 10);\n }\n break;\n }\n\n if (newRowIndex !== rowIndex || newColumnId !== columnId) {\n const rowDiff = newRowIndex - rowIndex;\n\n // For single-row vertical navigation (up/down arrows)\n if (\n Math.abs(rowDiff) === 1 &&\n (direction === \"up\" || direction === \"down\")\n ) {\n const container = dataGridRef.current;\n const currentRow = rowMapRef.current.get(rowIndex);\n const targetRow = rowMapRef.current.get(newRowIndex);\n\n if (!container || !currentRow) {\n // Fallback to simple focus if we can't find elements\n focusCell(newRowIndex, newColumnId);\n return;\n }\n\n // Check viewport boundaries\n const containerRect = container.getBoundingClientRect();\n const headerHeight =\n headerRef.current?.getBoundingClientRect().height ?? 0;\n const footerHeight =\n footerRef.current?.getBoundingClientRect().height ?? 0;\n\n const viewportTop =\n containerRect.top + headerHeight + VIEWPORT_OFFSET;\n const viewportBottom =\n containerRect.bottom - footerHeight - VIEWPORT_OFFSET;\n\n // If target row already exists, check if it's visible\n if (targetRow) {\n const rowRect = targetRow.getBoundingClientRect();\n const isFullyVisible =\n rowRect.top >= viewportTop && rowRect.bottom <= viewportBottom;\n\n if (isFullyVisible) {\n // Row is fully visible, just focus it\n focusCell(newRowIndex, newColumnId);\n return;\n }\n\n // Row exists but not fully visible, scroll it into view\n focusCell(newRowIndex, newColumnId);\n\n if (direction === \"down\") {\n // Scroll just enough to show the row at the bottom\n const scrollNeeded = rowRect.bottom - viewportBottom;\n container.scrollTop += scrollNeeded;\n } else {\n // Scroll just enough to show the row at the top\n const scrollNeeded = viewportTop - rowRect.top;\n container.scrollTop -= scrollNeeded;\n }\n return;\n }\n\n // Target row is not rendered yet\n // Focus immediately so the ring appears as the row is revealed\n focusCell(newRowIndex, newColumnId);\n\n // Scroll by exactly one row height to reveal it smoothly\n if (direction === \"down\") {\n container.scrollTop += rowHeightValue;\n } else {\n // For arrow up, ensure we don't go below 0\n const currentScrollTop = container.scrollTop;\n const targetScrollTop = Math.max(\n 0,\n currentScrollTop - rowHeightValue,\n );\n container.scrollTop = targetScrollTop;\n }\n return;\n }\n\n // For larger jumps (page up/down, ctrl+home/end, etc.)\n if (rowVirtualizer && Math.abs(rowDiff) > 1) {\n const align =\n direction === \"pageup\" || direction === \"ctrl+home\"\n ? \"start\"\n : direction === \"pagedown\" || direction === \"ctrl+end\"\n ? \"end\"\n : \"center\";\n rowVirtualizer.scrollToIndex(newRowIndex, { align });\n requestAnimationFrame(() => {\n focusCell(newRowIndex, newColumnId);\n });\n return;\n }\n\n // For horizontal navigation or when row is already visible\n focusCell(newRowIndex, newColumnId);\n }\n },\n [store, navigableColumnIds, focusCell, data.length, rowHeightValue],\n );\n\n const onCellEditingStart = React.useCallback(\n (rowIndex: number, columnId: string) => {\n store.batch(() => {\n store.setState(\"focusedCell\", { rowIndex, columnId });\n store.setState(\"editingCell\", { rowIndex, columnId });\n });\n },\n [store],\n );\n\n const onCellEditingStop = React.useCallback(\n (opts?: { moveToNextRow?: boolean; direction?: NavigationDirection }) => {\n const currentState = store.getState();\n const currentEditing = currentState.editingCell;\n\n store.setState(\"editingCell\", null);\n\n if (opts?.moveToNextRow && currentEditing) {\n const { rowIndex, columnId } = currentEditing;\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n const rowCount = rows.length ?? data.length;\n\n const nextRowIndex = rowIndex + 1;\n if (nextRowIndex < rowCount) {\n requestAnimationFrame(() => {\n focusCell(nextRowIndex, columnId);\n });\n }\n } else if (opts?.direction && currentEditing) {\n // Focus the current editing cell first, then navigate\n const { rowIndex, columnId } = currentEditing;\n focusCell(rowIndex, columnId);\n requestAnimationFrame(() => {\n navigateCell(opts.direction ?? \"right\");\n });\n } else if (currentEditing) {\n // No navigation - just refocus the current cell (e.g., when pressing Escape)\n const { rowIndex, columnId } = currentEditing;\n focusCellWrapper(rowIndex, columnId);\n }\n },\n [store, data.length, focusCell, navigateCell, focusCellWrapper],\n );\n\n const onSearchOpenChange = React.useCallback(\n (open: boolean) => {\n if (open) {\n store.setState(\"searchOpen\", true);\n return;\n }\n\n const currentState = store.getState();\n const currentMatch =\n currentState.matchIndex >= 0 &&\n currentState.searchMatches[currentState.matchIndex];\n\n store.batch(() => {\n store.setState(\"searchOpen\", false);\n store.setState(\"searchQuery\", \"\");\n store.setState(\"searchMatches\", []);\n store.setState(\"matchIndex\", -1);\n\n if (currentMatch) {\n store.setState(\"focusedCell\", {\n rowIndex: currentMatch.rowIndex,\n columnId: currentMatch.columnId,\n });\n }\n });\n\n if (\n dataGridRef.current &&\n document.activeElement !== dataGridRef.current\n ) {\n dataGridRef.current.focus();\n }\n },\n [store],\n );\n\n const onSearch = React.useCallback(\n (query: string) => {\n if (!query.trim()) {\n store.batch(() => {\n store.setState(\"searchMatches\", []);\n store.setState(\"matchIndex\", -1);\n });\n return;\n }\n\n const matches: CellPosition[] = [];\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n\n const lowerQuery = query.toLowerCase();\n\n for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {\n const row = rows[rowIndex];\n if (!row) continue;\n\n for (const columnId of columnIds) {\n const cell = row\n .getVisibleCells()\n .find((c) => c.column.id === columnId);\n if (!cell) continue;\n\n const value = cell.getValue();\n const stringValue = String(value ?? \"\").toLowerCase();\n\n if (stringValue.includes(lowerQuery)) {\n matches.push({ rowIndex, columnId });\n }\n }\n }\n\n store.batch(() => {\n store.setState(\"searchMatches\", matches);\n store.setState(\"matchIndex\", matches.length > 0 ? 0 : -1);\n });\n\n // Scroll to first match but don't focus it (to keep focus in search input)\n if (matches.length > 0 && matches[0]) {\n const firstMatch = matches[0];\n rowVirtualizerRef.current?.scrollToIndex(firstMatch.rowIndex, {\n align: \"center\",\n });\n }\n },\n [columnIds, store],\n );\n\n const onSearchQueryChange = React.useCallback(\n (query: string) => store.setState(\"searchQuery\", query),\n [store],\n );\n\n const onNavigateToPrevMatch = React.useCallback(() => {\n const currentState = store.getState();\n if (currentState.searchMatches.length === 0) return;\n\n const prevIndex =\n currentState.matchIndex - 1 < 0\n ? currentState.searchMatches.length - 1\n : currentState.matchIndex - 1;\n const match = currentState.searchMatches[prevIndex];\n\n if (match) {\n rowVirtualizerRef.current?.scrollToIndex(match.rowIndex, {\n align: \"center\",\n });\n\n requestAnimationFrame(() => {\n store.setState(\"matchIndex\", prevIndex);\n requestAnimationFrame(() => {\n focusCell(match.rowIndex, match.columnId);\n });\n });\n }\n }, [store, focusCell]);\n\n const onNavigateToNextMatch = React.useCallback(() => {\n const currentState = store.getState();\n if (currentState.searchMatches.length === 0) return;\n\n const nextIndex =\n (currentState.matchIndex + 1) % currentState.searchMatches.length;\n const match = currentState.searchMatches[nextIndex];\n\n if (match) {\n rowVirtualizerRef.current?.scrollToIndex(match.rowIndex, {\n align: \"center\",\n });\n\n requestAnimationFrame(() => {\n store.setState(\"matchIndex\", nextIndex);\n requestAnimationFrame(() => {\n focusCell(match.rowIndex, match.columnId);\n });\n });\n }\n }, [store, focusCell]);\n\n const getIsSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n return searchMatches.some(\n (match) => match.rowIndex === rowIndex && match.columnId === columnId,\n );\n },\n [searchMatches],\n );\n\n const getIsActiveSearchMatch = React.useCallback(\n (rowIndex: number, columnId: string) => {\n if (matchIndex < 0) return false;\n const currentMatch = searchMatches[matchIndex];\n return (\n currentMatch?.rowIndex === rowIndex &&\n currentMatch?.columnId === columnId\n );\n },\n [searchMatches, matchIndex],\n );\n\n const blurCell = React.useCallback(() => {\n const currentState = store.getState();\n if (\n currentState.editingCell &&\n document.activeElement instanceof HTMLElement\n ) {\n document.activeElement.blur();\n }\n\n store.batch(() => {\n store.setState(\"focusedCell\", null);\n store.setState(\"editingCell\", null);\n });\n }, [store]);\n\n const onCellClick = React.useCallback(\n (rowIndex: number, columnId: string, event?: React.MouseEvent) => {\n // Ignore right-click (button 2) - let onCellContextMenu handle it\n if (event?.button === 2) {\n return;\n }\n\n const currentState = store.getState();\n const currentFocused = currentState.focusedCell;\n\n if (event) {\n if (event.ctrlKey || event.metaKey) {\n event.preventDefault();\n const cellKey = getCellKey(rowIndex, columnId);\n const newSelectedCells = new Set(\n currentState.selectionState.selectedCells,\n );\n\n if (newSelectedCells.has(cellKey)) {\n newSelectedCells.delete(cellKey);\n } else {\n newSelectedCells.add(cellKey);\n }\n\n store.setState(\"selectionState\", {\n selectedCells: newSelectedCells,\n selectionRange: null,\n isSelecting: false,\n });\n focusCell(rowIndex, columnId);\n return;\n }\n\n if (event.shiftKey && currentState.focusedCell) {\n event.preventDefault();\n selectRange(currentState.focusedCell, { rowIndex, columnId });\n return;\n }\n }\n\n // Clear selection if there are selected cells or rows\n const hasSelectedCells =\n currentState.selectionState.selectedCells.size > 0;\n const hasSelectedRows = Object.keys(currentState.rowSelection).length > 0;\n\n if (hasSelectedCells && !currentState.selectionState.isSelecting) {\n // If there's a cell selection but we're not actively selecting (drag just finished),\n // don't clear it - keep the selection\n // Only clear if clicking elsewhere\n const cellKey = getCellKey(rowIndex, columnId);\n const isClickingSelectedCell =\n currentState.selectionState.selectedCells.has(cellKey);\n\n if (!isClickingSelectedCell) {\n clearSelection();\n } else {\n // Clicking on an already selected cell - just focus it\n focusCell(rowIndex, columnId);\n return;\n }\n } else if (hasSelectedRows && columnId !== \"select\") {\n // If there are selected rows but we're clicking on a non-checkbox cell, clear selections\n clearSelection();\n }\n\n if (\n currentFocused?.rowIndex === rowIndex &&\n currentFocused?.columnId === columnId\n ) {\n onCellEditingStart(rowIndex, columnId);\n } else {\n focusCell(rowIndex, columnId);\n }\n },\n [store, focusCell, onCellEditingStart, selectRange, clearSelection],\n );\n\n const onCellDoubleClick = React.useCallback(\n (rowIndex: number, columnId: string, event?: React.MouseEvent) => {\n if (event?.defaultPrevented) return;\n\n onCellEditingStart(rowIndex, columnId);\n },\n [onCellEditingStart],\n );\n\n const onCellMouseDown = React.useCallback(\n (rowIndex: number, columnId: string, event: React.MouseEvent) => {\n // Ignore right-click (button 2) - let onCellContextMenu handle it\n if (event.button === 2) {\n return;\n }\n\n event.preventDefault();\n\n // Only start drag selection if no modifier keys are pressed\n // Clear any existing selection and prepare for potential drag\n if (!event.ctrlKey && !event.metaKey && !event.shiftKey) {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set(),\n selectionRange: {\n start: { rowIndex, columnId },\n end: { rowIndex, columnId },\n },\n isSelecting: true,\n });\n store.setState(\"rowSelection\", {});\n });\n }\n },\n [store],\n );\n\n const onCellMouseEnter = React.useCallback(\n (rowIndex: number, columnId: string, _event: React.MouseEvent) => {\n const currentState = store.getState();\n if (\n currentState.selectionState.isSelecting &&\n currentState.selectionState.selectionRange\n ) {\n const start = currentState.selectionState.selectionRange.start;\n const end = { rowIndex, columnId };\n\n if (\n currentState.focusedCell?.rowIndex !== start.rowIndex ||\n currentState.focusedCell?.columnId !== start.columnId\n ) {\n focusCell(start.rowIndex, start.columnId);\n }\n\n selectRange(start, end, true);\n }\n },\n [store, selectRange, focusCell],\n );\n\n const onCellMouseUp = React.useCallback(() => {\n const currentState = store.getState();\n store.setState(\"selectionState\", {\n ...currentState.selectionState,\n isSelecting: false,\n });\n }, [store]);\n\n const onCellContextMenu = React.useCallback(\n (rowIndex: number, columnId: string, event: React.MouseEvent) => {\n event.preventDefault();\n event.stopPropagation();\n\n const currentState = store.getState();\n const cellKey = getCellKey(rowIndex, columnId);\n const isTargetCellSelected =\n currentState.selectionState.selectedCells.has(cellKey);\n\n // If right-clicking on a non-selected cell, select only that cell\n if (!isTargetCellSelected) {\n store.batch(() => {\n store.setState(\"selectionState\", {\n selectedCells: new Set([cellKey]),\n selectionRange: {\n start: { rowIndex, columnId },\n end: { rowIndex, columnId },\n },\n isSelecting: false,\n });\n store.setState(\"focusedCell\", { rowIndex, columnId });\n });\n }\n\n // Open context menu at cursor position\n store.setState(\"contextMenu\", {\n open: true,\n x: event.clientX,\n y: event.clientY,\n });\n },\n [store],\n );\n\n const onContextMenuOpenChange = React.useCallback(\n (open: boolean) => {\n if (!open) {\n const currentMenu = store.getState().contextMenu;\n store.setState(\"contextMenu\", {\n open: false,\n x: currentMenu.x,\n y: currentMenu.y,\n });\n }\n },\n [store],\n );\n\n const onDataGridKeyDown = React.useCallback(\n (event: KeyboardEvent) => {\n const currentState = store.getState();\n const { key, ctrlKey, metaKey, shiftKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n // Handle Cmd+F / Ctrl+F to open search (highest priority, works even when editing)\n if (enableSearch && isCtrlPressed && key === SEARCH_SHORTCUT_KEY) {\n event.preventDefault();\n onSearchOpenChange(true);\n return;\n }\n\n // Handle search navigation when search is open\n if (\n enableSearch &&\n currentState.searchOpen &&\n !currentState.editingCell\n ) {\n if (key === \"Enter\") {\n event.preventDefault();\n if (shiftKey) {\n onNavigateToPrevMatch();\n } else {\n onNavigateToNextMatch();\n }\n return;\n }\n if (key === \"Escape\") {\n event.preventDefault();\n onSearchOpenChange(false);\n return;\n }\n // When search is open, don't let data grid handle any other keys\n // (they should only affect the search input)\n return;\n }\n\n if (currentState.editingCell) return;\n\n if (!currentState.focusedCell) return;\n\n let direction: NavigationDirection | null = null;\n\n if (isCtrlPressed && key === \"a\") {\n event.preventDefault();\n selectAll();\n return;\n }\n\n if (key === \"Delete\" || key === \"Backspace\") {\n if (currentState.selectionState.selectedCells.size > 0) {\n event.preventDefault();\n\n const updates: Array<{\n rowIndex: number;\n columnId: string;\n value: unknown;\n }> = [];\n\n const currentTable = tableRef.current;\n const tableColumns = currentTable?.getAllColumns() ?? [];\n\n currentState.selectionState.selectedCells.forEach((cellKey) => {\n const { rowIndex, columnId } = parseCellKey(cellKey);\n\n const column = tableColumns.find((col) => col.id === columnId);\n const cellVariant = column?.columnDef?.meta?.cell?.variant;\n\n let emptyValue: unknown = \"\";\n if (cellVariant === \"multi-select\" || cellVariant === \"file\") {\n emptyValue = [];\n } else if (cellVariant === \"number\" || cellVariant === \"date\") {\n emptyValue = null;\n } else if (cellVariant === \"checkbox\") {\n emptyValue = false;\n }\n\n updates.push({ rowIndex, columnId, value: emptyValue });\n });\n\n onDataUpdate(updates);\n clearSelection();\n }\n return;\n }\n\n switch (key) {\n case \"ArrowUp\":\n direction = \"up\";\n break;\n case \"ArrowDown\":\n direction = \"down\";\n break;\n case \"ArrowLeft\":\n direction = \"left\";\n break;\n case \"ArrowRight\":\n direction = \"right\";\n break;\n case \"Home\":\n direction = isCtrlPressed ? \"ctrl+home\" : \"home\";\n break;\n case \"End\":\n direction = isCtrlPressed ? \"ctrl+end\" : \"end\";\n break;\n case \"PageUp\":\n direction = \"pageup\";\n break;\n case \"PageDown\":\n direction = \"pagedown\";\n break;\n case \"Escape\":\n event.preventDefault();\n if (\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0\n ) {\n clearSelection();\n } else {\n blurCell();\n }\n return;\n case \"Tab\":\n event.preventDefault();\n direction = event.shiftKey ? \"left\" : \"right\";\n break;\n }\n\n if (direction) {\n event.preventDefault();\n\n // Tab navigation should not trigger selection, even with Shift\n if (shiftKey && key !== \"Tab\" && currentState.focusedCell) {\n const currentColIndex = navigableColumnIds.indexOf(\n currentState.focusedCell.columnId,\n );\n let newRowIndex = currentState.focusedCell.rowIndex;\n let newColumnId = currentState.focusedCell.columnId;\n\n switch (direction) {\n case \"up\":\n newRowIndex = Math.max(0, currentState.focusedCell.rowIndex - 1);\n break;\n case \"down\":\n newRowIndex = Math.min(\n (tableRef.current?.getRowModel().rows.length || data.length) -\n 1,\n currentState.focusedCell.rowIndex + 1,\n );\n break;\n case \"left\":\n if (currentColIndex > 0) {\n const prevColumnId = navigableColumnIds[currentColIndex - 1];\n if (prevColumnId) newColumnId = prevColumnId;\n }\n break;\n case \"right\":\n if (currentColIndex < navigableColumnIds.length - 1) {\n const nextColumnId = navigableColumnIds[currentColIndex + 1];\n if (nextColumnId) newColumnId = nextColumnId;\n }\n break;\n }\n\n const selectionStart =\n currentState.selectionState.selectionRange?.start ||\n currentState.focusedCell;\n selectRange(selectionStart, {\n rowIndex: newRowIndex,\n columnId: newColumnId,\n });\n focusCell(newRowIndex, newColumnId);\n } else {\n if (currentState.selectionState.selectedCells.size > 0) {\n clearSelection();\n }\n navigateCell(direction);\n }\n }\n },\n [\n store,\n blurCell,\n navigateCell,\n selectAll,\n onDataUpdate,\n clearSelection,\n navigableColumnIds,\n data.length,\n selectRange,\n focusCell,\n onSearchOpenChange,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n enableSearch,\n ],\n );\n\n const onSortingChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newSorting =\n typeof updater === \"function\" ? updater(currentState.sorting) : updater;\n store.setState(\"sorting\", newSorting);\n },\n [store],\n );\n\n const onRowSelectionChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newRowSelection =\n typeof updater === \"function\"\n ? updater(currentState.rowSelection)\n : updater;\n\n const selectedRows = Object.keys(newRowSelection).filter(\n (key) => newRowSelection[key],\n );\n\n const selectedCells = new Set();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n\n for (const rowId of selectedRows) {\n const rowIndex = rows.findIndex((r) => r.id === rowId);\n if (rowIndex === -1) continue;\n\n for (const columnId of columnIds) {\n selectedCells.add(getCellKey(rowIndex, columnId));\n }\n }\n\n store.batch(() => {\n store.setState(\"rowSelection\", newRowSelection);\n store.setState(\"selectionState\", {\n selectedCells,\n selectionRange: null,\n isSelecting: false,\n });\n store.setState(\"focusedCell\", null);\n store.setState(\"editingCell\", null);\n });\n },\n [store, columnIds],\n );\n\n const onRowSelect = React.useCallback(\n (rowIndex: number, selected: boolean, shiftKey: boolean) => {\n const currentState = store.getState();\n const rows = tableRef.current?.getRowModel().rows ?? [];\n const currentRow = rows[rowIndex];\n if (!currentRow) return;\n\n if (shiftKey && currentState.lastClickedRowIndex !== null) {\n const startIndex = Math.min(currentState.lastClickedRowIndex, rowIndex);\n const endIndex = Math.max(currentState.lastClickedRowIndex, rowIndex);\n\n const newRowSelection: RowSelectionState = {\n ...currentState.rowSelection,\n };\n\n for (let i = startIndex; i <= endIndex; i++) {\n const row = rows[i];\n if (row) {\n newRowSelection[row.id] = selected;\n }\n }\n\n onRowSelectionChange(newRowSelection);\n } else {\n onRowSelectionChange({\n ...currentState.rowSelection,\n [currentRow.id]: selected,\n });\n }\n\n store.setState(\"lastClickedRowIndex\", rowIndex);\n },\n [store, onRowSelectionChange],\n );\n\n const onRowHeightChange = React.useCallback(\n (updater: Updater) => {\n const currentState = store.getState();\n const newRowHeight =\n typeof updater === \"function\"\n ? updater(currentState.rowHeight)\n : updater;\n store.setState(\"rowHeight\", newRowHeight);\n },\n [store],\n );\n\n const onColumnClick = React.useCallback(\n (columnId: string) => {\n if (!enableColumnSelection) {\n clearSelection();\n return;\n }\n\n selectColumn(columnId);\n },\n [enableColumnSelection, selectColumn, clearSelection],\n );\n\n const defaultColumn: Partial> = React.useMemo(\n () => ({\n cell: DataGridCell,\n minSize: MIN_COLUMN_SIZE,\n maxSize: MAX_COLUMN_SIZE,\n }),\n [],\n );\n\n const tableOptions = React.useMemo>(\n () => ({\n ...dataGridPropsRef.current,\n data,\n columns,\n defaultColumn,\n initialState,\n state: {\n ...dataGridPropsRef.current.state,\n sorting,\n rowSelection,\n },\n onRowSelectionChange,\n onSortingChange,\n columnResizeMode: \"onChange\",\n getCoreRowModel: getCoreRowModel(),\n getSortedRowModel: getSortedRowModel(),\n meta: {\n ...dataGridPropsRef.current.meta,\n dataGridRef,\n cellMapRef,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n rowHeight,\n isScrolling,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n onRowHeightChange,\n onRowSelect,\n onRowsDelete: onRowsDeleteProp ? onRowsDelete : undefined,\n onDataUpdate,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n contextMenu,\n onContextMenuOpenChange,\n },\n }),\n [\n dataGridPropsRef,\n data,\n columns,\n defaultColumn,\n initialState,\n sorting,\n rowSelection,\n onRowSelectionChange,\n onSortingChange,\n focusedCell,\n editingCell,\n selectionState,\n searchOpen,\n isScrolling,\n getIsCellSelected,\n getIsSearchMatch,\n getIsActiveSearchMatch,\n onDataUpdate,\n onRowsDeleteProp,\n onRowsDelete,\n onColumnClick,\n onCellClick,\n onCellDoubleClick,\n onCellMouseDown,\n onCellMouseEnter,\n onCellMouseUp,\n onCellContextMenu,\n onCellEditingStart,\n onCellEditingStop,\n contextMenu,\n onContextMenuOpenChange,\n rowHeight,\n onRowHeightChange,\n onRowSelect,\n ],\n );\n\n const table = useReactTable(tableOptions);\n\n if (!tableRef.current) {\n tableRef.current = table;\n }\n\n // biome-ignore lint/correctness/useExhaustiveDependencies: we need to memoize the column size vars\n const columnSizeVars = React.useMemo(() => {\n const headers = table.getFlatHeaders();\n const colSizes: { [key: string]: number } = {};\n for (const header of headers) {\n colSizes[`--header-${header.id}-size`] = header.getSize();\n colSizes[`--col-${header.column.id}-size`] = header.column.getSize();\n }\n return colSizes;\n }, [table.getState().columnSizingInfo, table.getState().columnSizing]);\n\n const rowVirtualizer = useVirtualizer({\n count: table.getRowModel().rows.length,\n getScrollElement: () => dataGridRef.current,\n estimateSize: () => rowHeightValue,\n overscan,\n measureElement:\n typeof window !== \"undefined\" &&\n navigator.userAgent.indexOf(\"Firefox\") === -1\n ? (element) => element?.getBoundingClientRect().height\n : undefined,\n onChange: (instance) => {\n // Sync virtualizer's isScrolling state to our store\n const virtualizerIsScrolling = instance.isScrolling;\n const currentIsScrolling = store.getState().isScrolling;\n\n if (virtualizerIsScrolling !== currentIsScrolling) {\n store.setState(\"isScrolling\", virtualizerIsScrolling);\n }\n\n // Batch DOM updates in a single animation frame\n const virtualItems = instance.getVirtualItems();\n if (virtualItems.length === 0) return;\n\n requestAnimationFrame(() => {\n for (let i = 0; i < virtualItems.length; i++) {\n const virtualRow = virtualItems[i];\n if (!virtualRow) continue;\n const rowRef = rowMapRef.current.get(virtualRow.index);\n if (rowRef) {\n rowRef.style.transform = `translateY(${virtualRow.start}px)`;\n }\n }\n });\n },\n });\n\n if (!rowVirtualizerRef.current) {\n rowVirtualizerRef.current = rowVirtualizer;\n }\n\n const onScrollToRow = React.useCallback(\n async (opts: Partial) => {\n const rowIndex = opts?.rowIndex ?? 0;\n const columnId = opts?.columnId;\n\n rowVirtualizer.scrollToIndex(rowIndex, {\n align: \"center\",\n });\n\n const targetColumnId = columnId ?? navigableColumnIds[0];\n\n if (!targetColumnId) return;\n\n requestAnimationFrame(() => {\n store.batch(() => {\n store.setState(\"focusedCell\", {\n rowIndex,\n columnId: targetColumnId,\n });\n store.setState(\"editingCell\", null);\n });\n\n focusCellWrapper(rowIndex, targetColumnId);\n });\n },\n [rowVirtualizer, navigableColumnIds, store, focusCellWrapper],\n );\n\n const onRowAdd = React.useCallback(\n async (event?: React.MouseEvent) => {\n if (!onRowAddProp) return;\n\n const result = await onRowAddProp(event);\n\n if (event?.defaultPrevented || result === null) return;\n\n const currentTable = tableRef.current;\n const rows = currentTable?.getRowModel().rows ?? [];\n\n if (result) {\n const adjustedRowIndex =\n (result.rowIndex ?? 0) >= rows.length ? rows.length : result.rowIndex;\n\n onScrollToRow({\n rowIndex: adjustedRowIndex,\n columnId: result.columnId,\n });\n return;\n }\n\n onScrollToRow({ rowIndex: rows.length });\n },\n [onRowAddProp, onScrollToRow],\n );\n\n const searchState = React.useMemo(() => {\n if (!enableSearch) return undefined;\n\n return {\n searchMatches,\n matchIndex,\n searchOpen,\n onSearchOpenChange,\n searchQuery,\n onSearchQueryChange,\n onSearch,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n };\n }, [\n enableSearch,\n searchMatches,\n matchIndex,\n searchOpen,\n onSearchOpenChange,\n searchQuery,\n onSearchQueryChange,\n onSearch,\n onNavigateToNextMatch,\n onNavigateToPrevMatch,\n ]);\n\n React.useEffect(() => {\n const dataGridElement = dataGridRef.current;\n if (!dataGridElement) return;\n\n dataGridElement.addEventListener(\"keydown\", onDataGridKeyDown);\n return () => {\n dataGridElement.removeEventListener(\"keydown\", onDataGridKeyDown);\n };\n }, [onDataGridKeyDown]);\n\n React.useEffect(() => {\n function onGlobalKeyDown(event: KeyboardEvent) {\n const dataGridElement = dataGridRef.current;\n if (!dataGridElement) return;\n\n const target = event.target;\n if (!(target instanceof HTMLElement)) return;\n\n const { key, ctrlKey, metaKey } = event;\n const isCtrlPressed = ctrlKey || metaKey;\n\n if (enableSearch && isCtrlPressed && key === SEARCH_SHORTCUT_KEY) {\n const isInInput =\n target.tagName === \"INPUT\" || target.tagName === \"TEXTAREA\";\n const isInDataGrid = dataGridElement.contains(target);\n const isInSearchInput = target.closest('[role=\"search\"]') !== null;\n\n if (isInDataGrid || isInSearchInput || !isInInput) {\n event.preventDefault();\n event.stopPropagation();\n onSearchOpenChange(true);\n\n if (!isInDataGrid && !isInSearchInput) {\n requestAnimationFrame(() => {\n dataGridElement.focus();\n });\n }\n return;\n }\n }\n\n const isInDataGrid = dataGridElement.contains(target);\n if (!isInDataGrid) return;\n\n if (key === \"Escape\") {\n const currentState = store.getState();\n const hasSelections =\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0;\n\n if (hasSelections) {\n event.preventDefault();\n event.stopPropagation();\n clearSelection();\n }\n }\n }\n\n window.addEventListener(\"keydown\", onGlobalKeyDown, true);\n return () => {\n window.removeEventListener(\"keydown\", onGlobalKeyDown, true);\n };\n }, [enableSearch, onSearchOpenChange, store, clearSelection]);\n\n React.useEffect(() => {\n const currentState = store.getState();\n if (\n autoFocus &&\n data.length > 0 &&\n columns.length > 0 &&\n !currentState.focusedCell\n ) {\n if (navigableColumnIds.length > 0) {\n const rafId = requestAnimationFrame(() => {\n if (typeof autoFocus === \"object\") {\n const { rowIndex, columnId } = autoFocus;\n if (columnId) {\n focusCell(rowIndex ?? 0, columnId);\n }\n return;\n }\n\n const firstColumnId = navigableColumnIds[0];\n if (firstColumnId) {\n focusCell(0, firstColumnId);\n }\n });\n return () => cancelAnimationFrame(rafId);\n }\n }\n }, [\n autoFocus,\n data.length,\n columns.length,\n store,\n navigableColumnIds,\n focusCell,\n ]);\n\n React.useEffect(() => {\n function onOutsideClick(event: MouseEvent) {\n if (event.button === 2) {\n return;\n }\n\n if (\n dataGridRef.current &&\n !dataGridRef.current.contains(event.target as Node)\n ) {\n const target = event.target;\n const isInsidePopover =\n target instanceof HTMLElement &&\n (target.closest(\"[data-grid-cell-editor]\") ||\n target.closest(\"[data-grid-popover]\"));\n\n if (!isInsidePopover) {\n blurCell();\n const currentState = store.getState();\n if (\n currentState.selectionState.selectedCells.size > 0 ||\n Object.keys(currentState.rowSelection).length > 0\n ) {\n clearSelection();\n }\n }\n }\n }\n\n document.addEventListener(\"mousedown\", onOutsideClick);\n return () => {\n document.removeEventListener(\"mousedown\", onOutsideClick);\n };\n }, [store, blurCell, clearSelection]);\n\n React.useEffect(() => {\n function onCleanup() {\n document.removeEventListener(\"selectstart\", preventSelection);\n document.removeEventListener(\"contextmenu\", preventContextMenu);\n document.body.style.userSelect = \"\";\n }\n\n function preventSelection(event: Event) {\n event.preventDefault();\n }\n function preventContextMenu(event: Event) {\n event.preventDefault();\n }\n\n const onUnsubscribe = store.subscribe(() => {\n const currentState = store.getState();\n if (currentState.selectionState.isSelecting) {\n document.addEventListener(\"selectstart\", preventSelection);\n document.addEventListener(\"contextmenu\", preventContextMenu);\n document.body.style.userSelect = \"none\";\n } else {\n onCleanup();\n }\n });\n\n return () => {\n onCleanup();\n onUnsubscribe();\n };\n }, [store]);\n\n useIsomorphicLayoutEffect(() => {\n const rafId = requestAnimationFrame(() => {\n rowVirtualizer.measure();\n });\n return () => cancelAnimationFrame(rafId);\n }, [\n table.getState().columnFilters,\n table.getState().columnOrder,\n table.getState().columnPinning,\n table.getState().columnSizing,\n table.getState().columnVisibility,\n table.getState().expanded,\n table.getState().globalFilter,\n table.getState().grouping,\n table.getState().rowSelection,\n table.getState().sorting,\n rowHeight,\n ]);\n\n return {\n dataGridRef,\n headerRef,\n rowMapRef,\n footerRef,\n table,\n rowVirtualizer,\n searchState,\n columnSizeVars,\n onRowAdd: onRowAddProp ? onRowAdd : undefined,\n };\n}\n\nexport { useDataGrid, type UseDataGridProps };\n", "type": "registry:hook" }, { diff --git a/src/components/data-grid/data-grid-cell-variants.tsx b/src/components/data-grid/data-grid-cell-variants.tsx index fc8215093..043b24665 100644 --- a/src/components/data-grid/data-grid-cell-variants.tsx +++ b/src/components/data-grid/data-grid-cell-variants.tsx @@ -890,10 +890,10 @@ export function MultiSelectCell({ isEditing, isSelected, }: CellVariantProps) { - const cellValue = React.useMemo( - () => (cell.getValue() as string[]) ?? [], - [cell], - ); + const cellValue = React.useMemo(() => { + const value = cell.getValue() as string[]; + return value ?? []; + }, [cell]); const cellKey = getCellKey(rowIndex, columnId); const prevCellKeyRef = React.useRef(cellKey); @@ -908,9 +908,14 @@ export function MultiSelectCell({ const options = cellOpts?.variant === "multi-select" ? cellOpts.options : []; const sideOffset = -(containerRef.current?.clientHeight ?? 0); + const prevCellValueRef = React.useRef(cellValue); + if (cellValue !== prevCellValueRef.current) { + prevCellValueRef.current = cellValue; + setSelectedValues(cellValue); + } + if (prevCellKeyRef.current !== cellKey) { prevCellKeyRef.current = cellKey; - setSelectedValues(cellValue); setSearchValue(""); } @@ -922,7 +927,6 @@ export function MultiSelectCell({ setSelectedValues(newValues); meta?.onDataUpdate?.({ rowIndex, columnId, value: newValues }); - // Clear search input and focus back on input after selection setSearchValue(""); queueMicrotask(() => inputRef.current?.focus()); }, @@ -1337,9 +1341,16 @@ export function FileCell({ [accept], ); + // Sync files when cellValue changes (same pattern as other cells) + const prevCellValueRef = React.useRef(cellValue); + if (cellValue !== prevCellValueRef.current) { + prevCellValueRef.current = cellValue; + setFiles(cellValue); + setError(null); + } + if (prevCellKeyRef.current !== cellKey) { prevCellKeyRef.current = cellKey; - setFiles(cellValue); setError(null); } diff --git a/src/components/data-grid/data-grid-context-menu.tsx b/src/components/data-grid/data-grid-context-menu.tsx index 9890baad4..793f928be 100644 --- a/src/components/data-grid/data-grid-context-menu.tsx +++ b/src/components/data-grid/data-grid-context-menu.tsx @@ -180,10 +180,24 @@ function ContextMenuImpl({ return; const updates: Array = []; + const tableColumns = table.getAllColumns(); for (const cellKey of selectionState.selectedCells) { const { rowIndex, columnId } = parseCellKey(cellKey); - updates.push({ rowIndex, columnId, value: "" }); + + const column = tableColumns.find((col) => col.id === columnId); + const cellVariant = column?.columnDef?.meta?.cell?.variant; + + let emptyValue: unknown = ""; + if (cellVariant === "multi-select" || cellVariant === "file") { + emptyValue = []; + } else if (cellVariant === "number" || cellVariant === "date") { + emptyValue = null; + } else if (cellVariant === "checkbox") { + emptyValue = false; + } + + updates.push({ rowIndex, columnId, value: emptyValue }); } onDataUpdate?.(updates); @@ -191,7 +205,7 @@ function ContextMenuImpl({ toast.success( `${updates.length} cell${updates.length !== 1 ? "s" : ""} cleared`, ); - }, [onDataUpdate, selectionState]); + }, [onDataUpdate, selectionState, table]); const onDelete = React.useCallback(async () => { if ( diff --git a/src/hooks/use-data-grid.tsx b/src/hooks/use-data-grid.tsx index 528c703d3..e08565c2b 100644 --- a/src/hooks/use-data-grid.tsx +++ b/src/hooks/use-data-grid.tsx @@ -1122,15 +1122,32 @@ function useDataGrid({ if (key === "Delete" || key === "Backspace") { if (currentState.selectionState.selectedCells.size > 0) { event.preventDefault(); + const updates: Array<{ rowIndex: number; columnId: string; value: unknown; }> = []; + const currentTable = tableRef.current; + const tableColumns = currentTable?.getAllColumns() ?? []; + currentState.selectionState.selectedCells.forEach((cellKey) => { const { rowIndex, columnId } = parseCellKey(cellKey); - updates.push({ rowIndex, columnId, value: "" }); + + const column = tableColumns.find((col) => col.id === columnId); + const cellVariant = column?.columnDef?.meta?.cell?.variant; + + let emptyValue: unknown = ""; + if (cellVariant === "multi-select" || cellVariant === "file") { + emptyValue = []; + } else if (cellVariant === "number" || cellVariant === "date") { + emptyValue = null; + } else if (cellVariant === "checkbox") { + emptyValue = false; + } + + updates.push({ rowIndex, columnId, value: emptyValue }); }); onDataUpdate(updates); From 0af182209e72bafd9e164a10816e7e8b69d8f529 Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Thu, 20 Nov 2025 19:11:44 +0600 Subject: [PATCH 2/2] fix: revoke object URLs in FileCell to prevent memory leaks --- public/r/data-grid.json | 2 +- src/components/data-grid/data-grid-cell-variants.tsx | 6 ++++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/public/r/data-grid.json b/public/r/data-grid.json index 819e8f046..48bb29f82 100644 --- a/public/r/data-grid.json +++ b/public/r/data-grid.json @@ -46,7 +46,7 @@ }, { "path": "src/components/data-grid/data-grid-cell-variants.tsx", - "content": "\"use client\";\n\nimport {\n Check,\n File,\n FileArchive,\n FileAudio,\n FileImage,\n FileSpreadsheet,\n FileText,\n FileVideo,\n Presentation,\n Upload,\n X,\n} from \"lucide-react\";\nimport * as React from \"react\";\nimport { toast } from \"sonner\";\nimport { DataGridCellWrapper } from \"@/components/data-grid/data-grid-cell-wrapper\";\nimport { Badge } from \"@/components/ui/badge\";\nimport { Button } from \"@/components/ui/button\";\nimport { Calendar } from \"@/components/ui/calendar\";\nimport { Checkbox } from \"@/components/ui/checkbox\";\nimport {\n Command,\n CommandEmpty,\n CommandGroup,\n CommandInput,\n CommandItem,\n CommandList,\n CommandSeparator,\n} from \"@/components/ui/command\";\nimport {\n Popover,\n PopoverAnchor,\n PopoverContent,\n} from \"@/components/ui/popover\";\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectTrigger,\n SelectValue,\n} from \"@/components/ui/select\";\nimport { Skeleton } from \"@/components/ui/skeleton\";\nimport { Textarea } from \"@/components/ui/textarea\";\nimport { useBadgeOverflow } from \"@/hooks/use-badge-overflow\";\nimport { useDebouncedCallback } from \"@/hooks/use-debounced-callback\";\nimport { getCellKey, getLineCount } from \"@/lib/data-grid\";\nimport { cn } from \"@/lib/utils\";\nimport type { CellVariantProps, FileCellData } from \"@/types/data-grid\";\n\nexport function ShortTextCell({\n cell,\n table,\n rowIndex,\n columnId,\n isEditing,\n isFocused,\n isSelected,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue);\n const cellRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue);\n if (cellRef.current && !isEditing) {\n cellRef.current.textContent = initialValue;\n }\n }\n\n const onBlur = React.useCallback(() => {\n // Read the current value directly from the DOM to avoid stale state\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (currentValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: currentValue });\n }\n meta?.onCellEditingStop?.();\n }, [meta, rowIndex, columnId, initialValue]);\n\n const onInput = React.useCallback(\n (event: React.FormEvent) => {\n const currentValue = event.currentTarget.textContent ?? \"\";\n setValue(currentValue);\n },\n [],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Enter\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (currentValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: currentValue });\n }\n meta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent ?? \"\";\n if (currentValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: currentValue });\n }\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n cellRef.current?.blur();\n }\n } else if (\n isFocused &&\n event.key.length === 1 &&\n !event.ctrlKey &&\n !event.metaKey\n ) {\n // Handle typing to pre-fill the value when editing starts\n setValue(event.key);\n\n queueMicrotask(() => {\n if (cellRef.current && cellRef.current.contentEditable === \"true\") {\n cellRef.current.textContent = event.key;\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n });\n }\n },\n [isEditing, isFocused, initialValue, meta, rowIndex, columnId],\n );\n\n React.useEffect(() => {\n if (isEditing && cellRef.current) {\n cellRef.current.focus();\n\n if (!cellRef.current.textContent && value) {\n cellRef.current.textContent = value;\n }\n\n if (cellRef.current.textContent) {\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n }\n }, [isEditing, value]);\n\n const displayValue = !isEditing ? (value ?? \"\") : \"\";\n\n return (\n \n \n {displayValue}\n \n \n );\n}\n\nexport function LongTextCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const textareaRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n const sideOffset = -(containerRef.current?.clientHeight ?? 0);\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n }\n\n // Debounced auto-save (300ms delay)\n const debouncedSave = useDebouncedCallback((newValue: string) => {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: newValue });\n }, 300);\n\n const onSave = React.useCallback(() => {\n // Immediately save any pending changes and close the popover\n if (value !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n meta?.onCellEditingStop?.();\n }, [meta, value, initialValue, rowIndex, columnId]);\n\n const onCancel = React.useCallback(() => {\n // Restore the original value\n setValue(initialValue ?? \"\");\n meta?.onDataUpdate?.({ rowIndex, columnId, value: initialValue });\n meta?.onCellEditingStop?.();\n }, [meta, initialValue, rowIndex, columnId]);\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n if (isOpen) {\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n // Immediately save any pending changes when closing\n if (value !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n meta?.onCellEditingStop?.();\n }\n },\n [meta, value, initialValue, rowIndex, columnId],\n );\n\n const onOpenAutoFocus: NonNullable<\n React.ComponentProps[\"onOpenAutoFocus\"]\n > = React.useCallback((event) => {\n event.preventDefault();\n if (textareaRef.current) {\n textareaRef.current.focus();\n const length = textareaRef.current.value.length;\n textareaRef.current.setSelectionRange(length, length);\n }\n }, []);\n\n const onBlur = React.useCallback(() => {\n // Immediately save any pending changes on blur\n if (value !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n meta?.onCellEditingStop?.();\n }, [meta, value, initialValue, rowIndex, columnId]);\n\n const onChange = React.useCallback(\n (event: React.ChangeEvent) => {\n const newValue = event.target.value;\n setValue(newValue);\n // Debounced auto-save\n debouncedSave(newValue);\n },\n [debouncedSave],\n );\n\n const onKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === \"Escape\") {\n event.preventDefault();\n onCancel();\n } else if (event.key === \"Enter\" && (event.ctrlKey || event.metaKey)) {\n event.preventDefault();\n onSave();\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n // Save any pending changes\n if (value !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n return;\n }\n // Stop propagation to prevent grid navigation\n event.stopPropagation();\n },\n [onSave, onCancel, value, initialValue, meta, rowIndex, columnId],\n );\n\n return (\n \n \n \n {value}\n \n \n \n \n \n \n );\n}\n\nexport function NumberCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as number;\n const [value, setValue] = React.useState(String(initialValue ?? \"\"));\n const inputRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n const cellOpts = cell.column.columnDef.meta?.cell;\n const min = cellOpts?.variant === \"number\" ? cellOpts.min : undefined;\n const max = cellOpts?.variant === \"number\" ? cellOpts.max : undefined;\n const step = cellOpts?.variant === \"number\" ? cellOpts.step : undefined;\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(String(initialValue ?? \"\"));\n }\n\n const onBlur = React.useCallback(() => {\n const numValue = value === \"\" ? null : Number(value);\n if (numValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n meta?.onCellEditingStop?.();\n }, [meta, rowIndex, columnId, initialValue, value]);\n\n const onChange = React.useCallback(\n (event: React.ChangeEvent) => {\n setValue(event.target.value);\n },\n [],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Enter\") {\n event.preventDefault();\n const numValue = value === \"\" ? null : Number(value);\n if (numValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n meta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const numValue = value === \"\" ? null : Number(value);\n if (numValue !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value: numValue });\n }\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(String(initialValue ?? \"\"));\n inputRef.current?.blur();\n }\n } else if (isFocused) {\n // Handle Backspace to start editing with empty value\n if (event.key === \"Backspace\") {\n setValue(\"\");\n } else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {\n // Handle typing to pre-fill the value when editing starts\n setValue(event.key);\n }\n }\n },\n [isEditing, isFocused, initialValue, meta, rowIndex, columnId, value],\n );\n\n React.useEffect(() => {\n if (isEditing && inputRef.current) {\n inputRef.current.focus();\n inputRef.current.select();\n }\n }, [isEditing]);\n\n return (\n \n {isEditing ? (\n \n ) : (\n {value}\n )}\n \n );\n}\n\nfunction getUrlHref(urlString: string): string {\n if (!urlString || urlString.trim() === \"\") return \"\";\n\n const trimmed = urlString.trim();\n\n // Reject dangerous protocols (extra safety, though our http:// prefix would neutralize them)\n if (/^(javascript|data|vbscript|file):/i.test(trimmed)) {\n return \"\";\n }\n\n // Check if it already has a protocol\n if (trimmed.startsWith(\"http://\") || trimmed.startsWith(\"https://\")) {\n return trimmed;\n }\n\n // Add http:// prefix for links without protocol\n return `http://${trimmed}`;\n}\n\nexport function UrlCell({\n cell,\n table,\n rowIndex,\n columnId,\n isEditing,\n isFocused,\n isSelected,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const cellRef = React.useRef(null);\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n if (cellRef.current && !isEditing) {\n cellRef.current.textContent = initialValue ?? \"\";\n }\n }\n\n const onBlur = React.useCallback(() => {\n const currentValue = cellRef.current?.textContent?.trim() ?? \"\";\n\n if (currentValue !== initialValue) {\n meta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n meta?.onCellEditingStop?.();\n }, [meta, rowIndex, columnId, initialValue]);\n\n const onInput = React.useCallback(\n (event: React.FormEvent) => {\n const currentValue = event.currentTarget.textContent ?? \"\";\n setValue(currentValue);\n },\n [],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Enter\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent?.trim() ?? \"\";\n if (currentValue !== initialValue) {\n meta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n meta?.onCellEditingStop?.({ moveToNextRow: true });\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n const currentValue = cellRef.current?.textContent?.trim() ?? \"\";\n if (currentValue !== initialValue) {\n meta?.onDataUpdate?.({\n rowIndex,\n columnId,\n value: currentValue || null,\n });\n }\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n } else if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue ?? \"\");\n cellRef.current?.blur();\n }\n } else if (\n isFocused &&\n event.key.length === 1 &&\n !event.ctrlKey &&\n !event.metaKey\n ) {\n // Handle typing to pre-fill the value when editing starts\n setValue(event.key);\n\n queueMicrotask(() => {\n if (cellRef.current && cellRef.current.contentEditable === \"true\") {\n cellRef.current.textContent = event.key;\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n });\n }\n },\n [isEditing, isFocused, initialValue, meta, rowIndex, columnId],\n );\n\n const onLinkClick = React.useCallback(\n (event: React.MouseEvent) => {\n if (isEditing) {\n event.preventDefault();\n return;\n }\n\n // Check if URL was rejected due to dangerous protocol\n const href = getUrlHref(value);\n if (!href) {\n event.preventDefault();\n toast.error(\"Invalid URL\", {\n description:\n \"URL contains a dangerous protocol (javascript:, data:, vbscript:, or file:)\",\n });\n return;\n }\n\n // Stop propagation to prevent grid from interfering with link navigation\n event.stopPropagation();\n },\n [isEditing, value],\n );\n\n React.useEffect(() => {\n if (isEditing && cellRef.current) {\n cellRef.current.focus();\n\n if (!cellRef.current.textContent && value) {\n cellRef.current.textContent = value;\n }\n\n if (cellRef.current.textContent) {\n const range = document.createRange();\n const selection = window.getSelection();\n range.selectNodeContents(cellRef.current);\n range.collapse(false);\n selection?.removeAllRanges();\n selection?.addRange(range);\n }\n }\n }, [isEditing, value]);\n\n const displayValue = !isEditing ? (value ?? \"\") : \"\";\n const urlHref = displayValue ? getUrlHref(displayValue) : \"\";\n const isDangerousUrl = displayValue && !urlHref;\n\n return (\n \n {!isEditing && displayValue ? (\n \n \n {displayValue}\n \n \n ) : (\n \n {displayValue}\n \n )}\n \n );\n}\n\nexport function CheckboxCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isSelected,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as boolean;\n const [value, setValue] = React.useState(Boolean(initialValue));\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(Boolean(initialValue));\n }\n\n const onCheckedChange = React.useCallback(\n (checked: boolean) => {\n setValue(checked);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: checked });\n },\n [meta, rowIndex, columnId],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isFocused && (event.key === \" \" || event.key === \"Enter\")) {\n event.preventDefault();\n event.stopPropagation();\n onCheckedChange(!value);\n } else if (isFocused && event.key === \"Tab\") {\n event.preventDefault();\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isFocused, value, onCheckedChange, meta],\n );\n\n const onWrapperClick = React.useCallback(\n (event: React.MouseEvent) => {\n if (isFocused) {\n event.preventDefault();\n event.stopPropagation();\n onCheckedChange(!value);\n }\n },\n [isFocused, value, onCheckedChange],\n );\n\n const onCheckboxClick = React.useCallback((event: React.MouseEvent) => {\n event.stopPropagation();\n }, []);\n\n const onCheckboxMouseDown = React.useCallback(\n (event: React.MouseEvent) => {\n event.stopPropagation();\n },\n [],\n );\n\n const onCheckboxDoubleClick = React.useCallback(\n (event: React.MouseEvent) => {\n event.stopPropagation();\n },\n [],\n );\n\n return (\n \n \n \n );\n}\n\nexport function SelectCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue);\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n const cellOpts = cell.column.columnDef.meta?.cell;\n const options = cellOpts?.variant === \"select\" ? cellOpts.options : [];\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue);\n }\n\n const onValueChange = React.useCallback(\n (newValue: string) => {\n setValue(newValue);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: newValue });\n meta?.onCellEditingStop?.();\n },\n [meta, rowIndex, columnId],\n );\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n if (isOpen) {\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n meta?.onCellEditingStop?.();\n }\n },\n [meta, rowIndex, columnId],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n meta?.onCellEditingStop?.();\n } else if (!isEditing && isFocused && event.key === \"Tab\") {\n event.preventDefault();\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, initialValue, meta],\n );\n\n const displayLabel =\n options.find((opt) => opt.value === value)?.label ?? value;\n\n return (\n \n {isEditing ? (\n \n \n \n \n \n {options.map((option) => (\n \n {option.label}\n \n ))}\n \n \n ) : (\n {displayLabel}\n )}\n \n );\n}\n\nexport function MultiSelectCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n}: CellVariantProps) {\n const cellValue = React.useMemo(() => {\n const value = cell.getValue() as string[];\n return value ?? [];\n }, [cell]);\n\n const cellKey = getCellKey(rowIndex, columnId);\n const prevCellKeyRef = React.useRef(cellKey);\n\n const [selectedValues, setSelectedValues] =\n React.useState(cellValue);\n const [searchValue, setSearchValue] = React.useState(\"\");\n const containerRef = React.useRef(null);\n const inputRef = React.useRef(null);\n const meta = table.options.meta;\n const cellOpts = cell.column.columnDef.meta?.cell;\n const options = cellOpts?.variant === \"multi-select\" ? cellOpts.options : [];\n const sideOffset = -(containerRef.current?.clientHeight ?? 0);\n\n const prevCellValueRef = React.useRef(cellValue);\n if (cellValue !== prevCellValueRef.current) {\n prevCellValueRef.current = cellValue;\n setSelectedValues(cellValue);\n }\n\n if (prevCellKeyRef.current !== cellKey) {\n prevCellKeyRef.current = cellKey;\n setSearchValue(\"\");\n }\n\n const onValueChange = React.useCallback(\n (value: string) => {\n const newValues = selectedValues.includes(value)\n ? selectedValues.filter((v) => v !== value)\n : [...selectedValues, value];\n\n setSelectedValues(newValues);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n setSearchValue(\"\");\n queueMicrotask(() => inputRef.current?.focus());\n },\n [selectedValues, meta, rowIndex, columnId],\n );\n\n const removeValue = React.useCallback(\n (valueToRemove: string, event?: React.MouseEvent) => {\n event?.stopPropagation();\n event?.preventDefault();\n const newValues = selectedValues.filter((v) => v !== valueToRemove);\n setSelectedValues(newValues);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: newValues });\n // Focus back on input after removing\n setTimeout(() => inputRef.current?.focus(), 0);\n },\n [selectedValues, meta, rowIndex, columnId],\n );\n\n const clearAll = React.useCallback(() => {\n setSelectedValues([]);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: [] });\n queueMicrotask(() => inputRef.current?.focus());\n }, [meta, rowIndex, columnId]);\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n if (isOpen) {\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n setSearchValue(\"\");\n meta?.onCellEditingStop?.();\n }\n },\n [meta, rowIndex, columnId],\n );\n\n const onOpenAutoFocus: NonNullable<\n React.ComponentProps[\"onOpenAutoFocus\"]\n > = React.useCallback((event) => {\n event.preventDefault();\n inputRef.current?.focus();\n }, []);\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && event.key === \"Escape\") {\n event.preventDefault();\n setSelectedValues(cellValue);\n setSearchValue(\"\");\n meta?.onCellEditingStop?.();\n } else if (!isEditing && isFocused && event.key === \"Tab\") {\n event.preventDefault();\n setSearchValue(\"\");\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, cellValue, meta],\n );\n\n const onInputKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n // Handle backspace when input is empty - remove last selected item\n if (\n event.key === \"Backspace\" &&\n searchValue === \"\" &&\n selectedValues.length > 0\n ) {\n event.preventDefault();\n const lastValue = selectedValues[selectedValues.length - 1];\n if (lastValue) {\n removeValue(lastValue);\n }\n }\n // Prevent escape from propagating to close the popover immediately\n // Let the command handle it first\n if (event.key === \"Escape\") {\n event.stopPropagation();\n }\n },\n [searchValue, selectedValues, removeValue],\n );\n\n const displayLabels = selectedValues\n .map((val) => options.find((opt) => opt.value === val)?.label ?? val)\n .filter(Boolean);\n\n const rowHeight = table.options.meta?.rowHeight ?? \"short\";\n const lineCount = getLineCount(rowHeight);\n\n const { visibleItems: visibleLabels, hiddenCount: hiddenBadgeCount } =\n useBadgeOverflow({\n items: displayLabels,\n getLabel: (label) => label,\n containerRef,\n lineCount,\n });\n\n return (\n \n {isEditing ? (\n \n \n
\n \n \n \n
\n {selectedValues.map((value) => {\n const option = options.find((opt) => opt.value === value);\n const label = option?.label ?? value;\n\n return (\n \n {label}\n removeValue(value, event)}\n onPointerDown={(event) => {\n event.preventDefault();\n event.stopPropagation();\n }}\n >\n \n \n \n );\n })}\n \n
\n \n No options found.\n \n {options.map((option) => {\n const isSelected = selectedValues.includes(option.value);\n\n return (\n onValueChange(option.value)}\n >\n \n \n
\n {option.label}\n \n );\n })}\n \n {selectedValues.length > 0 && (\n <>\n \n \n \n Clear all\n \n \n \n )}\n \n \n \n
\n ) : null}\n {displayLabels.length > 0 ? (\n
\n {visibleLabels.map((label, index) => (\n \n {label}\n \n ))}\n {hiddenBadgeCount > 0 && (\n \n +{hiddenBadgeCount}\n \n )}\n
\n ) : null}\n \n );\n}\n\nfunction formatDateForDisplay(dateStr: string) {\n if (!dateStr) return \"\";\n const date = new Date(dateStr);\n return date.toLocaleDateString();\n}\n\nexport function DateCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n}: CellVariantProps) {\n const initialValue = cell.getValue() as string;\n const [value, setValue] = React.useState(initialValue ?? \"\");\n const containerRef = React.useRef(null);\n const meta = table.options.meta;\n\n const prevInitialValueRef = React.useRef(initialValue);\n if (initialValue !== prevInitialValueRef.current) {\n prevInitialValueRef.current = initialValue;\n setValue(initialValue ?? \"\");\n }\n\n const selectedDate = value ? new Date(value) : undefined;\n\n const onDateSelect = React.useCallback(\n (date: Date | undefined) => {\n if (!date) return;\n\n const formattedDate = date.toISOString().split(\"T\")[0] ?? \"\";\n setValue(formattedDate);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: formattedDate });\n meta?.onCellEditingStop?.();\n },\n [meta, rowIndex, columnId],\n );\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n if (isOpen) {\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n meta?.onCellEditingStop?.();\n }\n },\n [meta, rowIndex, columnId],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n meta?.onCellEditingStop?.();\n } else if (!isEditing && isFocused && event.key === \"Tab\") {\n event.preventDefault();\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [isEditing, isFocused, initialValue, meta],\n );\n\n return (\n \n \n \n \n {formatDateForDisplay(value)}\n \n \n {isEditing && (\n \n \n \n )}\n \n \n );\n}\n\nfunction formatFileSize(bytes: number): string {\n if (bytes === 0) return \"0 B\";\n const k = 1024;\n const sizes = [\"B\", \"KB\", \"MB\", \"GB\"];\n const i = Math.floor(Math.log(bytes) / Math.log(k));\n return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;\n}\n\nfunction getFileIcon(\n type: string,\n): React.ComponentType> {\n if (type.startsWith(\"image/\")) return FileImage;\n if (type.startsWith(\"video/\")) return FileVideo;\n if (type.startsWith(\"audio/\")) return FileAudio;\n if (type.includes(\"pdf\")) return FileText;\n if (type.includes(\"zip\") || type.includes(\"rar\")) return FileArchive;\n if (\n type.includes(\"word\") ||\n type.includes(\"document\") ||\n type.includes(\"doc\")\n )\n return FileText;\n if (type.includes(\"sheet\") || type.includes(\"excel\") || type.includes(\"xls\"))\n return FileSpreadsheet;\n if (\n type.includes(\"presentation\") ||\n type.includes(\"powerpoint\") ||\n type.includes(\"ppt\")\n )\n return Presentation;\n return File;\n}\n\nexport function FileCell({\n cell,\n table,\n rowIndex,\n columnId,\n isFocused,\n isEditing,\n isSelected,\n}: CellVariantProps) {\n const cellValue = React.useMemo(\n () => (cell.getValue() as FileCellData[]) ?? [],\n [cell],\n );\n\n const cellKey = getCellKey(rowIndex, columnId);\n const prevCellKeyRef = React.useRef(cellKey);\n\n const labelId = React.useId();\n const descriptionId = React.useId();\n\n const [files, setFiles] = React.useState(cellValue);\n const [uploadingFiles, setUploadingFiles] = React.useState>(\n new Set(),\n );\n const [isDraggingOver, setIsDraggingOver] = React.useState(false);\n const [isDragging, setIsDragging] = React.useState(false);\n const [error, setError] = React.useState(null);\n const containerRef = React.useRef(null);\n const fileInputRef = React.useRef(null);\n const dropzoneRef = React.useRef(null);\n const meta = table.options.meta;\n const cellOpts = cell.column.columnDef.meta?.cell;\n const sideOffset = -(containerRef.current?.clientHeight ?? 0);\n\n const fileCellOpts = cellOpts?.variant === \"file\" ? cellOpts : null;\n const maxFileSize = fileCellOpts?.maxFileSize ?? 10 * 1024 * 1024;\n const maxFiles = fileCellOpts?.maxFiles ?? 10;\n const accept = fileCellOpts?.accept;\n const multiple = fileCellOpts?.multiple ?? true;\n\n const acceptedTypes = React.useMemo(\n () => (accept ? accept.split(\",\").map((t) => t.trim()) : null),\n [accept],\n );\n\n // Sync files when cellValue changes (same pattern as other cells)\n const prevCellValueRef = React.useRef(cellValue);\n if (cellValue !== prevCellValueRef.current) {\n prevCellValueRef.current = cellValue;\n setFiles(cellValue);\n setError(null);\n }\n\n if (prevCellKeyRef.current !== cellKey) {\n prevCellKeyRef.current = cellKey;\n setError(null);\n }\n\n const validateFile = React.useCallback(\n (file: File): string | null => {\n if (maxFileSize && file.size > maxFileSize) {\n return `File size exceeds ${formatFileSize(maxFileSize)}`;\n }\n if (acceptedTypes) {\n const fileExtension = `.${file.name.split(\".\").pop()}`;\n const isAccepted = acceptedTypes.some((type) => {\n if (type.endsWith(\"/*\")) {\n // Handle wildcard types like \"image/*\"\n const baseType = type.slice(0, -2);\n return file.type.startsWith(`${baseType}/`);\n }\n if (type.startsWith(\".\")) {\n // Handle file extensions like \".pdf\"\n return fileExtension.toLowerCase() === type.toLowerCase();\n }\n // Exact match for specific MIME types\n return file.type === type;\n });\n if (!isAccepted) {\n return \"File type not accepted\";\n }\n }\n return null;\n },\n [maxFileSize, acceptedTypes],\n );\n\n const addFiles = React.useCallback(\n async (newFiles: File[], skipUpload = false) => {\n setError(null);\n\n // Check max files limit\n if (maxFiles && files.length + newFiles.length > maxFiles) {\n const errorMessage = `Maximum ${maxFiles} files allowed`;\n setError(errorMessage);\n toast(errorMessage);\n setTimeout(() => {\n setError(null);\n }, 2000);\n return;\n }\n\n const validFiles: FileCellData[] = [];\n const rejectedFiles: Array<{ name: string; reason: string }> = [];\n\n for (const file of newFiles) {\n const validationError = validateFile(file);\n if (validationError) {\n rejectedFiles.push({ name: file.name, reason: validationError });\n continue;\n }\n\n // Create file data object with temporary ID\n const fileData: FileCellData = {\n id: crypto.randomUUID(),\n name: file.name,\n size: file.size,\n type: file.type,\n url: URL.createObjectURL(file),\n };\n validFiles.push(fileData);\n }\n\n if (rejectedFiles.length > 0) {\n const firstError = rejectedFiles[0];\n if (firstError) {\n setError(firstError.reason);\n\n const truncatedName =\n firstError.name.length > 20\n ? `${firstError.name.slice(0, 20)}...`\n : firstError.name;\n\n if (rejectedFiles.length === 1) {\n toast(firstError.reason, {\n description: `\"${truncatedName}\" has been rejected`,\n });\n } else {\n toast(firstError.reason, {\n description: `\"${truncatedName}\" and ${rejectedFiles.length - 1} more rejected`,\n });\n }\n\n setTimeout(() => {\n setError(null);\n }, 2000);\n }\n }\n\n if (validFiles.length > 0) {\n // If not skipping upload (dropped on cell), show skeletons first\n if (!skipUpload) {\n // Add temp files immediately (will show as skeletons)\n const tempFiles = validFiles.map((f) => ({ ...f, url: undefined }));\n const filesWithTemp = [...files, ...tempFiles];\n setFiles(filesWithTemp);\n\n // Mark as uploading\n const uploadingIds = new Set(validFiles.map((f) => f.id));\n setUploadingFiles(uploadingIds);\n\n // Simulate upload delay (in real app, this would be actual upload)\n await new Promise((resolve) => setTimeout(resolve, 800));\n\n // Replace temp files with real ones\n const finalFiles = filesWithTemp.map(\n (f) => validFiles.find((vf) => vf.id === f.id) || f,\n );\n setFiles(finalFiles);\n setUploadingFiles(new Set());\n meta?.onDataUpdate?.({ rowIndex, columnId, value: finalFiles });\n } else {\n // If from editor, add immediately without skeleton\n const updatedFiles = [...files, ...validFiles];\n setFiles(updatedFiles);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles });\n }\n }\n },\n [files, maxFiles, validateFile, meta, rowIndex, columnId],\n );\n\n const removeFile = React.useCallback(\n (fileId: string) => {\n setError(null);\n // Revoke object URL to prevent memory leak\n const fileToRemove = files.find((f) => f.id === fileId);\n if (fileToRemove?.url) {\n URL.revokeObjectURL(fileToRemove.url);\n }\n const updatedFiles = files.filter((f) => f.id !== fileId);\n setFiles(updatedFiles);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles });\n },\n [files, meta, rowIndex, columnId],\n );\n\n const clearAll = React.useCallback(() => {\n // Revoke all object URLs to prevent memory leak\n for (const file of files) {\n if (file.url) {\n URL.revokeObjectURL(file.url);\n }\n }\n setFiles([]);\n setError(null);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: [] });\n }, [files, meta, rowIndex, columnId]);\n\n const onDragEnter = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDragging(true);\n }, []);\n\n const onDragLeave = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n const rect = event.currentTarget.getBoundingClientRect();\n const x = event.clientX;\n const y = event.clientY;\n\n if (\n x <= rect.left ||\n x >= rect.right ||\n y <= rect.top ||\n y >= rect.bottom\n ) {\n setIsDragging(false);\n }\n }, []);\n\n const onDragOver = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n }, []);\n\n const onDrop = React.useCallback(\n (event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDragging(false);\n\n const droppedFiles = Array.from(event.dataTransfer.files);\n addFiles(droppedFiles, true); // Skip upload skeleton in editor\n },\n [addFiles],\n );\n\n const onFileInputChange = React.useCallback(\n (event: React.ChangeEvent) => {\n const selectedFiles = Array.from(event.target.files ?? []);\n addFiles(selectedFiles, true); // Skip upload skeleton for manual selection\n // Reset input so the same file can be selected again\n event.target.value = \"\";\n },\n [addFiles],\n );\n\n // Cell-level drag handlers (for dropping directly on cell)\n const onCellDragEnter = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n // Only show drop indicator if dragging files\n if (event.dataTransfer.types.includes(\"Files\")) {\n setIsDraggingOver(true);\n }\n }, []);\n\n const onCellDragLeave = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n const rect = event.currentTarget.getBoundingClientRect();\n const x = event.clientX;\n const y = event.clientY;\n\n if (\n x <= rect.left ||\n x >= rect.right ||\n y <= rect.top ||\n y >= rect.bottom\n ) {\n setIsDraggingOver(false);\n }\n }, []);\n\n const onCellDragOver = React.useCallback((event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n }, []);\n\n const onCellDrop = React.useCallback(\n (event: React.DragEvent) => {\n event.preventDefault();\n event.stopPropagation();\n setIsDraggingOver(false);\n\n const droppedFiles = Array.from(event.dataTransfer.files);\n if (droppedFiles.length > 0) {\n addFiles(droppedFiles, false); // Show skeleton for dropped files\n }\n },\n [addFiles],\n );\n\n const onDropzoneClick = React.useCallback(() => {\n fileInputRef.current?.click();\n }, []);\n\n const onDropzoneKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (event.key === \"Enter\" || event.key === \" \") {\n event.preventDefault();\n onDropzoneClick();\n }\n },\n [onDropzoneClick],\n );\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n if (isOpen) {\n setError(null);\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else {\n setError(null);\n meta?.onCellEditingStop?.();\n }\n },\n [meta, rowIndex, columnId],\n );\n\n const onEscapeKeyDown: NonNullable<\n React.ComponentProps[\"onEscapeKeyDown\"]\n > = React.useCallback((event) => {\n // Prevent the escape key from propagating to the data grid's keyboard handler\n // which would call blurCell() and remove focus from the cell\n event.stopPropagation();\n }, []);\n\n const onOpenAutoFocus: NonNullable<\n React.ComponentProps[\"onOpenAutoFocus\"]\n > = React.useCallback((event) => {\n event.preventDefault();\n // Focus the dropzone for better keyboard UX - users can press Enter again to open file dialog\n queueMicrotask(() => {\n dropzoneRef.current?.focus();\n });\n }, []);\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Escape\") {\n event.preventDefault();\n setFiles(cellValue);\n setError(null);\n meta?.onCellEditingStop?.();\n } else if (event.key === \" \") {\n event.preventDefault();\n onDropzoneClick();\n }\n } else if (isFocused && event.key === \"Enter\") {\n // Handle Enter key to start editing when focused but not editing\n event.preventDefault();\n meta?.onCellEditingStart?.(rowIndex, columnId);\n } else if (!isEditing && isFocused && event.key === \"Tab\") {\n event.preventDefault();\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n },\n [\n isEditing,\n isFocused,\n cellValue,\n meta,\n onDropzoneClick,\n rowIndex,\n columnId,\n ],\n );\n\n React.useEffect(() => {\n return () => {\n for (const file of files) {\n if (file.url) {\n URL.revokeObjectURL(file.url);\n }\n }\n };\n }, [files]);\n\n const rowHeight = table.options.meta?.rowHeight ?? \"short\";\n const lineCount = getLineCount(rowHeight);\n\n const { visibleItems: visibleFiles, hiddenCount: hiddenFileCount } =\n useBadgeOverflow({\n items: files,\n getLabel: (file) => file.name,\n containerRef,\n lineCount,\n cacheKeyPrefix: \"file\",\n iconSize: 12,\n maxWidth: 100,\n });\n\n return (\n \n {isEditing ? (\n \n \n
\n \n \n
\n \n File upload\n \n \n \n
\n

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

\n

\n or click to browse\n

\n
\n

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

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

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

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

{file.name}

\n

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

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

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

\n

\n or click to browse\n

\n
\n

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

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

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

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

{file.name}

\n

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

\n
\n removeFile(file.id)}\n >\n \n \n
\n );\n })}\n
\n
\n )}\n \n \n
\n ) : null}\n {isDraggingOver ? (\n
\n \n Drop files here\n
\n ) : files.length > 0 ? (\n
\n {visibleFiles.map((file) => {\n const isUploading = uploadingFiles.has(file.id);\n\n if (isUploading) {\n // Show skeleton for uploading files\n return (\n \n );\n }\n\n return (\n \n {React.createElement(getFileIcon(file.type), {\n className: \"size-3 shrink-0\",\n })}\n {file.name}\n \n );\n })}\n {hiddenFileCount > 0 && (\n \n +{hiddenFileCount}\n \n )}\n
\n ) : null}\n \n );\n}\n", "type": "registry:component", "target": "src/components/data-grid/data-grid-cell-variants.tsx" }, diff --git a/src/components/data-grid/data-grid-cell-variants.tsx b/src/components/data-grid/data-grid-cell-variants.tsx index 043b24665..596a6a428 100644 --- a/src/components/data-grid/data-grid-cell-variants.tsx +++ b/src/components/data-grid/data-grid-cell-variants.tsx @@ -1345,6 +1345,12 @@ export function FileCell({ const prevCellValueRef = React.useRef(cellValue); if (cellValue !== prevCellValueRef.current) { prevCellValueRef.current = cellValue; + // Revoke old object URLs to prevent memory leaks + for (const file of files) { + if (file.url) { + URL.revokeObjectURL(file.url); + } + } setFiles(cellValue); setError(null); }