diff --git a/public/r/data-grid.json b/public/r/data-grid.json index fac694e8d..fe00bc79d 100644 --- a/public/r/data-grid.json +++ b/public/r/data-grid.json @@ -34,7 +34,7 @@ }, { "path": "src/components/data-grid/data-grid-cell.tsx", - "content": "\"use client\";\n\nimport type { Cell, Table } from \"@tanstack/react-table\";\nimport * as React from \"react\";\n\nimport {\n CheckboxCell,\n DateCell,\n FileCell,\n LongTextCell,\n MultiSelectCell,\n NumberCell,\n SelectCell,\n ShortTextCell,\n} from \"@/components/data-grid/data-grid-cell-variants\";\n\ninterface DataGridCellProps {\n cell: Cell;\n table: Table;\n}\n\nexport function DataGridCell({ cell, table }: DataGridCellProps) {\n const meta = table.options.meta;\n const originalRowIndex = cell.row.index;\n\n const rows = table.getRowModel().rows;\n const displayRowIndex = rows.findIndex(\n (row) => row.original === cell.row.original,\n );\n const rowIndex = displayRowIndex >= 0 ? displayRowIndex : originalRowIndex;\n const columnId = cell.column.id;\n\n const isFocused =\n meta?.focusedCell?.rowIndex === rowIndex &&\n meta?.focusedCell?.columnId === columnId;\n const isEditing =\n meta?.editingCell?.rowIndex === rowIndex &&\n meta?.editingCell?.columnId === columnId;\n const isSelected = meta?.getIsCellSelected?.(rowIndex, columnId) ?? false;\n\n const cellOpts = cell.column.columnDef.meta?.cell;\n const variant = cellOpts?.variant ?? \"text\";\n\n switch (variant) {\n case \"short-text\":\n return (\n \n );\n case \"long-text\":\n return (\n \n );\n case \"number\":\n return (\n \n );\n case \"select\":\n return (\n \n );\n case \"multi-select\":\n return (\n \n );\n case \"checkbox\":\n return (\n \n );\n case \"date\":\n return (\n \n );\n case \"file\":\n return (\n \n );\n\n default:\n return (\n \n );\n }\n}\n", + "content": "\"use client\";\n\nimport type { Cell, Table } from \"@tanstack/react-table\";\nimport * as React from \"react\";\n\nimport {\n CheckboxCell,\n DateCell,\n FileCell,\n LongTextCell,\n MultiSelectCell,\n NumberCell,\n SelectCell,\n ShortTextCell,\n UrlCell,\n} from \"@/components/data-grid/data-grid-cell-variants\";\n\ninterface DataGridCellProps {\n cell: Cell;\n table: Table;\n}\n\nexport function DataGridCell({ cell, table }: DataGridCellProps) {\n const meta = table.options.meta;\n const originalRowIndex = cell.row.index;\n\n const rows = table.getRowModel().rows;\n const displayRowIndex = rows.findIndex(\n (row) => row.original === cell.row.original,\n );\n const rowIndex = displayRowIndex >= 0 ? displayRowIndex : originalRowIndex;\n const columnId = cell.column.id;\n\n const isFocused =\n meta?.focusedCell?.rowIndex === rowIndex &&\n meta?.focusedCell?.columnId === columnId;\n const isEditing =\n meta?.editingCell?.rowIndex === rowIndex &&\n meta?.editingCell?.columnId === columnId;\n const isSelected = meta?.getIsCellSelected?.(rowIndex, columnId) ?? false;\n\n const cellOpts = cell.column.columnDef.meta?.cell;\n const variant = cellOpts?.variant ?? \"text\";\n\n switch (variant) {\n case \"short-text\":\n return (\n \n );\n case \"long-text\":\n return (\n \n );\n case \"number\":\n return (\n \n );\n case \"url\":\n return (\n \n );\n case \"checkbox\":\n return (\n \n );\n case \"select\":\n return (\n \n );\n case \"multi-select\":\n return (\n \n );\n case \"date\":\n return (\n \n );\n case \"file\":\n return (\n \n );\n\n default:\n return (\n \n );\n }\n}\n", "type": "registry:component", "target": "src/components/data-grid/data-grid-cell.tsx" }, @@ -46,13 +46,13 @@ }, { "path": "src/components/data-grid/data-grid-cell-variants.tsx", - "content": "\"use client\";\n\nimport type { Cell, Table } from \"@tanstack/react-table\";\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 { 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 { FileCellData } from \"@/types/data-grid\";\n\ninterface CellVariantProps {\n cell: Cell;\n table: Table;\n rowIndex: number;\n columnId: string;\n isEditing: boolean;\n isFocused: boolean;\n isSelected: boolean;\n}\n\nexport 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\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\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\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 () => (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", "type": "registry:component", "target": "src/components/data-grid/data-grid-cell-variants.tsx" }, { "path": "src/components/data-grid/data-grid-column-header.tsx", - "content": "\"use client\";\n\nimport type {\n ColumnSort,\n Header,\n SortDirection,\n SortingState,\n Table,\n} from \"@tanstack/react-table\";\nimport {\n BaselineIcon,\n CalendarIcon,\n CheckSquareIcon,\n ChevronDownIcon,\n ChevronUpIcon,\n EyeOffIcon,\n FileIcon,\n HashIcon,\n ListChecksIcon,\n ListIcon,\n PinIcon,\n PinOffIcon,\n TextInitialIcon,\n XIcon,\n} from \"lucide-react\";\nimport * as React from \"react\";\n\nimport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport type { Cell } from \"@/types/data-grid\";\n\nfunction getColumnVariant(variant?: Cell[\"variant\"]): {\n icon: React.ComponentType>;\n label: string;\n} | null {\n switch (variant) {\n case \"short-text\":\n return { icon: BaselineIcon, label: \"Short text\" };\n case \"long-text\":\n return { icon: TextInitialIcon, label: \"Long text\" };\n case \"number\":\n return { icon: HashIcon, label: \"Number\" };\n case \"select\":\n return { icon: ListIcon, label: \"Select\" };\n case \"multi-select\":\n return { icon: ListChecksIcon, label: \"Multi-select\" };\n case \"checkbox\":\n return { icon: CheckSquareIcon, label: \"Checkbox\" };\n case \"date\":\n return { icon: CalendarIcon, label: \"Date\" };\n case \"file\":\n return { icon: FileIcon, label: \"File\" };\n default:\n return null;\n }\n}\n\ninterface DataGridColumnHeaderProps\n extends React.ComponentProps {\n header: Header;\n table: Table;\n}\n\nexport function DataGridColumnHeader({\n header,\n table,\n className,\n onPointerDown,\n ...props\n}: DataGridColumnHeaderProps) {\n const column = header.column;\n const label = column.columnDef.meta?.label\n ? column.columnDef.meta.label\n : typeof column.columnDef.header === \"string\"\n ? column.columnDef.header\n : column.id;\n\n const isAnyColumnResizing =\n table.getState().columnSizingInfo.isResizingColumn;\n\n const cellVariant = column.columnDef.meta?.cell;\n const columnVariant = getColumnVariant(cellVariant?.variant);\n\n const pinnedPosition = column.getIsPinned();\n const isPinnedLeft = pinnedPosition === \"left\";\n const isPinnedRight = pinnedPosition === \"right\";\n\n const onSortingChange = React.useCallback(\n (direction: SortDirection) => {\n table.setSorting((prev: SortingState) => {\n const existingSortIndex = prev.findIndex(\n (sort) => sort.id === column.id,\n );\n const newSort: ColumnSort = {\n id: column.id,\n desc: direction === \"desc\",\n };\n\n if (existingSortIndex >= 0) {\n const updated = [...prev];\n updated[existingSortIndex] = newSort;\n return updated;\n } else {\n return [...prev, newSort];\n }\n });\n },\n [column.id, table],\n );\n\n const onSortRemove = React.useCallback(() => {\n table.setSorting((prev: SortingState) =>\n prev.filter((sort) => sort.id !== column.id),\n );\n }, [column.id, table]);\n\n const onLeftPin = React.useCallback(() => {\n column.pin(\"left\");\n }, [column]);\n\n const onRightPin = React.useCallback(() => {\n column.pin(\"right\");\n }, [column]);\n\n const onUnpin = React.useCallback(() => {\n column.pin(false);\n }, [column]);\n\n const onTriggerPointerDown = React.useCallback(\n (event: React.PointerEvent) => {\n onPointerDown?.(event);\n if (event.defaultPrevented) return;\n\n if (event.button !== 0) {\n return;\n }\n table.options.meta?.onColumnClick?.(column.id);\n },\n [table.options.meta, column.id, onPointerDown],\n );\n\n return (\n <>\n \n \n
\n {columnVariant && (\n \n \n \n \n \n

{columnVariant.label}

\n
\n
\n )}\n {label}\n
\n \n \n \n {column.getCanSort() && (\n <>\n span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground\"\n checked={column.getIsSorted() === \"asc\"}\n onClick={() => onSortingChange(\"asc\")}\n >\n \n Sort asc\n \n span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground\"\n checked={column.getIsSorted() === \"desc\"}\n onClick={() => onSortingChange(\"desc\")}\n >\n \n Sort desc\n \n {column.getIsSorted() && (\n \n \n Remove sort\n \n )}\n \n )}\n {column.getCanPin() && (\n <>\n {column.getCanSort() && }\n\n {isPinnedLeft ? (\n \n \n Unpin from left\n \n ) : (\n \n \n Pin to left\n \n )}\n {isPinnedRight ? (\n \n \n Unpin from right\n \n ) : (\n \n \n Pin to right\n \n )}\n \n )}\n {column.getCanHide() && (\n <>\n \n span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground\"\n checked={!column.getIsVisible()}\n onClick={() => column.toggleVisibility(false)}\n >\n \n Hide column\n \n \n )}\n \n
\n {header.column.getCanResize() && (\n \n )}\n \n );\n}\n\nconst DataGridColumnResizer = React.memo(\n DataGridColumnResizerImpl,\n (prev, next) => {\n const prevColumn = prev.header.column;\n const nextColumn = next.header.column;\n\n if (\n prevColumn.getIsResizing() !== nextColumn.getIsResizing() ||\n prevColumn.getSize() !== nextColumn.getSize()\n ) {\n return false;\n }\n\n if (prev.label !== next.label) return false;\n\n return true;\n },\n) as typeof DataGridColumnResizerImpl;\n\ninterface DataGridColumnResizerProps\n extends DataGridColumnHeaderProps {\n label: string;\n}\n\nfunction DataGridColumnResizerImpl({\n header,\n table,\n label,\n}: DataGridColumnResizerProps) {\n const defaultColumnDef = table._getDefaultColumnDef();\n\n const onDoubleClick = React.useCallback(() => {\n header.column.resetSize();\n }, [header.column]);\n\n return (\n \n );\n}\n", + "content": "\"use client\";\n\nimport type {\n ColumnSort,\n Header,\n SortDirection,\n SortingState,\n Table,\n} from \"@tanstack/react-table\";\nimport {\n BaselineIcon,\n CalendarIcon,\n CheckSquareIcon,\n ChevronDownIcon,\n ChevronUpIcon,\n EyeOffIcon,\n FileIcon,\n HashIcon,\n LinkIcon,\n ListChecksIcon,\n ListIcon,\n PinIcon,\n PinOffIcon,\n TextInitialIcon,\n XIcon,\n} from \"lucide-react\";\nimport * as React from \"react\";\n\nimport {\n DropdownMenu,\n DropdownMenuCheckboxItem,\n DropdownMenuContent,\n DropdownMenuItem,\n DropdownMenuSeparator,\n DropdownMenuTrigger,\n} from \"@/components/ui/dropdown-menu\";\nimport {\n Tooltip,\n TooltipContent,\n TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport type { CellOpts } from \"@/types/data-grid\";\n\nfunction getColumnVariant(variant?: CellOpts[\"variant\"]): {\n icon: React.ComponentType>;\n label: string;\n} | null {\n switch (variant) {\n case \"short-text\":\n return { icon: BaselineIcon, label: \"Short text\" };\n case \"long-text\":\n return { icon: TextInitialIcon, label: \"Long text\" };\n case \"number\":\n return { icon: HashIcon, label: \"Number\" };\n case \"url\":\n return { icon: LinkIcon, label: \"URL\" };\n case \"checkbox\":\n return { icon: CheckSquareIcon, label: \"Checkbox\" };\n case \"select\":\n return { icon: ListIcon, label: \"Select\" };\n case \"multi-select\":\n return { icon: ListChecksIcon, label: \"Multi-select\" };\n case \"date\":\n return { icon: CalendarIcon, label: \"Date\" };\n case \"file\":\n return { icon: FileIcon, label: \"File\" };\n default:\n return null;\n }\n}\n\ninterface DataGridColumnHeaderProps\n extends React.ComponentProps {\n header: Header;\n table: Table;\n}\n\nexport function DataGridColumnHeader({\n header,\n table,\n className,\n onPointerDown,\n ...props\n}: DataGridColumnHeaderProps) {\n const column = header.column;\n const label = column.columnDef.meta?.label\n ? column.columnDef.meta.label\n : typeof column.columnDef.header === \"string\"\n ? column.columnDef.header\n : column.id;\n\n const isAnyColumnResizing =\n table.getState().columnSizingInfo.isResizingColumn;\n\n const cellVariant = column.columnDef.meta?.cell;\n const columnVariant = getColumnVariant(cellVariant?.variant);\n\n const pinnedPosition = column.getIsPinned();\n const isPinnedLeft = pinnedPosition === \"left\";\n const isPinnedRight = pinnedPosition === \"right\";\n\n const onSortingChange = React.useCallback(\n (direction: SortDirection) => {\n table.setSorting((prev: SortingState) => {\n const existingSortIndex = prev.findIndex(\n (sort) => sort.id === column.id,\n );\n const newSort: ColumnSort = {\n id: column.id,\n desc: direction === \"desc\",\n };\n\n if (existingSortIndex >= 0) {\n const updated = [...prev];\n updated[existingSortIndex] = newSort;\n return updated;\n } else {\n return [...prev, newSort];\n }\n });\n },\n [column.id, table],\n );\n\n const onSortRemove = React.useCallback(() => {\n table.setSorting((prev: SortingState) =>\n prev.filter((sort) => sort.id !== column.id),\n );\n }, [column.id, table]);\n\n const onLeftPin = React.useCallback(() => {\n column.pin(\"left\");\n }, [column]);\n\n const onRightPin = React.useCallback(() => {\n column.pin(\"right\");\n }, [column]);\n\n const onUnpin = React.useCallback(() => {\n column.pin(false);\n }, [column]);\n\n const onTriggerPointerDown = React.useCallback(\n (event: React.PointerEvent) => {\n onPointerDown?.(event);\n if (event.defaultPrevented) return;\n\n if (event.button !== 0) {\n return;\n }\n table.options.meta?.onColumnClick?.(column.id);\n },\n [table.options.meta, column.id, onPointerDown],\n );\n\n return (\n <>\n \n \n
\n {columnVariant && (\n \n \n \n \n \n

{columnVariant.label}

\n
\n
\n )}\n {label}\n
\n \n \n \n {column.getCanSort() && (\n <>\n span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground\"\n checked={column.getIsSorted() === \"asc\"}\n onClick={() => onSortingChange(\"asc\")}\n >\n \n Sort asc\n \n span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground\"\n checked={column.getIsSorted() === \"desc\"}\n onClick={() => onSortingChange(\"desc\")}\n >\n \n Sort desc\n \n {column.getIsSorted() && (\n \n \n Remove sort\n \n )}\n \n )}\n {column.getCanPin() && (\n <>\n {column.getCanSort() && }\n\n {isPinnedLeft ? (\n \n \n Unpin from left\n \n ) : (\n \n \n Pin to left\n \n )}\n {isPinnedRight ? (\n \n \n Unpin from right\n \n ) : (\n \n \n Pin to right\n \n )}\n \n )}\n {column.getCanHide() && (\n <>\n \n span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground\"\n checked={!column.getIsVisible()}\n onClick={() => column.toggleVisibility(false)}\n >\n \n Hide column\n \n \n )}\n \n
\n {header.column.getCanResize() && (\n \n )}\n \n );\n}\n\nconst DataGridColumnResizer = React.memo(\n DataGridColumnResizerImpl,\n (prev, next) => {\n const prevColumn = prev.header.column;\n const nextColumn = next.header.column;\n\n if (\n prevColumn.getIsResizing() !== nextColumn.getIsResizing() ||\n prevColumn.getSize() !== nextColumn.getSize()\n ) {\n return false;\n }\n\n if (prev.label !== next.label) return false;\n\n return true;\n },\n) as typeof DataGridColumnResizerImpl;\n\ninterface DataGridColumnResizerProps\n extends DataGridColumnHeaderProps {\n label: string;\n}\n\nfunction DataGridColumnResizerImpl({\n header,\n table,\n label,\n}: DataGridColumnResizerProps) {\n const defaultColumnDef = table._getDefaultColumnDef();\n\n const onDoubleClick = React.useCallback(() => {\n header.column.resetSize();\n }, [header.column]);\n\n return (\n \n );\n}\n", "type": "registry:component", "target": "src/components/data-grid/data-grid-column-header.tsx" }, @@ -106,7 +106,7 @@ }, { "path": "src/types/data-grid.ts", - "content": "import type { RowData } from \"@tanstack/react-table\";\n\nexport type RowHeightValue = \"short\" | \"medium\" | \"tall\" | \"extra-tall\";\n\nexport interface CellSelectOption {\n label: string;\n value: string;\n}\n\nexport type Cell =\n | {\n variant: \"short-text\";\n }\n | {\n variant: \"long-text\";\n }\n | {\n variant: \"number\";\n min?: number;\n max?: number;\n step?: number;\n }\n | {\n variant: \"select\";\n options: CellSelectOption[];\n }\n | {\n variant: \"multi-select\";\n options: CellSelectOption[];\n }\n | {\n variant: \"checkbox\";\n }\n | {\n variant: \"date\";\n }\n | {\n variant: \"file\";\n maxFileSize?: number;\n maxFiles?: number;\n accept?: string;\n multiple?: boolean;\n };\n\nexport interface UpdateCell {\n rowIndex: number;\n columnId: string;\n value: unknown;\n}\n\ndeclare module \"@tanstack/react-table\" {\n // biome-ignore lint/correctness/noUnusedVariables: TData and TValue are used in the ColumnMeta interface\n interface ColumnMeta {\n label?: string;\n cell?: Cell;\n }\n\n // biome-ignore lint/correctness/noUnusedVariables: TData is used in the TableMeta interface\n interface TableMeta {\n dataGridRef?: React.RefObject;\n cellMapRef?: React.RefObject>;\n focusedCell?: CellPosition | null;\n editingCell?: CellPosition | null;\n selectionState?: SelectionState;\n searchOpen?: boolean;\n isScrolling?: boolean;\n getIsCellSelected?: (rowIndex: number, columnId: string) => boolean;\n getIsSearchMatch?: (rowIndex: number, columnId: string) => boolean;\n getIsActiveSearchMatch?: (rowIndex: number, columnId: string) => boolean;\n onDataUpdate?: (props: UpdateCell | Array) => void;\n onRowsDelete?: (rowIndices: number[]) => void | Promise;\n onColumnClick?: (columnId: string) => void;\n onCellClick?: (\n rowIndex: number,\n columnId: string,\n event?: React.MouseEvent,\n ) => void;\n onCellDoubleClick?: (rowIndex: number, columnId: string) => void;\n onCellMouseDown?: (\n rowIndex: number,\n columnId: string,\n event: React.MouseEvent,\n ) => void;\n onCellMouseEnter?: (\n rowIndex: number,\n columnId: string,\n event: React.MouseEvent,\n ) => void;\n onCellMouseUp?: () => void;\n onCellContextMenu?: (\n rowIndex: number,\n columnId: string,\n event: React.MouseEvent,\n ) => void;\n onCellEditingStart?: (rowIndex: number, columnId: string) => void;\n onCellEditingStop?: (opts?: {\n direction?: NavigationDirection;\n moveToNextRow?: boolean;\n }) => void;\n contextMenu?: ContextMenuState;\n onContextMenuOpenChange?: (open: boolean) => void;\n rowHeight?: RowHeightValue;\n onRowHeightChange?: (value: RowHeightValue) => void;\n onRowSelect?: (\n rowIndex: number,\n checked: boolean,\n shiftKey: boolean,\n ) => void;\n }\n}\n\nexport interface CellPosition {\n rowIndex: number;\n columnId: string;\n}\n\nexport interface CellRange {\n start: CellPosition;\n end: CellPosition;\n}\n\nexport interface SelectionState {\n selectedCells: Set;\n selectionRange: CellRange | null;\n isSelecting: boolean;\n}\n\nexport interface ContextMenuState {\n open: boolean;\n x: number;\n y: number;\n}\n\nexport type NavigationDirection =\n | \"up\"\n | \"down\"\n | \"left\"\n | \"right\"\n | \"home\"\n | \"end\"\n | \"ctrl+home\"\n | \"ctrl+end\"\n | \"pageup\"\n | \"pagedown\";\n\nexport interface SearchState {\n searchMatches: CellPosition[];\n matchIndex: number;\n searchOpen: boolean;\n onSearchOpenChange: (open: boolean) => void;\n searchQuery: string;\n onSearchQueryChange: (query: string) => void;\n onSearch: (query: string) => void;\n onNavigateToNextMatch: () => void;\n onNavigateToPrevMatch: () => void;\n}\n\nexport interface FileCellData {\n id: string;\n name: string;\n size: number;\n type: string;\n url?: string;\n}\n", + "content": "import type { Cell, RowData, Table } from \"@tanstack/react-table\";\n\nexport type RowHeightValue = \"short\" | \"medium\" | \"tall\" | \"extra-tall\";\n\nexport interface CellSelectOption {\n label: string;\n value: string;\n}\n\nexport type CellOpts =\n | {\n variant: \"short-text\";\n }\n | {\n variant: \"long-text\";\n }\n | {\n variant: \"number\";\n min?: number;\n max?: number;\n step?: number;\n }\n | {\n variant: \"select\";\n options: CellSelectOption[];\n }\n | {\n variant: \"multi-select\";\n options: CellSelectOption[];\n }\n | {\n variant: \"checkbox\";\n }\n | {\n variant: \"date\";\n }\n | {\n variant: \"url\";\n }\n | {\n variant: \"file\";\n maxFileSize?: number;\n maxFiles?: number;\n accept?: string;\n multiple?: boolean;\n };\n\nexport interface UpdateCell {\n rowIndex: number;\n columnId: string;\n value: unknown;\n}\n\ndeclare module \"@tanstack/react-table\" {\n // biome-ignore lint/correctness/noUnusedVariables: TData and TValue are used in the ColumnMeta interface\n interface ColumnMeta {\n label?: string;\n cell?: CellOpts;\n }\n\n // biome-ignore lint/correctness/noUnusedVariables: TData is used in the TableMeta interface\n interface TableMeta {\n dataGridRef?: React.RefObject;\n cellMapRef?: React.RefObject>;\n focusedCell?: CellPosition | null;\n editingCell?: CellPosition | null;\n selectionState?: SelectionState;\n searchOpen?: boolean;\n isScrolling?: boolean;\n getIsCellSelected?: (rowIndex: number, columnId: string) => boolean;\n getIsSearchMatch?: (rowIndex: number, columnId: string) => boolean;\n getIsActiveSearchMatch?: (rowIndex: number, columnId: string) => boolean;\n onDataUpdate?: (props: UpdateCell | Array) => void;\n onRowsDelete?: (rowIndices: number[]) => void | Promise;\n onColumnClick?: (columnId: string) => void;\n onCellClick?: (\n rowIndex: number,\n columnId: string,\n event?: React.MouseEvent,\n ) => void;\n onCellDoubleClick?: (rowIndex: number, columnId: string) => void;\n onCellMouseDown?: (\n rowIndex: number,\n columnId: string,\n event: React.MouseEvent,\n ) => void;\n onCellMouseEnter?: (\n rowIndex: number,\n columnId: string,\n event: React.MouseEvent,\n ) => void;\n onCellMouseUp?: () => void;\n onCellContextMenu?: (\n rowIndex: number,\n columnId: string,\n event: React.MouseEvent,\n ) => void;\n onCellEditingStart?: (rowIndex: number, columnId: string) => void;\n onCellEditingStop?: (opts?: {\n direction?: NavigationDirection;\n moveToNextRow?: boolean;\n }) => void;\n contextMenu?: ContextMenuState;\n onContextMenuOpenChange?: (open: boolean) => void;\n rowHeight?: RowHeightValue;\n onRowHeightChange?: (value: RowHeightValue) => void;\n onRowSelect?: (\n rowIndex: number,\n checked: boolean,\n shiftKey: boolean,\n ) => void;\n }\n}\n\nexport interface CellPosition {\n rowIndex: number;\n columnId: string;\n}\n\nexport interface CellRange {\n start: CellPosition;\n end: CellPosition;\n}\n\nexport interface SelectionState {\n selectedCells: Set;\n selectionRange: CellRange | null;\n isSelecting: boolean;\n}\n\nexport interface ContextMenuState {\n open: boolean;\n x: number;\n y: number;\n}\n\nexport type NavigationDirection =\n | \"up\"\n | \"down\"\n | \"left\"\n | \"right\"\n | \"home\"\n | \"end\"\n | \"ctrl+home\"\n | \"ctrl+end\"\n | \"pageup\"\n | \"pagedown\";\n\nexport interface SearchState {\n searchMatches: CellPosition[];\n matchIndex: number;\n searchOpen: boolean;\n onSearchOpenChange: (open: boolean) => void;\n searchQuery: string;\n onSearchQueryChange: (query: string) => void;\n onSearch: (query: string) => void;\n onNavigateToNextMatch: () => void;\n onNavigateToPrevMatch: () => void;\n}\n\nexport interface CellVariantProps {\n cell: Cell;\n table: Table;\n rowIndex: number;\n columnId: string;\n isEditing: boolean;\n isFocused: boolean;\n isSelected: boolean;\n}\n\nexport interface FileCellData {\n id: string;\n name: string;\n size: number;\n type: string;\n url?: string;\n}\n", "type": "registry:file", "target": "src/types/data-grid.ts" } diff --git a/src/components/data-grid/data-grid-cell-variants.tsx b/src/components/data-grid/data-grid-cell-variants.tsx index a326945bc..540d015cc 100644 --- a/src/components/data-grid/data-grid-cell-variants.tsx +++ b/src/components/data-grid/data-grid-cell-variants.tsx @@ -1,6 +1,5 @@ "use client"; -import type { Cell, Table } from "@tanstack/react-table"; import { Check, File, @@ -15,6 +14,7 @@ import { X, } from "lucide-react"; import * as React from "react"; +import { toast } from "sonner"; import { DataGridCellWrapper } from "@/components/data-grid/data-grid-cell-wrapper"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -47,17 +47,7 @@ import { useBadgeOverflow } from "@/hooks/use-badge-overflow"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { getCellKey, getLineCount } from "@/lib/data-grid"; import { cn } from "@/lib/utils"; -import type { FileCellData } from "@/types/data-grid"; - -interface CellVariantProps { - cell: Cell; - table: Table; - rowIndex: number; - columnId: string; - isEditing: boolean; - isFocused: boolean; - isSelected: boolean; -} +import type { CellVariantProps, FileCellData } from "@/types/data-grid"; export function ShortTextCell({ cell, @@ -466,6 +456,323 @@ export function NumberCell({ ); } +function getUrlHref(urlString: string): string { + if (!urlString || urlString.trim() === "") return ""; + + const trimmed = urlString.trim(); + + // Reject dangerous protocols (extra safety, though our http:// prefix would neutralize them) + if (/^(javascript|data|vbscript|file):/i.test(trimmed)) { + return ""; + } + + // Check if it already has a protocol + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + return trimmed; + } + + // Add http:// prefix for links without protocol + return `http://${trimmed}`; +} + +export function UrlCell({ + cell, + table, + rowIndex, + columnId, + isEditing, + isFocused, + isSelected, +}: CellVariantProps) { + const initialValue = cell.getValue() as string; + const [value, setValue] = React.useState(initialValue ?? ""); + const cellRef = React.useRef(null); + const containerRef = React.useRef(null); + const meta = table.options.meta; + + const prevInitialValueRef = React.useRef(initialValue); + if (initialValue !== prevInitialValueRef.current) { + prevInitialValueRef.current = initialValue; + setValue(initialValue ?? ""); + if (cellRef.current && !isEditing) { + cellRef.current.textContent = initialValue ?? ""; + } + } + + const onBlur = React.useCallback(() => { + const currentValue = cellRef.current?.textContent?.trim() ?? ""; + + if (currentValue !== initialValue) { + meta?.onDataUpdate?.({ + rowIndex, + columnId, + value: currentValue || null, + }); + } + meta?.onCellEditingStop?.(); + }, [meta, rowIndex, columnId, initialValue]); + + const onInput = React.useCallback( + (event: React.FormEvent) => { + const currentValue = event.currentTarget.textContent ?? ""; + setValue(currentValue); + }, + [], + ); + + const onWrapperKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (isEditing) { + if (event.key === "Enter") { + event.preventDefault(); + const currentValue = cellRef.current?.textContent?.trim() ?? ""; + if (currentValue !== initialValue) { + meta?.onDataUpdate?.({ + rowIndex, + columnId, + value: currentValue || null, + }); + } + meta?.onCellEditingStop?.({ moveToNextRow: true }); + } else if (event.key === "Tab") { + event.preventDefault(); + const currentValue = cellRef.current?.textContent?.trim() ?? ""; + if (currentValue !== initialValue) { + meta?.onDataUpdate?.({ + rowIndex, + columnId, + value: currentValue || null, + }); + } + meta?.onCellEditingStop?.({ + direction: event.shiftKey ? "left" : "right", + }); + } else if (event.key === "Escape") { + event.preventDefault(); + setValue(initialValue ?? ""); + cellRef.current?.blur(); + } + } else if ( + isFocused && + event.key.length === 1 && + !event.ctrlKey && + !event.metaKey + ) { + // Handle typing to pre-fill the value when editing starts + setValue(event.key); + + queueMicrotask(() => { + if (cellRef.current && cellRef.current.contentEditable === "true") { + cellRef.current.textContent = event.key; + const range = document.createRange(); + const selection = window.getSelection(); + range.selectNodeContents(cellRef.current); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + } + }); + } + }, + [isEditing, isFocused, initialValue, meta, rowIndex, columnId], + ); + + const onLinkClick = React.useCallback( + (event: React.MouseEvent) => { + if (isEditing) { + event.preventDefault(); + return; + } + + // Check if URL was rejected due to dangerous protocol + const href = getUrlHref(value); + if (!href) { + event.preventDefault(); + toast.error("Invalid URL", { + description: + "URL contains a dangerous protocol (javascript:, data:, vbscript:, or file:)", + }); + return; + } + + // Stop propagation to prevent grid from interfering with link navigation + event.stopPropagation(); + }, + [isEditing, value], + ); + + React.useEffect(() => { + if (isEditing && cellRef.current) { + cellRef.current.focus(); + + if (!cellRef.current.textContent && value) { + cellRef.current.textContent = value; + } + + if (cellRef.current.textContent) { + const range = document.createRange(); + const selection = window.getSelection(); + range.selectNodeContents(cellRef.current); + range.collapse(false); + selection?.removeAllRanges(); + selection?.addRange(range); + } + } + }, [isEditing, value]); + + const displayValue = !isEditing ? (value ?? "") : ""; + const urlHref = displayValue ? getUrlHref(displayValue) : ""; + const isDangerousUrl = displayValue && !urlHref; + + return ( + + {!isEditing && displayValue ? ( + + ) : ( +
+ {displayValue} +
+ )} +
+ ); +} + +export function CheckboxCell({ + cell, + table, + rowIndex, + columnId, + isFocused, + isSelected, +}: CellVariantProps) { + const initialValue = cell.getValue() as boolean; + const [value, setValue] = React.useState(Boolean(initialValue)); + const containerRef = React.useRef(null); + const meta = table.options.meta; + + const prevInitialValueRef = React.useRef(initialValue); + if (initialValue !== prevInitialValueRef.current) { + prevInitialValueRef.current = initialValue; + setValue(Boolean(initialValue)); + } + + const onCheckedChange = React.useCallback( + (checked: boolean) => { + setValue(checked); + meta?.onDataUpdate?.({ rowIndex, columnId, value: checked }); + }, + [meta, rowIndex, columnId], + ); + + const onWrapperKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (isFocused && (event.key === " " || event.key === "Enter")) { + event.preventDefault(); + event.stopPropagation(); + onCheckedChange(!value); + } else if (isFocused && event.key === "Tab") { + event.preventDefault(); + meta?.onCellEditingStop?.({ + direction: event.shiftKey ? "left" : "right", + }); + } + }, + [isFocused, value, onCheckedChange, meta], + ); + + const onWrapperClick = React.useCallback( + (event: React.MouseEvent) => { + if (isFocused) { + event.preventDefault(); + event.stopPropagation(); + onCheckedChange(!value); + } + }, + [isFocused, value, onCheckedChange], + ); + + const onCheckboxClick = React.useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + }, []); + + const onCheckboxMouseDown = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + }, + [], + ); + + const onCheckboxDoubleClick = React.useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + }, + [], + ); + + return ( + + + + ); +} + export function SelectCell({ cell, table, @@ -845,104 +1152,6 @@ export function MultiSelectCell({ ); } -export function CheckboxCell({ - cell, - table, - rowIndex, - columnId, - isFocused, - isSelected, -}: CellVariantProps) { - const initialValue = cell.getValue() as boolean; - const [value, setValue] = React.useState(Boolean(initialValue)); - const containerRef = React.useRef(null); - const meta = table.options.meta; - - const prevInitialValueRef = React.useRef(initialValue); - if (initialValue !== prevInitialValueRef.current) { - prevInitialValueRef.current = initialValue; - setValue(Boolean(initialValue)); - } - - const onCheckedChange = React.useCallback( - (checked: boolean) => { - setValue(checked); - meta?.onDataUpdate?.({ rowIndex, columnId, value: checked }); - }, - [meta, rowIndex, columnId], - ); - - const onWrapperKeyDown = React.useCallback( - (event: React.KeyboardEvent) => { - if (isFocused && (event.key === " " || event.key === "Enter")) { - event.preventDefault(); - event.stopPropagation(); - onCheckedChange(!value); - } else if (isFocused && event.key === "Tab") { - event.preventDefault(); - meta?.onCellEditingStop?.({ - direction: event.shiftKey ? "left" : "right", - }); - } - }, - [isFocused, value, onCheckedChange, meta], - ); - - const onWrapperClick = React.useCallback( - (event: React.MouseEvent) => { - if (isFocused) { - event.preventDefault(); - event.stopPropagation(); - onCheckedChange(!value); - } - }, - [isFocused, value, onCheckedChange], - ); - - const onCheckboxClick = React.useCallback((event: React.MouseEvent) => { - event.stopPropagation(); - }, []); - - const onCheckboxMouseDown = React.useCallback( - (event: React.MouseEvent) => { - event.stopPropagation(); - }, - [], - ); - - const onCheckboxDoubleClick = React.useCallback( - (event: React.MouseEvent) => { - event.stopPropagation(); - }, - [], - ); - - return ( - - - - ); -} - function formatDateForDisplay(dateStr: string) { if (!dateStr) return ""; const date = new Date(dateStr); diff --git a/src/components/data-grid/data-grid-cell.tsx b/src/components/data-grid/data-grid-cell.tsx index c136f6ae4..64c077d7a 100644 --- a/src/components/data-grid/data-grid-cell.tsx +++ b/src/components/data-grid/data-grid-cell.tsx @@ -12,6 +12,7 @@ import { NumberCell, SelectCell, ShortTextCell, + UrlCell, } from "@/components/data-grid/data-grid-cell-variants"; interface DataGridCellProps { @@ -78,9 +79,9 @@ export function DataGridCell({ cell, table }: DataGridCellProps) { isSelected={isSelected} /> ); - case "select": + case "url": return ( - ({ cell, table }: DataGridCellProps) { isSelected={isSelected} /> ); - case "multi-select": + case "checkbox": return ( - ({ cell, table }: DataGridCellProps) { isSelected={isSelected} /> ); - case "checkbox": + case "select": return ( - + ); + case "multi-select": + return ( + >; label: string; } | null { @@ -52,12 +53,14 @@ function getColumnVariant(variant?: Cell["variant"]): { return { icon: TextInitialIcon, label: "Long text" }; case "number": return { icon: HashIcon, label: "Number" }; + case "url": + return { icon: LinkIcon, label: "URL" }; + case "checkbox": + return { icon: CheckSquareIcon, label: "Checkbox" }; case "select": return { icon: ListIcon, label: "Select" }; case "multi-select": return { icon: ListChecksIcon, label: "Multi-select" }; - case "checkbox": - return { icon: CheckSquareIcon, label: "Checkbox" }; case "date": return { icon: CalendarIcon, label: "Date" }; case "file": diff --git a/src/components/data-grid/data-grid-demo.tsx b/src/components/data-grid/data-grid-demo.tsx index 50fa74095..267ada18b 100644 --- a/src/components/data-grid/data-grid-demo.tsx +++ b/src/components/data-grid/data-grid-demo.tsx @@ -18,6 +18,7 @@ interface Person { name?: string; age?: number; email?: string; + website?: string; notes?: string; salary?: number; department?: string; @@ -125,6 +126,7 @@ function generatePerson(id: number): Person { name: `${firstName} ${lastName}`, age: faker.number.int({ min: 22, max: 65 }), email: faker.internet.email({ firstName, lastName }).toLowerCase(), + website: faker.internet.url().replace(/\/$/, ""), notes: faker.helpers.arrayElement(notes), salary: faker.number.int({ min: 40000, max: 150000 }), department: faker.helpers.arrayElement(departments), @@ -233,6 +235,18 @@ export function DataGridDemo() { }, }, }, + { + id: "website", + accessorKey: "website", + header: "Website", + minSize: 240, + meta: { + label: "Website", + cell: { + variant: "url", + }, + }, + }, { id: "notes", accessorKey: "notes", diff --git a/src/types/data-grid.ts b/src/types/data-grid.ts index 987ba3aca..9eb560df9 100644 --- a/src/types/data-grid.ts +++ b/src/types/data-grid.ts @@ -1,4 +1,4 @@ -import type { RowData } from "@tanstack/react-table"; +import type { Cell, RowData, Table } from "@tanstack/react-table"; export type RowHeightValue = "short" | "medium" | "tall" | "extra-tall"; @@ -7,7 +7,7 @@ export interface CellSelectOption { value: string; } -export type Cell = +export type CellOpts = | { variant: "short-text"; } @@ -34,6 +34,9 @@ export type Cell = | { variant: "date"; } + | { + variant: "url"; + } | { variant: "file"; maxFileSize?: number; @@ -52,7 +55,7 @@ declare module "@tanstack/react-table" { // biome-ignore lint/correctness/noUnusedVariables: TData and TValue are used in the ColumnMeta interface interface ColumnMeta { label?: string; - cell?: Cell; + cell?: CellOpts; } // biome-ignore lint/correctness/noUnusedVariables: TData is used in the TableMeta interface @@ -155,6 +158,16 @@ export interface SearchState { onNavigateToPrevMatch: () => void; } +export interface CellVariantProps { + cell: Cell; + table: Table; + rowIndex: number; + columnId: string; + isEditing: boolean; + isFocused: boolean; + isSelected: boolean; +} + export interface FileCellData { id: string; name: string;