Shadcn Table
componentdata-table-filter-listsource: stale · stale
Data Table Filter List
A filter list component for the data table
npx shadcn add "https://tablecn.com/r/data-table-filter-list.json"free
This source snapshot is older than our freshness window and couldn't be re-verified just now — it may be outdated.
Where it goes
data-table
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
ui-primitives@base-ui/react
Registry dependencies
badgebuttoncalendarcommandinputpopoverselect
Files14 · source cached 9/28/2026, 5:39:35 PM
src/registry/bases/base/components/data-table/data-table-filter-list.tsx
"use client";
import type { Column, RowData, Table } from "@tanstack/react-table";
import { cn } from "cn";
import { parseAsStringEnum, useQueryState } from "nuqs";
import * as React from "react";
import type { DataTableFeatures } from "@/lib/data-table-features";
import type {
DataTableColumnMeta,
ExtendedColumnFilter,
JoinOperator,
} from "@/lib/data-table-types";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import {
dataTableConfig,
getDefaultFilterOperator,
getFilterOperators,
} from "@/lib/data-table-utils";
import { formatDate } from "@/lib/format";
import { generateId } from "@/lib/id";
import { getFiltersStateParser } from "@/lib/parsers";
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 {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/registry/bases/base/ui/command";
import {
Faceted,
FacetedBadgeList,
FacetedContent,
FacetedEmpty,
FacetedGroup,
FacetedInput,
FacetedItem,
FacetedList,
FacetedTrigger,
} 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 DEBOUNCE_MS = 300;
const THROTTLE_MS = 50;
const FILTER_SHORTCUT_KEY = "f";
const REMOVE_FILTER_SHORTCUTS = ["backspace", "delete"];
interface DataTableFilterListProps<
TData extends RowData,
> extends React.ComponentProps<typeof PopoverContent> {
table: Table<DataTableFeatures, TData>;
debounceMs?: number;
throttleMs?: number;
shallow?: boolean;
disabled?: boolean;
}
export function DataTableFilterList<TData extends RowData>({
table,
debounceMs = DEBOUNCE_MS,
throttleMs = THROTTLE_MS,
shallow = true,
disabled,
...props
}: DataTableFilterListProps<TData>) {
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 columns = React.useMemo(() => {
return table
.getAllColumns()
.filter((column) => column.columnDef.enableColumnFilter);
}, [table]);
const [filters, setFilters] = useQueryState(
table.options.meta?.queryKeys?.filters ?? "filters",
getFiltersStateParser<TData>(columns.map((field) => field.id))
.withDefault([])
.withOptions({
clearOnDefault: true,
shallow,
throttleMs,
}),
);
const debouncedSetFilters = useDebouncedCallback(setFilters, debounceMs);
const [joinOperator, setJoinOperator] = useQueryState(
table.options.meta?.queryKeys?.joinOperator ?? "",
parseAsStringEnum(["and", "or"]).withDefault("and").withOptions({
clearOnDefault: true,
shallow,
}),
);
const onFilterAdd = React.useCallback(() => {
const column = columns[0];
if (!column) return;
debouncedSetFilters([
...filters,
{
id: column.id as Extract<keyof TData, string>,
value: "",
variant: column.columnDef.meta?.variant ?? "text",
operator: getDefaultFilterOperator(
column.columnDef.meta?.variant ?? "text",
),
filterId: generateId({ length: 8 }),
},
]);
}, [columns, filters, debouncedSetFilters]);
const onFilterUpdate = React.useCallback(
(
filterId: string,
updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>,
) => {
debouncedSetFilters((prevFilters) => {
const updatedFilters = prevFilters.map((filter) => {
if (filter.filterId === filterId) {
return { ...filter, ...updates } as ExtendedColumnFilter<TData>;
}
return filter;
});
return updatedFilters;
});
},
[debouncedSetFilters],
);
const onFilterRemove = React.useCallback(
(filterId: string) => {
const updatedFilters = filters.filter(
(filter) => filter.filterId !== filterId,
);
void setFilters(updatedFilters);
requestAnimationFrame(() => {
addButtonRef.current?.focus();
});
},
[filters, setFilters],
);
const onFiltersReset = React.useCallback(() => {
void setFilters(null);
void setJoinOperator("and");
}, [setFilters, setJoinOperator]);
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.includes(event.key.toLowerCase()) &&
filters.length > 0
) {
event.preventDefault();
onFilterRemove(filters[filters.length - 1]?.filterId ?? "");
}
},
[filters, onFilterRemove],
);
return (
<Sortable
value={filters}
onValueChange={setFilters}
getItemValue={(item) => item.filterId}
>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger
render={
<Button
variant="outline"
className="font-normal"
onKeyDown={onTriggerKeyDown}
disabled={disabled}
/>
}
>
<IconPlaceholder
lucide="ListFilter"
tabler="IconListDetails"
hugeicons="LeftToRightListBulletIcon"
phosphor="ListIcon"
remixicon="RiListUnordered"
className="text-muted-foreground"
/>
Filter
{filters.length > 0 && (
<Badge
variant="secondary"
className="h-[18.24px] rounded-md px-[5.12px] font-mono text-[10.4px] font-normal"
>
{filters.length}
</Badge>
)}
</PopoverTrigger>
<PopoverContent
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">
{filters.length > 0 ? "Filters" : "No filters applied"}
</h4>
<p
id={descriptionId}
className={cn(
"text-sm text-muted-foreground",
filters.length > 0 && "sr-only",
)}
>
{filters.length > 0
? "Modify filters to refine your rows."
: "Add filters to refine your rows."}
</p>
</div>
{filters.length > 0 ? (
<SortableContent
render={
<div
role="list"
className="flex max-h-75 flex-col gap-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}
setJoinOperator={setJoinOperator}
columns={columns}
onFilterUpdate={onFilterUpdate}
onFilterRemove={onFilterRemove}
/>
))}
</SortableContent>
) : null}
<div className="flex w-full items-center gap-2">
<Button
className="rounded"
ref={addButtonRef}
onClick={onFilterAdd}
>
Add filter
</Button>
{filters.length > 0 ? (
<Button
variant="outline"
className="rounded"
onClick={onFiltersReset}
>
Reset filters
</Button>
) : null}
</div>
</PopoverContent>
</Popover>
<SortableOverlay>
<div className="flex items-center gap-2">
<div className="h-8 min-w-18 rounded-sm bg-primary/10" />
<div className="h-8 w-32 rounded-sm bg-primary/10" />
<div className="h-8 w-32 rounded-sm bg-primary/10" />
<div className="h-8 min-w-36 flex-1 rounded-sm bg-primary/10" />
<div className="size-8 shrink-0 rounded-sm bg-primary/10" />
<div className="size-8 shrink-0 rounded-sm bg-primary/10" />
</div>
</SortableOverlay>
</Sortable>
);
}
interface DataTableFilterItemProps<TData extends RowData> {
filter: ExtendedColumnFilter<TData>;
index: number;
filterItemId: string;
joinOperator: JoinOperator;
setJoinOperator: (value: JoinOperator) => void;
columns: Column<DataTableFeatures, TData>[];
onFilterUpdate: (
filterId: string,
updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>,
) => void;
onFilterRemove: (filterId: string) => void;
}
function DataTableFilterItem<TData extends RowData>({
filter,
index,
filterItemId,
joinOperator,
setJoinOperator,
columns,
onFilterUpdate,
onFilterRemove,
}: DataTableFilterItemProps<TData>) {
const [showFieldSelector, setShowFieldSelector] = React.useState(false);
const [showOperatorSelector, setShowOperatorSelector] = React.useState(false);
const [showValueSelector, setShowValueSelector] = React.useState(false);
const column = columns.find((column) => column.id === filter.id);
const joinOperatorListboxId = `${filterItemId}-join-operator-listbox`;
const fieldListboxId = `${filterItemId}-field-listbox`;
const operatorListboxId = `${filterItemId}-operator-listbox`;
const inputId = `${filterItemId}-input`;
const columnMeta = column?.columnDef.meta;
const filterOperators = getFilterOperators(filter.variant);
const onItemKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
return;
}
if (showFieldSelector || showOperatorSelector || showValueSelector) {
return;
}
if (REMOVE_FILTER_SHORTCUTS.includes(event.key.toLowerCase())) {
event.preventDefault();
onFilterRemove(filter.filterId);
}
},
[
filter.filterId,
showFieldSelector,
showOperatorSelector,
showValueSelector,
onFilterRemove,
],
);
if (!column) return null;
return (
<SortableItem
value={filter.filterId}
render={
<div
role="listitem"
id={filterItemId}
tabIndex={-1}
className="flex items-center gap-2"
onKeyDown={onItemKeyDown}
/>
}
>
<div className="min-w-18 text-center">
{index === 0 ? (
<span className="text-sm text-muted-foreground">Where</span>
) : index === 1 ? (
<Select
value={joinOperator}
onValueChange={(value) => {
if (value == null) return;
setJoinOperator(value);
}}
>
<SelectTrigger
aria-label="Select join operator"
aria-controls={joinOperatorListboxId}
className="rounded lowercase"
>
<SelectValue placeholder={joinOperator} />
</SelectTrigger>
<SelectContent
id={joinOperatorListboxId}
alignItemWithTrigger={false}
className="min-w-(--anchor-width) lowercase"
>
<SelectGroup>
{dataTableConfig.joinOperators.map((joinOperator) => (
<SelectItem key={joinOperator} value={joinOperator}>
{joinOperator}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
) : (
<span className="text-sm text-muted-foreground">{joinOperator}</span>
)}
</div>
<Popover open={showFieldSelector} onOpenChange={setShowFieldSelector}>
<PopoverTrigger
render={
<Button
aria-controls={fieldListboxId}
variant="outline"
className="w-32 justify-between rounded font-normal"
/>
}
>
<span className="truncate">
{columns.find((column) => column.id === filter.id)?.columnDef.meta
?.label ?? "Select field"}
</span>
<IconPlaceholder
lucide="ChevronsUpDown"
tabler="IconSelector"
hugeicons="UnfoldMoreIcon"
phosphor="CaretUpDownIcon"
remixicon="RiArrowUpDownLine"
className="opacity-50"
/>
</PopoverTrigger>
<PopoverContent id={fieldListboxId} align="start" className="w-40 p-0">
<Command>
<CommandInput placeholder="Search fields..." />
<CommandList>
<CommandEmpty>No fields found.</CommandEmpty>
<CommandGroup>
{columns.map((column) => (
<CommandItem
key={column.id}
value={column.id}
onSelect={(value) => {
onFilterUpdate(filter.filterId, {
id: value as Extract<keyof TData, string>,
variant: column.columnDef.meta?.variant ?? "text",
operator: getDefaultFilterOperator(
column.columnDef.meta?.variant ?? "text",
),
value: "",
});
setShowFieldSelector(false);
}}
>
<span className="truncate">
{column.columnDef.meta?.label}
</span>
<IconPlaceholder
lucide="Check"
tabler="IconCheck"
hugeicons="Tick02Icon"
phosphor="CheckIcon"
remixicon="RiCheckLine"
className={cn(
"ml-auto",
column.id === filter.id ? "opacity-100" : "opacity-0",
)}
/>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
<Select
open={showOperatorSelector}
onOpenChange={setShowOperatorSelector}
value={filter.operator}
onValueChange={(value) => {
if (value == null) return;
onFilterUpdate(filter.filterId, {
operator: value,
value:
value === "isEmpty" || value === "isNotEmpty" ? "" : filter.value,
});
}}
>
<SelectTrigger
aria-controls={operatorListboxId}
className="w-32 rounded lowercase"
>
<div className="truncate">
<SelectValue placeholder={filter.operator} />
</div>
</SelectTrigger>
<SelectContent id={operatorListboxId}>
<SelectGroup>
{filterOperators.map((operator) => (
<SelectItem
key={operator.value}
value={operator.value}
className="lowercase"
>
{operator.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
<div className="max-w-60 min-w-36 flex-1">
{onFilterInputRender({
filter,
inputId,
column,
columnMeta,
onFilterUpdate,
showValueSelector,
setShowValueSelector,
})}
</div>
<Button
aria-controls={filterItemId}
variant="outline"
size="icon"
className="size-8 rounded"
onClick={() => onFilterRemove(filter.filterId)}
>
<IconPlaceholder
lucide="Trash2"
tabler="IconTrash"
hugeicons="Delete02Icon"
phosphor="TrashIcon"
remixicon="RiDeleteBinLine"
/>
</Button>
<SortableItemHandle
render={
<Button variant="outline" size="icon" className="size-8 rounded" />
}
>
<IconPlaceholder
lucide="GripVertical"
tabler="IconGripVertical"
hugeicons="DragDropVerticalIcon"
phosphor="DotsSixVerticalIcon"
remixicon="RiDraggable"
/>
</SortableItemHandle>
</SortableItem>
);
}
function onFilterInputRender<TData extends RowData>({
filter,
inputId,
column,
columnMeta,
onFilterUpdate,
showValueSelector,
setShowValueSelector,
}: {
filter: ExtendedColumnFilter<TData>;
inputId: string;
column: Column<DataTableFeatures, TData>;
columnMeta?: DataTableColumnMeta;
onFilterUpdate: (
filterId: string,
updates: Partial<Omit<ExtendedColumnFilter<TData>, "filterId">>,
) => void;
showValueSelector: boolean;
setShowValueSelector: (value: boolean) => void;
}) {
if (filter.operator === "isEmpty" || filter.operator === "isNotEmpty") {
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 border bg-transparent dark:bg-input/30"
/>
);
}
switch (filter.variant) {
case "text":
case "number":
case "range": {
if (
(filter.variant === "range" && filter.operator === "isBetween") ||
filter.operator === "isBetween"
) {
return (
<DataTableRangeFilter
filter={filter}
column={column}
inputId={inputId}
onFilterUpdate={onFilterUpdate}
/>
);
}
const isNumber =
filter.variant === "number" || filter.variant === "range";
return (
<Input
id={inputId}
type={isNumber ? "number" : filter.variant}
aria-label={`${columnMeta?.label} filter value`}
aria-describedby={`${inputId}-description`}
inputMode={isNumber ? "numeric" : undefined}
placeholder={columnMeta?.placeholder ?? "Enter a value..."}
className="h-8 w-full rounded"
defaultValue={
typeof filter.value === "string" ? filter.value : undefined
}
onChange={(event) =>
onFilterUpdate(filter.filterId, {
value: event.target.value,
})
}
/>
);
}
case "boolean": {
if (Array.isArray(filter.value)) return null;
const inputListboxId = `${inputId}-listbox`;
return (
<Select
open={showValueSelector}
onOpenChange={setShowValueSelector}
value={filter.value}
onValueChange={(value) => {
if (value == null) return;
onFilterUpdate(filter.filterId, {
value,
});
}}
>
<SelectTrigger
id={inputId}
aria-controls={inputListboxId}
aria-label={`${columnMeta?.label} boolean filter`}
className="w-full rounded"
>
<SelectValue placeholder={filter.value ? "True" : "False"} />
</SelectTrigger>
<SelectContent id={inputListboxId}>
<SelectGroup>
<SelectItem value="true">True</SelectItem>
<SelectItem value="false">False</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
);
}
case "select":
case "multiSelect": {
const inputListboxId = `${inputId}-listbox`;
const multiple = filter.variant === "multiSelect";
const selectedValues = multiple
? Array.isArray(filter.value)
? filter.value
: []
: typeof filter.value === "string"
? filter.value
: undefined;
return (
<Faceted
open={showValueSelector}
onOpenChange={setShowValueSelector}
value={selectedValues}
onValueChange={(value) => {
onFilterUpdate(filter.filterId, {
value,
});
}}
multiple={multiple}
>
<FacetedTrigger
render={
<Button
id={inputId}
aria-controls={inputListboxId}
aria-label={`${columnMeta?.label} filter value${multiple ? "s" : ""}`}
variant="outline"
className="w-full rounded font-normal"
/>
}
>
<FacetedBadgeList
options={columnMeta?.options}
placeholder={
columnMeta?.placeholder ??
`Select option${multiple ? "s" : ""}...`
}
/>
</FacetedTrigger>
<FacetedContent id={inputListboxId} 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>{option.label}</span>
{option.count && (
<span className="ml-auto font-mono text-xs">
{option.count}
</span>
)}
</FacetedItem>
))}
</FacetedGroup>
</FacetedList>
</FacetedContent>
</Faceted>
);
}
case "date":
case "dateRange": {
const inputListboxId = `${inputId}-listbox`;
const dateValue = Array.isArray(filter.value)
? filter.value.filter(Boolean)
: [filter.value, filter.value].filter(Boolean);
const startDate = dateValue[0]
? new Date(Number(dateValue[0]))
: undefined;
const endDate = dateValue[1] ? new Date(Number(dateValue[1])) : undefined;
const isSameDate =
startDate &&
endDate &&
startDate.toDateString() === endDate.toDateString();
const displayValue =
filter.operator === "isBetween" && dateValue.length === 2 && !isSameDate
? `${formatDate(startDate, { month: "short" })} - ${formatDate(endDate, { month: "short" })}`
: startDate
? formatDate(startDate, { month: "short" })
: "Pick a date";
return (
<Popover open={showValueSelector} onOpenChange={setShowValueSelector}>
<PopoverTrigger
render={
<Button
id={inputId}
aria-controls={inputListboxId}
aria-label={`${columnMeta?.label} date filter`}
variant="outline"
className={cn(
"w-full justify-start rounded text-left font-normal",
!filter.value && "text-muted-foreground",
)}
/>
}
>
<IconPlaceholder
lucide="CalendarIcon"
tabler="IconCalendar"
hugeicons="CalendarIcon"
phosphor="CalendarIcon"
remixicon="RiCalendarLine"
/>
<span className="truncate">{displayValue}</span>
</PopoverTrigger>
<PopoverContent
id={inputListboxId}
align="start"
className="w-auto p-0"
>
{filter.operator === "isBetween" ? (
<Calendar
aria-label={`Select ${columnMeta?.label} date range`}
autoFocus
captionLayout="dropdown"
mode="range"
selected={
dateValue.length === 2
? {
from: new Date(Number(dateValue[0])),
to: new Date(Number(dateValue[1])),
}
: {
from: new Date(),
to: new Date(),
}
}
onSelect={(date) => {
onFilterUpdate(filter.filterId, {
value: date
? [
(date.from?.getTime() ?? "").toString(),
(date.to?.getTime() ?? "").toString(),
]
: [],
});
}}
/>
) : (
<Calendar
aria-label={`Select ${columnMeta?.label} date`}
autoFocus
captionLayout="dropdown"
mode="single"
selected={
dateValue[0] ? new Date(Number(dateValue[0])) : undefined
}
onSelect={(date) => {
onFilterUpdate(filter.filterId, {
value: (date?.getTime() ?? "").toString(),
});
setShowValueSelector(false);
}}
/>
)}
</PopoverContent>
</Popover>
);
}
default:
return null;
}
}
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 { ExtendedColumnFilter } from "@/lib/data-table-types";
import { Input } from "@/registry/bases/base/ui/input";
interface DataTableRangeFilterProps<
TData extends RowData,
> extends React.ComponentProps<"div"> {
filter: ExtendedColumnFilter<TData>;
column: Column<DataTableFeatures, TData>;
inputId: string;
onFilterUpdate: (
filterId: string,
updates: Partial<Omit<ExtendedColumnFilter<TData>, "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 formatValue = React.useCallback(
(value: string | number | undefined) => {
if (value === undefined || value === "") return "";
const numValue = Number(value);
return Number.isNaN(numValue)
? ""
: numValue.toLocaleString(undefined, {
maximumFractionDigits: 0,
});
},
[],
);
const value = React.useMemo(() => {
if (Array.isArray(filter.value)) return filter.value.map(formatValue);
return [formatValue(filter.value), ""];
}, [filter.value, formatValue]);
const onRangeValueChange = React.useCallback(
(value: string, isMin?: boolean) => {
const numValue = Number(value);
const currentValues = Array.isArray(filter.value)
? 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="range"
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="range-min"
inputMode="numeric"
placeholder={min.toString()}
min={min}
max={max}
className="h-8 w-full rounded"
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="range-max"
inputMode="numeric"
placeholder={max.toString()}
min={min}
max={max}
className="h-8 w-full rounded"
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 { RowData, Table } 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: Table<DataTableFeatures, TData>;
}
export function DataTableAdvancedToolbar<TData extends RowData>({
table,
children,
className,
...props
}: DataTableAdvancedToolbarProps<TData>) {
return (
<div
role="toolbar"
aria-orientation="horizontal"
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 {
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";
import { useIsomorphicLayoutEffect } from "@/hooks/use-isomorphic-layout-effect";
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 id = React.useId();
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);
useIsomorphicLayoutEffect(() => 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 { cn } from "cn";
import * as React from "react";
import { Badge } from "@/registry/bases/base/ui/badge";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/registry/bases/base/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/bases/base/ui/popover";
import { IconPlaceholder } from "@/registry/icons/icon-placeholder";
type FacetedValue<Multiple extends boolean> = Multiple extends true
? string[]
: string;
interface FacetedContextValue<Multiple extends boolean = boolean> {
value?: FacetedValue<Multiple>;
onItemSelect?: (value: string) => void;
multiple?: Multiple;
}
const FacetedContext = React.createContext<FacetedContextValue | null>(null);
function useFacetedContext(name: string) {
const context = React.useContext(FacetedContext);
if (!context) {
throw new Error(`\`${name}\` must be within Faceted`);
}
return context;
}
interface FacetedProps<Multiple extends boolean = false> extends Omit<
React.ComponentProps<typeof Popover>,
"onOpenChange"
> {
value?: FacetedValue<Multiple>;
onValueChange?: (value: FacetedValue<Multiple> | undefined) => void;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
multiple?: Multiple;
}
function Faceted<Multiple extends boolean = false>(
props: FacetedProps<Multiple>,
) {
const {
open: openProp,
onOpenChange: onOpenChangeProp,
value,
onValueChange,
children,
multiple = false,
...facetedProps
} = props;
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false);
const isControlled = openProp !== undefined;
const open = isControlled ? openProp : uncontrolledOpen;
const onOpenChange = React.useCallback(
(newOpen: boolean) => {
if (!isControlled) {
setUncontrolledOpen(newOpen);
}
onOpenChangeProp?.(newOpen);
},
[isControlled, onOpenChangeProp],
);
const onItemSelect = React.useCallback(
(selectedValue: string) => {
if (!onValueChange) return;
if (multiple) {
const currentValue = (Array.isArray(value) ? value : []) as string[];
const newValue = currentValue.includes(selectedValue)
? currentValue.filter((v) => v !== selectedValue)
: [...currentValue, selectedValue];
onValueChange(newValue as FacetedValue<Multiple>);
} else {
if (value === selectedValue) {
onValueChange(undefined);
} else {
onValueChange(selectedValue as FacetedValue<Multiple>);
}
requestAnimationFrame(() => onOpenChange(false));
}
},
[multiple, value, onValueChange, onOpenChange],
);
const contextValue = React.useMemo<FacetedContextValue<typeof multiple>>(
() => ({ value, onItemSelect, multiple }),
[value, onItemSelect, multiple],
);
return (
<FacetedContext.Provider value={contextValue}>
<Popover open={open} onOpenChange={onOpenChange} {...facetedProps}>
{children}
</Popover>
</FacetedContext.Provider>
);
}
function FacetedTrigger(props: React.ComponentProps<typeof PopoverTrigger>) {
const { className, children, ...triggerProps } = props;
return (
<PopoverTrigger
{...triggerProps}
className={cn("justify-between text-left", className)}
>
{children}
</PopoverTrigger>
);
}
interface FacetedBadgeListProps extends React.ComponentProps<"div"> {
options?: { label: string; value: string }[];
max?: number;
badgeClassName?: string;
placeholder?: string;
}
function FacetedBadgeList(props: FacetedBadgeListProps) {
const {
options = [],
max = 2,
placeholder = "Select options...",
className,
badgeClassName,
...badgeListProps
} = props;
const context = useFacetedContext("FacetedBadgeList");
const values = Array.isArray(context.value)
? context.value
: ([context.value].filter(Boolean) as string[]);
const getLabel = React.useCallback(
(value: string) => {
const option = options.find((opt) => opt.value === value);
return option?.label ?? value;
},
[options],
);
if (!values || values.length === 0) {
return (
<div
{...badgeListProps}
className="flex w-full items-center gap-1 text-muted-foreground"
>
{placeholder}
<IconPlaceholder
lucide="ChevronsUpDown"
tabler="IconSelector"
hugeicons="UnfoldMoreIcon"
phosphor="CaretUpDownIcon"
remixicon="RiArrowUpDownLine"
className="ml-auto size-4 shrink-0 opacity-50"
/>
</div>
);
}
return (
<div
{...badgeListProps}
className={cn("flex flex-wrap items-center gap-1", className)}
>
{values.length > max ? (
<Badge
variant="secondary"
className={cn("rounded-sm px-1 font-normal", badgeClassName)}
>
{values.length} selected
</Badge>
) : (
values.map((value) => (
<Badge
key={value}
variant="secondary"
className={cn("rounded-sm px-1 font-normal", badgeClassName)}
>
<span className="truncate">{getLabel(value)}</span>
</Badge>
))
)}
</div>
);
}
function FacetedContent(props: React.ComponentProps<typeof PopoverContent>) {
const { className, children, ...contentProps } = props;
return (
<PopoverContent
{...contentProps}
align="start"
className={cn("w-50 origin-(--transform-origin) p-0", className)}
>
<Command>{children}</Command>
</PopoverContent>
);
}
const FacetedInput = CommandInput;
const FacetedList = CommandList;
const FacetedEmpty = CommandEmpty;
const FacetedGroup = CommandGroup;
interface FacetedItemProps extends React.ComponentProps<typeof CommandItem> {
value: string;
}
function FacetedItem(props: FacetedItemProps) {
const { value, onSelect, className, children, ...itemProps } = props;
const context = useFacetedContext("FacetedItem");
const isSelected = context.multiple
? Array.isArray(context.value) && context.value.includes(value)
: context.value === value;
const onItemSelect = React.useCallback(
(currentValue: string) => {
if (onSelect) {
onSelect(currentValue);
} else if (context.onItemSelect) {
context.onItemSelect(currentValue);
}
},
[onSelect, context],
);
return (
<CommandItem
aria-selected={isSelected}
data-selected={isSelected}
className={cn("gap-2", className)}
onSelect={() => onItemSelect(value)}
{...itemProps}
>
<span
className={cn(
"flex size-4 items-center justify-center rounded-sm border border-primary",
isSelected
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}
>
<IconPlaceholder
lucide="Check"
tabler="IconCheck"
hugeicons="Tick02Icon"
phosphor="CheckIcon"
remixicon="RiCheckLine"
className="size-4"
/>
</span>
{children}
</CommandItem>
);
}
const FacetedSeparator = CommandSeparator;
export {
Faceted,
FacetedBadgeList,
FacetedContent,
FacetedEmpty,
FacetedGroup,
FacetedInput,
FacetedItem,
FacetedList,
FacetedSeparator,
FacetedTrigger,
};
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-table-utils.ts
import type { Column, RowData } from "@tanstack/react-table";
import type { DataTableFeatures } from "@/lib/data-table-features";
import type {
ExtendedColumnFilter,
FilterOperator,
FilterVariant,
} from "@/lib/data-table-types";
export type DataTableConfig = typeof dataTableConfig;
export const dataTableConfig = {
textOperators: [
{ label: "Contains", value: "iLike" as const },
{ label: "Does not contain", value: "notILike" as const },
{ label: "Is", value: "eq" as const },
{ label: "Is not", value: "ne" as const },
{ label: "Is empty", value: "isEmpty" as const },
{ label: "Is not empty", value: "isNotEmpty" as const },
],
numericOperators: [
{ label: "Is", value: "eq" as const },
{ label: "Is not", value: "ne" as const },
{ label: "Is less than", value: "lt" as const },
{ label: "Is less than or equal to", value: "lte" as const },
{ label: "Is greater than", value: "gt" as const },
{ label: "Is greater than or equal to", value: "gte" as const },
{ label: "Is between", value: "isBetween" as const },
{ label: "Is empty", value: "isEmpty" as const },
{ label: "Is not empty", value: "isNotEmpty" as const },
],
dateOperators: [
{ label: "Is", value: "eq" as const },
{ label: "Is not", value: "ne" as const },
{ label: "Is before", value: "lt" as const },
{ label: "Is after", value: "gt" as const },
{ label: "Is on or before", value: "lte" as const },
{ label: "Is on or after", value: "gte" as const },
{ label: "Is between", value: "isBetween" as const },
{ label: "Is relative to today", value: "isRelativeToToday" as const },
{ label: "Is empty", value: "isEmpty" as const },
{ label: "Is not empty", value: "isNotEmpty" as const },
],
selectOperators: [
{ label: "Is", value: "eq" as const },
{ label: "Is not", value: "ne" as const },
{ label: "Is empty", value: "isEmpty" as const },
{ label: "Is not empty", value: "isNotEmpty" as const },
],
multiSelectOperators: [
{ label: "Has any of", value: "inArray" as const },
{ label: "Has none of", value: "notInArray" as const },
{ label: "Is empty", value: "isEmpty" as const },
{ label: "Is not empty", value: "isNotEmpty" as const },
],
booleanOperators: [
{ label: "Is", value: "eq" as const },
{ label: "Is not", value: "ne" as const },
],
sortOrders: [
{ label: "Asc", value: "asc" as const },
{ label: "Desc", value: "desc" as const },
],
filterVariants: [
"text",
"number",
"range",
"date",
"dateRange",
"boolean",
"select",
"multiSelect",
] as const,
operators: [
"iLike",
"notILike",
"eq",
"ne",
"inArray",
"notInArray",
"isEmpty",
"isNotEmpty",
"lt",
"lte",
"gt",
"gte",
"isBetween",
"isRelativeToToday",
] as const,
joinOperators: ["and", "or"] as const,
};
export function getColumnPinningStyle<TData extends RowData>({
column,
withBorder = false,
}: {
column: Column<DataTableFeatures, TData>;
withBorder?: boolean;
}): React.CSSProperties {
const isPinned = column.getIsPinned();
const isLastLeftPinnedColumn =
isPinned === "start" && column.getIsLastColumn("start");
const isFirstRightPinnedColumn =
isPinned === "end" && column.getIsFirstColumn("end");
return {
boxShadow: withBorder
? isLastLeftPinnedColumn
? "-4px 0 4px -4px var(--border) inset"
: isFirstRightPinnedColumn
? "4px 0 4px -4px var(--border) inset"
: undefined
: undefined,
left: isPinned === "start" ? `${column.getStart("start")}px` : undefined,
right: isPinned === "end" ? `${column.getAfter("end")}px` : undefined,
opacity: isPinned ? 0.97 : 1,
position: isPinned ? "sticky" : "relative",
background: isPinned ? "var(--background)" : "var(--background)",
width: column.getSize(),
zIndex: isPinned ? 1 : undefined,
};
}
export function getFilterOperators(filterVariant: FilterVariant) {
const operatorMap: Record<
FilterVariant,
{ label: string; value: FilterOperator }[]
> = {
text: dataTableConfig.textOperators,
number: dataTableConfig.numericOperators,
range: dataTableConfig.numericOperators,
date: dataTableConfig.dateOperators,
dateRange: dataTableConfig.dateOperators,
boolean: dataTableConfig.booleanOperators,
select: dataTableConfig.selectOperators,
multiSelect: dataTableConfig.multiSelectOperators,
};
return operatorMap[filterVariant] ?? dataTableConfig.textOperators;
}
export function getDefaultFilterOperator(filterVariant: FilterVariant) {
const operators = getFilterOperators(filterVariant);
return operators[0]?.value ?? (filterVariant === "text" ? "iLike" : "eq");
}
export function getValidFilters<TData>(
filters: ExtendedColumnFilter<TData>[],
): ExtendedColumnFilter<TData>[] {
return filters.filter(
(filter) =>
filter.operator === "isEmpty" ||
filter.operator === "isNotEmpty" ||
(Array.isArray(filter.value)
? filter.value.length > 0
: filter.value !== "" &&
filter.value !== null &&
filter.value !== undefined),
);
}
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/parsers.ts
import { createParser } from "nuqs/server";
import { z } from "zod";
import type {
ExtendedColumnFilter,
ExtendedColumnSort,
} from "@/lib/data-table-types";
import { dataTableConfig } from "@/lib/data-table-utils";
const sortingItemSchema = z.object({
id: z.string(),
desc: z.boolean(),
});
export const getSortingStateParser = <TData>(
columnIds?: string[] | Set<string>,
) => {
const validKeys = columnIds
? columnIds instanceof Set
? columnIds
: new Set(columnIds)
: null;
return createParser({
parse: (value) => {
try {
const parsed = JSON.parse(value);
const result = z.array(sortingItemSchema).safeParse(parsed);
if (!result.success) return null;
if (validKeys && result.data.some((item) => !validKeys.has(item.id))) {
return null;
}
return result.data as ExtendedColumnSort<TData>[];
} catch {
return null;
}
},
serialize: (value) => JSON.stringify(value),
eq: (a, b) =>
a.length === b.length &&
a.every(
(item, index) =>
item.id === b[index]?.id && item.desc === b[index]?.desc,
),
});
};
const filterItemSchema = z.object({
id: z.string(),
value: z.union([z.string(), z.array(z.string())]),
variant: z.enum(dataTableConfig.filterVariants),
operator: z.enum(dataTableConfig.operators),
filterId: z.string(),
});
export type FilterItemSchema = z.infer<typeof filterItemSchema>;
export const getFiltersStateParser = <TData>(
columnIds?: string[] | Set<string>,
) => {
const validKeys = columnIds
? columnIds instanceof Set
? columnIds
: new Set(columnIds)
: null;
return createParser({
parse: (value) => {
try {
const parsed = JSON.parse(value);
const result = z.array(filterItemSchema).safeParse(parsed);
if (!result.success) return null;
if (validKeys && result.data.some((item) => !validKeys.has(item.id))) {
return null;
}
return result.data as ExtendedColumnFilter<TData>[];
} catch {
return null;
}
},
serialize: (value) => JSON.stringify(value),
eq: (a, b) =>
a.length === b.length &&
a.every(
(filter, index) =>
filter.id === b[index]?.id &&
filter.value === b[index]?.value &&
filter.variant === b[index]?.variant &&
filter.operator === b[index]?.operator,
),
});
};
src/lib/data-table-types.ts
import type { ColumnSort, Row, RowData } from "@tanstack/react-table";
import type { DataTableFeatures } from "@/lib/data-table-features";
import type { DataTableConfig } from "@/lib/data-table-utils";
import type { FilterItemSchema } from "@/lib/parsers";
export interface DataTableMeta {
queryKeys?: QueryKeys;
}
export interface DataTableColumnMeta {
label?: string;
placeholder?: string;
variant?: FilterVariant;
options?: Option[];
range?: [number, number];
unit?: string;
icon?: React.ComponentType<React.ComponentProps<"svg">>;
}
export interface QueryKeys {
page: string;
perPage: string;
sort: string;
filters: string;
joinOperator: string;
}
export interface Option {
label: string;
value: string;
count?: number;
icon?: React.ComponentType<React.ComponentProps<"svg">>;
}
export type FilterOperator = DataTableConfig["operators"][number];
export type FilterVariant = DataTableConfig["filterVariants"][number];
export type JoinOperator = DataTableConfig["joinOperators"][number];
export interface ExtendedColumnSort<TData> extends Omit<ColumnSort, "id"> {
id: Extract<keyof TData, string>;
}
export interface ExtendedColumnFilter<TData> extends FilterItemSchema {
id: Extract<keyof TData, string>;
}
export interface DataTableRowAction<TData extends RowData> {
row: Row<DataTableFeatures, TData>;
variant: "update" | "delete";
}
src/lib/data-table-features.ts
import {
columnFacetingFeature,
columnFilteringFeature,
columnOrderingFeature,
columnPinningFeature,
columnSizingFeature,
columnVisibilityFeature,
createFacetedMinMaxValues,
createFacetedRowModel,
createFacetedUniqueValues,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
metaHelper,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
tableFeatures,
} from "@tanstack/react-table";
import type {
DataTableColumnMeta,
DataTableMeta,
} from "@/lib/data-table-types";
export const dataTableFeatures = tableFeatures({
columnFilteringFeature,
columnFacetingFeature,
columnOrderingFeature,
columnPinningFeature,
columnSizingFeature,
columnVisibilityFeature,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
filteredRowModel: createFilteredRowModel(),
facetedRowModel: createFacetedRowModel(),
facetedUniqueValues: createFacetedUniqueValues(),
facetedMinMaxValues: createFacetedMinMaxValues(),
paginatedRowModel: createPaginatedRowModel(),
sortedRowModel: createSortedRowModel(),
tableMeta: metaHelper<DataTableMeta>(),
columnMeta: metaHelper<DataTableColumnMeta>(),
});
export type DataTableFeatures = typeof dataTableFeatures;
src/registry/bases/base/components/data-table/data-table-view-options.tsx
"use client";
import { type RowData, Subscribe, type Table } 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 {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/registry/bases/base/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/bases/base/ui/popover";
import { IconPlaceholder } from "@/registry/icons/icon-placeholder";
interface DataTableViewOptionsProps<
TData extends RowData,
> extends React.ComponentProps<typeof PopoverContent> {
table: Table<DataTableFeatures, TData>;
disabled?: boolean;
}
export function DataTableViewOptions<TData extends RowData>({
table,
disabled,
className,
...props
}: DataTableViewOptionsProps<TData>) {
const columns = React.useMemo(
() =>
table
.getAllColumns()
.filter(
(column) =>
typeof column.accessorFn !== "undefined" && column.getCanHide(),
),
[table],
);
return (
<Popover>
<PopoverTrigger
render={
<Button
aria-label="Toggle columns"
role="combobox"
variant="outline"
className="ml-auto hidden h-8 font-normal lg:flex"
disabled={disabled}
/>
}
>
<IconPlaceholder
lucide="Settings2"
tabler="IconSettings"
hugeicons="Settings05Icon"
phosphor="GearIcon"
remixicon="RiSettingsLine"
className="text-muted-foreground"
/>
View
</PopoverTrigger>
<PopoverContent className={cn("w-44 p-0", className)} {...props}>
<Command>
<CommandInput placeholder="Search columns..." />
<CommandList>
<CommandEmpty>No columns found.</CommandEmpty>
<CommandGroup>
<Subscribe source={table.atoms.columnVisibility}>
{(columnVisibility) =>
columns.map((column) => {
const isVisible = columnVisibility[column.id] !== false;
return (
<CommandItem
key={column.id}
data-checked={isVisible}
onSelect={() => column.toggleVisibility(!isVisible)}
>
<span className="truncate">
{column.columnDef.meta?.label ?? column.id}
</span>
</CommandItem>
);
})
}
</Subscribe>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
src/hooks/use-isomorphic-layout-effect.ts
import * as React from "react";
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;
export { useIsomorphicLayoutEffect };