shadimage
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 · light
iconslucide-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;

More from Shadcn Table

Browse all components in this library →