diff --git a/public/r/data-grid.json b/public/r/data-grid.json index 92d53c5af..06f6c7f8e 100644 --- a/public/r/data-grid.json +++ b/public/r/data-grid.json @@ -21,6 +21,7 @@ "popover", "select", "separator", + "skeleton", "textarea", "tooltip" ], @@ -33,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 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\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} 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", "type": "registry:component", "target": "src/components/data-grid/data-grid-cell.tsx" }, @@ -45,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 { Check, X } 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 { 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 { Textarea } from \"@/components/ui/textarea\";\nimport { useDebouncedCallback } from \"@/hooks/use-debounced-callback\";\nimport { getLineCount } from \"@/lib/data-grid\";\nimport { cn } from \"@/lib/utils\";\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 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 setValue(initialValue);\n if (cellRef.current && !isEditing) {\n cellRef.current.textContent = initialValue;\n }\n }, [initialValue, isEditing]);\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 // Don't focus if we're in the middle of a scroll operation\n if (\n isFocused &&\n !isEditing &&\n !meta?.searchOpen &&\n !meta?.isScrolling &&\n containerRef.current\n ) {\n containerRef.current.focus();\n }\n }, [isFocused, isEditing, value, meta?.searchOpen, meta?.isScrolling]);\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 [open, setOpen] = React.useState(false);\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 setOpen(false);\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 setOpen(false);\n meta?.onCellEditingStop?.();\n }, [meta, 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 onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n setOpen(isOpen);\n if (!isOpen) {\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 onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && !open) {\n if (event.key === \"Escape\") {\n event.preventDefault();\n meta?.onCellEditingStop?.();\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 }\n }\n },\n [isEditing, open, meta, value, initialValue, rowIndex, columnId],\n );\n\n const onTextareaKeyDown = 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 }\n // Stop propagation to prevent grid navigation\n event.stopPropagation();\n },\n [onCancel, onSave],\n );\n\n const onTextareaBlur = React.useCallback(() => {\n // Immediately save any pending changes on blur\n if (value !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n setOpen(false);\n meta?.onCellEditingStop?.();\n }, [meta, value, initialValue, rowIndex, columnId]);\n\n React.useEffect(() => {\n if (isEditing && !open) {\n setOpen(true);\n }\n if (\n isFocused &&\n !isEditing &&\n !meta?.searchOpen &&\n !meta?.isScrolling &&\n containerRef.current\n ) {\n containerRef.current.focus();\n }\n }, [isFocused, isEditing, open, meta?.searchOpen, meta?.isScrolling]);\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 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 setValue(String(initialValue ?? \"\"));\n }, [initialValue]);\n\n React.useEffect(() => {\n if (isEditing && inputRef.current) {\n inputRef.current.focus();\n inputRef.current.select();\n }\n if (\n isFocused &&\n !isEditing &&\n !meta?.searchOpen &&\n !meta?.isScrolling &&\n containerRef.current\n ) {\n containerRef.current.focus();\n }\n }, [isFocused, isEditing, meta?.searchOpen, meta?.isScrolling]);\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 [open, setOpen] = React.useState(false);\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 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 setOpen(isOpen);\n if (!isOpen) {\n meta?.onCellEditingStop?.();\n }\n },\n [meta],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n setOpen(false);\n meta?.onCellEditingStop?.();\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n setOpen(false);\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n }\n },\n [isEditing, initialValue, meta],\n );\n\n React.useEffect(() => {\n setValue(initialValue);\n }, [initialValue]);\n\n React.useEffect(() => {\n if (isEditing && !open) {\n setOpen(true);\n }\n if (\n isFocused &&\n !isEditing &&\n !meta?.searchOpen &&\n !meta?.isScrolling &&\n containerRef.current\n ) {\n containerRef.current.focus();\n }\n }, [isFocused, isEditing, open, meta?.searchOpen, meta?.isScrolling]);\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 cellId = `${rowIndex}-${columnId}`;\n const prevCellIdRef = React.useRef(cellId);\n\n const [selectedValues, setSelectedValues] =\n React.useState(cellValue);\n const [open, setOpen] = React.useState(false);\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 (prevCellIdRef.current !== cellId) {\n prevCellIdRef.current = cellId;\n setSelectedValues(cellValue);\n setOpen(false);\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 setOpen(isOpen);\n if (!isOpen) {\n setSearchValue(\"\");\n meta?.onCellEditingStop?.();\n }\n },\n [meta],\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) {\n if (event.key === \"Escape\") {\n event.preventDefault();\n setSelectedValues(cellValue);\n setSearchValue(\"\");\n setOpen(false);\n meta?.onCellEditingStop?.();\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n setSearchValue(\"\");\n setOpen(false);\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n }\n },\n [isEditing, 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 React.useEffect(() => {\n if (isEditing && !open) {\n setOpen(true);\n }\n if (\n isFocused &&\n !isEditing &&\n !meta?.searchOpen &&\n !meta?.isScrolling &&\n containerRef.current\n ) {\n containerRef.current.focus();\n }\n }, [isFocused, isEditing, open, meta?.searchOpen, meta?.isScrolling]);\n\n // Focus input when popover opens\n React.useEffect(() => {\n if (open && inputRef.current) {\n setTimeout(() => inputRef.current?.focus(), 0);\n }\n }, [open]);\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\n const lineCount = getLineCount(rowHeight);\n const maxVisibleBadgeCount = lineCount * 3;\n\n const visibleLabels = displayLabels.slice(0, maxVisibleBadgeCount);\n const hiddenBadgeCount = Math.max(\n 0,\n displayLabels.length - maxVisibleBadgeCount,\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 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 }\n },\n [isFocused, value, onCheckedChange],\n );\n\n React.useEffect(() => {\n setValue(Boolean(initialValue));\n }, [initialValue]);\n\n React.useEffect(() => {\n if (\n isFocused &&\n !meta?.searchOpen &&\n !meta?.isScrolling &&\n containerRef.current\n ) {\n containerRef.current.focus();\n }\n }, [isFocused, meta?.searchOpen, meta?.isScrolling]);\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 [open, setOpen] = React.useState(false);\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 setOpen(false);\n meta?.onCellEditingStop?.();\n },\n [meta, rowIndex, columnId],\n );\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n setOpen(isOpen);\n if (!isOpen && isEditing) {\n meta?.onCellEditingStop?.();\n }\n },\n [isEditing, meta],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n setOpen(false);\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n setOpen(false);\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n }\n },\n [isEditing, initialValue, meta],\n );\n\n React.useEffect(() => {\n if (isEditing) {\n setOpen(true);\n } else {\n setOpen(false);\n }\n }, [isEditing]);\n\n React.useEffect(() => {\n if (\n isFocused &&\n !isEditing &&\n !meta?.searchOpen &&\n !meta?.isScrolling &&\n containerRef.current\n ) {\n containerRef.current.focus();\n }\n }, [isFocused, isEditing, meta?.searchOpen, meta?.isScrolling]);\n\n return (\n \n \n \n \n {formatDateForDisplay(value)}\n \n \n {isEditing && (\n \n \n \n )}\n \n \n );\n}\n", + "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 { useDebouncedCallback } from \"@/hooks/use-debounced-callback\";\nimport { 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 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 setValue(initialValue);\n if (cellRef.current && !isEditing) {\n cellRef.current.textContent = initialValue;\n }\n }, [initialValue, isEditing]);\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 // Don't focus if we're in the middle of a scroll operation\n if (\n isFocused &&\n !isEditing &&\n !meta?.searchOpen &&\n !meta?.isScrolling &&\n containerRef.current\n ) {\n containerRef.current.focus();\n }\n }, [isFocused, isEditing, value, meta?.searchOpen, meta?.isScrolling]);\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 [open, setOpen] = React.useState(false);\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 setOpen(false);\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 setOpen(false);\n meta?.onCellEditingStop?.();\n }, [meta, 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 onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n setOpen(isOpen);\n if (!isOpen) {\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 onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing && !open) {\n if (event.key === \"Escape\") {\n event.preventDefault();\n meta?.onCellEditingStop?.();\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 }\n }\n },\n [isEditing, open, meta, value, initialValue, rowIndex, columnId],\n );\n\n const onTextareaKeyDown = 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 }\n // Stop propagation to prevent grid navigation\n event.stopPropagation();\n },\n [onCancel, onSave],\n );\n\n const onTextareaBlur = React.useCallback(() => {\n // Immediately save any pending changes on blur\n if (value !== initialValue) {\n meta?.onDataUpdate?.({ rowIndex, columnId, value });\n }\n setOpen(false);\n meta?.onCellEditingStop?.();\n }, [meta, value, initialValue, rowIndex, columnId]);\n\n React.useEffect(() => {\n if (isEditing && !open) {\n setOpen(true);\n }\n if (\n isFocused &&\n !isEditing &&\n !meta?.searchOpen &&\n !meta?.isScrolling &&\n containerRef.current\n ) {\n containerRef.current.focus();\n }\n }, [isFocused, isEditing, open, meta?.searchOpen, meta?.isScrolling]);\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 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 setValue(String(initialValue ?? \"\"));\n }, [initialValue]);\n\n React.useEffect(() => {\n if (isEditing && inputRef.current) {\n inputRef.current.focus();\n inputRef.current.select();\n }\n if (\n isFocused &&\n !isEditing &&\n !meta?.searchOpen &&\n !meta?.isScrolling &&\n containerRef.current\n ) {\n containerRef.current.focus();\n }\n }, [isFocused, isEditing, meta?.searchOpen, meta?.isScrolling]);\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 [open, setOpen] = React.useState(false);\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 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 setOpen(isOpen);\n if (!isOpen) {\n meta?.onCellEditingStop?.();\n }\n },\n [meta],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n setOpen(false);\n meta?.onCellEditingStop?.();\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n setOpen(false);\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n }\n },\n [isEditing, initialValue, meta],\n );\n\n React.useEffect(() => {\n setValue(initialValue);\n }, [initialValue]);\n\n React.useEffect(() => {\n if (isEditing && !open) {\n setOpen(true);\n }\n if (\n isFocused &&\n !isEditing &&\n !meta?.searchOpen &&\n !meta?.isScrolling &&\n containerRef.current\n ) {\n containerRef.current.focus();\n }\n }, [isFocused, isEditing, open, meta?.searchOpen, meta?.isScrolling]);\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 cellId = `${rowIndex}-${columnId}`;\n const prevCellIdRef = React.useRef(cellId);\n\n const [selectedValues, setSelectedValues] =\n React.useState(cellValue);\n const [open, setOpen] = React.useState(false);\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 (prevCellIdRef.current !== cellId) {\n prevCellIdRef.current = cellId;\n setSelectedValues(cellValue);\n setOpen(false);\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 setOpen(isOpen);\n if (!isOpen) {\n setSearchValue(\"\");\n meta?.onCellEditingStop?.();\n }\n },\n [meta],\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) {\n if (event.key === \"Escape\") {\n event.preventDefault();\n setSelectedValues(cellValue);\n setSearchValue(\"\");\n setOpen(false);\n meta?.onCellEditingStop?.();\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n setSearchValue(\"\");\n setOpen(false);\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n }\n },\n [isEditing, 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 React.useEffect(() => {\n if (isEditing && !open) {\n setOpen(true);\n }\n if (\n isFocused &&\n !isEditing &&\n !meta?.searchOpen &&\n !meta?.isScrolling &&\n containerRef.current\n ) {\n containerRef.current.focus();\n }\n }, [isFocused, isEditing, open, meta?.searchOpen, meta?.isScrolling]);\n\n // Focus input when popover opens\n React.useEffect(() => {\n if (open && inputRef.current) {\n setTimeout(() => inputRef.current?.focus(), 0);\n }\n }, [open]);\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\n const lineCount = getLineCount(rowHeight);\n const maxVisibleBadgeCount = lineCount * 3;\n\n const visibleLabels = displayLabels.slice(0, maxVisibleBadgeCount);\n const hiddenBadgeCount = Math.max(\n 0,\n displayLabels.length - maxVisibleBadgeCount,\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 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 }\n },\n [isFocused, value, onCheckedChange],\n );\n\n React.useEffect(() => {\n setValue(Boolean(initialValue));\n }, [initialValue]);\n\n React.useEffect(() => {\n if (\n isFocused &&\n !meta?.searchOpen &&\n !meta?.isScrolling &&\n containerRef.current\n ) {\n containerRef.current.focus();\n }\n }, [isFocused, meta?.searchOpen, meta?.isScrolling]);\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 [open, setOpen] = React.useState(false);\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 setOpen(false);\n meta?.onCellEditingStop?.();\n },\n [meta, rowIndex, columnId],\n );\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n setOpen(isOpen);\n if (!isOpen && isEditing) {\n meta?.onCellEditingStop?.();\n }\n },\n [isEditing, meta],\n );\n\n const onWrapperKeyDown = React.useCallback(\n (event: React.KeyboardEvent) => {\n if (isEditing) {\n if (event.key === \"Escape\") {\n event.preventDefault();\n setValue(initialValue);\n setOpen(false);\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n setOpen(false);\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n }\n }\n },\n [isEditing, initialValue, meta],\n );\n\n React.useEffect(() => {\n if (isEditing) {\n setOpen(true);\n } else {\n setOpen(false);\n }\n }, [isEditing]);\n\n React.useEffect(() => {\n if (\n isFocused &&\n !isEditing &&\n !meta?.searchOpen &&\n !meta?.isScrolling &&\n containerRef.current\n ) {\n containerRef.current.focus();\n }\n }, [isFocused, isEditing, meta?.searchOpen, meta?.isScrolling]);\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 cellId = `${rowIndex}-${columnId}`;\n const prevCellIdRef = React.useRef(cellId);\n\n const [files, setFiles] = React.useState(cellValue);\n const [uploadingFiles, setUploadingFiles] = React.useState>(\n new Set(),\n );\n const [open, setOpen] = React.useState(false);\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 meta = table.options.meta;\n const cellOpts = cell.column.columnDef.meta?.cell;\n const sideOffset = -(containerRef.current?.clientHeight ?? 0);\n\n // Configuration options\n const maxFileSize =\n cellOpts?.variant === \"file\" ? cellOpts.maxFileSize : 10 * 1024 * 1024; // 10MB default\n const maxFiles = cellOpts?.variant === \"file\" ? cellOpts.maxFiles : 10;\n const acceptedTypes =\n cellOpts?.variant === \"file\" ? cellOpts.acceptedTypes : undefined;\n\n if (prevCellIdRef.current !== cellId) {\n prevCellIdRef.current = cellId;\n setFiles(cellValue);\n setOpen(false);\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 (\n acceptedTypes &&\n !acceptedTypes.some((type) => file.type.includes(type))\n ) {\n return `File type not accepted. Accepted: ${acceptedTypes.join(\", \")}`;\n }\n return null;\n },\n [maxFileSize, acceptedTypes],\n );\n\n const addFiles = React.useCallback(\n async (newFiles: File[], skipUpload = false) => {\n setError(null);\n\n // Check max files limit\n if (maxFiles && files.length + newFiles.length > maxFiles) {\n 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 && !firstError) {\n firstError = validationError;\n continue;\n }\n\n // Create file data object with temporary ID\n const fileData: FileCellData = {\n id: crypto.randomUUID(),\n name: file.name,\n size: file.size,\n type: file.type,\n url: URL.createObjectURL(file),\n };\n validFiles.push(fileData);\n }\n\n if (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 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 setFiles([]);\n setError(null);\n meta?.onDataUpdate?.({ rowIndex, columnId, value: [] });\n }, [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 onBrowseClick = React.useCallback(() => {\n fileInputRef.current?.click();\n }, []);\n\n const onOpenChange = React.useCallback(\n (isOpen: boolean) => {\n setOpen(isOpen);\n if (!isOpen) {\n setError(null);\n meta?.onCellEditingStop?.();\n }\n },\n [meta],\n );\n\n const onOpenAutoFocus: NonNullable<\n React.ComponentProps[\"onOpenAutoFocus\"]\n > = React.useCallback((event) => {\n event.preventDefault();\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 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 setOpen(false);\n meta?.onCellEditingStop?.();\n } else if (event.key === \"Tab\") {\n event.preventDefault();\n setOpen(false);\n meta?.onCellEditingStop?.({\n direction: event.shiftKey ? \"left\" : \"right\",\n });\n } else if (event.key === \" \") {\n event.preventDefault();\n onBrowseClick();\n }\n }\n },\n [isEditing, cellValue, meta, onBrowseClick],\n );\n\n React.useEffect(() => {\n if (isEditing && !open) {\n setOpen(true);\n }\n if (\n isFocused &&\n !isEditing &&\n !meta?.searchOpen &&\n !meta?.isScrolling &&\n containerRef.current\n ) {\n containerRef.current.focus();\n }\n }, [isFocused, isEditing, open, meta?.searchOpen, meta?.isScrolling]);\n\n const rowHeight = table.options.meta?.rowHeight ?? \"short\";\n const lineCount = getLineCount(rowHeight);\n const maxVisibleBadgeCount = lineCount * 3;\n\n const visibleFiles = files.slice(0, maxVisibleBadgeCount);\n const hiddenFileCount = Math.max(0, files.length - maxVisibleBadgeCount);\n\n return (\n \n {isEditing ? (\n \n \n
\n \n \n
\n {/* Drag & Drop Zone */}\n \n \n
\n

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

\n

\n or click to browse\n

\n
\n {maxFileSize && (\n

\n Max size: {formatFileSize(maxFileSize)}\n {maxFiles && ` • Max ${maxFiles} files`}\n

\n )}\n \n\n \n\n {/* Error Message */}\n {error && (\n
\n {error}\n
\n )}\n\n {/* File List */}\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 \n {React.createElement(getFileIcon(file.type), {\n className: \"size-4 shrink-0 text-muted-foreground\",\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 ) : null}\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 ) : isDraggingOver ? (\n
\n \n Drop files here\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 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 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 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", "type": "registry:component", "target": "src/components/data-grid/data-grid-column-header.tsx" }, @@ -100,7 +101,7 @@ }, { "path": "src/types/data-grid.ts", - "content": "import type { RowData } from \"@tanstack/react-table\";\n\nexport type RowHeightValue = \"short\" | \"medium\" | \"tall\" | \"extra-tall\";\n\nexport interface CellSelectOption {\n label: string;\n value: string;\n}\n\nexport type Cell =\n | {\n variant: \"short-text\";\n }\n | {\n variant: \"long-text\";\n }\n | {\n variant: \"number\";\n min?: number;\n max?: number;\n step?: number;\n }\n | {\n variant: \"select\";\n options: CellSelectOption[];\n }\n | {\n variant: \"multi-select\";\n options: CellSelectOption[];\n }\n | {\n variant: \"checkbox\";\n }\n | {\n variant: \"date\";\n };\n\nexport interface UpdateCell {\n rowIndex: number;\n columnId: string;\n value: unknown;\n}\n\ndeclare module \"@tanstack/react-table\" {\n // biome-ignore lint/correctness/noUnusedVariables: TData and TValue are used in the ColumnMeta interface\n interface ColumnMeta {\n label?: string;\n cell?: Cell;\n }\n\n // biome-ignore lint/correctness/noUnusedVariables: TData is used in the TableMeta interface\n interface TableMeta {\n dataGridRef?: React.RefObject;\n focusedCell?: CellPosition | null;\n editingCell?: CellPosition | null;\n selectionState?: SelectionState;\n searchOpen?: boolean;\n isScrolling?: boolean;\n getIsCellSelected?: (rowIndex: number, columnId: string) => boolean;\n getIsSearchMatch?: (rowIndex: number, columnId: string) => boolean;\n getIsActiveSearchMatch?: (rowIndex: number, columnId: string) => boolean;\n onDataUpdate?: (props: UpdateCell | Array) => void;\n 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", + "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 acceptedTypes?: string[];\n };\n\nexport interface UpdateCell {\n rowIndex: number;\n columnId: string;\n value: unknown;\n}\n\ndeclare module \"@tanstack/react-table\" {\n // biome-ignore lint/correctness/noUnusedVariables: TData and TValue are used in the ColumnMeta interface\n interface ColumnMeta {\n label?: string;\n cell?: Cell;\n }\n\n // biome-ignore lint/correctness/noUnusedVariables: TData is used in the TableMeta interface\n interface TableMeta {\n dataGridRef?: React.RefObject;\n focusedCell?: CellPosition | null;\n editingCell?: CellPosition | null;\n selectionState?: SelectionState;\n searchOpen?: boolean;\n isScrolling?: boolean;\n getIsCellSelected?: (rowIndex: number, columnId: string) => boolean;\n getIsSearchMatch?: (rowIndex: number, columnId: string) => boolean;\n getIsActiveSearchMatch?: (rowIndex: number, columnId: string) => boolean;\n onDataUpdate?: (props: UpdateCell | Array) => void;\n onRowsDelete?: (rowIndices: number[]) => void | Promise;\n onColumnClick?: (columnId: string) => void;\n onCellClick?: (\n rowIndex: number,\n columnId: string,\n event?: React.MouseEvent,\n ) => void;\n onCellDoubleClick?: (rowIndex: number, columnId: string) => void;\n onCellMouseDown?: (\n rowIndex: number,\n columnId: string,\n event: React.MouseEvent,\n ) => void;\n onCellMouseEnter?: (\n rowIndex: number,\n columnId: string,\n event: React.MouseEvent,\n ) => void;\n onCellMouseUp?: () => void;\n onCellContextMenu?: (\n rowIndex: number,\n columnId: string,\n event: React.MouseEvent,\n ) => void;\n onCellEditingStart?: (rowIndex: number, columnId: string) => void;\n onCellEditingStop?: (opts?: {\n direction?: NavigationDirection;\n moveToNextRow?: boolean;\n }) => void;\n contextMenu?: ContextMenuState;\n onContextMenuOpenChange?: (open: boolean) => void;\n rowHeight?: RowHeightValue;\n onRowHeightChange?: (value: RowHeightValue) => void;\n onRowSelect?: (\n rowIndex: number,\n checked: boolean,\n shiftKey: boolean,\n ) => void;\n }\n}\n\nexport interface CellPosition {\n rowIndex: number;\n columnId: string;\n}\n\nexport interface CellRange {\n start: CellPosition;\n end: CellPosition;\n}\n\nexport interface SelectionState {\n selectedCells: Set;\n selectionRange: CellRange | null;\n isSelecting: boolean;\n}\n\nexport interface ContextMenuState {\n open: boolean;\n x: number;\n y: number;\n}\n\nexport type NavigationDirection =\n | \"up\"\n | \"down\"\n | \"left\"\n | \"right\"\n | \"home\"\n | \"end\"\n | \"ctrl+home\"\n | \"ctrl+end\"\n | \"pageup\"\n | \"pagedown\";\n\nexport interface SearchState {\n searchMatches: CellPosition[];\n matchIndex: number;\n searchOpen: boolean;\n onSearchOpenChange: (open: boolean) => void;\n searchQuery: string;\n onSearchQueryChange: (query: string) => void;\n onSearch: (query: string) => void;\n onNavigateToNextMatch: () => void;\n onNavigateToPrevMatch: () => void;\n}\n\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/public/r/registry.json b/public/r/registry.json index f73cfd9be..1128291be 100644 --- a/public/r/registry.json +++ b/public/r/registry.json @@ -334,6 +334,7 @@ "popover", "select", "separator", + "skeleton", "textarea", "tooltip" ], diff --git a/registry.json b/registry.json index f73cfd9be..1128291be 100644 --- a/registry.json +++ b/registry.json @@ -334,6 +334,7 @@ "popover", "select", "separator", + "skeleton", "textarea", "tooltip" ], diff --git a/src/components/data-grid/data-grid-cell-variants.tsx b/src/components/data-grid/data-grid-cell-variants.tsx index 968d0aa17..cdfd904a7 100644 --- a/src/components/data-grid/data-grid-cell-variants.tsx +++ b/src/components/data-grid/data-grid-cell-variants.tsx @@ -1,10 +1,23 @@ "use client"; import type { Cell, Table } from "@tanstack/react-table"; -import { Check, X } from "lucide-react"; +import { + Check, + File, + FileArchive, + FileAudio, + FileImage, + FileSpreadsheet, + FileText, + FileVideo, + Presentation, + Upload, + X, +} from "lucide-react"; import * as React from "react"; import { DataGridCellWrapper } from "@/components/data-grid/data-grid-cell-wrapper"; import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { Checkbox } from "@/components/ui/checkbox"; import { @@ -28,10 +41,12 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; import { Textarea } from "@/components/ui/textarea"; import { useDebouncedCallback } from "@/hooks/use-debounced-callback"; import { getLineCount } from "@/lib/data-grid"; import { cn } from "@/lib/utils"; +import type { FileCellData } from "@/types/data-grid"; interface CellVariantProps { cell: Cell; @@ -184,7 +199,7 @@ export function ShortTextCell({ onInput={onInput} suppressContentEditableWarning className={cn("size-full overflow-hidden outline-none", { - "whitespace-nowrap [&_*]:inline [&_*]:whitespace-nowrap [&_br]:hidden": + "whitespace-nowrap **:inline **:whitespace-nowrap [&_br]:hidden": isEditing, })} > @@ -813,7 +828,7 @@ export function MultiSelectCell({ className="w-[300px] rounded-none p-0" onOpenAutoFocus={onOpenAutoFocus} > - +
{selectedValues.map((value) => { const option = options.find((opt) => opt.value === value); @@ -1004,9 +1019,9 @@ export function CheckboxCell({ isEditing={false} isFocused={isFocused} isSelected={isSelected} + className="flex size-full justify-center" onClick={onWrapperClick} onKeyDown={onWrapperKeyDown} - className="flex size-full justify-center" > ({ ); } + +function formatFileSize(bytes: number): string { + if (bytes === 0) return "0 B"; + const k = 1024; + const sizes = ["B", "KB", "MB", "GB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`; +} + +function getFileIcon( + type: string, +): React.ComponentType> { + if (type.startsWith("image/")) return FileImage; + if (type.startsWith("video/")) return FileVideo; + if (type.startsWith("audio/")) return FileAudio; + if (type.includes("pdf")) return FileText; + if (type.includes("zip") || type.includes("rar")) return FileArchive; + if ( + type.includes("word") || + type.includes("document") || + type.includes("doc") + ) + return FileText; + if (type.includes("sheet") || type.includes("excel") || type.includes("xls")) + return FileSpreadsheet; + if ( + type.includes("presentation") || + type.includes("powerpoint") || + type.includes("ppt") + ) + return Presentation; + return File; +} + +export function FileCell({ + cell, + table, + rowIndex, + columnId, + isFocused, + isEditing, + isSelected, +}: CellVariantProps) { + const cellValue = React.useMemo( + () => (cell.getValue() as FileCellData[]) ?? [], + [cell], + ); + + const cellId = `${rowIndex}-${columnId}`; + const prevCellIdRef = React.useRef(cellId); + + const [files, setFiles] = React.useState(cellValue); + const [uploadingFiles, setUploadingFiles] = React.useState>( + new Set(), + ); + const [open, setOpen] = React.useState(false); + const [isDraggingOver, setIsDraggingOver] = React.useState(false); + const [isDragging, setIsDragging] = React.useState(false); + const [error, setError] = React.useState(null); + const containerRef = React.useRef(null); + const fileInputRef = React.useRef(null); + const meta = table.options.meta; + const cellOpts = cell.column.columnDef.meta?.cell; + const sideOffset = -(containerRef.current?.clientHeight ?? 0); + + // Configuration options + const maxFileSize = + cellOpts?.variant === "file" ? cellOpts.maxFileSize : 10 * 1024 * 1024; // 10MB default + const maxFiles = cellOpts?.variant === "file" ? cellOpts.maxFiles : 10; + const acceptedTypes = + cellOpts?.variant === "file" ? cellOpts.acceptedTypes : undefined; + + if (prevCellIdRef.current !== cellId) { + prevCellIdRef.current = cellId; + setFiles(cellValue); + setOpen(false); + setError(null); + } + + const validateFile = React.useCallback( + (file: File): string | null => { + if (maxFileSize && file.size > maxFileSize) { + return `File size exceeds ${formatFileSize(maxFileSize)}`; + } + if ( + acceptedTypes && + !acceptedTypes.some((type) => file.type.includes(type)) + ) { + return `File type not accepted. Accepted: ${acceptedTypes.join(", ")}`; + } + return null; + }, + [maxFileSize, acceptedTypes], + ); + + const addFiles = React.useCallback( + async (newFiles: File[], skipUpload = false) => { + setError(null); + + // Check max files limit + if (maxFiles && files.length + newFiles.length > maxFiles) { + setError(`Maximum ${maxFiles} files allowed`); + return; + } + + const validFiles: FileCellData[] = []; + let firstError: string | null = null; + + for (const file of newFiles) { + const validationError = validateFile(file); + if (validationError && !firstError) { + firstError = validationError; + continue; + } + + // Create file data object with temporary ID + const fileData: FileCellData = { + id: crypto.randomUUID(), + name: file.name, + size: file.size, + type: file.type, + url: URL.createObjectURL(file), + }; + validFiles.push(fileData); + } + + if (firstError) { + setError(firstError); + } + + if (validFiles.length > 0) { + // If not skipping upload (dropped on cell), show skeletons first + if (!skipUpload) { + // Add temp files immediately (will show as skeletons) + const tempFiles = validFiles.map((f) => ({ ...f, url: undefined })); + const filesWithTemp = [...files, ...tempFiles]; + setFiles(filesWithTemp); + + // Mark as uploading + const uploadingIds = new Set(validFiles.map((f) => f.id)); + setUploadingFiles(uploadingIds); + + // Simulate upload delay (in real app, this would be actual upload) + await new Promise((resolve) => setTimeout(resolve, 800)); + + // Replace temp files with real ones + const finalFiles = filesWithTemp.map( + (f) => validFiles.find((vf) => vf.id === f.id) || f, + ); + setFiles(finalFiles); + setUploadingFiles(new Set()); + meta?.onDataUpdate?.({ rowIndex, columnId, value: finalFiles }); + } else { + // If from editor, add immediately without skeleton + const updatedFiles = [...files, ...validFiles]; + setFiles(updatedFiles); + meta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles }); + } + } + }, + [files, maxFiles, validateFile, meta, rowIndex, columnId], + ); + + const removeFile = React.useCallback( + (fileId: string) => { + setError(null); + const updatedFiles = files.filter((f) => f.id !== fileId); + setFiles(updatedFiles); + meta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles }); + }, + [files, meta, rowIndex, columnId], + ); + + const clearAll = React.useCallback(() => { + setFiles([]); + setError(null); + meta?.onDataUpdate?.({ rowIndex, columnId, value: [] }); + }, [meta, rowIndex, columnId]); + + const onDragEnter = React.useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(true); + }, []); + + const onDragLeave = React.useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + const rect = event.currentTarget.getBoundingClientRect(); + const x = event.clientX; + const y = event.clientY; + + if ( + x <= rect.left || + x >= rect.right || + y <= rect.top || + y >= rect.bottom + ) { + setIsDragging(false); + } + }, []); + + const onDragOver = React.useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + }, []); + + const onDrop = React.useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDragging(false); + + const droppedFiles = Array.from(event.dataTransfer.files); + addFiles(droppedFiles, true); // Skip upload skeleton in editor + }, + [addFiles], + ); + + const onFileInputChange = React.useCallback( + (event: React.ChangeEvent) => { + const selectedFiles = Array.from(event.target.files ?? []); + addFiles(selectedFiles, true); // Skip upload skeleton for manual selection + // Reset input so the same file can be selected again + event.target.value = ""; + }, + [addFiles], + ); + + // Cell-level drag handlers (for dropping directly on cell) + const onCellDragEnter = React.useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + // Only show drop indicator if dragging files + if (event.dataTransfer.types.includes("Files")) { + setIsDraggingOver(true); + } + }, []); + + const onCellDragLeave = React.useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + const rect = event.currentTarget.getBoundingClientRect(); + const x = event.clientX; + const y = event.clientY; + + if ( + x <= rect.left || + x >= rect.right || + y <= rect.top || + y >= rect.bottom + ) { + setIsDraggingOver(false); + } + }, []); + + const onCellDragOver = React.useCallback((event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + }, []); + + const onCellDrop = React.useCallback( + (event: React.DragEvent) => { + event.preventDefault(); + event.stopPropagation(); + setIsDraggingOver(false); + + const droppedFiles = Array.from(event.dataTransfer.files); + if (droppedFiles.length > 0) { + addFiles(droppedFiles, false); // Show skeleton for dropped files + } + }, + [addFiles], + ); + + const onBrowseClick = React.useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const onOpenChange = React.useCallback( + (isOpen: boolean) => { + setOpen(isOpen); + if (!isOpen) { + setError(null); + meta?.onCellEditingStop?.(); + } + }, + [meta], + ); + + const onOpenAutoFocus: NonNullable< + React.ComponentProps["onOpenAutoFocus"] + > = React.useCallback((event) => { + event.preventDefault(); + }, []); + + const onEscapeKeyDown: NonNullable< + React.ComponentProps["onEscapeKeyDown"] + > = React.useCallback((event) => { + // Prevent the escape key from propagating to the data grid's keyboard handler + // which would call blurCell() and remove focus from the cell + event.stopPropagation(); + }, []); + + const onWrapperKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (isEditing) { + if (event.key === "Escape") { + event.preventDefault(); + setFiles(cellValue); + setError(null); + setOpen(false); + meta?.onCellEditingStop?.(); + } else if (event.key === "Tab") { + event.preventDefault(); + setOpen(false); + meta?.onCellEditingStop?.({ + direction: event.shiftKey ? "left" : "right", + }); + } else if (event.key === " ") { + event.preventDefault(); + onBrowseClick(); + } + } + }, + [isEditing, cellValue, meta, onBrowseClick], + ); + + React.useEffect(() => { + if (isEditing && !open) { + setOpen(true); + } + if ( + isFocused && + !isEditing && + !meta?.searchOpen && + !meta?.isScrolling && + containerRef.current + ) { + containerRef.current.focus(); + } + }, [isFocused, isEditing, open, meta?.searchOpen, meta?.isScrolling]); + + const rowHeight = table.options.meta?.rowHeight ?? "short"; + const lineCount = getLineCount(rowHeight); + const maxVisibleBadgeCount = lineCount * 3; + + const visibleFiles = files.slice(0, maxVisibleBadgeCount); + const hiddenFileCount = Math.max(0, files.length - maxVisibleBadgeCount); + + return ( + + {isEditing ? ( + + +
+ + +
+ {/* Drag & Drop Zone */} + + + + + {/* Error Message */} + {error && ( +
+ {error} +
+ )} + + {/* File List */} + {files.length > 0 && ( +
+
+

+ {files.length} {files.length === 1 ? "file" : "files"} +

+ +
+
+ {files.map((file) => ( +
+ {React.createElement(getFileIcon(file.type), { + className: "size-4 shrink-0 text-muted-foreground", + })} +
+

{file.name}

+

+ {formatFileSize(file.size)} +

+
+ +
+ ))} +
+
+ )} +
+
+ + ) : null} + {files.length > 0 ? ( +
+ {visibleFiles.map((file) => { + const isUploading = uploadingFiles.has(file.id); + + if (isUploading) { + // Show skeleton for uploading files + return ( + + ); + } + + return ( + + {React.createElement(getFileIcon(file.type), { + className: "size-3 shrink-0", + })} + {file.name} + + ); + })} + {hiddenFileCount > 0 && ( + + +{hiddenFileCount} + + )} +
+ ) : isDraggingOver ? ( +
+ + Drop files here +
+ ) : null} + + ); +} diff --git a/src/components/data-grid/data-grid-cell.tsx b/src/components/data-grid/data-grid-cell.tsx index 21617267d..c136f6ae4 100644 --- a/src/components/data-grid/data-grid-cell.tsx +++ b/src/components/data-grid/data-grid-cell.tsx @@ -6,6 +6,7 @@ import * as React from "react"; import { CheckboxCell, DateCell, + FileCell, LongTextCell, MultiSelectCell, NumberCell, @@ -125,6 +126,18 @@ export function DataGridCell({ cell, table }: DataGridCellProps) { isSelected={isSelected} /> ); + case "file": + return ( + + ); default: return ( diff --git a/src/components/data-grid/data-grid-column-header.tsx b/src/components/data-grid/data-grid-column-header.tsx index 22b45930c..992cd1980 100644 --- a/src/components/data-grid/data-grid-column-header.tsx +++ b/src/components/data-grid/data-grid-column-header.tsx @@ -14,6 +14,7 @@ import { ChevronDownIcon, ChevronUpIcon, EyeOffIcon, + FileIcon, HashIcon, ListChecksIcon, ListIcon, @@ -59,6 +60,8 @@ function getColumnVariant(variant?: Cell["variant"]): { return { icon: CheckSquareIcon, label: "Checkbox" }; case "date": return { icon: CalendarIcon, label: "Date" }; + case "file": + return { icon: FileIcon, label: "File" }; default: return null; } diff --git a/src/components/data-grid/data-grid-demo.tsx b/src/components/data-grid/data-grid-demo.tsx index 5194d703e..c24a88afd 100644 --- a/src/components/data-grid/data-grid-demo.tsx +++ b/src/components/data-grid/data-grid-demo.tsx @@ -11,6 +11,7 @@ import { DataGridViewMenu } from "@/components/data-grid/data-grid-view-menu"; import { Checkbox } from "@/components/ui/checkbox"; import { type UseDataGridProps, useDataGrid } from "@/hooks/use-data-grid"; import { useWindowSize } from "@/hooks/use-window-size"; +import type { FileCellData } from "@/types/data-grid"; interface Person { id: string; @@ -24,6 +25,7 @@ interface Person { skills?: string[]; isActive?: boolean; startDate?: string; + attachments?: FileCellData[]; } faker.seed(12345); @@ -67,10 +69,57 @@ const notes = [ "Completed advanced training in data visualization. Now serving as the team's go-to expert for dashboards.", ]; +const sampleFiles = [ + { name: "Resume.pdf", type: "application/pdf", sizeRange: [50, 500] }, + { name: "Contract.pdf", type: "application/pdf", sizeRange: [100, 300] }, + { name: "ID_Document.pdf", type: "application/pdf", sizeRange: [200, 400] }, + { name: "Profile_Photo.jpg", type: "image/jpeg", sizeRange: [500, 2000] }, + { + name: "Presentation.pptx", + type: "application/vnd.openxmlformats-officedocument.presentationml.presentation", + sizeRange: [1000, 5000], + }, + { + name: "Report.docx", + type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + sizeRange: [100, 800], + }, + { + name: "Timesheet.xlsx", + type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + sizeRange: [50, 200], + }, + { name: "Certificate.pdf", type: "application/pdf", sizeRange: [200, 500] }, + { + name: "Background_Check.pdf", + type: "application/pdf", + sizeRange: [300, 600], + }, + { name: "Training_Video.mp4", type: "video/mp4", sizeRange: [5000, 15000] }, +] as const; + function generatePerson(id: number): Person { const firstName = faker.person.firstName(); const lastName = faker.person.lastName(); + // Generate 0-3 files for this person + const fileCount = faker.number.int({ min: 0, max: 3 }); + const selectedFiles = faker.helpers.arrayElements(sampleFiles, fileCount); + + const attachments: FileCellData[] = selectedFiles.map((file, index) => { + const sizeKB = faker.number.int({ + min: file.sizeRange[0], + max: file.sizeRange[1], + }); + return { + id: `${id}-file-${index}`, + name: file.name, + size: sizeKB * 1024, // Convert to bytes + type: file.type, + url: `https://example.com/files/${id}/${file.name}`, + }; + }); + return { id: id.toString(), name: `${firstName} ${lastName}`, @@ -87,6 +136,7 @@ function generatePerson(id: number): Person { .toISOString() .split("T")[0] ?? "", skills: faker.helpers.arrayElements(skills, { min: 1, max: 5 }), + attachments, }; } @@ -281,6 +331,20 @@ export function DataGridDemo() { }, }, }, + { + id: "attachments", + accessorKey: "attachments", + header: "Attachments", + minSize: 240, + meta: { + label: "Attachments", + cell: { + variant: "file", + maxFileSize: 10 * 1024 * 1024, // 10MB + maxFiles: 5, + }, + }, + }, ], [], ); diff --git a/src/types/data-grid.ts b/src/types/data-grid.ts index 9575ad417..445d9ff1e 100644 --- a/src/types/data-grid.ts +++ b/src/types/data-grid.ts @@ -33,6 +33,12 @@ export type Cell = } | { variant: "date"; + } + | { + variant: "file"; + maxFileSize?: number; + maxFiles?: number; + acceptedTypes?: string[]; }; export interface UpdateCell { @@ -146,3 +152,11 @@ export interface SearchState { onNavigateToNextMatch: () => void; onNavigateToPrevMatch: () => void; } + +export interface FileCellData { + id: string; + name: string; + size: number; + type: string; + url?: string; +}