Shadcn Table
componentdata-tablesource: ok · hit
Data Table
A feature-rich data table component with server-side filtering, sorting, and pagination
npx shadcn add "https://tablecn.com/r/data-table.json"free
Where it goes
data-tablefeature-grid
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
buttoncalendarcommanddropdown-menuinputpopoverselectseparatorslidertablelabelskeleton
Files19 · source cached 10/8/2026, 10:23:04 PM
src/registry/bases/base/components/data-table/data-table.tsx
"use client";
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import {
FlexRender,
type Row,
type RowData,
Subscribe,
type Table as TanstackTable,
} from "@tanstack/react-table";
import { cn } from "cn";
import * as React from "react";
import type { DataTableFeatures } from "@/lib/data-table-features";
import {
getColumnPinningStyle,
getColumnSizingStyle,
} from "@/lib/data-table-utils";
import { DataTablePagination } from "@/registry/bases/base/components/data-table/data-table-pagination";
import { useDirection } from "@/registry/bases/base/ui/direction";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/registry/bases/base/ui/table";
interface DataTableProps<
TData extends RowData,
> extends React.ComponentProps<"div"> {
table: TanstackTable<DataTableFeatures, TData>;
actionBar?: React.ReactNode;
}
export function DataTable<TData extends RowData>({
table,
actionBar,
children,
className,
...props
}: DataTableProps<TData>) {
const dir = useDirection();
return (
<div
dir={dir}
className={cn("flex w-full flex-col gap-2.5 overflow-auto", className)}
{...props}
>
{children}
<div className="overflow-hidden rounded-md border">
<DataTableLayout table={table}>
<DataTableHeader table={table} />
<DataTableBody table={table} />
</DataTableLayout>
</div>
<div className="flex flex-col gap-2.5">
<DataTablePagination table={table} />
{actionBar ? (
<DataTableActionBar table={table} actionBar={actionBar} />
) : null}
</div>
</div>
);
}
interface DataTableLayoutProps<TData extends RowData> {
table: TanstackTable<DataTableFeatures, TData>;
children: React.ReactNode;
}
function DataTableLayout<TData extends RowData>({
table,
children,
}: DataTableLayoutProps<TData>) {
return (
<Subscribe
source={table.store}
selector={(state) => ({
columnOrder: state.columnOrder,
columnPinning: state.columnPinning,
columnSizing: state.columnSizing,
columnVisibility: state.columnVisibility,
})}
>
{() => (
<Table className="table-fixed" style={getColumnSizingStyle(table)}>
{children}
</Table>
)}
</Subscribe>
);
}
interface DataTableHeaderProps<TData extends RowData> {
table: TanstackTable<DataTableFeatures, TData>;
}
function DataTableHeader<TData extends RowData>({
table,
}: DataTableHeaderProps<TData>) {
return (
<Subscribe
source={table.store}
selector={(state) => ({
columnOrder: state.columnOrder,
columnPinning: state.columnPinning,
columnVisibility: state.columnVisibility,
rowSelection: state.rowSelection,
})}
>
{() => (
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="group/row">
{headerGroup.headers.map((header) => (
<DataTableCellSlot
key={header.id}
render={<TableHead colSpan={header.colSpan} />}
pinned={!!header.column.getIsPinned()}
style={getColumnPinningStyle(header.column)}
>
{header.isPlaceholder ? null : <FlexRender header={header} />}
</DataTableCellSlot>
))}
</TableRow>
))}
</TableHeader>
)}
</Subscribe>
);
}
interface DataTableBodyProps<TData extends RowData> {
table: TanstackTable<DataTableFeatures, TData>;
}
function DataTableBody<TData extends RowData>({
table,
}: DataTableBodyProps<TData>) {
const rows = table.getRowModel().rows;
if (!rows.length) {
return (
<Subscribe source={table.atoms.columnVisibility}>
{() => (
<TableBody>
<TableRow>
<TableCell
colSpan={table.getVisibleLeafColumns().length || 1}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
</TableBody>
)}
</Subscribe>
);
}
return (
<TableBody>
{rows.map((row) => (
<MemoizedDataTableRow key={row.id} row={row} />
))}
</TableBody>
);
}
interface DataTableRowProps<TData extends RowData> {
row: Row<DataTableFeatures, TData>;
}
function DataTableRow<TData extends RowData>({
row,
}: DataTableRowProps<TData>) {
return (
<Subscribe
source={row.table.store}
selector={(state) => ({
columnOrder: state.columnOrder,
columnPinning: state.columnPinning,
columnVisibility: state.columnVisibility,
})}
>
{() => {
const cells = row.getVisibleCells().map((cell) => ({
cell,
pinned: !!cell.column.getIsPinned(),
style: getColumnPinningStyle(cell.column),
}));
return (
<Subscribe
source={row.table.atoms.rowSelection}
selector={(selection) => selection[row.id] === true}
>
{(isSelected) => (
<TableRow
data-state={isSelected ? "selected" : undefined}
className="group/row"
>
{cells.map(({ cell, pinned, style }) => (
<DataTableCellSlot
key={cell.id}
render={<TableCell />}
pinned={pinned}
style={style}
>
<FlexRender cell={cell} />
</DataTableCellSlot>
))}
</TableRow>
)}
</Subscribe>
);
}}
</Subscribe>
);
}
const MemoizedDataTableRow = React.memo(DataTableRow) as typeof DataTableRow;
interface DataTableActionBarProps<TData extends RowData> {
table: TanstackTable<DataTableFeatures, TData>;
actionBar: React.ReactNode;
}
function DataTableActionBar<TData extends RowData>({
table,
actionBar,
}: DataTableActionBarProps<TData>) {
return (
<Subscribe
source={table.atoms.rowSelection}
selector={() => table.getSelectedRowIds().length > 0}
>
{(hasSelectedRows) => (hasSelectedRows ? actionBar : null)}
</Subscribe>
);
}
interface DataTableCellSlotProps extends useRender.ComponentProps<"td"> {
pinned?: boolean;
}
function DataTableCellSlot({
pinned = false,
className,
render,
...props
}: DataTableCellSlotProps) {
return useRender({
defaultTagName: "td",
props: mergeProps<"td">(
{
className: cn(
"overflow-hidden",
pinned &&
"bg-background transition-colors group-hover/row:bg-[color-mix(in_srgb,var(--muted)_50%,var(--background))] group-has-aria-expanded/row:bg-[color-mix(in_srgb,var(--muted)_50%,var(--background))] group-data-[state=selected]/row:bg-muted",
className,
),
},
props,
),
render,
});
}
src/registry/bases/base/components/data-table/data-table-column-header.tsx
"use client";
import {
type Column,
type RowData,
type SortDirection,
type SortingState,
Subscribe,
} from "@tanstack/react-table";
import { cn } from "cn";
import * as React from "react";
import type { DataTableFeatures } from "@/lib/data-table-features";
import { useDirection } from "@/registry/bases/base/ui/direction";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/registry/bases/base/ui/dropdown-menu";
import { IconPlaceholder } from "@/registry/icons/icon-placeholder";
interface DataTableColumnHeaderProps<
TData extends RowData,
TValue,
> extends React.ComponentProps<typeof DropdownMenuTrigger> {
column: Column<DataTableFeatures, TData, TValue>;
label: string;
}
export function DataTableColumnHeader<TData extends RowData, TValue>({
column,
label,
className,
...props
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort() && !column.getCanHide()) {
return <div className={cn(className)}>{label}</div>;
}
return (
<Subscribe
source={column.table.store}
selector={(state) => ({
isVisible: state.columnVisibility[column.id] !== false,
sortDirection: getSortDirection(state.sorting, column.id),
})}
>
{(headerState) => (
<DataTableColumnHeaderMenu
column={column}
label={label}
className={className}
isVisible={headerState.isVisible}
sortDirection={headerState.sortDirection}
{...props}
/>
)}
</Subscribe>
);
}
interface DataTableColumnHeaderMenuProps<
TData extends RowData,
TValue,
> extends DataTableColumnHeaderProps<TData, TValue> {
isVisible: boolean;
sortDirection: SortDirection | "none";
}
function DataTableColumnHeaderMenu<TData extends RowData, TValue>({
column,
label,
className,
isVisible,
sortDirection,
...props
}: DataTableColumnHeaderMenuProps<TData, TValue>) {
const dir = useDirection();
function onSortDirectionChange(direction: SortDirection) {
if (sortDirection === direction) {
column.clearSorting();
return;
}
column.toggleSorting(direction === "desc", true);
}
return (
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"-ms-1.5 flex h-8 items-center gap-1.5 rounded-md px-2 py-1.5 hover:bg-accent focus:ring-1 focus:ring-ring focus:outline-none data-[state=open]:bg-accent rtl:flex-row-reverse [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground",
className,
)}
{...props}
>
{label}
{column.getCanSort() &&
(sortDirection === "desc" ? (
<IconPlaceholder
lucide="ChevronDown"
tabler="IconChevronDown"
hugeicons="ArrowDown01Icon"
phosphor="CaretDownIcon"
remixicon="RiArrowDownSLine"
/>
) : sortDirection === "asc" ? (
<IconPlaceholder
lucide="ChevronUp"
tabler="IconChevronUp"
hugeicons="ArrowUp01Icon"
phosphor="CaretUpIcon"
remixicon="RiArrowUpSLine"
/>
) : (
<IconPlaceholder
lucide="ChevronsUpDown"
tabler="IconSelector"
hugeicons="UnfoldMoreIcon"
phosphor="CaretUpDownIcon"
remixicon="RiArrowUpDownLine"
/>
))}
</DropdownMenuTrigger>
<DropdownMenuContent
dir={dir}
align="start"
className="w-28 data-closed:fill-mode-forwards"
>
{column.getCanSort() && (
<>
<DropdownMenuCheckboxItem
className="[&_svg]:text-muted-foreground"
checked={sortDirection === "asc"}
onClick={() => onSortDirectionChange("asc")}
>
<IconPlaceholder
lucide="ChevronUp"
tabler="IconChevronUp"
hugeicons="ArrowUp01Icon"
phosphor="CaretUpIcon"
remixicon="RiArrowUpSLine"
/>
Asc
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
className="[&_svg]:text-muted-foreground"
checked={sortDirection === "desc"}
onClick={() => onSortDirectionChange("desc")}
>
<IconPlaceholder
lucide="ChevronDown"
tabler="IconChevronDown"
hugeicons="ArrowDown01Icon"
phosphor="CaretDownIcon"
remixicon="RiArrowDownSLine"
/>
Desc
</DropdownMenuCheckboxItem>
</>
)}
{column.getCanHide() && (
<DropdownMenuCheckboxItem
className="[&_svg]:text-muted-foreground"
checked={!isVisible}
onClick={() => column.toggleVisibility(false)}
>
<IconPlaceholder
lucide="EyeOff"
tabler="IconEyeClosed"
hugeicons="ViewOffIcon"
phosphor="EyeSlashIcon"
remixicon="RiEyeOffLine"
/>
Hide
</DropdownMenuCheckboxItem>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
function getSortDirection(
sorting: SortingState,
columnId: string,
): SortDirection | "none" {
const sort = sorting.find((sort) => sort.id === columnId);
if (!sort) return "none";
return sort.desc ? "desc" : "asc";
}
src/registry/bases/base/components/data-table/data-table-pagination.tsx
"use client";
import type * as React from "react";
import { type RowData, Subscribe, type Table } from "@tanstack/react-table";
import { cn } from "cn";
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 {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/registry/bases/base/ui/select";
import { IconPlaceholder } from "@/registry/icons/icon-placeholder";
interface DataTablePaginationProps<
TData extends RowData,
> extends React.ComponentProps<"div"> {
table: Table<DataTableFeatures, TData>;
pageSizeOptions?: number[];
}
export function DataTablePagination<TData extends RowData>({
table,
pageSizeOptions = [10, 20, 30, 40, 50],
className,
...props
}: DataTablePaginationProps<TData>) {
return (
<Subscribe
source={table.store}
selector={(state) => ({
pageIndex: state.pagination.pageIndex,
pageSize: state.pagination.pageSize,
selectedRowCount: table.getSelectedRowIds().length,
})}
>
{({ pageIndex, pageSize, selectedRowCount }) => (
<DataTablePaginationContent
table={table}
pageIndex={pageIndex}
pageSize={pageSize}
selectedRowCount={selectedRowCount}
pageSizeOptions={pageSizeOptions}
className={className}
{...props}
/>
)}
</Subscribe>
);
}
interface DataTablePaginationContentProps<
TData extends RowData,
> extends React.ComponentProps<"div"> {
table: Table<DataTableFeatures, TData>;
pageIndex: number;
pageSize: number;
selectedRowCount: number;
pageSizeOptions: number[];
}
function DataTablePaginationContent<TData extends RowData>({
table,
pageIndex,
pageSize,
selectedRowCount,
pageSizeOptions,
className,
...props
}: DataTablePaginationContentProps<TData>) {
const pageCount = table.getPageCount();
const canPreviousPage = table.getCanPreviousPage();
const canNextPage = table.getCanNextPage();
const dir = useDirection();
return (
<div
className={cn(
"flex w-full flex-col-reverse items-center justify-between gap-4 overflow-auto p-1 sm:flex-row sm:gap-8",
className,
)}
{...props}
>
<div className="flex-1 text-sm whitespace-nowrap text-muted-foreground">
{selectedRowCount} {selectedRowCount === 1 ? "row" : "rows"} selected.
</div>
<div className="flex flex-col-reverse items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
<div className="flex items-center gap-2">
<p className="text-sm font-medium whitespace-nowrap">Rows per page</p>
<Select
value={`${pageSize}`}
onValueChange={(value) => {
if (value == null) return;
table.setPageSize(Number(value));
}}
>
<SelectTrigger className="w-18">
<SelectValue placeholder={pageSize} />
</SelectTrigger>
<SelectContent side="top" dir={dir}>
<SelectGroup>
{pageSizeOptions.map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-center text-sm font-medium">
Page {pageIndex + 1} of {pageCount}
</div>
<div className="flex items-center gap-2">
<Button
aria-label="Go to first page"
variant="outline"
size="icon"
className="hidden lg:flex"
onClick={() => table.setPageIndex(0)}
disabled={!canPreviousPage}
>
<IconPlaceholder
lucide="ChevronsLeft"
tabler="IconChevronsLeft"
hugeicons="ArrowLeftDoubleIcon"
phosphor="CaretDoubleLeftIcon"
remixicon="RiSkipLeftLine"
className="rtl:rotate-180"
/>
</Button>
<Button
aria-label="Go to previous page"
variant="outline"
size="icon"
onClick={() => table.previousPage()}
disabled={!canPreviousPage}
>
<IconPlaceholder
lucide="ChevronLeft"
tabler="IconChevronLeft"
hugeicons="ArrowLeft01Icon"
phosphor="CaretLeftIcon"
remixicon="RiArrowLeftSLine"
className="rtl:rotate-180"
/>
</Button>
<Button
aria-label="Go to next page"
variant="outline"
size="icon"
onClick={() => table.nextPage()}
disabled={!canNextPage}
>
<IconPlaceholder
lucide="ChevronRight"
tabler="IconChevronRight"
hugeicons="ArrowRight01Icon"
phosphor="CaretRightIcon"
remixicon="RiArrowRightSLine"
className="rtl:rotate-180"
/>
</Button>
<Button
aria-label="Go to last page"
variant="outline"
size="icon"
className="hidden lg:flex"
onClick={() => table.setPageIndex(pageCount - 1)}
disabled={!canNextPage}
>
<IconPlaceholder
lucide="ChevronsRight"
tabler="IconChevronsRight"
hugeicons="ArrowRightDoubleIcon"
phosphor="CaretDoubleRightIcon"
remixicon="RiSkipRightLine"
className="rtl:rotate-180"
/>
</Button>
</div>
</div>
</div>
);
}
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 { 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: Table<DataTableFeatures, TData>;
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="Toggle columns"
role="combobox"
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
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>
);
}
src/registry/bases/base/components/data-table/data-table-faceted-filter.tsx
"use client";
import type * as React from "react";
import { type Column, type RowData, Subscribe } from "@tanstack/react-table";
import type { DataTableFeatures } from "@/lib/data-table-features";
import type { FilterOption } from "@/lib/data-table-types";
import { Button } from "@/registry/bases/base/ui/button";
import { useDirection } from "@/registry/bases/base/ui/direction";
import {
Faceted,
FacetedClear,
FacetedContent,
FacetedEmpty,
FacetedGroup,
FacetedInput,
FacetedItem,
FacetedItemIndicator,
FacetedList,
FacetedSeparator,
FacetedTrigger,
FacetedValue,
} from "@/registry/bases/base/ui/faceted";
import { Separator } from "@/registry/bases/base/ui/separator";
import { IconPlaceholder } from "@/registry/icons/icon-placeholder";
interface DataTableFacetedFilterProps<TData extends RowData, TValue> {
column?: Column<DataTableFeatures, TData, TValue>;
title?: string;
options: FilterOption[];
multiple?: boolean;
}
export function DataTableFacetedFilter<TData extends RowData, TValue>({
column,
...props
}: DataTableFacetedFilterProps<TData, TValue>) {
if (!column) {
return <DataTableFacetedFilterContent column={column} {...props} />;
}
return (
<Subscribe
source={column.table.atoms.columnFilters}
selector={() => column.getFilterValue()}
>
{(filterValue) => (
<DataTableFacetedFilterContent
column={column}
columnFilterValue={filterValue}
{...props}
/>
)}
</Subscribe>
);
}
interface DataTableFacetedFilterContentProps<
TData extends RowData,
TValue,
> extends DataTableFacetedFilterProps<TData, TValue> {
columnFilterValue?: unknown;
}
function DataTableFacetedFilterContent<TData extends RowData, TValue>({
column,
title,
options,
multiple = false,
columnFilterValue,
}: DataTableFacetedFilterContentProps<TData, TValue>) {
const dir = useDirection();
const selectedValues = Array.isArray(columnFilterValue)
? columnFilterValue.filter(
(value): value is string => typeof value === "string",
)
: [];
const hasSelection = selectedValues.length > 0;
function onValueChange(value: string | string[] | undefined) {
const values = Array.isArray(value) ? value : value ? [value] : [];
column?.setFilterValue(values.length > 0 ? values : undefined);
}
function onReset(event?: React.MouseEvent) {
event?.stopPropagation();
column?.setFilterValue(undefined);
}
return (
<Faceted
value={multiple ? selectedValues : selectedValues[0]}
onValueChange={onValueChange}
items={options}
multiple={multiple}
>
<FacetedTrigger
render={<Button variant="outline" className="border-dashed" />}
>
{hasSelection ? (
<div
role="button"
aria-label={`Clear ${title} filter`}
tabIndex={0}
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
onClick={onReset}
>
<IconPlaceholder
lucide="XCircle"
tabler="IconCircleX"
hugeicons="Cancel01Icon"
phosphor="XCircleIcon"
remixicon="RiCloseCircleLine"
/>
</div>
) : (
<IconPlaceholder
lucide="PlusCircle"
tabler="IconCirclePlus"
hugeicons="PlusSignCircleIcon"
phosphor="PlusCircleIcon"
remixicon="RiAddCircleLine"
/>
)}
{title}
{hasSelection && (
<>
<Separator
orientation="vertical"
className="mx-0.5 data-vertical:h-4 data-vertical:self-center"
/>
<FacetedValue />
</>
)}
</FacetedTrigger>
<FacetedContent dir={dir} className="w-50">
<FacetedInput placeholder={title} />
<FacetedList className="max-h-full">
<FacetedEmpty>No results found.</FacetedEmpty>
<FacetedGroup className="max-h-75 scroll-py-1 overflow-x-hidden overflow-y-auto">
{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>
{hasSelection && (
<>
<FacetedSeparator />
<FacetedGroup>
<FacetedClear>Clear filters</FacetedClear>
</FacetedGroup>
</>
)}
</FacetedList>
</FacetedContent>
</Faceted>
);
}
src/registry/bases/base/components/data-table/data-table-toolbar.tsx
"use client";
import {
type Column,
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 { DataTableDateFilter } from "@/registry/bases/base/components/data-table/data-table-date-filter";
import { DataTableFacetedFilter } from "@/registry/bases/base/components/data-table/data-table-faceted-filter";
import { DataTableSliderFilter } from "@/registry/bases/base/components/data-table/data-table-slider-filter";
import { DataTableViewOptions } from "@/registry/bases/base/components/data-table/data-table-view-options";
import { Button } from "@/registry/bases/base/ui/button";
import { Input } from "@/registry/bases/base/ui/input";
import { IconPlaceholder } from "@/registry/icons/icon-placeholder";
interface DataTableToolbarProps<
TData extends RowData,
> extends React.ComponentProps<"div"> {
table: Table<DataTableFeatures, TData>;
}
export function DataTableToolbar<TData extends RowData>({
table,
children,
className,
...props
}: DataTableToolbarProps<TData>) {
const columns = React.useMemo(
() => table.getAllColumns().filter((column) => column.getCanFilter()),
[table],
);
function onReset() {
table.resetColumnFilters(true);
table.resetJoinOperator(true);
}
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">
{columns.map((column) => (
<DataTableToolbarFilter key={column.id} column={column} />
))}
<Subscribe
source={table.atoms.columnFilters}
selector={(filters) => filters.length > 0}
>
{(isFiltered) =>
isFiltered && (
<Button
aria-label="Reset filters"
variant="outline"
className="border-dashed"
onClick={onReset}
>
<IconPlaceholder
lucide="X"
tabler="IconX"
hugeicons="Cancel01Icon"
phosphor="XIcon"
remixicon="RiCloseLine"
/>
Reset
</Button>
)
}
</Subscribe>
</div>
<div className="flex items-center gap-2">
{children}
<DataTableViewOptions table={table} align="end" />
</div>
</div>
);
}
interface DataTableToolbarFilterProps<TData extends RowData> {
column: Column<DataTableFeatures, TData>;
}
function DataTableToolbarFilter<TData extends RowData>({
column,
}: DataTableToolbarFilterProps<TData>) {
const columnMeta = column.columnDef.meta;
if (!columnMeta?.variant) return null;
const title = columnMeta.label ?? column.id;
const placeholder = columnMeta.placeholder ?? columnMeta.label;
switch (columnMeta.variant) {
case "text":
return (
<DataTableFilterInput
column={column}
placeholder={placeholder}
className="w-40 lg:w-56"
/>
);
case "number":
return (
<div className="relative">
<DataTableFilterInput
column={column}
type="number"
inputMode="numeric"
placeholder={placeholder}
className={cn("w-30", columnMeta.unit && "pe-8")}
/>
{columnMeta.unit && (
<span className="absolute inset-e-0 top-0 bottom-0 flex items-center rounded-e-md bg-accent px-2 text-sm text-muted-foreground">
{columnMeta.unit}
</span>
)}
</div>
);
case "range":
return <DataTableSliderFilter column={column} title={title} />;
case "date":
case "dateRange":
return (
<DataTableDateFilter
column={column}
title={title}
multiple={columnMeta.variant === "dateRange"}
/>
);
case "select":
case "multiSelect":
return (
<DataTableFacetedFilter
column={column}
title={title}
options={columnMeta.options ?? []}
multiple={columnMeta.variant === "multiSelect"}
/>
);
default:
return null;
}
}
function readFilterInputValue(value: unknown) {
if (typeof value === "string" || typeof value === "number") {
return String(value);
}
if (Array.isArray(value)) {
return value.join(",");
}
return "";
}
interface DataTableFilterInputProps<
TData extends RowData,
> extends React.ComponentProps<"input"> {
column: Column<DataTableFeatures, TData>;
}
function DataTableFilterInput<TData extends RowData>({
column,
type = "text",
...props
}: DataTableFilterInputProps<TData>) {
return (
<Subscribe
source={column.table.atoms.columnFilters}
selector={() => column.getFilterValue()}
>
{(filterValue) => (
<Input
type={type}
{...props}
value={readFilterInputValue(filterValue)}
onChange={(event) =>
column.setFilterValue(event.target.value || undefined)
}
/>
)}
</Subscribe>
);
}
src/registry/bases/base/components/data-table/data-table-slider-filter.tsx
"use client";
import { type Column, 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 { Input } from "@/registry/bases/base/ui/input";
import { Label } from "@/registry/bases/base/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/bases/base/ui/popover";
import { Separator } from "@/registry/bases/base/ui/separator";
import { Slider } from "@/registry/bases/base/ui/slider";
import { IconPlaceholder } from "@/registry/icons/icon-placeholder";
interface Range {
min: number;
max: number;
}
type RangeValue = [number, number];
function getIsValidRange(value: unknown): value is RangeValue {
return (
Array.isArray(value) &&
value.length === 2 &&
typeof value[0] === "number" &&
typeof value[1] === "number"
);
}
function parseRangeBounds(
value: unknown,
): [number | undefined, number | undefined] | undefined {
if (!Array.isArray(value) || value.length !== 2) return undefined;
const [start, end] = value.map(parseRangeBound);
if (start === null || end === null) return undefined;
if (start === undefined && end === undefined) return undefined;
return [start, end];
}
function parseRangeBound(value: unknown) {
if (value === undefined || value === "") return undefined;
if (typeof value !== "number" && typeof value !== "string") return null;
const number = Number(value);
return Number.isNaN(number) ? null : number;
}
interface DataTableSliderFilterProps<TData extends RowData> {
column: Column<DataTableFeatures, TData>;
title?: string;
}
export function DataTableSliderFilter<TData extends RowData>({
column,
...props
}: DataTableSliderFilterProps<TData>) {
return (
<Subscribe
source={column.table.atoms.columnFilters}
selector={() => column.getFilterValue()}
>
{(filterValue) => (
<DataTableSliderFilterContent
column={column}
columnFilterValue={filterValue}
{...props}
/>
)}
</Subscribe>
);
}
function DataTableSliderFilterContent<TData extends RowData>({
column,
title,
columnFilterValue: columnFilterValueProp,
}: DataTableSliderFilterProps<TData> & {
columnFilterValue: unknown;
}) {
const dir = useDirection();
const id = React.useId();
const defaultRange = column.columnDef.meta?.range;
const unit = column.columnDef.meta?.unit;
const { min, max, step } = React.useMemo<Range & { step: number }>(() => {
let minValue = 0;
let maxValue = 100;
if (defaultRange && getIsValidRange(defaultRange)) {
[minValue, maxValue] = defaultRange;
} else {
const values = column.getFacetedMinMaxValues();
if (values && Array.isArray(values) && values.length === 2) {
const [facetMinValue, facetMaxValue] = values;
if (
typeof facetMinValue === "number" &&
typeof facetMaxValue === "number"
) {
minValue = facetMinValue;
maxValue = facetMaxValue;
}
}
}
const rangeSize = maxValue - minValue;
const step =
rangeSize <= 20
? 1
: rangeSize <= 100
? Math.ceil(rangeSize / 20)
: Math.ceil(rangeSize / 50);
return { min: minValue, max: maxValue, step };
}, [column, defaultRange]);
// An open-ended bound, like `estimatedHours=,8`, shows as the column limit.
const bounds = parseRangeBounds(columnFilterValueProp);
const columnFilterValue: RangeValue | undefined = bounds
? [bounds[0] ?? min, bounds[1] ?? max]
: undefined;
const range: RangeValue = columnFilterValue ?? [min, max];
const formatValue = React.useCallback((value: number) => {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}, []);
const onFromInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const numValue = Number(event.target.value);
if (!Number.isNaN(numValue) && numValue >= min && numValue <= range[1]) {
column.setFilterValue([numValue, range[1]]);
}
},
[column, min, range],
);
const onToInputChange = React.useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
const numValue = Number(event.target.value);
if (!Number.isNaN(numValue) && numValue <= max && numValue >= range[0]) {
column.setFilterValue([range[0], numValue]);
}
},
[column, max, range],
);
const onSliderValueChange = React.useCallback(
(value: number | readonly number[]) => {
if (Array.isArray(value) && value.length === 2) {
column.setFilterValue(value);
}
},
[column],
);
const onReset = React.useCallback(
(event: React.MouseEvent) => {
if (event.target instanceof HTMLDivElement) {
event.stopPropagation();
}
column.setFilterValue(undefined);
},
[column],
);
return (
<Popover>
<PopoverTrigger
render={<Button variant="outline" className="border-dashed" />}
>
{columnFilterValue ? (
<div
role="button"
aria-label={`Clear ${title} filter`}
tabIndex={0}
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
onClick={onReset}
>
<IconPlaceholder
lucide="XCircle"
tabler="IconCircleX"
hugeicons="Cancel01Icon"
phosphor="XCircleIcon"
remixicon="RiCloseCircleLine"
/>
</div>
) : (
<IconPlaceholder
lucide="PlusCircle"
tabler="IconCirclePlus"
hugeicons="PlusSignCircleIcon"
phosphor="PlusCircleIcon"
remixicon="RiAddCircleLine"
/>
)}
<span>{title}</span>
{columnFilterValue ? (
<>
<Separator
orientation="vertical"
className="mx-0.5 data-vertical:h-4 data-vertical:self-center"
/>
{formatValue(columnFilterValue[0])} -{" "}
{formatValue(columnFilterValue[1])}
{unit ? ` ${unit}` : ""}
</>
) : null}
</PopoverTrigger>
<PopoverContent
dir={dir}
align="start"
className="flex w-auto flex-col gap-4"
>
<div className="flex flex-col gap-3">
<p className="leading-none font-medium peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
{title}
</p>
<div className="flex items-center gap-4">
<Label htmlFor={`${id}-from`} className="sr-only">
From
</Label>
<div className="relative">
<Input
id={`${id}-from`}
type="number"
aria-valuemin={min}
aria-valuemax={max}
inputMode="numeric"
pattern="[0-9]*"
placeholder={min.toString()}
min={min}
max={max}
value={range[0]?.toString()}
onChange={onFromInputChange}
className={cn("w-24", unit && "pe-8")}
/>
{unit && (
<span className="absolute inset-e-0 top-0 bottom-0 flex items-center rounded-e-md bg-accent px-2 text-sm text-muted-foreground">
{unit}
</span>
)}
</div>
<Label htmlFor={`${id}-to`} className="sr-only">
to
</Label>
<div className="relative">
<Input
id={`${id}-to`}
type="number"
aria-valuemin={min}
aria-valuemax={max}
inputMode="numeric"
pattern="[0-9]*"
placeholder={max.toString()}
min={min}
max={max}
value={range[1]?.toString()}
onChange={onToInputChange}
className={cn("w-24", unit && "pe-8")}
/>
{unit && (
<span className="absolute inset-e-0 top-0 bottom-0 flex items-center rounded-e-md bg-accent px-2 text-sm text-muted-foreground">
{unit}
</span>
)}
</div>
</div>
<Label htmlFor={`${id}-slider`} className="sr-only">
{title} slider
</Label>
<Slider
id={`${id}-slider`}
min={min}
max={max}
step={step}
value={range}
onValueChange={onSliderValueChange}
/>
</div>
<Button
aria-label={`Clear ${title} filter`}
variant="outline"
onClick={onReset}
>
Clear
</Button>
</PopoverContent>
</Popover>
);
}
src/registry/bases/base/components/data-table/data-table-date-filter.tsx
"use client";
import type { DateRange } from "react-day-picker";
import { type Column, type RowData, Subscribe } from "@tanstack/react-table";
import * as React from "react";
import type { DataTableFeatures } from "@/lib/data-table-features";
import { formatDate } from "@/lib/format";
import { Button } from "@/registry/bases/base/ui/button";
import { useDirection } from "@/registry/bases/base/ui/direction";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/registry/bases/base/ui/popover";
import { Separator } from "@/registry/bases/base/ui/separator";
import { Calendar } from "@/registry/bases/radix/ui/calendar";
import { IconPlaceholder } from "@/registry/icons/icon-placeholder";
type DateSelection = Date[] | DateRange;
function getIsDateRange(value: DateSelection): value is DateRange {
return value && typeof value === "object" && !Array.isArray(value);
}
function parseAsDate(timestamp: number | string | undefined): Date | undefined {
if (!timestamp) return undefined;
const numericTimestamp =
typeof timestamp === "string" ? Number(timestamp) : timestamp;
const date = new Date(numericTimestamp);
return !Number.isNaN(date.getTime()) ? date : undefined;
}
function parseColumnFilterValue(value: unknown) {
if (value === null || value === undefined) {
return [];
}
if (Array.isArray(value)) {
return value.map((item) => {
if (typeof item === "number" || typeof item === "string") {
return item;
}
return undefined;
});
}
if (typeof value === "string" || typeof value === "number") {
return [value];
}
return [];
}
interface DataTableDateFilterProps<TData extends RowData> {
column: Column<DataTableFeatures, TData>;
title?: string;
multiple?: boolean;
}
export function DataTableDateFilter<TData extends RowData>({
column,
...props
}: DataTableDateFilterProps<TData>) {
return (
<Subscribe
source={column.table.atoms.columnFilters}
selector={() => column.getFilterValue()}
>
{(filterValue) => (
<DataTableDateFilterContent
column={column}
columnFilterValue={filterValue}
{...props}
/>
)}
</Subscribe>
);
}
function DataTableDateFilterContent<TData extends RowData>({
column,
title,
multiple,
columnFilterValue,
}: DataTableDateFilterProps<TData> & {
columnFilterValue: unknown;
}) {
const dir = useDirection();
const selectedDates = React.useMemo<DateSelection>(() => {
if (!columnFilterValue) {
return multiple ? { from: undefined, to: undefined } : [];
}
if (multiple) {
const timestamps = parseColumnFilterValue(columnFilterValue);
return {
from: parseAsDate(timestamps[0]),
to: parseAsDate(timestamps[1]),
};
}
const timestamps = parseColumnFilterValue(columnFilterValue);
const date = parseAsDate(timestamps[0]);
return date ? [date] : [];
}, [columnFilterValue, multiple]);
const onSelect = React.useCallback(
(date: Date | DateRange | undefined) => {
if (!date) {
column.setFilterValue(undefined);
return;
}
if (multiple && !("getTime" in date)) {
const from = date.from?.getTime();
const to = date.to?.getTime();
column.setFilterValue(from || to ? [from, to] : undefined);
} else if (!multiple && "getTime" in date) {
column.setFilterValue(date.getTime());
}
},
[column, multiple],
);
const onReset = React.useCallback(
(event: React.MouseEvent) => {
event.stopPropagation();
column.setFilterValue(undefined);
},
[column],
);
const hasValue = React.useMemo(() => {
if (multiple) {
if (!getIsDateRange(selectedDates)) return false;
return selectedDates.from || selectedDates.to;
}
if (!Array.isArray(selectedDates)) return false;
return selectedDates.length > 0;
}, [multiple, selectedDates]);
const formatDateRange = React.useCallback((range: DateRange) => {
if (!range.from && !range.to) return "";
if (range.from && range.to) {
return `${formatDate(range.from)} - ${formatDate(range.to)}`;
}
return formatDate(range.from ?? range.to);
}, []);
const label = React.useMemo(() => {
if (multiple) {
if (!getIsDateRange(selectedDates)) return null;
const hasSelectedDates = selectedDates.from || selectedDates.to;
const dateText = hasSelectedDates
? formatDateRange(selectedDates)
: "Select date range";
return (
<span className="flex items-center gap-2">
<span>{title}</span>
{hasSelectedDates && (
<>
<Separator
orientation="vertical"
className="mx-0.5 data-vertical:h-4 data-vertical:self-center"
/>
<span>{dateText}</span>
</>
)}
</span>
);
}
if (getIsDateRange(selectedDates)) return null;
const hasSelectedDate = selectedDates.length > 0;
const dateText = hasSelectedDate
? formatDate(selectedDates[0])
: "Select date";
return (
<span className="flex items-center gap-2">
<span>{title}</span>
{hasSelectedDate && (
<>
<Separator
orientation="vertical"
className="mx-0.5 data-vertical:h-4 data-vertical:self-center"
/>
<span>{dateText}</span>
</>
)}
</span>
);
}, [selectedDates, multiple, formatDateRange, title]);
return (
<Popover>
<PopoverTrigger
render={<Button variant="outline" className="border-dashed" />}
>
{hasValue ? (
<div
role="button"
aria-label={`Clear ${title} filter`}
tabIndex={0}
onClick={onReset}
className="rounded-sm opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-1 focus-visible:ring-ring focus-visible:outline-none"
>
<IconPlaceholder
lucide="XCircle"
tabler="IconCircleX"
hugeicons="Cancel01Icon"
phosphor="XCircleIcon"
remixicon="RiCloseCircleLine"
/>
</div>
) : (
<IconPlaceholder
lucide="CalendarIcon"
tabler="IconCalendar"
hugeicons="CalendarIcon"
phosphor="CalendarIcon"
remixicon="RiCalendarLine"
/>
)}
{label}
</PopoverTrigger>
<PopoverContent dir={dir} className="w-auto p-0" align="start">
{multiple ? (
<Calendar
autoFocus
captionLayout="dropdown"
mode="range"
selected={
getIsDateRange(selectedDates)
? selectedDates
: { from: undefined, to: undefined }
}
onSelect={onSelect}
/>
) : (
<Calendar
captionLayout="dropdown"
mode="single"
selected={
!getIsDateRange(selectedDates) ? selectedDates[0] : undefined
}
onSelect={onSelect}
/>
)}
</PopoverContent>
</Popover>
);
}
src/registry/bases/base/components/data-table/data-table-skeleton.tsx
import type * as React from "react";
import { cn } from "cn";
import { Skeleton } from "@/registry/bases/base/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/registry/bases/base/ui/table";
interface DataTableSkeletonProps extends React.ComponentProps<"div"> {
columnCount: number;
rowCount?: number;
filterCount?: number;
cellWidths?: string[];
withViewOptions?: boolean;
withPagination?: boolean;
shrinkZero?: boolean;
}
export function DataTableSkeleton({
columnCount,
rowCount = 10,
filterCount = 0,
cellWidths = ["auto"],
withViewOptions = true,
withPagination = true,
shrinkZero = false,
className,
...props
}: DataTableSkeletonProps) {
const cozyCellWidths = Array.from(
{ length: columnCount },
(_, index) => cellWidths[index % cellWidths.length] ?? "auto",
);
return (
<div
className={cn("flex w-full flex-col gap-2.5 overflow-auto", className)}
{...props}
>
<div className="flex w-full items-center justify-between gap-2 overflow-auto p-1">
<div className="flex flex-1 items-center gap-2">
{filterCount > 0
? Array.from({ length: filterCount }).map((_, i) => (
<Skeleton
key={i}
className="h-8 w-18 rounded-lg border-dashed"
/>
))
: null}
</div>
{withViewOptions ? (
<Skeleton className="ms-auto hidden h-8 w-18 rounded-lg lg:flex" />
) : null}
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
{Array.from({ length: 1 }).map((_, i) => (
<TableRow key={i} className="hover:bg-transparent">
{Array.from({ length: columnCount }).map((_, j) => (
<TableHead
key={j}
style={{
width: cozyCellWidths[j],
minWidth: shrinkZero ? cozyCellWidths[j] : "auto",
}}
>
<Skeleton className="h-6 w-full" />
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{Array.from({ length: rowCount }).map((_, i) => (
<TableRow key={i} className="hover:bg-transparent">
{Array.from({ length: columnCount }).map((_, j) => (
<TableCell
key={j}
style={{
width: cozyCellWidths[j],
minWidth: shrinkZero ? cozyCellWidths[j] : "auto",
}}
>
<Skeleton className="h-6 w-full" />
</TableCell>
))}
</TableRow>
))}
</TableBody>
</Table>
</div>
{withPagination ? (
<div className="flex w-full items-center justify-between gap-4 overflow-auto p-1 sm:gap-8">
<Skeleton className="h-8 w-40 shrink-0 rounded-lg" />
<div className="flex items-center gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center gap-2">
<Skeleton className="h-8 w-24 rounded-lg" />
<Skeleton className="h-8 w-18 rounded-lg" />
</div>
<div className="flex items-center justify-center text-sm font-medium">
<Skeleton className="h-8 w-20 rounded-lg" />
</div>
<div className="flex items-center gap-2">
<Skeleton className="hidden size-8 rounded-lg lg:block" />
<Skeleton className="size-8 rounded-lg" />
<Skeleton className="size-8 rounded-lg" />
<Skeleton className="hidden size-8 rounded-lg lg:block" />
</div>
</div>
</div>
) : null}
</div>
);
}
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 { 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 {
value: string;
label: string;
}
const NO_ITEMS: FacetedOption[] = [];
interface FacetedState {
value: string | string[] | undefined;
open: boolean;
}
interface FacetedStore {
subscribe: (callback: () => void) => () => void;
getState: () => FacetedState;
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: FacetedState,
props: FacetedController,
): string | string[] | undefined {
return props.isControlled ? props.value : state.value;
}
function getHasSelection(value: string | string[] | undefined) {
if (Array.isArray(value)) return value.length > 0;
return !!value;
}
function getIsValueSelected(
value: string | string[] | undefined,
multiple: boolean,
itemValue: string,
) {
if (multiple) return Array.isArray(value) && value.includes(itemValue);
return value === itemValue;
}
function getSelectedItems(
value: string | string[] | undefined,
items: FacetedOption[],
) {
const values = Array.isArray(value) ? value : value ? [value] : [];
return values.map((itemValue) => ({
value: itemValue,
label: items.find((item) => item.value === itemValue)?.label ?? itemValue,
}));
}
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: FacetedState,
): 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: FacetedState) => 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>(
props: FacetedProps<Multiple>,
) {
const {
open: openProp,
defaultOpen = false,
onOpenChange,
value,
defaultValue,
onValueChange,
items = NO_ITEMS,
multiple = false as Multiple,
...popoverProps
} = props;
const controller: FacetedController = {
open: openProp,
onOpenChange,
value,
onValueChange(nextValue) {
if (!getIsFacetedSelectedValue(nextValue, multiple)) return;
onValueChange?.(nextValue);
},
isControlled: "value" in props,
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);
return (
<FacetedStoreContext.Provider value={store}>
<Popover
data-slot="faceted"
open={openProp ?? open}
onOpenChange={store.setOpen}
{...popoverProps}
/>
</FacetedStoreContext.Provider>
);
}
function FacetedTrigger({
className,
...props
}: React.ComponentProps<typeof PopoverTrigger>) {
return (
<PopoverTrigger
data-slot="faceted-trigger"
className={cn("justify-between text-left", 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 selected = getSelectedItems(value, store.getProps().items);
if (typeof children === "function") return children(selected);
if (children != null) return children;
if (selected.length === 0) {
return (
<span
data-slot="faceted-value"
className="flex w-full items-center gap-1 text-muted-foreground"
>
{placeholder}
<IconPlaceholder
lucide="ChevronDown"
tabler="IconChevronDown"
hugeicons="ArrowDown01Icon"
phosphor="CaretDownIcon"
remixicon="RiArrowDownSLine"
className="ml-auto size-4 shrink-0 text-muted-foreground"
/>
</span>
);
}
if (selected.length > 2) {
return (
<FacetedBadgeList>
<FacetedBadge>{selected.length} selected</FacetedBadge>
</FacetedBadgeList>
);
}
return (
<FacetedBadgeList>
{selected.map((item) => (
<FacetedBadge key={item.value}>
<span className="truncate">{item.label}</span>
</FacetedBadge>
))}
</FacetedBadgeList>
);
}
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,
),
});
}
function FacetedContent({
className,
children,
...props
}: React.ComponentProps<typeof PopoverContent>) {
return (
<PopoverContent
data-slot="faceted-content"
align="start"
className={cn("w-50 origin-(--transform-origin) p-0", className)}
{...props}
>
<Command className="p-0.5">{children}</Command>
</PopoverContent>
);
}
function FacetedInput({
ref,
...props
}: React.ComponentProps<typeof CommandInput>) {
return <CommandInput data-slot="faceted-input" ref={ref} {...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 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 props = store.getProps();
return getIsValueSelected(
getSelection(state, props),
props.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) =>
getHasSelection(getSelection(state, store.getProps())),
);
if (!hasSelection) return null;
return (
<FacetedCommandItem
data-slot="faceted-clear"
className={cn("justify-center text-center", className)}
onSelect={(value) => {
onSelect?.(value);
store.clear();
}}
{...props}
>
{children}
</FacetedCommandItem>
);
}
function FacetedSeparator(
props: React.ComponentProps<typeof CommandSeparator>,
) {
return (
<CommandSeparator
data-slot="faceted-separator"
className="mx-0 my-0.5 w-full"
{...props}
/>
);
}
export {
Faceted,
FacetedBadge,
FacetedBadgeList,
FacetedClear,
FacetedContent,
FacetedEmpty,
FacetedGroup,
FacetedInput,
FacetedItem,
FacetedItemIndicator,
FacetedList,
FacetedSeparator,
FacetedTrigger,
FacetedValue,
};
src/registry/bases/base/ui/direction.tsx
"use client";
export {
DirectionProvider,
useDirection,
} from "@base-ui/react/direction-provider";
src/registry/bases/base/hooks/use-data-table.ts
import {
type ColumnFiltersState,
functionalUpdate,
type PaginationState,
type RowData,
type SortingState,
type TableOptions,
type Updater,
useTable,
} from "@tanstack/react-table";
import {
parseAsInteger,
parseAsStringEnum,
useQueryState,
type UseQueryStateOptions,
useQueryStates,
} from "nuqs";
import * as React from "react";
import type {
ColumnFilterItem,
FilterVariant,
JoinOperator,
DataTableQueryKeys,
} from "@/lib/data-table-types";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import {
type DataTableFeatures,
dataTableFeatures,
} from "@/lib/data-table-features";
import {
getColumnFilterParser,
getColumnFilters,
getColumnFiltersKey,
getSortingStateParser,
sortColumnFiltersBySearch,
} from "@/lib/data-table-parsers";
import {
getActiveFilters,
JOIN_OPERATORS,
normalizeColumnFilter,
} from "@/lib/data-table-utils";
const PAGE_KEY = "page";
const PER_PAGE_KEY = "perPage";
const SORT_KEY = "sort";
const JOIN_OPERATOR_KEY = "joinOperator";
const DEBOUNCE_MS = 300;
const THROTTLE_MS = 50;
const DEFAULT_PAGE_SIZE = 10;
const EMPTY_SORTING: SortingState = [];
interface FiltersDraft {
filters: ColumnFiltersState;
urlFiltersKey: string;
}
type UseDataTableProps<TData extends RowData> = Omit<
TableOptions<DataTableFeatures, TData>,
| "state"
| "pageCount"
| "features"
| "manualFiltering"
| "manualPagination"
| "manualSorting"
> & {
queryKeys?: Partial<DataTableQueryKeys>;
history?: "push" | "replace";
debounceMs?: number;
throttleMs?: number;
clearOnDefault?: boolean;
scroll?: boolean;
shallow?: boolean;
startTransition?: React.TransitionStartFunction;
} & (
| {
mode?: "server";
pageCount: number;
}
| {
mode: "client";
pageCount?: never;
}
);
function useDataTable<TData extends RowData>({
columns,
mode = "server",
pageCount,
initialState,
queryKeys,
history = "replace",
debounceMs = DEBOUNCE_MS,
throttleMs = THROTTLE_MS,
clearOnDefault = false,
scroll = false,
shallow: shallowProp = true,
startTransition,
...props
}: UseDataTableProps<TData>) {
const isServer = mode === "server";
const shallow = isServer ? shallowProp : true;
const pageKey = queryKeys?.page ?? PAGE_KEY;
const perPageKey = queryKeys?.perPage ?? PER_PAGE_KEY;
const sortKey = queryKeys?.sort ?? SORT_KEY;
const joinOperatorKey = queryKeys?.joinOperator ?? JOIN_OPERATOR_KEY;
const queryStateOptions = React.useMemo<
Omit<UseQueryStateOptions<string>, "parse">
>(
() => ({
history,
scroll,
shallow,
throttleMs,
debounceMs,
clearOnDefault,
startTransition,
}),
[
history,
scroll,
shallow,
throttleMs,
debounceMs,
clearOnDefault,
startTransition,
],
);
const [initialTableState] = React.useState(initialState);
const pageParser = React.useMemo(
() => parseAsInteger.withOptions(queryStateOptions).withDefault(1),
[queryStateOptions],
);
const perPageParser = React.useMemo(
() =>
parseAsInteger
.withOptions(queryStateOptions)
.withDefault(
initialTableState?.pagination?.pageSize ?? DEFAULT_PAGE_SIZE,
),
[initialTableState, queryStateOptions],
);
const [page, setPage] = useQueryState(pageKey, pageParser);
const [perPage, setPerPage] = useQueryState(perPageKey, perPageParser);
const pagination = React.useMemo<PaginationState>(
() => ({ pageIndex: page - 1, pageSize: perPage }),
[page, perPage],
);
function onPaginationChange(updater: Updater<PaginationState>) {
const next = functionalUpdate(updater, pagination);
void setPage(next.pageIndex + 1);
void setPerPage(next.pageSize);
}
const columnIndex = React.useMemo(() => {
const sortableIds = new Set<string>();
const filterableVariants = new Map<string, FilterVariant>();
for (const column of columns) {
if (!column.id) continue;
const hasAccessor = "accessorKey" in column || "accessorFn" in column;
if (hasAccessor && column.enableSorting !== false) {
sortableIds.add(column.id);
}
if (!column.enableColumnFilter) continue;
filterableVariants.set(column.id, column.meta?.variant ?? "text");
}
return {
sortableIds,
filterableIds: [...filterableVariants.keys()],
variantById: Object.fromEntries(filterableVariants),
normalizeColumnFilters: (filters: ColumnFiltersState) =>
filters.map((filter) =>
normalizeColumnFilter(
filter,
filterableVariants.get(filter.id) ?? "text",
),
),
};
}, [columns]);
const sortingParser = React.useMemo(
() =>
getSortingStateParser(columnIndex.sortableIds)
.withOptions(queryStateOptions)
.withDefault(initialTableState?.sorting ?? EMPTY_SORTING),
[columnIndex, initialTableState, queryStateOptions],
);
const [sorting, setSorting] = useQueryState(sortKey, sortingParser);
function onSortingChange(updater: Updater<SortingState>) {
void setSorting(functionalUpdate(updater, sorting));
}
const joinOperatorParser = React.useMemo(
() =>
parseAsStringEnum([...JOIN_OPERATORS])
.withOptions(queryStateOptions)
.withDefault(initialTableState?.joinOperator ?? "and"),
[initialTableState, queryStateOptions],
);
const [joinOperator, setJoinOperator] = useQueryState(
joinOperatorKey,
joinOperatorParser,
);
function onJoinOperatorChange(updater: Updater<JoinOperator>) {
void setJoinOperator(functionalUpdate(updater, joinOperator));
}
const filterParsers = React.useMemo(
() =>
Object.fromEntries(
columnIndex.filterableIds.map((id) => [
id,
getColumnFilterParser(
id,
columnIndex.variantById[id] ?? "text",
).withOptions(queryStateOptions),
]),
),
[columnIndex, queryStateOptions],
);
const [filterParams, setFilterParams] = useQueryStates(filterParsers);
const search = React.useSyncExternalStore(
subscribeToHistory,
getLocationSearch,
getServerLocationSearch,
);
const urlFilters = React.useMemo(
() =>
sortColumnFiltersBySearch(
getColumnFilters(
columnIndex.filterableIds,
(id) => filterParams[id] ?? [],
),
search,
),
[columnIndex, filterParams, search],
);
const urlFiltersKey = getColumnFiltersKey(urlFilters);
const [filtersDraft, setFiltersDraft] = React.useState<FiltersDraft | null>(
() => {
const initialFilters = initialTableState?.columnFilters;
if (urlFilters.length > 0 || !initialFilters?.length) return null;
return {
filters: columnIndex.normalizeColumnFilters(initialFilters),
urlFiltersKey,
};
},
);
const columnFilters =
filtersDraft?.urlFiltersKey === urlFiltersKey
? filtersDraft.filters
: urlFilters;
const debouncedWriteFilters = useDebouncedCallback(
(columnFilters: ColumnFiltersState, sourceUrlFiltersKey: string) => {
if (sourceUrlFiltersKey !== urlFiltersKey) return;
const filters = getActiveFilters(
columnIndex.normalizeColumnFilters(columnFilters),
).filter((filter) => Object.hasOwn(columnIndex.variantById, filter.id));
const params = new Map<string, ColumnFilterItem[] | null>();
for (const filter of filters) {
const group = params.get(filter.id);
if (group) group.push(filter);
else params.set(filter.id, [filter]);
}
for (const id of columnIndex.filterableIds) {
if (!params.has(id)) params.set(id, null);
}
const nextUrlFiltersKey = getColumnFiltersKey(filters);
setFiltersDraft(
(prev) => prev && { ...prev, urlFiltersKey: nextUrlFiltersKey },
);
void setPage(1);
void setFilterParams(Object.fromEntries(params));
},
debounceMs,
);
function onColumnFiltersChange(updater: Updater<ColumnFiltersState>) {
const filters = functionalUpdate(updater, columnFilters);
setFiltersDraft({ filters, urlFiltersKey });
debouncedWriteFilters(filters, urlFiltersKey);
}
const table = useTable(
{
...props,
features: dataTableFeatures,
columns,
initialState: initialTableState,
pageCount: isServer ? pageCount : undefined,
state: {
pagination,
sorting,
columnFilters,
joinOperator,
},
onPaginationChange,
onSortingChange,
onColumnFiltersChange,
onJoinOperatorChange,
manualPagination: isServer,
manualSorting: isServer,
manualFiltering: isServer,
},
(state) => ({
columnFilters: state.columnFilters,
joinOperator: state.joinOperator,
pagination: state.pagination,
sorting: state.sorting,
}),
);
return React.useMemo(() => ({ table }), [table]);
}
function subscribeToHistory(onChange: () => void) {
window.addEventListener("popstate", onChange);
return () => window.removeEventListener("popstate", onChange);
}
function getLocationSearch() {
return window.location.search;
}
function getServerLocationSearch() {
return "";
}
export { useDataTable, type UseDataTableProps };
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,
ColumnFilter,
RowData,
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 = [
"select",
"multiSelect",
"range",
"dateRange",
] satisfies FilterVariant[];
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 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 ? 1 : 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/data-table-parsers.ts
import {
createParser,
parseAsInteger,
parseAsNativeArrayOf,
parseAsStringEnum,
} from "nuqs/server";
import { z } from "zod";
import type {
ColumnFilterItem,
ColumnSortItem,
DataTableColumnConfig,
DataTableColumnConfigs,
DataTableColumnConfigsQuery,
FilterableColumnId,
FilterOperator,
FilterVariant,
JoinOperator,
SortableColumnId,
} from "@/lib/data-table-types";
import {
FILTER_OPERATORS,
getFilterOperators,
getIsActiveFilter,
getIsValuelessOperator,
getPlainFilterOperator,
JOIN_OPERATORS,
normalizeColumnFilter,
} from "@/lib/data-table-utils";
const sortingItemSchema = z.object({
id: z.string(),
desc: z.boolean(),
});
export const getSortingStateParser = <TColumnId extends string = string>(
columnIds?: readonly TColumnId[] | Set<TColumnId>,
) => {
const validIds = getIdSet(columnIds);
return createParser<ColumnSortItem<TColumnId>[]>({
parse: (value) => {
const sorting = parseSorting(value);
return sorting && getHasKnownIds(sorting, validIds) ? sorting : null;
},
serialize: (value) => {
const compact = value
.map((item) => `${item.id}.${item.desc ? "desc" : "asc"}`)
.join(",");
const parsed = parseSorting(compact);
// Ids with a comma, or a first id starting with `[`, don't round-trip.
return parsed && getIsSameSorting(parsed, value)
? compact
: JSON.stringify(value);
},
eq: getIsSameSorting,
});
};
const OPERATORS_BY_NAME = new Map(
getKeys(FILTER_OPERATORS).flatMap((operator): [string, FilterOperator][] => [
[FILTER_OPERATORS[operator], operator],
[operator.toLowerCase(), operator],
]),
);
const MAX_OPERATOR_PART_COUNT = 3;
const LIST_OPERATORS = new Set<FilterOperator>([
"inArray",
"notInArray",
"isBetween",
]);
export function parseColumnFilter<TColumnId extends string>(
id: TColumnId,
variant: FilterVariant,
param: string,
): ColumnFilterItem<TColumnId> {
const { operator, rawValue } = splitFilterOperator(param, variant);
const filter = normalizeColumnFilter(
{
id,
variant,
operator,
value: getIsValuelessOperator(operator)
? ""
: LIST_OPERATORS.has(operator)
? parseListValue(rawValue, operator)
: rawValue,
},
variant,
);
return { ...filter, id };
}
export function serializeColumnFilter(filter: ColumnFilterItem) {
const name = FILTER_OPERATORS[filter.operator];
if (getIsValuelessOperator(filter.operator)) return name;
const rawValue = Array.isArray(filter.value)
? joinListValue(filter.value)
: filter.value;
if (filter.operator === getPlainFilterOperator(filter.variant)) {
const implicit = parseColumnFilter(filter.id, filter.variant, rawValue);
if (getIsSameFilter(implicit, filter)) return rawValue;
}
return `${name}.${rawValue}`;
}
export function getColumnFilterParser<TColumnId extends string>(
id: TColumnId,
variant: FilterVariant,
) {
return parseAsNativeArrayOf(
createParser<ColumnFilterItem<TColumnId>>({
// nuqs drops items that parse to null, so `?title=` reads as no filter.
parse: (param) => {
const filter = parseColumnFilter(id, variant, param);
return getIsActiveFilter(filter) ? filter : null;
},
serialize: serializeColumnFilter,
eq: (a, b) => serializeColumnFilter(a) === serializeColumnFilter(b),
}),
);
}
export function getColumnFilters<TColumnId extends string>(
columnIds: readonly TColumnId[],
getFilters: (id: TColumnId) => ColumnFilterItem<TColumnId>[],
) {
return columnIds.flatMap((id) =>
getFilters(id).map((filter, index) => ({
...filter,
filterId: `${id}-${index}`,
})),
);
}
export function sortColumnFiltersBySearch<TFilter extends ColumnFilterItem>(
filters: TFilter[],
search: string | URLSearchParams,
) {
const positions = new Map<string, number>();
for (const key of new URLSearchParams(search).keys()) {
if (!positions.has(key)) positions.set(key, positions.size);
}
return [...filters].sort(
(a, b) =>
(positions.get(a.id) ?? Number.POSITIVE_INFINITY) -
(positions.get(b.id) ?? Number.POSITIVE_INFINITY),
);
}
export function getColumnFiltersKey(filters: ColumnFilterItem[]) {
return filters
.map((filter) => `${filter.id}=${serializeColumnFilter(filter)}`)
.sort()
.join("&");
}
export function getFilterableColumns<
TColumnConfigs extends DataTableColumnConfigs,
>(columnConfigs: TColumnConfigs) {
const entries = getKeys<keyof TColumnConfigs & string>(columnConfigs).flatMap(
(id) => {
const variant = columnConfigs[id]?.variant;
return variant ? [[id, variant] as const] : [];
},
);
return Object.fromEntries(entries) as Record<
FilterableColumnId<TColumnConfigs>,
FilterVariant
>;
}
export function getSortableColumns<
TColumnConfigs extends DataTableColumnConfigs,
>(columnConfigs: TColumnConfigs) {
return getKeys<keyof TColumnConfigs & string>(columnConfigs).filter(
(id): id is SortableColumnId<TColumnConfigs> =>
columnConfigs[id]?.isSortable !== false,
);
}
export function getColumnOptions(config: DataTableColumnConfig) {
return {
enableColumnFilter: config.variant !== undefined,
enableSorting: config.isSortable !== false,
};
}
interface DataTableSearchParamsOptions<
TColumnConfigs extends DataTableColumnConfigs,
> {
columnConfigs: TColumnConfigs;
defaultSorting?: ColumnSortItem<NoInfer<SortableColumnId<TColumnConfigs>>>[];
defaultPerPage?: number;
}
export function getDataTableSearchParams<
TColumnConfigs extends DataTableColumnConfigs,
>({
columnConfigs,
defaultSorting = [],
defaultPerPage = 10,
}: DataTableSearchParamsOptions<TColumnConfigs>) {
return {
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(defaultPerPage),
sort: getSortingStateParser(getSortableColumns(columnConfigs)).withDefault(
defaultSorting,
),
joinOperator: parseAsStringEnum([...JOIN_OPERATORS]).withDefault("and"),
...getFilterParsers(getFilterableColumns(columnConfigs)),
};
}
interface DataTableSearch<TSortColumnId extends string> {
page: number;
perPage: number;
sort: ColumnSortItem<TSortColumnId>[];
joinOperator: JoinOperator;
}
export function getDataTableQuery<
TColumnConfigs extends DataTableColumnConfigs,
>(
search: NoInfer<
DataTableSearch<SortableColumnId<TColumnConfigs>> &
Record<
FilterableColumnId<TColumnConfigs>,
ColumnFilterItem<FilterableColumnId<TColumnConfigs>>[]
>
>,
columnConfigs: TColumnConfigs,
): DataTableColumnConfigsQuery<TColumnConfigs> {
const filtersById: Record<
FilterableColumnId<TColumnConfigs>,
ColumnFilterItem<FilterableColumnId<TColumnConfigs>>[]
> = search;
return {
page: search.page,
perPage: search.perPage,
sorting: search.sort,
filters: getColumnFilters(
getKeys(getFilterableColumns(columnConfigs)),
(id) => filtersById[id],
),
joinOperator: search.joinOperator,
};
}
function getFilterParsers<TColumnId extends string>(
columns: Record<TColumnId, FilterVariant>,
) {
const parsers = getKeys(columns).map(
(id) => [id, getColumnFilterParser(id, columns[id])] as const,
);
return Object.fromEntries(parsers) as Record<
TColumnId,
ReturnType<typeof getColumnFilterParser<TColumnId>>
>;
}
function splitFilterOperator(param: string, variant: FilterVariant) {
const parts = param.split(".");
for (
let count = Math.min(MAX_OPERATOR_PART_COUNT, parts.length);
count > 0;
count--
) {
const operator = OPERATORS_BY_NAME.get(
parts.slice(0, count).join(".").toLowerCase(),
);
if (!operator || !getIsVariantOperator(variant, operator)) continue;
if (count === parts.length && !getIsValuelessOperator(operator)) continue;
return { operator, rawValue: parts.slice(count).join(".") };
}
return { operator: getPlainFilterOperator(variant), rawValue: param };
}
function getIsVariantOperator(
variant: FilterVariant,
operator: FilterOperator,
) {
return (
operator === getPlainFilterOperator(variant) ||
getFilterOperators(variant).some((option) => option.value === operator)
);
}
function parseListValue(rawValue: string, operator: FilterOperator) {
const items: string[] = [];
let item = "";
let isQuoted = false;
let wasQuoted = false;
for (let index = 0; index < rawValue.length; index++) {
const char = rawValue[index];
if (char === '"') {
if (isQuoted && rawValue[index + 1] === '"') {
item += char;
index++;
} else {
isQuoted = !isQuoted;
wasQuoted = true;
}
} else if (char === "," && !isQuoted) {
items.push(wasQuoted ? item : item.trim());
item = "";
wasQuoted = false;
} else {
item += char;
}
}
if (rawValue !== "") items.push(wasQuoted ? item : item.trim());
if (operator === "isBetween") return [items[0] ?? "", items[1] ?? ""];
return items.filter((value) => value !== "");
}
function joinListValue(values: string[]) {
return values
.map((value) =>
/[,"]/.test(value) || value !== value.trim()
? `"${value.replaceAll('"', '""')}"`
: value,
)
.join(",");
}
function getIsSameFilter(a: ColumnFilterItem, b: ColumnFilterItem) {
if (a.operator !== b.operator) return false;
if (!Array.isArray(a.value) || !Array.isArray(b.value)) {
return a.value === b.value;
}
const otherValue = b.value;
return (
a.value.length === otherValue.length &&
a.value.every((item, index) => item === otherValue[index])
);
}
function getKeys<TKey extends string>(record: Record<TKey, unknown>) {
return Object.keys(record).filter((key): key is TKey =>
Object.hasOwn(record, key),
);
}
function parseSorting(value: string): ColumnSortItem[] | null {
if (value.startsWith("[")) {
try {
const result = z.array(sortingItemSchema).safeParse(JSON.parse(value));
return result.success ? result.data : null;
} catch {
return null;
}
}
const sorting: ColumnSortItem[] = [];
for (const part of value.split(",")) {
const index = part.lastIndexOf(".");
const order = part.slice(index + 1);
if (index <= 0 || (order !== "asc" && order !== "desc")) return null;
sorting.push({ id: part.slice(0, index), desc: order === "desc" });
}
return sorting;
}
function getIsSameSorting(a: ColumnSortItem[], b: ColumnSortItem[]) {
return (
a.length === b.length &&
a.every(
(item, index) => item.id === b[index]?.id && item.desc === b[index]?.desc,
)
);
}
function getIdSet<TColumnId extends string>(
ids?: readonly TColumnId[] | Set<TColumnId>,
) {
if (!ids) return null;
return ids instanceof Set ? ids : new Set(ids);
}
function getHasKnownIds<
TColumnItem extends { id: string },
TColumnId extends string,
>(
items: TColumnItem[],
ids: ReadonlySet<TColumnId> | null,
): items is (TColumnItem & { id: TColumnId })[] {
const knownIds: ReadonlySet<string> | null = ids;
return knownIds === null || items.every((item) => knownIds.has(item.id));
}
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,
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(),
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)
);
}