Shadcn Table
componentdata-gridsource: ok · hit
Data Grid
A high-performance editable data grid component with virtualization, keyboard navigation, and cell editing
npx shadcn add "https://tablecn.com/r/data-grid.json"free
Where it goes
data-tablekanban
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
toastssonner
ui-primitives@base-ui/react@base-ui/utils
Registry dependencies
badgebuttoncalendarcheckboxcommanddialogdropdown-menuinputpopoverselectskeletontextareatooltip
Files20 · source cached 10/14/2026, 3:07:59 PM
src/registry/bases/base/components/data-grid/data-grid.tsx
"use client";
import type { RowData } from "@tanstack/react-table";
import { useValueAsRef } from "@base-ui/utils/useValueAsRef";
import { cn } from "cn";
import * as React from "react";
import type { Direction } from "@/lib/data-grid-types";
import type { useDataGrid } from "@/registry/bases/base/hooks/use-data-grid";
import {
flexRender,
getColumnBorderVisibility,
getColumnPinningStyle,
} from "@/lib/data-grid-utils";
import { DataGridColumnHeader } from "@/registry/bases/base/components/data-grid/data-grid-column-header";
import { DataGridContextMenu } from "@/registry/bases/base/components/data-grid/data-grid-context-menu";
import { DataGridPasteDialog } from "@/registry/bases/base/components/data-grid/data-grid-paste-dialog";
import { DataGridRow } from "@/registry/bases/base/components/data-grid/data-grid-row";
import { DataGridSearch } from "@/registry/bases/base/components/data-grid/data-grid-search";
import { IconPlaceholder } from "@/registry/icons/icon-placeholder";
const EMPTY_CELL_SELECTION_SET = new Set<string>();
interface DataGridProps<TData extends RowData>
extends
Omit<ReturnType<typeof useDataGrid<TData>>, "dir">,
Omit<React.ComponentProps<"div">, "contextMenu"> {
dir?: Direction;
height?: number;
stretchColumns?: boolean;
}
export function DataGrid<TData extends RowData>({
dataGridRef,
headerRef,
rowMapRef,
footerRef,
dir = "ltr",
table,
tableMeta,
virtualTotalSize,
virtualItems,
measureElement,
columns,
columnSizeVars,
searchState,
searchMatchesByRow,
activeSearchMatch,
cellSelectionMap,
focusedCell,
editingCell,
readOnlyColumnIds,
rowHeight,
contextMenu,
pasteDialog,
onRowAdd: onRowAddProp,
height = 600,
stretchColumns = false,
adjustLayout,
className,
...props
}: DataGridProps<TData>) {
const rows = table.getRowModel().rows;
const readOnly = tableMeta?.readOnly ?? false;
const columnVisibility = table.state.columnVisibility;
const columnPinning = table.state.columnPinning;
const onRowAddRef = useValueAsRef(onRowAddProp);
const onRowAdd = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
void onRowAddRef.current?.(event);
},
[onRowAddRef],
);
const onDataGridContextMenu = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
event.preventDefault();
},
[],
);
const onFooterCellKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (!onRowAddRef.current) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
void onRowAddRef.current();
}
},
[onRowAddRef],
);
return (
<div
data-slot="grid-wrapper"
dir={dir}
{...props}
className={cn("relative flex w-full flex-col", className)}
>
{searchState && <DataGridSearch {...searchState} />}
<DataGridContextMenu
tableMeta={tableMeta}
columns={columns}
contextMenu={contextMenu}
/>
<DataGridPasteDialog tableMeta={tableMeta} pasteDialog={pasteDialog} />
<div
role="grid"
aria-label="Data grid"
aria-rowcount={rows.length + (onRowAddProp ? 1 : 0)}
aria-colcount={columns.length}
data-slot="grid"
tabIndex={0}
ref={dataGridRef}
className="relative isolate grid overflow-auto rounded-md border select-none focus:outline-none"
style={{
...columnSizeVars,
maxHeight: `${height}px`,
}}
onContextMenu={onDataGridContextMenu}
>
<div
role="rowgroup"
data-slot="grid-header"
ref={headerRef}
className="sticky top-0 z-30 grid border-b bg-background"
>
{table.getHeaderGroups().map((headerGroup, rowIndex) => (
<div
key={headerGroup.id}
role="row"
aria-rowindex={rowIndex + 1}
data-slot="grid-header-row"
tabIndex={-1}
className="flex w-full"
>
{headerGroup.headers.map((header, colIndex) => {
const sorting = table.state.sorting;
const currentSort = sorting.find(
(sort) => sort.id === header.column.id,
);
const isSortable = header.column.getCanSort();
const nextHeader = headerGroup.headers[colIndex + 1];
const isLastColumn =
colIndex === headerGroup.headers.length - 1;
const { showEndBorder, showStartBorder } =
getColumnBorderVisibility({
column: header.column,
nextColumn: nextHeader?.column,
isLastColumn,
});
const cornerClassName = cn(
rowIndex === 0 && {
"rounded-ss-[calc(var(--radius-md)-1px)]": colIndex === 0,
"rounded-se-[calc(var(--radius-md)-1px)]": isLastColumn,
},
);
return (
<div
key={header.id}
role="columnheader"
aria-colindex={colIndex + 1}
aria-sort={
currentSort?.desc === false
? "ascending"
: currentSort?.desc === true
? "descending"
: isSortable
? "none"
: undefined
}
data-slot="grid-header-cell"
data-column-id={header.column.id}
tabIndex={-1}
className={cn("relative", {
grow: stretchColumns && header.column.id !== "select",
"border-e":
showEndBorder && header.column.id !== "select",
"border-s":
showStartBorder && header.column.id !== "select",
})}
style={{
...getColumnPinningStyle({ column: header.column, dir }),
width: `calc(var(--header-${header.id}-size) * 1px)`,
}}
>
{header.isPlaceholder ? null : typeof header.column
.columnDef.header === "function" ? (
<div
className={cn(
"size-full px-3 py-1.5 has-focus-visible:ring-1 has-focus-visible:ring-ring has-focus-visible:ring-inset",
cornerClassName,
)}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</div>
) : (
<DataGridColumnHeader
header={header}
table={table}
className={cornerClassName}
/>
)}
</div>
);
})}
</div>
))}
</div>
<div
role="rowgroup"
data-slot="grid-body"
className="relative grid"
style={{
height: `${virtualTotalSize}px`,
contain: adjustLayout ? "layout paint" : "strict",
}}
>
{virtualItems.map((virtualItem) => {
const row = rows[virtualItem.index];
if (!row) return null;
const cellSelectionKeys =
cellSelectionMap?.get(row.id) ?? EMPTY_CELL_SELECTION_SET;
const searchMatchColumns = searchMatchesByRow?.get(row.id) ?? null;
const isActiveSearchRow = activeSearchMatch?.rowId === row.id;
return (
<DataGridRow
key={row.id}
row={row}
tableMeta={tableMeta}
rowMapRef={rowMapRef}
virtualItem={virtualItem}
measureElement={measureElement}
rowHeight={rowHeight}
columnVisibility={columnVisibility}
columnPinning={columnPinning}
focusedCell={focusedCell}
editingCell={editingCell}
cellSelectionKeys={cellSelectionKeys}
searchMatchColumns={searchMatchColumns}
activeSearchMatch={isActiveSearchRow ? activeSearchMatch : null}
dir={dir}
adjustLayout={adjustLayout}
stretchColumns={stretchColumns}
readOnlyColumnIds={readOnlyColumnIds}
/>
);
})}
</div>
{!readOnly && onRowAdd && (
<div
role="rowgroup"
data-slot="grid-footer"
ref={footerRef}
className="sticky bottom-0 z-30 grid border-t bg-background"
>
<div
role="row"
aria-rowindex={rows.length + 2}
data-slot="grid-add-row"
tabIndex={-1}
className="flex w-full"
>
<div
role="gridcell"
tabIndex={0}
className="relative flex h-9 grow items-center bg-muted/30 transition-colors hover:bg-muted/50 focus:bg-muted/50 focus:outline-none"
style={{
width: table.getTotalSize(),
minWidth: table.getTotalSize(),
}}
onClick={onRowAdd}
onKeyDown={onFooterCellKeyDown}
>
<div className="sticky inset-s-0 flex items-center gap-2 px-3 text-muted-foreground">
<IconPlaceholder
lucide="Plus"
tabler="IconPlus"
hugeicons="PlusSignIcon"
phosphor="PlusIcon"
remixicon="RiAddLine"
className="size-3.5"
/>
<span className="text-sm">Add row</span>
</div>
</div>
</div>
</div>
)}
</div>
</div>
);
}
src/registry/bases/base/components/data-grid/data-grid-presence.tsx
"use client";
import * as React from "react";
interface DataGridCellPresence {
color: string;
name: string;
}
const DataGridCellPresenceContext = React.createContext<Map<
string,
DataGridCellPresence
> | null>(null);
interface DataGridPresenceProviderProps {
value: Map<string, DataGridCellPresence>;
children: React.ReactNode;
}
function DataGridPresenceProvider({
value,
children,
}: DataGridPresenceProviderProps) {
return (
<DataGridCellPresenceContext.Provider value={value}>
{children}
</DataGridCellPresenceContext.Provider>
);
}
function useDataGridPresence(cellKey: string): DataGridCellPresence | null {
const map = React.useContext(DataGridCellPresenceContext);
return map?.get(cellKey) ?? null;
}
export {
type DataGridCellPresence,
DataGridPresenceProvider,
useDataGridPresence,
};
src/registry/bases/base/components/data-grid/data-grid-cell.tsx
"use client";
import type { RowData } from "@tanstack/react-table";
import * as React from "react";
import type { DataGridCellProps } from "@/lib/data-grid-types";
import {
CheckboxCell,
DateCell,
FileCell,
LongTextCell,
MultiSelectCell,
NumberCell,
SelectCell,
ShortTextCell,
UrlCell,
} from "@/registry/bases/base/components/data-grid/data-grid-cell-variants";
export const DataGridCell = React.memo(DataGridCellImpl, (prev, next) => {
// Fast path: check stable primitive props first
if (prev.isFocused !== next.isFocused) return false;
if (prev.isEditing !== next.isEditing) return false;
if (prev.isSelected !== next.isSelected) return false;
if (prev.isSearchMatch !== next.isSearchMatch) return false;
if (prev.isActiveSearchMatch !== next.isActiveSearchMatch) return false;
if (prev.readOnly !== next.readOnly) return false;
if (prev.rowId !== next.rowId) return false;
if (prev.columnId !== next.columnId) return false;
if (prev.rowHeight !== next.rowHeight) return false;
// Check cell value using row.original instead of getValue() for stability
// getValue() is unstable and recreates on every render, breaking memoization
const prevValue = (prev.cell.row.original as Record<string, unknown>)[
prev.columnId
];
const nextValue = (next.cell.row.original as Record<string, unknown>)[
next.columnId
];
if (prevValue !== nextValue) {
return false;
}
// Check cell/row identity
if (prev.cell.row.id !== next.cell.row.id) return false;
return true;
}) as typeof DataGridCellImpl;
function DataGridCellImpl<TData extends RowData>({
cell,
tableMeta,
rowId,
columnId,
isFocused,
isEditing,
isSelected,
isSearchMatch,
isActiveSearchMatch,
readOnly,
rowHeight,
}: DataGridCellProps<TData>) {
const cellOpts = cell.column.columnDef.meta?.cell;
const variant = cellOpts?.variant ?? "text";
let Comp: React.ComponentType<DataGridCellProps<TData>>;
switch (variant) {
case "short-text":
Comp = ShortTextCell;
break;
case "long-text":
Comp = LongTextCell;
break;
case "number":
Comp = NumberCell;
break;
case "url":
Comp = UrlCell;
break;
case "checkbox":
Comp = CheckboxCell;
break;
case "select":
Comp = SelectCell;
break;
case "multi-select":
Comp = MultiSelectCell;
break;
case "date":
Comp = DateCell;
break;
case "file":
Comp = FileCell;
break;
default:
Comp = ShortTextCell;
break;
}
return (
<Comp
cell={cell}
tableMeta={tableMeta}
rowId={rowId}
columnId={columnId}
rowHeight={rowHeight}
isEditing={isEditing}
isFocused={isFocused}
isSelected={isSelected}
isSearchMatch={isSearchMatch}
isActiveSearchMatch={isActiveSearchMatch}
readOnly={readOnly}
/>
);
}
src/registry/bases/base/components/data-grid/data-grid-cell-wrapper.tsx
"use client";
import type { RowData } from "@tanstack/react-table";
import { useMergedRefs } from "@base-ui/utils/useMergedRefs";
import { cn } from "cn";
import * as React from "react";
import type { DataGridCellProps } from "@/lib/data-grid-types";
import { getCellKey } from "@/lib/data-grid-utils";
import { useDataGridPresence } from "@/registry/bases/base/components/data-grid/data-grid-presence";
interface DataGridCellWrapperProps<TData extends RowData>
extends DataGridCellProps<TData>, React.ComponentProps<"div"> {}
export function DataGridCellWrapper<TData extends RowData>({
tableMeta,
rowId,
columnId,
isEditing,
isFocused,
isSelected,
isSearchMatch,
isActiveSearchMatch,
readOnly,
rowHeight,
className,
onClick: onClickProp,
onKeyDown: onKeyDownProp,
ref,
...props
}: DataGridCellWrapperProps<TData>) {
const cellMapRef = tableMeta?.cellMapRef;
const cellPresence = useDataGridPresence(getCellKey(rowId, columnId));
const onCellChange = React.useCallback(
(node: HTMLDivElement | null) => {
if (!cellMapRef) return;
const cellKey = getCellKey(rowId, columnId);
if (node) {
cellMapRef.current.set(cellKey, node);
} else {
cellMapRef.current.delete(cellKey);
}
},
[rowId, columnId, cellMapRef],
);
const composedRef = useMergedRefs(ref, onCellChange);
const onClick = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
if (!isEditing) {
event.preventDefault();
onClickProp?.(event);
if (isFocused && !readOnly) {
tableMeta?.onCellEditingStart?.(rowId, columnId);
} else {
tableMeta?.onCellClick?.(rowId, columnId, event);
}
}
},
[tableMeta, rowId, columnId, isEditing, isFocused, readOnly, onClickProp],
);
const onContextMenu = React.useCallback(
(event: React.MouseEvent) => {
if (!isEditing) {
tableMeta?.onCellContextMenu?.(rowId, columnId, event);
}
},
[tableMeta, rowId, columnId, isEditing],
);
const onDoubleClick = React.useCallback(
(event: React.MouseEvent) => {
if (!isEditing) {
event.preventDefault();
tableMeta?.onCellDoubleClick?.(rowId, columnId);
}
},
[tableMeta, rowId, columnId, isEditing],
);
const onKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
onKeyDownProp?.(event);
if (event.defaultPrevented) return;
if (
event.key === "ArrowUp" ||
event.key === "ArrowDown" ||
event.key === "ArrowLeft" ||
event.key === "ArrowRight" ||
event.key === "Home" ||
event.key === "End" ||
event.key === "PageUp" ||
event.key === "PageDown" ||
event.key === "Tab"
) {
return;
}
if (isFocused && !isEditing && !readOnly) {
if (event.key === "F2" || event.key === "Enter") {
event.preventDefault();
event.stopPropagation();
tableMeta?.onCellEditingStart?.(rowId, columnId);
return;
}
if (event.key === " ") {
event.preventDefault();
event.stopPropagation();
tableMeta?.onCellEditingStart?.(rowId, columnId);
return;
}
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
event.preventDefault();
event.stopPropagation();
tableMeta?.onCellEditingStart?.(rowId, columnId);
}
}
},
[onKeyDownProp, isFocused, isEditing, readOnly, tableMeta, rowId, columnId],
);
const onMouseDown = React.useCallback(
(event: React.MouseEvent) => {
if (!isEditing) {
tableMeta?.onCellMouseDown?.(rowId, columnId, event);
}
},
[tableMeta, rowId, columnId, isEditing],
);
const onMouseEnter = React.useCallback(() => {
if (!isEditing) {
tableMeta?.onCellMouseEnter?.(rowId, columnId);
}
}, [tableMeta, rowId, columnId, isEditing]);
const onMouseUp = React.useCallback(() => {
if (!isEditing) {
tableMeta?.onCellMouseUp?.();
}
}, [tableMeta, isEditing]);
return (
<div
role="button"
data-slot="grid-cell-wrapper"
data-editing={isEditing ? "" : undefined}
data-focused={isFocused ? "" : undefined}
data-selected={isSelected ? "" : undefined}
tabIndex={isFocused && !isEditing ? 0 : -1}
{...props}
ref={composedRef}
className={cn(
"size-full px-2 py-1.5 text-start text-sm outline-none has-data-[slot=checkbox]:pt-2.5",
{
"ring-1 ring-inset": isFocused || !!cellPresence,
"ring-ring": isFocused && !cellPresence,
"bg-yellow-100 dark:bg-yellow-900/30":
isSearchMatch && !isActiveSearchMatch,
"bg-orange-200 dark:bg-orange-900/50": isActiveSearchMatch,
"bg-primary/10": isSelected && !isEditing,
"cursor-default": !isEditing,
"**:data-[slot=grid-cell-content]:line-clamp-1":
!isEditing && rowHeight === "short",
"**:data-[slot=grid-cell-content]:line-clamp-2":
!isEditing && rowHeight === "medium",
"**:data-[slot=grid-cell-content]:line-clamp-3":
!isEditing && rowHeight === "tall",
"**:data-[slot=grid-cell-content]:line-clamp-4":
!isEditing && rowHeight === "extra-tall",
},
className,
)}
style={
cellPresence
? ({ "--tw-ring-color": cellPresence.color } as React.CSSProperties)
: undefined
}
onClick={onClick}
onContextMenu={onContextMenu}
onDoubleClick={onDoubleClick}
onMouseDown={onMouseDown}
onMouseEnter={onMouseEnter}
onMouseUp={onMouseUp}
onKeyDown={onKeyDown}
/>
);
}
src/registry/bases/base/components/data-grid/data-grid-cell-variants.tsx
"use client";
import type { RowData } from "@tanstack/react-table";
import { cn } from "cn";
import * as React from "react";
import { toast } from "sonner";
import type { DataGridCellProps, FileCellData } from "@/lib/data-grid-types";
import { useBadgeOverflow } from "@/hooks/use-badge-overflow";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import {
formatDateForDisplay,
formatDateToString,
formatFileSize,
getCellKey,
getFileIcon,
getLineCount,
getUrlHref,
parseLocalDate,
} from "@/lib/data-grid-utils";
import { DataGridCellWrapper } from "@/registry/bases/base/components/data-grid/data-grid-cell-wrapper";
import { Badge } from "@/registry/bases/base/ui/badge";
import { Button } from "@/registry/bases/base/ui/button";
import { Checkbox } from "@/registry/bases/base/ui/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/registry/bases/base/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/bases/base/ui/popover";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/bases/base/ui/select";
import { Skeleton } from "@/registry/bases/base/ui/skeleton";
import { Textarea } from "@/registry/bases/base/ui/textarea";
import { Calendar } from "@/registry/bases/radix/ui/calendar";
import { IconPlaceholder } from "@/registry/icons/icon-placeholder";
export function ShortTextCell<TData extends RowData>({
cell,
tableMeta,
rowId,
columnId,
rowHeight,
isEditing,
isFocused,
isSelected,
isSearchMatch,
isActiveSearchMatch,
readOnly,
}: DataGridCellProps<TData>) {
const initialValue = cell.getValue() as string;
const [value, setValue] = React.useState(initialValue);
const cellRef = React.useRef<HTMLDivElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const prevIsEditingRef = React.useRef(false);
const prevInitialValueRef = React.useRef(initialValue);
if (initialValue !== prevInitialValueRef.current) {
prevInitialValueRef.current = initialValue;
setValue(initialValue);
if (cellRef.current && !isEditing) {
cellRef.current.textContent = initialValue;
}
}
const onBlur = React.useCallback(() => {
// Read the current value directly from the DOM to avoid stale state
const currentValue = cellRef.current?.textContent ?? "";
if (!readOnly && currentValue !== initialValue) {
tableMeta?.onDataUpdate?.({ rowId, columnId, value: currentValue });
}
tableMeta?.onCellEditingStop?.();
}, [tableMeta, rowId, columnId, initialValue, readOnly]);
const onInput = React.useCallback(
(event: React.FormEvent<HTMLDivElement>) => {
const currentValue = event.currentTarget.textContent ?? "";
setValue(currentValue);
},
[],
);
const onWrapperKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (isEditing) {
if (event.key === "Enter") {
event.preventDefault();
const currentValue = cellRef.current?.textContent ?? "";
if (currentValue !== initialValue) {
tableMeta?.onDataUpdate?.({
rowId,
columnId,
value: currentValue,
});
}
tableMeta?.onCellEditingStop?.({ moveToNextRow: true });
} else if (event.key === "Tab") {
event.preventDefault();
const currentValue = cellRef.current?.textContent ?? "";
if (currentValue !== initialValue) {
tableMeta?.onDataUpdate?.({
rowId,
columnId,
value: currentValue,
});
}
tableMeta?.onCellEditingStop?.({
direction: event.shiftKey ? "shift+tab" : "tab",
});
} else if (event.key === "Escape") {
event.preventDefault();
setValue(initialValue);
cellRef.current?.blur();
}
} else if (
isFocused &&
event.key.length === 1 &&
!event.ctrlKey &&
!event.metaKey
) {
// Handle typing to pre-fill the value when editing starts
setValue(event.key);
queueMicrotask(() => {
if (cellRef.current && cellRef.current.contentEditable === "true") {
cellRef.current.textContent = event.key;
const range = document.createRange();
const selection = window.getSelection();
range.selectNodeContents(cellRef.current);
range.collapse(false);
selection?.removeAllRanges();
selection?.addRange(range);
}
});
}
},
[isEditing, isFocused, initialValue, tableMeta, rowId, columnId],
);
React.useEffect(() => {
const wasEditing = prevIsEditingRef.current;
prevIsEditingRef.current = isEditing;
if (isEditing && !wasEditing && cellRef.current) {
cellRef.current.focus();
if (!cellRef.current.textContent && value) {
cellRef.current.textContent = value;
}
if (cellRef.current.textContent) {
const range = document.createRange();
const selection = window.getSelection();
range.selectNodeContents(cellRef.current);
range.collapse(false);
selection?.removeAllRanges();
selection?.addRange(range);
}
}
}, [isEditing, value]);
const displayValue = !isEditing ? (value ?? "") : "";
return (
<DataGridCellWrapper<TData>
ref={containerRef}
cell={cell}
tableMeta={tableMeta}
rowId={rowId}
columnId={columnId}
rowHeight={rowHeight}
isEditing={isEditing}
isFocused={isFocused}
isSelected={isSelected}
isSearchMatch={isSearchMatch}
isActiveSearchMatch={isActiveSearchMatch}
readOnly={readOnly}
onKeyDown={onWrapperKeyDown}
>
<div
role="textbox"
data-slot="grid-cell-content"
contentEditable={isEditing}
tabIndex={-1}
ref={cellRef}
onBlur={onBlur}
onInput={onInput}
suppressContentEditableWarning
className={cn("size-full overflow-hidden outline-none", {
"whitespace-nowrap **:inline **:whitespace-nowrap [&_br]:hidden":
isEditing,
})}
>
{displayValue}
</div>
</DataGridCellWrapper>
);
}
export function LongTextCell<TData extends RowData>({
cell,
tableMeta,
rowId,
columnId,
rowHeight,
isFocused,
isEditing,
isSelected,
isSearchMatch,
isActiveSearchMatch,
readOnly,
}: DataGridCellProps<TData>) {
const initialValue = cell.getValue() as string;
const [value, setValue] = React.useState(initialValue ?? "");
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const pendingCharRef = React.useRef<string | null>(null);
const sideOffset = -(containerRef.current?.clientHeight ?? 0);
const prevInitialValueRef = React.useRef(initialValue);
if (initialValue !== prevInitialValueRef.current) {
prevInitialValueRef.current = initialValue;
setValue(initialValue ?? "");
}
const debouncedSave = useDebouncedCallback((newValue: string) => {
if (!readOnly) {
tableMeta?.onDataUpdate?.({ rowId, columnId, value: newValue });
}
}, 300);
const onSave = React.useCallback(() => {
// Immediately save any pending changes and close the popover
if (!readOnly && value !== initialValue) {
tableMeta?.onDataUpdate?.({ rowId, columnId, value });
}
tableMeta?.onCellEditingStop?.();
}, [tableMeta, value, initialValue, rowId, columnId, readOnly]);
const onCancel = React.useCallback(() => {
// Restore the original value
setValue(initialValue ?? "");
if (!readOnly) {
tableMeta?.onDataUpdate?.({ rowId, columnId, value: initialValue });
}
tableMeta?.onCellEditingStop?.();
}, [tableMeta, initialValue, rowId, columnId, readOnly]);
const onOpenChange = React.useCallback(
(open: boolean) => {
if (open && !readOnly) {
tableMeta?.onCellEditingStart?.(rowId, columnId);
} else {
// Immediately save any pending changes when closing
if (!readOnly && value !== initialValue) {
tableMeta?.onDataUpdate?.({ rowId, columnId, value });
}
tableMeta?.onCellEditingStop?.();
}
},
[tableMeta, value, initialValue, rowId, columnId, readOnly],
);
const onInitialFocus = React.useCallback(() => {
const textarea = textareaRef.current;
if (!textarea) return false;
textarea.focus();
const length = textarea.value.length;
textarea.setSelectionRange(length, length);
// Insert pending character using execCommand so it's part of undo history
// Use requestAnimationFrame to ensure focus has fully settled
if (pendingCharRef.current) {
const char = pendingCharRef.current;
pendingCharRef.current = null;
requestAnimationFrame(() => {
if (
textareaRef.current &&
document.activeElement === textareaRef.current
) {
document.execCommand("insertText", false, char);
textareaRef.current.scrollTop = textareaRef.current.scrollHeight;
}
});
} else {
textarea.scrollTop = textarea.scrollHeight;
}
return false;
}, []);
const onWrapperKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (
isFocused &&
!isEditing &&
!readOnly &&
event.key.length === 1 &&
!event.ctrlKey &&
!event.metaKey
) {
// Store the character to be inserted after textarea focuses
// This ensures it's part of the textarea's undo history
pendingCharRef.current = event.key;
}
},
[isFocused, isEditing, readOnly],
);
const onBlur = React.useCallback(() => {
// Immediately save any pending changes on blur
if (!readOnly && value !== initialValue) {
tableMeta?.onDataUpdate?.({ rowId, columnId, value });
}
tableMeta?.onCellEditingStop?.();
}, [tableMeta, value, initialValue, rowId, columnId, readOnly]);
const onChange = React.useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = event.target.value;
setValue(newValue);
debouncedSave(newValue);
},
[debouncedSave],
);
const onKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (event.key === "Escape") {
event.preventDefault();
onCancel();
} else if (event.key === "Enter" && (event.ctrlKey || event.metaKey)) {
event.preventDefault();
onSave();
} else if (event.key === "Tab") {
event.preventDefault();
// Save any pending changes
if (value !== initialValue) {
tableMeta?.onDataUpdate?.({ rowId, columnId, value });
}
tableMeta?.onCellEditingStop?.({
direction: event.shiftKey ? "shift+tab" : "tab",
});
return;
}
// Stop propagation to prevent grid navigation
event.stopPropagation();
},
[onSave, onCancel, value, initialValue, tableMeta, rowId, columnId],
);
return (
<Popover open={isEditing} onOpenChange={onOpenChange}>
<PopoverTrigger
nativeButton={false}
render={
<DataGridCellWrapper<TData>
ref={containerRef}
cell={cell}
tableMeta={tableMeta}
rowId={rowId}
columnId={columnId}
rowHeight={rowHeight}
isEditing={isEditing}
isFocused={isFocused}
isSelected={isSelected}
isSearchMatch={isSearchMatch}
isActiveSearchMatch={isActiveSearchMatch}
readOnly={readOnly}
onKeyDown={onWrapperKeyDown}
/>
}
>
<span data-slot="grid-cell-content">{value}</span>
</PopoverTrigger>
<PopoverContent
data-grid-cell-editor=""
align="start"
side="bottom"
sideOffset={sideOffset}
className="w-100 rounded-none p-0"
initialFocus={onInitialFocus}
>
<Textarea
placeholder="Enter text..."
className="max-h-75 min-h-37.5 resize-none overflow-y-auto rounded-none border-0 shadow-none focus-visible:ring-1 focus-visible:ring-ring"
ref={textareaRef}
value={value}
onBlur={onBlur}
onChange={onChange}
onKeyDown={onKeyDown}
/>
</PopoverContent>
</Popover>
);
}
export function NumberCell<TData extends RowData>({
cell,
tableMeta,
rowId,
columnId,
rowHeight,
isFocused,
isEditing,
isSelected,
isSearchMatch,
isActiveSearchMatch,
readOnly,
}: DataGridCellProps<TData>) {
const initialValue = cell.getValue() as number;
const [value, setValue] = React.useState(String(initialValue ?? ""));
const inputRef = React.useRef<HTMLInputElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const cellOpts = cell.column.columnDef.meta?.cell;
const numberCellOpts = cellOpts?.variant === "number" ? cellOpts : null;
const min = numberCellOpts?.min;
const max = numberCellOpts?.max;
const step = numberCellOpts?.step;
const prevIsEditingRef = React.useRef(false);
const prevInitialValueRef = React.useRef(initialValue);
if (initialValue !== prevInitialValueRef.current) {
prevInitialValueRef.current = initialValue;
setValue(String(initialValue ?? ""));
}
const onBlur = React.useCallback(() => {
const numValue = value === "" ? null : Number(value);
if (!readOnly && numValue !== initialValue) {
tableMeta?.onDataUpdate?.({ rowId, columnId, value: numValue });
}
tableMeta?.onCellEditingStop?.();
}, [tableMeta, rowId, columnId, initialValue, value, readOnly]);
const onChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
},
[],
);
const onWrapperKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (isEditing) {
if (event.key === "Enter") {
event.preventDefault();
const numValue = value === "" ? null : Number(value);
if (numValue !== initialValue) {
tableMeta?.onDataUpdate?.({ rowId, columnId, value: numValue });
}
tableMeta?.onCellEditingStop?.({ moveToNextRow: true });
} else if (event.key === "Tab") {
event.preventDefault();
const numValue = value === "" ? null : Number(value);
if (numValue !== initialValue) {
tableMeta?.onDataUpdate?.({ rowId, columnId, value: numValue });
}
tableMeta?.onCellEditingStop?.({
direction: event.shiftKey ? "shift+tab" : "tab",
});
} else if (event.key === "Escape") {
event.preventDefault();
setValue(String(initialValue ?? ""));
inputRef.current?.blur();
}
} else if (isFocused) {
// Handle Backspace to start editing with empty value
if (event.key === "Backspace") {
setValue("");
} else if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
// Handle typing to pre-fill the value when editing starts
setValue(event.key);
}
}
},
[isEditing, isFocused, initialValue, tableMeta, rowId, columnId, value],
);
React.useEffect(() => {
const wasEditing = prevIsEditingRef.current;
prevIsEditingRef.current = isEditing;
// Only focus when we start editing (transition from false to true)
if (isEditing && !wasEditing && inputRef.current) {
inputRef.current.focus();
}
}, [isEditing]);
return (
<DataGridCellWrapper<TData>
ref={containerRef}
cell={cell}
tableMeta={tableMeta}
rowId={rowId}
columnId={columnId}
rowHeight={rowHeight}
isEditing={isEditing}
isFocused={isFocused}
isSelected={isSelected}
isSearchMatch={isSearchMatch}
isActiveSearchMatch={isActiveSearchMatch}
readOnly={readOnly}
onKeyDown={onWrapperKeyDown}
>
{isEditing ? (
<input
type="number"
ref={inputRef}
value={value}
min={min}
max={max}
step={step}
className="w-full [appearance:textfield] border-none bg-transparent p-0 outline-none [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none"
onBlur={onBlur}
onChange={onChange}
/>
) : (
<span data-slot="grid-cell-content">{value}</span>
)}
</DataGridCellWrapper>
);
}
export function UrlCell<TData extends RowData>({
cell,
tableMeta,
rowId,
columnId,
rowHeight,
isEditing,
isFocused,
isSelected,
isSearchMatch,
isActiveSearchMatch,
readOnly,
}: DataGridCellProps<TData>) {
const initialValue = cell.getValue() as string;
const [value, setValue] = React.useState(initialValue ?? "");
const cellRef = React.useRef<HTMLDivElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const prevIsEditingRef = React.useRef(false);
const prevInitialValueRef = React.useRef(initialValue);
if (initialValue !== prevInitialValueRef.current) {
prevInitialValueRef.current = initialValue;
setValue(initialValue ?? "");
if (cellRef.current && !isEditing) {
cellRef.current.textContent = initialValue ?? "";
}
}
const onBlur = React.useCallback(() => {
const currentValue = cellRef.current?.textContent?.trim() ?? "";
if (!readOnly && currentValue !== initialValue) {
tableMeta?.onDataUpdate?.({
rowId,
columnId,
value: currentValue || null,
});
}
tableMeta?.onCellEditingStop?.();
}, [tableMeta, rowId, columnId, initialValue, readOnly]);
const onInput = React.useCallback(
(event: React.FormEvent<HTMLDivElement>) => {
const currentValue = event.currentTarget.textContent ?? "";
setValue(currentValue);
},
[],
);
const onWrapperKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (isEditing) {
if (event.key === "Enter") {
event.preventDefault();
const currentValue = cellRef.current?.textContent?.trim() ?? "";
if (!readOnly && currentValue !== initialValue) {
tableMeta?.onDataUpdate?.({
rowId,
columnId,
value: currentValue || null,
});
}
tableMeta?.onCellEditingStop?.({ moveToNextRow: true });
} else if (event.key === "Tab") {
event.preventDefault();
const currentValue = cellRef.current?.textContent?.trim() ?? "";
if (!readOnly && currentValue !== initialValue) {
tableMeta?.onDataUpdate?.({
rowId,
columnId,
value: currentValue || null,
});
}
tableMeta?.onCellEditingStop?.({
direction: event.shiftKey ? "shift+tab" : "tab",
});
} else if (event.key === "Escape") {
event.preventDefault();
setValue(initialValue ?? "");
cellRef.current?.blur();
}
} else if (
isFocused &&
event.key === "Enter" &&
(event.ctrlKey || event.metaKey)
) {
event.preventDefault();
if (!value) return;
const href = getUrlHref(value);
if (href) {
window.open(href, "_blank", "noopener,noreferrer");
} else {
toastDangerousUrl();
}
} else if (
isFocused &&
!readOnly &&
event.key.length === 1 &&
!event.ctrlKey &&
!event.metaKey
) {
// Handle typing to pre-fill the value when editing starts
setValue(event.key);
queueMicrotask(() => {
if (cellRef.current && cellRef.current.contentEditable === "true") {
cellRef.current.textContent = event.key;
const range = document.createRange();
const selection = window.getSelection();
range.selectNodeContents(cellRef.current);
range.collapse(false);
selection?.removeAllRanges();
selection?.addRange(range);
}
});
}
},
[
isEditing,
isFocused,
initialValue,
value,
tableMeta,
rowId,
columnId,
readOnly,
],
);
const onLinkClick = React.useCallback(
(event: React.MouseEvent<HTMLAnchorElement>) => {
if (isEditing) {
event.preventDefault();
return;
}
// Check if URL was rejected due to dangerous protocol
const href = getUrlHref(value);
if (!href) {
event.preventDefault();
toastDangerousUrl();
return;
}
// Stop propagation to prevent grid from interfering with link navigation
event.stopPropagation();
},
[isEditing, value],
);
React.useEffect(() => {
const wasEditing = prevIsEditingRef.current;
prevIsEditingRef.current = isEditing;
if (isEditing && !wasEditing && cellRef.current) {
cellRef.current.focus();
if (!cellRef.current.textContent && value) {
cellRef.current.textContent = value;
}
if (cellRef.current.textContent) {
const range = document.createRange();
const selection = window.getSelection();
range.selectNodeContents(cellRef.current);
range.collapse(false);
selection?.removeAllRanges();
selection?.addRange(range);
}
}
}, [isEditing, value]);
const displayValue = !isEditing ? (value ?? "") : "";
const urlHref = displayValue ? getUrlHref(displayValue) : "";
const isDangerousUrl = displayValue && !urlHref;
return (
<DataGridCellWrapper<TData>
ref={containerRef}
cell={cell}
tableMeta={tableMeta}
rowId={rowId}
columnId={columnId}
rowHeight={rowHeight}
isEditing={isEditing}
isFocused={isFocused}
isSelected={isSelected}
isSearchMatch={isSearchMatch}
isActiveSearchMatch={isActiveSearchMatch}
readOnly={readOnly}
onKeyDown={onWrapperKeyDown}
>
{!isEditing && displayValue ? (
<div
data-slot="grid-cell-content"
className="size-full overflow-hidden"
>
<a
tabIndex={-1}
data-focused={isFocused && !isDangerousUrl ? "" : undefined}
data-invalid={isDangerousUrl ? "" : undefined}
href={urlHref}
target="_blank"
rel="noopener noreferrer"
className="truncate text-primary underline decoration-primary/30 underline-offset-2 hover:decoration-primary/60 data-focused:text-foreground data-focused:decoration-foreground/50 data-focused:hover:decoration-foreground/70 data-invalid:cursor-not-allowed data-invalid:text-destructive data-invalid:decoration-destructive/50 data-invalid:hover:decoration-destructive/70"
onClick={onLinkClick}
>
{displayValue}
</a>
</div>
) : (
<div
role="textbox"
data-slot="grid-cell-content"
contentEditable={isEditing}
tabIndex={-1}
ref={cellRef}
onBlur={onBlur}
onInput={onInput}
suppressContentEditableWarning
className={cn("size-full overflow-hidden outline-none", {
"whitespace-nowrap **:inline **:whitespace-nowrap [&_br]:hidden":
isEditing,
})}
>
{displayValue}
</div>
)}
</DataGridCellWrapper>
);
}
export function CheckboxCell<TData extends RowData>({
cell,
tableMeta,
rowId,
columnId,
rowHeight,
isFocused,
isSelected,
isSearchMatch,
isActiveSearchMatch,
readOnly,
}: Omit<DataGridCellProps<TData>, "isEditing">) {
const initialValue = cell.getValue() as boolean;
const [value, setValue] = React.useState(initialValue);
const containerRef = React.useRef<HTMLDivElement>(null);
const prevInitialValueRef = React.useRef(initialValue);
if (initialValue !== prevInitialValueRef.current) {
prevInitialValueRef.current = initialValue;
setValue(initialValue);
}
const onCheckedChange = React.useCallback(
(checked: boolean) => {
if (readOnly) return;
setValue(checked);
tableMeta?.onDataUpdate?.({ rowId, columnId, value: checked });
},
[tableMeta, rowId, columnId, readOnly],
);
const onWrapperKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (
isFocused &&
!readOnly &&
(event.key === " " || event.key === "Enter")
) {
event.preventDefault();
event.stopPropagation();
onCheckedChange(!value);
}
},
[isFocused, value, onCheckedChange, readOnly],
);
const onWrapperClick = React.useCallback(
(event: React.MouseEvent) => {
if (isFocused && !readOnly) {
event.preventDefault();
event.stopPropagation();
onCheckedChange(!value);
}
},
[isFocused, value, onCheckedChange, readOnly],
);
const onCheckboxClick = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
}, []);
const onCheckboxMouseDown = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
event.stopPropagation();
},
[],
);
const onCheckboxDoubleClick = React.useCallback(
(event: React.MouseEvent<HTMLElement>) => {
event.stopPropagation();
},
[],
);
return (
<DataGridCellWrapper<TData>
ref={containerRef}
cell={cell}
tableMeta={tableMeta}
rowId={rowId}
columnId={columnId}
rowHeight={rowHeight}
isEditing={false}
isFocused={isFocused}
isSelected={isSelected}
isSearchMatch={isSearchMatch}
isActiveSearchMatch={isActiveSearchMatch}
readOnly={readOnly}
className="flex size-full justify-center"
onClick={onWrapperClick}
onKeyDown={onWrapperKeyDown}
>
<Checkbox
tabIndex={-1}
checked={value}
onCheckedChange={onCheckedChange}
disabled={readOnly}
className="border-primary"
onClick={onCheckboxClick}
onMouseDown={onCheckboxMouseDown}
onDoubleClick={onCheckboxDoubleClick}
/>
</DataGridCellWrapper>
);
}
export function SelectCell<TData extends RowData>({
cell,
tableMeta,
rowId,
columnId,
rowHeight,
isFocused,
isEditing,
isSelected,
isSearchMatch,
isActiveSearchMatch,
readOnly,
}: DataGridCellProps<TData>) {
const initialValue = (cell.getValue() ?? undefined) as string | undefined;
const [value, setValue] = React.useState(initialValue);
const containerRef = React.useRef<HTMLDivElement>(null);
const cellOpts = cell.column.columnDef.meta?.cell;
const options = React.useMemo(
() => (cellOpts?.variant === "select" ? cellOpts.options : []),
[cellOpts],
);
const optionByValue = React.useMemo(
() => new Map(options.map((option) => [option.value, option])),
[options],
);
const prevInitialValueRef = React.useRef(initialValue);
if (initialValue !== prevInitialValueRef.current) {
prevInitialValueRef.current = initialValue;
setValue(initialValue);
}
const onValueChange = React.useCallback(
(newValue: string | null) => {
if (newValue == null || readOnly) return;
setValue(newValue);
tableMeta?.onDataUpdate?.({ rowId, columnId, value: newValue });
tableMeta?.onCellEditingStop?.();
},
[tableMeta, rowId, columnId, readOnly],
);
const onOpenChange = React.useCallback(
(open: boolean) => {
if (open && !readOnly) {
tableMeta?.onCellEditingStart?.(rowId, columnId);
} else {
tableMeta?.onCellEditingStop?.();
}
},
[tableMeta, rowId, columnId, readOnly],
);
const onWrapperKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (isEditing && event.key === "Escape") {
event.preventDefault();
setValue(initialValue);
tableMeta?.onCellEditingStop?.();
} else if (isEditing && event.key === "Tab") {
event.preventDefault();
tableMeta?.onCellEditingStop?.({
direction: event.shiftKey ? "shift+tab" : "tab",
});
}
},
[isEditing, initialValue, tableMeta],
);
const displayLabel = value
? (optionByValue.get(value)?.label ?? value)
: null;
return (
<DataGridCellWrapper<TData>
ref={containerRef}
cell={cell}
tableMeta={tableMeta}
rowId={rowId}
columnId={columnId}
rowHeight={rowHeight}
isEditing={isEditing}
isFocused={isFocused}
isSelected={isSelected}
isSearchMatch={isSearchMatch}
isActiveSearchMatch={isActiveSearchMatch}
readOnly={readOnly}
onKeyDown={onWrapperKeyDown}
>
{isEditing ? (
<Select
value={value}
onValueChange={onValueChange}
open={isEditing}
onOpenChange={onOpenChange}
>
<SelectTrigger className="size-full items-start border-none p-0 shadow-none focus-visible:ring-0 dark:bg-transparent [&_svg]:hidden">
{displayLabel ? (
<Badge
variant="secondary"
className="px-1.5 py-px whitespace-pre-wrap"
>
<SelectValue />
</Badge>
) : (
<SelectValue />
)}
</SelectTrigger>
<SelectContent
data-grid-cell-editor=""
// compensate for the wrapper padding
align="start"
alignOffset={-8}
sideOffset={-8}
className="min-w-[calc(var(--anchor-width)+16px)]"
>
<SelectGroup>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : displayLabel ? (
<Badge
data-slot="grid-cell-content"
variant="secondary"
className="px-1.5 py-px whitespace-pre-wrap"
>
{displayLabel}
</Badge>
) : null}
</DataGridCellWrapper>
);
}
export function MultiSelectCell<TData extends RowData>({
cell,
tableMeta,
rowId,
columnId,
rowHeight,
isFocused,
isEditing,
isSelected,
isSearchMatch,
isActiveSearchMatch,
readOnly,
}: DataGridCellProps<TData>) {
const cellValue = React.useMemo(() => {
const value = cell.getValue() as string[];
return value ?? [];
}, [cell]);
const cellKey = getCellKey(rowId, columnId);
const prevCellKeyRef = React.useRef(cellKey);
const [selectedValues, setSelectedValues] =
React.useState<string[]>(cellValue);
const [searchValue, setSearchValue] = React.useState("");
const containerRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const cellOpts = cell.column.columnDef.meta?.cell;
const options = React.useMemo(
() => (cellOpts?.variant === "multi-select" ? cellOpts.options : []),
[cellOpts],
);
const optionByValue = React.useMemo(
() => new Map(options.map((option) => [option.value, option])),
[options],
);
const sideOffset = -(containerRef.current?.clientHeight ?? 0);
const prevCellValueRef = React.useRef(cellValue);
if (cellValue !== prevCellValueRef.current) {
prevCellValueRef.current = cellValue;
setSelectedValues(cellValue);
}
if (prevCellKeyRef.current !== cellKey) {
prevCellKeyRef.current = cellKey;
setSearchValue("");
}
const onValueChange = React.useCallback(
(value: string) => {
if (readOnly) return;
let newValues: string[] = [];
setSelectedValues((curr) => {
newValues = curr.includes(value)
? curr.filter((v) => v !== value)
: [...curr, value];
return newValues;
});
queueMicrotask(() => {
tableMeta?.onDataUpdate?.({ rowId, columnId, value: newValues });
inputRef.current?.focus();
});
setSearchValue("");
},
[tableMeta, rowId, columnId, readOnly],
);
const removeValue = React.useCallback(
(valueToRemove: string, event?: React.MouseEvent) => {
if (readOnly) return;
event?.stopPropagation();
event?.preventDefault();
let newValues: string[] = [];
setSelectedValues((curr) => {
newValues = curr.filter((v) => v !== valueToRemove);
return newValues;
});
queueMicrotask(() => {
tableMeta?.onDataUpdate?.({ rowId, columnId, value: newValues });
inputRef.current?.focus();
});
},
[tableMeta, rowId, columnId, readOnly],
);
const clearAll = React.useCallback(() => {
if (readOnly) return;
setSelectedValues([]);
tableMeta?.onDataUpdate?.({ rowId, columnId, value: [] });
queueMicrotask(() => inputRef.current?.focus());
}, [tableMeta, rowId, columnId, readOnly]);
const onOpenChange = React.useCallback(
(open: boolean) => {
if (open && !readOnly) {
tableMeta?.onCellEditingStart?.(rowId, columnId);
} else {
setSearchValue("");
tableMeta?.onCellEditingStop?.();
}
},
[tableMeta, rowId, columnId, readOnly],
);
const onInitialFocus = React.useCallback(() => {
inputRef.current?.focus();
return false;
}, []);
const onWrapperKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (isEditing && event.key === "Escape") {
event.preventDefault();
setSelectedValues(cellValue);
setSearchValue("");
tableMeta?.onCellEditingStop?.();
} else if (isEditing && event.key === "Tab") {
event.preventDefault();
setSearchValue("");
tableMeta?.onCellEditingStop?.({
direction: event.shiftKey ? "shift+tab" : "tab",
});
}
},
[isEditing, cellValue, tableMeta],
);
const onInputKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Backspace" && searchValue === "") {
event.preventDefault();
let newValues: string[] | null = null;
setSelectedValues((curr) => {
if (curr.length === 0) return curr;
newValues = curr.slice(0, -1);
return newValues;
});
queueMicrotask(() => {
if (newValues !== null) {
tableMeta?.onDataUpdate?.({ rowId, columnId, value: newValues });
}
inputRef.current?.focus();
});
}
if (event.key === "Escape") {
event.stopPropagation();
}
},
[searchValue, tableMeta, rowId, columnId],
);
const displayLabels = selectedValues
.map((val) => optionByValue.get(val)?.label ?? val)
.filter(Boolean);
const selectedValuesSet = React.useMemo(
() => new Set(selectedValues),
[selectedValues],
);
const lineCount = getLineCount(rowHeight);
const { visibleItems: visibleLabels, hiddenCount: hiddenBadgeCount } =
useBadgeOverflow({
items: displayLabels,
getLabel: (label) => label,
containerRef,
lineCount,
});
return (
<DataGridCellWrapper<TData>
ref={containerRef}
cell={cell}
tableMeta={tableMeta}
rowId={rowId}
columnId={columnId}
rowHeight={rowHeight}
isEditing={isEditing}
isFocused={isFocused}
isSelected={isSelected}
isSearchMatch={isSearchMatch}
isActiveSearchMatch={isActiveSearchMatch}
readOnly={readOnly}
onKeyDown={onWrapperKeyDown}
>
{isEditing ? (
<Popover open={isEditing} onOpenChange={onOpenChange}>
<PopoverTrigger
nativeButton={false}
render={<div className="pointer-events-none absolute inset-0" />}
/>
<PopoverContent
data-grid-cell-editor=""
align="start"
sideOffset={sideOffset}
className="w-75 rounded-none p-0"
initialFocus={onInitialFocus}
>
<Command className="**:data-[slot=command-input-wrapper]:min-w-16 **:data-[slot=command-input-wrapper]:flex-1 **:data-[slot=command-input-wrapper]:p-0 **:data-[slot=input-group]:h-auto! **:data-[slot=input-group]:rounded-none! **:data-[slot=input-group]:border-none **:data-[slot=input-group]:bg-transparent **:data-[slot=input-group-addon]:hidden **:data-[slot=input-group]:dark:bg-transparent">
<div className="flex min-h-9 flex-wrap items-center gap-1 border-b px-3 py-1.5">
{selectedValues.map((value) => {
const label = optionByValue.get(value)?.label ?? value;
return (
<Badge
key={value}
variant="secondary"
className="gap-1 px-1.5 py-px"
>
{label}
<button
type="button"
onClick={(event) => removeValue(value, event)}
onPointerDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
>
<IconPlaceholder
lucide="X"
tabler="IconX"
hugeicons="Cancel01Icon"
phosphor="XIcon"
remixicon="RiCloseLine"
className="size-3"
/>
</button>
</Badge>
);
})}
<CommandInput
ref={inputRef}
value={searchValue}
onValueChange={setSearchValue}
onKeyDown={onInputKeyDown}
placeholder="Search..."
className="p-0! placeholder:text-muted-foreground"
/>
</div>
<CommandList className="max-h-full">
<CommandEmpty>No options found.</CommandEmpty>
<CommandGroup className="max-h-75 scroll-py-1 overflow-x-hidden overflow-y-auto">
{options.map((option) => {
const isSelected = selectedValuesSet.has(option.value);
return (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => onValueChange(option.value)}
data-checked={isSelected}
>
<span>{option.label}</span>
</CommandItem>
);
})}
</CommandGroup>
{selectedValues.length > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={clearAll}
className="justify-center text-muted-foreground"
>
Clear all
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : null}
{displayLabels.length > 0 ? (
<div className="flex flex-wrap items-center gap-1 overflow-hidden">
{visibleLabels.map((label, index) => (
<Badge
key={selectedValues[index]}
variant="secondary"
className="px-1.5 py-px"
>
{label}
</Badge>
))}
{hiddenBadgeCount > 0 && (
<Badge
variant="outline"
className="px-1.5 py-px text-muted-foreground"
>
+{hiddenBadgeCount}
</Badge>
)}
</div>
) : null}
</DataGridCellWrapper>
);
}
export function DateCell<TData extends RowData>({
cell,
tableMeta,
rowId,
columnId,
rowHeight,
isFocused,
isEditing,
isSelected,
isSearchMatch,
isActiveSearchMatch,
readOnly,
}: DataGridCellProps<TData>) {
const initialValue = cell.getValue() as string;
const [value, setValue] = React.useState(initialValue ?? "");
const containerRef = React.useRef<HTMLDivElement>(null);
const prevInitialValueRef = React.useRef(initialValue);
if (initialValue !== prevInitialValueRef.current) {
prevInitialValueRef.current = initialValue;
setValue(initialValue ?? "");
}
// Parse date as local time to avoid timezone shifts
const selectedDate = value ? (parseLocalDate(value) ?? undefined) : undefined;
const onDateSelect = React.useCallback(
(date: Date | undefined) => {
if (!date || readOnly) return;
// Format using local date components to avoid timezone issues
const formattedDate = formatDateToString(date);
setValue(formattedDate);
tableMeta?.onDataUpdate?.({ rowId, columnId, value: formattedDate });
tableMeta?.onCellEditingStop?.();
},
[tableMeta, rowId, columnId, readOnly],
);
const onOpenChange = React.useCallback(
(open: boolean) => {
if (open && !readOnly) {
tableMeta?.onCellEditingStart?.(rowId, columnId);
} else {
tableMeta?.onCellEditingStop?.();
}
},
[tableMeta, rowId, columnId, readOnly],
);
const onWrapperKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (isEditing && event.key === "Escape") {
event.preventDefault();
setValue(initialValue);
tableMeta?.onCellEditingStop?.();
} else if (isEditing && event.key === "Tab") {
event.preventDefault();
tableMeta?.onCellEditingStop?.({
direction: event.shiftKey ? "shift+tab" : "tab",
});
}
},
[isEditing, initialValue, tableMeta],
);
return (
<DataGridCellWrapper<TData>
ref={containerRef}
cell={cell}
tableMeta={tableMeta}
rowId={rowId}
columnId={columnId}
rowHeight={rowHeight}
isEditing={isEditing}
isFocused={isFocused}
isSelected={isSelected}
isSearchMatch={isSearchMatch}
isActiveSearchMatch={isActiveSearchMatch}
readOnly={readOnly}
onKeyDown={onWrapperKeyDown}
>
<Popover open={isEditing} onOpenChange={onOpenChange}>
<PopoverTrigger
nativeButton={false}
render={<span data-slot="grid-cell-content" />}
>
{formatDateForDisplay(value)}
</PopoverTrigger>
{isEditing && (
<PopoverContent
data-grid-cell-editor=""
align="start"
alignOffset={-8}
className="w-auto p-0"
>
<Calendar
autoFocus
captionLayout="dropdown"
mode="single"
defaultMonth={selectedDate ?? new Date()}
selected={selectedDate}
onSelect={onDateSelect}
/>
</PopoverContent>
)}
</Popover>
</DataGridCellWrapper>
);
}
export function FileCell<TData extends RowData>({
cell,
tableMeta,
rowId,
columnId,
rowHeight,
isFocused,
isEditing,
isSelected,
isSearchMatch,
isActiveSearchMatch,
readOnly,
}: DataGridCellProps<TData>) {
const cellValue = React.useMemo(
() => (cell.getValue() as FileCellData[]) ?? [],
[cell],
);
const cellKey = getCellKey(rowId, columnId);
const prevCellKeyRef = React.useRef(cellKey);
const labelId = React.useId();
const descriptionId = React.useId();
const [files, setFiles] = React.useState<FileCellData[]>(cellValue);
const [uploadingFiles, setUploadingFiles] = React.useState<Set<string>>(
new Set(),
);
const [deletingFiles, setDeletingFiles] = React.useState<Set<string>>(
new Set(),
);
const [isDraggingOver, setIsDraggingOver] = React.useState(false);
const [isDragging, setIsDragging] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const isUploading = uploadingFiles.size > 0;
const isDeleting = deletingFiles.size > 0;
const isPending = isUploading || isDeleting;
const containerRef = React.useRef<HTMLDivElement>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const dropzoneRef = React.useRef<HTMLDivElement>(null);
const cellOpts = cell.column.columnDef.meta?.cell;
const sideOffset = -(containerRef.current?.clientHeight ?? 0);
const fileCellOpts = cellOpts?.variant === "file" ? cellOpts : null;
const maxFileSize = fileCellOpts?.maxFileSize ?? 10 * 1024 * 1024;
const maxFiles = fileCellOpts?.maxFiles ?? 10;
const accept = fileCellOpts?.accept;
const multiple = fileCellOpts?.multiple ?? false;
const acceptedTypes = React.useMemo(
() => (accept ? accept.split(",").map((t) => t.trim()) : null),
[accept],
);
const prevCellValueRef = React.useRef(cellValue);
if (cellValue !== prevCellValueRef.current) {
prevCellValueRef.current = cellValue;
for (const file of files) {
if (file.url) {
URL.revokeObjectURL(file.url);
}
}
setFiles(cellValue);
setError(null);
}
if (prevCellKeyRef.current !== cellKey) {
prevCellKeyRef.current = cellKey;
setError(null);
}
const validateFile = React.useCallback(
(file: File): string | null => {
if (maxFileSize && file.size > maxFileSize) {
return `File size exceeds ${formatFileSize(maxFileSize)}`;
}
if (acceptedTypes) {
const fileExtension = `.${file.name.split(".").pop()}`;
const isAccepted = acceptedTypes.some((type) => {
if (type.endsWith("/*")) {
const baseType = type.slice(0, -2);
return file.type.startsWith(`${baseType}/`);
}
if (type.startsWith(".")) {
return fileExtension.toLowerCase() === type.toLowerCase();
}
return file.type === type;
});
if (!isAccepted) {
return "File type not accepted";
}
}
return null;
},
[maxFileSize, acceptedTypes],
);
const addFiles = React.useCallback(
async (newFiles: File[], skipUpload = false) => {
if (readOnly || isPending) return;
setError(null);
if (maxFiles && files.length + newFiles.length > maxFiles) {
const errorMessage = `Maximum ${maxFiles} files allowed`;
setError(errorMessage);
toast(errorMessage);
setTimeout(() => {
setError(null);
}, 2000);
return;
}
const rejectedFiles: Array<{ name: string; reason: string }> = [];
const filesToValidate: File[] = [];
for (const file of newFiles) {
const validationError = validateFile(file);
if (validationError) {
rejectedFiles.push({ name: file.name, reason: validationError });
continue;
}
filesToValidate.push(file);
}
if (rejectedFiles.length > 0) {
const firstError = rejectedFiles[0];
if (firstError) {
setError(firstError.reason);
const truncatedName =
firstError.name.length > 20
? `${firstError.name.slice(0, 20)}...`
: firstError.name;
if (rejectedFiles.length === 1) {
toast(firstError.reason, {
description: `"${truncatedName}" has been rejected`,
});
} else {
toast(firstError.reason, {
description: `"${truncatedName}" and ${rejectedFiles.length - 1} more rejected`,
});
}
setTimeout(() => {
setError(null);
}, 2000);
}
}
if (filesToValidate.length > 0) {
if (!skipUpload) {
const tempFiles = filesToValidate.map((f) => ({
id: crypto.randomUUID(),
name: f.name,
size: f.size,
type: f.type,
url: undefined,
}));
const filesWithTemp = [...files, ...tempFiles];
setFiles(filesWithTemp);
const uploadingIds = new Set(tempFiles.map((f) => f.id));
setUploadingFiles(uploadingIds);
let uploadedFiles: FileCellData[] = [];
if (tableMeta?.onFilesUpload) {
try {
uploadedFiles = await tableMeta.onFilesUpload({
files: filesToValidate,
rowId,
columnId,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: `Failed to upload ${filesToValidate.length} file${filesToValidate.length !== 1 ? "s" : ""}`,
);
setFiles((prev) => prev.filter((f) => !uploadingIds.has(f.id)));
setUploadingFiles(new Set());
return;
}
} else {
uploadedFiles = filesToValidate.map((f, i) => ({
id: tempFiles[i]?.id ?? crypto.randomUUID(),
name: f.name,
size: f.size,
type: f.type,
url: URL.createObjectURL(f),
}));
}
const finalFiles = filesWithTemp
.map((f) => {
if (uploadingIds.has(f.id)) {
return uploadedFiles.find((uf) => uf.name === f.name) ?? f;
}
return f;
})
.filter((f) => f.url !== undefined);
setFiles(finalFiles);
setUploadingFiles(new Set());
tableMeta?.onDataUpdate?.({ rowId, columnId, value: finalFiles });
} else {
const newFilesData: FileCellData[] = filesToValidate.map((f) => ({
id: crypto.randomUUID(),
name: f.name,
size: f.size,
type: f.type,
url: URL.createObjectURL(f),
}));
const updatedFiles = [...files, ...newFilesData];
setFiles(updatedFiles);
tableMeta?.onDataUpdate?.({
rowId,
columnId,
value: updatedFiles,
});
}
}
},
[
files,
maxFiles,
validateFile,
tableMeta,
rowId,
columnId,
readOnly,
isPending,
],
);
const removeFile = React.useCallback(
async (fileId: string) => {
if (readOnly || isPending) return;
setError(null);
const fileToRemove = files.find((f) => f.id === fileId);
if (!fileToRemove) return;
setDeletingFiles((prev) => new Set(prev).add(fileId));
if (tableMeta?.onFilesDelete) {
try {
await tableMeta.onFilesDelete({
fileIds: [fileId],
rowId,
columnId,
});
} catch (error) {
toast.error(
error instanceof Error
? error.message
: `Failed to delete ${fileToRemove.name}`,
);
setDeletingFiles((prev) => {
const next = new Set(prev);
next.delete(fileId);
return next;
});
return;
}
}
if (fileToRemove.url?.startsWith("blob:")) {
URL.revokeObjectURL(fileToRemove.url);
}
const updatedFiles = files.filter((f) => f.id !== fileId);
setFiles(updatedFiles);
setDeletingFiles((prev) => {
const next = new Set(prev);
next.delete(fileId);
return next;
});
tableMeta?.onDataUpdate?.({ rowId, columnId, value: updatedFiles });
},
[files, tableMeta, rowId, columnId, readOnly, isPending],
);
const clearAll = React.useCallback(async () => {
if (readOnly || isPending) return;
setError(null);
const fileIds = files.map((f) => f.id);
setDeletingFiles(new Set(fileIds));
if (tableMeta?.onFilesDelete && files.length > 0) {
try {
await tableMeta.onFilesDelete({
fileIds,
rowId,
columnId,
});
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to delete files",
);
setDeletingFiles(new Set());
return;
}
}
for (const file of files) {
if (file.url?.startsWith("blob:")) {
URL.revokeObjectURL(file.url);
}
}
setFiles([]);
setDeletingFiles(new Set());
tableMeta?.onDataUpdate?.({ rowId, columnId, value: [] });
}, [files, tableMeta, rowId, columnId, readOnly, isPending]);
const onCellDragEnter = React.useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
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) {
void addFiles(droppedFiles, false);
}
},
[addFiles],
);
const onDropzoneDragEnter = React.useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(true);
}, []);
const onDropzoneDragLeave = 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 onDropzoneDragOver = React.useCallback((event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
}, []);
const onDropzoneDrop = React.useCallback(
(event: React.DragEvent) => {
event.preventDefault();
event.stopPropagation();
setIsDragging(false);
const droppedFiles = Array.from(event.dataTransfer.files);
void addFiles(droppedFiles, false);
},
[addFiles],
);
const onDropzoneClick = React.useCallback(() => {
fileInputRef.current?.click();
}, []);
const onDropzoneKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onDropzoneClick();
}
},
[onDropzoneClick],
);
const onFileInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(event.target.files ?? []);
void addFiles(selectedFiles, false);
event.target.value = "";
},
[addFiles],
);
const onOpenChange = React.useCallback(
(open: boolean) => {
if (open && !readOnly) {
setError(null);
tableMeta?.onCellEditingStart?.(rowId, columnId);
} else {
setError(null);
tableMeta?.onCellEditingStop?.();
}
},
[tableMeta, rowId, columnId, readOnly],
);
const onEditorKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
// Prevent the escape key from propagating to the data grid's keyboard handler
// which would call blurCell() and remove focus from the cell
if (event.key === "Escape") event.stopPropagation();
},
[],
);
const onInitialFocus = React.useCallback(() => {
queueMicrotask(() => {
dropzoneRef.current?.focus();
});
return false;
}, []);
const onWrapperKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (isEditing) {
if (event.key === "Escape") {
event.preventDefault();
setFiles(cellValue);
setError(null);
tableMeta?.onCellEditingStop?.();
} else if (event.key === " ") {
event.preventDefault();
onDropzoneClick();
} else if (event.key === "Tab") {
event.preventDefault();
tableMeta?.onCellEditingStop?.({
direction: event.shiftKey ? "shift+tab" : "tab",
});
}
} else if (isFocused && event.key === "Enter") {
event.preventDefault();
tableMeta?.onCellEditingStart?.(rowId, columnId);
}
},
[
isEditing,
isFocused,
cellValue,
tableMeta,
onDropzoneClick,
rowId,
columnId,
],
);
React.useEffect(() => {
return () => {
for (const file of files) {
if (file.url) {
URL.revokeObjectURL(file.url);
}
}
};
}, [files]);
const lineCount = getLineCount(rowHeight);
const { visibleItems: visibleFiles, hiddenCount: hiddenFileCount } =
useBadgeOverflow({
items: files,
getLabel: (file) => file.name,
containerRef,
lineCount,
cacheKeyPrefix: "file",
iconSize: 12,
maxWidth: 100,
});
return (
<DataGridCellWrapper<TData>
ref={containerRef}
cell={cell}
tableMeta={tableMeta}
rowId={rowId}
columnId={columnId}
rowHeight={rowHeight}
isEditing={isEditing}
isFocused={isFocused}
isSelected={isSelected}
isSearchMatch={isSearchMatch}
isActiveSearchMatch={isActiveSearchMatch}
readOnly={readOnly}
className={cn({
"ring-1 ring-primary/80 ring-inset": isDraggingOver,
})}
onDragEnter={onCellDragEnter}
onDragLeave={onCellDragLeave}
onDragOver={onCellDragOver}
onDrop={onCellDrop}
onKeyDown={onWrapperKeyDown}
>
{isEditing ? (
<Popover open={isEditing} onOpenChange={onOpenChange}>
<PopoverTrigger
nativeButton={false}
render={<div className="pointer-events-none absolute inset-0" />}
/>
<PopoverContent
data-grid-cell-editor=""
align="start"
sideOffset={sideOffset}
className="w-100 rounded-none p-0"
initialFocus={onInitialFocus}
onKeyDown={onEditorKeyDown}
>
<div className="flex flex-col gap-2 p-3">
<span id={labelId} className="sr-only">
File upload
</span>
<div
role="region"
aria-labelledby={labelId}
aria-describedby={descriptionId}
aria-invalid={!!error}
aria-disabled={isPending}
data-dragging={isDragging ? "" : undefined}
data-invalid={error ? "" : undefined}
data-disabled={isPending ? "" : undefined}
tabIndex={isDragging || isPending ? -1 : 0}
className="flex cursor-pointer flex-col items-center justify-center gap-2 rounded-md border-2 border-dashed p-6 transition-colors outline-none hover:bg-accent/30 focus-visible:border-ring/50 data-dragging:border-primary/30 data-dragging:bg-accent/30 data-invalid:border-destructive data-invalid:ring-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50"
ref={dropzoneRef}
onClick={onDropzoneClick}
onDragEnter={onDropzoneDragEnter}
onDragLeave={onDropzoneDragLeave}
onDragOver={onDropzoneDragOver}
onDrop={onDropzoneDrop}
onKeyDown={onDropzoneKeyDown}
>
<IconPlaceholder
lucide="Upload"
tabler="IconCloudUpload"
hugeicons="CloudUploadIcon"
phosphor="CloudArrowUpIcon"
remixicon="RiUploadCloudLine"
className="size-8 text-muted-foreground"
/>
<div className="text-center text-sm">
<p className="font-medium">
{isDragging ? "Drop files here" : "Drag files here"}
</p>
<p className="text-xs text-muted-foreground">
or click to browse
</p>
</div>
<p id={descriptionId} className="text-xs text-muted-foreground">
{maxFileSize
? `Max size: ${formatFileSize(maxFileSize)}${maxFiles ? ` • Max ${maxFiles} files` : ""}`
: maxFiles
? `Max ${maxFiles} files`
: "Select files to upload"}
</p>
</div>
<input
type="file"
aria-labelledby={labelId}
aria-describedby={descriptionId}
multiple={multiple}
accept={accept}
className="sr-only"
ref={fileInputRef}
onChange={onFileInputChange}
/>
{files.length > 0 && (
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium text-muted-foreground">
{files.length} {files.length === 1 ? "file" : "files"}
</p>
<Button
type="button"
variant="ghost"
className="h-6 text-xs text-muted-foreground"
onClick={clearAll}
disabled={isPending}
>
Clear all
</Button>
</div>
<div className="max-h-50 space-y-1 overflow-y-auto">
{files.map((file) => {
const FileIcon = getFileIcon(file.type);
const isFileUploading = uploadingFiles.has(file.id);
const isFileDeleting = deletingFiles.has(file.id);
const isFilePending = isFileUploading || isFileDeleting;
return (
<div
key={file.id}
data-pending={isFilePending ? "" : undefined}
className="flex items-center gap-2 rounded-md border bg-muted/50 px-2 py-1.5 data-pending:opacity-60"
>
{FileIcon && (
<FileIcon className="size-4 shrink-0 text-muted-foreground" />
)}
<div className="flex-1 overflow-hidden">
<p className="truncate text-sm">{file.name}</p>
<p className="text-xs text-muted-foreground">
{isFileUploading
? "Uploading..."
: isFileDeleting
? "Deleting..."
: formatFileSize(file.size)}
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon"
className="size-5 rounded-sm"
onClick={() => removeFile(file.id)}
disabled={isPending}
>
<IconPlaceholder
lucide="X"
tabler="IconX"
hugeicons="Cancel01Icon"
phosphor="XIcon"
remixicon="RiCloseLine"
className="size-3"
/>
</Button>
</div>
);
})}
</div>
</div>
)}
</div>
</PopoverContent>
</Popover>
) : null}
{isDraggingOver ? (
<div className="flex items-center justify-center gap-2 text-sm text-primary">
<IconPlaceholder
lucide="Upload"
tabler="IconCloudUpload"
hugeicons="CloudUploadIcon"
phosphor="CloudArrowUpIcon"
remixicon="RiUploadCloudLine"
className="size-4"
/>
<span>Drop files here</span>
</div>
) : files.length > 0 ? (
<div className="flex flex-wrap items-center gap-1 overflow-hidden">
{visibleFiles.map((file) => {
const isUploading = uploadingFiles.has(file.id);
if (isUploading) {
return (
<Skeleton
key={file.id}
className="h-5 shrink-0 px-1.5"
style={{
width: `${Math.min(file.name.length * 8 + 30, 100)}px`,
}}
/>
);
}
const FileIcon = getFileIcon(file.type);
return (
<Badge
key={file.id}
variant="secondary"
className="gap-1 px-1.5 py-px"
>
{FileIcon && <FileIcon className="size-3 shrink-0" />}
<span className="max-w-25 truncate">{file.name}</span>
</Badge>
);
})}
{hiddenFileCount > 0 && (
<Badge
variant="outline"
className="px-1.5 py-px text-muted-foreground"
>
+{hiddenFileCount}
</Badge>
)}
</div>
) : null}
</DataGridCellWrapper>
);
}
function toastDangerousUrl() {
toast.error("Invalid URL", {
description:
"URL contains a dangerous protocol (javascript:, data:, vbscript:, or file:)",
});
}
src/registry/bases/base/components/data-grid/data-grid-column-header.tsx
"use client";
import type {
Column,
ColumnSort,
Header,
RowData,
SortDirection,
SortingState,
Table,
} from "@tanstack/react-table";
import { cn } from "cn";
import * as React from "react";
import type { DataGridFeatures } from "@/lib/data-grid-features";
import { getBadgeListWidth } from "@/hooks/use-badge-overflow";
import {
getColumnFitSize,
getColumnVariant,
getIsFileCellData,
} from "@/lib/data-grid-utils";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/registry/bases/base/ui/dropdown-menu";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/registry/bases/base/ui/tooltip";
import { IconPlaceholder } from "@/registry/icons/icon-placeholder";
interface DataGridColumnHeaderProps<
TData extends RowData,
TValue,
> extends React.ComponentProps<typeof DropdownMenuTrigger> {
header: Header<DataGridFeatures, TData, TValue>;
table: Table<DataGridFeatures, TData>;
}
export function DataGridColumnHeader<TData extends RowData, TValue>({
header,
table,
className,
onPointerDown,
...props
}: DataGridColumnHeaderProps<TData, TValue>) {
const column = header.column;
const label = column.columnDef.meta?.label
? column.columnDef.meta.label
: typeof column.columnDef.header === "string"
? column.columnDef.header
: column.id;
const isAnyColumnResizing = table.store.state.columnResizing.isResizingColumn;
const cellVariant = column.columnDef.meta?.cell;
const columnVariant = getColumnVariant(cellVariant?.variant);
const pinnedPosition = column.getIsPinned();
const isPinnedLeft = pinnedPosition === "start";
const isPinnedRight = pinnedPosition === "end";
const onSortingChange = React.useCallback(
(direction: SortDirection) => {
table.setSorting((prev: SortingState) => {
const existingSortIndex = prev.findIndex(
(sort) => sort.id === column.id,
);
const newSort: ColumnSort = {
id: column.id,
desc: direction === "desc",
};
if (existingSortIndex >= 0) {
const updated = [...prev];
updated[existingSortIndex] = newSort;
return updated;
} else {
return [...prev, newSort];
}
});
},
[column.id, table],
);
const onSortRemove = React.useCallback(() => {
table.setSorting((prev: SortingState) =>
prev.filter((sort) => sort.id !== column.id),
);
}, [column.id, table]);
const onLeftPin = React.useCallback(() => {
column.pin("start");
}, [column]);
const onRightPin = React.useCallback(() => {
column.pin("end");
}, [column]);
const onUnpin = React.useCallback(() => {
column.pin(false);
}, [column]);
const onTriggerPointerDown = React.useCallback<
NonNullable<
React.ComponentProps<typeof DropdownMenuTrigger>["onPointerDown"]
>
>(
(event) => {
onPointerDown?.(event);
if (event.defaultPrevented) return;
if (event.button !== 0) {
return;
}
table.options.meta?.onColumnClick?.(column.id);
},
[table.options.meta, column.id, onPointerDown],
);
return (
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
tabIndex={-1}
className={cn(
"flex size-full items-center justify-between gap-2 p-2 text-sm outline-none hover:bg-accent/40 focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset data-open:bg-accent/40 [&_svg]:size-4",
isAnyColumnResizing && "pointer-events-none",
className,
)}
onPointerDown={onTriggerPointerDown}
{...props}
>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{columnVariant && (
<TooltipProvider delay={100}>
<Tooltip>
<TooltipTrigger
render={
<columnVariant.icon className="size-3.5 shrink-0 text-muted-foreground" />
}
/>
<TooltipContent side="top">
<p>{columnVariant.label}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<span className="truncate">{label}</span>
</div>
<IconPlaceholder
lucide="ChevronDownIcon"
tabler="IconChevronDown"
hugeicons="ArrowDown01Icon"
phosphor="CaretDownIcon"
remixicon="RiArrowDownSLine"
className="shrink-0 text-muted-foreground"
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" sideOffset={0} className="w-60">
{column.getCanSort() && (
<>
<DropdownMenuCheckboxItem
className="relative ltr:pr-8 ltr:pl-2 rtl:pr-2 rtl:pl-8 [&_svg]:text-muted-foreground [&>span:first-child]:ltr:right-2 [&>span:first-child]:ltr:left-auto [&>span:first-child]:rtl:right-auto [&>span:first-child]:rtl:left-2"
checked={column.getIsSorted() === "asc"}
onClick={() => onSortingChange("asc")}
>
<IconPlaceholder
lucide="ChevronUpIcon"
tabler="IconChevronUp"
hugeicons="ArrowUp01Icon"
phosphor="CaretUpIcon"
remixicon="RiArrowUpSLine"
/>
Sort asc
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
className="relative ltr:pr-8 ltr:pl-2 rtl:pr-2 rtl:pl-8 [&_svg]:text-muted-foreground [&>span:first-child]:ltr:right-2 [&>span:first-child]:ltr:left-auto [&>span:first-child]:rtl:right-auto [&>span:first-child]:rtl:left-2"
checked={column.getIsSorted() === "desc"}
onClick={() => onSortingChange("desc")}
>
<IconPlaceholder
lucide="ChevronDownIcon"
tabler="IconChevronDown"
hugeicons="ArrowDown01Icon"
phosphor="CaretDownIcon"
remixicon="RiArrowDownSLine"
/>
Sort desc
</DropdownMenuCheckboxItem>
{column.getIsSorted() && (
<DropdownMenuItem onClick={onSortRemove}>
<IconPlaceholder
lucide="XIcon"
tabler="IconX"
hugeicons="Cancel01Icon"
phosphor="XIcon"
remixicon="RiCloseLine"
/>
Remove sort
</DropdownMenuItem>
)}
</>
)}
{column.getCanPin() && (
<>
{column.getCanSort() && <DropdownMenuSeparator />}
{isPinnedLeft ? (
<DropdownMenuItem
className="[&_svg]:text-muted-foreground"
onClick={onUnpin}
>
<IconPlaceholder
lucide="PinOffIcon"
tabler="IconPinnedOff"
hugeicons="BookmarkIcon"
phosphor="PushPinSlashIcon"
remixicon="RiUnpinLine"
/>
Unpin from left
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="[&_svg]:text-muted-foreground"
onClick={onLeftPin}
>
<IconPlaceholder
lucide="PinIcon"
tabler="IconPin"
hugeicons="BookmarkIcon"
phosphor="PushPinIcon"
remixicon="RiPushpinLine"
/>
Pin to left
</DropdownMenuItem>
)}
{isPinnedRight ? (
<DropdownMenuItem
className="[&_svg]:text-muted-foreground"
onClick={onUnpin}
>
<IconPlaceholder
lucide="PinOffIcon"
tabler="IconPinnedOff"
hugeicons="BookmarkIcon"
phosphor="PushPinSlashIcon"
remixicon="RiUnpinLine"
/>
Unpin from right
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="[&_svg]:text-muted-foreground"
onClick={onRightPin}
>
<IconPlaceholder
lucide="PinIcon"
tabler="IconPin"
hugeicons="BookmarkIcon"
phosphor="PushPinIcon"
remixicon="RiPushpinLine"
/>
Pin to right
</DropdownMenuItem>
)}
</>
)}
{column.getCanHide() && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="[&_svg]:text-muted-foreground"
onClick={() => column.toggleVisibility(false)}
>
<IconPlaceholder
lucide="EyeOffIcon"
tabler="IconEyeClosed"
hugeicons="ViewOffIcon"
phosphor="EyeSlashIcon"
remixicon="RiEyeOffLine"
/>
Hide column
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
{header.column.getCanResize() && (
<DataGridColumnResizer header={header} table={table} label={label} />
)}
</>
);
}
const DataGridColumnResizer = React.memo(
DataGridColumnResizerImpl,
(prev, next) => {
const prevColumn = prev.header.column;
const nextColumn = next.header.column;
if (
prevColumn.getIsResizing() !== nextColumn.getIsResizing() ||
prevColumn.getSize() !== nextColumn.getSize()
) {
return false;
}
if (prev.label !== next.label) return false;
return true;
},
) as typeof DataGridColumnResizerImpl;
interface DataGridColumnResizerProps<
TData extends RowData,
TValue,
> extends DataGridColumnHeaderProps<TData, TValue> {
label: string;
}
function DataGridColumnResizerImpl<TData extends RowData, TValue>({
header,
table,
label,
}: DataGridColumnResizerProps<TData, TValue>) {
const defaultColumnDef = table.getDefaultColumnDef();
const onDoubleClick = React.useCallback(
(event: React.MouseEvent<HTMLDivElement>) => {
const column = header.column;
const gridElement =
event.currentTarget.closest<HTMLElement>('[role="grid"]');
const fitSize = gridElement
? getColumnFitSize({
gridElement,
columnId: column.id,
minSize: column.columnDef.minSize ?? defaultColumnDef.minSize ?? 0,
maxSize:
column.columnDef.maxSize ??
defaultColumnDef.maxSize ??
Number.POSITIVE_INFINITY,
wrapperContentSize: getBadgeColumnContentSize(column, table),
})
: null;
if (fitSize === null) {
column.resetSize();
return;
}
table.setColumnSizing((prev) => ({ ...prev, [column.id]: fitSize }));
},
[header.column, table, defaultColumnDef],
);
return (
<div
role="separator"
aria-orientation="vertical"
aria-label={`Resize ${label} column`}
aria-valuenow={header.column.getSize()}
aria-valuemin={defaultColumnDef.minSize}
aria-valuemax={defaultColumnDef.maxSize}
tabIndex={-1}
className={cn(
"absolute -inset-e-px top-0 z-10 h-full w-0.5 cursor-ew-resize touch-none bg-border transition-opacity select-none after:absolute after:inset-y-0 after:inset-s-1/2 after:h-full after:w-4.5 after:-translate-x-1/2 after:content-[''] hover:bg-primary focus:bg-primary focus:outline-none",
header.column.getIsResizing()
? "bg-primary"
: "opacity-0 hover:opacity-100",
)}
onDoubleClick={onDoubleClick}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
/>
);
}
function getBadgeColumnContentSize<TData extends RowData, TValue>(
column: Column<DataGridFeatures, TData, TValue>,
table: Table<DataGridFeatures, TData>,
): number {
const cellOpts = column.columnDef.meta?.cell;
if (cellOpts?.variant !== "multi-select" && cellOpts?.variant !== "file") {
return 0;
}
const labelByValue = new Map(
cellOpts.variant === "multi-select"
? cellOpts.options.map((option) => [option.value, option.label])
: [],
);
let contentSize = 0;
for (const row of table.getRowModel().rows) {
const value = row.getValue(column.id);
if (!Array.isArray(value)) continue;
const rowContentSize =
cellOpts.variant === "multi-select"
? getBadgeListWidth({
items: value
.filter((item): item is string => typeof item === "string")
.map((item) => labelByValue.get(item) ?? item)
.filter(Boolean),
getLabel: (label) => label,
})
: getBadgeListWidth({
items: value.filter(getIsFileCellData),
getLabel: (file) => file.name,
cacheKeyPrefix: "file",
iconSize: 12,
maxWidth: 100,
});
contentSize = Math.max(contentSize, rowContentSize);
}
return contentSize;
}
src/registry/bases/base/components/data-grid/data-grid-context-menu.tsx
"use client";
import type { ColumnDef, RowData } from "@tanstack/react-table";
import { useValueAsRef } from "@base-ui/utils/useValueAsRef";
import * as React from "react";
import { toast } from "sonner";
import type { DataGridFeatures } from "@/lib/data-grid-features";
import type {
CellUpdate,
ContextMenuState,
DataGridTableMeta,
} from "@/lib/data-grid-types";
import { getEmptyCellValue, parseCellKey } from "@/lib/data-grid-utils";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/registry/bases/base/ui/dropdown-menu";
import { IconPlaceholder } from "@/registry/icons/icon-placeholder";
interface DataGridContextMenuProps<TData extends RowData> {
tableMeta: DataGridTableMeta;
columns: ReadonlyArray<ColumnDef<DataGridFeatures, TData>>;
contextMenu: ContextMenuState;
}
export function DataGridContextMenu<TData extends RowData>({
tableMeta,
columns,
contextMenu,
}: DataGridContextMenuProps<TData>) {
const onContextMenuOpenChange = tableMeta?.onContextMenuOpenChange;
const selectionState = tableMeta?.selectionState;
const dataGridRef = tableMeta?.dataGridRef;
const onDataUpdate = tableMeta?.onDataUpdate;
const onRowsDelete = tableMeta?.onRowsDelete;
const onCellsCopy = tableMeta?.onCellsCopy;
const onCellsCut = tableMeta?.onCellsCut;
if (!contextMenu.open) return null;
return (
<ContextMenu
tableMeta={tableMeta}
columns={columns}
dataGridRef={dataGridRef}
contextMenu={contextMenu}
onContextMenuOpenChange={onContextMenuOpenChange}
selectionState={selectionState}
onDataUpdate={onDataUpdate}
onRowsDelete={onRowsDelete}
onCellsCopy={onCellsCopy}
onCellsCut={onCellsCut}
/>
);
}
interface ContextMenuProps<TData extends RowData>
extends
Pick<
DataGridTableMeta,
| "dataGridRef"
| "onContextMenuOpenChange"
| "selectionState"
| "onDataUpdate"
| "onRowsDelete"
| "onCellsCopy"
| "onCellsCut"
| "readOnly"
>,
Required<Pick<DataGridTableMeta, "contextMenu">> {
tableMeta: DataGridTableMeta;
columns: ReadonlyArray<ColumnDef<DataGridFeatures, TData>>;
}
const ContextMenu = React.memo(ContextMenuImpl, (prev, next) => {
if (prev.contextMenu.open !== next.contextMenu.open) return false;
if (!next.contextMenu.open) return true;
if (prev.contextMenu.x !== next.contextMenu.x) return false;
if (prev.contextMenu.y !== next.contextMenu.y) return false;
const prevSize = prev.selectionState?.selectedCells?.size ?? 0;
const nextSize = next.selectionState?.selectedCells?.size ?? 0;
if (prevSize !== nextSize) return false;
return true;
}) as typeof ContextMenuImpl;
function ContextMenuImpl<TData extends RowData>({
tableMeta,
columns,
dataGridRef,
contextMenu,
onContextMenuOpenChange,
selectionState,
onDataUpdate,
onRowsDelete,
onCellsCopy,
onCellsCut,
}: ContextMenuProps<TData>) {
const propsRef = useValueAsRef({
dataGridRef,
selectionState,
onDataUpdate,
onRowsDelete,
onCellsCopy,
onCellsCut,
columns,
});
const triggerStyle = React.useMemo<React.CSSProperties>(
() => ({
position: "fixed",
left: `${contextMenu.x}px`,
top: `${contextMenu.y}px`,
width: "1px",
height: "1px",
padding: 0,
margin: 0,
border: "none",
background: "transparent",
pointerEvents: "none",
opacity: 0,
}),
[contextMenu.x, contextMenu.y],
);
const onFinalFocus = React.useCallback(() => {
propsRef.current.dataGridRef?.current?.focus();
return false;
}, [propsRef]);
const onCopy = React.useCallback(() => {
propsRef.current.onCellsCopy?.();
}, [propsRef]);
const onCut = React.useCallback(() => {
propsRef.current.onCellsCut?.();
}, [propsRef]);
const onClear = React.useCallback(() => {
const { selectionState, columns, onDataUpdate } = propsRef.current;
if (
!selectionState?.selectedCells ||
selectionState.selectedCells.size === 0
)
return;
const updates: Array<CellUpdate> = [];
for (const cellKey of selectionState.selectedCells) {
const { rowId, columnId } = parseCellKey(cellKey);
// Get column from columns array
const column = columns.find((col) => {
if (col.id) return col.id === columnId;
if ("accessorKey" in col) return col.accessorKey === columnId;
return false;
});
const cellVariant = column?.meta?.cell?.variant;
const emptyValue = getEmptyCellValue(cellVariant);
updates.push({ rowId, columnId, value: emptyValue });
}
onDataUpdate?.(updates);
toast.success(
`${updates.length} cell${updates.length !== 1 ? "s" : ""} cleared`,
);
}, [propsRef]);
const onDelete = React.useCallback(async () => {
const { selectionState, onRowsDelete } = propsRef.current;
if (
!selectionState?.selectedCells ||
selectionState.selectedCells.size === 0
)
return;
const rowIds = new Set<string>();
for (const cellKey of selectionState.selectedCells) {
rowIds.add(parseCellKey(cellKey).rowId);
}
const rowCount = rowIds.size;
await onRowsDelete?.(Array.from(rowIds));
toast.success(`${rowCount} row${rowCount !== 1 ? "s" : ""} deleted`);
}, [propsRef]);
return (
<DropdownMenu
open={contextMenu.open}
onOpenChange={onContextMenuOpenChange}
>
<DropdownMenuTrigger style={triggerStyle} />
<DropdownMenuContent
data-grid-popover=""
align="start"
className="w-48"
finalFocus={onFinalFocus}
>
<DropdownMenuItem onClick={onCopy}>
<IconPlaceholder
lucide="CopyIcon"
tabler="IconCopy"
hugeicons="Copy01Icon"
phosphor="CopyIcon"
remixicon="RiFileCopyLine"
/>
Copy
</DropdownMenuItem>
<DropdownMenuItem onClick={onCut} disabled={tableMeta?.readOnly}>
<IconPlaceholder
lucide="ScissorsIcon"
tabler="IconCut"
hugeicons="ScissorIcon"
phosphor="ScissorsIcon"
remixicon="RiScissorsLine"
/>
Cut
</DropdownMenuItem>
<DropdownMenuItem onClick={onClear} disabled={tableMeta?.readOnly}>
<IconPlaceholder
lucide="EraserIcon"
tabler="IconEraser"
hugeicons="DeleteIcon"
phosphor="EraserIcon"
remixicon="RiEraserLine"
/>
Clear
</DropdownMenuItem>
{onRowsDelete && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" onClick={onDelete}>
<IconPlaceholder
lucide="Trash2Icon"
tabler="IconTrash"
hugeicons="Delete02Icon"
phosphor="TrashIcon"
remixicon="RiDeleteBinLine"
/>
Delete rows
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
src/registry/bases/base/components/data-grid/data-grid-paste-dialog.tsx
"use client";
import { useValueAsRef } from "@base-ui/utils/useValueAsRef";
import { cn } from "cn";
import * as React from "react";
import type {
DataGridTableMeta,
PasteDialogState,
} from "@/lib/data-grid-types";
import { Button } from "@/registry/bases/base/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/registry/bases/base/ui/dialog";
interface DataGridPasteDialogProps {
tableMeta: DataGridTableMeta;
pasteDialog: PasteDialogState;
}
export function DataGridPasteDialog({
tableMeta,
pasteDialog,
}: DataGridPasteDialogProps) {
const onPasteDialogOpenChange = tableMeta?.onPasteDialogOpenChange;
const onCellsPaste = tableMeta?.onCellsPaste;
if (!pasteDialog.open) return null;
return (
<PasteDialog
pasteDialog={pasteDialog}
onPasteDialogOpenChange={onPasteDialogOpenChange}
onCellsPaste={onCellsPaste}
/>
);
}
interface PasteDialogProps
extends
Pick<DataGridTableMeta, "onPasteDialogOpenChange" | "onCellsPaste">,
Required<Pick<DataGridTableMeta, "pasteDialog">> {}
const PasteDialog = React.memo(PasteDialogImpl, (prev, next) => {
if (prev.pasteDialog.open !== next.pasteDialog.open) return false;
if (!next.pasteDialog.open) return true;
if (prev.pasteDialog.rowsNeeded !== next.pasteDialog.rowsNeeded) return false;
return true;
});
function PasteDialogImpl({
pasteDialog,
onPasteDialogOpenChange,
onCellsPaste,
}: PasteDialogProps) {
const propsRef = useValueAsRef({
onPasteDialogOpenChange,
onCellsPaste,
});
const expandRadioRef = React.useRef<HTMLInputElement | null>(null);
const onOpenChange = React.useCallback(
(open: boolean) => {
propsRef.current.onPasteDialogOpenChange?.(open);
},
[propsRef],
);
const onCancel = React.useCallback(() => {
propsRef.current.onPasteDialogOpenChange?.(false);
}, [propsRef]);
const onContinue = React.useCallback(() => {
propsRef.current.onCellsPaste?.(expandRadioRef.current?.checked ?? false);
}, [propsRef]);
return (
<Dialog open={pasteDialog.open} onOpenChange={onOpenChange}>
<DialogContent data-grid-popover="">
<DialogHeader>
<DialogTitle>Do you want to add more rows?</DialogTitle>
<DialogDescription>
We need <strong>{pasteDialog.rowsNeeded}</strong> additional row
{pasteDialog.rowsNeeded !== 1 ? "s" : ""} to paste everything from
your clipboard.
</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3 py-1">
<label className="flex cursor-pointer items-start gap-3">
<RadioItem
ref={expandRadioRef}
name="expand-option"
value="expand"
defaultChecked
/>
<div className="flex flex-col gap-1">
<span className="text-sm leading-none font-medium">
Create new rows
</span>
<span className="text-sm text-muted-foreground">
Add {pasteDialog.rowsNeeded} new row
{pasteDialog.rowsNeeded !== 1 ? "s" : ""} to the table and paste
all data
</span>
</div>
</label>
<label className="flex cursor-pointer items-start gap-3">
<RadioItem name="expand-option" value="no-expand" />
<div className="flex flex-col gap-1">
<span className="text-sm leading-none font-medium">
Keep current rows
</span>
<span className="text-sm text-muted-foreground">
Paste only what fits in the existing rows
</span>
</div>
</label>
</div>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button onClick={onContinue}>Continue</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function RadioItem({ className, ...props }: React.ComponentProps<"input">) {
return (
<input
type="radio"
className={cn(
"relative size-4 shrink-0 appearance-none rounded-full border border-input bg-background shadow-xs transition-[color,box-shadow] outline-none",
"text-primary focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50",
"disabled:cursor-not-allowed disabled:opacity-50",
"checked:before:absolute checked:before:inset-s-1/2 checked:before:top-1/2 checked:before:size-2 checked:before:-translate-x-1/2 checked:before:-translate-y-1/2 checked:before:rounded-full checked:before:bg-primary checked:before:content-['']",
"dark:bg-input/30",
className,
)}
{...props}
/>
);
}
src/registry/bases/base/components/data-grid/data-grid-row.tsx
"use client";
import type {
Cell,
ColumnPinningState,
ColumnVisibilityState,
Row,
RowData,
} from "@tanstack/react-table";
import type { VirtualItem } from "@tanstack/react-virtual";
import { useMergedRefs } from "@base-ui/utils/useMergedRefs";
import { cn } from "cn";
import * as React from "react";
import type { DataGridFeatures } from "@/lib/data-grid-features";
import type {
CellPosition,
DataGridTableMeta,
Direction,
RowHeightValue,
} from "@/lib/data-grid-types";
import {
flexRender,
getCellKey,
getColumnBorderVisibility,
getColumnPinningStyle,
getRowHeightValue,
} from "@/lib/data-grid-utils";
import { DataGridCell } from "@/registry/bases/base/components/data-grid/data-grid-cell";
interface DataGridRowProps<
TData extends RowData,
> extends React.ComponentProps<"div"> {
row: Row<DataGridFeatures, TData>;
tableMeta: DataGridTableMeta;
virtualItem: VirtualItem;
measureElement: (node: Element | null) => void;
rowMapRef: React.RefObject<Map<number, HTMLDivElement>>;
rowHeight: RowHeightValue;
columnVisibility: ColumnVisibilityState;
columnPinning: ColumnPinningState;
focusedCell: CellPosition | null;
editingCell: CellPosition | null;
cellSelectionKeys: Set<string>;
searchMatchColumns: Set<string> | null;
activeSearchMatch: CellPosition | null;
dir: Direction;
readOnlyColumnIds: Set<string>;
stretchColumns: boolean;
adjustLayout: boolean;
}
export const DataGridRow = React.memo(DataGridRowImpl, (prev, next) => {
const rowId = next.row.id;
// Re-render if row identity changed
if (prev.row.id !== rowId) {
return false;
}
// Re-render if the row moved, since the row map and aria-rowindex are positional
if (prev.virtualItem.index !== next.virtualItem.index) {
return false;
}
// Re-render if row data (original) reference changed
if (prev.row.original !== next.row.original) {
return false;
}
// Re-render if virtual position changed (handles transform updates)
if (prev.virtualItem.start !== next.virtualItem.start) {
return false;
}
// Re-render if focus state changed for this row
const prevHasFocus = prev.focusedCell?.rowId === rowId;
const nextHasFocus = next.focusedCell?.rowId === rowId;
if (prevHasFocus !== nextHasFocus) {
return false;
}
// Re-render if focused column changed within this row
if (nextHasFocus && prevHasFocus) {
if (prev.focusedCell?.columnId !== next.focusedCell?.columnId) {
return false;
}
}
// Re-render if editing state changed for this row
const prevHasEditing = prev.editingCell?.rowId === rowId;
const nextHasEditing = next.editingCell?.rowId === rowId;
if (prevHasEditing !== nextHasEditing) {
return false;
}
// Re-render if editing column changed within this row
if (nextHasEditing && prevHasEditing) {
if (prev.editingCell?.columnId !== next.editingCell?.columnId) {
return false;
}
}
// Re-render if this row's selected cells changed
// Using stable Set reference that only includes this row's cells
if (prev.cellSelectionKeys !== next.cellSelectionKeys) {
return false;
}
// Re-render if column visibility changed
if (prev.columnVisibility !== next.columnVisibility) {
return false;
}
// Re-render if row height changed
if (prev.rowHeight !== next.rowHeight) {
return false;
}
// Re-render if column pinning state changed
if (prev.columnPinning !== next.columnPinning) {
return false;
}
// Re-render if table or column editing permissions changed
if (prev.readOnlyColumnIds !== next.readOnlyColumnIds) {
return false;
}
// Re-render if search match columns changed for this row
if (prev.searchMatchColumns !== next.searchMatchColumns) {
return false;
}
// Re-render if active search match changed for this row
if (prev.activeSearchMatch?.columnId !== next.activeSearchMatch?.columnId) {
return false;
}
// Re-render if direction changed
if (prev.dir !== next.dir) {
return false;
}
// Re-render if adjustLayout state changed
if (prev.adjustLayout !== next.adjustLayout) {
return false;
}
// Re-render if stretchColumns changed
if (prev.stretchColumns !== next.stretchColumns) {
return false;
}
// Skip re-render - props are equal
return true;
}) as typeof DataGridRowImpl;
function DataGridRowImpl<TData extends RowData>({
row,
tableMeta,
virtualItem,
measureElement,
rowMapRef,
rowHeight,
columnVisibility,
columnPinning,
focusedCell,
editingCell,
cellSelectionKeys,
searchMatchColumns,
activeSearchMatch,
dir,
readOnlyColumnIds,
stretchColumns,
adjustLayout,
className,
style,
ref,
...props
}: DataGridRowProps<TData>) {
const virtualRowIndex = virtualItem.index;
const rowId = row.id;
const onRowChange = React.useCallback(
(node: HTMLDivElement | null) => {
if (typeof virtualRowIndex === "undefined") return;
if (node) {
measureElement(node);
rowMapRef.current?.set(virtualRowIndex, node);
} else {
rowMapRef.current?.delete(virtualRowIndex);
}
},
[virtualRowIndex, measureElement, rowMapRef],
);
const rowRef = useMergedRefs(ref, onRowChange);
const isRowSelected = row.getIsSelected();
// Memoize visible cells to avoid recreating cell array on every render
// Though TanStack returns new Cell wrappers, memoizing the array helps React's reconciliation
const visibleCells = React.useMemo(
() => row.getVisibleCells(),
[row, columnVisibility, columnPinning],
);
return (
<div
key={row.id}
role="row"
aria-rowindex={virtualRowIndex + 2}
aria-selected={isRowSelected}
data-index={virtualRowIndex}
data-slot="grid-row"
tabIndex={-1}
{...props}
ref={rowRef}
className={cn(
"absolute flex w-full border-b [content-visibility:auto]",
!adjustLayout && "will-change-transform",
className,
)}
style={{
height: `${getRowHeightValue(rowHeight)}px`,
...(adjustLayout
? { top: `${virtualItem.start}px` }
: { transform: `translateY(${virtualItem.start}px)` }),
...style,
}}
>
{visibleCells.map((cell, colIndex) => {
const columnId = cell.column.id;
const isCellFocused =
focusedCell?.rowId === rowId && focusedCell?.columnId === columnId;
const isCellEditing =
editingCell?.rowId === rowId && editingCell?.columnId === columnId;
const isCellSelected =
cellSelectionKeys?.has(getCellKey(rowId, columnId)) ?? false;
const isSearchMatch = searchMatchColumns?.has(columnId) ?? false;
const isActiveSearchMatch = activeSearchMatch?.columnId === columnId;
const nextCell = visibleCells[colIndex + 1];
const isLastColumn = colIndex === visibleCells.length - 1;
const { showEndBorder, showStartBorder } = getColumnBorderVisibility({
column: cell.column,
nextColumn: nextCell?.column,
isLastColumn,
});
return (
<div
key={cell.id}
role="gridcell"
aria-colindex={colIndex + 1}
data-slot="grid-cell"
data-column-id={columnId}
data-highlighted={isCellFocused ? "" : undefined}
tabIndex={-1}
className={cn({
grow: stretchColumns && columnId !== "select",
"border-e": showEndBorder && columnId !== "select",
"border-s": showStartBorder && columnId !== "select",
})}
style={{
...getColumnPinningStyle({ column: cell.column, dir }),
width: `calc(var(--col-${columnId}-size) * 1px)`,
}}
>
{typeof cell.column.columnDef.header === "function" ? (
<DataGridUtilityCell
cell={cell}
tableMeta={tableMeta}
rowId={rowId}
columnId={columnId}
isFocused={isCellFocused}
isRowSelected={isRowSelected}
/>
) : (
<DataGridCell
cell={cell}
tableMeta={tableMeta}
rowId={rowId}
columnId={columnId}
rowHeight={rowHeight}
isFocused={isCellFocused}
isEditing={isCellEditing}
isSelected={isCellSelected}
isSearchMatch={isSearchMatch}
isActiveSearchMatch={isActiveSearchMatch}
readOnly={readOnlyColumnIds.has(columnId)}
/>
)}
</div>
);
})}
</div>
);
}
interface DataGridUtilityCellProps<TData extends RowData> {
cell: Cell<DataGridFeatures, TData>;
tableMeta: DataGridTableMeta;
rowId: string;
columnId: string;
isFocused: boolean;
isRowSelected: boolean;
}
function DataGridUtilityCell<TData extends RowData>({
cell,
tableMeta,
rowId,
columnId,
isFocused,
isRowSelected,
}: DataGridUtilityCellProps<TData>) {
const cellMapRef = tableMeta?.cellMapRef;
const onCellChange = React.useCallback(
(node: HTMLDivElement | null) => {
if (!cellMapRef) return;
const cellKey = getCellKey(rowId, columnId);
if (node) {
cellMapRef.current.set(cellKey, node);
} else {
cellMapRef.current.delete(cellKey);
}
},
[rowId, columnId, cellMapRef],
);
return (
<div
data-slot="grid-utility-cell"
data-focused={isFocused ? "" : undefined}
tabIndex={-1}
ref={onCellChange}
className={cn("size-full px-3 py-1.5 outline-none", {
"bg-primary/10": isRowSelected,
"ring-1 ring-ring ring-inset": isFocused,
})}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
);
}
src/registry/bases/base/components/data-grid/data-grid-search.tsx
"use client";
import { useValueAsRef } from "@base-ui/utils/useValueAsRef";
import * as React from "react";
import type { SearchState } from "@/lib/data-grid-types";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { Button } from "@/registry/bases/base/ui/button";
import { Input } from "@/registry/bases/base/ui/input";
import { IconPlaceholder } from "@/registry/icons/icon-placeholder";
function onTriggerPointerDown(event: React.PointerEvent<HTMLButtonElement>) {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (target.hasPointerCapture(event.pointerId)) {
target.releasePointerCapture(event.pointerId);
}
// Prevent the trigger from stealing focus away from the input
if (
event.button === 0 &&
event.ctrlKey === false &&
event.pointerType === "mouse" &&
!(event.target instanceof HTMLInputElement)
) {
event.preventDefault();
}
}
interface DataGridSearchProps extends SearchState {}
export const DataGridSearch = React.memo(DataGridSearchImpl, (prev, next) => {
if (prev.searchOpen !== next.searchOpen) return false;
if (!next.searchOpen) return true;
// Exclude searchQuery because the input is uncontrolled, and hasQuery state handles the status text
if (prev.matchIndex !== next.matchIndex) return false;
if (prev.searchMatches.length !== next.searchMatches.length) return false;
for (let i = 0; i < prev.searchMatches.length; i++) {
const prevMatch = prev.searchMatches[i];
const nextMatch = next.searchMatches[i];
if (!prevMatch || !nextMatch) return false;
if (
prevMatch.rowId !== nextMatch.rowId ||
prevMatch.columnId !== nextMatch.columnId
) {
return false;
}
}
return true;
});
function DataGridSearchImpl({
searchMatches,
matchIndex,
searchOpen,
onSearchOpenChange,
searchQuery,
onSearchQueryChange,
onSearch,
onNavigateToNextMatch,
onNavigateToPrevMatch,
}: DataGridSearchProps) {
const propsRef = useValueAsRef({
onSearchOpenChange,
onSearchQueryChange,
onSearch,
onNavigateToNextMatch,
onNavigateToPrevMatch,
});
const inputRef = React.useRef<HTMLInputElement>(null);
const isComposingRef = React.useRef(false);
const [hasQuery, setHasQuery] = React.useState(searchQuery.length > 0);
React.useEffect(() => {
if (searchOpen) {
requestAnimationFrame(() => {
inputRef.current?.focus();
});
return;
}
isComposingRef.current = false;
setHasQuery(false);
}, [searchOpen]);
React.useEffect(() => {
if (!searchOpen) return;
function onEscape(event: KeyboardEvent) {
if (event.key === "Escape") {
event.preventDefault();
propsRef.current.onSearchOpenChange(false);
}
}
document.addEventListener("keydown", onEscape);
return () => document.removeEventListener("keydown", onEscape);
}, [searchOpen, propsRef]);
const debouncedSearch = useDebouncedCallback((query: string) => {
propsRef.current.onSearch(query);
}, 150);
function onCompositionStart() {
isComposingRef.current = true;
}
function onCompositionEnd(event: React.CompositionEvent<HTMLInputElement>) {
isComposingRef.current = false;
const value = event.currentTarget.value;
setHasQuery(value.length > 0);
propsRef.current.onSearchQueryChange(value);
debouncedSearch(value);
}
function onKeyDown(event: React.KeyboardEvent) {
event.stopPropagation();
if (event.key === "Enter") {
if (event.nativeEvent.isComposing) return;
event.preventDefault();
if (event.shiftKey) {
propsRef.current.onNavigateToPrevMatch();
} else {
propsRef.current.onNavigateToNextMatch();
}
}
}
function onChange(event: React.ChangeEvent<HTMLInputElement>) {
if (isComposingRef.current) return;
const value = event.target.value;
setHasQuery(value.length > 0);
propsRef.current.onSearchQueryChange(value);
debouncedSearch(value);
}
function onClose() {
propsRef.current.onSearchOpenChange(false);
}
function onPrevMatch() {
propsRef.current.onNavigateToPrevMatch();
}
function onNextMatch() {
propsRef.current.onNavigateToNextMatch();
}
if (!searchOpen) return null;
return (
<div
role="search"
data-slot="grid-search"
className="absolute inset-e-4 top-4 z-50 flex animate-in flex-col gap-2 rounded-lg border bg-background p-2 shadow-lg fade-in-0 slide-in-from-top-2"
>
<div className="flex items-center gap-2">
<Input
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck={false}
placeholder="Find in table..."
className="w-64"
ref={inputRef}
defaultValue={searchQuery}
onChange={onChange}
onKeyDown={onKeyDown}
onCompositionStart={onCompositionStart}
onCompositionEnd={onCompositionEnd}
/>
<div className="flex items-center gap-1">
<Button
aria-label="Previous match"
variant="ghost"
size="icon"
className="size-7"
onClick={onPrevMatch}
onPointerDown={onTriggerPointerDown}
disabled={searchMatches.length === 0}
>
<IconPlaceholder
lucide="ChevronUp"
tabler="IconChevronUp"
hugeicons="ArrowUp01Icon"
phosphor="CaretUpIcon"
remixicon="RiArrowUpSLine"
/>
</Button>
<Button
aria-label="Next match"
variant="ghost"
size="icon"
className="size-7"
onClick={onNextMatch}
onPointerDown={onTriggerPointerDown}
disabled={searchMatches.length === 0}
>
<IconPlaceholder
lucide="ChevronDown"
tabler="IconChevronDown"
hugeicons="ArrowDown01Icon"
phosphor="CaretDownIcon"
remixicon="RiArrowDownSLine"
/>
</Button>
<Button
aria-label="Close search"
variant="ghost"
size="icon"
className="size-7"
onClick={onClose}
>
<IconPlaceholder
lucide="X"
tabler="IconX"
hugeicons="Cancel01Icon"
phosphor="XIcon"
remixicon="RiCloseLine"
/>
</Button>
</div>
</div>
<div className="flex items-center gap-1 text-xs whitespace-nowrap text-muted-foreground">
{searchMatches.length > 0 ? (
<span>
{matchIndex + 1} of {searchMatches.length}
</span>
) : hasQuery ? (
<span>No results</span>
) : (
<span>Type to search</span>
)}
</div>
</div>
);
}
src/registry/bases/base/ui/direction.tsx
"use client";
export {
DirectionProvider,
useDirection,
} from "@base-ui/react/direction-provider";
src/hooks/use-value-as-ref.ts
import * as React from "react";
import { useIsomorphicLayoutEffect } from "@/hooks/use-isomorphic-layout-effect";
function useValueAsRef<T>(value: T) {
const ref = React.useRef<T>(value);
useIsomorphicLayoutEffect(() => {
ref.current = value;
});
return ref;
}
export { useValueAsRef };
src/hooks/use-badge-overflow.ts
import * as React from "react";
const badgeWidthCache = new Map<string, number>();
const DEFAULT_CONTAINER_PADDING = 16; // px-2 = 8px * 2
const DEFAULT_BADGE_GAP = 4; // gap-1 = 4px
const DEFAULT_OVERFLOW_BADGE_WIDTH = 40; // Approximate width of "+N" badge
interface MeasureBadgeWidthProps {
label: string;
cacheKey: string;
iconSize?: number;
maxWidth?: number;
className?: string;
}
function measureBadgeWidth({
label,
cacheKey,
iconSize,
maxWidth,
className,
}: MeasureBadgeWidthProps): number {
const cached = badgeWidthCache.get(cacheKey);
if (cached !== undefined) {
return cached;
}
const measureEl = document.createElement("div");
measureEl.className = `inline-flex items-center rounded-md border px-1.5 text-xs font-semibold h-5 gap-1 shrink-0 absolute invisible pointer-events-none ${
className ?? ""
}`;
measureEl.style.whiteSpace = "nowrap";
if (iconSize) {
const icon = document.createElement("span");
icon.className = "shrink-0";
icon.style.width = `${iconSize}px`;
icon.style.height = `${iconSize}px`;
measureEl.appendChild(icon);
}
if (maxWidth) {
const text = document.createElement("span");
text.className = "truncate";
text.style.maxWidth = `${maxWidth}px`;
text.textContent = label;
measureEl.appendChild(text);
} else {
measureEl.textContent = label;
}
document.body.appendChild(measureEl);
const width = measureEl.offsetWidth;
document.body.removeChild(measureEl);
badgeWidthCache.set(cacheKey, width);
return width;
}
interface GetBadgeListWidthProps<T> {
items: T[];
getLabel: (item: T) => string;
cacheKeyPrefix?: string;
iconSize?: number;
maxWidth?: number;
className?: string;
containerPadding?: number;
badgeGap?: number;
}
export function getBadgeListWidth<T>({
items,
getLabel,
cacheKeyPrefix = "",
iconSize,
maxWidth,
className,
containerPadding = DEFAULT_CONTAINER_PADDING,
badgeGap = DEFAULT_BADGE_GAP,
}: GetBadgeListWidthProps<T>): number {
if (items.length === 0) return 0;
let width = containerPadding;
for (const item of items) {
const label = getLabel(item);
width +=
measureBadgeWidth({
label,
cacheKey: cacheKeyPrefix ? `${cacheKeyPrefix}:${label}` : label,
iconSize,
maxWidth,
className,
}) + badgeGap;
}
return width;
}
interface UseBadgeOverflowProps<T> extends GetBadgeListWidthProps<T> {
containerRef: React.RefObject<HTMLElement | null>;
lineCount: number;
overflowBadgeWidth?: number;
}
interface UseBadgeOverflowReturn<T> {
visibleItems: T[];
hiddenCount: number;
containerWidth: number;
}
export function useBadgeOverflow<T>({
items,
getLabel,
containerRef,
lineCount,
cacheKeyPrefix = "",
containerPadding = DEFAULT_CONTAINER_PADDING,
badgeGap = DEFAULT_BADGE_GAP,
overflowBadgeWidth = DEFAULT_OVERFLOW_BADGE_WIDTH,
iconSize,
maxWidth,
className,
}: UseBadgeOverflowProps<T>): UseBadgeOverflowReturn<T> {
const [containerWidth, setContainerWidth] = React.useState(0);
React.useEffect(() => {
if (!containerRef.current) return;
function measureWidth() {
if (containerRef.current) {
const width = containerRef.current.clientWidth - containerPadding;
setContainerWidth(width);
}
}
measureWidth();
const resizeObserver = new ResizeObserver(measureWidth);
resizeObserver.observe(containerRef.current);
return () => {
resizeObserver.disconnect();
};
}, [containerRef, containerPadding]);
const result = React.useMemo(() => {
if (!containerWidth || items.length === 0) {
return { visibleItems: items, hiddenCount: 0, containerWidth };
}
let currentLineWidth = 0;
let currentLine = 1;
const visible: T[] = [];
for (const item of items) {
const label = getLabel(item);
const cacheKey = cacheKeyPrefix ? `${cacheKeyPrefix}:${label}` : label;
const badgeWidth = measureBadgeWidth({
label,
cacheKey,
iconSize,
maxWidth,
className,
});
const widthWithGap = badgeWidth + badgeGap;
if (currentLineWidth + widthWithGap <= containerWidth) {
currentLineWidth += widthWithGap;
visible.push(item);
} else if (currentLine < lineCount) {
currentLine++;
currentLineWidth = widthWithGap;
visible.push(item);
} else {
if (
currentLineWidth + overflowBadgeWidth > containerWidth &&
visible.length > 0
) {
visible.pop();
}
break;
}
}
return {
visibleItems: visible,
hiddenCount: Math.max(0, items.length - visible.length),
containerWidth,
};
}, [
items,
getLabel,
containerWidth,
lineCount,
cacheKeyPrefix,
iconSize,
maxWidth,
className,
badgeGap,
overflowBadgeWidth,
]);
return result;
}
export function clearBadgeWidthCache(): void {
badgeWidthCache.clear();
}
src/registry/bases/base/hooks/use-data-grid.ts
import { useIsoLayoutEffect } from "@base-ui/utils/useIsoLayoutEffect";
import { useRefWithInit } from "@base-ui/utils/useRefWithInit";
import { useValueAsRef } from "@base-ui/utils/useValueAsRef";
import {
type ColumnDef,
type ColumnFiltersState,
type ColumnOrderState,
type ColumnPinningState,
type ColumnVisibilityState,
type Row,
type RowData,
type RowSelectionState,
type SortingState,
type Table,
type TableOptions,
type TableState,
type Updater,
useTable,
} from "@tanstack/react-table";
import { useVirtualizer, type Virtualizer } from "@tanstack/react-virtual";
import * as React from "react";
import { toast } from "sonner";
import type {
CellPosition,
CellUpdate,
ContextMenuState,
DataGridTableMeta,
Direction,
FileCellData,
NavigationDirection,
PasteDialogState,
SearchState,
SelectionState,
} from "@/lib/data-grid-types";
import {
type DataGridFeatures,
dataGridFeatures,
} from "@/lib/data-grid-features";
import {
getCellFocusTarget,
getCellKey,
getEmptyCellValue,
getIsFileCellData,
getIsInPopover,
getRowIndexById,
getScrollDirection,
getTabTargetCell,
getVisibleColumnIds,
matchSelectOption,
parseCellKey,
parseTsv,
scrollCellIntoView,
stringifyUnknown,
} from "@/lib/data-grid-utils";
import { useDirection } from "@/registry/bases/base/ui/direction";
const OVERSCAN = 6;
const VIEWPORT_OFFSET = 1;
const HORIZONTAL_PAGE_SIZE = 5;
const SCROLL_SYNC_RETRY_COUNT = 16;
const MIN_COLUMN_SIZE = 60;
const MAX_COLUMN_SIZE = 800;
const SEARCH_SHORTCUT_KEY = "f";
const NON_NAVIGABLE_COLUMN_IDS = new Set(["select", "actions"]);
const AUTO_SCROLL_EDGE_ZONE = 50;
const AUTO_SCROLL_SPEED_RAMP_ZONE = AUTO_SCROLL_EDGE_ZONE * 3;
const AUTO_SCROLL_MIN_SPEED = 8;
const AUTO_SCROLL_MAX_SPEED = 40;
const AUTO_SCROLL_SELECTION_THROTTLE_MS = 32;
const DOMAIN_REGEX = /^[\w.-]+\.[a-z]{2,}(\/\S*)?$/i;
const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}:\d{2}.*)?$/;
const TRUTHY_BOOLEANS = new Set(["true", "1", "yes", "checked"]);
const VALID_BOOLEANS = new Set([
"true",
"false",
"1",
"0",
"yes",
"no",
"checked",
"unchecked",
]);
function getIsDataColumn(columnId: string) {
return !NON_NAVIGABLE_COLUMN_IDS.has(columnId);
}
function getCanEditColumnId<TData extends RowData>(
table: Table<DataGridFeatures, TData>,
columnId: string,
) {
const column = table.getAllFlatColumnsById()[columnId];
return column
? column.getCanEdit()
: table.options.enableCellEditing !== false;
}
function setRowSelected(
rowSelection: RowSelectionState,
rowId: string,
selected: boolean,
) {
if (selected) {
rowSelection[rowId] = true;
} else {
delete rowSelection[rowId];
}
}
interface DataGridState {
sorting: SortingState;
columnFilters: ColumnFiltersState;
columnVisibility: ColumnVisibilityState;
columnPinning: ColumnPinningState;
columnOrder: ColumnOrderState;
rowSelection: RowSelectionState;
selectionState: SelectionState;
focusedCell: CellPosition | null;
/** Cut source keys laid out in the same rows and columns as the clipboard text. */
cutCells: Array<Array<string | null>>;
contextMenu: ContextMenuState;
searchQuery: string;
searchMatches: CellPosition[];
matchIndex: number;
searchOpen: boolean;
lastClickedRowId: string | null;
pasteDialog: PasteDialogState;
}
interface DataGridStore {
subscribe: (callback: () => void) => () => void;
getState: () => DataGridState;
setState: <K extends keyof DataGridState>(
key: K,
value: DataGridState[K],
) => void;
notify: () => void;
batch: (fn: () => void) => void;
}
function useStore<T>(
store: DataGridStore,
selector: (state: DataGridState) => T,
): T {
const getSnapshot = React.useCallback(
() => selector(store.getState()),
[store, selector],
);
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
interface UseDataGridProps<TData extends RowData> extends Omit<
TableOptions<DataGridFeatures, TData>,
"features"
> {
onDataChange?: (data: TData[]) => void;
onRowAdd?: (
event?: React.MouseEvent<HTMLDivElement>,
) => Partial<CellPosition> | Promise<Partial<CellPosition> | null> | null;
onRowsAdd?: (count: number) => void | Promise<void>;
onRowsDelete?: (rows: TData[], rowIds: string[]) => void | Promise<void>;
onPaste?: (updates: Array<CellUpdate>) => void | Promise<void>;
onFilesUpload?: (params: {
files: File[];
rowId: string;
columnId: string;
}) => Promise<FileCellData[]>;
onFilesDelete?: (params: {
fileIds: string[];
rowId: string;
columnId: string;
}) => void | Promise<void>;
overscan?: number;
dir?: Direction;
autoFocus?: boolean | Partial<CellPosition>;
enableSingleCellSelection?: boolean;
enableColumnSelection?: boolean;
enableSearch?: boolean;
enablePaste?: boolean;
readOnly?: boolean;
}
function useDataGrid<TData extends RowData>({
data,
columns,
overscan = OVERSCAN,
dir: dirProp,
initialState,
...props
}: UseDataGridProps<TData>) {
const contextDir = useDirection();
const dir = dirProp ?? contextDir;
const dataGridRef = React.useRef<HTMLDivElement>(null);
const tableRef =
React.useRef<ReturnType<typeof useTable<DataGridFeatures, TData>>>(null);
const rowVirtualizerRef =
React.useRef<Virtualizer<HTMLDivElement, Element>>(null);
const headerRef = React.useRef<HTMLDivElement>(null);
const rowMapRef = React.useRef<Map<number, HTMLDivElement>>(new Map());
const cellMapRef = React.useRef<Map<string, HTMLDivElement>>(new Map());
const footerRef = React.useRef<HTMLDivElement>(null);
const focusGuardRef = React.useRef(false);
const propsRef = useValueAsRef({
...props,
data,
columns,
initialState,
});
const listenersRef = useRefWithInit(() => new Set<() => void>());
const stateRef = useRefWithInit<DataGridState>(() => {
return {
sorting: initialState?.sorting ?? [],
columnFilters: initialState?.columnFilters ?? [],
columnVisibility: initialState?.columnVisibility ?? {},
columnPinning: {
start: initialState?.columnPinning?.start ?? [],
end: initialState?.columnPinning?.end ?? [],
},
columnOrder: initialState?.columnOrder ?? [],
rowSelection: initialState?.rowSelection ?? {},
selectionState: {
selectedCells: new Set(),
selectionRange: null,
isSelecting: false,
},
focusedCell: null,
cutCells: [],
contextMenu: {
open: false,
x: 0,
y: 0,
},
searchQuery: "",
searchMatches: [],
matchIndex: -1,
searchOpen: false,
lastClickedRowId: null,
pasteDialog: {
open: false,
rowsNeeded: 0,
clipboardText: "",
},
};
});
const store = React.useMemo<DataGridStore>(() => {
let isBatching = false;
let pendingNotification = false;
return {
subscribe: (callback) => {
listenersRef.current.add(callback);
return () => listenersRef.current.delete(callback);
},
getState: () => stateRef.current,
setState: (key, value) => {
if (Object.is(stateRef.current[key], value)) return;
stateRef.current[key] = value;
if (isBatching) {
pendingNotification = true;
} else {
if (!pendingNotification) {
pendingNotification = true;
queueMicrotask(() => {
pendingNotification = false;
store.notify();
});
}
}
},
notify: () => {
for (const listener of listenersRef.current) {
listener();
}
},
batch: (fn) => {
if (isBatching) {
fn();
return;
}
isBatching = true;
const wasPending = pendingNotification;
pendingNotification = false;
try {
fn();
} finally {
isBatching = false;
if (pendingNotification || wasPending) {
pendingNotification = false;
store.notify();
}
}
},
};
}, [listenersRef, stateRef]);
const focusedCell = useStore(store, (state) => state.focusedCell);
const selectionState = useStore(store, (state) => state.selectionState);
const searchQuery = useStore(store, (state) => state.searchQuery);
const searchMatches = useStore(store, (state) => state.searchMatches);
const matchIndex = useStore(store, (state) => state.matchIndex);
const searchOpen = useStore(store, (state) => state.searchOpen);
const sorting = useStore(store, (state) => state.sorting);
const columnFilters = useStore(store, (state) => state.columnFilters);
const storeColumnVisibility = useStore(
store,
(state) => state.columnVisibility,
);
const storeColumnPinning = useStore(store, (state) => state.columnPinning);
const storeColumnOrder = useStore(store, (state) => state.columnOrder);
const columnVisibility =
props.state?.columnVisibility ?? storeColumnVisibility;
const columnPinning = props.state?.columnPinning ?? storeColumnPinning;
const columnOrder = props.state?.columnOrder ?? storeColumnOrder;
const rowSelection = useStore(store, (state) => state.rowSelection);
const contextMenu = useStore(store, (state) => state.contextMenu);
const pasteDialog = useStore(store, (state) => state.pasteDialog);
const prevCellSelectionMapRef = useRefWithInit(
() => new Map<string, Set<string>>(),
);
// Memoize per-row selection sets to prevent unnecessary row re-renders
// Each row gets a stable Set reference that only changes when its cells' selection changes
const cellSelectionMap = React.useMemo(() => {
const selectedCells = selectionState.selectedCells;
if (selectedCells.size === 0) {
prevCellSelectionMapRef.current.clear();
return null;
}
const newRowCells = new Map<string, Set<string>>();
for (const cellKey of selectedCells) {
const { rowId } = parseCellKey(cellKey);
let rowSet = newRowCells.get(rowId);
if (!rowSet) {
rowSet = new Set<string>();
newRowCells.set(rowId, rowSet);
}
rowSet.add(cellKey);
}
const stableMap = new Map<string, Set<string>>();
for (const [rowId, newSet] of newRowCells) {
const prevSet = prevCellSelectionMapRef.current.get(rowId);
if (
prevSet &&
prevSet.size === newSet.size &&
[...newSet].every((key) => prevSet.has(key))
) {
stableMap.set(rowId, prevSet);
} else {
stableMap.set(rowId, newSet);
}
}
prevCellSelectionMapRef.current = stableMap;
return stableMap;
}, [selectionState.selectedCells, prevCellSelectionMapRef]);
const getRowIndex = React.useCallback((rowId: string) => {
const currentTable = tableRef.current;
return currentTable ? getRowIndexById(currentTable, rowId) : -1;
}, []);
const getRowIdAt = React.useCallback(
(rowIndex: number) => tableRef.current?.getRowModel().rows[rowIndex]?.id,
[],
);
const columnIds = React.useMemo(() => {
return getVisibleColumnIds({
columnIds: columns
.map((c) => {
if (c.id) return c.id;
if ("accessorKey" in c) return c.accessorKey as string;
return undefined;
})
.filter((id): id is string => Boolean(id)),
columnVisibility,
columnPinning,
columnOrder,
});
}, [columns, columnVisibility, columnPinning, columnOrder]);
const navigableColumnIds = React.useMemo(() => {
return columnIds.filter((c) => !NON_NAVIGABLE_COLUMN_IDS.has(c));
}, [columnIds]);
const onDataUpdate = React.useCallback(
(updates: CellUpdate | Array<CellUpdate>) => {
if (propsRef.current.readOnly) return;
const updateArray = Array.isArray(updates) ? updates : [updates];
if (updateArray.length === 0) return;
const currentTable = tableRef.current;
if (!currentTable) return;
const rowsById = currentTable.getCoreRowModel().rowsById;
const currentData = propsRef.current.data;
const rowUpdatesMap = new Map<
number,
{ original: TData; updates: Array<Omit<CellUpdate, "rowId">> }
>();
for (const update of updateArray) {
const row = rowsById[update.rowId];
if (!row || !getCanEditColumnId(currentTable, update.columnId)) {
continue;
}
let rowUpdates = rowUpdatesMap.get(row.index);
if (!rowUpdates) {
rowUpdates = { original: row.original, updates: [] };
rowUpdatesMap.set(row.index, rowUpdates);
}
rowUpdates.updates.push({
columnId: update.columnId,
value: update.value,
});
}
if (rowUpdatesMap.size === 0) return;
const newData = [...currentData];
for (const [dataIndex, { original, updates }] of rowUpdatesMap) {
const updatedRow = Object.assign(
{},
(currentData[dataIndex] ?? original) as Record<string, unknown>,
);
for (const { columnId, value } of updates) {
updatedRow[columnId] = value;
}
newData[dataIndex] = updatedRow as TData;
}
propsRef.current.onDataChange?.(newData);
},
[propsRef],
);
const getIsCellSelected = React.useCallback(
(rowId: string, columnId: string) => {
const currentSelectionState = store.getState().selectionState;
return currentSelectionState.selectedCells.has(
getCellKey(rowId, columnId),
);
},
[store],
);
const onSelectionClear = React.useCallback(() => {
store.batch(() => {
store.setState("selectionState", {
selectedCells: new Set(),
selectionRange: null,
isSelecting: false,
});
store.setState("rowSelection", {});
});
}, [store]);
const selectAll = React.useCallback(() => {
const allCells = new Set<string>();
const rows = tableRef.current?.getRowModel().rows ?? [];
for (const row of rows) {
for (const columnId of navigableColumnIds) {
allCells.add(getCellKey(row.id, columnId));
}
}
const firstRowId = rows[0]?.id;
const lastRowId = rows[rows.length - 1]?.id;
const firstColumnId = navigableColumnIds[0];
const lastColumnId = navigableColumnIds[navigableColumnIds.length - 1];
store.setState("selectionState", {
selectedCells: allCells,
selectionRange:
firstRowId && lastRowId && firstColumnId && lastColumnId
? {
start: { rowId: firstRowId, columnId: firstColumnId },
end: { rowId: lastRowId, columnId: lastColumnId },
}
: null,
isSelecting: false,
});
}, [navigableColumnIds, store]);
const selectColumn = React.useCallback(
(columnId: string) => {
const rows = tableRef.current?.getRowModel().rows ?? [];
const firstRowId = rows[0]?.id;
const lastRowId = rows[rows.length - 1]?.id;
if (!firstRowId || !lastRowId) return;
const selectedCells = new Set<string>();
for (const row of rows) {
selectedCells.add(getCellKey(row.id, columnId));
}
store.setState("selectionState", {
selectedCells,
selectionRange: {
start: { rowId: firstRowId, columnId },
end: { rowId: lastRowId, columnId },
},
isSelecting: false,
});
},
[store],
);
const selectRange = React.useCallback(
(start: CellPosition, end: CellPosition, isSelecting = false) => {
const rows = tableRef.current?.getRowModel().rows ?? [];
const startRowIndex = getRowIndex(start.rowId);
const endRowIndex = getRowIndex(end.rowId);
const startColIndex = columnIds.indexOf(start.columnId);
const endColIndex = columnIds.indexOf(end.columnId);
const selectedCells = new Set<string>();
if (startRowIndex !== -1 && endRowIndex !== -1) {
const minRow = Math.min(startRowIndex, endRowIndex);
const maxRow = Math.max(startRowIndex, endRowIndex);
const minCol = Math.min(startColIndex, endColIndex);
const maxCol = Math.max(startColIndex, endColIndex);
for (let rowIndex = minRow; rowIndex <= maxRow; rowIndex++) {
const rowId = rows[rowIndex]?.id;
if (!rowId) continue;
for (let colIndex = minCol; colIndex <= maxCol; colIndex++) {
const columnId = columnIds[colIndex];
if (columnId) {
selectedCells.add(getCellKey(rowId, columnId));
}
}
}
}
store.setState("selectionState", {
selectedCells,
selectionRange: { start, end },
isSelecting,
});
},
[columnIds, store, getRowIndex],
);
const serializeCellsToTsv = React.useCallback(() => {
const currentState = store.getState();
let selectedCellsArray: string[];
if (!currentState.selectionState.selectedCells.size) {
if (!currentState.focusedCell) return null;
const focusedCellKey = getCellKey(
currentState.focusedCell.rowId,
currentState.focusedCell.columnId,
);
selectedCellsArray = [focusedCellKey];
} else {
selectedCellsArray = Array.from(
currentState.selectionState.selectedCells,
);
}
const currentTable = tableRef.current;
const rowsById = currentTable?.getRowModel().rowsById;
if (!rowsById) return null;
const selectedColumnIds: string[] = [];
const seenColumnIds = new Set<string>();
const cellData = new Map<string, string>();
const selectedRows = new Map<string, Row<DataGridFeatures, TData>>();
const rowCellMaps = new Map<
string,
Map<
string,
ReturnType<Row<DataGridFeatures, TData>["getVisibleCells"]>[number]
>
>();
const navigableCells: string[] = [];
for (const cellKey of selectedCellsArray) {
const { rowId, columnId } = parseCellKey(cellKey);
if (columnId && NON_NAVIGABLE_COLUMN_IDS.has(columnId)) {
continue;
}
navigableCells.push(cellKey);
if (columnId && !seenColumnIds.has(columnId)) {
seenColumnIds.add(columnId);
selectedColumnIds.push(columnId);
}
const row = rowsById[rowId];
if (row) {
selectedRows.set(rowId, row);
let cellMap = rowCellMaps.get(rowId);
if (!cellMap) {
cellMap = new Map(row.getVisibleCells().map((c) => [c.column.id, c]));
rowCellMaps.set(rowId, cellMap);
}
const cell = cellMap.get(columnId);
if (cell) {
const value = cell.getValue();
const cellVariant = cell.column.columnDef?.meta?.cell?.variant;
let serializedValue = "";
if (cellVariant === "file" || cellVariant === "multi-select") {
serializedValue = value ? JSON.stringify(value) : "";
} else if (value instanceof Date) {
serializedValue = value.toISOString();
} else {
serializedValue = stringifyUnknown(value);
}
cellData.set(cellKey, serializedValue);
}
}
}
const colIndices = new Set<number>();
for (const cellKey of navigableCells) {
const { columnId } = parseCellKey(cellKey);
const colIndex = selectedColumnIds.indexOf(columnId);
if (colIndex >= 0) {
colIndices.add(colIndex);
}
}
const sortedRows = Array.from(selectedRows.values()).sort(
(a, b) => a.getDisplayIndex() - b.getDisplayIndex(),
);
const sortedColIndices = Array.from(colIndices).sort((a, b) => a - b);
const sortedColumnIds = sortedColIndices.map((i) => selectedColumnIds[i]);
const navigableCellSet = new Set(navigableCells);
const cellGrid = sortedRows.map((row) =>
sortedColumnIds.map((columnId) => {
if (!columnId) return null;
const cellKey = getCellKey(row.id, columnId);
return navigableCellSet.has(cellKey) ? cellKey : null;
}),
);
const tsvData = sortedRows
.map((row) =>
sortedColumnIds
.map((columnId) =>
columnId ? (cellData.get(getCellKey(row.id, columnId)) ?? "") : "",
)
.join("\t"),
)
.join("\n");
return { tsvData, cellGrid, selectedCellsArray: navigableCells };
}, [store]);
const onCellsCopy = React.useCallback(async () => {
const result = serializeCellsToTsv();
if (!result) return;
const { tsvData, selectedCellsArray } = result;
try {
await navigator.clipboard.writeText(tsvData);
const currentState = store.getState();
if (currentState.cutCells.length > 0) {
store.setState("cutCells", []);
}
toast.success(
`${selectedCellsArray.length} cell${
selectedCellsArray.length !== 1 ? "s" : ""
} copied`,
);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to copy to clipboard",
);
}
}, [store, serializeCellsToTsv]);
const onCellsCut = React.useCallback(async () => {
if (propsRef.current.readOnly) return;
const result = serializeCellsToTsv();
if (!result) return;
const { tsvData, cellGrid, selectedCellsArray } = result;
try {
await navigator.clipboard.writeText(tsvData);
store.setState("cutCells", cellGrid);
toast.success(
`${selectedCellsArray.length} cell${
selectedCellsArray.length !== 1 ? "s" : ""
} cut`,
);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Failed to cut to clipboard",
);
}
}, [store, propsRef, serializeCellsToTsv]);
const restoreFocus = React.useCallback((element: HTMLDivElement | null) => {
if (element && document.activeElement !== element) {
requestAnimationFrame(() => {
element.focus();
});
}
}, []);
const onCellsPaste = React.useCallback(
async (expandRows = false) => {
if (propsRef.current.readOnly) return;
const currentState = store.getState();
if (!currentState.focusedCell) return;
const currentTable = tableRef.current;
const rows = currentTable?.getRowModel().rows;
if (!rows) return;
try {
let clipboardText = currentState.pasteDialog.clipboardText;
if (!clipboardText) {
clipboardText = await navigator.clipboard.readText();
if (!clipboardText) return;
}
const rawPastedData = parseTsv(
clipboardText,
navigableColumnIds.length,
);
// Fill entire selection when clipboard has a single value and multiple cells are selected
const selectionCells = currentState.selectionState.selectedCells;
const isSingleCellClipboard =
rawPastedData.length === 1 && (rawPastedData[0]?.length ?? 0) === 1;
let pastedData = rawPastedData;
let isFillPaste = false;
let startRowIndex = getRowIndex(currentState.focusedCell.rowId);
let startColIndex = navigableColumnIds.indexOf(
currentState.focusedCell.columnId,
);
if (isSingleCellClipboard && selectionCells.size > 1) {
const singleValue = rawPastedData[0]?.[0] ?? "";
let minRow = Infinity;
let maxRow = -Infinity;
let minColIdx = Infinity;
let maxColIdx = -Infinity;
for (const cellKey of selectionCells) {
const { rowId, columnId } = parseCellKey(cellKey);
const rowIndex = getRowIndex(rowId);
const colIdx = navigableColumnIds.indexOf(columnId);
if (rowIndex === -1 || colIdx === -1) continue;
minRow = Math.min(minRow, rowIndex);
maxRow = Math.max(maxRow, rowIndex);
minColIdx = Math.min(minColIdx, colIdx);
maxColIdx = Math.max(maxColIdx, colIdx);
}
if (minRow !== Infinity) {
startRowIndex = minRow;
startColIndex = minColIdx;
const numRows = maxRow - minRow + 1;
const numCols = maxColIdx - minColIdx + 1;
pastedData = Array.from({ length: numRows }, () =>
Array.from({ length: numCols }, () => singleValue),
);
isFillPaste = true;
}
}
if (startRowIndex === -1 || startColIndex === -1) return;
const rowCount = rows.length;
const rowsNeeded = startRowIndex + pastedData.length - rowCount;
if (
rowsNeeded > 0 &&
!expandRows &&
propsRef.current.onRowAdd &&
!currentState.pasteDialog.clipboardText
) {
store.setState("pasteDialog", {
open: true,
rowsNeeded,
clipboardText,
});
return;
}
if (expandRows && rowsNeeded > 0) {
const expectedRowCount = rowCount + rowsNeeded;
if (propsRef.current.onRowsAdd) {
await propsRef.current.onRowsAdd(rowsNeeded);
} else if (propsRef.current.onRowAdd) {
for (let i = 0; i < rowsNeeded; i++) {
await propsRef.current.onRowAdd();
}
}
let attempts = 0;
const maxAttempts = 50;
let currentTableRowCount =
tableRef.current?.getRowModel().rows.length ?? 0;
while (
currentTableRowCount < expectedRowCount &&
attempts < maxAttempts
) {
await new Promise((resolve) => setTimeout(resolve, 100));
currentTableRowCount =
tableRef.current?.getRowModel().rows.length ?? 0;
attempts++;
}
}
const updates: Array<CellUpdate> = [];
const tableColumns = currentTable?.getAllColumns() ?? [];
let cellsUpdated = 0;
let endRowIndex = startRowIndex;
let endColIndex = startColIndex;
const updatedTable = tableRef.current;
const updatedRows = updatedTable?.getRowModel().rows;
let cellsSkipped = 0;
const writtenCellKeys = new Set<string>();
const movedSourceCellKeys = new Set<string>();
const columnMap = new Map(tableColumns.map((c) => [c.id, c]));
for (
let pasteRowIdx = 0;
pasteRowIdx < pastedData.length;
pasteRowIdx++
) {
const pasteRow = pastedData[pasteRowIdx];
if (!pasteRow) continue;
const targetRowIndex = startRowIndex + pasteRowIdx;
const targetRowId = updatedRows?.[targetRowIndex]?.id;
if (!targetRowId) break;
for (
let pasteColIdx = 0;
pasteColIdx < pasteRow.length;
pasteColIdx++
) {
const targetColIndex = startColIndex + pasteColIdx;
if (targetColIndex >= navigableColumnIds.length) break;
const targetColumnId = navigableColumnIds[targetColIndex];
if (!targetColumnId) continue;
if (
currentTable &&
!getCanEditColumnId(currentTable, targetColumnId)
) {
cellsSkipped++;
endRowIndex = Math.max(endRowIndex, targetRowIndex);
endColIndex = Math.max(endColIndex, targetColIndex);
continue;
}
const pastedValue = pasteRow[pasteColIdx] ?? "";
const column = columnMap.get(targetColumnId);
const cellOpts = column?.columnDef?.meta?.cell;
const cellVariant = cellOpts?.variant;
let processedValue: unknown = pastedValue;
let shouldSkip = false;
switch (cellVariant) {
case "number": {
if (!pastedValue) {
processedValue = null;
} else {
const num = Number.parseFloat(pastedValue);
if (Number.isNaN(num)) shouldSkip = true;
else processedValue = num;
}
break;
}
case "checkbox": {
if (!pastedValue) {
processedValue = false;
} else {
const lower = pastedValue.toLowerCase();
if (VALID_BOOLEANS.has(lower)) {
processedValue = TRUTHY_BOOLEANS.has(lower);
} else {
shouldSkip = true;
}
}
break;
}
case "date": {
if (!pastedValue) {
processedValue = null;
} else {
const date = new Date(pastedValue);
if (Number.isNaN(date.getTime())) shouldSkip = true;
else processedValue = date;
}
break;
}
case "select": {
const options = cellOpts?.options ?? [];
if (!pastedValue) {
processedValue = null;
} else {
const matched = matchSelectOption(pastedValue, options);
if (matched) processedValue = matched;
else shouldSkip = true;
}
break;
}
case "multi-select": {
const options = cellOpts?.options ?? [];
let values: string[] = [];
try {
const parsed = JSON.parse(pastedValue);
if (Array.isArray(parsed)) {
values = parsed.filter(
(v): v is string => typeof v === "string",
);
}
} catch {
values = pastedValue
? pastedValue.split(",").map((v) => v.trim())
: [];
}
const validated = values
.map((v) => matchSelectOption(v, options))
.filter(Boolean) as string[];
if (values.length > 0 && validated.length === 0) {
shouldSkip = true;
} else {
processedValue = validated;
}
break;
}
case "file": {
if (!pastedValue) {
processedValue = [];
} else {
try {
const parsed = JSON.parse(pastedValue);
if (!Array.isArray(parsed)) {
shouldSkip = true;
} else {
const validFiles = parsed.filter(getIsFileCellData);
if (parsed.length > 0 && validFiles.length === 0) {
shouldSkip = true;
} else {
processedValue = validFiles;
}
}
} catch {
shouldSkip = true;
}
}
break;
}
case "url": {
if (!pastedValue) {
processedValue = "";
} else {
const firstChar = pastedValue[0];
if (firstChar === "[" || firstChar === "{") {
shouldSkip = true;
} else {
try {
new URL(pastedValue);
processedValue = pastedValue;
} catch {
if (DOMAIN_REGEX.test(pastedValue)) {
processedValue = pastedValue;
} else {
shouldSkip = true;
}
}
}
}
break;
}
default: {
if (!pastedValue) {
processedValue = "";
break;
}
if (ISO_DATE_REGEX.test(pastedValue)) {
const date = new Date(pastedValue);
if (!Number.isNaN(date.getTime())) {
processedValue = date.toLocaleDateString();
break;
}
}
const firstChar = pastedValue[0];
if (
firstChar === "[" ||
firstChar === "{" ||
firstChar === "t" ||
firstChar === "f"
) {
try {
const parsed = JSON.parse(pastedValue);
if (Array.isArray(parsed)) {
if (
parsed.length > 0 &&
parsed.every(getIsFileCellData)
) {
processedValue = parsed.map((f) => f.name).join(", ");
} else if (parsed.every((v) => typeof v === "string")) {
processedValue = (parsed as string[]).join(", ");
}
} else if (typeof parsed === "boolean") {
processedValue = parsed ? "Checked" : "Unchecked";
}
} catch {
const lower = pastedValue.toLowerCase();
if (lower === "true" || lower === "false") {
processedValue =
lower === "true" ? "Checked" : "Unchecked";
}
}
}
}
}
if (shouldSkip) {
cellsSkipped++;
endRowIndex = Math.max(endRowIndex, targetRowIndex);
endColIndex = Math.max(endColIndex, targetColIndex);
continue;
}
updates.push({
rowId: targetRowId,
columnId: targetColumnId,
value: processedValue,
});
cellsUpdated++;
writtenCellKeys.add(getCellKey(targetRowId, targetColumnId));
const sourceCellKey = isFillPaste
? currentState.cutCells[0]?.[0]
: currentState.cutCells[pasteRowIdx]?.[pasteColIdx];
if (sourceCellKey) movedSourceCellKeys.add(sourceCellKey);
endRowIndex = Math.max(endRowIndex, targetRowIndex);
endColIndex = Math.max(endColIndex, targetColIndex);
}
}
if (updates.length > 0) {
if (propsRef.current.onPaste) {
await propsRef.current.onPaste(updates);
}
const allUpdates = [...updates];
if (currentState.cutCells.length > 0) {
const columnById = new Map(tableColumns.map((c) => [c.id, c]));
for (const cellKey of movedSourceCellKeys) {
if (writtenCellKeys.has(cellKey)) continue;
const { rowId, columnId } = parseCellKey(cellKey);
const column = columnById.get(columnId);
const cellVariant = column?.columnDef?.meta?.cell?.variant;
const emptyValue = getEmptyCellValue(cellVariant);
allUpdates.push({ rowId, columnId, value: emptyValue });
}
store.setState("cutCells", []);
}
onDataUpdate(allUpdates);
if (cellsSkipped > 0) {
toast.success(
`${cellsUpdated} cell${
cellsUpdated !== 1 ? "s" : ""
} pasted, ${cellsSkipped} skipped`,
);
} else {
toast.success(
`${cellsUpdated} cell${cellsUpdated !== 1 ? "s" : ""} pasted`,
);
}
const startRowId = updatedRows?.[startRowIndex]?.id;
const endRowId = updatedRows?.[endRowIndex]?.id;
const endColumnId = navigableColumnIds[endColIndex];
if (startRowId && endRowId && endColumnId) {
selectRange(
{
rowId: startRowId,
columnId: currentState.focusedCell.columnId,
},
{ rowId: endRowId, columnId: endColumnId },
);
}
restoreFocus(dataGridRef.current);
} else if (cellsSkipped > 0) {
toast.error(
`${cellsSkipped} cell${
cellsSkipped !== 1 ? "s" : ""
} skipped pasting for invalid data`,
);
}
if (currentState.pasteDialog.open) {
store.setState("pasteDialog", {
open: false,
rowsNeeded: 0,
clipboardText: "",
});
}
} catch (error) {
toast.error(
error instanceof Error
? error.message
: "Failed to paste. Please try again.",
);
}
},
[
store,
navigableColumnIds,
propsRef,
onDataUpdate,
selectRange,
restoreFocus,
getRowIndex,
],
);
// Release focus guard after delay to allow async data re-renders to settle.
// 300ms accounts for db sync and virtualized cell mounting
const releaseFocusGuard = React.useCallback((immediate = false) => {
if (immediate) {
focusGuardRef.current = false;
return;
}
setTimeout(() => {
focusGuardRef.current = false;
}, 300);
}, []);
const focusCellWrapper = React.useCallback(
(rowId: string, columnId: string) => {
focusGuardRef.current = true;
requestAnimationFrame(() => {
const cellKey = getCellKey(rowId, columnId);
const cellWrapperElement = cellMapRef.current.get(cellKey);
if (!cellWrapperElement) {
const container = dataGridRef.current;
if (container) {
container.focus();
}
releaseFocusGuard();
return;
}
getCellFocusTarget(cellWrapperElement).focus();
releaseFocusGuard();
});
},
[releaseFocusGuard],
);
const focusCell = React.useCallback(
(rowId: string, columnId: string) => {
store.batch(() => {
store.setState("focusedCell", { rowId, columnId });
tableRef.current?.setEditingCell(null);
});
const currentState = store.getState();
if (currentState.searchOpen) return;
focusCellWrapper(rowId, columnId);
},
[store, focusCellWrapper],
);
const onRowsDelete = React.useCallback(
async (rowIds: string[]) => {
if (
propsRef.current.readOnly ||
!propsRef.current.onRowsDelete ||
rowIds.length === 0
)
return;
const rowsById = tableRef.current?.getRowModel().rowsById;
if (!rowsById) return;
const currentState = store.getState();
const currentFocusedColumn =
currentState.focusedCell?.columnId ?? navigableColumnIds[0];
const rowsToDelete: TData[] = [];
const deletedRowIds: string[] = [];
let minDeletedRowIndex = Infinity;
for (const rowId of rowIds) {
const row = rowsById[rowId];
if (!row) continue;
rowsToDelete.push(row.original);
deletedRowIds.push(rowId);
minDeletedRowIndex = Math.min(
minDeletedRowIndex,
row.getDisplayIndex(),
);
}
if (rowsToDelete.length === 0) return;
await propsRef.current.onRowsDelete(rowsToDelete, deletedRowIds);
store.batch(() => {
store.setState("selectionState", {
selectedCells: new Set(),
selectionRange: null,
isSelecting: false,
});
store.setState("rowSelection", {});
tableRef.current?.setEditingCell(null);
});
requestAnimationFrame(() => {
const currentRows = tableRef.current?.getRowModel().rows ?? [];
const targetRow =
currentRows[Math.min(minDeletedRowIndex, currentRows.length - 1)];
if (targetRow && currentFocusedColumn) {
focusCell(targetRow.id, currentFocusedColumn);
}
});
},
[propsRef, store, navigableColumnIds, focusCell],
);
const navigateCell = React.useCallback(
(direction: NavigationDirection) => {
const currentState = store.getState();
if (!currentState.focusedCell) return;
const { rowId, columnId } = currentState.focusedCell;
const rowIndex = getRowIndex(rowId);
if (rowIndex === -1) return;
const currentColIndex = navigableColumnIds.indexOf(columnId);
const rowVirtualizer = rowVirtualizerRef.current;
const rows = tableRef.current?.getRowModel().rows ?? [];
const rowCount = rows.length;
let newRowIndex = rowIndex;
let newColumnId = columnId;
const isRtl = dir === "rtl";
switch (direction) {
case "up":
newRowIndex = Math.max(0, rowIndex - 1);
break;
case "down":
newRowIndex = Math.min(rowCount - 1, rowIndex + 1);
break;
case "left":
case "right": {
const isNext = (direction === "right") !== isRtl;
const adjacentColumnId =
columnIds[columnIds.indexOf(columnId) + (isNext ? 1 : -1)];
if (adjacentColumnId) newColumnId = adjacentColumnId;
break;
}
case "home":
if (navigableColumnIds.length > 0) {
newColumnId = navigableColumnIds[0] ?? columnId;
}
break;
case "end":
if (navigableColumnIds.length > 0) {
newColumnId =
navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;
}
break;
case "ctrl+home":
newRowIndex = 0;
if (navigableColumnIds.length > 0) {
newColumnId = navigableColumnIds[0] ?? columnId;
}
break;
case "ctrl+end":
newRowIndex = Math.max(0, rowCount - 1);
if (navigableColumnIds.length > 0) {
newColumnId =
navigableColumnIds[navigableColumnIds.length - 1] ?? columnId;
}
break;
case "ctrl+up":
newRowIndex = 0;
break;
case "ctrl+down":
newRowIndex = Math.max(0, rowCount - 1);
break;
case "pageup":
if (rowVirtualizer) {
const visibleRange = rowVirtualizer.getVirtualItems();
const pageSize = visibleRange.length ?? 10;
newRowIndex = Math.max(0, rowIndex - pageSize);
} else {
newRowIndex = Math.max(0, rowIndex - 10);
}
break;
case "pagedown":
if (rowVirtualizer) {
const visibleRange = rowVirtualizer.getVirtualItems();
const pageSize = visibleRange.length ?? 10;
newRowIndex = Math.min(rowCount - 1, rowIndex + pageSize);
} else {
newRowIndex = Math.min(rowCount - 1, rowIndex + 10);
}
break;
case "pageleft":
if (currentColIndex > 0) {
const targetIndex = Math.max(
0,
currentColIndex - HORIZONTAL_PAGE_SIZE,
);
const targetColumnId = navigableColumnIds[targetIndex];
if (targetColumnId) newColumnId = targetColumnId;
}
break;
case "pageright":
if (currentColIndex < navigableColumnIds.length - 1) {
const targetIndex = Math.min(
navigableColumnIds.length - 1,
currentColIndex + HORIZONTAL_PAGE_SIZE,
);
const targetColumnId = navigableColumnIds[targetIndex];
if (targetColumnId) newColumnId = targetColumnId;
}
break;
case "tab":
case "shift+tab": {
const target = getTabTargetCell({
rowIndex,
columnId,
columnIds,
rowCount,
isBackward: direction === "shift+tab",
getIsColumnTabbable: getIsDataColumn,
});
if (target) {
newRowIndex = target.rowIndex;
newColumnId = target.columnId;
}
break;
}
}
const newRowId = rows[newRowIndex]?.id;
if (!newRowId) return;
if (newRowIndex !== rowIndex || newColumnId !== columnId) {
focusCell(newRowId, newColumnId);
// Calculate and apply scrolls synchronously to avoid flashing
const container = dataGridRef.current;
if (!container) return;
const targetRow = rowMapRef.current.get(newRowIndex);
const cellKey = getCellKey(newRowId, newColumnId);
const targetCell = cellMapRef.current.get(cellKey);
// If target row is not rendered, scroll it into view first
if (!targetRow) {
if (rowVirtualizer) {
const align =
direction === "up" ||
direction === "pageup" ||
direction === "ctrl+up" ||
direction === "ctrl+home" ||
direction === "shift+tab"
? "start"
: direction === "down" ||
direction === "pagedown" ||
direction === "ctrl+down" ||
direction === "ctrl+end" ||
direction === "tab"
? "end"
: "center";
rowVirtualizer.scrollToIndex(newRowIndex, { align });
// Wait for row to render before horizontal scroll
if (newColumnId !== columnId) {
requestAnimationFrame(() => {
const cellKeyRetry = getCellKey(newRowId, newColumnId);
const targetCellRetry = cellMapRef.current.get(cellKeyRetry);
if (targetCellRetry) {
const scrollDirection = getScrollDirection(direction);
scrollCellIntoView({
container,
targetCell: targetCellRetry,
tableRef,
viewportOffset: VIEWPORT_OFFSET,
direction: scrollDirection,
isRtl: dir === "rtl",
});
}
});
}
} else {
// Use direct scroll calculation when virtualizer is not available
const rowSize = tableRef.current?.getRowSize() ?? 0;
container.scrollTop = newRowIndex * rowSize;
}
return;
}
// Vertical scrolling for rendered rows that changed
if (newRowIndex !== rowIndex && targetRow) {
requestAnimationFrame(() => {
const containerRect = container.getBoundingClientRect();
const headerHeight =
headerRef.current?.getBoundingClientRect().height ?? 0;
const footerHeight =
footerRef.current?.getBoundingClientRect().height ?? 0;
const viewportTop =
containerRect.top + headerHeight + VIEWPORT_OFFSET;
const viewportBottom =
containerRect.bottom - footerHeight - VIEWPORT_OFFSET;
const rowRect = targetRow.getBoundingClientRect();
const isFullyVisible =
rowRect.top >= viewportTop && rowRect.bottom <= viewportBottom;
if (!isFullyVisible) {
// Only apply vertical scroll for vertical navigation
const isVerticalNavigation =
direction === "up" ||
direction === "down" ||
direction === "pageup" ||
direction === "pagedown" ||
direction === "ctrl+up" ||
direction === "ctrl+down" ||
direction === "ctrl+home" ||
direction === "ctrl+end" ||
direction === "tab" ||
direction === "shift+tab";
if (isVerticalNavigation) {
if (
direction === "down" ||
direction === "pagedown" ||
direction === "ctrl+down" ||
direction === "ctrl+end" ||
direction === "tab"
) {
container.scrollTop += rowRect.bottom - viewportBottom;
} else {
container.scrollTop -= viewportTop - rowRect.top;
}
}
}
});
}
// Horizontal scrolling for rendered cells
if (newColumnId !== columnId && targetCell) {
requestAnimationFrame(() => {
const scrollDirection = getScrollDirection(direction);
scrollCellIntoView({
container,
targetCell,
tableRef,
viewportOffset: VIEWPORT_OFFSET,
direction: scrollDirection,
isRtl: dir === "rtl",
});
});
}
}
},
[dir, store, columnIds, navigableColumnIds, focusCell, getRowIndex],
);
const onCellEditingStart = React.useCallback(
(rowId: string, columnId: string) => {
const currentTable = tableRef.current;
if (!currentTable || !getCanEditColumnId(currentTable, columnId)) return;
store.batch(() => {
store.setState("focusedCell", { rowId, columnId });
tableRef.current?.setEditingCell({ rowId, columnId });
});
},
[store],
);
const onCellEditingStop = React.useCallback(
(opts?: { moveToNextRow?: boolean; direction?: NavigationDirection }) => {
const currentEditing = tableRef.current?.getEditingCell();
tableRef.current?.setEditingCell(null);
if (opts?.moveToNextRow && currentEditing) {
const { rowId, columnId } = currentEditing;
const nextRowId = getRowIdAt(getRowIndex(rowId) + 1);
if (nextRowId) {
requestAnimationFrame(() => {
focusCell(nextRowId, columnId);
});
}
} else if (opts?.direction && currentEditing) {
const { rowId, columnId } = currentEditing;
focusCell(rowId, columnId);
requestAnimationFrame(() => {
navigateCell(opts.direction ?? "right");
});
} else if (currentEditing) {
const { rowId, columnId } = currentEditing;
focusCellWrapper(rowId, columnId);
}
},
[store, focusCell, navigateCell, focusCellWrapper, getRowIndex, getRowIdAt],
);
const onSearchOpenChange = React.useCallback(
(open: boolean) => {
if (open) {
store.setState("searchOpen", true);
return;
}
const currentState = store.getState();
const currentMatch =
currentState.matchIndex >= 0 &&
currentState.searchMatches[currentState.matchIndex];
store.batch(() => {
store.setState("searchOpen", false);
store.setState("searchQuery", "");
store.setState("searchMatches", []);
store.setState("matchIndex", -1);
if (currentMatch) {
store.setState("focusedCell", {
rowId: currentMatch.rowId,
columnId: currentMatch.columnId,
});
}
});
if (
dataGridRef.current &&
document.activeElement !== dataGridRef.current
) {
dataGridRef.current.focus();
}
},
[store],
);
const onSearch = React.useCallback(
(query: string) => {
if (!query.trim()) {
store.batch(() => {
store.setState("searchMatches", []);
store.setState("matchIndex", -1);
});
return;
}
const matches: CellPosition[] = [];
let firstMatchRowIndex = -1;
const currentTable = tableRef.current;
const rows = currentTable?.getRowModel().rows ?? [];
const lowerQuery = query.toLowerCase();
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
const row = rows[rowIndex];
if (!row) continue;
const cellById = new Map(
row.getVisibleCells().map((c) => [c.column.id, c]),
);
for (const columnId of columnIds) {
const cell = cellById.get(columnId);
if (!cell) continue;
const value = cell.getValue();
const stringValue = stringifyUnknown(value).toLowerCase();
if (stringValue.includes(lowerQuery)) {
if (firstMatchRowIndex === -1) firstMatchRowIndex = rowIndex;
matches.push({ rowId: row.id, columnId });
}
}
}
store.batch(() => {
store.setState("searchMatches", matches);
store.setState("matchIndex", matches.length > 0 ? 0 : -1);
});
if (firstMatchRowIndex !== -1) {
rowVirtualizerRef.current?.scrollToIndex(firstMatchRowIndex, {
align: "center",
});
}
},
[columnIds, store],
);
const onSearchQueryChange = React.useCallback(
(query: string) => store.setState("searchQuery", query),
[store],
);
const onNavigateToPrevMatch = React.useCallback(() => {
const currentState = store.getState();
if (currentState.searchMatches.length === 0) return;
const prevIndex =
currentState.matchIndex - 1 < 0
? currentState.searchMatches.length - 1
: currentState.matchIndex - 1;
const match = currentState.searchMatches[prevIndex];
const matchRowIndex = match ? getRowIndex(match.rowId) : -1;
if (match && matchRowIndex !== -1) {
rowVirtualizerRef.current?.scrollToIndex(matchRowIndex, {
align: "center",
});
requestAnimationFrame(() => {
store.setState("matchIndex", prevIndex);
requestAnimationFrame(() => {
focusCell(match.rowId, match.columnId);
});
});
}
}, [store, focusCell, getRowIndex]);
const onNavigateToNextMatch = React.useCallback(() => {
const currentState = store.getState();
if (currentState.searchMatches.length === 0) return;
const nextIndex =
(currentState.matchIndex + 1) % currentState.searchMatches.length;
const match = currentState.searchMatches[nextIndex];
const matchRowIndex = match ? getRowIndex(match.rowId) : -1;
if (match && matchRowIndex !== -1) {
rowVirtualizerRef.current?.scrollToIndex(matchRowIndex, {
align: "center",
});
requestAnimationFrame(() => {
store.setState("matchIndex", nextIndex);
requestAnimationFrame(() => {
focusCell(match.rowId, match.columnId);
});
});
}
}, [store, focusCell, getRowIndex]);
const searchMatchSet = React.useMemo(() => {
return new Set(searchMatches.map((m) => getCellKey(m.rowId, m.columnId)));
}, [searchMatches]);
const getIsSearchMatch = React.useCallback(
(rowId: string, columnId: string) => {
return searchMatchSet.has(getCellKey(rowId, columnId));
},
[searchMatchSet],
);
const getIsActiveSearchMatch = React.useCallback(
(rowId: string, columnId: string) => {
const currentState = store.getState();
if (currentState.matchIndex < 0) return false;
const currentMatch = currentState.searchMatches[currentState.matchIndex];
return (
currentMatch?.rowId === rowId && currentMatch?.columnId === columnId
);
},
[store],
);
// Compute search match data for targeted row re-renders
const searchMatchesByRow = React.useMemo(() => {
if (searchMatches.length === 0) return null;
const rowMap = new Map<string, Set<string>>();
for (const match of searchMatches) {
let columnSet = rowMap.get(match.rowId);
if (!columnSet) {
columnSet = new Set<string>();
rowMap.set(match.rowId, columnSet);
}
columnSet.add(match.columnId);
}
return rowMap;
}, [searchMatches]);
const activeSearchMatch = React.useMemo<CellPosition | null>(() => {
if (matchIndex < 0 || searchMatches.length === 0) return null;
return searchMatches[matchIndex] ?? null;
}, [searchMatches, matchIndex]);
const blurCell = React.useCallback(() => {
if (
tableRef.current?.getEditingCell() &&
document.activeElement instanceof HTMLElement
) {
document.activeElement.blur();
}
store.batch(() => {
store.setState("focusedCell", null);
tableRef.current?.setEditingCell(null);
});
}, [store]);
const scrollToCell = React.useCallback(
(rowId: string, columnId: string) => {
requestAnimationFrame(() => {
const container = dataGridRef.current;
const cellKey = getCellKey(rowId, columnId);
const targetCell = cellMapRef.current.get(cellKey);
if (container && targetCell) {
scrollCellIntoView({
container,
targetCell,
tableRef,
viewportOffset: VIEWPORT_OFFSET,
isRtl: dir === "rtl",
});
}
});
},
[dir],
);
const onCellClick = React.useCallback(
(rowId: string, columnId: string, event?: React.MouseEvent) => {
if (event?.button === 2) return;
const currentState = store.getState();
const currentFocused = currentState.focusedCell;
if (event) {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
const cellKey = getCellKey(rowId, columnId);
const newSelectedCells = new Set(
currentState.selectionState.selectedCells,
);
if (newSelectedCells.has(cellKey)) {
newSelectedCells.delete(cellKey);
} else {
newSelectedCells.add(cellKey);
}
store.setState("selectionState", {
selectedCells: newSelectedCells,
selectionRange: null,
isSelecting: false,
});
focusCell(rowId, columnId);
scrollToCell(rowId, columnId);
return;
}
if (event.shiftKey && currentState.focusedCell) {
event.preventDefault();
selectRange(currentState.focusedCell, { rowId, columnId });
scrollToCell(rowId, columnId);
return;
}
}
const hasSelectedCells =
currentState.selectionState.selectedCells.size > 0;
const hasSelectedRows = Object.keys(currentState.rowSelection).length > 0;
if (hasSelectedCells && !currentState.selectionState.isSelecting) {
const cellKey = getCellKey(rowId, columnId);
const isClickingSelectedCell =
currentState.selectionState.selectedCells.has(cellKey);
if (!isClickingSelectedCell) {
onSelectionClear();
} else {
focusCell(rowId, columnId);
scrollToCell(rowId, columnId);
return;
}
} else if (hasSelectedRows && columnId !== "select") {
onSelectionClear();
}
if (
currentFocused?.rowId === rowId &&
currentFocused?.columnId === columnId
) {
onCellEditingStart(rowId, columnId);
} else {
focusCell(rowId, columnId);
scrollToCell(rowId, columnId);
}
},
[
store,
focusCell,
scrollToCell,
onCellEditingStart,
selectRange,
onSelectionClear,
],
);
const onCellDoubleClick = React.useCallback(
(rowId: string, columnId: string, event?: React.MouseEvent) => {
if (event?.defaultPrevented) return;
onCellEditingStart(rowId, columnId);
},
[onCellEditingStart],
);
const onCellMouseDown = React.useCallback(
(rowId: string, columnId: string, event: React.MouseEvent) => {
if (event.button === 2) {
return;
}
event.preventDefault();
if (!event.ctrlKey && !event.metaKey && !event.shiftKey) {
const cellKey = getCellKey(rowId, columnId);
store.batch(() => {
store.setState("selectionState", {
selectedCells: propsRef.current.enableSingleCellSelection
? new Set([cellKey])
: new Set(),
selectionRange: {
start: { rowId, columnId },
end: { rowId, columnId },
},
isSelecting: true,
});
store.setState("rowSelection", {});
});
}
},
[store, propsRef],
);
const onCellMouseEnter = React.useCallback(
(rowId: string, columnId: string) => {
const currentState = store.getState();
if (
currentState.selectionState.isSelecting &&
currentState.selectionState.selectionRange
) {
const start = currentState.selectionState.selectionRange.start;
const end = { rowId, columnId };
if (
currentState.focusedCell?.rowId !== start.rowId ||
currentState.focusedCell?.columnId !== start.columnId
) {
focusCell(start.rowId, start.columnId);
}
selectRange(start, end, true);
}
},
[store, selectRange, focusCell],
);
const onCellMouseUp = React.useCallback(() => {
const currentState = store.getState();
store.setState("selectionState", {
...currentState.selectionState,
isSelecting: false,
});
}, [store]);
const onCellContextMenu = React.useCallback(
(rowId: string, columnId: string, event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
const currentState = store.getState();
const cellKey = getCellKey(rowId, columnId);
const isTargetCellSelected =
currentState.selectionState.selectedCells.has(cellKey);
if (!isTargetCellSelected) {
store.batch(() => {
store.setState("selectionState", {
selectedCells: new Set([cellKey]),
selectionRange: {
start: { rowId, columnId },
end: { rowId, columnId },
},
isSelecting: false,
});
store.setState("focusedCell", { rowId, columnId });
});
}
store.setState("contextMenu", {
open: true,
x: event.clientX,
y: event.clientY,
});
},
[store],
);
const onContextMenuOpenChange = React.useCallback(
(open: boolean) => {
if (!open) {
const currentMenu = store.getState().contextMenu;
store.setState("contextMenu", {
open: false,
x: currentMenu.x,
y: currentMenu.y,
});
}
},
[store],
);
const onSortingChange = React.useCallback(
(updater: Updater<SortingState>) => {
const currentState = store.getState();
const newSorting =
typeof updater === "function" ? updater(currentState.sorting) : updater;
store.batch(() => {
store.setState("sorting", newSorting);
});
propsRef.current.onSortingChange?.(newSorting);
},
[store, propsRef],
);
const onColumnVisibilityChange = React.useCallback(
(updater: Updater<ColumnVisibilityState>) => {
const currentState = store.getState();
const newColumnVisibility =
typeof updater === "function"
? updater(
propsRef.current.state?.columnVisibility ??
currentState.columnVisibility,
)
: updater;
store.batch(() => {
store.setState("columnVisibility", newColumnVisibility);
const focusedColumnId = currentState.focusedCell?.columnId;
if (focusedColumnId && newColumnVisibility[focusedColumnId] === false) {
store.setState("focusedCell", null);
tableRef.current?.setEditingCell(null);
}
});
propsRef.current.onColumnVisibilityChange?.(newColumnVisibility);
},
[store, propsRef],
);
const onColumnPinningChange = React.useCallback(
(updater: Updater<ColumnPinningState>) => {
const currentState = store.getState();
const newColumnPinning =
typeof updater === "function"
? updater(
propsRef.current.state?.columnPinning ??
currentState.columnPinning,
)
: updater;
store.setState("columnPinning", newColumnPinning);
propsRef.current.onColumnPinningChange?.(newColumnPinning);
},
[store, propsRef],
);
const onColumnOrderChange = React.useCallback(
(updater: Updater<ColumnOrderState>) => {
const currentState = store.getState();
const newColumnOrder =
typeof updater === "function"
? updater(
propsRef.current.state?.columnOrder ?? currentState.columnOrder,
)
: updater;
store.setState("columnOrder", newColumnOrder);
propsRef.current.onColumnOrderChange?.(newColumnOrder);
},
[store, propsRef],
);
const onColumnFiltersChange = React.useCallback(
(updater: Updater<ColumnFiltersState>) => {
const currentState = store.getState();
const newColumnFilters =
typeof updater === "function"
? updater(currentState.columnFilters)
: updater;
store.batch(() => {
store.setState("columnFilters", newColumnFilters);
});
propsRef.current.onColumnFiltersChange?.(newColumnFilters);
},
[store, propsRef],
);
const onRowSelectionChange = React.useCallback(
(updater: Updater<RowSelectionState>) => {
const currentState = store.getState();
const newRowSelection =
typeof updater === "function"
? updater(currentState.rowSelection)
: updater;
const selectedRows = Object.keys(newRowSelection).filter(
(key) => newRowSelection[key],
);
const selectedCells = new Set<string>();
const rowsById = tableRef.current?.getRowModel().rowsById ?? {};
for (const rowId of selectedRows) {
if (!rowsById[rowId]) continue;
for (const columnId of navigableColumnIds) {
selectedCells.add(getCellKey(rowId, columnId));
}
}
const isUtilityCellFocused =
currentState.focusedCell !== null &&
!getIsDataColumn(currentState.focusedCell.columnId);
store.batch(() => {
store.setState("rowSelection", newRowSelection);
store.setState("selectionState", {
selectedCells,
selectionRange: null,
isSelecting: false,
});
if (!isUtilityCellFocused) {
store.setState("focusedCell", null);
tableRef.current?.setEditingCell(null);
}
});
propsRef.current.onRowSelectionChange?.(updater);
},
[store, navigableColumnIds, propsRef],
);
const onRowSelect = React.useCallback(
(rowId: string, selected: boolean, shiftKey: boolean) => {
const currentState = store.getState();
const rows = tableRef.current?.getRowModel().rows ?? [];
const currentRowIndex = rows.findIndex((r) => r.id === rowId);
const currentRow = currentRowIndex >= 0 ? rows[currentRowIndex] : null;
if (!currentRow) return;
if (shiftKey && currentState.lastClickedRowId !== null) {
const lastClickedRowIndex = rows.findIndex(
(r) => r.id === currentState.lastClickedRowId,
);
if (lastClickedRowIndex >= 0) {
const startIndex = Math.min(lastClickedRowIndex, currentRowIndex);
const endIndex = Math.max(lastClickedRowIndex, currentRowIndex);
const newRowSelection: RowSelectionState = {
...currentState.rowSelection,
};
for (let i = startIndex; i <= endIndex; i++) {
const row = rows[i];
if (row) {
setRowSelected(newRowSelection, row.id, selected);
}
}
onRowSelectionChange(newRowSelection);
} else {
const newRowSelection: RowSelectionState = {
...currentState.rowSelection,
};
setRowSelected(newRowSelection, currentRow.id, selected);
onRowSelectionChange(newRowSelection);
}
} else {
const newRowSelection: RowSelectionState = {
...currentState.rowSelection,
};
setRowSelected(newRowSelection, currentRow.id, selected);
onRowSelectionChange(newRowSelection);
}
store.setState("lastClickedRowId", rowId);
const focusedCell = store.getState().focusedCell;
if (focusedCell && !getIsDataColumn(focusedCell.columnId)) {
store.setState("focusedCell", { rowId, columnId: "select" });
}
},
[store, onRowSelectionChange],
);
const onColumnClick = React.useCallback(
(columnId: string) => {
if (!propsRef.current.enableColumnSelection) {
onSelectionClear();
return;
}
selectColumn(columnId);
},
[propsRef, selectColumn, onSelectionClear],
);
const onPasteDialogOpenChange = React.useCallback(
(open: boolean) => {
if (!open) {
store.setState("pasteDialog", {
open: false,
rowsNeeded: 0,
clipboardText: "",
});
}
},
[store],
);
const defaultColumn: Partial<ColumnDef<DataGridFeatures, TData>> =
React.useMemo(
() => ({
// Cell is rendered directly in DataGridRow to bypass flexRender's
// unstable cell.getContext() (https://github.com/TanStack/table/issues/4794)
minSize: MIN_COLUMN_SIZE,
maxSize: MAX_COLUMN_SIZE,
}),
[],
);
const tableMeta = React.useMemo<DataGridTableMeta>(() => {
return {
...propsRef.current.meta,
dataGridRef,
cellMapRef,
get focusedCell() {
return store.getState().focusedCell;
},
get editingCell() {
return tableRef.current?.getEditingCell() ?? null;
},
get selectionState() {
return store.getState().selectionState;
},
get searchOpen() {
return store.getState().searchOpen;
},
get contextMenu() {
return store.getState().contextMenu;
},
get pasteDialog() {
return store.getState().pasteDialog;
},
get readOnly() {
return propsRef.current.readOnly;
},
getIsCellSelected,
getIsSearchMatch,
getIsActiveSearchMatch,
scrollToCell: (rowId, columnId, align = "auto") => {
const container = dataGridRef.current;
const rowIndex = getRowIndex(rowId);
if (!container || rowIndex === -1) return;
rowVirtualizerRef.current?.scrollToIndex(rowIndex, { align });
const scrollRowIntoView = (retries = 1) => {
requestAnimationFrame(() => {
const targetRow = rowMapRef.current.get(rowIndex);
if (!targetRow) {
if (retries > 0) scrollRowIntoView(retries - 1);
return;
}
const headerBottom =
headerRef.current?.getBoundingClientRect().bottom ??
container.getBoundingClientRect().top;
const viewportTop = headerBottom + VIEWPORT_OFFSET;
const rowRect = targetRow.getBoundingClientRect();
if (rowRect.top < viewportTop) {
container.scrollTop -= viewportTop - rowRect.top;
}
const cellKey = getCellKey(rowId, columnId);
const targetCell = cellMapRef.current.get(cellKey);
if (targetCell) {
scrollCellIntoView({
container,
targetCell,
tableRef,
viewportOffset: VIEWPORT_OFFSET,
isRtl: dir === "rtl",
});
}
});
};
scrollRowIntoView();
},
onRowSelect,
onDataUpdate,
onRowsDelete: propsRef.current.onRowsDelete ? onRowsDelete : undefined,
onColumnClick,
onCellClick,
onCellDoubleClick,
onCellMouseDown,
onCellMouseEnter,
onCellMouseUp,
onCellContextMenu,
onCellEditingStart,
onCellEditingStop,
onCellsCopy,
onCellsCut,
onCellsPaste,
onSelectionClear,
onFilesUpload: propsRef.current.onFilesUpload
? propsRef.current.onFilesUpload
: undefined,
onFilesDelete: propsRef.current.onFilesDelete
? propsRef.current.onFilesDelete
: undefined,
onContextMenuOpenChange,
onPasteDialogOpenChange,
};
}, [
propsRef,
store,
dir,
getIsCellSelected,
getIsSearchMatch,
getIsActiveSearchMatch,
getRowIndex,
onRowSelect,
onDataUpdate,
onRowsDelete,
onColumnClick,
onCellClick,
onCellDoubleClick,
onCellMouseDown,
onCellMouseEnter,
onCellMouseUp,
onCellContextMenu,
onCellEditingStart,
onCellEditingStop,
onCellsCopy,
onCellsCut,
onCellsPaste,
onSelectionClear,
onContextMenuOpenChange,
onPasteDialogOpenChange,
]);
// Memoize state object to reduce shallow equality checks
const tableState = React.useMemo<Partial<TableState<DataGridFeatures>>>(
() => ({
...props.state,
sorting,
columnFilters,
columnVisibility,
columnPinning,
columnOrder,
rowSelection,
}),
[
sorting,
columnFilters,
columnVisibility,
columnPinning,
columnOrder,
rowSelection,
props.state?.rowHeight,
props.state?.editingCell,
],
);
const enableCellEditing =
!props.readOnly && props.enableCellEditing !== false;
const hasEditingCellChange = !!props.onEditingCellChange;
const hasRowHeightChange = !!props.onRowHeightChange;
const tableOptions = React.useMemo<
TableOptions<DataGridFeatures, TData>
>(() => {
const {
onEditingCellChange: _onEditingCellChange,
onRowHeightChange: _onRowHeightChange,
...tableProps
} = propsRef.current;
return {
...tableProps,
...(hasEditingCellChange && {
onEditingCellChange: (updater) =>
propsRef.current.onEditingCellChange?.(updater),
}),
...(hasRowHeightChange && {
onRowHeightChange: (updater) =>
propsRef.current.onRowHeightChange?.(updater),
}),
features: dataGridFeatures,
data,
columns,
defaultColumn,
initialState: propsRef.current.initialState,
state: tableState,
enableCellEditing,
onRowSelectionChange,
onSortingChange,
onColumnFiltersChange,
onColumnVisibilityChange,
onColumnPinningChange,
onColumnOrderChange,
columnResizeMode: "onChange",
columnResizeDirection: dir,
meta: tableMeta,
};
}, [
propsRef,
data,
columns,
defaultColumn,
tableState,
enableCellEditing,
hasEditingCellChange,
hasRowHeightChange,
dir,
onRowSelectionChange,
onSortingChange,
onColumnFiltersChange,
onColumnVisibilityChange,
onColumnPinningChange,
onColumnOrderChange,
tableMeta,
]);
const table = useTable(tableOptions);
if (!tableRef.current) {
tableRef.current = table;
}
const rowHeight = table.state.rowHeight;
const editingCell = table.state.editingCell;
const readOnlyColumnIds = React.useMemo(
() =>
new Set(
table
.getAllLeafColumns()
.filter((column) => !column.getCanEdit())
.map((column) => column.id),
),
[table, columns, enableCellEditing],
);
const rowSize = table.getRowSize();
const dragDepsRef = useValueAsRef({
selectRange,
dir,
rowSize,
columnIds,
});
const columnSizeVars = React.useMemo(() => {
const headers = table.getFlatHeaders();
const colSizes: { [key: string]: number } = {};
for (const header of headers) {
colSizes[`--header-${header.id}-size`] = header.getSize();
colSizes[`--col-${header.column.id}-size`] = header.column.getSize();
}
return colSizes;
}, [table.state.columnResizing, table.state.columnSizing]);
const isFirefox = React.useSyncExternalStore(
React.useCallback(() => () => {}, []),
React.useCallback(() => {
if (typeof window === "undefined" || typeof navigator === "undefined") {
return false;
}
return navigator.userAgent.indexOf("Firefox") !== -1;
}, []),
React.useCallback(() => false, []),
);
const adjustLayout = React.useMemo(() => {
const columnPinning = table.state.columnPinning;
return (
isFirefox &&
((columnPinning.start?.length ?? 0) > 0 ||
(columnPinning.end?.length ?? 0) > 0)
);
}, [isFirefox, table.state.columnPinning]);
const rowVirtualizer = useVirtualizer({
count: table.getRowModel().rows.length,
getScrollElement: () => dataGridRef.current,
estimateSize: () => rowSize,
overscan,
measureElement: !isFirefox
? (element) => element?.getBoundingClientRect().height
: undefined,
scrollPaddingStart:
(headerRef.current?.getBoundingClientRect().bottom ?? 0) -
(dataGridRef.current?.getBoundingClientRect().top ?? 0) +
VIEWPORT_OFFSET,
// Add extra row buffer to absorb virtual position drift after render measurements
scrollPaddingEnd:
(dataGridRef.current?.getBoundingClientRect().bottom ?? 0) -
(footerRef.current?.getBoundingClientRect().top ??
dataGridRef.current?.getBoundingClientRect().bottom ??
0) +
rowSize +
VIEWPORT_OFFSET,
});
if (!rowVirtualizerRef.current) {
rowVirtualizerRef.current = rowVirtualizer;
}
const onScrollToRow = React.useCallback(
async (opts: Partial<CellPosition> & { rowIndex?: number }) => {
const { rowId, columnId } = opts;
focusGuardRef.current = true;
const targetColumnId = columnId ?? navigableColumnIds[0];
if (!targetColumnId) {
releaseFocusGuard(true);
return;
}
async function onScrollAndFocus(retryCount: number) {
if (!targetColumnId) return;
const rows = tableRef.current?.getRowModel().rows ?? [];
const rowIndex =
rowId !== undefined ? getRowIndex(rowId) : (opts.rowIndex ?? 0);
// If the requested row doesn't exist yet, wait for data to update
if ((rowIndex === -1 || rowIndex >= rows.length) && retryCount > 0) {
await new Promise((resolve) => setTimeout(resolve, 50));
await onScrollAndFocus(retryCount - 1);
return;
}
const safeRowIndex =
rowIndex === -1
? rows.length - 1
: Math.min(rowIndex, rows.length - 1);
const safeRowId = rows[safeRowIndex]?.id;
if (!safeRowId) {
releaseFocusGuard();
return;
}
const currentRowCount = rows.length;
const isBottomHalf = safeRowIndex > currentRowCount / 2;
rowVirtualizer.scrollToIndex(safeRowIndex, {
align: isBottomHalf ? "end" : "start",
});
await new Promise((resolve) => requestAnimationFrame(resolve));
// Adjust scroll position to account for sticky header/footer
const container = dataGridRef.current;
const targetRow = rowMapRef.current.get(safeRowIndex);
if (container && targetRow) {
const containerRect = container.getBoundingClientRect();
const headerHeight =
headerRef.current?.getBoundingClientRect().height ?? 0;
const footerHeight =
footerRef.current?.getBoundingClientRect().height ?? 0;
const viewportTop =
containerRect.top + headerHeight + VIEWPORT_OFFSET;
const viewportBottom =
containerRect.bottom - footerHeight - VIEWPORT_OFFSET;
const rowRect = targetRow.getBoundingClientRect();
const isFullyVisible =
rowRect.top >= viewportTop && rowRect.bottom <= viewportBottom;
if (!isFullyVisible) {
if (rowRect.top < viewportTop) {
// Scroll up as row is partially hidden by header
container.scrollTop -= viewportTop - rowRect.top;
} else if (rowRect.bottom > viewportBottom) {
// Scroll down as row is partially hidden by footer
container.scrollTop += rowRect.bottom - viewportBottom;
}
}
}
store.batch(() => {
store.setState("focusedCell", {
rowId: safeRowId,
columnId: targetColumnId,
});
tableRef.current?.setEditingCell(null);
});
const cellKey = getCellKey(safeRowId, targetColumnId);
const cellElement = cellMapRef.current.get(cellKey);
if (cellElement) {
getCellFocusTarget(cellElement).focus();
releaseFocusGuard();
} else if (retryCount > 0) {
await new Promise((resolve) => requestAnimationFrame(resolve));
await onScrollAndFocus(retryCount - 1);
} else {
dataGridRef.current?.focus();
releaseFocusGuard();
}
}
await onScrollAndFocus(SCROLL_SYNC_RETRY_COUNT);
},
[rowVirtualizer, store, releaseFocusGuard, navigableColumnIds, getRowIndex],
);
const onRowAdd = React.useCallback(
async (event?: React.MouseEvent<HTMLDivElement>) => {
if (propsRef.current.readOnly || !propsRef.current.onRowAdd) return;
const initialRowCount = tableRef.current?.getRowModel().rows.length ?? 0;
let result: Partial<CellPosition> | null;
try {
result = await propsRef.current.onRowAdd(event);
} catch {
// Callback threw an error, don't proceed with scroll/focus
return;
}
if (result === null || event?.defaultPrevented) return;
onSelectionClear();
// onScrollToRow will handle retries if the row isn't rendered yet
void onScrollToRow({
rowId: result.rowId,
rowIndex: initialRowCount,
columnId: result.columnId,
});
},
[propsRef, onScrollToRow, onSelectionClear],
);
const getColumnHeaderTrigger = React.useCallback((columnId: string) => {
return (
headerRef.current?.querySelector<HTMLElement>(
`[data-slot="grid-header-cell"][data-column-id="${CSS.escape(columnId)}"] :is(button, [role="checkbox"])`,
) ?? null
);
}, []);
const focusColumnHeader = React.useCallback(
(columnId: string) => {
const trigger = getColumnHeaderTrigger(columnId);
if (!trigger) return false;
const currentState = store.getState();
if (
currentState.selectionState.selectedCells.size > 0 &&
Object.keys(currentState.rowSelection).length === 0
) {
onSelectionClear();
}
store.batch(() => {
store.setState("focusedCell", null);
tableRef.current?.setEditingCell(null);
});
trigger.focus();
return true;
},
[store, getColumnHeaderTrigger, onSelectionClear],
);
const onGridTabBackOut = React.useCallback(() => {
const container = dataGridRef.current;
if (!container) return;
// The container precedes every cell in the tab order, so it has to drop out
// until the browser finishes moving focus or Shift+Tab would land on it.
container.tabIndex = -1;
requestAnimationFrame(() => {
container.tabIndex = 0;
});
}, []);
const onColumnHeaderKeyDown = React.useCallback(
(event: KeyboardEvent, columnId: string) => {
const { key, shiftKey, ctrlKey, metaKey, altKey } = event;
if (ctrlKey || metaKey || altKey) return false;
const headerColumnIds = columnIds.filter((id) =>
getColumnHeaderTrigger(id),
);
const headerIndex = headerColumnIds.indexOf(columnId);
if (headerIndex === -1) return false;
const rowCount = tableRef.current?.getRowModel().rows.length ?? 0;
const isRtl = dir === "rtl";
function focusHeaderAt(index: number) {
const targetColumnId = headerColumnIds[index];
if (targetColumnId) focusColumnHeader(targetColumnId);
}
switch (key) {
case "ArrowLeft":
case "ArrowRight": {
const isNext = (key === "ArrowRight") !== isRtl;
focusHeaderAt(headerIndex + (isNext ? 1 : -1));
break;
}
case "Home":
focusHeaderAt(0);
break;
case "End":
focusHeaderAt(headerColumnIds.length - 1);
break;
case "ArrowUp":
break;
case "ArrowDown":
// Stops the menu trigger from opening its menu on ArrowDown
event.stopPropagation();
if (rowCount > 0) void onScrollToRow({ rowIndex: 0, columnId });
break;
case "Escape":
dataGridRef.current?.focus();
break;
case "Tab":
if (shiftKey) {
if (headerIndex === 0) {
onGridTabBackOut();
return true;
}
focusHeaderAt(headerIndex - 1);
} else if (headerIndex < headerColumnIds.length - 1) {
focusHeaderAt(headerIndex + 1);
} else {
const firstColumnId = navigableColumnIds[0];
if (rowCount === 0 || !firstColumnId) return true;
void onScrollToRow({ rowIndex: 0, columnId: firstColumnId });
}
break;
default:
return false;
}
event.preventDefault();
return true;
},
[
dir,
columnIds,
navigableColumnIds,
getColumnHeaderTrigger,
focusColumnHeader,
onGridTabBackOut,
onScrollToRow,
],
);
const onDataGridKeyDown = React.useCallback(
(event: KeyboardEvent) => {
const currentState = store.getState();
const { key, ctrlKey, metaKey, shiftKey, altKey } = event;
const isCtrlPressed = ctrlKey || metaKey;
if (
propsRef.current.enableSearch &&
isCtrlPressed &&
!shiftKey &&
key === SEARCH_SHORTCUT_KEY
) {
event.preventDefault();
onSearchOpenChange(true);
return;
}
if (
propsRef.current.enableSearch &&
currentState.searchOpen &&
!tableRef.current?.getEditingCell()
) {
if (key === "Enter") {
event.preventDefault();
if (shiftKey) {
onNavigateToPrevMatch();
} else {
onNavigateToNextMatch();
}
return;
}
if (key === "Escape") {
event.preventDefault();
onSearchOpenChange(false);
return;
}
return;
}
// Cell editing keyboard events (Enter, Tab, Escape) are handled by the cell variants
// to ensure proper value commitment before navigation
if (tableRef.current?.getEditingCell()) return;
const headerCell =
event.target instanceof Element
? event.target.closest<HTMLElement>('[data-slot="grid-header-cell"]')
: null;
const headerColumnId = headerCell?.dataset.columnId;
if (headerColumnId && onColumnHeaderKeyDown(event, headerColumnId)) {
return;
}
if (
isCtrlPressed &&
(key === "Backspace" || key === "Delete") &&
!propsRef.current.readOnly &&
propsRef.current.onRowsDelete
) {
const rowIds = new Set<string>();
if (Object.keys(currentState.rowSelection).length > 0) {
const rows = tableRef.current?.getRowModel().rows ?? [];
for (const row of rows) {
if (currentState.rowSelection[row.id]) {
rowIds.add(row.id);
}
}
} else if (currentState.selectionState.selectedCells.size > 0) {
for (const cellKey of currentState.selectionState.selectedCells) {
rowIds.add(parseCellKey(cellKey).rowId);
}
} else if (currentState.focusedCell) {
rowIds.add(currentState.focusedCell.rowId);
}
if (rowIds.size > 0) {
event.preventDefault();
void onRowsDelete(Array.from(rowIds));
}
return;
}
if (!currentState.focusedCell) return;
let direction: NavigationDirection | null = null;
const isUtilityCellFocused = !getIsDataColumn(
currentState.focusedCell.columnId,
);
if (
isUtilityCellFocused &&
shiftKey &&
(key.startsWith("Arrow") ||
key === "Home" ||
key === "End" ||
key === "PageUp" ||
key === "PageDown")
) {
event.preventDefault();
return;
}
if (isCtrlPressed && !shiftKey && key === "a") {
event.preventDefault();
selectAll();
return;
}
if (isCtrlPressed && !shiftKey && key === "c" && !isUtilityCellFocused) {
event.preventDefault();
void onCellsCopy();
return;
}
if (
isCtrlPressed &&
!shiftKey &&
key === "x" &&
!isUtilityCellFocused &&
!propsRef.current.readOnly
) {
event.preventDefault();
void onCellsCut();
return;
}
if (
propsRef.current.enablePaste &&
isCtrlPressed &&
!shiftKey &&
key === "v" &&
!isUtilityCellFocused &&
!propsRef.current.readOnly
) {
event.preventDefault();
void onCellsPaste();
return;
}
if (
(key === "Delete" || key === "Backspace") &&
!isCtrlPressed &&
!isUtilityCellFocused &&
!propsRef.current.readOnly
) {
const cellsToClear =
currentState.selectionState.selectedCells.size > 0
? Array.from(currentState.selectionState.selectedCells)
: currentState.focusedCell
? [
getCellKey(
currentState.focusedCell.rowId,
currentState.focusedCell.columnId,
),
]
: [];
if (cellsToClear.length > 0) {
event.preventDefault();
const updates: Array<CellUpdate> = [];
const currentTable = tableRef.current;
const tableColumns = currentTable?.getAllColumns() ?? [];
const columnById = new Map(tableColumns.map((c) => [c.id, c]));
for (const cellKey of cellsToClear) {
const { rowId, columnId } = parseCellKey(cellKey);
const column = columnById.get(columnId);
const cellVariant = column?.columnDef?.meta?.cell?.variant;
const emptyValue = getEmptyCellValue(cellVariant);
updates.push({ rowId, columnId, value: emptyValue });
}
onDataUpdate(updates);
if (currentState.selectionState.selectedCells.size > 0) {
onSelectionClear();
}
if (currentState.cutCells.length > 0) {
store.setState("cutCells", []);
}
}
return;
}
if (
key === "Enter" &&
shiftKey &&
!propsRef.current.readOnly &&
propsRef.current.onRowAdd
) {
event.preventDefault();
const initialRowCount =
tableRef.current?.getRowModel().rows.length ?? 0;
const currentColumnId = isUtilityCellFocused
? navigableColumnIds[0]
: currentState.focusedCell.columnId;
Promise.resolve(propsRef.current.onRowAdd())
.then(async (result) => {
if (result === null) return;
onSelectionClear();
void onScrollToRow({
rowId: result.rowId,
rowIndex: initialRowCount,
columnId: result.columnId ?? currentColumnId,
});
})
.catch(() => {
// Callback threw an error, don't proceed with scroll/focus
});
return;
}
switch (key) {
case "ArrowUp":
if (altKey && !isCtrlPressed && !shiftKey) {
direction = "pageup";
} else if (isCtrlPressed && shiftKey) {
const selectionEdge =
currentState.selectionState.selectionRange?.end ||
currentState.focusedCell;
const currentColIndex = navigableColumnIds.indexOf(
selectionEdge.columnId,
);
const selectionStart =
currentState.selectionState.selectionRange?.start ||
currentState.focusedCell;
const firstRowId = getRowIdAt(0);
if (firstRowId) {
selectRange(selectionStart, {
rowId: firstRowId,
columnId:
navigableColumnIds[currentColIndex] ?? selectionEdge.columnId,
});
}
const rowVirtualizer = rowVirtualizerRef.current;
if (rowVirtualizer) {
rowVirtualizer.scrollToIndex(0, { align: "start" });
}
restoreFocus(dataGridRef.current);
event.preventDefault();
return;
} else if (isCtrlPressed && !shiftKey) {
direction = "ctrl+up";
} else if (
!shiftKey &&
getRowIndex(currentState.focusedCell.rowId) === 0 &&
focusColumnHeader(currentState.focusedCell.columnId)
) {
event.preventDefault();
return;
} else {
direction = "up";
}
break;
case "ArrowDown":
if (altKey && !isCtrlPressed && !shiftKey) {
direction = "pagedown";
} else if (isCtrlPressed && shiftKey) {
const rowCount = tableRef.current?.getRowModel().rows.length ?? 0;
const selectionEdge =
currentState.selectionState.selectionRange?.end ||
currentState.focusedCell;
const currentColIndex = navigableColumnIds.indexOf(
selectionEdge.columnId,
);
const selectionStart =
currentState.selectionState.selectionRange?.start ||
currentState.focusedCell;
const lastRowId = getRowIdAt(rowCount - 1);
if (lastRowId) {
selectRange(selectionStart, {
rowId: lastRowId,
columnId:
navigableColumnIds[currentColIndex] ?? selectionEdge.columnId,
});
}
const rowVirtualizer = rowVirtualizerRef.current;
if (rowVirtualizer) {
rowVirtualizer.scrollToIndex(Math.max(0, rowCount - 1), {
align: "end",
});
}
restoreFocus(dataGridRef.current);
event.preventDefault();
return;
} else if (isCtrlPressed && !shiftKey) {
direction = "ctrl+down";
} else {
direction = "down";
}
break;
case "ArrowLeft":
if (isCtrlPressed && shiftKey) {
const selectionEdge =
currentState.selectionState.selectionRange?.end ||
currentState.focusedCell;
const selectionStart =
currentState.selectionState.selectionRange?.start ||
currentState.focusedCell;
const targetColumnId =
dir === "rtl"
? navigableColumnIds[navigableColumnIds.length - 1]
: navigableColumnIds[0];
if (targetColumnId) {
selectRange(selectionStart, {
rowId: selectionEdge.rowId,
columnId: targetColumnId,
});
const container = dataGridRef.current;
const cellKey = getCellKey(selectionEdge.rowId, targetColumnId);
const targetCell = cellMapRef.current.get(cellKey);
if (container && targetCell) {
scrollCellIntoView({
container,
targetCell,
tableRef,
viewportOffset: VIEWPORT_OFFSET,
direction: "home",
isRtl: dir === "rtl",
});
}
restoreFocus(container);
}
event.preventDefault();
return;
} else if (isCtrlPressed && !shiftKey) {
direction = "home";
} else {
direction = "left";
}
break;
case "ArrowRight":
if (isCtrlPressed && shiftKey) {
const selectionEdge =
currentState.selectionState.selectionRange?.end ||
currentState.focusedCell;
const selectionStart =
currentState.selectionState.selectionRange?.start ||
currentState.focusedCell;
const targetColumnId =
dir === "rtl"
? navigableColumnIds[0]
: navigableColumnIds[navigableColumnIds.length - 1];
if (targetColumnId) {
selectRange(selectionStart, {
rowId: selectionEdge.rowId,
columnId: targetColumnId,
});
const container = dataGridRef.current;
const cellKey = getCellKey(selectionEdge.rowId, targetColumnId);
const targetCell = cellMapRef.current.get(cellKey);
if (container && targetCell) {
scrollCellIntoView({
container,
targetCell,
tableRef,
viewportOffset: VIEWPORT_OFFSET,
direction: "end",
isRtl: dir === "rtl",
});
}
restoreFocus(container);
}
event.preventDefault();
return;
} else if (isCtrlPressed && !shiftKey) {
direction = "end";
} else {
direction = "right";
}
break;
case "Home":
direction = isCtrlPressed ? "ctrl+home" : "home";
break;
case "End":
direction = isCtrlPressed ? "ctrl+end" : "end";
break;
case "PageUp":
direction = altKey ? "pageleft" : "pageup";
break;
case "PageDown":
direction = altKey ? "pageright" : "pagedown";
break;
case "Escape":
event.preventDefault();
if (
currentState.selectionState.selectedCells.size > 0 ||
Object.keys(currentState.rowSelection).length > 0
) {
onSelectionClear();
} else {
blurCell();
dataGridRef.current?.focus();
}
return;
case "Tab": {
if (isCtrlPressed || altKey) return;
const tabTarget = getTabTargetCell({
rowIndex: getRowIndex(currentState.focusedCell.rowId),
columnId: currentState.focusedCell.columnId,
columnIds,
rowCount: tableRef.current?.getRowModel().rows.length ?? 0,
isBackward: shiftKey,
getIsColumnTabbable: getIsDataColumn,
});
if (!tabTarget) {
if (shiftKey) onGridTabBackOut();
return;
}
direction = shiftKey ? "shift+tab" : "tab";
break;
}
}
if (direction) {
event.preventDefault();
if (shiftKey && key !== "Tab" && currentState.focusedCell) {
const selectionEdge =
currentState.selectionState.selectionRange?.end ||
currentState.focusedCell;
const currentColIndex = navigableColumnIds.indexOf(
selectionEdge.columnId,
);
const edgeRowIndex = getRowIndex(selectionEdge.rowId);
if (edgeRowIndex === -1) return;
let newRowIndex = edgeRowIndex;
let newColumnId = selectionEdge.columnId;
const isRtl = dir === "rtl";
const rowCount = tableRef.current?.getRowModel().rows.length ?? 0;
switch (direction) {
case "up":
newRowIndex = Math.max(0, edgeRowIndex - 1);
break;
case "down":
newRowIndex = Math.min(rowCount - 1, edgeRowIndex + 1);
break;
case "left":
[truncated — showing 100000 of 124658 characters]src/hooks/use-debounced-callback.ts
import * as React from "react";
export function useDebouncedCallback<T extends (...args: never[]) => unknown>(
callback: T,
delay: number,
) {
const callbackRef = React.useRef(callback);
callbackRef.current = callback;
const debounceTimerRef = React.useRef(0);
React.useEffect(
() => () => window.clearTimeout(debounceTimerRef.current),
[],
);
return React.useCallback(
(...args: Parameters<T>) => {
window.clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = window.setTimeout(
() => callbackRef.current(...args),
delay,
);
},
[delay],
);
}
src/hooks/use-isomorphic-layout-effect.ts
import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };
src/hooks/use-ref-with-init.ts
import * as React from "react";
const UNINITIALIZED = {};
function useRefWithInit<T>(init: () => T): React.RefObject<T> {
const ref = React.useRef<T | typeof UNINITIALIZED>(UNINITIALIZED);
if (ref.current === UNINITIALIZED) {
ref.current = init();
}
return ref as React.RefObject<T>;
}
export { useRefWithInit };
src/lib/data-grid-utils.ts
import type {
Column,
ColumnOrderState,
ColumnPinningState,
ColumnVisibilityState,
RowData,
Table,
} from "@tanstack/react-table";
import type * as React from "react";
import {
BaselineIcon,
CalendarIcon,
CheckSquareIcon,
File,
FileArchive,
FileAudio,
FileIcon,
FileImage,
FileSpreadsheet,
FileText,
FileVideo,
HashIcon,
LinkIcon,
ListChecksIcon,
ListIcon,
Presentation,
TextInitialIcon,
} from "lucide-react";
import type { DataGridFeatures } from "@/lib/data-grid-features";
import type {
CellOpts,
CellPosition,
Direction,
FileCellData,
RowHeightValue,
} from "@/lib/data-grid-types";
export function stringifyUnknown(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
if (value instanceof Date) return value.toISOString();
try {
return JSON.stringify(value) ?? "";
} catch {
return "";
}
}
export function flexRender<TProps extends object>(
Comp: ((props: TProps) => React.ReactNode) | string | undefined,
props: TProps,
): React.ReactNode {
if (typeof Comp === "string") {
return Comp;
}
return Comp?.(props);
}
export function getIsFileCellData(item: unknown): item is FileCellData {
return (
!!item &&
typeof item === "object" &&
"id" in item &&
"name" in item &&
"size" in item &&
"type" in item
);
}
export function matchSelectOption(
value: string,
options: { value: string; label: string }[],
): string | undefined {
return options.find(
(o) =>
o.value === value ||
o.value.toLowerCase() === value.toLowerCase() ||
o.label.toLowerCase() === value.toLowerCase(),
)?.value;
}
// Unit separator, so row ids and column ids may contain any printable character
const CELL_KEY_SEPARATOR = "\u001f";
export function getCellKey(rowId: string, columnId: string) {
return `${rowId}${CELL_KEY_SEPARATOR}${columnId}`;
}
export function parseCellKey(cellKey: string): CellPosition {
const separatorIndex = cellKey.indexOf(CELL_KEY_SEPARATOR);
if (separatorIndex === -1) return { rowId: "", columnId: "" };
return {
rowId: cellKey.slice(0, separatorIndex),
columnId: cellKey.slice(separatorIndex + CELL_KEY_SEPARATOR.length),
};
}
/** Position of a row in the rendered row model, or `-1` if it isn't rendered. */
export function getRowIndexById<TData extends RowData>(
table: Table<DataGridFeatures, TData>,
rowId: string,
) {
return table.getRowModel().rowsById[rowId]?.getDisplayIndex() ?? -1;
}
export function getRowHeightValue(rowHeight: RowHeightValue): number {
const rowHeightMap: Record<RowHeightValue, number> = {
short: 36,
medium: 56,
tall: 76,
"extra-tall": 96,
};
return rowHeightMap[rowHeight];
}
export function getLineCount(rowHeight: RowHeightValue): number {
const lineCountMap: Record<RowHeightValue, number> = {
short: 1,
medium: 2,
tall: 3,
"extra-tall": 4,
};
return lineCountMap[rowHeight];
}
export function getColumnBorderVisibility<TData extends RowData>(params: {
column: Column<DataGridFeatures, TData>;
nextColumn?: Column<DataGridFeatures, TData>;
isLastColumn: boolean;
}): {
showEndBorder: boolean;
showStartBorder: boolean;
} {
const { column, nextColumn, isLastColumn } = params;
const isPinned = column.getIsPinned();
const isFirstRightPinnedColumn =
isPinned === "end" && column.getIsFirstColumn("end");
const isLastRightPinnedColumn =
isPinned === "end" && column.getIsLastColumn("end");
const nextIsPinned = nextColumn?.getIsPinned();
const isBeforeRightPinned =
nextIsPinned === "end" && nextColumn?.getIsFirstColumn("end");
const showEndBorder =
!isBeforeRightPinned && (isLastColumn || !isLastRightPinnedColumn);
const showStartBorder = isFirstRightPinnedColumn;
return {
showEndBorder,
showStartBorder,
};
}
export function getColumnPinningStyle<TData extends RowData>(params: {
column: Column<DataGridFeatures, TData>;
withBorder?: boolean;
dir?: Direction;
}): React.CSSProperties {
const { column, dir = "ltr", withBorder = false } = params;
const isPinned = column.getIsPinned();
const isLastLeftPinnedColumn =
isPinned === "start" && column.getIsLastColumn("start");
const isFirstRightPinnedColumn =
isPinned === "end" && column.getIsFirstColumn("end");
const isRtl = dir === "rtl";
const leftPosition =
isPinned === "start" ? `${column.getStart("start")}px` : undefined;
const rightPosition =
isPinned === "end" ? `${column.getAfter("end")}px` : undefined;
return {
boxShadow: withBorder
? isLastLeftPinnedColumn
? isRtl
? "4px 0 4px -4px var(--border) inset"
: "-4px 0 4px -4px var(--border) inset"
: isFirstRightPinnedColumn
? isRtl
? "-4px 0 4px -4px var(--border) inset"
: "4px 0 4px -4px var(--border) inset"
: undefined
: undefined,
left: isRtl ? rightPosition : leftPosition,
right: isRtl ? leftPosition : rightPosition,
opacity: isPinned ? 0.97 : 1,
position: isPinned ? "sticky" : "relative",
background: isPinned ? "var(--background)" : "var(--background)",
width: column.getSize(),
zIndex: isPinned ? 20 : undefined,
};
}
export function getVisibleColumnIds(params: {
columnIds: string[];
columnVisibility?: ColumnVisibilityState;
columnPinning?: Partial<ColumnPinningState>;
columnOrder?: ColumnOrderState;
}): string[] {
const {
columnIds,
columnVisibility = {},
columnPinning = {},
columnOrder = [],
} = params;
const knownIds = new Set(columnIds);
const orderedIds = new Set(columnOrder.filter((id) => knownIds.has(id)));
for (const id of columnIds) orderedIds.add(id);
const visibleIds = [...orderedIds].filter(
(id) => columnVisibility[id] !== false,
);
const visibleIdSet = new Set(visibleIds);
const startIds = (columnPinning.start ?? []).filter((id) =>
visibleIdSet.has(id),
);
const endIds = (columnPinning.end ?? []).filter((id) => visibleIdSet.has(id));
const pinnedIds = new Set([...startIds, ...endIds]);
return [
...startIds,
...visibleIds.filter((id) => !pinnedIds.has(id)),
...endIds,
];
}
export function getTabTargetCell(params: {
rowIndex: number;
columnId: string;
columnIds: string[];
rowCount: number;
isBackward: boolean;
getIsColumnTabbable?: (columnId: string) => boolean;
}): { rowIndex: number; columnId: string } | null {
const {
rowIndex,
columnId,
columnIds,
rowCount,
isBackward,
getIsColumnTabbable = () => true,
} = params;
const colIndex = columnIds.indexOf(columnId);
if (colIndex === -1 || !columnIds.some(getIsColumnTabbable)) return null;
const step = isBackward ? -1 : 1;
let nextRowIndex = rowIndex;
let nextColIndex = colIndex;
while (true) {
nextColIndex += step;
if (nextColIndex >= columnIds.length) {
nextColIndex = 0;
nextRowIndex++;
} else if (nextColIndex < 0) {
nextColIndex = columnIds.length - 1;
nextRowIndex--;
}
if (nextRowIndex < 0 || nextRowIndex >= rowCount) return null;
const nextColumnId = columnIds[nextColIndex];
if (nextColumnId && getIsColumnTabbable(nextColumnId)) {
return { rowIndex: nextRowIndex, columnId: nextColumnId };
}
}
}
export function getCellFocusTarget(cellElement: HTMLElement): HTMLElement {
if (cellElement.dataset.slot === "grid-cell-wrapper") return cellElement;
return (
cellElement.querySelector<HTMLElement>(
'button, a[href], [role="checkbox"]',
) ?? cellElement
);
}
export function getColumnFitSize(params: {
gridElement: HTMLElement;
columnId: string;
minSize: number;
maxSize: number;
wrapperContentSize?: number;
}): number | null {
const {
gridElement,
columnId,
minSize,
maxSize,
wrapperContentSize = 0,
} = params;
const cellElements = gridElement.querySelectorAll<HTMLElement>(
`:is([data-slot="grid-header-cell"], [data-slot="grid-cell"])[data-column-id="${CSS.escape(columnId)}"]`,
);
if (cellElements.length === 0) return null;
let cellChromeSize = 0;
for (const cellElement of cellElements) {
const wrapperElement = cellElement.querySelector<HTMLElement>(
'[data-slot="grid-cell-wrapper"]',
);
if (!wrapperElement) continue;
cellChromeSize =
cellElement.getBoundingClientRect().width - wrapperElement.clientWidth;
break;
}
const measurer = document.createElement("div");
measurer.setAttribute("aria-hidden", "true");
Object.assign(measurer.style, {
position: "absolute",
top: "0",
insetInlineStart: "0",
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
visibility: "hidden",
pointerEvents: "none",
contain: "layout style",
});
for (const cellElement of cellElements) {
const clone = cellElement.cloneNode(true) as HTMLElement;
clone.removeAttribute("id");
Object.assign(clone.style, {
position: "static",
width: "max-content",
minWidth: "0",
maxWidth: "none",
});
for (const element of clone.querySelectorAll<HTMLElement>("*")) {
element.style.whiteSpace = "nowrap";
element.style.flexWrap = "nowrap";
element.style.webkitLineClamp = "unset";
}
measurer.append(clone);
}
gridElement.append(measurer);
let contentSize =
wrapperContentSize > 0 ? wrapperContentSize + cellChromeSize : 0;
for (const clone of measurer.children) {
contentSize = Math.max(contentSize, clone.getBoundingClientRect().width);
}
measurer.remove();
return Math.min(maxSize, Math.max(minSize, Math.ceil(contentSize)));
}
export function getScrollDirection(
direction: string,
): "left" | "right" | "home" | "end" | undefined {
if (
direction === "left" ||
direction === "right" ||
direction === "home" ||
direction === "end"
) {
return direction as "left" | "right" | "home" | "end";
}
if (direction === "pageleft") return "left";
if (direction === "pageright") return "right";
return undefined;
}
export function scrollCellIntoView<TData extends RowData>(params: {
container: HTMLDivElement;
targetCell: HTMLDivElement;
tableRef: React.RefObject<Table<DataGridFeatures, TData> | null>;
viewportOffset: number;
direction?: "left" | "right" | "home" | "end";
isRtl: boolean;
}): void {
const { container, targetCell, tableRef, direction, viewportOffset, isRtl } =
params;
const containerRect = container.getBoundingClientRect();
const cellRect = targetCell.getBoundingClientRect();
const hasNegativeScroll = container.scrollLeft < 0;
const isActuallyRtl = isRtl || hasNegativeScroll;
const currentTable = tableRef.current;
const leftPinnedColumns = currentTable?.getStartVisibleLeafColumns() ?? [];
const rightPinnedColumns = currentTable?.getEndVisibleLeafColumns() ?? [];
const leftPinnedWidth = leftPinnedColumns.reduce(
(sum, c) => sum + c.getSize(),
0,
);
const rightPinnedWidth = rightPinnedColumns.reduce(
(sum, c) => sum + c.getSize(),
0,
);
const viewportLeft = isActuallyRtl
? containerRect.left + rightPinnedWidth + viewportOffset
: containerRect.left + leftPinnedWidth + viewportOffset;
const viewportRight = isActuallyRtl
? containerRect.right - leftPinnedWidth - viewportOffset
: containerRect.right - rightPinnedWidth - viewportOffset;
const isFullyVisible =
cellRect.left >= viewportLeft && cellRect.right <= viewportRight;
if (isFullyVisible) return;
const isClippedLeft = cellRect.left < viewportLeft;
const isClippedRight = cellRect.right > viewportRight;
let scrollDelta = 0;
if (!direction) {
if (isClippedRight) {
scrollDelta = cellRect.right - viewportRight;
} else if (isClippedLeft) {
scrollDelta = -(viewportLeft - cellRect.left);
}
} else {
const shouldScrollRight = isActuallyRtl
? direction === "right" || direction === "home"
: direction === "right" || direction === "end";
if (shouldScrollRight) {
scrollDelta = cellRect.right - viewportRight;
} else {
scrollDelta = -(viewportLeft - cellRect.left);
}
}
container.scrollLeft += scrollDelta;
}
function countTabs(s: string): number {
let n = 0;
for (let i = 0; i < s.length; i++) if (s[i] === "\t") n++;
return n;
}
export function parseTsv(
text: string,
fallbackColumnCount: number,
): string[][] {
if (text.startsWith('"') || text.includes('\t"')) {
const rows: string[][] = [];
let currentRow: string[] = [];
let currentField = "";
let inQuotes = false;
let i = 0;
while (i < text.length) {
const char = text[i];
const nextChar = text[i + 1];
if (inQuotes) {
if (char === '"' && nextChar === '"') {
currentField += '"';
i += 2;
} else if (char === '"') {
inQuotes = false;
i++;
} else {
currentField += char;
i++;
}
} else {
if (char === '"' && currentField === "") {
inQuotes = true;
i++;
} else if (char === "\t") {
currentRow.push(currentField);
currentField = "";
i++;
} else if (char === "\n") {
currentRow.push(currentField);
if (currentRow.length > 1 || currentRow.some((f) => f.length > 0)) {
rows.push(currentRow);
}
currentRow = [];
currentField = "";
i++;
} else if (char === "\r" && nextChar === "\n") {
currentRow.push(currentField);
if (currentRow.length > 1 || currentRow.some((f) => f.length > 0)) {
rows.push(currentRow);
}
currentRow = [];
currentField = "";
i += 2;
} else {
currentField += char;
i++;
}
}
}
currentRow.push(currentField);
if (currentRow.length > 1 || currentRow.some((f) => f.length > 0)) {
rows.push(currentRow);
}
return rows;
}
const lines = text.split("\n").map((l) => l.replace(/\r$/, ""));
let maxTabCount = 0;
for (const line of lines) {
const n = countTabs(line);
if (n > maxTabCount) maxTabCount = n;
}
const columnCount = maxTabCount > 0 ? maxTabCount + 1 : fallbackColumnCount;
if (columnCount <= 0) return [];
const expectedTabCount = columnCount - 1;
const rows: string[][] = [];
let buf = "";
let bufTabCount = 0;
for (const line of lines) {
const tc = countTabs(line);
if (tc === expectedTabCount) {
if (buf && bufTabCount === expectedTabCount) rows.push(buf.split("\t"));
buf = "";
bufTabCount = 0;
rows.push(line.split("\t"));
} else {
buf = buf ? `${buf}\n${line}` : line;
bufTabCount += tc;
if (bufTabCount === expectedTabCount) {
rows.push(buf.split("\t"));
buf = "";
bufTabCount = 0;
}
}
}
if (buf && bufTabCount === expectedTabCount) rows.push(buf.split("\t"));
return rows.length > 0
? rows
: lines.filter((l) => l.length > 0).map((l) => l.split("\t"));
}
export function getIsInPopover(element: unknown): boolean {
if (!(element instanceof Element)) return false;
return (
element.closest("[data-grid-cell-editor]") !== null ||
element.closest("[data-grid-popover]") !== null ||
element.closest("[data-slot='dropdown-menu-content']") !== null ||
element.closest("[data-slot='popover-content']") !== null ||
element.closest("[data-slot='select-content']") !== null ||
element.closest("[data-slot='faceted-content']") !== null
);
}
export function getColumnVariant(variant?: CellOpts["variant"]): {
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
label: string;
} | null {
switch (variant) {
case "short-text":
return { label: "Short text", icon: BaselineIcon };
case "long-text":
return { label: "Long text", icon: TextInitialIcon };
case "number":
return { label: "Number", icon: HashIcon };
case "url":
return { label: "URL", icon: LinkIcon };
case "checkbox":
return { label: "Checkbox", icon: CheckSquareIcon };
case "select":
return { label: "Select", icon: ListIcon };
case "multi-select":
return { label: "Multi-select", icon: ListChecksIcon };
case "date":
return { label: "Date", icon: CalendarIcon };
case "file":
return { label: "File", icon: FileIcon };
default:
return null;
}
}
export function getEmptyCellValue(
variant: CellOpts["variant"] | undefined,
): unknown {
if (variant === "multi-select" || variant === "file") return [];
if (variant === "number" || variant === "date" || variant === "select")
return null;
if (variant === "checkbox") return false;
return "";
}
export function getUrlHref(urlString: string): string {
if (!urlString || urlString.trim() === "") return "";
const trimmed = urlString.trim();
// Reject dangerous protocols (extra safety, though our http:// prefix would neutralize them)
if (/^(javascript|data|vbscript|file):/i.test(trimmed)) {
return "";
}
if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
return trimmed;
}
return `http://${trimmed}`;
}
export function parseLocalDate(dateStr: unknown): Date | null {
if (!dateStr) return null;
if (dateStr instanceof Date) return dateStr;
if (typeof dateStr !== "string") return null;
const [year, month, day] = dateStr.split("-").map(Number);
if (!year || !month || !day) return null;
const date = new Date(year, month - 1, day);
// Verify date wasn't auto-corrected (e.g. Feb 30 -> Mar 1)
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return null;
}
return date;
}
export function formatDateToString(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function formatDateForDisplay(dateStr: unknown): string {
if (!dateStr) return "";
const date = parseLocalDate(dateStr);
if (!date) return typeof dateStr === "string" ? dateStr : "";
return date.toLocaleDateString();
}
export function formatFileSize(bytes: number): string {
if (bytes <= 0 || !Number.isFinite(bytes)) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB"];
const i = Math.min(
sizes.length - 1,
Math.floor(Math.log(bytes) / Math.log(k)),
);
return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`;
}
export function getFileIcon(
type: string,
): React.ComponentType<React.SVGProps<SVGSVGElement>> {
if (type.startsWith("image/")) return FileImage;
if (type.startsWith("video/")) return FileVideo;
if (type.startsWith("audio/")) return FileAudio;
if (type.includes("pdf")) return FileText;
if (type.includes("zip") || type.includes("rar")) return FileArchive;
if (
type.includes("word") ||
type.includes("document") ||
type.includes("doc")
)
return FileText;
if (type.includes("sheet") || type.includes("excel") || type.includes("xls"))
return FileSpreadsheet;
if (
type.includes("presentation") ||
type.includes("powerpoint") ||
type.includes("ppt")
)
return Presentation;
return File;
}
src/lib/data-grid-types.ts
import type { Cell, RowData } from "@tanstack/react-table";
import type { DataGridFeatures } from "@/lib/data-grid-features";
export type Direction = "ltr" | "rtl";
export type RowHeightValue = "short" | "medium" | "tall" | "extra-tall";
export interface CellSelectOption {
label: string;
value: string;
icon?: React.ComponentType<React.ComponentProps<"svg">>;
count?: number;
}
export type CellOpts =
| {
variant: "short-text";
}
| {
variant: "long-text";
}
| {
variant: "number";
min?: number;
max?: number;
step?: number;
}
| {
variant: "select";
options: CellSelectOption[];
}
| {
variant: "multi-select";
options: CellSelectOption[];
}
| {
variant: "checkbox";
}
| {
variant: "date";
}
| {
variant: "url";
}
| {
variant: "file";
maxFileSize?: number;
maxFiles?: number;
accept?: string;
multiple?: boolean;
};
export interface CellUpdate {
rowId: string;
columnId: string;
value: unknown;
}
export interface DataGridColumnMeta {
label?: string;
cell?: CellOpts;
}
export interface DataGridTableMeta {
dataGridRef?: React.RefObject<HTMLElement | null>;
cellMapRef?: React.RefObject<Map<string, HTMLDivElement>>;
focusedCell?: CellPosition | null;
editingCell?: CellPosition | null;
selectionState?: SelectionState;
searchOpen?: boolean;
getIsCellSelected?: (rowId: string, columnId: string) => boolean;
getIsSearchMatch?: (rowId: string, columnId: string) => boolean;
getIsActiveSearchMatch?: (rowId: string, columnId: string) => boolean;
scrollToCell?: (
rowId: string,
columnId: string,
align?: "auto" | "start" | "center" | "end",
) => void;
onRowSelect?: (rowId: string, checked: boolean, shiftKey: boolean) => void;
onDataUpdate?: (params: CellUpdate | Array<CellUpdate>) => void;
onRowsDelete?: (rowIds: string[]) => void | Promise<void>;
onColumnClick?: (columnId: string) => void;
onCellClick?: (
rowId: string,
columnId: string,
event?: React.MouseEvent,
) => void;
onCellDoubleClick?: (rowId: string, columnId: string) => void;
onCellMouseDown?: (
rowId: string,
columnId: string,
event: React.MouseEvent,
) => void;
onCellMouseEnter?: (rowId: string, columnId: string) => void;
onCellMouseUp?: () => void;
onCellContextMenu?: (
rowId: string,
columnId: string,
event: React.MouseEvent,
) => void;
onCellEditingStart?: (rowId: string, columnId: string) => void;
onCellEditingStop?: (opts?: {
direction?: NavigationDirection;
moveToNextRow?: boolean;
}) => void;
onCellsCopy?: () => void;
onCellsCut?: () => void;
onCellsPaste?: (expand?: boolean) => void;
onSelectionClear?: () => void;
onFilesUpload?: (params: {
files: File[];
rowId: string;
columnId: string;
}) => Promise<FileCellData[]>;
onFilesDelete?: (params: {
fileIds: string[];
rowId: string;
columnId: string;
}) => void | Promise<void>;
contextMenu?: ContextMenuState;
onContextMenuOpenChange?: (open: boolean) => void;
pasteDialog?: PasteDialogState;
onPasteDialogOpenChange?: (open: boolean) => void;
readOnly?: boolean;
}
export interface CellPosition {
rowId: string;
columnId: string;
}
export interface CellRange {
start: CellPosition;
end: CellPosition;
}
export interface SelectionState {
selectedCells: Set<string>;
selectionRange: CellRange | null;
isSelecting: boolean;
}
export interface ContextMenuState {
open: boolean;
x: number;
y: number;
}
export interface PasteDialogState {
open: boolean;
rowsNeeded: number;
clipboardText: string;
}
export type NavigationDirection =
| "up"
| "down"
| "left"
| "right"
| "home"
| "end"
| "ctrl+up"
| "ctrl+down"
| "ctrl+home"
| "ctrl+end"
| "pageup"
| "pagedown"
| "pageleft"
| "pageright"
| "tab"
| "shift+tab";
export interface SearchState {
searchMatches: CellPosition[];
matchIndex: number;
searchOpen: boolean;
onSearchOpenChange: (open: boolean) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
onSearch: (query: string) => void;
onNavigateToNextMatch: () => void;
onNavigateToPrevMatch: () => void;
}
export interface DataGridCellProps<TData extends RowData> {
cell: Cell<DataGridFeatures, TData>;
tableMeta: DataGridTableMeta;
rowId: string;
columnId: string;
rowHeight: RowHeightValue;
isEditing: boolean;
isFocused: boolean;
isSelected: boolean;
isSearchMatch: boolean;
isActiveSearchMatch: boolean;
readOnly: boolean;
}
export interface FileCellData {
id: string;
name: string;
size: number;
type: string;
url?: string;
}
export type TextFilterOperator =
| "contains"
| "notContains"
| "equals"
| "notEquals"
| "startsWith"
| "endsWith"
| "isEmpty"
| "isNotEmpty";
export type NumberFilterOperator =
| "equals"
| "notEquals"
| "lessThan"
| "lessThanOrEqual"
| "greaterThan"
| "greaterThanOrEqual"
| "isBetween"
| "isEmpty"
| "isNotEmpty";
export type DateFilterOperator =
| "equals"
| "notEquals"
| "before"
| "after"
| "onOrBefore"
| "onOrAfter"
| "isBetween"
| "isEmpty"
| "isNotEmpty";
export type SelectFilterOperator =
| "is"
| "isNot"
| "isAnyOf"
| "isNoneOf"
| "isEmpty"
| "isNotEmpty";
export type BooleanFilterOperator = "isTrue" | "isFalse";
export type FilterOperator =
| TextFilterOperator
| NumberFilterOperator
| DateFilterOperator
| SelectFilterOperator
| BooleanFilterOperator;
export interface FilterValue {
operator: FilterOperator;
value?: string | number | string[];
endValue?: string | number;
}
src/lib/data-grid-features.ts
import {
assignPrototypeAPIs,
assignTableAPIs,
type CellData,
columnFilteringFeature,
columnOrderingFeature,
columnPinningFeature,
columnResizingFeature,
columnSizingFeature,
columnVisibilityFeature,
createFilteredRowModel,
createSortedRowModel,
functionalUpdate,
makeStateUpdater,
metaHelper,
type OnChangeFn,
type RowData,
rowSelectionFeature,
rowSortingFeature,
type Table,
type TableFeature,
type TableFeatures,
tableFeatures,
type Updater,
} from "@tanstack/react-table";
import type {
CellPosition,
DataGridColumnMeta,
DataGridTableMeta,
RowHeightValue,
} from "@/lib/data-grid-types";
import { getLineCount, getRowHeightValue } from "@/lib/data-grid-utils";
const DEFAULT_ROW_HEIGHT: RowHeightValue = "short";
interface TableState_DataGridRowHeight {
rowHeight: RowHeightValue;
}
interface TableOptions_DataGridRowHeight {
onRowHeightChange?: OnChangeFn<RowHeightValue>;
}
interface Table_DataGridRowHeight {
getRowHeight: () => RowHeightValue;
getRowSize: () => number;
getRowLineCount: () => number;
setRowHeight: (updater: Updater<RowHeightValue>) => void;
resetRowHeight: (defaultState?: boolean) => void;
}
interface TableState_DataGridCellEditing {
editingCell: CellPosition | null;
}
interface TableOptions_DataGridCellEditing {
enableCellEditing?: boolean;
onEditingCellChange?: OnChangeFn<CellPosition | null>;
}
interface ColumnDef_DataGridCellEditing {
enableCellEditing?: boolean;
}
interface Table_DataGridCellEditing {
getEditingCell: () => CellPosition | null;
setEditingCell: (updater: Updater<CellPosition | null>) => void;
resetEditingCell: (defaultState?: boolean) => void;
}
interface Column_DataGridCellEditing {
getCanEdit: () => boolean;
}
interface Cell_DataGridCellEditing {
getCanEdit: () => boolean;
getIsEditing: () => boolean;
}
declare module "@tanstack/react-table" {
interface Plugins {
dataGridRowHeightFeature: TableFeature;
dataGridCellEditingFeature: TableFeature;
}
interface TableState_FeatureMap {
dataGridRowHeightFeature: TableState_DataGridRowHeight;
dataGridCellEditingFeature: TableState_DataGridCellEditing;
}
interface TableOptions_FeatureMap<
in out TFeatures extends TableFeatures,
in out TData extends RowData,
> {
dataGridRowHeightFeature: TableOptions_DataGridRowHeight;
dataGridCellEditingFeature: TableOptions_DataGridCellEditing;
}
interface ColumnDef_FeatureMap<
in out TFeatures extends TableFeatures,
in out TData extends RowData,
TValue extends CellData,
> {
dataGridCellEditingFeature: ColumnDef_DataGridCellEditing;
}
interface Table_FeatureMap<
in out TFeatures extends TableFeatures,
in out TData extends RowData,
> {
dataGridRowHeightFeature: Table_DataGridRowHeight;
dataGridCellEditingFeature: Table_DataGridCellEditing;
}
interface Column_FeatureMap<
in out TFeatures extends TableFeatures,
in out TData extends RowData,
> {
dataGridCellEditingFeature: Column_DataGridCellEditing;
}
interface Cell_FeatureMap {
dataGridCellEditingFeature: Cell_DataGridCellEditing;
}
}
type DataGridInstance = Table<DataGridFeatures, RowData>;
function asDataGrid(table: object) {
return table as DataGridInstance;
}
const dataGridRowHeightFeature: TableFeature = {
getInitialState: (initialState) => ({
rowHeight: DEFAULT_ROW_HEIGHT,
...initialState,
}),
getDefaultTableOptions: (table) => {
const options: TableOptions_DataGridRowHeight = {
onRowHeightChange: makeStateUpdater("rowHeight", table),
};
return options;
},
constructTableAPIs: (table) => {
const instance = asDataGrid(table);
const setRowHeight = (updater: Updater<RowHeightValue>) =>
instance.options.onRowHeightChange?.((old) =>
functionalUpdate(updater, old),
);
assignTableAPIs("dataGridRowHeightFeature", table, {
table_getRowHeight: {
fn: () => instance.atoms.rowHeight.get(),
},
table_getRowSize: {
fn: () => getRowHeightValue(instance.atoms.rowHeight.get()),
},
table_getRowLineCount: {
fn: () => getLineCount(instance.atoms.rowHeight.get()),
},
table_setRowHeight: { fn: setRowHeight },
table_resetRowHeight: {
fn: (defaultState?: boolean) =>
setRowHeight(
defaultState
? DEFAULT_ROW_HEIGHT
: (instance.initialState.rowHeight ?? DEFAULT_ROW_HEIGHT),
),
},
});
},
};
function getCanEditColumn(column: {
table: object;
columnDef: ColumnDef_DataGridCellEditing;
}) {
return (
asDataGrid(column.table).options.enableCellEditing !== false &&
column.columnDef.enableCellEditing !== false
);
}
const dataGridCellEditingFeature: TableFeature = {
getInitialState: (initialState) => ({
editingCell: null,
...initialState,
}),
getDefaultTableOptions: (table) => {
const options: TableOptions_DataGridCellEditing = {
onEditingCellChange: makeStateUpdater("editingCell", table),
};
return options;
},
constructTableAPIs: (table) => {
const instance = asDataGrid(table);
const setEditingCell = (updater: Updater<CellPosition | null>) =>
instance.options.onEditingCellChange?.((old) =>
functionalUpdate(updater, old),
);
assignTableAPIs("dataGridCellEditingFeature", table, {
table_getEditingCell: {
fn: () => instance.atoms.editingCell.get(),
},
table_setEditingCell: { fn: setEditingCell },
table_resetEditingCell: {
fn: (defaultState?: boolean) =>
setEditingCell(
defaultState ? null : (instance.initialState.editingCell ?? null),
),
},
});
},
assignColumnPrototype: (prototype, table) => {
assignPrototypeAPIs("dataGridCellEditingFeature", prototype, table, {
column_getCanEdit: { fn: getCanEditColumn },
});
},
assignCellPrototype: (prototype, table) => {
assignPrototypeAPIs("dataGridCellEditingFeature", prototype, table, {
cell_getCanEdit: {
fn: (cell: { column: Parameters<typeof getCanEditColumn>[0] }) =>
getCanEditColumn(cell.column),
},
cell_getIsEditing: {
fn: (cell: {
table: object;
row: { id: string };
column: { id: string };
}) => {
const editingCell = asDataGrid(cell.table).atoms.editingCell.get();
return (
editingCell?.rowId === cell.row.id &&
editingCell.columnId === cell.column.id
);
},
},
});
},
};
export const dataGridFeatures = tableFeatures({
columnFilteringFeature,
columnOrderingFeature,
columnPinningFeature,
columnResizingFeature,
columnSizingFeature,
columnVisibilityFeature,
rowSelectionFeature,
rowSortingFeature,
dataGridRowHeightFeature,
dataGridCellEditingFeature,
filteredRowModel: createFilteredRowModel(),
sortedRowModel: createSortedRowModel(),
tableMeta: metaHelper<DataGridTableMeta>(),
columnMeta: metaHelper<DataGridColumnMeta>(),
});
export type DataGridFeatures = typeof dataGridFeatures;