Shadcn Table
componentdata-grid-filter-menusource: ok · hit
Data Grid Filter Menu
A filter menu component for the data grid with drag-and-drop reordering and advanced filtering
npx shadcn add "https://tablecn.com/r/data-grid-filter-menu.json"free
Interaction
dragdropdown
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
ui-primitives@base-ui/react
Registry dependencies
badgebuttoncalendarcommandinputpopoverselect
Files10 · source cached 10/14/2026, 1:32:31 PM
src/registry/bases/base/components/data-grid/data-grid-filter-menu.tsx
"use client";
import type {
Column,
ColumnFilter,
RowData,
Table,
} from "@tanstack/react-table";
import { cn } from "cn";
import * as React from "react";
import type { DataGridFeatures } from "@/lib/data-grid-features";
import type { FilterOperator, FilterValue } from "@/lib/data-grid-types";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import {
getDefaultOperator,
getOperatorsForVariant,
} from "@/lib/data-grid-filters";
import { formatDate } from "@/lib/format";
import { Badge } from "@/registry/bases/base/ui/badge";
import { Button } from "@/registry/bases/base/ui/button";
import { useDirection } from "@/registry/bases/base/ui/direction";
import {
Faceted,
FacetedContent,
FacetedEmpty,
FacetedGroup,
FacetedInput,
FacetedItem,
FacetedItemIndicator,
FacetedList,
FacetedTrigger,
FacetedValue,
} from "@/registry/bases/base/ui/faceted";
import { Input } from "@/registry/bases/base/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/bases/base/ui/popover";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/bases/base/ui/select";
import {
Sortable,
SortableContent,
SortableItem,
SortableItemHandle,
SortableOverlay,
} from "@/registry/bases/base/ui/sortable";
import { Calendar } from "@/registry/bases/radix/ui/calendar";
import { IconPlaceholder } from "@/registry/icons/icon-placeholder";
const FILTER_SHORTCUT_KEY = "f";
const REMOVE_FILTER_SHORTCUTS = new Set(["backspace", "delete"]);
const FILTER_DEBOUNCE_MS = 300;
const OPERATORS_WITHOUT_VALUE = new Set([
"isEmpty",
"isNotEmpty",
"isTrue",
"isFalse",
]);
interface DataGridFilterMenuProps<
TData extends RowData,
> extends React.ComponentProps<typeof PopoverContent> {
table: Table<DataGridFeatures, TData>;
disabled?: boolean;
}
export function DataGridFilterMenu<TData extends RowData>({
table,
disabled,
className,
...props
}: DataGridFilterMenuProps<TData>) {
const dir = useDirection();
const id = React.useId();
const labelId = React.useId();
const descriptionId = React.useId();
const [open, setOpen] = React.useState(false);
const addButtonRef = React.useRef<HTMLButtonElement>(null);
const columnFilters = table.store.state.columnFilters;
const { columnLabels, columns, columnVariants } = React.useMemo(() => {
const labels = new Map<string, string>();
const variants = new Map<string, string>();
const filteringIds = new Set(columnFilters.map((f) => f.id));
const availableColumns: { id: string; label: string }[] = [];
for (const column of table.getAllColumns()) {
if (!column.getCanFilter()) continue;
const label = column.columnDef.meta?.label ?? column.id;
const variant = column.columnDef.meta?.cell?.variant ?? "short-text";
labels.set(column.id, label);
variants.set(column.id, variant);
if (!filteringIds.has(column.id)) {
availableColumns.push({ id: column.id, label });
}
}
return {
columnLabels: labels,
columns: availableColumns,
columnVariants: variants,
};
}, [columnFilters, table]);
const onFilterAdd = React.useCallback(() => {
const firstColumn = columns[0];
if (!firstColumn) return;
const variant = columnVariants.get(firstColumn.id) ?? "short-text";
const defaultOperator = getDefaultOperator(variant);
table.setColumnFilters((prevFilters) => [
...prevFilters,
{
id: firstColumn.id,
value: {
operator: defaultOperator,
value: "",
},
},
]);
}, [columns, columnVariants, table]);
const onFilterUpdate = React.useCallback(
(filterId: string, updates: Partial<ColumnFilter>) => {
table.setColumnFilters((prevFilters) => {
if (!prevFilters) return prevFilters;
return prevFilters.map((filter) =>
filter.id === filterId ? { ...filter, ...updates } : filter,
);
});
},
[table],
);
const onFilterRemove = React.useCallback(
(filterId: string) => {
table.setColumnFilters((prevFilters) =>
prevFilters.filter((item) => item.id !== filterId),
);
},
[table],
);
const onFiltersReset = React.useCallback(() => {
table.setColumnFilters(table.initialState.columnFilters ?? []);
}, [table]);
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement ||
(event.target instanceof HTMLElement &&
event.target.contentEditable === "true")
) {
return;
}
if (
event.key.toLowerCase() === FILTER_SHORTCUT_KEY &&
(event.ctrlKey || event.metaKey) &&
event.shiftKey
) {
event.preventDefault();
setOpen((prev) => !prev);
}
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const onTriggerKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLButtonElement>) => {
if (
REMOVE_FILTER_SHORTCUTS.has(event.key.toLowerCase()) &&
columnFilters.length > 0
) {
event.preventDefault();
onFiltersReset();
}
},
[columnFilters.length, onFiltersReset],
);
return (
<Sortable
value={columnFilters}
onValueChange={table.setColumnFilters}
getItemValue={(item) => item.id}
>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
dir={dir}
variant="outline"
onKeyDown={onTriggerKeyDown}
disabled={disabled}
/>
}
>
<IconPlaceholder
lucide="ListFilter"
tabler="IconListDetails"
hugeicons="LeftToRightListBulletIcon"
phosphor="ListIcon"
remixicon="RiListUnordered"
className="text-muted-foreground"
/>
Filter
{columnFilters.length > 0 && (
<Badge
variant="secondary"
className="h-[18.24px] px-[5.12px] font-mono text-[10.4px]"
>
{columnFilters.length}
</Badge>
)}
</PopoverTrigger>
<PopoverContent
aria-labelledby={labelId}
aria-describedby={descriptionId}
dir={dir}
className={cn(
"flex w-full max-w-(--available-width) flex-col gap-3.5 p-4 sm:min-w-120",
className,
)}
{...props}
>
<div className="flex flex-col gap-1">
<h4 id={labelId} className="leading-none font-medium">
{columnFilters.length > 0 ? "Filter by" : "No filters applied"}
</h4>
<p
id={descriptionId}
className={cn(
"text-sm text-muted-foreground",
columnFilters.length > 0 && "sr-only",
)}
>
{columnFilters.length > 0
? "Modify filters to narrow down your data."
: "Add filters to narrow down your data."}
</p>
</div>
{columnFilters.length > 0 && (
<SortableContent
render={
<div
role="list"
className="grid max-h-100 grid-cols-[minmax(4.5rem,auto)_8rem_8rem_minmax(10rem,max-content)_auto_auto] gap-x-2 gap-y-2 overflow-y-auto p-1"
/>
}
>
{columnFilters.map((filter, index) => (
<DataGridFilterItem
key={filter.id}
filter={filter}
index={index}
filterItemId={`${id}-filter-${filter.id}`}
dir={dir}
columns={columns}
columnLabels={columnLabels}
columnVariants={columnVariants}
table={table}
onFilterUpdate={onFilterUpdate}
onFilterRemove={onFilterRemove}
/>
))}
</SortableContent>
)}
<div className="flex w-full items-center gap-2">
<Button
ref={addButtonRef}
onClick={onFilterAdd}
disabled={columns.length === 0}
>
Add filter
</Button>
{columnFilters.length > 0 && (
<Button variant="outline" onClick={onFiltersReset}>
Reset filters
</Button>
)}
</div>
</PopoverContent>
</Popover>
<SortableOverlay>
<div
dir={dir}
className="grid size-full grid-cols-[minmax(4.5rem,auto)_8rem_8rem_minmax(10rem,1fr)_auto_auto] items-center gap-2"
>
<div className="h-8 rounded-lg bg-primary/10" />
<div className="h-8 rounded-lg bg-primary/10" />
<div className="h-8 rounded-lg bg-primary/10" />
<div className="h-8 rounded-lg bg-primary/10" />
<div className="size-8 rounded-lg bg-primary/10" />
<div className="size-8 rounded-lg bg-primary/10" />
</div>
</SortableOverlay>
</Sortable>
);
}
interface DataGridFilterItemProps<TData extends RowData> {
filter: ColumnFilter;
index: number;
filterItemId: string;
dir: "ltr" | "rtl";
columns: { id: string; label: string }[];
columnLabels: Map<string, string>;
columnVariants: Map<string, string>;
table: Table<DataGridFeatures, TData>;
onFilterUpdate: (filterId: string, updates: Partial<ColumnFilter>) => void;
onFilterRemove: (filterId: string) => void;
}
function DataGridFilterItem<TData extends RowData>({
filter,
index,
filterItemId,
dir,
columns,
columnLabels,
columnVariants,
table,
onFilterUpdate,
onFilterRemove,
}: DataGridFilterItemProps<TData>) {
const fieldListboxId = `${filterItemId}-field-listbox`;
const fieldTriggerId = `${filterItemId}-field-trigger`;
const operatorListboxId = `${filterItemId}-operator-listbox`;
const inputId = `${filterItemId}-input`;
const [showFieldSelector, setShowFieldSelector] = React.useState(false);
const [showOperatorSelector, setShowOperatorSelector] = React.useState(false);
const variant = columnVariants.get(filter.id) ?? "short-text";
const filterValue = filter.value as FilterValue | undefined;
const operator = filterValue?.operator ?? getDefaultOperator(variant);
const operators = getOperatorsForVariant(variant);
const needsValue = !OPERATORS_WITHOUT_VALUE.has(operator);
const column = table.getColumn(filter.id);
const onItemKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
return;
}
if (showFieldSelector || showOperatorSelector) {
return;
}
if (REMOVE_FILTER_SHORTCUTS.has(event.key.toLowerCase())) {
event.preventDefault();
onFilterRemove(filter.id);
}
},
[filter.id, showFieldSelector, showOperatorSelector, onFilterRemove],
);
const onOperatorChange = React.useCallback(
(newOperator: FilterOperator) => {
onFilterUpdate(filter.id, {
value: {
operator: newOperator,
value: filterValue?.value,
endValue: filterValue?.endValue,
},
});
},
[filter.id, filterValue?.value, filterValue?.endValue, onFilterUpdate],
);
const onValueChange = React.useCallback(
(newValue: string | number | string[] | undefined) => {
onFilterUpdate(filter.id, {
value: {
operator,
value: newValue,
endValue: filterValue?.endValue,
},
});
},
[filter.id, operator, filterValue?.endValue, onFilterUpdate],
);
const onEndValueChange = React.useCallback(
(newValue: string | number | string[] | undefined) => {
onFilterUpdate(filter.id, {
value: {
operator,
value: filterValue?.value,
endValue: newValue as string | number | undefined,
},
});
},
[filter.id, operator, filterValue?.value, onFilterUpdate],
);
return (
<SortableItem
value={filter.id}
render={
<div
role="listitem"
id={filterItemId}
tabIndex={-1}
className="col-span-full grid grid-cols-subgrid items-center"
onKeyDown={onItemKeyDown}
/>
}
>
<div className="min-w-18 text-center">
{index === 0 ? (
<span className="text-sm text-muted-foreground">Where</span>
) : (
<span className="text-sm text-muted-foreground">And</span>
)}
</div>
<Faceted
open={showFieldSelector}
onOpenChange={setShowFieldSelector}
onValueChange={(columnId) => {
if (!columnId) return;
const newVariant = columnVariants.get(columnId) ?? "short-text";
const newOperator = getDefaultOperator(newVariant);
table.setColumnFilters((prevFilters) =>
prevFilters.map((f) =>
f.id === filter.id
? {
id: columnId,
value: {
operator: newOperator,
value: "",
},
}
: f,
),
);
}}
>
<FacetedTrigger
render={
<Button
id={fieldTriggerId}
aria-controls={fieldListboxId}
dir={dir}
variant="outline"
className="w-32"
/>
}
>
<span className="truncate">{columnLabels.get(filter.id)}</span>
<IconPlaceholder
lucide="ChevronsUpDown"
tabler="IconSelector"
hugeicons="UnfoldMoreIcon"
phosphor="CaretUpDownIcon"
remixicon="RiArrowUpDownLine"
className="opacity-50"
/>
</FacetedTrigger>
<FacetedContent id={fieldListboxId} dir={dir} className="w-40">
<FacetedInput placeholder="Search fields..." />
<FacetedList>
<FacetedEmpty>No fields found.</FacetedEmpty>
<FacetedGroup>
{columns.map((column) => (
<FacetedItem
key={column.id}
value={column.id}
keywords={[column.label]}
>
<span className="truncate">{column.label}</span>
<FacetedItemIndicator />
</FacetedItem>
))}
</FacetedGroup>
</FacetedList>
</FacetedContent>
</Faceted>
<Select
open={showOperatorSelector}
onOpenChange={setShowOperatorSelector}
value={operator}
onValueChange={(value) => {
if (value == null) return;
onOperatorChange(value);
}}
>
<SelectTrigger
aria-controls={operatorListboxId}
className="w-32 lowercase"
>
<div className="truncate">
<SelectValue />
</div>
</SelectTrigger>
<SelectContent id={operatorListboxId}>
<SelectGroup>
{operators.map((op) => (
<SelectItem key={op.value} value={op.value} className="lowercase">
{op.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className="w-full min-w-0">
{needsValue && column ? (
<DataGridFilterInput
key={filter.id}
variant={variant}
operator={operator}
column={column}
inputId={inputId}
dir={dir}
value={filterValue?.value}
endValue={filterValue?.endValue}
onValueChange={onValueChange}
onEndValueChange={onEndValueChange}
/>
) : (
<div
id={inputId}
role="status"
aria-label={`${columnLabels.get(filter.id)} filter is empty`}
aria-live="polite"
className="h-8 w-full rounded-lg border border-dashed border-input bg-transparent dark:bg-input/30"
/>
)}
</div>
<Button
aria-controls={filterItemId}
variant="outline"
size="icon"
onClick={() => onFilterRemove(filter.id)}
>
<IconPlaceholder
lucide="Trash2"
tabler="IconTrash"
hugeicons="Delete02Icon"
phosphor="TrashIcon"
remixicon="RiDeleteBinLine"
/>
</Button>
<SortableItemHandle render={<Button variant="outline" size="icon" />}>
<IconPlaceholder
lucide="GripVertical"
tabler="IconGripVertical"
hugeicons="DragDropVerticalIcon"
phosphor="DotsSixVerticalIcon"
remixicon="RiDraggable"
/>
</SortableItemHandle>
</SortableItem>
);
}
interface DataGridFilterInputProps<TData extends RowData> {
variant: string;
operator: FilterOperator;
dir: "ltr" | "rtl";
placeholder?: string;
value: string | number | string[] | undefined;
endValue?: string | number;
column: Column<DataGridFeatures, TData>;
inputId: string;
onValueChange: (value: string | number | string[] | undefined) => void;
onEndValueChange?: (value: string | number | string[] | undefined) => void;
}
function DataGridFilterInput<TData extends RowData>({
variant,
operator,
dir,
placeholder = "Value",
value,
endValue,
column,
inputId,
onValueChange,
onEndValueChange,
}: DataGridFilterInputProps<TData>) {
const [showValueSelector, setShowValueSelector] = React.useState(false);
const [localValue, setLocalValue] = React.useState(value);
const [localEndValue, setLocalEndValue] = React.useState(endValue);
const debouncedOnChange = useDebouncedCallback(
(newValue: string | number | string[] | undefined) => {
onValueChange(newValue);
},
FILTER_DEBOUNCE_MS,
);
const debouncedOnEndValueChange = useDebouncedCallback(
(newValue: string | number | string[] | undefined) => {
onEndValueChange?.(newValue);
},
FILTER_DEBOUNCE_MS,
);
const cellVariant = column.columnDef.meta?.cell;
const selectOptions = React.useMemo(() => {
return cellVariant?.variant === "select" ||
cellVariant?.variant === "multi-select"
? cellVariant.options
: [];
}, [cellVariant]);
const isBetween = operator === "isBetween";
if (variant === "number") {
if (isBetween) {
return (
<div className="flex gap-2">
<Input
id={inputId}
type="number"
inputMode="numeric"
placeholder="Start"
value={(localValue as number | undefined) ?? ""}
onChange={(event) => {
const val = event.target.value;
const newValue = val === "" ? undefined : Number(val);
setLocalValue(newValue);
debouncedOnChange(newValue);
}}
className="w-full flex-1"
/>
<Input
id={`${inputId}-end`}
type="number"
inputMode="numeric"
placeholder="End"
value={(localEndValue as number | undefined) ?? ""}
onChange={(event) => {
const val = event.target.value;
const newValue = val === "" ? undefined : Number(val);
setLocalEndValue(newValue);
debouncedOnEndValueChange(newValue);
}}
className="w-full flex-1"
/>
</div>
);
}
return (
<Input
id={inputId}
type="number"
inputMode="numeric"
placeholder={placeholder}
value={(localValue as number | undefined) ?? ""}
onChange={(event) => {
const val = event.target.value;
const newValue = val === "" ? undefined : Number(val);
setLocalValue(newValue);
debouncedOnChange(newValue);
}}
/>
);
}
if (variant === "date") {
const inputListboxId = `${inputId}-listbox`;
if (isBetween) {
const startDate =
localValue && typeof localValue === "string"
? new Date(localValue)
: undefined;
const endDate =
localEndValue && typeof localEndValue === "string"
? new Date(localEndValue)
: undefined;
const isSameDate =
startDate &&
endDate &&
startDate.toDateString() === endDate.toDateString();
const displayValue =
startDate && endDate && !isSameDate
? `${formatDate(startDate, { month: "short" })} - ${formatDate(endDate, { month: "short" })}`
: startDate
? formatDate(startDate, { month: "short" })
: "Pick a range";
return (
<Popover open={showValueSelector} onOpenChange={setShowValueSelector}>
<PopoverTrigger
render={
<Button
id={inputId}
aria-controls={inputListboxId}
dir={dir}
variant="outline"
className={cn(
"w-full justify-start",
!startDate && "text-muted-foreground",
)}
/>
}
>
<IconPlaceholder
lucide="CalendarIcon"
tabler="IconCalendar"
hugeicons="CalendarIcon"
phosphor="CalendarIcon"
remixicon="RiCalendarLine"
/>
<span className="truncate">{displayValue}</span>
</PopoverTrigger>
<PopoverContent
id={inputListboxId}
dir={dir}
align="start"
className="w-auto p-0"
>
<Calendar
autoFocus
captionLayout="dropdown"
mode="range"
selected={
startDate && endDate
? { from: startDate, to: endDate }
: startDate
? { from: startDate, to: startDate }
: undefined
}
onSelect={(range) => {
const fromValue = range?.from
? range.from.toISOString()
: undefined;
const toValue = range?.to ? range.to.toISOString() : undefined;
setLocalValue(fromValue);
setLocalEndValue(toValue);
onValueChange(fromValue);
onEndValueChange?.(toValue);
}}
/>
</PopoverContent>
</Popover>
);
}
const dateValue =
localValue && typeof localValue === "string"
? new Date(localValue)
: undefined;
return (
<Popover open={showValueSelector} onOpenChange={setShowValueSelector}>
<PopoverTrigger
render={
<Button
id={inputId}
aria-controls={inputListboxId}
dir={dir}
variant="outline"
className={cn(
"w-full justify-start",
!dateValue && "text-muted-foreground",
)}
/>
}
>
<IconPlaceholder
lucide="CalendarIcon"
tabler="IconCalendar"
hugeicons="CalendarIcon"
phosphor="CalendarIcon"
remixicon="RiCalendarLine"
/>
<span className="truncate">
{dateValue
? formatDate(dateValue, { month: "short" })
: "Pick a date"}
</span>
</PopoverTrigger>
<PopoverContent
id={inputListboxId}
dir={dir}
align="start"
className="w-auto p-0"
>
<Calendar
autoFocus
captionLayout="dropdown"
mode="single"
selected={dateValue}
onSelect={(date) => {
const newValue = date ? date.toISOString() : undefined;
setLocalValue(newValue);
onValueChange(newValue);
setShowValueSelector(false);
}}
/>
</PopoverContent>
</Popover>
);
}
const isSelectVariant = variant === "select" || variant === "multi-select";
const isMultiValueOperator =
operator === "isAnyOf" || operator === "isNoneOf";
if (isSelectVariant && selectOptions.length > 0) {
const inputListboxId = `${inputId}-listbox`;
if (isMultiValueOperator) {
const selectedValues = Array.isArray(value) ? value : [];
return (
<Faceted
open={showValueSelector}
onOpenChange={setShowValueSelector}
value={selectedValues}
onValueChange={(nextValue) => {
const values = nextValue ?? [];
onValueChange(values.length > 0 ? values : undefined);
}}
items={selectOptions}
multiple
>
<FacetedTrigger
render={
<Button
id={inputId}
aria-controls={inputListboxId}
dir={dir}
variant="outline"
className="w-full"
/>
}
>
<FacetedValue placeholder={placeholder} />
</FacetedTrigger>
<FacetedContent id={inputListboxId} dir={dir} className="w-48">
<FacetedInput placeholder="Search options..." />
<FacetedList>
<FacetedEmpty>No options found.</FacetedEmpty>
<FacetedGroup>
{selectOptions.map((option) => (
<FacetedItem
key={option.value}
value={option.value}
keywords={[option.label]}
>
{option.icon && <option.icon />}
<span className="truncate">{option.label}</span>
<FacetedItemIndicator>{option.count}</FacetedItemIndicator>
</FacetedItem>
))}
</FacetedGroup>
</FacetedList>
</FacetedContent>
</Faceted>
);
}
return (
<Faceted
open={showValueSelector}
onOpenChange={setShowValueSelector}
value={typeof value === "string" ? value : undefined}
onValueChange={(nextValue) => onValueChange(nextValue)}
items={selectOptions}
>
<FacetedTrigger
render={
<Button
id={inputId}
aria-controls={inputListboxId}
dir={dir}
variant="outline"
className="w-full"
/>
}
>
<FacetedValue placeholder={placeholder} />
</FacetedTrigger>
<FacetedContent id={inputListboxId} dir={dir} className="w-50">
<FacetedInput placeholder="Search options..." />
<FacetedList>
<FacetedEmpty>No options found.</FacetedEmpty>
<FacetedGroup>
{selectOptions.map((option) => (
<FacetedItem
key={option.value}
value={option.value}
keywords={[option.label]}
>
{option.icon && <option.icon />}
<span className="truncate">{option.label}</span>
<FacetedItemIndicator>{option.count}</FacetedItemIndicator>
</FacetedItem>
))}
</FacetedGroup>
</FacetedList>
</FacetedContent>
</Faceted>
);
}
if (isBetween) {
return (
<div className="flex gap-2">
<Input
id={inputId}
type="text"
placeholder="Start"
className="w-full flex-1"
value={(localValue as string | undefined) ?? ""}
onChange={(event) => {
const val = event.target.value;
const newValue = val === "" ? undefined : val;
setLocalValue(newValue);
debouncedOnChange(newValue);
}}
/>
<Input
id={`${inputId}-end`}
type="text"
placeholder="End"
className="w-full flex-1"
value={(localEndValue as string | undefined) ?? ""}
onChange={(event) => {
const val = event.target.value;
const newValue = val === "" ? undefined : val;
setLocalEndValue(newValue);
debouncedOnEndValueChange(newValue);
}}
/>
</div>
);
}
return (
<Input
id={inputId}
type="text"
placeholder={placeholder}
value={(localValue as string | undefined) ?? ""}
onChange={(event) => {
const val = event.target.value;
const newValue = val === "" ? undefined : val;
setLocalValue(newValue);
debouncedOnChange(newValue);
}}
/>
);
}
src/registry/bases/base/ui/direction.tsx
"use client";
export {
DirectionProvider,
useDirection,
} from "@base-ui/react/direction-provider";
src/registry/bases/base/ui/sortable.tsx
"use client";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { useIsoLayoutEffect } from "@base-ui/utils/useIsoLayoutEffect";
import {
type Announcements,
closestCenter,
closestCorners,
defaultDropAnimationSideEffects,
DndContext,
type DndContextProps,
type DragEndEvent,
type DraggableAttributes,
type DraggableSyntheticListeners,
DragOverlay,
type DragStartEvent,
type DropAnimation,
KeyboardSensor,
MouseSensor,
type ScreenReaderInstructions,
TouchSensor,
type UniqueIdentifier,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
restrictToHorizontalAxis,
restrictToParentElement,
restrictToVerticalAxis,
} from "@dnd-kit/modifiers";
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
type SortableContextProps,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "cn";
import * as React from "react";
import * as ReactDOM from "react-dom";
const orientationConfig = {
vertical: {
modifiers: [restrictToVerticalAxis, restrictToParentElement],
strategy: verticalListSortingStrategy,
collisionDetection: closestCenter,
},
horizontal: {
modifiers: [restrictToHorizontalAxis, restrictToParentElement],
strategy: horizontalListSortingStrategy,
collisionDetection: closestCenter,
},
mixed: {
modifiers: [restrictToParentElement],
strategy: undefined,
collisionDetection: closestCorners,
},
};
const ROOT_NAME = "Sortable";
const CONTENT_NAME = "SortableContent";
const ITEM_NAME = "SortableItem";
const ITEM_HANDLE_NAME = "SortableItemHandle";
const OVERLAY_NAME = "SortableOverlay";
interface SortableRootContextValue<T> {
id: string;
items: UniqueIdentifier[];
modifiers: DndContextProps["modifiers"];
strategy: SortableContextProps["strategy"];
activeId: UniqueIdentifier | null;
setActiveId: (id: UniqueIdentifier | null) => void;
getItemValue: (item: T) => UniqueIdentifier;
flatCursor: boolean;
}
const SortableRootContext =
React.createContext<SortableRootContextValue<unknown> | null>(null);
function useSortableContext(consumerName: string) {
const context = React.useContext(SortableRootContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ROOT_NAME}\``);
}
return context;
}
interface GetItemValue<T> {
/**
* Callback that returns a unique identifier for each sortable item. Required for array of objects.
* @example getItemValue={(item) => item.id}
*/
getItemValue: (item: T) => UniqueIdentifier;
}
type SortableProps<T> = DndContextProps &
(T extends object ? GetItemValue<T> : Partial<GetItemValue<T>>) & {
value: T[];
onValueChange?: (items: T[]) => void;
onMove?: (
event: DragEndEvent & { activeIndex: number; overIndex: number },
) => void;
strategy?: SortableContextProps["strategy"];
orientation?: "vertical" | "horizontal" | "mixed";
flatCursor?: boolean;
};
function Sortable<T>(props: SortableProps<T>) {
const {
value,
onValueChange,
collisionDetection,
modifiers,
strategy,
onMove,
orientation = "vertical",
flatCursor = false,
getItemValue: getItemValueProp,
accessibility,
...sortableProps
} = props;
const id = React.useId();
const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const config = React.useMemo(
() => orientationConfig[orientation],
[orientation],
);
const getItemValue = React.useCallback(
(item: T): UniqueIdentifier => {
if (typeof item === "object" && !getItemValueProp) {
throw new Error(
"`getItemValue` is required when using array of objects",
);
}
return getItemValueProp
? getItemValueProp(item)
: (item as UniqueIdentifier);
},
[getItemValueProp],
);
const items = React.useMemo(() => {
return value.map((item) => getItemValue(item));
}, [value, getItemValue]);
const onDragStart = React.useCallback(
(event: DragStartEvent) => {
sortableProps.onDragStart?.(event);
if (event.activatorEvent.defaultPrevented) return;
setActiveId(event.active.id);
},
[sortableProps.onDragStart],
);
const onDragEnd = React.useCallback(
(event: DragEndEvent) => {
sortableProps.onDragEnd?.(event);
if (event.activatorEvent.defaultPrevented) return;
const { active, over } = event;
if (over && active.id !== over?.id) {
const activeIndex = value.findIndex(
(item) => getItemValue(item) === active.id,
);
const overIndex = value.findIndex(
(item) => getItemValue(item) === over.id,
);
if (onMove) {
onMove({ ...event, activeIndex, overIndex });
} else {
onValueChange?.(arrayMove(value, activeIndex, overIndex));
}
}
setActiveId(null);
},
[value, onValueChange, onMove, getItemValue, sortableProps.onDragEnd],
);
const onDragCancel = React.useCallback(
(event: DragEndEvent) => {
sortableProps.onDragCancel?.(event);
if (event.activatorEvent.defaultPrevented) return;
setActiveId(null);
},
[sortableProps.onDragCancel],
);
const announcements: Announcements = React.useMemo(
() => ({
onDragStart({ active }) {
const activeValue = active.id.toString();
return `Grabbed sortable item "${activeValue}". Current position is ${active.data.current?.sortable.index + 1} of ${value.length}. Use arrow keys to move, space to drop.`;
},
onDragOver({ active, over }) {
if (over) {
const overIndex = over.data.current?.sortable.index ?? 0;
const activeIndex = active.data.current?.sortable.index ?? 0;
const moveDirection = overIndex > activeIndex ? "down" : "up";
const activeValue = active.id.toString();
return `Sortable item "${activeValue}" moved ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
}
return "Sortable item is no longer over a droppable area. Press escape to cancel.";
},
onDragEnd({ active, over }) {
const activeValue = active.id.toString();
if (over) {
const overIndex = over.data.current?.sortable.index ?? 0;
return `Sortable item "${activeValue}" dropped at position ${overIndex + 1} of ${value.length}.`;
}
return `Sortable item "${activeValue}" dropped. No changes were made.`;
},
onDragCancel({ active }) {
const activeIndex = active.data.current?.sortable.index ?? 0;
const activeValue = active.id.toString();
return `Sorting cancelled. Sortable item "${activeValue}" returned to position ${activeIndex + 1} of ${value.length}.`;
},
onDragMove({ active, over }) {
if (over) {
const overIndex = over.data.current?.sortable.index ?? 0;
const activeIndex = active.data.current?.sortable.index ?? 0;
const moveDirection = overIndex > activeIndex ? "down" : "up";
const activeValue = active.id.toString();
return `Sortable item "${activeValue}" is moving ${moveDirection} to position ${overIndex + 1} of ${value.length}.`;
}
return "Sortable item is no longer over a droppable area. Press escape to cancel.";
},
}),
[value],
);
const screenReaderInstructions: ScreenReaderInstructions = React.useMemo(
() => ({
draggable: `
To pick up a sortable item, press space or enter.
While dragging, use the ${orientation === "vertical" ? "up and down" : orientation === "horizontal" ? "left and right" : "arrow"} keys to move the item.
Press space or enter again to drop the item in its new position, or press escape to cancel.
`,
}),
[orientation],
);
const contextValue = React.useMemo(
() => ({
id,
items,
modifiers: modifiers ?? config.modifiers,
strategy: strategy ?? config.strategy,
activeId,
setActiveId,
getItemValue,
flatCursor,
}),
[
id,
items,
modifiers,
strategy,
config.modifiers,
config.strategy,
activeId,
getItemValue,
flatCursor,
],
);
return (
<SortableRootContext.Provider
value={contextValue as SortableRootContextValue<unknown>}
>
<DndContext
collisionDetection={collisionDetection ?? config.collisionDetection}
modifiers={modifiers ?? config.modifiers}
sensors={sensors}
{...sortableProps}
id={id}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
accessibility={{
announcements,
screenReaderInstructions,
...accessibility,
}}
/>
</SortableRootContext.Provider>
);
}
const SortableContentContext = React.createContext<boolean>(false);
interface SortableContentProps
extends React.ComponentProps<"div">, useRender.ComponentProps<"div"> {
strategy?: SortableContextProps["strategy"];
children: React.ReactNode;
withoutSlot?: boolean;
}
function SortableContent(props: SortableContentProps) {
const {
strategy: strategyProp,
withoutSlot,
children,
className,
render,
ref,
...contentProps
} = props;
const context = useSortableContext(CONTENT_NAME);
const element = useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
ref,
className,
children,
},
contentProps,
),
render,
state: {
slot: "sortable-content",
},
enabled: !withoutSlot,
});
return (
<SortableContentContext.Provider value={true}>
<SortableContext
items={context.items}
strategy={strategyProp ?? context.strategy}
>
{withoutSlot ? children : element}
</SortableContext>
</SortableContentContext.Provider>
);
}
interface SortableItemContextValue {
id: string;
attributes: DraggableAttributes;
listeners: DraggableSyntheticListeners | undefined;
setActivatorNodeRef: (node: HTMLElement | null) => void;
isDragging?: boolean;
disabled?: boolean;
}
const SortableItemContext =
React.createContext<SortableItemContextValue | null>(null);
function useSortableItemContext(consumerName: string) {
const context = React.useContext(SortableItemContext);
if (!context) {
throw new Error(`\`${consumerName}\` must be used within \`${ITEM_NAME}\``);
}
return context;
}
interface SortableItemProps
extends React.ComponentProps<"div">, useRender.ComponentProps<"div"> {
value: UniqueIdentifier;
asHandle?: boolean;
disabled?: boolean;
}
function SortableItem(props: SortableItemProps) {
const {
value,
style,
asHandle,
disabled,
className,
render,
ref,
...itemProps
} = props;
const inSortableContent = React.useContext(SortableContentContext);
const inSortableOverlay = React.useContext(SortableOverlayContext);
if (!inSortableContent && !inSortableOverlay) {
throw new Error(
`\`${ITEM_NAME}\` must be used within \`${CONTENT_NAME}\` or \`${OVERLAY_NAME}\``,
);
}
if (value === "") {
throw new Error(`\`${ITEM_NAME}\` value cannot be an empty string`);
}
const context = useSortableContext(ITEM_NAME);
const instanceId = React.useId();
const id = itemProps.id ?? instanceId;
const {
attributes,
listeners,
setNodeRef,
setActivatorNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: value, disabled });
const onNodeRefChange = React.useCallback(
(node: HTMLElement | null) => {
if (disabled) return;
setNodeRef(node);
if (asHandle) setActivatorNodeRef(node);
},
[disabled, asHandle, setNodeRef, setActivatorNodeRef],
);
const composedStyle = React.useMemo<React.CSSProperties>(() => {
return {
transform: CSS.Translate.toString(transform),
transition,
...style,
};
}, [transform, transition, style]);
const itemContext = React.useMemo<SortableItemContextValue>(
() => ({
id,
attributes,
listeners,
setActivatorNodeRef,
isDragging,
disabled,
}),
[id, attributes, listeners, setActivatorNodeRef, isDragging, disabled],
);
const handleProps =
asHandle && !disabled ? { ...attributes, ...listeners } : undefined;
const element = useRender({
defaultTagName: "div",
ref: [ref ?? null, onNodeRefChange],
props: mergeProps<"div">(
{
id,
style: composedStyle,
className: cn(
"focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:outline-hidden",
{
"touch-none select-none": asHandle,
"cursor-default": context.flatCursor,
"data-dragging:cursor-grabbing": !context.flatCursor,
"cursor-grab": !isDragging && asHandle && !context.flatCursor,
"opacity-50": isDragging,
"pointer-events-none opacity-50": disabled,
},
className,
),
},
itemProps,
handleProps,
),
render,
state: {
slot: "sortable-item",
...(disabled && { disabled: "" }),
...(isDragging && { dragging: "" }),
},
});
return (
<SortableItemContext.Provider value={itemContext}>
{element}
</SortableItemContext.Provider>
);
}
interface SortableItemHandleProps
extends React.ComponentProps<"button">, useRender.ComponentProps<"button"> {}
function SortableItemHandle(props: SortableItemHandleProps) {
const { disabled, className, render, ref, ...itemHandleProps } = props;
const context = useSortableContext(ITEM_HANDLE_NAME);
const itemContext = useSortableItemContext(ITEM_HANDLE_NAME);
const isDisabled = disabled ?? itemContext.disabled;
const { setActivatorNodeRef } = itemContext;
const onActivatorNodeRef = React.useCallback(
(node: HTMLElement | null) => {
if (isDisabled) return;
setActivatorNodeRef(node);
},
[isDisabled, setActivatorNodeRef],
);
const handleProps = isDisabled
? undefined
: { ...itemContext.attributes, ...itemContext.listeners };
return useRender({
defaultTagName: "button",
ref: [ref ?? null, onActivatorNodeRef],
props: mergeProps<"button">(
{
type: "button",
"aria-controls": itemContext.id,
disabled: isDisabled,
className: cn(
"select-none disabled:pointer-events-none disabled:opacity-50",
context.flatCursor
? "cursor-default"
: "cursor-grab data-dragging:cursor-grabbing",
className,
),
},
itemHandleProps,
handleProps,
),
render,
state: {
slot: "sortable-item-handle",
...(isDisabled && { disabled: "" }),
...(itemContext.isDragging && { dragging: "" }),
},
});
}
const SortableOverlayContext = React.createContext(false);
const dropAnimation: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.4",
},
},
}),
};
interface SortableOverlayProps extends Omit<
React.ComponentProps<typeof DragOverlay>,
"children"
> {
container?: Element | DocumentFragment | null;
children?:
| ((params: { value: UniqueIdentifier }) => React.ReactNode)
| React.ReactNode;
}
function SortableOverlay(props: SortableOverlayProps) {
const { container: containerProp, children, ...overlayProps } = props;
const context = useSortableContext(OVERLAY_NAME);
const [mounted, setMounted] = React.useState(false);
useIsoLayoutEffect(() => setMounted(true), []);
const container =
containerProp ?? (mounted ? globalThis.document?.body : null);
if (!container) return null;
return ReactDOM.createPortal(
<DragOverlay
dropAnimation={dropAnimation}
modifiers={context.modifiers}
className={cn(!context.flatCursor && "cursor-grabbing")}
{...overlayProps}
>
<SortableOverlayContext.Provider value={true}>
{context.activeId
? typeof children === "function"
? children({ value: context.activeId })
: children
: null}
</SortableOverlayContext.Provider>
</DragOverlay>,
container,
);
}
export {
Sortable,
SortableContent,
SortableItem,
SortableItemHandle,
SortableOverlay,
type SortableProps,
};
src/registry/bases/base/ui/faceted.tsx
"use client";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { useIsoLayoutEffect } from "@base-ui/utils/useIsoLayoutEffect";
import { Command as CommandPrimitive } from "cmdk";
import { cn } from "cn";
import * as React from "react";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandList,
CommandSeparator,
} from "@/registry/bases/base/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/bases/base/ui/popover";
import { IconPlaceholder } from "@/registry/icons/icon-placeholder";
type FacetedSelectedValue<Multiple extends boolean> = Multiple extends true
? string[]
: string;
interface FacetedOption {
label: string;
value: string;
}
const NO_ITEMS: FacetedOption[] = [];
interface FacetedStoreState {
value: string | string[] | undefined;
open: boolean;
}
interface FacetedStore {
subscribe: (callback: () => void) => () => void;
getState: () => FacetedStoreState;
getProps: () => FacetedController;
notify: () => void;
setOpen: (open: boolean) => void;
selectItem: (value: string) => void;
clear: () => void;
}
interface FacetedController {
open?: boolean;
onOpenChange?: (open: boolean) => void;
value?: string | string[];
onValueChange?: (value: string | string[] | undefined) => void;
isControlled: boolean;
multiple: boolean;
items: FacetedOption[];
}
function getSelection(
state: FacetedStoreState,
props: FacetedController,
): string | string[] | undefined {
return props.isControlled ? props.value : state.value;
}
function getIsValueSelected(
value: string | string[] | undefined,
multiple: boolean,
itemValue: string,
) {
if (multiple) return Array.isArray(value) && value.includes(itemValue);
return value === itemValue;
}
const labelsCache = new WeakMap<FacetedOption[], Map<string, string>>();
function getLabels(items: FacetedOption[]) {
let labels = labelsCache.get(items);
if (!labels) {
labels = new Map(items.map((item) => [item.value, item.label]));
labelsCache.set(items, labels);
}
return labels;
}
function getSelectedValues(value: string | string[] | undefined) {
if (Array.isArray(value)) return value;
return value ? [value] : [];
}
function getSelectedItems(
values: string[],
items: FacetedOption[],
): FacetedOption[] {
const labels = getLabels(items);
return values.map((value) => ({
label: labels.get(value) ?? value,
value,
}));
}
function getIsFacetedSelectedValue<Multiple extends boolean>(
value: string | string[] | undefined,
multiple: Multiple,
): value is FacetedSelectedValue<Multiple> | undefined {
if (value === undefined) return true;
return multiple ? Array.isArray(value) : typeof value === "string";
}
function createFacetedStore(
propsRef: React.RefObject<FacetedController>,
state: FacetedStoreState,
): FacetedStore {
const listeners = new Set<() => void>();
function commitValue(nextValue: string | string[] | undefined) {
const props = propsRef.current;
if (!props.isControlled && !Object.is(state.value, nextValue)) {
state.value = nextValue;
store.notify();
}
props.onValueChange?.(nextValue);
}
const store: FacetedStore = {
subscribe: (callback) => {
listeners.add(callback);
return () => listeners.delete(callback);
},
getState: () => state,
getProps: () => propsRef.current,
notify: () => {
for (const callback of listeners) {
callback();
}
},
setOpen: (open) => {
const { open: openProp, onOpenChange } = propsRef.current;
if (openProp === undefined && state.open !== open) {
state.open = open;
store.notify();
}
onOpenChange?.(open);
},
selectItem: (selectedValue) => {
const props = propsRef.current;
const currentValue = getSelection(state, props);
if (props.multiple) {
const selected = Array.isArray(currentValue) ? currentValue : [];
commitValue(
selected.includes(selectedValue)
? selected.filter((item) => item !== selectedValue)
: [...selected, selectedValue],
);
return;
}
commitValue(currentValue === selectedValue ? undefined : selectedValue);
store.setOpen(false);
},
clear: () => {
commitValue(propsRef.current.multiple ? [] : undefined);
},
};
return store;
}
function useFacetedStoreSelector<T>(
store: FacetedStore,
selector: (state: FacetedStoreState) => T,
): T {
const getSnapshot = () => selector(store.getState());
return React.useSyncExternalStore(store.subscribe, getSnapshot, getSnapshot);
}
const FacetedStoreContext = React.createContext<FacetedStore | null>(null);
function useFacetedStore(name: string) {
const store = React.useContext(FacetedStoreContext);
if (!store) {
throw new Error(`\`${name}\` must be used within \`Faceted\`.`);
}
return store;
}
interface FacetedProps<Multiple extends boolean = false> extends Omit<
React.ComponentProps<typeof Popover>,
"onOpenChange"
> {
value?: FacetedSelectedValue<Multiple>;
defaultValue?: FacetedSelectedValue<Multiple>;
onValueChange?: (value: FacetedSelectedValue<Multiple> | undefined) => void;
onOpenChange?: (open: boolean) => void;
items?: FacetedOption[];
multiple?: Multiple;
}
function Faceted<Multiple extends boolean = false>({
open: openProp,
defaultOpen = false,
onOpenChange,
value,
defaultValue,
onValueChange,
items = NO_ITEMS,
multiple = false as Multiple,
...props
}: FacetedProps<Multiple>) {
const controller: FacetedController = {
open: openProp,
onOpenChange,
value,
onValueChange(nextValue) {
if (!getIsFacetedSelectedValue(nextValue, multiple)) return;
onValueChange?.(nextValue);
},
isControlled: value !== undefined,
multiple,
items,
};
const propsRef = React.useRef(controller);
propsRef.current = controller;
const storeRef = React.useRef<FacetedStore | null>(null);
storeRef.current ??= createFacetedStore(propsRef, {
value: defaultValue,
open: defaultOpen,
});
const store = storeRef.current;
const open = useFacetedStoreSelector(store, (state) => state.open);
useIsoLayoutEffect(() => {
if (value !== undefined) store.notify();
}, [store, value]);
return (
<FacetedStoreContext.Provider value={store}>
<Popover
data-slot="faceted"
open={openProp ?? open}
onOpenChange={store.setOpen}
{...props}
/>
</FacetedStoreContext.Provider>
);
}
function FacetedTrigger({
className,
...props
}: React.ComponentProps<typeof PopoverTrigger>) {
return (
<PopoverTrigger
data-slot="faceted-trigger"
className={cn(
"justify-between text-left has-[>[data-slot=faceted-value]]:transition-[color,background-color,border-color,box-shadow] has-[>[data-slot=faceted-value]>[data-slot=faceted-badge-list]]:ps-1",
className,
)}
{...props}
/>
);
}
interface FacetedValueProps {
placeholder?: React.ReactNode;
children?: React.ReactNode | ((selected: FacetedOption[]) => React.ReactNode);
}
function FacetedValue({
placeholder = "Select options...",
children,
}: FacetedValueProps) {
const store = useFacetedStore("FacetedValue");
const value = useFacetedStoreSelector(store, (state) =>
getSelection(state, store.getProps()),
);
const { items, multiple } = store.getProps();
const values = getSelectedValues(value);
if (typeof children === "function") {
return children(getSelectedItems(values, items));
}
if (children != null) return children;
const selected =
multiple && values.length > 2 ? [] : getSelectedItems(values, items);
const [item] = selected;
return (
<span
data-slot="faceted-value"
className={cn(
"flex w-full min-w-0 items-center gap-1",
values.length === 0 && "text-muted-foreground",
)}
>
{multiple && values.length > 0 ? (
<FacetedBadgeList>
{values.length > 2 ? (
<FacetedBadge>{values.length} selected</FacetedBadge>
) : (
selected.map((selectedItem) => (
<FacetedBadge key={selectedItem.value}>
<span className="truncate">{selectedItem.label}</span>
</FacetedBadge>
))
)}
</FacetedBadgeList>
) : (
<span className="truncate">{item ? item.label : placeholder}</span>
)}
<IconPlaceholder
lucide="ChevronDown"
tabler="IconChevronDown"
hugeicons="ArrowDown01Icon"
phosphor="CaretDownIcon"
remixicon="RiArrowDownSLine"
className="ml-auto size-4 shrink-0 text-muted-foreground"
/>
</span>
);
}
function FacetedBadgeList({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="faceted-badge-list"
className={cn(
"flex w-max max-w-full flex-wrap items-center gap-1",
className,
)}
{...props}
/>
);
}
function FacetedBadge({
className,
render,
...props
}: useRender.ComponentProps<"span">) {
return useRender({
defaultTagName: "span",
render,
state: {
slot: "faceted-badge",
},
props: mergeProps<"span">(
{
className: cn(
"flex h-[calc(--spacing(5.25))] w-fit max-w-full min-w-0 items-center justify-center gap-1 rounded-sm bg-input/60 px-1.5 text-xs font-medium whitespace-nowrap text-foreground [&_svg]:pointer-events-none [&_svg]:size-3 [&_svg]:shrink-0",
className,
),
},
props,
),
});
}
interface FacetedContentProps
extends
React.ComponentProps<typeof PopoverContent>,
Pick<
React.ComponentProps<typeof Command>,
"shouldFilter" | "filter" | "loop"
> {}
function FacetedContent({
className,
children,
shouldFilter,
filter,
loop,
...props
}: FacetedContentProps) {
return (
<PopoverContent
data-slot="faceted-content"
align="start"
className={cn("w-50 origin-(--transform-origin) p-0", className)}
{...props}
>
<Command
shouldFilter={shouldFilter}
filter={filter}
loop={loop}
className="p-0.5"
>
{children}
</Command>
</PopoverContent>
);
}
function FacetedInput({ ...props }: React.ComponentProps<typeof CommandInput>) {
return <CommandInput data-slot="faceted-input" {...props} />;
}
function FacetedList({ ...props }: React.ComponentProps<typeof CommandList>) {
return <CommandList data-slot="faceted-list" {...props} />;
}
function FacetedEmpty({ ...props }: React.ComponentProps<typeof CommandEmpty>) {
return <CommandEmpty data-slot="faceted-empty" {...props} />;
}
function FacetedLoading({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Loading>) {
return (
<CommandPrimitive.Loading
data-slot="faceted-loading"
className={cn(
"py-6 text-center text-sm text-muted-foreground",
className,
)}
{...props}
/>
);
}
function FacetedGroup({ ...props }: React.ComponentProps<typeof CommandGroup>) {
return <CommandGroup data-slot="faceted-group" {...props} />;
}
function FacetedCommandItem({
className,
onPointerDownCapture,
onMouseDown,
onSelect,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
className={cn(
"group/command-item relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 data-selected:bg-muted data-selected:text-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-selected:*:[svg]:text-foreground",
className,
)}
{...mergeProps<"div">(
{
onPointerDownCapture(event) {
// A focusable item steals the input on pointerdown.
event.preventDefault();
},
onMouseDown(event) {
// iOS Safari can emit a synthetic mousedown without a pointerdown.
event.preventDefault();
},
},
{ onPointerDownCapture, onMouseDown },
)}
onSelect={onSelect}
{...props}
/>
);
}
interface FacetedItemProps extends React.ComponentProps<
typeof CommandPrimitive.Item
> {
value: string;
}
function FacetedItem({ value, onSelect, ...props }: FacetedItemProps) {
const store = useFacetedStore("FacetedItem");
const isSelected = useFacetedStoreSelector(store, (state) => {
const facetedProps = store.getProps();
return getIsValueSelected(
getSelection(state, facetedProps),
facetedProps.multiple,
value,
);
});
return (
<FacetedCommandItem
data-slot="faceted-item"
data-checked={isSelected || undefined}
aria-checked={isSelected}
onSelect={() => {
if (onSelect) {
onSelect(value);
return;
}
store.selectItem(value);
}}
{...props}
/>
);
}
function FacetedItemIndicator({
className,
children,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="faceted-item-indicator"
className={cn(
"ml-auto grid place-items-center *:col-start-1 *:row-start-1",
className,
)}
{...props}
>
{children != null && (
<span className="font-mono text-xs group-data-checked/command-item:invisible">
{children}
</span>
)}
<IconPlaceholder
lucide="Check"
tabler="IconCheck"
hugeicons="Tick02Icon"
phosphor="CheckIcon"
remixicon="RiCheckLine"
className="invisible group-data-checked/command-item:visible"
/>
</span>
);
}
function FacetedClear({
className,
children = "Clear",
onSelect,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
const store = useFacetedStore("FacetedClear");
const hasSelection = useFacetedStoreSelector(
store,
(state) =>
getSelectedValues(getSelection(state, store.getProps())).length > 0,
);
if (!hasSelection) return null;
return (
<>
<FacetedSeparator />
<FacetedGroup>
<FacetedCommandItem
data-slot="faceted-clear"
className={cn("justify-center text-center", className)}
onSelect={(value) => {
onSelect?.(value);
store.clear();
}}
{...props}
>
{children}
</FacetedCommandItem>
</FacetedGroup>
</>
);
}
function FacetedSeparator({
...props
}: React.ComponentProps<typeof CommandSeparator>) {
return (
<CommandSeparator
data-slot="faceted-separator"
aria-hidden="true"
className="mx-0 my-0.5 w-full"
{...props}
/>
);
}
export {
Faceted,
FacetedBadge,
FacetedBadgeList,
FacetedClear,
FacetedContent,
FacetedEmpty,
FacetedGroup,
FacetedInput,
FacetedItem,
FacetedItemIndicator,
FacetedList,
FacetedLoading,
FacetedSeparator,
FacetedTrigger,
FacetedValue,
};
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/lib/data-grid-filters.ts
import type { FilterFn, Row, RowData } from "@tanstack/react-table";
import type { DataGridFeatures } from "@/lib/data-grid-features";
import type {
BooleanFilterOperator,
DateFilterOperator,
FilterOperator,
FilterValue,
NumberFilterOperator,
SelectFilterOperator,
TextFilterOperator,
} from "@/lib/data-grid-types";
import { stringifyUnknown } from "@/lib/data-grid-utils";
export const TEXT_FILTER_OPERATORS: ReadonlyArray<{
label: string;
value: TextFilterOperator;
}> = [
{ label: "Contains", value: "contains" },
{ label: "Does not contain", value: "notContains" },
{ label: "Is", value: "equals" },
{ label: "Is not", value: "notEquals" },
{ label: "Starts with", value: "startsWith" },
{ label: "Ends with", value: "endsWith" },
{ label: "Is empty", value: "isEmpty" },
{ label: "Is not empty", value: "isNotEmpty" },
];
export const NUMBER_FILTER_OPERATORS: ReadonlyArray<{
label: string;
value: NumberFilterOperator;
}> = [
{ label: "Is", value: "equals" },
{ label: "Is not", value: "notEquals" },
{ label: "Is less than", value: "lessThan" },
{ label: "Is less than or equal to", value: "lessThanOrEqual" },
{ label: "Is greater than", value: "greaterThan" },
{ label: "Is greater than or equal to", value: "greaterThanOrEqual" },
{ label: "Is between", value: "isBetween" },
{ label: "Is empty", value: "isEmpty" },
{ label: "Is not empty", value: "isNotEmpty" },
];
export const DATE_FILTER_OPERATORS: ReadonlyArray<{
label: string;
value: DateFilterOperator;
}> = [
{ label: "Is", value: "equals" },
{ label: "Is not", value: "notEquals" },
{ label: "Is before", value: "before" },
{ label: "Is after", value: "after" },
{ label: "Is on or before", value: "onOrBefore" },
{ label: "Is on or after", value: "onOrAfter" },
{ label: "Is between", value: "isBetween" },
{ label: "Is empty", value: "isEmpty" },
{ label: "Is not empty", value: "isNotEmpty" },
];
export const SELECT_FILTER_OPERATORS: ReadonlyArray<{
label: string;
value: SelectFilterOperator;
}> = [
{ label: "Is", value: "is" },
{ label: "Is not", value: "isNot" },
{ label: "Has any of", value: "isAnyOf" },
{ label: "Has none of", value: "isNoneOf" },
{ label: "Is empty", value: "isEmpty" },
{ label: "Is not empty", value: "isNotEmpty" },
];
export const BOOLEAN_FILTER_OPERATORS: ReadonlyArray<{
label: string;
value: BooleanFilterOperator;
}> = [
{ label: "Is", value: "isTrue" },
{ label: "Is not", value: "isFalse" },
];
export function getDefaultOperator(variant: string): FilterOperator {
switch (variant) {
case "number":
return "equals";
case "date":
return "equals";
case "select":
case "multi-select":
return "is";
case "checkbox":
return "isTrue";
default:
return "contains";
}
}
export function getOperatorsForVariant(variant: string): ReadonlyArray<{
label: string;
value: FilterOperator;
}> {
switch (variant) {
case "number":
return NUMBER_FILTER_OPERATORS;
case "date":
return DATE_FILTER_OPERATORS;
case "select":
case "multi-select":
return SELECT_FILTER_OPERATORS;
case "checkbox":
return BOOLEAN_FILTER_OPERATORS;
default:
return TEXT_FILTER_OPERATORS;
}
}
export function getFilterFn<TData extends RowData>(): FilterFn<
DataGridFeatures,
TData
> {
return (
row: Row<DataGridFeatures, TData>,
columnId: string,
filterValue: unknown,
): boolean => {
if (!filterValue || typeof filterValue !== "object") {
return true;
}
const filter = filterValue as FilterValue;
const { operator, value, endValue } = filter;
const cellValue = row.getValue(columnId);
if (operator === "isEmpty") {
return (
cellValue === null ||
cellValue === undefined ||
cellValue === "" ||
(Array.isArray(cellValue) && cellValue.length === 0)
);
}
if (operator === "isNotEmpty") {
return !(
cellValue === null ||
cellValue === undefined ||
cellValue === "" ||
(Array.isArray(cellValue) && cellValue.length === 0)
);
}
if (operator === "isTrue") {
return cellValue === true;
}
if (operator === "isFalse") {
return cellValue === false || !cellValue;
}
if (value === undefined || value === null || value === "") {
return true;
}
const cellValueStr = stringifyUnknown(cellValue).toLowerCase();
const filterValueStr = stringifyUnknown(value).toLowerCase();
if (operator === "contains") {
return cellValueStr.includes(filterValueStr);
}
if (operator === "notContains") {
return !cellValueStr.includes(filterValueStr);
}
if (operator === "equals") {
if (typeof cellValue === "number" && typeof value === "number") {
return cellValue === value;
}
if (cellValue instanceof Date && typeof value === "string") {
const cellDate = new Date(cellValue);
const filterDate = new Date(value);
return cellDate.toDateString() === filterDate.toDateString();
}
return cellValueStr === filterValueStr;
}
if (operator === "notEquals") {
if (typeof cellValue === "number" && typeof value === "number") {
return cellValue !== value;
}
if (cellValue instanceof Date && typeof value === "string") {
const cellDate = new Date(cellValue);
const filterDate = new Date(value);
return cellDate.toDateString() !== filterDate.toDateString();
}
return cellValueStr !== filterValueStr;
}
if (operator === "startsWith") {
return cellValueStr.startsWith(filterValueStr);
}
if (operator === "endsWith") {
return cellValueStr.endsWith(filterValueStr);
}
if (typeof cellValue === "number" && typeof value === "number") {
if (operator === "greaterThan") {
return cellValue > value;
}
if (operator === "greaterThanOrEqual") {
return cellValue >= value;
}
if (operator === "lessThan") {
return cellValue < value;
}
if (operator === "lessThanOrEqual") {
return cellValue <= value;
}
if (operator === "isBetween" && typeof endValue === "number") {
return cellValue >= value && cellValue <= endValue;
}
}
if (cellValue instanceof Date || typeof cellValue === "string") {
const cellDate = new Date(cellValue);
if (!Number.isNaN(cellDate.getTime()) && typeof value === "string") {
const filterDate = new Date(value);
if (operator === "before") {
return cellDate < filterDate;
}
if (operator === "after") {
return cellDate > filterDate;
}
if (operator === "onOrBefore") {
return cellDate <= filterDate;
}
if (operator === "onOrAfter") {
return cellDate >= filterDate;
}
if (operator === "isBetween" && typeof endValue === "string") {
const filterDate2 = new Date(endValue);
return cellDate >= filterDate && cellDate <= filterDate2;
}
}
}
if (operator === "is") {
if (Array.isArray(cellValue)) {
return cellValue.some(
(v) => stringifyUnknown(v) === stringifyUnknown(value),
);
}
return stringifyUnknown(cellValue) === stringifyUnknown(value);
}
if (operator === "isNot") {
if (Array.isArray(cellValue)) {
return !cellValue.some(
(v) => stringifyUnknown(v) === stringifyUnknown(value),
);
}
return stringifyUnknown(cellValue) !== stringifyUnknown(value);
}
if (operator === "isAnyOf" && Array.isArray(value)) {
if (Array.isArray(cellValue)) {
return cellValue.some((v) =>
value.some((fv) => stringifyUnknown(v) === fv),
);
}
return value.some((fv) => stringifyUnknown(cellValue) === fv);
}
if (operator === "isNoneOf" && Array.isArray(value)) {
if (Array.isArray(cellValue)) {
return !cellValue.some((v) =>
value.some((fv) => stringifyUnknown(v) === fv),
);
}
return !value.some((fv) => stringifyUnknown(cellValue) === fv);
}
return true;
};
}
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/format.ts
export function formatDate(
date: Date | string | number | undefined,
opts: Intl.DateTimeFormatOptions = {},
) {
if (!date) return "";
try {
return new Intl.DateTimeFormat("en-US", {
month: opts.month ?? "long",
day: opts.day ?? "numeric",
year: opts.year ?? "numeric",
...opts,
}).format(new Date(date));
} catch {
return "";
}
}
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;