Shadcn Table
componentdata-table-filter-menusource: ok · hit
Data Table Filter Menu
A filter menu component for the data table
npx shadcn add "https://tablecn.com/r/data-table-filter-menu.json"free
Where it goes
data-table
Interaction
dropdown
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
formszod
Registry dependencies
badgebuttoncalendarcommandinputpopoverselect
Files13 · source cached 10/14/2026, 1:22:16 PM
src/registry/bases/base/components/data-table/data-table-filter-menu.tsx
"use client";
import type { Column, ReactTable, RowData } from "@tanstack/react-table";
import { cn } from "cn";
import * as React from "react";
import type { DataTableFeatures } from "@/lib/data-table-features";
import type { ColumnFilterItem, JoinOperator } from "@/lib/data-table-types";
import {
getDefaultFilter,
getDateFilterLabel,
getFilterDates,
getFilterOperators,
coerceFilterValue,
getIsEditableTarget,
getIsValuelessOperator,
getSelectFilterValue,
JOIN_OPERATORS,
getFilterDateValue,
} from "@/lib/data-table-utils";
import { generateId } from "@/lib/id";
import { DataTableRangeFilter } from "@/registry/bases/base/components/data-table/data-table-range-filter";
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 = ["backspace", "delete"];
function getIsRemoveKey(event: React.KeyboardEvent) {
return REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase());
}
type FilterUpdate = Partial<Omit<ColumnFilterItem, "filterId">>;
type FilterSelector = "field" | "operator" | "value";
interface DataTableFilterMenuProps<
TData extends RowData,
> extends React.ComponentProps<typeof PopoverContent> {
table: ReactTable<DataTableFeatures, TData, unknown>;
disabled?: boolean;
}
export function DataTableFilterMenu<TData extends RowData>({
table,
...props
}: DataTableFilterMenuProps<TData>) {
return (
<table.Subscribe
selector={(state) => ({
filters: table.getColumnFilterItems(),
joinOperator: state.joinOperator,
})}
>
{({ filters, joinOperator }) => (
<DataTableFilterMenuContent
table={table}
filters={filters}
joinOperator={joinOperator}
{...props}
/>
)}
</table.Subscribe>
);
}
interface DataTableFilterMenuContentProps<
TData extends RowData,
> extends DataTableFilterMenuProps<TData> {
filters: ColumnFilterItem[];
joinOperator: JoinOperator;
}
function DataTableFilterMenuContent<TData extends RowData>({
table,
filters,
joinOperator,
disabled,
...props
}: DataTableFilterMenuContentProps<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);
React.useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (getIsEditableTarget(event.target)) return;
if (
event.key.toLowerCase() !== FILTER_SHORTCUT_KEY ||
!(event.ctrlKey || event.metaKey) ||
!event.shiftKey
) {
return;
}
event.preventDefault();
setOpen((open) => !open);
}
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
const columns = React.useMemo(
() => table.getAllColumns().filter((column) => column.getCanFilter()),
[table],
);
const hasFilters = filters.length > 0;
function onFilterAdd() {
const column = columns[0];
if (!column) return;
table.addColumnFilter({
...getDefaultFilter(column),
filterId: generateId({ length: 8 }),
});
}
function onFilterUpdate(filterId: string, updates: FilterUpdate) {
table.updateColumnFilter(filterId, updates);
}
function onFilterRemove(filterId: string) {
table.removeColumnFilter(filterId);
addButtonRef.current?.focus();
}
function onFiltersReset() {
table.resetColumnFilters(true);
table.resetJoinOperator(true);
}
function onTriggerKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
const lastFilter = filters.at(-1);
if (!lastFilter || !getIsRemoveKey(event)) return;
event.preventDefault();
onFilterRemove(lastFilter.filterId);
}
return (
<Sortable
value={filters}
onValueChange={(value) => table.setColumnFilters(value)}
getItemValue={(item) => item.filterId}
>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
variant="outline"
onKeyDown={onTriggerKeyDown}
disabled={disabled}
/>
}
>
<IconPlaceholder
lucide="ListFilter"
tabler="IconListDetails"
hugeicons="LeftToRightListBulletIcon"
phosphor="ListIcon"
remixicon="RiListUnordered"
className="text-muted-foreground"
/>
Filter
{hasFilters && (
<Badge
variant="secondary"
className="h-[18.24px] px-[5.12px] font-mono text-[10.4px]"
>
{filters.length}
</Badge>
)}
</PopoverTrigger>
<PopoverContent
dir={dir}
aria-describedby={descriptionId}
aria-labelledby={labelId}
className="flex w-full max-w-(--available-width) flex-col gap-3.5 p-4 sm:min-w-95"
{...props}
>
<div className="flex flex-col gap-1">
<h4 id={labelId} className="leading-none font-medium">
{hasFilters ? "Filters" : "No filters applied"}
</h4>
<p
id={descriptionId}
className={cn(
"text-sm text-muted-foreground",
hasFilters && "sr-only",
)}
>
{hasFilters
? "Modify filters to refine your rows."
: "Add filters to refine your rows."}
</p>
</div>
{hasFilters && (
<SortableContent
render={
<div
role="list"
className="grid max-h-75 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"
/>
}
>
{filters.map((filter, index) => (
<DataTableFilterItem<TData>
key={filter.filterId}
filter={filter}
index={index}
filterItemId={`${id}-filter-${filter.filterId}`}
joinOperator={joinOperator}
columns={columns}
onJoinOperatorChange={(value) => table.setJoinOperator(value)}
onFilterUpdate={onFilterUpdate}
onFilterRemove={onFilterRemove}
/>
))}
</SortableContent>
)}
<div className="flex w-full items-center gap-2">
<Button ref={addButtonRef} onClick={onFilterAdd}>
Add filter
</Button>
{hasFilters && (
<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 DataTableFilterItemProps<TData extends RowData> {
filter: ColumnFilterItem;
index: number;
filterItemId: string;
joinOperator: JoinOperator;
columns: Column<DataTableFeatures, TData>[];
onJoinOperatorChange: (value: JoinOperator) => void;
onFilterUpdate: (filterId: string, updates: FilterUpdate) => void;
onFilterRemove: (filterId: string) => void;
}
function DataTableFilterItem<TData extends RowData>({
filter,
index,
filterItemId,
joinOperator,
columns,
onJoinOperatorChange,
onFilterUpdate,
onFilterRemove,
}: DataTableFilterItemProps<TData>) {
const [activeSelector, setActiveSelector] =
React.useState<FilterSelector | null>(null);
const column = columns.find((column) => column.id === filter.id);
if (!column) return null;
const label = column.columnDef.meta?.label ?? column.id;
function getSelectorProps(selector: FilterSelector) {
return {
open: activeSelector === selector,
onOpenChange: (open: boolean) =>
setActiveSelector(open ? selector : null),
};
}
function onItemKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
if (activeSelector || getIsEditableTarget(event.target)) return;
if (!getIsRemoveKey(event)) return;
event.preventDefault();
onFilterRemove(filter.filterId);
}
return (
<SortableItem
id={filterItemId}
value={filter.filterId}
render={
<div
role="listitem"
tabIndex={-1}
className="col-span-full grid grid-cols-subgrid items-center"
onKeyDown={onItemKeyDown}
/>
}
>
<div className="min-w-18 text-center">
<FilterJoinOperator
index={index}
joinOperator={joinOperator}
listboxId={`${filterItemId}-join-operator-listbox`}
onJoinOperatorChange={onJoinOperatorChange}
/>
</div>
<FilterFieldSelector
filter={filter}
column={column}
columns={columns}
listboxId={`${filterItemId}-field-listbox`}
onFilterUpdate={onFilterUpdate}
{...getSelectorProps("field")}
/>
<FilterOperatorSelector
filter={filter}
label={label}
listboxId={`${filterItemId}-operator-listbox`}
onFilterUpdate={onFilterUpdate}
{...getSelectorProps("operator")}
/>
<div className="w-full min-w-0">
<FilterValueInput
filter={filter}
column={column}
inputId={`${filterItemId}-input`}
onFilterUpdate={onFilterUpdate}
{...getSelectorProps("value")}
/>
</div>
<Button
aria-controls={filterItemId}
aria-label={`Remove ${label} filter`}
variant="outline"
size="icon"
onClick={() => onFilterRemove(filter.filterId)}
>
<IconPlaceholder
lucide="Trash2"
tabler="IconTrash"
hugeicons="Delete02Icon"
phosphor="TrashIcon"
remixicon="RiDeleteBinLine"
/>
</Button>
<SortableItemHandle
aria-label={`Reorder ${label} filter`}
render={<Button variant="outline" size="icon" />}
>
<IconPlaceholder
lucide="GripVertical"
tabler="IconGripVertical"
hugeicons="DragDropVerticalIcon"
phosphor="DotsSixVerticalIcon"
remixicon="RiDraggable"
/>
</SortableItemHandle>
</SortableItem>
);
}
interface FilterJoinOperatorProps {
index: number;
joinOperator: JoinOperator;
listboxId: string;
onJoinOperatorChange: (value: JoinOperator) => void;
}
function FilterJoinOperator({
index,
joinOperator,
listboxId,
onJoinOperatorChange,
}: FilterJoinOperatorProps) {
const dir = useDirection();
if (index === 0) {
return <span className="text-sm text-muted-foreground">Where</span>;
}
if (index > 1) {
return (
<span className="text-sm text-muted-foreground">{joinOperator}</span>
);
}
return (
<Select
value={joinOperator}
onValueChange={(value) => {
if (value == null) return;
onJoinOperatorChange(value);
}}
>
<SelectTrigger
aria-label="Select join operator"
aria-controls={listboxId}
className="lowercase"
>
<SelectValue placeholder={joinOperator} />
</SelectTrigger>
<SelectContent
dir={dir}
id={listboxId}
alignItemWithTrigger={false}
className="min-w-(--anchor-width) lowercase"
>
<SelectGroup>
{JOIN_OPERATORS.map((joinOperator) => (
<SelectItem key={joinOperator} value={joinOperator}>
{joinOperator}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
}
interface FilterSelectorProps {
filter: ColumnFilterItem;
open: boolean;
onOpenChange: (open: boolean) => void;
onFilterUpdate: (filterId: string, updates: FilterUpdate) => void;
}
interface FilterFieldSelectorProps<
TData extends RowData,
> extends FilterSelectorProps {
column: Column<DataTableFeatures, TData>;
columns: Column<DataTableFeatures, TData>[];
listboxId: string;
}
function FilterFieldSelector<TData extends RowData>({
filter,
column,
columns,
listboxId,
open,
onOpenChange,
onFilterUpdate,
}: FilterFieldSelectorProps<TData>) {
const dir = useDirection();
return (
<Faceted
open={open}
onOpenChange={onOpenChange}
value={filter.id}
onValueChange={(columnId) => {
const column = columns.find((column) => column.id === columnId);
if (column) {
onFilterUpdate(filter.filterId, getDefaultFilter(column));
}
}}
>
<FacetedTrigger
render={
<Button
aria-controls={listboxId}
variant="outline"
className="w-32"
/>
}
>
<span className="truncate">
{column.columnDef.meta?.label ?? "Select field"}
</span>
<IconPlaceholder
lucide="ChevronsUpDown"
tabler="IconSelector"
hugeicons="UnfoldMoreIcon"
phosphor="CaretUpDownIcon"
remixicon="RiArrowUpDownLine"
className="opacity-50"
/>
</FacetedTrigger>
<FacetedContent dir={dir} id={listboxId} className="w-40">
<FacetedInput placeholder="Search fields..." />
<FacetedList>
<FacetedEmpty>No fields found.</FacetedEmpty>
<FacetedGroup>
{columns.map((column) => (
<FacetedItem key={column.id} value={column.id}>
<span className="truncate">{column.columnDef.meta?.label}</span>
<FacetedItemIndicator />
</FacetedItem>
))}
</FacetedGroup>
</FacetedList>
</FacetedContent>
</Faceted>
);
}
interface FilterOperatorSelectorProps extends FilterSelectorProps {
label: string;
listboxId: string;
}
function FilterOperatorSelector({
filter,
label,
listboxId,
open,
onOpenChange,
onFilterUpdate,
}: FilterOperatorSelectorProps) {
const dir = useDirection();
return (
<Select
open={open}
onOpenChange={onOpenChange}
value={filter.operator}
onValueChange={(operator) => {
if (operator == null) return;
onFilterUpdate(filter.filterId, {
operator,
value: coerceFilterValue(operator, filter.value),
});
}}
>
<SelectTrigger
aria-controls={listboxId}
aria-label={`${label} filter operator`}
className="w-32 lowercase"
>
<div className="truncate">
<SelectValue placeholder={filter.operator} />
</div>
</SelectTrigger>
<SelectContent dir={dir} id={listboxId}>
<SelectGroup>
{getFilterOperators(filter.variant).map((operator) => (
<SelectItem
key={operator.value}
value={operator.value}
className="lowercase"
>
{operator.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
);
}
interface FilterValueInputProps<
TData extends RowData,
> extends FilterSelectorProps {
column: Column<DataTableFeatures, TData>;
inputId: string;
}
function FilterValueInput<TData extends RowData>(
props: FilterValueInputProps<TData>,
) {
const { filter, column, inputId, onFilterUpdate } = props;
const columnMeta = column.columnDef.meta;
if (getIsValuelessOperator(filter.operator)) {
return (
<div
id={inputId}
role="status"
aria-label={`${columnMeta?.label} filter is ${
filter.operator === "isEmpty" ? "empty" : "not empty"
}`}
aria-live="polite"
className="h-8 w-full rounded-lg border border-dashed border-input bg-transparent dark:bg-input/30"
/>
);
}
switch (filter.variant) {
case "text":
case "number":
case "range":
if (filter.operator === "isBetween") {
return (
<DataTableRangeFilter
filter={filter}
column={column}
inputId={inputId}
onFilterUpdate={onFilterUpdate}
/>
);
}
return (
<Input
id={inputId}
type={filter.variant === "text" ? "text" : "number"}
aria-label={`${columnMeta?.label} filter value`}
inputMode={filter.variant === "text" ? undefined : "numeric"}
placeholder={columnMeta?.placeholder ?? "Enter a value..."}
defaultValue={
typeof filter.value === "string" ? filter.value : undefined
}
onChange={(event) =>
onFilterUpdate(filter.filterId, { value: event.target.value })
}
/>
);
case "boolean":
return <BooleanFilterValue {...props} />;
case "select":
case "multiSelect":
return <SelectFilterValue {...props} />;
case "date":
case "dateRange":
return <DateFilterValue {...props} />;
default:
return null;
}
}
function BooleanFilterValue<TData extends RowData>({
filter,
column,
inputId,
open,
onOpenChange,
onFilterUpdate,
}: FilterValueInputProps<TData>) {
const dir = useDirection();
const listboxId = `${inputId}-listbox`;
return (
<Select
open={open}
onOpenChange={onOpenChange}
value={typeof filter.value === "string" ? filter.value : null}
onValueChange={(value) => {
if (value == null) return;
onFilterUpdate(filter.filterId, { value });
}}
>
<SelectTrigger
id={inputId}
aria-controls={listboxId}
aria-label={`${column.columnDef.meta?.label} boolean filter`}
className="w-full"
>
<SelectValue placeholder="Select value" />
</SelectTrigger>
<SelectContent dir={dir} id={listboxId}>
<SelectGroup>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
);
}
function SelectFilterValue<TData extends RowData>({
filter,
column,
inputId,
open,
onOpenChange,
onFilterUpdate,
}: FilterValueInputProps<TData>) {
const dir = useDirection();
const listboxId = `${inputId}-listbox`;
const columnMeta = column.columnDef.meta;
const multiple = filter.variant === "multiSelect";
return (
<Faceted
open={open}
onOpenChange={onOpenChange}
value={getSelectFilterValue(filter)}
onValueChange={(value) =>
onFilterUpdate(filter.filterId, { value: value ?? "" })
}
items={columnMeta?.options}
multiple={multiple}
>
<FacetedTrigger
render={
<Button
id={inputId}
aria-controls={listboxId}
aria-label={`${columnMeta?.label} filter value${multiple ? "s" : ""}`}
variant="outline"
className="w-full hover:bg-muted/50 aria-expanded:bg-background dark:aria-expanded:bg-input/30"
/>
}
>
<FacetedValue
placeholder={
columnMeta?.placeholder ?? `Select option${multiple ? "s" : ""}...`
}
/>
</FacetedTrigger>
<FacetedContent dir={dir} id={listboxId} className="w-50">
<FacetedInput
aria-label={`Search ${columnMeta?.label} options`}
placeholder={columnMeta?.placeholder ?? "Search options..."}
/>
<FacetedList>
<FacetedEmpty>No options found.</FacetedEmpty>
<FacetedGroup>
{columnMeta?.options?.map((option) => (
<FacetedItem key={option.value} value={option.value}>
{option.icon && <option.icon />}
<span className="flex-1 truncate">{option.label}</span>
<FacetedItemIndicator>{option.count}</FacetedItemIndicator>
</FacetedItem>
))}
</FacetedGroup>
</FacetedList>
</FacetedContent>
</Faceted>
);
}
function DateFilterValue<TData extends RowData>({
filter,
column,
inputId,
open,
onOpenChange,
onFilterUpdate,
}: FilterValueInputProps<TData>) {
const dir = useDirection();
const listboxId = `${inputId}-listbox`;
const label = column.columnDef.meta?.label;
const [startDate, endDate] = getFilterDates(filter.value);
return (
<Popover open={open} onOpenChange={onOpenChange}>
<PopoverTrigger
render={
<Button
id={inputId}
aria-controls={listboxId}
aria-label={`${label} date filter`}
variant="outline"
className={cn(
"w-full justify-start text-start",
!startDate && "text-muted-foreground",
)}
/>
}
>
<IconPlaceholder
lucide="CalendarIcon"
tabler="IconCalendar"
hugeicons="CalendarIcon"
phosphor="CalendarIcon"
remixicon="RiCalendarLine"
/>
<span className="truncate">
{getDateFilterLabel(filter) ?? "Pick a date"}
</span>
</PopoverTrigger>
<PopoverContent
dir={dir}
id={listboxId}
align="start"
className="w-auto p-0"
>
{filter.operator === "isBetween" ? (
<Calendar
aria-label={`Select ${label} date range`}
autoFocus
captionLayout="dropdown"
mode="range"
selected={startDate ? { from: startDate, to: endDate } : undefined}
onSelect={(range) =>
onFilterUpdate(filter.filterId, {
value: range
? [
getFilterDateValue(range.from),
getFilterDateValue(range.to),
]
: [],
})
}
/>
) : (
<Calendar
aria-label={`Select ${label} date`}
autoFocus
captionLayout="dropdown"
mode="single"
selected={startDate}
onSelect={(date) => {
onFilterUpdate(filter.filterId, {
value: getFilterDateValue(date),
});
onOpenChange(false);
}}
/>
)}
</PopoverContent>
</Popover>
);
}
src/registry/bases/base/components/data-table/data-table-range-filter.tsx
"use client";
import type { Column, RowData } from "@tanstack/react-table";
import { cn } from "cn";
import * as React from "react";
import type { DataTableFeatures } from "@/lib/data-table-features";
import type { ColumnFilterItem } from "@/lib/data-table-types";
import { Input } from "@/registry/bases/base/ui/input";
function getRangeFilterValues(value: string | string[]): [string, string] {
if (Array.isArray(value)) return [value[0] ?? "", value[1] ?? ""];
return [value, ""];
}
/** Number inputs only accept plain numbers, so skip rounding and grouping. */
function getInputValue(value: string) {
return value === "" || Number.isNaN(Number(value)) ? "" : value;
}
interface DataTableRangeFilterProps<
TData extends RowData,
> extends React.ComponentProps<"div"> {
filter: ColumnFilterItem;
column: Column<DataTableFeatures, TData>;
inputId: string;
onFilterUpdate: (
filterId: string,
updates: Partial<Omit<ColumnFilterItem, "filterId">>,
) => void;
}
export function DataTableRangeFilter<TData extends RowData>({
filter,
column,
inputId,
onFilterUpdate,
className,
...props
}: DataTableRangeFilterProps<TData>) {
const meta = column.columnDef.meta;
const [min, max] = React.useMemo(() => {
const range = column.columnDef.meta?.range;
if (range) return range;
const values = column.getFacetedMinMaxValues();
if (!values) return [0, 100];
return [values[0], values[1]];
}, [column]);
const value = getRangeFilterValues(filter.value).map(getInputValue);
const onRangeValueChange = React.useCallback(
(value: string, isMin?: boolean) => {
const numValue = Number(value);
const currentValues = getRangeFilterValues(filter.value);
const otherValue = isMin
? (currentValues[1] ?? "")
: (currentValues[0] ?? "");
if (
value === "" ||
(!Number.isNaN(numValue) &&
(isMin
? numValue >= min && numValue <= (Number(otherValue) || max)
: numValue <= max && numValue >= (Number(otherValue) || min)))
) {
onFilterUpdate(filter.filterId, {
value: isMin ? [value, otherValue] : [otherValue, value],
});
}
},
[filter.filterId, filter.value, min, max, onFilterUpdate],
);
return (
<div
data-slot="data-table-range-filter"
className={cn("flex w-full items-center gap-2", className)}
{...props}
>
<Input
id={`${inputId}-min`}
type="number"
aria-label={`${meta?.label} minimum value`}
aria-valuemin={min}
aria-valuemax={max}
data-slot="data-table-range-filter-min"
inputMode="numeric"
placeholder={min.toString()}
min={min}
max={max}
className="w-full min-w-24"
defaultValue={value[0]}
onChange={(event) => onRangeValueChange(event.target.value, true)}
/>
<span className="sr-only shrink-0 text-muted-foreground">to</span>
<Input
id={`${inputId}-max`}
type="number"
aria-label={`${meta?.label} maximum value`}
aria-valuemin={min}
aria-valuemax={max}
data-slot="data-table-range-filter-max"
inputMode="numeric"
placeholder={max.toString()}
min={min}
max={max}
className="w-full min-w-24"
defaultValue={value[1]}
onChange={(event) => onRangeValueChange(event.target.value)}
/>
</div>
);
}
src/registry/bases/base/components/data-table/data-table-advanced-toolbar.tsx
"use client";
import type { ReactTable, RowData } from "@tanstack/react-table";
import type * as React from "react";
import { cn } from "cn";
import type { DataTableFeatures } from "@/lib/data-table-features";
import { DataTableViewOptions } from "@/registry/bases/base/components/data-table/data-table-view-options";
interface DataTableAdvancedToolbarProps<
TData extends RowData,
> extends React.ComponentProps<"div"> {
table: ReactTable<DataTableFeatures, TData, unknown>;
}
export function DataTableAdvancedToolbar<TData extends RowData>({
table,
children,
className,
...props
}: DataTableAdvancedToolbarProps<TData>) {
return (
<div
data-slot="data-table-advanced-toolbar"
className={cn(
"flex w-full items-start justify-between gap-2 p-1",
className,
)}
{...props}
>
<div className="flex flex-1 flex-wrap items-center gap-2">{children}</div>
<div className="flex items-center gap-2">
<DataTableViewOptions table={table} align="end" />
</div>
</div>
);
}
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/registry/bases/base/ui/direction.tsx
"use client";
export {
DirectionProvider,
useDirection,
} from "@base-ui/react/direction-provider";
src/lib/data-table-utils.ts
import type {
Column,
ColumnFilter,
RowData,
SortingState,
Table,
} from "@tanstack/react-table";
import type { DataTableFeatures } from "@/lib/data-table-features";
import type {
ColumnFilterItem,
FilterOperator,
FilterOperatorOption,
FilterVariant,
} from "@/lib/data-table-types";
import { formatDate } from "@/lib/format";
export const FILTER_VARIANTS = [
"text",
"number",
"range",
"date",
"dateRange",
"boolean",
"select",
"multiSelect",
] as const;
export const FILTER_OPERATORS = {
iLike: "ilike",
notILike: "not.ilike",
eq: "eq",
ne: "neq",
inArray: "in",
notInArray: "not.in",
isEmpty: "is.empty",
isNotEmpty: "not.is.empty",
lt: "lt",
lte: "lte",
gt: "gt",
gte: "gte",
isBetween: "between",
isRelativeToToday: "rel",
} as const;
export const JOIN_OPERATORS = ["and", "or"] as const;
export const SORT_ORDERS = [
{ label: "Asc", value: "asc" },
{ label: "Desc", value: "desc" },
] as const;
const TEXT_OPERATORS = [
{ label: "Contains", value: "iLike" },
{ label: "Does not contain", value: "notILike" },
{ label: "Is", value: "eq" },
{ label: "Is not", value: "ne" },
{ label: "Is empty", value: "isEmpty" },
{ label: "Is not empty", value: "isNotEmpty" },
] satisfies FilterOperatorOption[];
const NUMERIC_OPERATORS = [
{ label: "Is", value: "eq" },
{ label: "Is not", value: "ne" },
{ label: "Is less than", value: "lt" },
{ label: "Is less than or equal to", value: "lte" },
{ label: "Is greater than", value: "gt" },
{ label: "Is greater than or equal to", value: "gte" },
{ label: "Is between", value: "isBetween" },
{ label: "Is empty", value: "isEmpty" },
{ label: "Is not empty", value: "isNotEmpty" },
] satisfies FilterOperatorOption[];
const DATE_OPERATORS = [
{ label: "Is", value: "eq" },
{ label: "Is not", value: "ne" },
{ label: "Is before", value: "lt" },
{ label: "Is after", value: "gt" },
{ label: "Is on or before", value: "lte" },
{ label: "Is on or after", value: "gte" },
{ label: "Is between", value: "isBetween" },
{ label: "Is relative to today", value: "isRelativeToToday" },
{ label: "Is empty", value: "isEmpty" },
{ label: "Is not empty", value: "isNotEmpty" },
] satisfies FilterOperatorOption[];
const SELECT_OPERATORS = [
{ label: "Is", value: "eq" },
{ label: "Is not", value: "ne" },
{ label: "Is empty", value: "isEmpty" },
{ label: "Is not empty", value: "isNotEmpty" },
] satisfies FilterOperatorOption[];
const MULTI_SELECT_OPERATORS = [
{ label: "Has any of", value: "inArray" },
{ label: "Has none of", value: "notInArray" },
{ label: "Is empty", value: "isEmpty" },
{ label: "Is not empty", value: "isNotEmpty" },
] satisfies FilterOperatorOption[];
const BOOLEAN_OPERATORS = [
{ label: "Is", value: "eq" },
{ label: "Is not", value: "ne" },
] satisfies FilterOperatorOption[];
const MULTI_VALUE_FILTER_VARIANTS: FilterVariant[] = [
"select",
"multiSelect",
"range",
"dateRange",
];
const FILTER_OPERATORS_BY_VARIANT: Record<
FilterVariant,
FilterOperatorOption[]
> = {
text: TEXT_OPERATORS,
number: NUMERIC_OPERATORS,
range: NUMERIC_OPERATORS,
date: DATE_OPERATORS,
dateRange: DATE_OPERATORS,
boolean: BOOLEAN_OPERATORS,
select: SELECT_OPERATORS,
multiSelect: MULTI_SELECT_OPERATORS,
};
function getColumnVar(columnId: string, property: "size" | "offset") {
const name = columnId.replace(
/[^a-zA-Z0-9-]/g,
(char) => `_${char.codePointAt(0)?.toString(16)}_`,
);
return `--column-${name}-${property}`;
}
export function getAriaSort(
sorting: SortingState,
columnId: string,
): React.AriaAttributes["aria-sort"] {
const sort = sorting[0];
if (sort?.id !== columnId) return undefined;
return sort.desc ? "descending" : "ascending";
}
export function getVisibleSelectedRows<TData extends RowData>(
table: Table<DataTableFeatures, TData>,
) {
return table.getRowModel().rows.filter((row) => row.getIsSelected());
}
export function getColumnPinningStyle<TData extends RowData>(
column: Column<DataTableFeatures, TData>,
): React.CSSProperties {
const isPinned = column.getIsPinned();
return {
insetInlineStart:
isPinned === "start"
? `var(${getColumnVar(column.id, "offset")})`
: undefined,
insetInlineEnd:
isPinned === "end"
? `var(${getColumnVar(column.id, "offset")})`
: undefined,
opacity: isPinned ? 0.97 : 1,
position: isPinned ? "sticky" : "relative",
width: `var(${getColumnVar(column.id, "size")})`,
zIndex: isPinned ? 20 : undefined,
};
}
export function getColumnSizingStyle<TData extends RowData>(
table: Table<DataTableFeatures, TData>,
): React.CSSProperties {
const style: Record<string, string> = {
minWidth: `${table.getTotalSize()}px`,
};
for (const header of table.getFlatHeaders()) {
style[getColumnVar(header.column.id, "size")] = `${header.getSize()}px`;
}
for (const { column } of table.getLeafHeaders()) {
const isPinned = column.getIsPinned();
if (!isPinned) continue;
const offset =
isPinned === "start" ? column.getStart("start") : column.getAfter("end");
style[getColumnVar(column.id, "offset")] = `${offset}px`;
}
return style;
}
export function getFilterOperators(filterVariant: FilterVariant) {
return FILTER_OPERATORS_BY_VARIANT[filterVariant] ?? [];
}
export function getDefaultFilterOperator(filterVariant: FilterVariant) {
const operators = getFilterOperators(filterVariant);
return operators[0]?.value ?? (filterVariant === "text" ? "iLike" : "eq");
}
export function getIsEditableTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false;
return (
target.isContentEditable ||
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement
);
}
export function getIsValuelessOperator(operator: FilterOperator) {
return operator === "isEmpty" || operator === "isNotEmpty";
}
export function coerceFilterValue(
operator: FilterOperator,
value: string | string[],
) {
if (getIsValuelessOperator(operator)) return "";
if (operator === "inArray" || operator === "notInArray") {
if (Array.isArray(value)) return value;
return value ? [value] : [];
}
if (operator === "isBetween") {
return Array.isArray(value) ? value : [value, ""];
}
if (Array.isArray(value)) return value.find((item) => item !== "") ?? "";
return value;
}
export function getDefaultFilter<TData extends RowData>(
column: Column<DataTableFeatures, TData>,
) {
const variant = column.columnDef.meta?.variant ?? "text";
return {
id: column.id,
variant,
operator: getDefaultFilterOperator(variant),
value: "",
};
}
export function getSelectFilterValue(filter: ColumnFilterItem) {
if (filter.variant === "multiSelect") {
return Array.isArray(filter.value) ? filter.value : [];
}
return typeof filter.value === "string" ? filter.value : undefined;
}
const CALENDAR_DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
export function parseFilterDate(value: unknown): Date | undefined {
if (value instanceof Date) return getValidDate(value);
if (typeof value === "number") return getValidDate(new Date(value));
if (typeof value !== "string" || value.trim() === "") return undefined;
const text = value.trim();
const match = CALENDAR_DATE_PATTERN.exec(text);
if (match) {
const [, year, month, day] = match.map(Number);
if (year === undefined || month === undefined || day === undefined) {
return undefined;
}
const date = new Date(year, month - 1, day);
return date.getFullYear() === year &&
date.getMonth() === month - 1 &&
date.getDate() === day
? date
: undefined;
}
const numeric = Number(text);
return getValidDate(new Date(Number.isNaN(numeric) ? text : numeric));
}
export function formatFilterDate(date: Date) {
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${month}-${day}`;
}
function getValidDate(date: Date) {
return Number.isNaN(date.getTime()) ? undefined : date;
}
function stringifyFilterDate(value: unknown) {
const date = parseFilterDate(value);
return date ? formatFilterDate(date) : "";
}
export function getIsDateVariant(variant: FilterVariant) {
return variant === "date" || variant === "dateRange";
}
function getFilterValueStringifier(
variant: FilterVariant,
operator: FilterOperator,
) {
return getIsDateVariant(variant) && operator !== "isRelativeToToday"
? stringifyFilterDate
: stringifyFilterValue;
}
export function getFilterDates(value: ColumnFilterItem["value"]) {
return (Array.isArray(value) ? value : [value]).flatMap((item) => {
const date = parseFilterDate(item);
return date ? [date] : [];
});
}
export function getFilterDateValue(date: Date | undefined) {
return date ? formatFilterDate(date) : "";
}
export function getDateFilterLabel(filter: ColumnFilterItem) {
const [startDate, endDate] = getFilterDates(filter.value);
if (!startDate) return undefined;
const start = formatDate(startDate, { month: "short" });
if (
filter.operator !== "isBetween" ||
!endDate ||
startDate.toDateString() === endDate.toDateString()
) {
return start;
}
return `${start} - ${formatDate(endDate, { month: "short" })}`;
}
export function stringifyFilterValue(value: unknown): string {
if (value == null) return "";
if (value instanceof Date) return value.toISOString();
if (typeof value === "object") return JSON.stringify(value);
return String(value as string | number | boolean | bigint);
}
export function getIsMultiValueVariant(variant: FilterVariant) {
return MULTI_VALUE_FILTER_VARIANTS.includes(variant);
}
export function getPlainFilterOperator(variant: FilterVariant): FilterOperator {
if (variant === "select" || variant === "multiSelect") return "inArray";
if (variant === "range" || variant === "dateRange") return "isBetween";
return getDefaultFilterOperator(variant);
}
export function getIsPlainFilter(filter: ColumnFilterItem) {
return (
filter.operator === getPlainFilterOperator(filter.variant) &&
Array.isArray(filter.value) === getIsMultiValueVariant(filter.variant)
);
}
export function getPlainFilterId(columnId: string) {
return `${columnId}-filter`;
}
export function createPlainFilter<TColumnId extends string>(
id: TColumnId,
variant: FilterVariant,
value: unknown,
): ColumnFilterItem<TColumnId> | null {
if (value === undefined || value === null || value === "") return null;
const operator = getPlainFilterOperator(variant);
const filterId = getPlainFilterId(id);
const stringify = getFilterValueStringifier(variant, operator);
if (getIsMultiValueVariant(variant)) {
const values = (Array.isArray(value) ? value : [value]).map(stringify);
if (values.every((item) => item === "")) return null;
return { id, variant, operator, value: values, filterId };
}
if (Array.isArray(value)) return null;
return { id, variant, operator, value: stringify(value), filterId };
}
export function normalizeColumnFilter(
filter: ColumnFilter,
variant: FilterVariant,
): ColumnFilterItem {
const filterId = filter.filterId ?? getPlainFilterId(filter.id);
if (filter.operator) {
const filterVariant = filter.variant ?? variant;
const stringify = getFilterValueStringifier(filterVariant, filter.operator);
return {
id: filter.id,
variant: filterVariant,
operator: filter.operator,
value: Array.isArray(filter.value)
? filter.value.map(stringify)
: stringify(filter.value),
filterId,
};
}
const item = createPlainFilter(filter.id, variant, filter.value);
if (item) return { ...item, filterId };
return {
id: filter.id,
variant,
operator: getPlainFilterOperator(variant),
value: getIsMultiValueVariant(variant) ? [] : "",
filterId,
};
}
export function getPlainFilterValue(filter: ColumnFilterItem): unknown {
const { variant, value } = filter;
if (variant === "select" || variant === "multiSelect") {
return Array.isArray(value) ? value : [value];
}
if (variant === "range") {
return Array.isArray(value)
? value.map((item) => (item === "" ? undefined : Number(item)))
: value;
}
if (getIsDateVariant(variant)) {
return Array.isArray(value)
? value.map((item) => parseFilterDate(item)?.getTime())
: parseFilterDate(value)?.getTime();
}
return value;
}
export function getIsActiveFilter(filter: ColumnFilterItem) {
return (
getIsValuelessOperator(filter.operator) ||
(Array.isArray(filter.value)
? filter.value.some((value) => value !== "")
: filter.value !== "" &&
filter.value !== null &&
filter.value !== undefined)
);
}
export function getActiveFilters<TFilterItem extends ColumnFilterItem>(
filters: TFilterItem[],
): TFilterItem[] {
return filters.filter(getIsActiveFilter);
}
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/id.ts
const DEFAULT_ALPHABET =
"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
interface GenerateIdOptions {
alphabet?: string;
length?: number;
}
export function generateId(
prefixOrOptions?: string | GenerateIdOptions,
inputOptions: GenerateIdOptions = {},
): string {
const options =
typeof prefixOrOptions === "object" ? prefixOrOptions : inputOptions;
const { alphabet = DEFAULT_ALPHABET, length = 12 } = options;
return Array.from(
{ length },
() => alphabet[(Math.random() * alphabet.length) | 0],
).join("");
}
src/lib/data-table-types.ts
import type {
ColumnFilter,
ColumnSort,
Row,
RowData,
} from "@tanstack/react-table";
import type { DataTableFeatures } from "@/lib/data-table-features";
import type {
FILTER_OPERATORS,
FILTER_VARIANTS,
JOIN_OPERATORS,
} from "@/lib/data-table-utils";
export interface DataTableColumnMeta {
label?: string;
placeholder?: string;
variant?: FilterVariant;
options?: FilterOption[];
range?: [number, number];
unit?: string;
icon?: React.ComponentType<React.ComponentProps<"svg">>;
}
export interface DataTableQueryKeys {
page: string;
perPage: string;
sort: string;
joinOperator: string;
}
export interface FilterOption {
label: string;
value: string;
count?: number;
icon?: React.ComponentType<React.ComponentProps<"svg">>;
}
export type FilterOperator = keyof typeof FILTER_OPERATORS;
export type FilterVariant = (typeof FILTER_VARIANTS)[number];
export type JoinOperator = (typeof JOIN_OPERATORS)[number];
export interface FilterOperatorOption {
label: string;
value: FilterOperator;
}
export interface ColumnSortItem<
TColumnId extends string = string,
> extends ColumnSort {
id: TColumnId;
}
export interface ColumnFilterItem<
TColumnId extends string = string,
> extends ColumnFilter {
id: TColumnId;
value: string | string[];
variant: FilterVariant;
operator: FilterOperator;
filterId: string;
}
declare module "@tanstack/react-table" {
interface ColumnFilter extends Partial<
Pick<ColumnFilterItem, "operator" | "variant" | "filterId">
> {}
}
export interface DataTableColumnConfig {
variant?: FilterVariant;
isSortable?: boolean;
}
export type DataTableColumnConfigs = Record<string, DataTableColumnConfig>;
export type FilterableColumnId<TColumnConfigs extends DataTableColumnConfigs> =
{
[K in keyof TColumnConfigs]: TColumnConfigs[K] extends {
variant: FilterVariant;
}
? K
: never;
}[keyof TColumnConfigs] &
string;
export type SortableColumnId<TColumnConfigs extends DataTableColumnConfigs> = {
[K in keyof TColumnConfigs]: TColumnConfigs[K] extends { isSortable: false }
? never
: K;
}[keyof TColumnConfigs] &
string;
export interface DataTableQuery<
TFilterColumnId extends string = string,
TSortColumnId extends string = TFilterColumnId,
> {
page: number;
perPage: number;
sorting: ColumnSortItem<TSortColumnId>[];
filters: ColumnFilterItem<TFilterColumnId>[];
joinOperator: JoinOperator;
}
export type DataTableColumnConfigsQuery<
TColumnConfigs extends DataTableColumnConfigs,
> = DataTableQuery<
FilterableColumnId<TColumnConfigs>,
SortableColumnId<TColumnConfigs>
>;
export interface DataTableRowAction<TData extends RowData> {
row: Row<DataTableFeatures, TData>;
variant: "update" | "delete";
}
src/lib/data-table-features.ts
import {
assignPrototypeAPIs,
assignTableAPIs,
type ColumnFilter,
columnFacetingFeature,
columnFilteringFeature,
columnOrderingFeature,
columnPinningFeature,
columnSizingFeature,
columnVisibilityFeature,
createFacetedMinMaxValues,
createFacetedUniqueValues,
createPaginatedRowModel,
createSortedRowModel,
functionalUpdate,
makeStateUpdater,
metaHelper,
setStateSlice,
sortFn_alphanumeric,
sortFn_datetime,
sortFn_text,
type OnChangeFn,
type RowData,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
type Table,
type TableFeature,
type TableFeatures,
tableFeatures,
type Updater,
} from "@tanstack/react-table";
import type {
ColumnFilterItem,
DataTableColumnMeta,
JoinOperator,
} from "@/lib/data-table-types";
import {
createDataTableFacetedRowModel,
createDataTableFilteredRowModel,
dataTableFilterFn,
} from "@/lib/data-table-filters";
import {
getIsPlainFilter,
normalizeColumnFilter,
getPlainFilterValue,
} from "@/lib/data-table-utils";
interface TableState_DataTableFiltering {
joinOperator: JoinOperator;
}
interface TableOptions_DataTableFiltering {
onJoinOperatorChange?: OnChangeFn<JoinOperator>;
}
interface Table_DataTableFiltering {
getColumnFilterItems: () => ColumnFilterItem[];
addColumnFilter: (filter: ColumnFilterItem) => void;
updateColumnFilter: (
filterId: string,
updates: Partial<Omit<ColumnFilterItem, "filterId">>,
) => void;
removeColumnFilter: (filterId: string) => void;
getJoinOperator: () => JoinOperator;
setJoinOperator: (updater: Updater<JoinOperator>) => void;
resetJoinOperator: (defaultState?: boolean) => void;
}
declare module "@tanstack/react-table" {
interface Plugins {
dataTableFilteringFeature: TableFeature;
}
interface TableState_FeatureMap {
dataTableFilteringFeature: TableState_DataTableFiltering;
}
interface TableOptions_FeatureMap<
in out TFeatures extends TableFeatures,
in out TData extends RowData,
> {
dataTableFilteringFeature: TableOptions_DataTableFiltering;
}
interface Table_FeatureMap<
in out TFeatures extends TableFeatures,
in out TData extends RowData,
> {
dataTableFilteringFeature: Table_DataTableFiltering;
}
}
type DataTableInstance = Table<DataTableFeatures, RowData>;
/**
* Feature hooks get a `Table` generic over any features. This feature is only
* registered in `dataTableFeatures`, so its hooks can use that table's types.
*/
function asDataTable(table: object) {
return table as DataTableInstance;
}
const dataTableFilteringFeature: TableFeature = {
getInitialState: (initialState) => ({
joinOperator: "and",
...initialState,
}),
getDefaultColumnDef: () => ({
enableColumnFilter: false,
filterFn: dataTableFilterFn,
}),
getDefaultTableOptions: (table) => {
const options: TableOptions_DataTableFiltering = {
onJoinOperatorChange: makeStateUpdater("joinOperator", table),
};
return options;
},
assignColumnPrototype: (prototype, table) => {
const instance = asDataTable(table);
assignPrototypeAPIs("dataTableFilteringFeature", prototype, table, {
column_getFilterValue: {
fn: (column: { id: string }) =>
getPlainFilter(
instance,
column.id,
instance.atoms.columnFilters.get(),
).value,
memoDeps: () => [instance.atoms.columnFilters.get()],
},
column_setFilterValue: {
fn: (column: { id: string }, updater: Updater<unknown>) =>
setPlainFilter(instance, column.id, updater),
},
});
},
constructTableAPIs: (table) => {
const instance = asDataTable(table);
const resolve = (filter: ColumnFilter) =>
normalizeColumnFilter(
filter,
instance.getColumn(filter.id)?.columnDef.meta?.variant ?? "text",
);
const setJoinOperator = (updater: Updater<JoinOperator>) =>
instance.options.onJoinOperatorChange?.((old) =>
functionalUpdate(updater, old),
);
assignTableAPIs("dataTableFilteringFeature", table, {
table_setColumnFilters: {
fn: (updater: Updater<ColumnFilter[]>) =>
setStateSlice(table, "columnFilters", (old: ColumnFilter[]) =>
functionalUpdate(updater, old).filter(
(filter) => !getShouldRemoveFilter(filter, instance),
),
),
},
table_getColumnFilterItems: {
fn: () => instance.atoms.columnFilters.get().map(resolve),
memoDeps: () => [instance.atoms.columnFilters.get()],
},
table_addColumnFilter: {
fn: (filter: ColumnFilterItem) =>
instance.setColumnFilters((old) => [...old, filter]),
},
table_updateColumnFilter: {
fn: (
filterId: string,
updates: Partial<Omit<ColumnFilterItem, "filterId">>,
) =>
instance.setColumnFilters((old) =>
old.map((filter) => {
const item = resolve(filter);
return item.filterId === filterId
? { ...item, ...updates }
: filter;
}),
),
},
table_removeColumnFilter: {
fn: (filterId: string) =>
instance.setColumnFilters((old) =>
old.filter((filter) => resolve(filter).filterId !== filterId),
),
},
table_getJoinOperator: {
fn: () => instance.atoms.joinOperator.get(),
},
table_setJoinOperator: { fn: setJoinOperator },
table_resetJoinOperator: {
fn: (defaultState?: boolean) =>
setJoinOperator(
defaultState
? "and"
: (instance.initialState.joinOperator ?? "and"),
),
},
});
},
};
function getPlainFilter(
instance: DataTableInstance,
columnId: string,
filters: ColumnFilter[],
) {
const variant =
instance.getColumn(columnId)?.columnDef.meta?.variant ?? "text";
const index = filters.findIndex(
(filter) =>
filter.id === columnId &&
getIsPlainFilter(normalizeColumnFilter(filter, variant)),
);
const filter = filters[index];
const value = filter
? getPlainFilterValue(normalizeColumnFilter(filter, variant))
: undefined;
return { index, filter, value };
}
function setPlainFilter(
instance: DataTableInstance,
columnId: string,
updater: Updater<unknown>,
) {
instance.setColumnFilters((old) => {
const {
index,
filter: previous,
value: previousValue,
} = getPlainFilter(instance, columnId, old);
const value = functionalUpdate(updater, previousValue);
const next: ColumnFilter = previous?.filterId
? { id: columnId, value, filterId: previous.filterId }
: { id: columnId, value };
if (index === -1) return [...old, next];
return old.map((filter, filterIndex) =>
filterIndex === index ? next : filter,
);
});
}
/**
* TanStack's `autoRemove` rules for plain filters. Filters with an
* `operator` are only removed by an `undefined` value, so empty drafts in the
* filter list survive a column `filterFn` whose `autoRemove` would drop them.
*/
function getShouldRemoveFilter(
filter: ColumnFilter,
instance: DataTableInstance,
) {
if (filter.value === undefined) return true;
if (filter.operator) return false;
const column = instance.getColumn(filter.id);
if (!column) return false;
const filterFn = column.getFilterFn();
if (filterFn?.autoRemove) {
return filterFn.autoRemove(filter.value, column);
}
return typeof filter.value === "string" && !filter.value;
}
export const dataTableFeatures = tableFeatures({
columnFilteringFeature,
columnFacetingFeature,
columnOrderingFeature,
columnPinningFeature,
columnSizingFeature,
columnVisibilityFeature,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
dataTableFilteringFeature,
filteredRowModel: createDataTableFilteredRowModel(),
facetedRowModel: createDataTableFacetedRowModel(),
facetedUniqueValues: createFacetedUniqueValues(),
facetedMinMaxValues: createFacetedMinMaxValues(),
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(),
sortFns: {
alphanumeric: sortFn_alphanumeric,
datetime: sortFn_datetime,
text: sortFn_text,
},
columnMeta: metaHelper<DataTableColumnMeta>(),
});
export type DataTableFeatures = typeof dataTableFeatures;
src/lib/data-table-filters.ts
import {
type ColumnFilter,
constructFilterFn,
constructRow,
type FilterFn,
makeObjectMap,
type Row,
type RowData,
type RowModel,
skipFirstRun,
type Table,
type TableFeatures,
tableMemo,
} from "@tanstack/react-table";
import type {
ColumnFilterItem,
FilterVariant,
JoinOperator,
} from "@/lib/data-table-types";
import {
createPlainFilter,
FILTER_VARIANTS,
getIsActiveFilter,
normalizeColumnFilter,
parseFilterDate,
stringifyFilterValue,
} from "@/lib/data-table-utils";
const DAY_MS = 24 * 60 * 60 * 1000;
const GLOBAL_FILTER_ID = "__global__";
const filterResultsByRow = new WeakMap<object, Record<string, boolean>>();
export function matchesFilter(
cellValue: unknown,
filter: Pick<ColumnFilterItem, "operator" | "variant" | "value">,
): boolean {
const { operator, variant, value } = filter;
const isDate = variant === "date" || variant === "dateRange";
const isNumeric = variant === "number" || variant === "range";
switch (operator) {
case "iLike":
return typeof value === "string"
? getLowercaseText(cellValue).includes(value.toLowerCase())
: true;
case "notILike":
return typeof value === "string"
? !getLowercaseText(cellValue).includes(value.toLowerCase())
: true;
case "eq":
if (variant === "boolean") return getBooleanString(cellValue) === value;
if (isDate) return getIsSameDay(cellValue, value);
if (isNumeric) return parseNumber(cellValue) === parseNumber(value);
return stringifyFilterValue(cellValue) === String(value);
case "ne":
if (variant === "boolean") return getBooleanString(cellValue) !== value;
if (isDate) return !getIsSameDay(cellValue, value);
if (isNumeric) return parseNumber(cellValue) !== parseNumber(value);
return stringifyFilterValue(cellValue) !== String(value);
case "inArray":
return Array.isArray(value)
? value.includes(stringifyFilterValue(cellValue))
: true;
case "notInArray":
return Array.isArray(value)
? !value.includes(stringifyFilterValue(cellValue))
: true;
case "lt":
case "lte":
case "gt":
case "gte":
return compare(cellValue, value, operator, isDate);
case "isBetween":
return getIsBetween(cellValue, value, isDate);
case "isRelativeToToday":
return getIsRelativeToToday(cellValue, value);
case "isEmpty":
return getIsEmptyValue(cellValue);
case "isNotEmpty":
return !getIsEmptyValue(cellValue);
default:
return true;
}
}
export const dataTableFilterFn = constructFilterFn({
filter: (dataValue, filterValue, row, columnId) => {
const variant = getFilterVariant(
row.table.getColumn(columnId)?.columnDef.meta,
);
const item = createPlainFilter(columnId, variant, filterValue);
return !item || matchesFilter(dataValue, item);
},
autoRemove: (value) => value === undefined,
});
export function createDataTableFilteredRowModel<
TFeatures extends TableFeatures,
TData extends RowData,
>() {
return (table: Table<TFeatures, TData>) => {
const instance = asFilteringTable(table);
return tableMemo({
feature: "dataTableFilteringFeature",
table,
fnName: "table.getFilteredRowModel",
memoDeps: () => [
table.getPreFilteredRowModel(),
instance.atoms.columnFilters?.get(),
instance.atoms.joinOperator?.get(),
instance.atoms.globalFilter?.get(),
],
fn: () => getFilteredRowModel(table.getPreFilteredRowModel(), instance),
onAfterUpdate: skipFirstRun(() => instance.autoResetPageIndex?.()),
});
};
}
export function createDataTableFacetedRowModel<
TFeatures extends TableFeatures,
TData extends RowData,
>() {
return (table: Table<TFeatures, TData>, columnId: string) => {
const instance = asFilteringTable(table);
return tableMemo({
feature: "columnFacetingFeature",
table,
fnName: "createDataTableFacetedRowModel",
memoDeps: () => [
table.getPreFilteredRowModel(),
instance.atoms.columnFilters?.get(),
instance.atoms.joinOperator?.get(),
instance.atoms.globalFilter?.get(),
table.getFilteredRowModel(),
],
fn: () =>
getFacetedRowModel(table.getPreFilteredRowModel(), columnId, instance),
});
};
}
type RowTest<TFeatures extends TableFeatures, TData extends RowData> = (
row: Row<TFeatures, TData>,
) => boolean;
interface FilteringColumn<
TFeatures extends TableFeatures,
TData extends RowData,
> {
id: string;
columnDef: { meta?: unknown };
getFilterFn?: () => FilterFn<TFeatures, TData> | undefined;
getCanGlobalFilter?: () => boolean;
}
interface FilteringMembers<
TFeatures extends TableFeatures,
TData extends RowData,
> {
atoms: {
columnFilters?: { get: () => ColumnFilter[] };
joinOperator?: { get: () => JoinOperator };
globalFilter?: { get: () => unknown };
};
options: { filterFromLeafRows?: boolean; maxLeafRowFilterDepth?: number };
getColumn: (
columnId: string,
) => FilteringColumn<TFeatures, TData> | undefined;
getAllLeafColumns: () => FilteringColumn<TFeatures, TData>[];
getGlobalFilterFn?: () => FilterFn<TFeatures, TData> | undefined;
autoResetPageIndex?: () => void;
}
type FilteringInstance<
TFeatures extends TableFeatures,
TData extends RowData,
> = FilteringMembers<TFeatures, TData> & Table<TFeatures, TData>;
function asFilteringTable<
TFeatures extends TableFeatures,
TData extends RowData,
>(table: Table<TFeatures, TData>) {
return table as FilteringInstance<TFeatures, TData>;
}
function getFilteredRowModel<
TFeatures extends TableFeatures,
TData extends RowData,
>(
rowModel: RowModel<TFeatures, TData>,
instance: FilteringInstance<TFeatures, TData>,
): RowModel<TFeatures, TData> {
const joinOperator = instance.atoms.joinOperator?.get() ?? "and";
const testsByColumn = new Map<string, RowTest<TFeatures, TData>[]>();
for (const filter of instance.atoms.columnFilters?.get() ?? []) {
const column = instance.getColumn(filter.id);
const test = column ? getFilterTest(column, filter) : null;
if (!test) continue;
testsByColumn.set(filter.id, [
...(testsByColumn.get(filter.id) ?? []),
test,
]);
}
const globalTests = getGlobalFilterTests(instance);
const hasFilters = testsByColumn.size > 0 || globalTests.length > 0;
for (const row of rowModel.flatRows) {
const results = makeObjectMap<boolean>();
if (hasFilters) {
for (const [columnId, tests] of testsByColumn) {
results[columnId] =
joinOperator === "or"
? tests.some((test) => test(row))
: tests.every((test) => test(row));
}
if (globalTests.length > 0) {
results[GLOBAL_FILTER_ID] = globalTests.some((test) => test(row));
}
}
setFilterResults(row, results);
}
if (!hasFilters || rowModel.rows.length === 0) return rowModel;
return filterRows(
rowModel.rows,
(row) => getRowPasses(row, joinOperator),
instance,
);
}
function getFacetedRowModel<
TFeatures extends TableFeatures,
TData extends RowData,
>(
rowModel: RowModel<TFeatures, TData>,
columnId: string,
instance: FilteringInstance<TFeatures, TData>,
): RowModel<TFeatures, TData> {
const globalFilter = instance.atoms.globalFilter?.get();
const hasGlobalFilter =
columnId !== GLOBAL_FILTER_ID &&
globalFilter !== undefined &&
globalFilter !== null &&
globalFilter !== "";
const hasOtherFilters =
hasGlobalFilter ||
(instance.atoms.columnFilters?.get() ?? []).some(
(filter) => filter.id !== columnId,
);
if (!hasOtherFilters || rowModel.rows.length === 0) return rowModel;
const joinOperator = instance.atoms.joinOperator?.get() ?? "and";
return filterRows(
rowModel.rows,
(row) => getRowPasses(row, joinOperator, columnId),
instance,
);
}
function getRowPasses<TFeatures extends TableFeatures, TData extends RowData>(
row: Row<TFeatures, TData>,
joinOperator: JoinOperator,
excludedColumnId?: string,
) {
const results = filterResultsByRow.get(row) ?? {};
if (
excludedColumnId !== GLOBAL_FILTER_ID &&
results[GLOBAL_FILTER_ID] === false
) {
return false;
}
const columnIds = Object.keys(results).filter(
(id) => id !== GLOBAL_FILTER_ID && id !== excludedColumnId,
);
if (columnIds.length === 0) return true;
return joinOperator === "or"
? columnIds.some((id) => results[id])
: columnIds.every((id) => results[id]);
}
function setFilterResults<
TFeatures extends TableFeatures,
TData extends RowData,
>(row: Row<TFeatures, TData>, results: Record<string, boolean> = {}) {
filterResultsByRow.set(row, results);
Object.assign(row, {
columnFilters: results,
columnFiltersMeta: makeObjectMap(),
});
}
function getFilterVariant(meta: unknown): FilterVariant {
const variant =
typeof meta === "object" && meta && "variant" in meta
? meta.variant
: undefined;
return FILTER_VARIANTS.find((item) => item === variant) ?? "text";
}
function getFilterTest<TFeatures extends TableFeatures, TData extends RowData>(
column: FilteringColumn<TFeatures, TData>,
filter: ColumnFilter,
): RowTest<TFeatures, TData> | null {
const filterFn = column.getFilterFn?.();
if (!filter.operator && filterFn && !Object.is(filterFn, dataTableFilterFn)) {
const value = filterFn.resolveFilterValue?.(filter.value) ?? filter.value;
return (row) => filterFn(row, column.id, value);
}
const item = normalizeColumnFilter(
filter,
getFilterVariant(column.columnDef.meta),
);
if (!getIsActiveFilter(item)) return null;
return (row) => matchesFilter(row.getValue(column.id), item);
}
function getGlobalFilterTests<
TFeatures extends TableFeatures,
TData extends RowData,
>(instance: FilteringInstance<TFeatures, TData>): RowTest<TFeatures, TData>[] {
const globalFilter = instance.atoms.globalFilter?.get();
const filterFn = instance.getGlobalFilterFn?.();
if (
globalFilter === undefined ||
globalFilter === null ||
globalFilter === "" ||
!filterFn
) {
return [];
}
const value = filterFn.resolveFilterValue?.(globalFilter) ?? globalFilter;
return instance
.getAllLeafColumns()
.filter((column) => column.getCanGlobalFilter?.())
.map((column) => (row) => filterFn(row, column.id, value));
}
function filterRows<TFeatures extends TableFeatures, TData extends RowData>(
rows: Row<TFeatures, TData>[],
passes: RowTest<TFeatures, TData>,
instance: FilteringInstance<TFeatures, TData>,
): RowModel<TFeatures, TData> {
const maxDepth = instance.options.maxLeafRowFilterDepth ?? 100;
const flatRows: Row<TFeatures, TData>[] = [];
const rowsById = makeObjectMap<Row<TFeatures, TData>>();
function copyRow(row: Row<TFeatures, TData>) {
const copy = constructRow(
instance,
row.id,
row.original,
row.index,
row.depth,
undefined,
row.parentId,
);
setFilterResults(copy, filterResultsByRow.get(row));
return copy;
}
function addToFlat(subRows: Row<TFeatures, TData>[]) {
for (const subRow of subRows) {
flatRows.push(subRow);
rowsById[subRow.id] = subRow;
if (subRow.subRows.length) addToFlat(subRow.subRows);
}
}
function fromLeafs(
rowsToFilter: Row<TFeatures, TData>[],
depth: number,
): Row<TFeatures, TData>[] {
const filtered: Row<TFeatures, TData>[] = [];
for (const row of rowsToFilter) {
const copy = copyRow(row);
if (row.subRows.length && depth < maxDepth) {
copy.subRows = fromLeafs(row.subRows, depth + 1);
if (copy.subRows.length || passes(copy)) filtered.push(copy);
} else if (passes(copy)) {
copy.subRows = row.subRows;
filtered.push(copy);
}
}
return filtered;
}
function fromRoot(
rowsToFilter: Row<TFeatures, TData>[],
depth: number,
): Row<TFeatures, TData>[] {
const filtered: Row<TFeatures, TData>[] = [];
for (const row of rowsToFilter) {
if (!passes(row)) continue;
if (row.subRows.length && depth < maxDepth) {
const copy = copyRow(row);
filtered.push(copy);
flatRows.push(copy);
rowsById[copy.id] = copy;
copy.subRows = fromRoot(row.subRows, depth + 1);
} else {
filtered.push(row);
flatRows.push(row);
rowsById[row.id] = row;
if (row.subRows.length) addToFlat(row.subRows);
}
}
return filtered;
}
if (instance.options.filterFromLeafRows) {
const filtered = fromLeafs(rows, 0);
addToFlat(filtered);
return { rows: filtered, flatRows, rowsById };
}
return { rows: fromRoot(rows, 0), flatRows, rowsById };
}
function getLowercaseText(value: unknown) {
return stringifyFilterValue(value).toLowerCase();
}
function getBooleanString(value: unknown) {
return value === true || value === "true" ? "true" : "false";
}
function parseNumber(value: unknown) {
if (value === "" || value == null) return Number.NaN;
return typeof value === "number" ? value : Number(value);
}
function parseTime(value: unknown) {
return parseFilterDate(value)?.getTime() ?? Number.NaN;
}
function startOfDay(time: number) {
const date = new Date(time);
date.setHours(0, 0, 0, 0);
return date.getTime();
}
function endOfDay(time: number) {
const date = new Date(time);
date.setHours(23, 59, 59, 999);
return date.getTime();
}
function getIsSameDay(cellValue: unknown, value: unknown) {
const cell = parseTime(cellValue);
const target = parseTime(value);
if (Number.isNaN(cell) || Number.isNaN(target)) return false;
return cell >= startOfDay(target) && cell <= endOfDay(target);
}
function compare(
cellValue: unknown,
value: unknown,
operator: "lt" | "lte" | "gt" | "gte",
isDate: boolean,
) {
if (Array.isArray(value)) return true;
let cell: number;
let target: number;
if (isDate) {
cell = parseTime(cellValue);
const time = parseTime(value);
target =
operator === "lt" || operator === "gte"
? startOfDay(time)
: endOfDay(time);
} else {
cell = parseNumber(cellValue);
target = parseNumber(value);
}
if (Number.isNaN(cell) || Number.isNaN(target)) return false;
switch (operator) {
case "lt":
return cell < target;
case "lte":
return cell <= target;
case "gt":
return cell > target;
default:
return cell >= target;
}
}
function getIsBetween(cellValue: unknown, value: unknown, isDate: boolean) {
if (!Array.isArray(value) || value.length !== 2) return true;
const [rawStart, rawEnd] = value;
const hasStart = stringifyFilterValue(rawStart).trim() !== "";
const hasEnd = stringifyFilterValue(rawEnd).trim() !== "";
if (!hasStart && !hasEnd) return true;
const cell = isDate ? parseTime(cellValue) : parseNumber(cellValue);
if (Number.isNaN(cell)) return false;
if (isDate) {
const start = hasStart ? startOfDay(parseTime(rawStart)) : null;
const end = hasEnd ? endOfDay(parseTime(rawEnd)) : null;
return (start === null || cell >= start) && (end === null || cell <= end);
}
const start = hasStart ? parseNumber(rawStart) : null;
const end = hasEnd ? parseNumber(rawEnd) : null;
return (start === null || cell >= start) && (end === null || cell <= end);
}
function getIsRelativeToToday(cellValue: unknown, value: unknown) {
if (typeof value !== "string") return true;
const [amountRaw, unit] = value.split(" ");
const amount = Number.parseInt(amountRaw ?? "", 10);
if (Number.isNaN(amount) || !unit) return true;
const today = Date.now();
let start: number;
let end: number;
switch (unit) {
case "days":
start = startOfDay(today + amount * DAY_MS);
end = endOfDay(start);
break;
case "weeks":
start = startOfDay(today + amount * 7 * DAY_MS);
end = endOfDay(start + 6 * DAY_MS);
break;
case "months":
start = startOfDay(today + amount * 30 * DAY_MS);
end = endOfDay(start + 29 * DAY_MS);
break;
default:
return true;
}
const cell = parseTime(cellValue);
if (Number.isNaN(cell)) return false;
return cell >= start && cell <= end;
}
function getIsEmptyValue(value: unknown) {
return (
value === null ||
value === undefined ||
value === "" ||
(Array.isArray(value) && value.length === 0)
);
}
src/registry/bases/base/components/data-table/data-table-view-options.tsx
"use client";
import {
type ReactTable,
type RowData,
Subscribe,
} from "@tanstack/react-table";
import { cn } from "cn";
import * as React from "react";
import type { DataTableFeatures } from "@/lib/data-table-features";
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,
} from "@/registry/bases/base/ui/faceted";
import { IconPlaceholder } from "@/registry/icons/icon-placeholder";
interface DataTableViewOptionsProps<
TData extends RowData,
> extends React.ComponentProps<typeof FacetedContent> {
table: ReactTable<DataTableFeatures, TData, unknown>;
disabled?: boolean;
}
export function DataTableViewOptions<TData extends RowData>({
table,
disabled,
className,
...props
}: DataTableViewOptionsProps<TData>) {
const dir = useDirection();
const columns = React.useMemo(
() =>
table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" && column.getCanHide(),
),
[table],
);
return (
<Subscribe source={table.atoms.columnVisibility}>
{(columnVisibility) => (
<Faceted
multiple
value={columns
.filter((column) => columnVisibility[column.id] !== false)
.map((column) => column.id)}
>
<FacetedTrigger
render={
<Button
aria-label="View columns"
variant="outline"
className="ms-auto hidden lg:flex"
disabled={disabled}
/>
}
>
<IconPlaceholder
lucide="Settings2"
tabler="IconSettings"
hugeicons="Settings05Icon"
phosphor="GearIcon"
remixicon="RiSettingsLine"
className="text-muted-foreground"
/>
View
</FacetedTrigger>
<FacetedContent
aria-label="Columns"
dir={dir}
align="center"
className={cn("w-44", className)}
{...props}
>
<FacetedInput placeholder="Search columns..." />
<FacetedList>
<FacetedEmpty>No columns found.</FacetedEmpty>
<FacetedGroup>
{columns.map((column) => (
<FacetedItem
key={column.id}
value={column.id}
onSelect={() => column.toggleVisibility()}
>
<span className="truncate">
{column.columnDef.meta?.label ?? column.id}
</span>
<FacetedItemIndicator />
</FacetedItem>
))}
</FacetedGroup>
</FacetedList>
</FacetedContent>
</Faceted>
)}
</Subscribe>
);
}