From 0dc2e8abeba885dc8438d88c2bb5396a6ccdfb00 Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Tue, 18 Nov 2025 15:19:41 +0600 Subject: [PATCH 1/8] chore: sync --- src/hooks/use-data-grid.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/hooks/use-data-grid.tsx b/src/hooks/use-data-grid.tsx index 1755f7b73..3e212d5fe 100644 --- a/src/hooks/use-data-grid.tsx +++ b/src/hooks/use-data-grid.tsx @@ -119,8 +119,8 @@ function useDataGrid({ overscan = OVERSCAN, initialState, autoFocus = false, - enableColumnSelection = false, enableSearch = false, + enableColumnSelection = false, ...dataGridProps }: UseDataGridProps) { const dataGridRef = React.useRef(null); From 7948782189711c50052c1c80caa2f402be345dfe Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Tue, 18 Nov 2025 17:08:52 +0600 Subject: [PATCH 2/8] feat: add file cell component with multi-file upload support - Add FileCell component with drag & drop functionality - Support multiple file uploads with validation - Add file size and type restrictions (configurable) - Display file icons based on MIME types - Add file list with remove functionality - Update data-grid-demo with sample file attachments - Add file variant to cell type definitions - Add File icon to column header variants --- .../data-grid/data-grid-cell-variants.tsx | 428 +++++++++++++++++- src/components/data-grid/data-grid-cell.tsx | 13 + .../data-grid/data-grid-column-header.tsx | 3 + src/components/data-grid/data-grid-demo.tsx | 71 +++ src/hooks/use-data-grid.tsx | 2 +- src/types/data-grid.ts | 16 +- 6 files changed, 526 insertions(+), 7 deletions(-) diff --git a/src/components/data-grid/data-grid-cell-variants.tsx b/src/components/data-grid/data-grid-cell-variants.tsx index 968d0aa17..40ee30300 100644 --- a/src/components/data-grid/data-grid-cell-variants.tsx +++ b/src/components/data-grid/data-grid-cell-variants.tsx @@ -1,10 +1,11 @@ "use client"; import type { Cell, Table } from "@tanstack/react-table"; -import { Check, X } from "lucide-react"; +import { Check, 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 { @@ -1151,3 +1152,428 @@ export function DateCell({ ); } + +interface FileData { + id: string; + name: string; + size: number; + type: string; + url?: string; +} + +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): string { + if (type.startsWith("image/")) return "🖼️"; + if (type.startsWith("video/")) return "🎥"; + if (type.startsWith("audio/")) return "🎵"; + if (type.includes("pdf")) return "📄"; + if (type.includes("zip") || type.includes("rar")) return "📦"; + if ( + type.includes("word") || + type.includes("document") || + type.includes("doc") + ) + return "📝"; + if (type.includes("sheet") || type.includes("excel") || type.includes("xls")) + return "📊"; + if ( + type.includes("presentation") || + type.includes("powerpoint") || + type.includes("ppt") + ) + return "📽️"; + return "📄"; +} + +export function FileCell({ + cell, + table, + rowIndex, + columnId, + isFocused, + isEditing, + isSelected, +}: CellVariantProps) { + const cellValue = React.useMemo( + () => (cell.getValue() as FileData[]) ?? [], + [cell], + ); + + const cellId = `${rowIndex}-${columnId}`; + const prevCellIdRef = React.useRef(cellId); + + const [files, setFiles] = React.useState(cellValue); + const [open, setOpen] = 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( + (newFiles: File[]) => { + setError(null); + + // Check max files limit + if (maxFiles && files.length + newFiles.length > maxFiles) { + setError(`Maximum ${maxFiles} files allowed`); + return; + } + + const validFiles: FileData[] = []; + let firstError: string | null = null; + + for (const file of newFiles) { + const validationError = validateFile(file); + if (validationError && !firstError) { + firstError = validationError; + continue; + } + + // Create file data object + const fileData: FileData = { + id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + name: file.name, + size: file.size, + type: file.type, + // In a real app, you'd upload the file and get a URL back + url: URL.createObjectURL(file), + }; + validFiles.push(fileData); + } + + if (firstError) { + setError(firstError); + } + + if (validFiles.length > 0) { + const updatedFiles = [...files, ...validFiles]; + setFiles(updatedFiles); + meta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles }); + } + }, + [files, maxFiles, validateFile, meta, rowIndex, columnId], + ); + + const removeFile = React.useCallback( + (fileId: string, event?: React.MouseEvent) => { + event?.stopPropagation(); + event?.preventDefault(); + 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); + }, + [addFiles], + ); + + const onFileInputChange = React.useCallback( + (event: React.ChangeEvent) => { + const selectedFiles = Array.from(event.target.files ?? []); + addFiles(selectedFiles); + // Reset input so the same file can be selected again + event.target.value = ""; + }, + [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 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) => ( +
+ + {getFileIcon(file.type)} + +
+

{file.name}

+

+ {formatFileSize(file.size)} +

+
+ +
+ ))} +
+
+ )} +
+
+ + ) : null} + {files.length > 0 ? ( +
+ {visibleFiles.map((file) => ( + + {getFileIcon(file.type)} + {file.name} + + ))} + {hiddenFileCount > 0 && ( + + +{hiddenFileCount} + + )} +
+ ) : ( + No files + )} + + ); +} 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..2befc4e61 100644 --- a/src/components/data-grid/data-grid-demo.tsx +++ b/src/components/data-grid/data-grid-demo.tsx @@ -12,6 +12,14 @@ import { Checkbox } from "@/components/ui/checkbox"; import { type UseDataGridProps, useDataGrid } from "@/hooks/use-data-grid"; import { useWindowSize } from "@/hooks/use-window-size"; +interface FileData { + id: string; + name: string; + size: number; + type: string; + url?: string; +} + interface Person { id: string; name?: string; @@ -24,6 +32,7 @@ interface Person { skills?: string[]; isActive?: boolean; startDate?: string; + attachments?: FileData[]; } faker.seed(12345); @@ -67,10 +76,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: FileData[] = 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 +143,7 @@ function generatePerson(id: number): Person { .toISOString() .split("T")[0] ?? "", skills: faker.helpers.arrayElements(skills, { min: 1, max: 5 }), + attachments, }; } @@ -281,6 +338,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/hooks/use-data-grid.tsx b/src/hooks/use-data-grid.tsx index 3e212d5fe..1755f7b73 100644 --- a/src/hooks/use-data-grid.tsx +++ b/src/hooks/use-data-grid.tsx @@ -119,8 +119,8 @@ function useDataGrid({ overscan = OVERSCAN, initialState, autoFocus = false, - enableSearch = false, enableColumnSelection = false, + enableSearch = false, ...dataGridProps }: UseDataGridProps) { const dataGridRef = React.useRef(null); diff --git a/src/types/data-grid.ts b/src/types/data-grid.ts index 9575ad417..44c7005c3 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 { @@ -65,24 +71,24 @@ declare module "@tanstack/react-table" { onCellClick?: ( rowIndex: number, columnId: string, - event?: React.MouseEvent, + event?: React.MouseEvent ) => void; onCellDoubleClick?: (rowIndex: number, columnId: string) => void; onCellMouseDown?: ( rowIndex: number, columnId: string, - event: React.MouseEvent, + event: React.MouseEvent ) => void; onCellMouseEnter?: ( rowIndex: number, columnId: string, - event: React.MouseEvent, + event: React.MouseEvent ) => void; onCellMouseUp?: () => void; onCellContextMenu?: ( rowIndex: number, columnId: string, - event: React.MouseEvent, + event: React.MouseEvent ) => void; onCellEditingStart?: (rowIndex: number, columnId: string) => void; onCellEditingStop?: (opts?: { @@ -96,7 +102,7 @@ declare module "@tanstack/react-table" { onRowSelect?: ( rowIndex: number, checked: boolean, - shiftKey: boolean, + shiftKey: boolean ) => void; } } From f8fa2a11a3795785f6620a37482ef9399d7c824d Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Tue, 18 Nov 2025 17:20:40 +0600 Subject: [PATCH 3/8] feat: add direct cell drop with skeleton loader UX - Add drag & drop directly on file cells (non-blocking) - Show skeleton loaders while files 'upload' - Display blue ring highlight when dragging over cell - Show 'Drop files here' indicator on empty cells - Distinguish between cell drops (show skeleton) and editor drops (instant) - Simulate 800ms upload delay (configurable for real uploads) - Keep users productive - no blocking editor popup --- .../data-grid/data-grid-cell-variants.tsx | 139 +++++++++++++++--- 1 file changed, 120 insertions(+), 19 deletions(-) diff --git a/src/components/data-grid/data-grid-cell-variants.tsx b/src/components/data-grid/data-grid-cell-variants.tsx index 40ee30300..7a96271b7 100644 --- a/src/components/data-grid/data-grid-cell-variants.tsx +++ b/src/components/data-grid/data-grid-cell-variants.tsx @@ -1210,7 +1210,11 @@ export function FileCell({ 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); @@ -1250,7 +1254,7 @@ export function FileCell({ ); const addFiles = React.useCallback( - (newFiles: File[]) => { + async (newFiles: File[], skipUpload = false) => { setError(null); // Check max files limit @@ -1269,13 +1273,13 @@ export function FileCell({ continue; } - // Create file data object + // Create file data object with temporary ID + const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; const fileData: FileData = { - id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + id: tempId, name: file.name, size: file.size, type: file.type, - // In a real app, you'd upload the file and get a URL back url: URL.createObjectURL(file), }; validFiles.push(fileData); @@ -1286,9 +1290,33 @@ export function FileCell({ } if (validFiles.length > 0) { - const updatedFiles = [...files, ...validFiles]; - setFiles(updatedFiles); - meta?.onDataUpdate?.({ rowIndex, columnId, value: updatedFiles }); + // 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], @@ -1347,7 +1375,7 @@ export function FileCell({ setIsDragging(false); const droppedFiles = Array.from(event.dataTransfer.files); - addFiles(droppedFiles); + addFiles(droppedFiles, true); // Skip upload skeleton in editor }, [addFiles], ); @@ -1355,13 +1383,59 @@ export function FileCell({ const onFileInputChange = React.useCallback( (event: React.ChangeEvent) => { const selectedFiles = Array.from(event.target.files ?? []); - addFiles(selectedFiles); + 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(); }, []); @@ -1440,6 +1514,13 @@ export function FileCell({ isFocused={isFocused} isSelected={isSelected} onKeyDown={onWrapperKeyDown} + onDragEnter={onCellDragEnter} + onDragLeave={onCellDragLeave} + onDragOver={onCellDragOver} + onDrop={onCellDrop} + className={cn({ + "ring-2 ring-primary ring-offset-1": isDraggingOver, + })} > {isEditing ? ( @@ -1552,16 +1633,31 @@ export function FileCell({ ) : null} {files.length > 0 ? (
- {visibleFiles.map((file) => ( - - {getFileIcon(file.type)} - {file.name} - - ))} + {visibleFiles.map((file) => { + const isUploading = uploadingFiles.has(file.id); + + if (isUploading) { + // Show skeleton for uploading files + return ( +
+ ); + } + + return ( + + {getFileIcon(file.type)} + {file.name} + + ); + })} {hiddenFileCount > 0 && ( ({ )}
+ ) : isDraggingOver ? ( +
+ + Drop files here +
) : ( No files )} From fc7c7271915138916960409a50b22d1a42188b0e Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Tue, 18 Nov 2025 17:51:12 +0600 Subject: [PATCH 4/8] feat: update file cell --- registry.json | 1 + .../data-grid/data-grid-cell-variants.tsx | 78 ++++++++++++------- src/types/data-grid.ts | 10 +-- 3 files changed, 54 insertions(+), 35 deletions(-) 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 7a96271b7..3ea8fa8de 100644 --- a/src/components/data-grid/data-grid-cell-variants.tsx +++ b/src/components/data-grid/data-grid-cell-variants.tsx @@ -1,7 +1,19 @@ "use client"; import type { Cell, Table } from "@tanstack/react-table"; -import { Check, Upload, 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"; @@ -29,6 +41,7 @@ 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"; @@ -185,7 +198,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, })} > @@ -814,7 +827,7 @@ export function MultiSelectCell({ className="w-[300px] rounded-none p-0" onOpenAutoFocus={onOpenAutoFocus} > - +
{selectedValues.map((value) => { const option = options.find((opt) => opt.value === value); @@ -1169,27 +1182,29 @@ function formatFileSize(bytes: number): string { return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`; } -function getFileIcon(type: string): string { - if (type.startsWith("image/")) return "🖼️"; - if (type.startsWith("video/")) return "🎥"; - if (type.startsWith("audio/")) return "🎵"; - if (type.includes("pdf")) return "📄"; - if (type.includes("zip") || type.includes("rar")) return "📦"; +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 "📝"; + return FileText; if (type.includes("sheet") || type.includes("excel") || type.includes("xls")) - return "📊"; + return FileSpreadsheet; if ( type.includes("presentation") || type.includes("powerpoint") || type.includes("ppt") ) - return "📽️"; - return "📄"; + return Presentation; + return File; } export function FileCell({ @@ -1274,9 +1289,8 @@ export function FileCell({ } // Create file data object with temporary ID - const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; const fileData: FileData = { - id: tempId, + id: crypto.randomUUID(), name: file.name, size: file.size, type: file.type, @@ -1305,8 +1319,8 @@ export function FileCell({ 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, + const finalFiles = filesWithTemp.map( + (f) => validFiles.find((vf) => vf.id === f.id) || f, ); setFiles(finalFiles); setUploadingFiles(new Set()); @@ -1323,7 +1337,7 @@ export function FileCell({ ); const removeFile = React.useCallback( - (fileId: string, event?: React.MouseEvent) => { + (fileId: string, event?: React.MouseEvent) => { event?.stopPropagation(); event?.preventDefault(); setError(null); @@ -1606,9 +1620,9 @@ export function FileCell({ key={file.id} className="flex items-center gap-2 rounded-md border bg-muted/50 px-2 py-1.5" > - - {getFileIcon(file.type)} - + {React.createElement(getFileIcon(file.type), { + className: "size-4 shrink-0 text-muted-foreground", + })}

{file.name}

@@ -1617,8 +1631,10 @@ export function FileCell({

@@ -1635,14 +1651,16 @@ export function FileCell({
{visibleFiles.map((file) => { const isUploading = uploadingFiles.has(file.id); - + if (isUploading) { // Show skeleton for uploading files return ( -
); } @@ -1653,7 +1671,9 @@ export function FileCell({ variant="secondary" className="h-5 shrink-0 gap-1 px-1.5 text-xs" > - {getFileIcon(file.type)} + {React.createElement(getFileIcon(file.type), { + className: "size-3 shrink-0", + })} {file.name} ); @@ -1672,9 +1692,7 @@ export function FileCell({ Drop files here
- ) : ( - No files - )} + ) : null} ); } diff --git a/src/types/data-grid.ts b/src/types/data-grid.ts index 44c7005c3..04853e919 100644 --- a/src/types/data-grid.ts +++ b/src/types/data-grid.ts @@ -71,24 +71,24 @@ declare module "@tanstack/react-table" { onCellClick?: ( rowIndex: number, columnId: string, - event?: React.MouseEvent + event?: React.MouseEvent, ) => void; onCellDoubleClick?: (rowIndex: number, columnId: string) => void; onCellMouseDown?: ( rowIndex: number, columnId: string, - event: React.MouseEvent + event: React.MouseEvent, ) => void; onCellMouseEnter?: ( rowIndex: number, columnId: string, - event: React.MouseEvent + event: React.MouseEvent, ) => void; onCellMouseUp?: () => void; onCellContextMenu?: ( rowIndex: number, columnId: string, - event: React.MouseEvent + event: React.MouseEvent, ) => void; onCellEditingStart?: (rowIndex: number, columnId: string) => void; onCellEditingStop?: (opts?: { @@ -102,7 +102,7 @@ declare module "@tanstack/react-table" { onRowSelect?: ( rowIndex: number, checked: boolean, - shiftKey: boolean + shiftKey: boolean, ) => void; } } From 43a5e1103e5551c76d0f032cd4631bddbd0d6538 Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Tue, 18 Nov 2025 17:58:57 +0600 Subject: [PATCH 5/8] feat: update focus ring for file cell --- public/r/data-grid.json | 9 ++++--- public/r/registry.json | 1 + .../data-grid/data-grid-cell-variants.tsx | 25 +++++++++---------- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/public/r/data-grid.json b/public/r/data-grid.json index 92d53c5af..38f911b19 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\";\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\ninterface FileData {\n id: string;\n name: string;\n size: number;\n type: string;\n url?: string;\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 FileData[]) ?? [],\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: FileData[] = [];\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: FileData = {\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 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 onPointerDown={(event) => event.preventDefault()}\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", "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/src/components/data-grid/data-grid-cell-variants.tsx b/src/components/data-grid/data-grid-cell-variants.tsx index 3ea8fa8de..7f6194baf 100644 --- a/src/components/data-grid/data-grid-cell-variants.tsx +++ b/src/components/data-grid/data-grid-cell-variants.tsx @@ -1018,9 +1018,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" > ({ ); const removeFile = React.useCallback( - (fileId: string, event?: React.MouseEvent) => { - event?.stopPropagation(); - event?.preventDefault(); + (fileId: string) => { setError(null); const updatedFiles = files.filter((f) => f.id !== fileId); setFiles(updatedFiles); @@ -1527,14 +1525,14 @@ export function FileCell({ isEditing={isEditing} isFocused={isFocused} isSelected={isSelected} + className={cn({ + "ring-1 ring-primary/80 ring-inset": isDraggingOver, + })} onKeyDown={onWrapperKeyDown} onDragEnter={onCellDragEnter} onDragLeave={onCellDragLeave} onDragOver={onCellDragOver} onDrop={onCellDrop} - className={cn({ - "ring-2 ring-primary ring-offset-1": isDraggingOver, - })} > {isEditing ? ( @@ -1629,15 +1627,16 @@ export function FileCell({ {formatFileSize(file.size)}

- +
))} From a4f843efec43304e7558da393d40f2db055244ff Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Tue, 18 Nov 2025 18:39:54 +0600 Subject: [PATCH 6/8] fix: prevent escape key from blurring file cell after popover closes --- .../data-grid/data-grid-cell-variants.tsx | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/components/data-grid/data-grid-cell-variants.tsx b/src/components/data-grid/data-grid-cell-variants.tsx index 7f6194baf..5ae521f14 100644 --- a/src/components/data-grid/data-grid-cell-variants.tsx +++ b/src/components/data-grid/data-grid-cell-variants.tsx @@ -1469,6 +1469,14 @@ export function FileCell({ 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) { @@ -1528,11 +1536,11 @@ export function FileCell({ className={cn({ "ring-1 ring-primary/80 ring-inset": isDraggingOver, })} - onKeyDown={onWrapperKeyDown} onDragEnter={onCellDragEnter} onDragLeave={onCellDragLeave} onDragOver={onCellDragOver} onDrop={onCellDrop} + onKeyDown={onWrapperKeyDown} > {isEditing ? ( @@ -1545,11 +1553,13 @@ export function FileCell({ sideOffset={sideOffset} className="w-[400px] rounded-none p-0" onOpenAutoFocus={onOpenAutoFocus} + onEscapeKeyDown={onEscapeKeyDown} >
{/* Drag & Drop Zone */} @@ -1633,7 +1642,6 @@ export function FileCell({ size="icon" className="size-5 rounded-sm" onClick={() => removeFile(file.id)} - onPointerDown={(event) => event.preventDefault()} > From fc3ef9f413e83293209bfb742ee923e4445a1037 Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Tue, 18 Nov 2025 19:05:34 +0600 Subject: [PATCH 7/8] feat: update types --- public/r/data-grid.json | 4 +- .../data-grid/data-grid-cell-variants.tsx | 17 +- src/components/data-grid/data-grid-demo.tsx | 13 +- src/hooks/use-badge-truncation.ts | 209 ++++++++++++++++++ src/types/data-grid.ts | 8 + 5 files changed, 227 insertions(+), 24 deletions(-) create mode 100644 src/hooks/use-badge-truncation.ts diff --git a/public/r/data-grid.json b/public/r/data-grid.json index 38f911b19..06f6c7f8e 100644 --- a/public/r/data-grid.json +++ b/public/r/data-grid.json @@ -46,7 +46,7 @@ }, { "path": "src/components/data-grid/data-grid-cell-variants.tsx", - "content": "\"use client\";\n\nimport 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\";\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\ninterface FileData {\n id: string;\n name: string;\n size: number;\n type: string;\n url?: string;\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 FileData[]) ?? [],\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: FileData[] = [];\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: FileData = {\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 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 onPointerDown={(event) => event.preventDefault()}\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", + "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" }, @@ -101,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 | {\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", + "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/src/components/data-grid/data-grid-cell-variants.tsx b/src/components/data-grid/data-grid-cell-variants.tsx index 5ae521f14..cdfd904a7 100644 --- a/src/components/data-grid/data-grid-cell-variants.tsx +++ b/src/components/data-grid/data-grid-cell-variants.tsx @@ -46,6 +46,7 @@ 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; @@ -1166,14 +1167,6 @@ export function DateCell({ ); } -interface FileData { - id: string; - name: string; - size: number; - type: string; - url?: string; -} - function formatFileSize(bytes: number): string { if (bytes === 0) return "0 B"; const k = 1024; @@ -1217,14 +1210,14 @@ export function FileCell({ isSelected, }: CellVariantProps) { const cellValue = React.useMemo( - () => (cell.getValue() as FileData[]) ?? [], + () => (cell.getValue() as FileCellData[]) ?? [], [cell], ); const cellId = `${rowIndex}-${columnId}`; const prevCellIdRef = React.useRef(cellId); - const [files, setFiles] = React.useState(cellValue); + const [files, setFiles] = React.useState(cellValue); const [uploadingFiles, setUploadingFiles] = React.useState>( new Set(), ); @@ -1278,7 +1271,7 @@ export function FileCell({ return; } - const validFiles: FileData[] = []; + const validFiles: FileCellData[] = []; let firstError: string | null = null; for (const file of newFiles) { @@ -1289,7 +1282,7 @@ export function FileCell({ } // Create file data object with temporary ID - const fileData: FileData = { + const fileData: FileCellData = { id: crypto.randomUUID(), name: file.name, size: file.size, diff --git a/src/components/data-grid/data-grid-demo.tsx b/src/components/data-grid/data-grid-demo.tsx index 2befc4e61..c24a88afd 100644 --- a/src/components/data-grid/data-grid-demo.tsx +++ b/src/components/data-grid/data-grid-demo.tsx @@ -11,14 +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"; - -interface FileData { - id: string; - name: string; - size: number; - type: string; - url?: string; -} +import type { FileCellData } from "@/types/data-grid"; interface Person { id: string; @@ -32,7 +25,7 @@ interface Person { skills?: string[]; isActive?: boolean; startDate?: string; - attachments?: FileData[]; + attachments?: FileCellData[]; } faker.seed(12345); @@ -113,7 +106,7 @@ function generatePerson(id: number): Person { const fileCount = faker.number.int({ min: 0, max: 3 }); const selectedFiles = faker.helpers.arrayElements(sampleFiles, fileCount); - const attachments: FileData[] = selectedFiles.map((file, index) => { + const attachments: FileCellData[] = selectedFiles.map((file, index) => { const sizeKB = faker.number.int({ min: file.sizeRange[0], max: file.sizeRange[1], diff --git a/src/hooks/use-badge-truncation.ts b/src/hooks/use-badge-truncation.ts new file mode 100644 index 000000000..12d4332a0 --- /dev/null +++ b/src/hooks/use-badge-truncation.ts @@ -0,0 +1,209 @@ +import * as React from "react"; + +interface UseBadgeTruncationProps { + /** + * The number of items to display + */ + itemCount: number; + /** + * Ref to the container element (usually DataGridCellWrapper) + */ + containerRef: React.RefObject; + /** + * Width in pixels to reserve for the "+N" counter badge + * @default 40 + */ + counterBadgeWidth?: number; + /** + * Gap between items in pixels + * @default 4 + */ + gap?: number; + /** + * Cell padding in pixels (will be subtracted from available width) + * @default 16 + */ + cellPadding?: number; +} + +interface UseBadgeTruncationReturn { + /** + * Number of items that can be displayed + */ + visibleCount: number; + /** + * Number of items that are hidden + */ + hiddenCount: number; + /** + * Ref to attach to the measurement container + */ + measurementRef: React.RefObject; + /** + * Ref to attach to the display container + */ + contentRef: React.RefObject; + /** + * Map to store item element refs for width measurement + */ + itemRefs: React.RefObject>; + /** + * Current container size for use in inline styles + */ + containerSize: { width: number; height: number }; +} + +export function useBadgeTruncation({ + itemCount, + containerRef, + counterBadgeWidth = 40, + gap = 4, + cellPadding = 16, +}: UseBadgeTruncationProps): UseBadgeTruncationReturn { + const itemRefs = React.useRef>(new Map()); + const contentRef = React.useRef(null); + const measurementRef = React.useRef(null); + const [visibleCount, setVisibleCount] = React.useState(0); + const [containerSize, setContainerSize] = React.useState({ + width: 0, + height: 0, + }); + + // Track container resize with optimization + React.useEffect(() => { + if (!containerRef.current) return; + + let rafId: number | null = null; + let lastWidth = 0; + let lastHeight = 0; + + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + + // Skip if dimensions haven't changed (can happen during scroll) + if (width === lastWidth && height === lastHeight) { + return; + } + + lastWidth = width; + lastHeight = height; + + // Cancel any pending update + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + + // Batch updates with RAF to avoid layout thrashing + rafId = requestAnimationFrame(() => { + setContainerSize({ width, height }); + rafId = null; + }); + } + }); + + resizeObserver.observe(containerRef.current); + + return () => { + if (rafId !== null) { + cancelAnimationFrame(rafId); + } + resizeObserver.disconnect(); + }; + }, [containerRef]); + + // Calculate how many items can fit based on actual measurements + React.useLayoutEffect(() => { + if (!containerRef.current || itemCount === 0) { + setVisibleCount(itemCount); + return; + } + + const containerWidth = + containerSize.width || containerRef.current.clientWidth; + const containerHeight = + containerSize.height || containerRef.current.clientHeight; + + if (containerWidth === 0 || containerHeight === 0) { + return; + } + + const availableWidth = containerWidth - cellPadding; + + let totalHeight = 20; // height of one badge (h-5 = 20px) + let currentLineWidth = 0; + let count = 0; + + for (let i = 0; i < itemCount; i++) { + const item = itemRefs.current.get(i); + if (!item) continue; + + const itemWidth = item.offsetWidth; + + // Calculate width with gap + const widthWithGap = + currentLineWidth + (currentLineWidth > 0 ? gap : 0) + itemWidth; + + // Check if we need to reserve space for counter badge + const isLastItem = i === itemCount - 1; + const needsCounterSpace = !isLastItem; + const widthWithCounter = needsCounterSpace + ? widthWithGap + gap + counterBadgeWidth + : widthWithGap; + + // Check if this item fits on the current line + if ( + needsCounterSpace + ? widthWithCounter > availableWidth + : widthWithGap > availableWidth + ) { + // Would need to wrap to next line + const nextLineHeight = totalHeight + 20 + gap; + + // Check if we have room for another line + if (nextLineHeight > containerHeight) { + // No room for another line, stop here + break; + } + + // Check if item + counter would fit on new line + if ( + needsCounterSpace && + itemWidth + gap + counterBadgeWidth > availableWidth + ) { + // Even on a new line, can't fit item + counter + break; + } + + // Move to next line + totalHeight = nextLineHeight; + currentLineWidth = itemWidth; + } else { + // Fits on current line + currentLineWidth = widthWithGap; + } + + count = i + 1; + } + + setVisibleCount(count); + }, [ + itemCount, + containerSize, + containerRef, + counterBadgeWidth, + gap, + cellPadding, + ]); + + const hiddenCount = Math.max(0, itemCount - visibleCount); + + return { + visibleCount, + hiddenCount, + measurementRef, + contentRef, + itemRefs, + containerSize, + }; +} diff --git a/src/types/data-grid.ts b/src/types/data-grid.ts index 04853e919..445d9ff1e 100644 --- a/src/types/data-grid.ts +++ b/src/types/data-grid.ts @@ -152,3 +152,11 @@ export interface SearchState { onNavigateToNextMatch: () => void; onNavigateToPrevMatch: () => void; } + +export interface FileCellData { + id: string; + name: string; + size: number; + type: string; + url?: string; +} From fa1bdd3e3060b768bbe6edb9f6ea9df76436683e Mon Sep 17 00:00:00 2001 From: sadmann7 Date: Tue, 18 Nov 2025 19:06:39 +0600 Subject: [PATCH 8/8] chore: remove badge trancation --- src/hooks/use-badge-truncation.ts | 209 ------------------------------ 1 file changed, 209 deletions(-) delete mode 100644 src/hooks/use-badge-truncation.ts diff --git a/src/hooks/use-badge-truncation.ts b/src/hooks/use-badge-truncation.ts deleted file mode 100644 index 12d4332a0..000000000 --- a/src/hooks/use-badge-truncation.ts +++ /dev/null @@ -1,209 +0,0 @@ -import * as React from "react"; - -interface UseBadgeTruncationProps { - /** - * The number of items to display - */ - itemCount: number; - /** - * Ref to the container element (usually DataGridCellWrapper) - */ - containerRef: React.RefObject; - /** - * Width in pixels to reserve for the "+N" counter badge - * @default 40 - */ - counterBadgeWidth?: number; - /** - * Gap between items in pixels - * @default 4 - */ - gap?: number; - /** - * Cell padding in pixels (will be subtracted from available width) - * @default 16 - */ - cellPadding?: number; -} - -interface UseBadgeTruncationReturn { - /** - * Number of items that can be displayed - */ - visibleCount: number; - /** - * Number of items that are hidden - */ - hiddenCount: number; - /** - * Ref to attach to the measurement container - */ - measurementRef: React.RefObject; - /** - * Ref to attach to the display container - */ - contentRef: React.RefObject; - /** - * Map to store item element refs for width measurement - */ - itemRefs: React.RefObject>; - /** - * Current container size for use in inline styles - */ - containerSize: { width: number; height: number }; -} - -export function useBadgeTruncation({ - itemCount, - containerRef, - counterBadgeWidth = 40, - gap = 4, - cellPadding = 16, -}: UseBadgeTruncationProps): UseBadgeTruncationReturn { - const itemRefs = React.useRef>(new Map()); - const contentRef = React.useRef(null); - const measurementRef = React.useRef(null); - const [visibleCount, setVisibleCount] = React.useState(0); - const [containerSize, setContainerSize] = React.useState({ - width: 0, - height: 0, - }); - - // Track container resize with optimization - React.useEffect(() => { - if (!containerRef.current) return; - - let rafId: number | null = null; - let lastWidth = 0; - let lastHeight = 0; - - const resizeObserver = new ResizeObserver((entries) => { - for (const entry of entries) { - const { width, height } = entry.contentRect; - - // Skip if dimensions haven't changed (can happen during scroll) - if (width === lastWidth && height === lastHeight) { - return; - } - - lastWidth = width; - lastHeight = height; - - // Cancel any pending update - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - - // Batch updates with RAF to avoid layout thrashing - rafId = requestAnimationFrame(() => { - setContainerSize({ width, height }); - rafId = null; - }); - } - }); - - resizeObserver.observe(containerRef.current); - - return () => { - if (rafId !== null) { - cancelAnimationFrame(rafId); - } - resizeObserver.disconnect(); - }; - }, [containerRef]); - - // Calculate how many items can fit based on actual measurements - React.useLayoutEffect(() => { - if (!containerRef.current || itemCount === 0) { - setVisibleCount(itemCount); - return; - } - - const containerWidth = - containerSize.width || containerRef.current.clientWidth; - const containerHeight = - containerSize.height || containerRef.current.clientHeight; - - if (containerWidth === 0 || containerHeight === 0) { - return; - } - - const availableWidth = containerWidth - cellPadding; - - let totalHeight = 20; // height of one badge (h-5 = 20px) - let currentLineWidth = 0; - let count = 0; - - for (let i = 0; i < itemCount; i++) { - const item = itemRefs.current.get(i); - if (!item) continue; - - const itemWidth = item.offsetWidth; - - // Calculate width with gap - const widthWithGap = - currentLineWidth + (currentLineWidth > 0 ? gap : 0) + itemWidth; - - // Check if we need to reserve space for counter badge - const isLastItem = i === itemCount - 1; - const needsCounterSpace = !isLastItem; - const widthWithCounter = needsCounterSpace - ? widthWithGap + gap + counterBadgeWidth - : widthWithGap; - - // Check if this item fits on the current line - if ( - needsCounterSpace - ? widthWithCounter > availableWidth - : widthWithGap > availableWidth - ) { - // Would need to wrap to next line - const nextLineHeight = totalHeight + 20 + gap; - - // Check if we have room for another line - if (nextLineHeight > containerHeight) { - // No room for another line, stop here - break; - } - - // Check if item + counter would fit on new line - if ( - needsCounterSpace && - itemWidth + gap + counterBadgeWidth > availableWidth - ) { - // Even on a new line, can't fit item + counter - break; - } - - // Move to next line - totalHeight = nextLineHeight; - currentLineWidth = itemWidth; - } else { - // Fits on current line - currentLineWidth = widthWithGap; - } - - count = i + 1; - } - - setVisibleCount(count); - }, [ - itemCount, - containerSize, - containerRef, - counterBadgeWidth, - gap, - cellPadding, - ]); - - const hiddenCount = Math.max(0, itemCount - visibleCount); - - return { - visibleCount, - hiddenCount, - measurementRef, - contentRef, - itemRefs, - containerSize, - }; -}