Data Table Filters
blockdata-tablesource: ok · refreshed
Data Table with Filters
Data table with search, checkbox, slider, date range filters, sorting, infinite scroll, virtualization, and in-memory state management.
npx shadcn add "https://logs.run/r/data-table.json"free
Where it goes
statsdata-table
Interaction
animated-countercarouselinfinite-scroll
Use cases
dashboard
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lightui-primitives@radix-ui/react-slider@^1.2.2@radix-ui/react-visually-hidden@^1.1.1@radix-ui/react-slot@^1.1.1@radix-ui/react-accordion@^1.2.2
Registry dependencies
buttoninputinput-groupkbdtablecheckboxtooltipdropdown-menuseparatorlabelcommandpopoverskeletondrawercalendarselect
Files63 · source cached 10/2/2026, 1:53:11 AM
src/components/data-table/types.ts
import type { SerializableDisplayConfig } from "@/lib/table-schema/types";
import type { JSX } from "react";
export type SearchParams = {
[key: string]: string | string[] | undefined;
};
export type DatePreset = {
label: string;
from: Date;
to: Date;
shortcut: string;
};
// TODO: we could type the value(!) especially when using enums
export type Option = {
label: string;
value: string | boolean | number | undefined;
};
export type Input = {
type: "input";
options?: Option[];
};
/**
* What a checkbox filter hands its option component: the option, plus the
* field's `options` as they are at render time — a field filled from facets
* only has them then — for anything that depends on the siblings, like
* alignment. It is the whole list, not the rows the search box leaves
* visible, so what depends on it holds still while the user types.
*/
export type CheckboxOptionProps = Option & { options?: Option[] };
export type Checkbox = {
type: "checkbox";
component?: (props: CheckboxOptionProps) => JSX.Element | null;
options?: Option[];
};
export type Slider = {
type: "slider";
min: number;
max: number;
// if options is undefined, faceted unique values from the data are used in the command
options?: Option[];
unit?: string;
};
export type Timerange = {
type: "timerange";
options?: Option[]; // required for TS
presets?: DatePreset[];
};
export type Base<TData> = {
label: string;
value: keyof TData;
/**
* Defines if the accordion in the filter bar is open by default
*/
defaultOpen?: boolean;
/**
* Defines if the command input is disabled for this field
*/
commandDisabled?: boolean;
};
export type DataTableCheckboxFilterField<TData> = Base<TData> & Checkbox;
export type DataTableSliderFilterField<TData> = Base<TData> & Slider;
export type DataTableInputFilterField<TData> = Base<TData> & Input;
export type DataTableTimerangeFilterField<TData> = Base<TData> & Timerange;
export type DataTableFilterField<TData> =
| DataTableCheckboxFilterField<TData>
| DataTableSliderFilterField<TData>
| DataTableInputFilterField<TData>
| DataTableTimerangeFilterField<TData>;
/** ----------------------------------------- */
export type SheetField<TData, TMeta = Record<string, unknown>> = {
id: keyof TData;
label: string;
// FIXME: rethink that! I dont think we need this as there is no input type
// REMINDER: readonly if we only want to copy the value (e.g. uuid)
// TODO: we might have some values that are not in the data but can be computed
type: "readonly" | "input" | "checkbox" | "slider" | "timerange";
display?: SerializableDisplayConfig;
component?: (
// REMINDER: this is used to pass additional data like the `InfiniteQueryMeta`
props: TData & {
metadata?: {
totalRows: number;
filterRows: number;
totalRowsFetched: number;
} & TMeta;
},
) => JSX.Element | null | string;
condition?: (props: TData) => boolean;
className?: string;
skeletonClassName?: string;
};
src/components/data-table/utils.ts
// TODO: check if we can move to /data-table-filter-command/utils.ts
import {
ARRAY_DELIMITER,
RANGE_DELIMITER,
SLIDER_DELIMITER,
} from "@/lib/delimiters";
import type { ColumnFiltersState } from "@tanstack/react-table";
import type { CSSProperties } from "react";
import { z } from "zod";
import type { DataTableFilterField } from "./types";
export function deserialize<T extends z.ZodObject>(schema: T) {
const castToSchema = z.preprocess((val) => {
if (typeof val !== "string") return val;
return val
.trim()
.split(" ")
.reduce(
(prev, curr) => {
// Split on the FIRST colon only — values legitimately contain colons
// (urls, timestamps), and `split(":")` would truncate them.
const separatorIndex = curr.indexOf(":");
// -1 = no separator, 0 = empty name; both are skipped
if (separatorIndex < 1) return prev;
const name = curr.slice(0, separatorIndex);
const value = curr.slice(separatorIndex + 1);
if (!value) return prev;
prev[name] = value;
return prev;
},
{} as Record<string, unknown>,
);
}, schema);
return (value: string) => castToSchema.safeParse(value);
}
export function serializeColumnFilters<TData>(
columnFilters: ColumnFiltersState,
filterFields?: DataTableFilterField<TData>[],
) {
return columnFilters.reduce((prev, curr) => {
const { type, commandDisabled } = filterFields?.find(
(field) => curr.id === field.value,
) || { commandDisabled: true }; // if column filter is not found, disable the command by default
if (commandDisabled) return prev;
if (Array.isArray(curr.value)) {
if (type === "slider") {
return `${prev}${curr.id}:${curr.value.join(SLIDER_DELIMITER)} `;
}
if (type === "checkbox") {
return `${prev}${curr.id}:${curr.value.join(ARRAY_DELIMITER)} `;
}
if (type === "timerange") {
return `${prev}${curr.id}:${curr.value.join(RANGE_DELIMITER)} `;
}
}
return `${prev}${curr.id}:${curr.value} `;
}, "");
}
/**
* Whether another `fetchNextPage()` would actually return rows.
*
* `hasNextPage` alone is not enough for cursor pagination as this table's API
* implements it: the response reports `nextCursor: null` only once a page comes
* back *empty*, so after the final row React Query still believes there is a
* next page. Left to `hasNextPage`, the button keeps offering "Load More" until
* one wasted click fetches nothing and flips it to "No more data to load".
*
* When the server reports how many rows match the active filters, that count is
* authoritative and lets us stop one page early. Consumers that do not pass
* `filterRows` keep the plain `hasNextPage` behaviour.
*/
export function canLoadMore({
hasNextPage,
filterRows,
totalRowsFetched,
}: {
hasNextPage?: boolean;
filterRows?: number;
totalRowsFetched?: number;
}): boolean {
if (!hasNextPage) return false;
if (filterRows === undefined || totalRowsFetched === undefined) return true;
return totalRowsFetched < filterRows;
}
/**
* The sizing bounds TanStack merges into every column def
* (`getDefaultColumnSizingColumnDef` in table-core, not re-exported).
*/
const TANSTACK_SIZE_DEFAULTS = {
minSize: 20,
maxSize: Number.MAX_SAFE_INTEGER,
};
/**
* Derive a header/cell width style from the column's sizing mode:
*
* - resizable → track the measured size var (`clamp` is `"min"` on headers so
* a drag can grow past the content, `"max"` on cells so `truncate` kicks in)
* - locked (`maxSize` on the def) → pin the var as width, min and max
* - floor only (`minSize` without `maxSize`) → flex, but never below the floor
* - unsized → flex freely
*
* TanStack merges its own defaults (`minSize: 20`, `maxSize: MAX_SAFE_INTEGER`)
* into every `columnDef`, so "not set" has to be read as "still the default".
* Reading presence alone locked every column, and a table narrower than its
* container then spread the surplus over all of them instead of letting the
* one unsized column absorb it.
*/
export function columnSizeStyle(
column: {
getCanResize: () => boolean;
columnDef: { minSize?: number; maxSize?: number };
},
sizeVar: string,
clamp: "min" | "max",
): CSSProperties | undefined {
const width = `var(${sizeVar})`;
if (column.getCanResize()) {
return clamp === "min"
? { width, minWidth: width }
: { width, maxWidth: width };
}
const { minSize, maxSize } = column.columnDef;
const hasMax =
maxSize !== undefined && maxSize !== TANSTACK_SIZE_DEFAULTS.maxSize;
const hasMin =
minSize !== undefined && minSize !== TANSTACK_SIZE_DEFAULTS.minSize;
if (hasMax) return { width, minWidth: width, maxWidth: width };
if (hasMin) return { minWidth: width };
return undefined;
}
src/components/data-table/ui-compat.ts
/**
* Props that are spelled differently on Radix and on Base UI.
*
* The blocks resolve `ui/tooltip`, `ui/hover-card` and friends from whichever
* library the project was initialized with — `shadcn init -d` gives Base UI,
* `init -b radix` gives Radix — so a literal prop that only one of them
* accepts fails to typecheck on the other. Spreading a shared object sets the
* name the installed primitive reads and leaves the other one inert: a JSX
* spread is not excess-property checked, and these roots are context providers
* that render no DOM element, so the unused key never reaches the page.
*
* Delete the key you don't need if you only ever install on one library.
*/
/**
* Radix: `delayDuration`. Base UI: `delay`.
*
* Spread this on the `TooltipProvider`, never on the tooltip itself: Radix
* reads its spelling on either, but Base UI's tooltip root has no delay prop
* at all, so on the tooltip this would silently apply to Radix only.
*/
export const TOOLTIP_DELAY = { delayDuration: 100, delay: 100 };
/**
* Radix: `disableHoverableContent`. Base UI: `disableHoverablePopup`.
*
* Both belong on the tooltip root. Radix also accepts its spelling on the
* provider; Base UI does not, which is why this is separate from the delay.
*/
export const TOOLTIP_NOT_HOVERABLE = {
disableHoverableContent: true,
disableHoverablePopup: true,
};
/**
* Radix: `openDelay` / `closeDelay`.
*
* Base UI's preview card — what its `hover-card` is built on — takes neither,
* so these apply on Radix and Base UI uses its own defaults.
*/
export const HOVER_CARD_DELAY = { openDelay: 0, closeDelay: 0 };
src/components/data-table/data-table-provider.tsx
import { DataTableFilterField } from "@/components/data-table/types";
import type { DataTableFeatures } from "@/lib/table/features";
import { ControlsProvider } from "@/providers/controls";
import type {
ColumnDef,
ColumnFiltersState,
ColumnVisibilityState,
PaginationState,
ReactTable,
RowData,
RowSelectionState,
SortingState,
Table,
} from "@tanstack/react-table";
import { createContext, useContext, useMemo } from "react";
import { DataTableStoreSync } from "./data-table-store-sync";
// REMINDER: read about how to move controlled state out of the useTable hook
// https://github.com/TanStack/table/discussions/4005#discussioncomment-7303569
interface DataTableStateContextType {
columnFilters: ColumnFiltersState;
sorting: SortingState;
rowSelection: RowSelectionState;
columnOrder: string[];
columnVisibility: ColumnVisibilityState;
pagination: PaginationState;
enableColumnOrdering: boolean;
}
/**
* `TFeatures` is pinned to `DataTableFeatures` rather than left generic.
*
* v9 declares it `in out` — invariant — so a component generic over `TFeatures`
* cannot resolve any feature API (TypeScript cannot evaluate the feature-map
* lookup behind an unresolved type parameter), and `Table<any, TData>` is not a
* supertype it could fall back to. Pinning is the only typing that works, and
* it costs nothing here: `features.ts` ships as part of the block, so a
* consumer who registers extra features edits that one file and every type
* below follows through `typeof dataTableFeatures`.
*/
interface DataTableBaseContextType<
TData extends RowData = RowData,
TValue = unknown,
> {
// `ReactTable`, not the core `Table`: `table.state` (v9's replacement for
// `getState()`), `table.Subscribe` and `table.FlexRender` are added by
// `useTable` and consumers of this context read them.
table: ReactTable<DataTableFeatures, TData>;
filterFields: DataTableFilterField<TData>[];
columns: ColumnDef<DataTableFeatures, TData, TValue>[];
isLoading?: boolean;
totalRows?: number;
filterRows?: number;
getFacetedUniqueValues?: (
table: Table<DataTableFeatures, TData>,
columnId: string,
) => Map<string, number>;
getFacetedMinMaxValues?: (
table: Table<DataTableFeatures, TData>,
columnId: string,
) => undefined | [number, number];
}
interface DataTableContextType<
TData extends RowData = RowData,
TValue = unknown,
> extends DataTableStateContextType,
DataTableBaseContextType<TData, TValue> {}
export const DataTableContext = createContext<DataTableContextType<
any,
any
> | null>(null);
export function DataTableProvider<TData extends RowData, TValue>({
children,
...props
}: Partial<DataTableStateContextType> &
DataTableBaseContextType<TData, TValue> & {
children: React.ReactNode;
}) {
const value = useMemo(
// eslint-disable-next-line react-hooks/preserve-manual-memoization
() => ({
...props,
columnFilters: props.columnFilters ?? [],
sorting: props.sorting ?? [],
rowSelection: props.rowSelection ?? {},
columnOrder: props.columnOrder ?? [],
columnVisibility: props.columnVisibility ?? {},
pagination: props.pagination ?? { pageIndex: 0, pageSize: 10 },
enableColumnOrdering: props.enableColumnOrdering ?? false,
}),
[
props.columnFilters,
props.sorting,
props.rowSelection,
props.columnOrder,
props.columnVisibility,
props.pagination,
props.table,
props.filterFields,
props.columns,
props.enableColumnOrdering,
props.isLoading,
props.totalRows,
props.filterRows,
props.getFacetedUniqueValues,
props.getFacetedMinMaxValues,
],
);
return (
// `TData` is invariant in v9, so a concrete `DataTableContextType<TData>`
// is not assignable to the erased `<any, any>` the context is declared
// with — even though `any` is involved. React contexts cannot be generic,
// so the erasure happens here and `useDataTable` casts it back.
<DataTableContext.Provider value={value as DataTableContextType<any, any>}>
<ControlsProvider>
<DataTableStoreSync />
{children}
</ControlsProvider>
</DataTableContext.Provider>
);
}
export function useDataTable<TData extends RowData, TValue>() {
const context = useContext(DataTableContext);
if (!context) {
throw new Error("useDataTable must be used within a DataTableProvider");
}
return context as DataTableContextType<TData, TValue>;
}
src/components/data-table/data-table-store-sync.tsx
"use client";
/**
* DataTableStoreSync - Syncs React Table state to/from BYOS adapter
*
* On initial mount: seeds the table's columnFilters from BYOS state (BYOS → Table).
* This handles the case where URL params are available in BYOS but useState missed
* them during SSR/hydration (e.g. nuqs reads URL after initial render).
*
* After mount: syncs changes from React Table's state (columnFilters, sorting,
* rowSelection) to the BYOS adapter (Table → BYOS). Filter components update
* the table directly via column.setFilterValue(), and this component propagates
* those changes to the BYOS adapter for URL sync (nuqs) or state persistence.
*/
import { useStoreContext } from "@/lib/store/context";
import { useFilterActions } from "@/lib/store/hooks/useFilterActions";
import { isStateEqual } from "@/lib/store/schema";
import { useEffect, useRef } from "react";
import { useDataTable } from "./data-table-provider";
export function DataTableStoreSync() {
useDataTableStoreSync();
return null;
}
/**
* Hook version for more control - syncs filters, sorting, and selection
*/
export function useDataTableStoreSync() {
const context = useStoreContext();
const { table, filterFields, sorting, rowSelection } = useDataTable();
const { setFilters } = useFilterActions();
const lastSentFiltersRef = useRef<Record<string, unknown>>({});
const lastSentSortRef = useRef<{ id: string; desc: boolean } | null>(null);
const lastSentUuidRef = useRef<string>("");
const isInitialMount = useRef(true);
const columnFilters = table.state.columnFilters;
// Sync column filters
useEffect(() => {
if (!context) return;
if (isInitialMount.current) {
isInitialMount.current = false;
const filterFieldKeys = new Set(
filterFields?.map((f) => f.value as string) || [],
);
const currentFilters: Record<string, unknown> = {};
for (const filter of columnFilters) {
if (filterFieldKeys.has(filter.id)) {
currentFilters[filter.id] = filter.value;
}
}
// Seed table from BYOS on initial mount (handles URL hydration where
// useState(defaultColumnFilters) missed the URL-derived values)
const byosState = context.adapter.getSnapshot().state as Record<
string,
unknown
>;
for (const key of filterFieldKeys) {
if (key in currentFilters) continue;
const byosValue = byosState[key];
if (byosValue == null) continue;
if (Array.isArray(byosValue) && byosValue.length === 0) continue;
table.getColumn(key)?.setFilterValue(byosValue);
currentFilters[key] = byosValue;
}
lastSentFiltersRef.current = { ...currentFilters };
lastSentSortRef.current = sorting?.[0] || null;
const selectedKeys = Object.keys(rowSelection || {});
lastSentUuidRef.current = selectedKeys.length > 0 ? selectedKeys[0] : "";
return;
}
const filterFieldKeys = new Set(
filterFields?.map((f) => f.value as string) || [],
);
const currentFilters: Record<string, unknown> = {};
for (const filter of columnFilters) {
if (filterFieldKeys.has(filter.id)) {
currentFilters[filter.id] = filter.value;
}
}
if (isStateEqual(currentFilters, lastSentFiltersRef.current)) {
return;
}
const updates: Record<string, unknown> = { ...currentFilters };
// Only null out keys that we previously sent — never clear BYOS filters
// that were set externally (e.g. from URL) and not managed by this sync.
for (const key of Object.keys(lastSentFiltersRef.current)) {
if (!(key in currentFilters)) {
updates[key] = null;
}
}
lastSentFiltersRef.current = { ...currentFilters };
setFilters(updates);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columnFilters, context, filterFields, setFilters]);
// Sync sorting
useEffect(() => {
if (!context) return;
if (isInitialMount.current) return;
const newSort = sorting?.[0] || null;
const lastSort = lastSentSortRef.current;
if (newSort?.id === lastSort?.id && newSort?.desc === lastSort?.desc) {
return;
}
lastSentSortRef.current = newSort;
setFilters({ sort: newSort });
}, [sorting, context, setFilters]);
// Sync row selection (single-select only — skip when multi-row selection is
// enabled because rowSelection is used for checkbox bulk actions, not sheet)
useEffect(() => {
if (!context) return;
if (isInitialMount.current) return;
if (table.options.enableMultiRowSelection) return;
const selectedKeys = Object.keys(rowSelection || {});
const newUuid = selectedKeys.length > 0 ? selectedKeys[0] : null;
const newUuidStr = newUuid || "";
if (newUuidStr === lastSentUuidRef.current) {
return;
}
lastSentUuidRef.current = newUuidStr;
setFilters({ uuid: newUuid });
}, [
rowSelection,
context,
setFilters,
table.options.enableMultiRowSelection,
]);
}
src/components/data-table/data-table-infinite.tsx
"use client";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/custom/table";
import { DataTableFilterControls } from "@/components/data-table/data-table-filter-controls";
import { DataTableFilterRail } from "@/components/data-table/data-table-filter-rail";
import { DataTableProvider } from "@/components/data-table/data-table-provider";
import { DataTableResetButton } from "@/components/data-table/data-table-reset-button";
import { DataTableToolbar } from "@/components/data-table/data-table-toolbar"; // TODO: check where to put this
import type { DataTableFilterField } from "@/components/data-table/types";
import { Button } from "@/components/ui/button";
import { useHotKey } from "@/hooks/use-hot-key";
import { useLocalStorage } from "@/hooks/use-local-storage";
import {
getColumnOrderKey,
getColumnVisibilityKey,
} from "@/lib/constants/local-storage";
import { formatCompactNumber } from "@/lib/format";
import { useFilterActions } from "@/lib/store/hooks/useFilterActions";
import { useFilterState } from "@/lib/store/hooks/useFilterState";
import {
dataTableFeatures,
type DataTableFeatures,
} from "@/lib/table/features";
import { cn } from "@/lib/utils";
import { useControls } from "@/providers/controls";
import {
type FetchNextPageOptions,
type FetchPreviousPageOptions,
} from "@tanstack/react-query";
import type {
ColumnDef,
ColumnFiltersState,
ColumnVisibilityState,
Row,
RowData,
RowSelectionState,
SortingState,
TableOptions,
Table as TTable,
} from "@tanstack/react-table";
import { flexRender, Subscribe, useTable } from "@tanstack/react-table";
import { LoaderCircle } from "lucide-react";
import * as React from "react";
import { canLoadMore, columnSizeStyle } from "./utils";
// TODO: add a possible chartGroupBy
/**
* BREAKING (v9): the `TValue` type parameter is gone.
*
* It forced every column in the array to share one cell-value type, which was
* never true. v9 makes per-column `TValue` explicit (`ColumnDef<TFeatures,
* TData, TValue>`, and `columnHelper.columns([...])` to preserve it), and the
* `columns` table option is typed against the default `CellData`. Every call
* site in this repo passed `unknown` anyway.
*/
export interface DataTableInfiniteProps<TData extends RowData> {
columns: ColumnDef<DataTableFeatures, TData>[];
/**
* Extra classes for each row, re-evaluated whenever this callback's identity
* changes — that is how live mode dims the rows behind it.
*
* Keep it stable (the React Compiler, or a `useCallback`): it is compared by
* the row memo, so a fresh arrow per render re-renders every row on screen.
*/
getRowClassName?: (row: Row<DataTableFeatures, TData>) => string;
// REMINDER: make sure to pass the correct id to access the rows
getRowId?: TableOptions<DataTableFeatures, TData>["getRowId"];
data: TData[];
defaultColumnFilters?: ColumnFiltersState;
defaultColumnSorting?: SortingState;
defaultRowSelection?: RowSelectionState;
defaultColumnVisibility?: ColumnVisibilityState;
filterFields?: DataTableFilterField<TData>[];
// REMINDER: close to the same signature as the `facetedUniqueValues` slot on
// `tableFeatures()`, but resolved rather than curried
getFacetedUniqueValues?: (
table: TTable<DataTableFeatures, TData>,
columnId: string,
) => Map<string, number>;
getFacetedMinMaxValues?: (
table: TTable<DataTableFeatures, TData>,
columnId: string,
) => undefined | [number, number];
totalRows?: number;
/**
* How many rows match the active filters, as reported by the server.
*
* Must be authoritative relative to what `fetchNextPage` can actually return:
* both the "Load More" button (`canLoadMore`) and the scroll auto-load stop
* once `totalRowsFetched` reaches it. A value that under-reports the real
* count makes the remaining rows unreachable, because there is no other way
* to trigger a fetch. Leave it undefined rather than pass a guess — the
* button then falls back to `hasNextPage` alone.
*/
filterRows?: number;
totalRowsFetched?: number;
isFetching?: boolean;
isLoading?: boolean;
hasNextPage?: boolean;
fetchNextPage: (
options?: FetchNextPageOptions | undefined,
) => Promise<unknown>;
fetchPreviousPage?: (
options?: FetchPreviousPageOptions | undefined,
) => Promise<unknown>;
renderLiveRow?: (props?: {
row: Row<DataTableFeatures, TData>;
}) => React.ReactNode;
// Used to store column order and visibility in local storage for specific data-table namespace
tableId?: string;
// Optional slots for extensibility
commandSlot?: React.ReactNode;
sheetSlot?: React.ReactNode;
toolbarActions?: React.ReactNode;
chartSlot?: React.ReactNode;
footerSlot?: React.ReactNode;
floatingBarSlot?: React.ReactNode;
className?: string;
}
export function DataTableInfinite<TData extends RowData>({
columns,
getRowClassName,
getRowId,
data,
defaultColumnFilters = [],
defaultColumnSorting = [],
defaultRowSelection = {},
defaultColumnVisibility = {},
filterFields = [],
isFetching,
isLoading,
fetchNextPage,
hasNextPage,
fetchPreviousPage,
totalRows,
filterRows,
totalRowsFetched = 0,
getFacetedUniqueValues,
getFacetedMinMaxValues,
renderLiveRow,
tableId = "infinite",
commandSlot,
sheetSlot,
toolbarActions,
chartSlot,
footerSlot,
floatingBarSlot,
className,
}: DataTableInfiniteProps<TData>) {
const [columnFilters, setColumnFilters] =
React.useState<ColumnFiltersState>(defaultColumnFilters);
const [sorting, setSorting] =
React.useState<SortingState>(defaultColumnSorting);
const [rowSelection, setRowSelection] =
React.useState<RowSelectionState>(defaultRowSelection);
const [columnOrder, setColumnOrder] = useLocalStorage<string[]>(
getColumnOrderKey(tableId),
[],
);
const [columnVisibility, setColumnVisibility] =
useLocalStorage<ColumnVisibilityState>(
getColumnVisibilityKey(tableId),
defaultColumnVisibility,
);
const tableRef = React.useRef<HTMLTableElement>(null);
// Detect if a select column exists to enable multi-row selection
const hasSelectColumn = React.useMemo(
() => columns.some((col) => col.meta?.kind === "select"),
[columns],
);
const onScroll = React.useCallback(
(e: React.UIEvent<HTMLElement>) => {
const onPageBottom =
Math.ceil(e.currentTarget.scrollTop + e.currentTarget.clientHeight) >=
e.currentTarget.scrollHeight;
if (onPageBottom && !isFetching && totalRowsFetched < (filterRows ?? 0)) {
fetchNextPage();
}
},
[fetchNextPage, isFetching, filterRows, totalRowsFetched],
);
// See `canLoadMore` — `hasNextPage` stays true past the final row because the
// API only signals the end with an empty page.
const hasMoreToLoad = canLoadMore({
hasNextPage,
filterRows,
totalRowsFetched,
});
/**
* BREAKING (v9): the options object has to be memoized by the caller.
*
* `useTable` ends with `useMemo(() => ({ ...table, options, state }), [table,
* options, state])`, so an inline literal hands back a *new* table object on
* every render — v8's `useReactTable` returned one stable instance. That
* identity is what `DataTableProvider` memoizes its context value on, so an
* unmemoized literal re-renders every `useDataTable()` consumer (toolbar,
* filter controls, command, sheet, floating bar) and re-runs the
* `useReactTableSync` effect on each ResizeObserver tick, `isFetching` flip
* and live poll.
*/
const tableOptions = React.useMemo(
() => ({
// Row models, filter fns and sort fns all live on the feature set in v9 —
// see `lib/table/features.ts`.
features: dataTableFeatures,
// This table paginates on the server (cursor-based, via `fetchNextPage`),
// so client pagination must be off. It is not optional: the shared feature
// set registers `paginatedRowModel` for the paginated blocks, and v9
// routes `getRowModel()` through it whenever that slot exists and this
// flag is falsy — silently truncating every page to the default pageSize
// of 10.
manualPagination: true,
data,
columns,
state: {
columnFilters,
sorting,
columnVisibility,
rowSelection,
columnOrder,
},
enableMultiRowSelection: hasSelectColumn,
columnResizeMode: "onChange" as const,
getRowId,
onColumnVisibilityChange: setColumnVisibility,
onColumnFiltersChange: setColumnFilters,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnOrderChange: setColumnOrder,
debugAll: process.env.NEXT_PUBLIC_TABLE_DEBUG === "true",
// Kept on `meta` for consumers that declared it in `react-table.d.ts`;
// `Row` reads the prop, not this — see the reminder on that prop.
meta: { getRowClassName },
}),
[
data,
columns,
columnFilters,
sorting,
columnVisibility,
rowSelection,
columnOrder,
hasSelectColumn,
getRowId,
setColumnVisibility,
setColumnFilters,
setRowSelection,
setSorting,
setColumnOrder,
getRowClassName,
],
);
const table = useTable(tableOptions);
// Clear multi-select when filters or sorting change
React.useEffect(() => {
if (hasSelectColumn) {
setRowSelection({});
}
}, [columnFilters, sorting, hasSelectColumn, setRowSelection]);
// NOTE: Filter, sort, and selection syncing is now handled by DataTableStoreSync
/**
* https://tanstack.com/table/latest/docs/guide/column-sizing#advanced-column-resizing-performance
* Instead of calling `column.getSize()` on every render for every header
* and especially every data cell (very expensive),
* we will calculate all column sizes at once at the root table level in a useMemo
* and pass the column sizes down as CSS variables to the <table> element.
*/
const columnSizeVars = React.useMemo(() => {
const headers = table.getFlatHeaders();
const colSizes: { [key: string]: string } = {};
for (let i = 0; i < headers.length; i++) {
const header = headers[i]!;
// REMINDER: replace "." with "-" to avoid invalid CSS variable name (e.g. "timing.dns" -> "timing-dns")
colSizes[`--header-${header.id.replaceAll(".", "-")}-size`] =
`${header.getSize()}px`;
colSizes[`--col-${header.column.id.replaceAll(".", "-")}-size`] =
`${header.column.getSize()}px`;
}
return colSizes;
}, [
table.state.columnResizing,
table.state.columnSizing,
table.state.columnVisibility,
]);
useHotKey(() => {
setColumnOrder([]);
setColumnVisibility(defaultColumnVisibility);
}, "u");
const { setFilters } = useFilterActions();
/**
* REMINDER: the detail row id is deliberately *not* read here.
* Subscribing to it at this level re-rendered the entire table on every
* detail click, and — because the callback closed over it — handed every
* `MemoizedRow` a new `onRowClick`, so all rows re-rendered too. Each row
* reads its own detail state instead and passes it back in, which keeps this
* callback stable across selections.
*/
const onRowClick = React.useCallback(
(row: Row<DataTableFeatures, TData>, isDetail: boolean) => {
if (hasSelectColumn) {
setFilters({ uuid: isDetail ? null : row.id });
} else {
row.toggleSelected();
}
},
[hasSelectColumn, setFilters],
);
return (
<DataTableProvider
table={table}
columns={columns}
filterFields={filterFields}
columnFilters={columnFilters}
sorting={sorting}
rowSelection={rowSelection}
columnOrder={columnOrder}
columnVisibility={columnVisibility}
enableColumnOrdering={true}
isLoading={isFetching || isLoading}
totalRows={totalRows}
// Verbatim, no client-side stand-in: consumers that only display a
// number fall back themselves (`DataTableToolbar`), and anything that
// promises it to the server must not be handed the loaded-row count as
// if it were the match count.
filterRows={filterRows}
getFacetedUniqueValues={getFacetedUniqueValues}
getFacetedMinMaxValues={getFacetedMinMaxValues}
>
{/*
The viewport is the frame: the sidebar and the main column are both
exactly one screen tall, and the only things that scroll are the filter
list and the table container. Nothing here depends on a measured
height, so the page itself can never grow past the viewport.
*/}
<div
className={cn(
"flex h-screen w-full flex-col sm:flex-row",
// Single source of truth for the panel width: the panel, its inner
// wrapper and the main pane's `max-width` all read this variable so
// they animate in lockstep.
"sm:[--controls-width:13rem] md:[--controls-width:18rem]",
)}
style={columnSizeVars as React.CSSProperties}
>
<FilterPanel>
<div className="border-border bg-background shrink-0 border-b p-2">
<div className="flex h-[46px] items-center justify-between gap-3">
<p className="text-foreground px-2 font-medium">Filters</p>
<div>
{table.state.columnFilters.length ? (
<DataTableResetButton />
) : null}
</div>
</div>
</div>
{/* REMINDER: no top padding - it would offset the first filter row */}
<div className="min-h-0 flex-1 px-2 pb-2 sm:overflow-y-scroll">
<DataTableFilterControls />
</div>
{footerSlot ? (
<div className="border-border bg-background shrink-0 border-t p-4">
{footerSlot}
</div>
) : null}
</FilterPanel>
<div
className={cn(
"border-border relative flex h-full max-w-full flex-1 flex-col",
// Collapsed, the panel is w-0 and the border would sit on the
// viewport edge. Keep the 1px so nothing shifts, drop the colour.
"sm:border-l sm:border-l-transparent",
"sm:group-data-[expanded=true]/controls:border-l-border",
// Chrome issue
"sm:group-data-[expanded=true]/controls:max-w-[calc(100%-var(--controls-width))]",
// Matches the panel's own transition: without it the pane snaps to
// its final width while the panel is still sliding.
"transition-[max-width,border-color] duration-200 ease-linear motion-reduce:transition-none",
)}
>
<DataTableFilterRail />
<div
className={cn(
"bg-background flex shrink-0 flex-col gap-4 p-2 pb-4",
// Above the table so the command palette's popover overlays it.
"relative z-10",
)}
>
{commandSlot}
{/* TBD: better flexibility with compound components? */}
<DataTableToolbar
renderActions={toolbarActions ? () => toolbarActions : undefined}
/>
{chartSlot}
</div>
{/* `min-h-0` lets the flex item shrink so the table container scrolls instead of the page. */}
<div className="z-0 min-h-0 flex-1">
<Table
ref={tableRef}
onScroll={onScroll}
// REMINDER: https://stackoverflow.com/questions/50361698/border-style-do-not-work-with-sticky-position-element
className="border-separate border-spacing-0"
containerClassName="max-h-full"
>
<TableHeader className={cn("bg-background sticky top-0 z-20")}>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow
key={headerGroup.id}
className={cn(
"bg-muted/50 hover:bg-muted/50",
"*:border-t [&>:not(:last-child)]:border-r",
)}
>
{headerGroup.headers.map((header) => {
return (
<TableHead
key={header.id}
style={columnSizeStyle(
header.column,
`--header-${header.id.replaceAll(".", "-")}-size`,
"min",
)}
className={cn(
"border-border relative truncate border-b select-none last:[&>.cursor-col-resize]:opacity-0",
header.column.columnDef.meta?.headerClassName,
)}
aria-sort={
header.column.getIsSorted() === "asc"
? "ascending"
: header.column.getIsSorted() === "desc"
? "descending"
: "none"
}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
{header.column.getCanResize() && (
<div
onDoubleClick={() => header.column.resetSize()}
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={cn(
"user-select-none absolute top-0 -right-2 z-10 flex h-full w-4 cursor-col-resize touch-none justify-center",
"before:bg-border before:absolute before:inset-y-0 before:w-px before:translate-x-px",
)}
/>
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody
id="content"
tabIndex={-1}
className="outline-primary outline-1 -outline-offset-1 transition-colors outline-none focus-visible:outline-solid"
// REMINDER: keeps the first rows out from under the sticky
// header when "skip to content" focuses the body
style={{ scrollMarginTop: "40px" }}
>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
// REMINDER: if we want to add arrow navigation https://github.com/TanStack/table/discussions/2752#discussioncomment-192558
<React.Fragment key={row.id}>
{renderLiveRow?.({ row })}
<MemoizedRow
row={row}
selected={row.getIsSelected()}
isMultiSelect={hasSelectColumn}
onRowClick={onRowClick}
getRowClassName={getRowClassName}
/>
</React.Fragment>
))
) : (
<React.Fragment>
{renderLiveRow?.()}
<TableRow>
<TableCell
colSpan={columns.length}
className="h-24 text-center"
>
No results.
</TableCell>
</TableRow>
</React.Fragment>
)}
<TableRow className="hover:bg-transparent data-[state=selected]:bg-transparent">
<TableCell colSpan={columns.length} className="text-center">
{hasMoreToLoad || isFetching || isLoading ? (
<Button
disabled={isFetching || isLoading}
onClick={() => fetchNextPage()}
variant="outline"
>
{isFetching ? (
<LoaderCircle className="mr-2 h-4 w-4 animate-spin" />
) : null}
Load More
</Button>
) : (
<p className="text-muted-foreground text-sm">
No more data to load (
<span className="font-mono font-medium">
{formatCompactNumber(filterRows ?? 0)}
</span>{" "}
of{" "}
<span className="font-mono font-medium">
{formatCompactNumber(totalRows ?? 0)}
</span>{" "}
rows)
</p>
)}
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
</div>
</div>
{sheetSlot}
{floatingBarSlot}
</DataTableProvider>
);
}
/**
* The filter controls column. Collapsing animates `width` — `display` can't be
* transitioned — so the inner wrapper holds its own width and the content
* slides out of view instead of reflowing on every frame. Mirrors the shadcn
* sidebar's `transition-[width] duration-200 ease-linear`.
*/
function FilterPanel({ children }: { children: React.ReactNode }) {
const { open } = useControls();
return (
<div
// `hidden sm:flex` is the breakpoint only; the collapsed state is `w-0`,
// which leaves the filters focusable — hence `inert`.
inert={!open}
className={cn(
"h-full w-full flex-col sm:w-(--controls-width) sm:shrink-0",
"sm:group-data-[expanded=false]/controls:w-0",
"hidden overflow-hidden transition-[width] duration-200 ease-linear sm:flex",
"motion-reduce:transition-none",
)}
>
<div className="flex h-full w-full flex-col sm:w-(--controls-width)">
{children}
</div>
</div>
);
}
/**
* REMINDER: this is the heaviest component in the table if lots of rows
* Some other components are rendered more often necessary, but are fixed size (not like rows that can grow in height)
* e.g. DataTableFilterControls, DataTableFilterCommand, DataTableToolbar, DataTableHeader
*/
function Row<TData extends RowData>({
row,
selected,
isMultiSelect,
onRowClick,
getRowClassName,
}: {
row: Row<DataTableFeatures, TData>;
// REMINDER: row.getIsSelected(); - just for memoization
selected?: boolean;
// Whether the table renders a select column (multi-select + detail sheet)
isMultiSelect: boolean;
onRowClick: (row: Row<DataTableFeatures, TData>, isDetail: boolean) => void;
/**
* REMINDER: taken as a prop rather than read back off
* `row.table.options.meta`. Compiled, `cn(..., row.table.options.meta
* ?.getRowClassName?.(row))` caches on `row` identity alone — the callback is
* invisible to the compiler behind that method chain — so the class was
* computed once per row object and never again. This row used to subscribe to
* `live` to force the recompute; the subscription re-rendered every row on
* every toggle and changed nothing. As a prop the callback is a real
* dependency of both the compiler and the `MemoizedRow` comparator, so the
* class follows live mode and nothing else re-renders for it.
*/
getRowClassName?: (row: Row<DataTableFeatures, TData>) => string;
}) {
/**
* REMINDER: selects a boolean, not the uuid itself, so `useSyncExternalStore`
* bails out for every row whose detail state did not flip. `isMultiSelect`
* belongs *inside* the selector: in single-select mode `DataTableStoreSync`
* still writes the selected row id to `uuid`, and folding the flag in keeps
* that write from waking any row at all.
*/
const isDetail = useFilterState((s) => isMultiSelect && s.uuid === row.id);
const handleClick = React.useCallback(() => {
onRowClick(row, isDetail);
}, [onRowClick, row, isDetail]);
return (
<TableRow
id={row.id}
tabIndex={0}
// Single-select: data-state="selected" for the selected row
// Multi-select: data-detail + data-checked are independent (a row can be both)
data-state={!isMultiSelect && selected ? "selected" : undefined}
data-detail={isDetail ? "" : undefined}
data-checked={isMultiSelect && selected ? "" : undefined}
onClick={handleClick}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
handleClick();
}
}}
className={cn(
"[&>:not(:last-child)]:border-r",
"outline-primary focus-visible:bg-muted/50 outline-1 -outline-offset-1 transition-colors outline-none focus-visible:outline-solid",
"data-[state=selected]:outline-solid",
"data-detail:outline-solid",
"data-checked:bg-muted/50",
getRowClassName?.(row),
)}
>
{/*
REMINDER: the cells read table state behind method calls
(`row.getVisibleCells()` → `columnVisibility` + `columnOrder`,
`row.getIsSelected()` in the select column's checkbox), which the React
Compiler cannot see. Compiled, it caches this callback on `row` identity
and the `<Subscribe>` element on that callback, so the whole subtree is
an unchanged element that React bails out of re-rendering — no prop
change on `Row`, and no parent re-render, can reach it. Every slice the
cells render from therefore has to be selected here: `Subscribe`'s own
store subscription is the only thing that re-runs them. That is what
lets the rest of the file stay compiled instead of opting out with
"use no memo".
https://tanstack.com/table/latest/docs/framework/react/guide/react-compiler
*/}
<Subscribe
source={row.table.store}
selector={(state) => ({
columnVisibility: state.columnVisibility,
columnOrder: state.columnOrder,
// REMINDER: this row's own selection, not the whole `rowSelection`
// map — `shallow` then re-renders the rows that actually flipped
// instead of every mounted row on every checkbox click.
selected: state.rowSelection?.[row.id] ?? false,
})}
>
{() =>
row.getVisibleCells().map((cell) => (
<TableCell
key={cell.id}
style={columnSizeStyle(
cell.column,
`--col-${cell.column.id.replaceAll(".", "-")}-size`,
"max",
)}
className={cn(
"border-border truncate border-b",
cell.column.columnDef.meta?.cellClassName,
)}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))
}
</Subscribe>
</TableRow>
);
}
/**
* REMINDER: this comparator cannot be dropped in favour of the React Compiler.
* `createCoreRowModel` memoizes on `[table.options.data]`, so every
* `fetchNextPage` rebuilds the core row model and hands back a *new* `row`
* object for every already-rendered row. Comparing by `row.id` says "same row,
* same render" — a fact about this table that the compiler cannot infer, since
* it can only ever key on the identity it is given. Without it, each page fetch
* re-runs `cn()` (tailwind-merge), `getVisibleCells()` and a full reconcile for
* every row on screen, and that count only grows as you scroll.
*
* REMINDER: the id alone is not enough. A row action edits a row in place —
* same id, new contents — and comparing ids only holds the old cells on screen
* until a filter change or a reload rebuilds the table (a delete looks like it
* works, because the row leaves the list entirely). `row.original` is the
* identity of the underlying record, so a row the server changed always
* re-renders.
*
* It costs the memo nothing on the path it was written for: `fetchNextPage`
* appends a page and leaves the earlier ones referentially untouched, so
* `original` is identical for every row already on screen. A full refetch is
* the other story — react-query's `replaceEqualDeep` bails out of non-plain
* values, and one `Date` on the row (`date`, here) is enough to make it hand
* back a fresh object for every row, changed or not. So an action's
* invalidation re-renders the mounted rows once. That is the price of never
* showing a stale row, and it is paid per action, not per scroll.
*/
const MemoizedRow = React.memo(
Row,
(prev, next) =>
prev.row.id === next.row.id &&
prev.row.original === next.row.original &&
prev.selected === next.selected &&
prev.isMultiSelect === next.isMultiSelect &&
prev.onRowClick === next.onRowClick &&
// Changes when live mode is toggled — see the prop's own reminder.
prev.getRowClassName === next.getRowClassName,
) as typeof Row;
src/components/data-table/data-table-floating-bar.tsx
"use client";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useHotKey } from "@/hooks/use-hot-key";
import { boxRadiusClassName } from "@/lib/style";
import type { DataTableFeatures } from "@/lib/table/features";
import { cn } from "@/lib/utils";
import type { Row, RowData, Table as TTable } from "@tanstack/react-table";
import { X } from "lucide-react";
import * as React from "react";
import { useDataTable } from "./data-table-provider";
import { TOOLTIP_DELAY } from "./ui-compat";
interface DataTableFloatingBarProps<TData extends RowData> {
children: (props: {
rows: Row<DataTableFeatures, TData>[];
table: TTable<DataTableFeatures, TData>;
}) => React.ReactNode;
}
export function DataTableFloatingBar<TData extends RowData>({
children,
}: DataTableFloatingBarProps<TData>) {
const { table, rowSelection } = useDataTable<TData, unknown>();
const selectedRowCount = Object.keys(rowSelection).length;
const selectedRows = React.useMemo(() => {
return table.getFilteredSelectedRowModel().rows;
// rowSelection is not used directly but serves as an invalidation trigger
}, [table, rowSelection]);
useHotKey(() => table.resetRowSelection(), "x", { shift: true });
if (selectedRowCount === 0) return null;
return (
<div className="fixed inset-x-0 bottom-4 z-50 mx-auto w-fit">
<div
className={cn(
"bg-background border-border flex items-center gap-2 border px-4 py-2.5 shadow-lg",
boxRadiusClassName,
)}
>
<div className="flex items-center gap-1">
<span className="text-muted-foreground text-sm whitespace-nowrap">
{selectedRowCount} selected
</span>
<TooltipProvider {...TOOLTIP_DELAY}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon-xs"
onClick={() => table.resetRowSelection()}
className="text-muted-foreground hover:text-foreground p-0.5 transition-colors"
aria-label="Deselect all"
>
<X className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p className="text-nowrap">
Deselect all <Kbd className="ml-1">⌘ ⇧ X</Kbd>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="bg-border mr-1 h-5 w-px" />
<div className="flex items-center gap-2">
{children({ rows: selectedRows, table })}
</div>
</div>
</div>
);
}
src/components/data-table/data-table-column-header.tsx
import { Button } from "@/components/ui/button";
import type { DataTableFeatures } from "@/lib/table/features";
import { cn } from "@/lib/utils";
import type { Column, RowData } from "@tanstack/react-table";
import { ChevronDown, ChevronUp } from "lucide-react";
import type React from "react";
interface DataTableColumnHeaderProps<TData extends RowData, TValue>
extends React.ComponentProps<typeof Button> {
column: Column<DataTableFeatures, TData, TValue>;
title: string;
}
export function DataTableColumnHeader<TData extends RowData, TValue>({
column,
title,
className,
...props
}: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>;
}
return (
<Button
variant="ghost"
onClick={() => {
column.toggleSorting(undefined);
}}
className={cn(
"flex h-7 w-full items-center justify-between gap-2 px-0 py-0 hover:bg-transparent",
className,
)}
{...props}
>
<span>{title}</span>
<span className="flex flex-col">
<ChevronUp
className={cn(
"-mb-0.5 size-3",
column.getIsSorted() === "asc"
? "text-accent-foreground"
: "text-muted-foreground",
)}
/>
<ChevronDown
className={cn(
"-mt-0.5 size-3",
column.getIsSorted() === "desc"
? "text-accent-foreground"
: "text-muted-foreground",
)}
/>
</span>
</Button>
);
}
src/components/data-table/data-table-toolbar.tsx
"use client";
import { useDataTable } from "@/components/data-table/data-table-provider";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useHotKey } from "@/hooks/use-hot-key";
import { formatCompactNumber } from "@/lib/format";
import { useControls } from "@/providers/controls";
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { DataTableFilterControlsDrawer } from "./data-table-filter-controls-drawer";
import { DataTableResetButton } from "./data-table-reset-button";
import { DataTableViewOptions } from "./data-table-view-options";
import { TOOLTIP_DELAY } from "./ui-compat";
interface DataTableToolbarProps {
renderActions?: () => React.ReactNode;
}
export function DataTableToolbar({ renderActions }: DataTableToolbarProps) {
const { table, isLoading, columnFilters, totalRows, filterRows } =
useDataTable();
const { open, setOpen } = useControls();
useHotKey(() => setOpen((prev) => !prev), "b");
const rows = {
total: totalRows ?? table.getCoreRowModel().rows.length,
filtered: filterRows ?? table.getFilteredRowModel().rows.length,
};
return (
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex flex-wrap items-center gap-2">
<TooltipProvider {...TOOLTIP_DELAY}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
onClick={() => setOpen((prev) => !prev)}
className="hidden gap-2 sm:flex"
>
{open ? (
<>
<PanelLeftClose className="h-4 w-4" />
<span className="hidden md:block">Hide Controls</span>
</>
) : (
<>
<PanelLeftOpen className="h-4 w-4" />
<span className="hidden md:block">Show Controls</span>
</>
)}
</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-nowrap">
Toggle controls with{" "}
<Kbd className="text-muted-foreground group-hover:text-accent-foreground ml-1">
<span className="mr-1">⌘</span>
<span>B</span>
</Kbd>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div className="block sm:hidden">
<DataTableFilterControlsDrawer />
</div>
<div>
<p className="text-muted-foreground hidden text-sm sm:block">
<span className="font-mono font-medium">
{formatCompactNumber(rows.filtered)}
</span>{" "}
of{" "}
<span className="font-mono font-medium">
{formatCompactNumber(rows.total)}
</span>{" "}
row(s) <span className="sr-only sm:not-sr-only">filtered</span>
</p>
<p className="text-muted-foreground block text-sm sm:hidden">
<span className="font-mono font-medium">
{formatCompactNumber(rows.filtered)}
</span>{" "}
row(s)
</p>
</div>
</div>
<div className="ml-auto flex items-center gap-2">
{columnFilters.length ? <DataTableResetButton /> : null}
{renderActions?.()}
<DataTableViewOptions />
</div>
</div>
);
}
src/components/data-table/data-table-refresh-button.tsx
"use client";
import { useDataTable } from "@/components/data-table/data-table-provider";
import { Button } from "@/components/ui/button";
import { LoaderCircle, RefreshCcw } from "lucide-react";
/**
* A toolbar action that re-reads the table from the top. Pair it with
* `refreshDataTableQuery` rather than the bare `refetch`: the helper resets
* the cache to a fresh first page first, so a list extended by live mode or
* infinite scroll does not restart from a stale cursor.
*
* @example
* ```tsx
* const queryClient = useQueryClient();
* const refresh = () => refreshDataTableQuery(queryClient, dataOptions(search));
* <DataTableInfinite toolbarActions={<DataTableRefreshButton onClick={refresh} />} />
* ```
*/
export function DataTableRefreshButton({ onClick }: { onClick: () => void }) {
const { isLoading } = useDataTable();
return (
<Button
type="button"
variant="outline"
size="icon"
disabled={isLoading}
onClick={onClick}
className="shadow-none"
>
{isLoading ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<RefreshCcw className="h-4 w-4" />
)}
<span className="sr-only">Refresh data</span>
</Button>
);
}
src/components/data-table/data-table-view-options.tsx
"use client";
import {
Sortable,
SortableDragHandle,
SortableItem,
} from "@/components/custom/sortable";
import { useDataTable } from "@/components/data-table/data-table-provider";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { boxRadiusClassName } from "@/lib/style";
import { cn } from "@/lib/utils";
import { Check, GripVertical, Settings2 } from "lucide-react";
import { useMemo, useState } from "react";
export function DataTableViewOptions() {
const { table, enableColumnOrdering } = useDataTable();
const [open, setOpen] = useState(false);
const [drag, setDrag] = useState(false);
const [search, setSearch] = useState("");
const columnOrder = table.state.columnOrder;
const sortedColumns = useMemo(
() =>
table.getAllColumns().sort((a, b) => {
return columnOrder.indexOf(a.id) - columnOrder.indexOf(b.id);
}),
[columnOrder, table],
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="icon"
role="combobox"
aria-expanded={open}
className="shadow-none"
>
<Settings2 className="h-4 w-4" />
<span className="sr-only">View</span>
</Button>
</PopoverTrigger>
<PopoverContent side="bottom" align="end" className="w-[200px] p-0">
<Command>
<CommandInput
value={search}
onValueChange={setSearch}
placeholder="Search options..."
/>
<CommandList>
<CommandEmpty>No option found.</CommandEmpty>
<CommandGroup>
<Sortable
value={sortedColumns.map((c) => ({ id: c.id }))}
onValueChange={(items) =>
table.setColumnOrder(items.map((c) => c.id))
}
overlay={
<div
className={cn("bg-muted/60 h-8 w-full", boxRadiusClassName)}
/>
}
onDragStart={() => setDrag(true)}
onDragEnd={() => setDrag(false)}
onDragCancel={() => setDrag(false)}
>
{sortedColumns
// `getCanHide()` is the single source of truth: columns that
// must always render (select) declare `enableHiding: false`
// instead of being special-cased here.
.filter((column) => column.getCanHide())
.map((column) => (
<SortableItem key={column.id} value={column.id} asChild>
<CommandItem
value={column.id}
onSelect={() =>
column.toggleVisibility(!column.getIsVisible())
}
className={"capitalize"}
disabled={drag}
>
<div
className={cn(
"border-input flex h-4 w-4 items-center justify-center rounded-sm border",
column.getIsVisible()
? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible",
)}
>
<Check className={cn("text-background size-3")} />
</div>
<span>{column.columnDef.meta?.label || column.id}</span>
<span data-slot="command-shortcut" className="hidden" />
{enableColumnOrdering && !search ? (
<SortableDragHandle
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground focus:bg-muted focus:text-foreground ml-auto size-5"
>
<GripVertical
className="size-4"
aria-hidden="true"
/>
</SortableDragHandle>
) : null}
</CommandItem>
</SortableItem>
))}
</Sortable>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
src/components/data-table/data-table-reset-button.tsx
"use client";
import { useDataTable } from "@/components/data-table/data-table-provider";
import { Button } from "@/components/ui/button";
import { Kbd } from "@/components/ui/kbd";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useHotKey } from "@/hooks/use-hot-key";
import { X } from "lucide-react";
import { TOOLTIP_DELAY } from "./ui-compat";
export function DataTableResetButton() {
const { table } = useDataTable();
useHotKey(table.resetColumnFilters, "Escape");
return (
<TooltipProvider {...TOOLTIP_DELAY}>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" onClick={() => table.resetColumnFilters()}>
<X className="mr-2 h-4 w-4" />
Reset
</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p className="text-nowrap">
Reset filters with{" "}
<Kbd className="text-muted-foreground group-hover:text-accent-foreground ml-1">
<span className="mr-1">⌘</span>
<span>Esc</span>
</Kbd>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
src/components/data-table/data-table-filter-checkbox.tsx
"use client";
import { useDataTable } from "@/components/data-table/data-table-provider";
import { Checkbox } from "@/components/ui/checkbox";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { formatCompactNumber } from "@/lib/format";
import {
boxRadiusClassName,
boxSurfaceClassName,
} from "@/lib/style";
import { cn } from "@/lib/utils";
import { Search } from "lucide-react";
import { useState } from "react";
import type { DataTableCheckboxFilterField } from "./types";
export function DataTableFilterCheckbox<TData>({
value: _value,
options,
component,
}: DataTableCheckboxFilterField<TData>) {
const value = _value as string;
const [inputValue, setInputValue] = useState("");
const { table, columnFilters, isLoading, getFacetedUniqueValues } =
useDataTable();
const column = table.getColumn(value);
// REMINDER: avoid using column?.getFilterValue()
const filterValue = columnFilters.find((i) => i.id === value)?.value;
const facetedValue =
getFacetedUniqueValues?.(table, value) || column?.getFacetedUniqueValues();
const Component = component;
// filter out the options based on the input value
const filterOptions = options?.filter(
(option) =>
inputValue === "" ||
option.label.toLowerCase().includes(inputValue.toLowerCase()),
);
// CHECK: it could be filterValue or searchValue
const filters = filterValue
? Array.isArray(filterValue)
? filterValue
: [filterValue]
: [];
// REMINDER: if no options are defined, while fetching data, we should show a skeleton
if (isLoading && !filterOptions?.length)
return (
<div className={cn("grid divide-y border", boxSurfaceClassName)}>
{Array.from({ length: 3 }).map((_, index) => (
<div
key={index}
className="flex items-center justify-between gap-2 px-2 py-2.5"
>
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-full" />
</div>
))}
</div>
);
return (
<div className="grid gap-2">
{options && options.length > 4 ? (
<InputGroup className="h-9 shadow-none">
<InputGroupAddon>
<Search className="mt-0.5 h-4 w-4" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
/>
</InputGroup>
) : null}
{/* FIXME: due to the added max-h and overflow-y-auto, the hover state and border is laying on top of the scroll bar */}
<div
className={cn(
"max-h-[200px] overflow-y-auto border empty:border-none",
boxSurfaceClassName,
)}
>
{filterOptions
// TODO: we shoudn't sort the options here, instead filterOptions should be sorted by default
// .sort((a, b) => a.label.localeCompare(b.label))
?.map((option, index) => {
const checked = filters.includes(option.value);
return (
<div
key={String(option.value)}
className={cn(
"group hover:bg-accent/50 relative flex items-center space-x-2 px-2 py-2.5",
index !== filterOptions.length - 1 ? "border-b" : undefined,
)}
>
<Checkbox
id={`${value}-${option.value}`}
checked={checked}
onCheckedChange={(checked) => {
const newValue = checked
? [...(filters || []), option.value]
: filters?.filter((value) => option.value !== value);
column?.setFilterValue(
newValue?.length ? newValue : undefined,
);
}}
/>
<Label
htmlFor={`${value}-${option.value}`}
className="text-foreground/70 group-hover:text-accent-foreground flex w-full items-center justify-center gap-1 truncate"
>
{Component ? (
<Component {...option} options={options} />
) : (
<span className="truncate font-normal">{option.label}</span>
)}
<span className="ml-auto flex items-center justify-center font-mono text-xs">
{isLoading ? (
<Skeleton className="h-4 w-4" />
) : facetedValue?.has(option.value) ? (
formatCompactNumber(facetedValue.get(option.value) || 0)
) : null}
</span>
<button
type="button"
onClick={() => column?.setFilterValue([option.value])}
className={cn(
"text-muted-foreground hover:text-foreground absolute inset-y-0 right-0 hidden font-normal backdrop-blur-xs group-hover:block",
"focus-visible:border-ring focus-visible:ring-ring/50 transition-all outline-none focus-visible:ring-[3px]",
boxRadiusClassName,
)}
>
<span className="px-2">only</span>
</button>
</Label>
</div>
);
})}
</div>
</div>
);
}
src/components/data-table/data-table-filter-input.tsx
"use client";
import { useDataTable } from "@/components/data-table/data-table-provider";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { useDebounce } from "@/hooks/use-debounce";
import { Search } from "lucide-react";
import { useEffect, useState } from "react";
import type { DataTableInputFilterField } from "./types";
function getFilter(filterValue: unknown) {
return typeof filterValue === "string" ? filterValue : null;
}
export function DataTableFilterInput<TData>({
value: _value,
}: DataTableInputFilterField<TData>) {
const value = _value as string;
const { table, columnFilters } = useDataTable();
const column = table.getColumn(value);
const filterValue = columnFilters.find((i) => i.id === value)?.value;
const filters = getFilter(filterValue);
const [input, setInput] = useState<string | null>(filters);
const debouncedInput = useDebounce(input, 500);
useEffect(() => {
const newValue = debouncedInput?.trim() === "" ? null : debouncedInput;
if (debouncedInput === null) return;
column?.setFilterValue(newValue);
}, [debouncedInput]);
useEffect(() => {
if (debouncedInput?.trim() !== filters) {
setInput(filters);
}
}, [filters]);
return (
<div className="grid w-full gap-1.5">
<Label htmlFor={value} className="text-muted-foreground sr-only px-2">
{value}
</Label>
<InputGroup className="h-9 shadow-none">
<InputGroupAddon>
<Search className="mt-0.5 h-4 w-4" />
</InputGroupAddon>
<InputGroupInput
placeholder="Search"
name={value}
id={value}
value={input || ""}
onChange={(e) => setInput(e.target.value)}
/>
</InputGroup>
</div>
);
}
src/components/data-table/data-table-filter-slider.tsx
"use client";
import { Slider } from "@/components/custom/slider";
import { useDataTable } from "@/components/data-table/data-table-provider";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group";
import { Label } from "@/components/ui/label";
import { useDebounce } from "@/hooks/use-debounce";
import { isArrayOfNumbers } from "@/lib/is-array";
import { useEffect, useState } from "react";
import type { DataTableSliderFilterField } from "./types";
function getFilter(filterValue: unknown) {
return typeof filterValue === "number"
? [filterValue, filterValue]
: Array.isArray(filterValue) && isArrayOfNumbers(filterValue)
? filterValue.length === 1
? [filterValue[0], filterValue[0]]
: filterValue
: null;
}
// TODO: discuss if we even need the `defaultMin` and `defaultMax`
export function DataTableFilterSlider<TData>({
value: _value,
min: defaultMin,
max: defaultMax,
unit,
}: DataTableSliderFilterField<TData>) {
const value = _value as string;
const { table, columnFilters, getFacetedMinMaxValues } = useDataTable();
const column = table.getColumn(value);
const filterValue = columnFilters.find((i) => i.id === value)?.value;
const filters = getFilter(filterValue);
const [input, setInput] = useState<number[] | null>(filters);
const [min, max] = getFacetedMinMaxValues?.(table, value) ||
column?.getFacetedMinMaxValues() || [defaultMin, defaultMax];
const debouncedInput = useDebounce(input, 500);
useEffect(() => {
if (debouncedInput?.length === 2) {
column?.setFilterValue(debouncedInput);
}
}, [debouncedInput]);
useEffect(() => {
if (debouncedInput?.length !== 2) {
} else if (!filters) {
setInput(null);
} else if (
debouncedInput[0] !== filters[0] ||
debouncedInput[1] !== filters[1]
) {
setInput(filters);
}
}, [filters]);
return (
<div className="grid gap-2">
<div className="flex items-center gap-4">
<div className="grid w-full gap-1.5">
<Label
htmlFor={`min-${value}`}
className="text-muted-foreground px-2"
>
Min.
</Label>
<InputGroup className="mb-2 h-9 font-mono shadow-none">
<InputGroupInput
placeholder="from"
type="number"
name={`min-${value}`}
id={`min-${value}`}
value={`${input?.[0] ?? min}`}
min={min}
max={max}
onChange={(e) =>
setInput((prev) => [Number(e.target.value), prev?.[1] || max])
}
/>
{unit ? (
<InputGroupAddon align="inline-end">{unit}</InputGroupAddon>
) : null}
</InputGroup>
</div>
<div className="grid w-full gap-1.5">
<Label
htmlFor={`max-${value}`}
className="text-muted-foreground px-2"
>
Max.
</Label>
<InputGroup className="mb-2 h-9 font-mono shadow-none">
<InputGroupInput
placeholder="to"
type="number"
name={`max-${value}`}
id={`max-${value}`}
value={`${input?.[1] ?? max}`}
min={min}
max={max}
onChange={(e) =>
setInput((prev) => [prev?.[0] || min, Number(e.target.value)])
}
/>
{unit ? (
<InputGroupAddon align="inline-end">{unit}</InputGroupAddon>
) : null}
</InputGroup>
</div>
</div>
<Slider
min={min}
max={max}
value={input?.length === 2 ? input : [min, max]}
onValueChange={(values) => setInput(values)}
/>
</div>
);
}
src/components/data-table/data-table-filter-timerange.tsx
"use client";
import { DatePickerWithRange } from "@/components/custom/date-picker-with-range";
import { useDataTable } from "@/components/data-table/data-table-provider";
import { isArrayOfDates } from "@/lib/is-array";
import { useMemo } from "react";
import type { DateRange } from "react-day-picker";
import type { DataTableTimerangeFilterField } from "./types";
export function DataTableFilterTimerange<TData>({
value: _value,
presets,
}: DataTableTimerangeFilterField<TData>) {
const value = _value as string;
const { table, columnFilters } = useDataTable();
const column = table.getColumn(value);
const filterValue = columnFilters.find((i) => i.id === value)?.value;
const date: DateRange | undefined = useMemo(
() =>
filterValue instanceof Date
? { from: filterValue, to: undefined }
: Array.isArray(filterValue) && isArrayOfDates(filterValue)
? { from: filterValue?.[0], to: filterValue?.[1] }
: undefined,
[filterValue],
);
const setDate = (date: DateRange | undefined) => {
if (!date) {
column?.setFilterValue(undefined);
return;
}
if (date.from && !date.to) {
column?.setFilterValue([date.from]);
}
if (date.to && date.from) {
column?.setFilterValue([date.from, date.to]);
}
};
return <DatePickerWithRange {...{ date, setDate, presets }} />;
}
src/components/data-table/data-table-filter-reset-button.tsx
"use client";
import { useDataTable } from "@/components/data-table/data-table-provider";
import { Button } from "@/components/ui/button";
import { X } from "lucide-react";
import type { DataTableFilterField } from "./types";
export function DataTableFilterResetButton<TData>({
value: _value,
}: DataTableFilterField<TData>) {
const { columnFilters, table } = useDataTable();
const value = _value as string;
const column = table.getColumn(value);
const filterValue = columnFilters.find((f) => f.id === value)?.value;
// TODO: check if we could useMemo
const filters = filterValue
? Array.isArray(filterValue)
? filterValue
: [filterValue]
: [];
if (filters.length === 0) return null;
return (
<Button
variant="outline"
className="h-5 gap-1 px-1.5! py-1! font-mono text-[10px] shadow-none"
onClick={(e) => {
e.stopPropagation();
column?.setFilterValue(undefined);
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.code === "Enter") {
column?.setFilterValue(undefined);
}
}}
asChild
>
{/* REMINDER: `AccordionTrigger` is also a button(!) and we get Hydration error when rendering button within button */}
<div role="button" tabIndex={0}>
<span>{filters.length}</span>
<X className="text-muted-foreground ml-1! size-2.5!" />
</div>
</Button>
);
}
src/components/data-table/data-table-filter-controls.tsx
"use client";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/custom/accordion";
import { useDataTable } from "@/components/data-table/data-table-provider";
import * as React from "react";
import { DataTableFilterCheckbox } from "./data-table-filter-checkbox";
import { DataTableFilterInput } from "./data-table-filter-input";
import { DataTableFilterResetButton } from "./data-table-filter-reset-button";
import { DataTableFilterSlider } from "./data-table-filter-slider";
import { DataTableFilterTimerange } from "./data-table-filter-timerange";
// FIXME: use @container (especially for the slider element) to restructure elements
// TODO: only pass the columns to generate the filters!
// https://tanstack.com/table/latest/docs/framework/react/examples/filters
// Pluggable filter registry — extend by adding entries
export const FILTER_COMPONENTS: Record<string, React.ComponentType<any>> = {
checkbox: DataTableFilterCheckbox,
input: DataTableFilterInput,
slider: DataTableFilterSlider,
timerange: DataTableFilterTimerange,
};
export function DataTableFilterControls() {
const { filterFields } = useDataTable();
const [isMounted, setIsMounted] = React.useState(false);
React.useEffect(() => {
const timer = setTimeout(() => setIsMounted(true), 0);
return () => clearTimeout(timer);
}, []);
return (
<Accordion
type="multiple"
className={
isMounted
? undefined
: "[&_[data-slot=accordion-content]]:!animate-none"
}
defaultValue={filterFields
?.filter(({ defaultOpen }) => defaultOpen)
?.map(({ value }) => value as string)}
>
{filterFields?.map((field) => {
const value = field.value as string;
const FilterComponent = FILTER_COMPONENTS[field.type];
if (!FilterComponent) return null;
return (
// REMINDER: -mx-2 lets the border-b span the full sidebar width despite the parent p-2; px-2 keeps the content aligned
<AccordionItem key={value} value={value} className="-mx-2 px-2">
<AccordionTrigger className="data-[state=closed]:text-muted-foreground data-[state=open]:text-foreground focus-within:data-[state=closed]:text-foreground hover:data-[state=closed]:text-foreground w-full items-center gap-2 px-2 py-0 hover:no-underline">
<div className="flex w-full items-center justify-between gap-2 truncate py-3 pr-2">
<div className="flex items-center gap-2 truncate">
<p className="text-sm font-medium">{field.label}</p>
{value !== field.label.toLowerCase() &&
!field.commandDisabled ? (
<p className="text-muted-foreground mt-px truncate font-mono text-[10px]">
{value}
</p>
) : null}
</div>
<DataTableFilterResetButton {...field} />
</div>
</AccordionTrigger>
<AccordionContent>
{/* REMINDER: avoid the focus state to be cut due to overflow-hidden */}
{/* REMINDER: need to move within here because of accordion height animation */}
<div className="p-1">
<FilterComponent {...field} />
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
);
}
src/components/data-table/data-table-filter-controls-drawer.tsx
import { Button } from "@/components/ui/button";
import {
Drawer,
DrawerClose,
DrawerContent,
DrawerDescription,
DrawerFooter,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer";
import { Kbd } from "@/components/ui/kbd";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useHotKey } from "@/hooks/use-hot-key";
import { useMediaQuery } from "@/hooks/use-media-query";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { FilterIcon } from "lucide-react";
import React from "react";
import { DataTableFilterControls } from "./data-table-filter-controls";
import { TOOLTIP_DELAY } from "./ui-compat";
export function DataTableFilterControlsDrawer() {
const triggerButtonRef = React.useRef<HTMLButtonElement>(null);
const isMobile = useMediaQuery("(max-width: 640px)");
useHotKey(() => {
triggerButtonRef.current?.click();
}, "b");
return (
<Drawer>
<TooltipProvider {...TOOLTIP_DELAY}>
<Tooltip>
<TooltipTrigger asChild>
<DrawerTrigger asChild>
<Button
ref={isMobile ? triggerButtonRef : null}
variant="ghost"
size="icon"
className="h-9 w-9"
>
<FilterIcon className="h-4 w-4" />
</Button>
</DrawerTrigger>
</TooltipTrigger>
<TooltipContent side="right">
<p className="text-nowrap">
Toggle controls with{" "}
<Kbd className="text-muted-foreground group-hover:text-accent-foreground ml-1">
<span className="mr-1">⌘</span>
<span>B</span>
</Kbd>
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<DrawerContent className="max-h-[calc(100dvh-4rem)]">
<VisuallyHidden>
<DrawerHeader>
<DrawerTitle>Filters</DrawerTitle>
<DrawerDescription>Adjust your table filters</DrawerDescription>
</DrawerHeader>
</VisuallyHidden>
<div className="flex-1 overflow-y-auto px-4">
<DataTableFilterControls />
</div>
<DrawerFooter>
<DrawerClose asChild>
<Button variant="outline" className="w-full">
Close
</Button>
</DrawerClose>
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}
src/components/data-table/data-table-filter-rail.tsx
"use client";
import { cn } from "@/lib/utils";
import { useControls } from "@/providers/controls";
/**
* The hit area on the filter sidebar's border, mirroring the app sidebar's
* rail. Absolutely positioned so it costs no layout width; the accessible
* control is the toolbar's Hide/Show Controls button.
*/
export function DataTableFilterRail({
className,
...props
}: React.ComponentProps<"button">) {
const { open, setOpen } = useControls();
return (
<button
type="button"
data-slot="data-table-filter-rail"
aria-label="Toggle Filters"
aria-expanded={open}
title="Toggle Filters"
tabIndex={-1}
onClick={() => setOpen((prev) => !prev)}
className={cn(
// z-30: above the table's own sticky header, which is z-20
"absolute inset-y-0 left-0 z-30 hidden w-4 -translate-x-1/2 sm:block",
"after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] after:transition-colors",
"hover:after:bg-border",
open ? "cursor-w-resize" : "cursor-e-resize",
className,
)}
{...props}
/>
);
}
src/components/custom/table.tsx
import { cn } from "@/lib/utils";
import * as React from "react";
interface TableProps extends React.ComponentProps<"table"> {
containerClassName?: string;
}
function Table({
className,
containerClassName,
onScroll,
...props
}: TableProps) {
return (
<div
// `relative` makes this the containing block for anything absolutely
// positioned inside a cell (an `sr-only` label, a tooltip anchor), so it
// scrolls and clips with the rows instead of stretching the page.
className={cn("relative w-full overflow-auto", containerClassName)}
// REMINDER: we are not scrolling the table, but the container
{...{ onScroll }}
>
<table
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
);
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return <thead className={cn("[&_tr]:border-b", className)} {...props} />;
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody className={cn("[&_tr:last-child]:border-0", className)} {...props} />
);
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
className={cn(
"bg-primary text-primary-foreground font-medium",
className,
)}
{...props}
/>
);
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className,
)}
{...props}
/>
);
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
className={cn(
"text-muted-foreground h-10 px-2 text-left align-middle font-medium",
className,
)}
{...props}
/>
);
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return <td className={cn("p-2 align-middle", className)} {...props} />;
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
};
src/components/custom/accordion.tsx
"use client";
import { boxRadiusClassName } from "@/lib/style";
import { cn } from "@/lib/utils";
import * as AccordionPrimitive from "@radix-ui/react-accordion";
import { ChevronDownIcon } from "lucide-react";
import * as React from "react";
/**
* The shadcn accordion, shipped with the block instead of resolved from the
* project's `ui/accordion`.
*
* Radix and Base UI disagree here in two ways that no prop spelling can paper
* over: the root takes `type="multiple"` on Radix and `openMultiple` on Base
* UI, and the open/closed state is published as `data-state="open"` on Radix
* but `data-open` on Base UI — which is what the filter controls style
* against. Owning the component keeps the filter sidebar identical on a
* project initialized either way, the same way this block already ships its
* own `sheet` and `sortable`.
*/
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />;
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
);
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
boxRadiusClassName,
className,
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
);
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
);
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent };
src/components/custom/sortable.tsx
"use client";
import type {
DndContextProps,
DraggableSyntheticListeners,
DropAnimation,
UniqueIdentifier,
} from "@dnd-kit/core";
import {
closestCenter,
defaultDropAnimationSideEffects,
DndContext,
DragOverlay,
KeyboardSensor,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
restrictToHorizontalAxis,
restrictToParentElement,
restrictToVerticalAxis,
} from "@dnd-kit/modifiers";
import {
arrayMove,
horizontalListSortingStrategy,
SortableContext,
useSortable,
verticalListSortingStrategy,
type SortableContextProps,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { Button } from "@/components/ui/button";
import { composeRefs } from "@/lib/compose-refs";
import { cn } from "@/lib/utils";
import { Slot, type SlotProps } from "@radix-ui/react-slot";
import * as React from "react";
import { createPortal } from "react-dom";
const orientationConfig = {
vertical: {
modifiers: [restrictToVerticalAxis, restrictToParentElement],
strategy: verticalListSortingStrategy,
},
horizontal: {
modifiers: [restrictToHorizontalAxis, restrictToParentElement],
strategy: horizontalListSortingStrategy,
},
mixed: {
modifiers: [restrictToParentElement],
strategy: undefined,
},
};
interface SortableProps<TData extends { id: UniqueIdentifier }>
extends DndContextProps {
/**
* An array of data items that the sortable component will render.
* @example
* value={[
* { id: 1, name: 'Item 1' },
* { id: 2, name: 'Item 2' },
* ]}
*/
value: TData[];
/**
* An optional callback function that is called when the order of the data items changes.
* It receives the new array of items as its argument.
* @example
* onValueChange={(items) => console.log(items)}
*/
onValueChange?: (items: TData[]) => void;
/**
* An optional callback function that is called when an item is moved.
* It receives an event object with `activeIndex` and `overIndex` properties, representing the original and new positions of the moved item.
* This will override the default behavior of updating the order of the data items.
* @type (event: { activeIndex: number; overIndex: number }) => void
* @example
* onMove={(event) => console.log(`Item moved from index ${event.activeIndex} to index ${event.overIndex}`)}
*/
onMove?: (event: { activeIndex: number; overIndex: number }) => void;
/**
* A collision detection strategy that will be used to determine the closest sortable item.
* @default closestCenter
* @type DndContextProps["collisionDetection"]
*/
collisionDetection?: DndContextProps["collisionDetection"];
/**
* An array of modifiers that will be used to modify the behavior of the sortable component.
* @default
* [restrictToVerticalAxis, restrictToParentElement]
* @type Modifier[]
*/
modifiers?: DndContextProps["modifiers"];
/**
* A sorting strategy that will be used to determine the new order of the data items.
* @default verticalListSortingStrategy
* @type SortableContextProps["strategy"]
*/
strategy?: SortableContextProps["strategy"];
/**
* Specifies the axis for the drag-and-drop operation. It can be "vertical", "horizontal", or "both".
* @default "vertical"
* @type "vertical" | "horizontal" | "mixed"
*/
orientation?: "vertical" | "horizontal" | "mixed";
/**
* An optional React node that is rendered on top of the sortable component.
* It can be used to display additional information or controls.
* @default null
* @type React.ReactNode | null
* @example
* overlay={<Skeleton className="w-full h-8" />}
*/
overlay?: React.ReactNode | null;
}
function Sortable<TData extends { id: UniqueIdentifier }>({
value,
onValueChange,
onDragStart,
onDragEnd,
onDragCancel,
collisionDetection = closestCenter,
modifiers,
strategy,
onMove,
orientation = "vertical",
overlay,
children,
...props
}: SortableProps<TData>) {
const [activeId, setActiveId] = React.useState<UniqueIdentifier | null>(null);
const sensors = useSensors(
useSensor(MouseSensor),
useSensor(TouchSensor),
useSensor(KeyboardSensor),
);
const config = orientationConfig[orientation];
return (
<DndContext
modifiers={modifiers ?? config.modifiers}
sensors={sensors}
onDragStart={(event) => {
setActiveId(event.active.id);
onDragStart?.(event);
}}
onDragEnd={(event) => {
const { active, over } = event;
if (over && active.id !== over?.id) {
const activeIndex = value.findIndex((item) => item.id === active.id);
const overIndex = value.findIndex((item) => item.id === over.id);
if (onMove) {
onMove({ activeIndex, overIndex });
} else {
onValueChange?.(arrayMove(value, activeIndex, overIndex));
}
}
setActiveId(null);
onDragEnd?.(event);
}}
onDragCancel={(event) => {
setActiveId?.(null);
onDragCancel?.(event);
}}
collisionDetection={collisionDetection}
{...props}
>
<SortableContext items={value} strategy={strategy ?? config.strategy}>
{children}
</SortableContext>
{overlay
? // https://docs.dndkit.com/api-documentation/draggable/drag-overlay#portals
createPortal(
<SortableOverlay activeId={activeId}>{overlay}</SortableOverlay>,
document.body,
)
: null}
</DndContext>
);
}
const dropAnimationOpts: DropAnimation = {
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: {
opacity: "0.4",
},
},
}),
};
interface SortableOverlayProps
extends React.ComponentPropsWithRef<typeof DragOverlay> {
activeId?: UniqueIdentifier | null;
}
function SortableOverlay({
activeId,
dropAnimation = dropAnimationOpts,
children,
...props
}: SortableOverlayProps) {
return (
<DragOverlay dropAnimation={dropAnimation} {...props}>
{activeId ? (
// REMINDER: `asChild` is spread, not written as an attribute. When the
// shadcn CLI installs into a Base UI project it rewrites a literal
// `asChild` into `render={<child />}` — and where the child is an
// expression rather than an element, as here, it drops the prop
// instead, which would silently turn the overlay into a wrapper div.
<SortableItem
value={activeId}
className="cursor-grabbing"
{...{ asChild: true }}
>
{children}
</SortableItem>
) : null}
</DragOverlay>
);
}
interface SortableItemContextProps {
attributes: React.HTMLAttributes<HTMLElement>;
listeners: DraggableSyntheticListeners | undefined;
isDragging?: boolean;
}
const SortableItemContext = React.createContext<SortableItemContextProps>({
attributes: {},
listeners: undefined,
isDragging: false,
});
function useSortableItem() {
const context = React.useContext(SortableItemContext);
if (!context) {
throw new Error("useSortableItem must be used within a SortableItem");
}
return context;
}
interface SortableItemProps extends SlotProps {
/**
* The unique identifier of the item.
* @example "item-1"
* @type UniqueIdentifier
*/
value: UniqueIdentifier;
/**
* Specifies whether the item should act as a trigger for the drag-and-drop action.
* @default false
* @type boolean | undefined
*/
asTrigger?: boolean;
/**
* Merges the item's props into its immediate child.
* @default false
* @type boolean | undefined
*/
asChild?: boolean;
/**
* The element to render as, with the item's props merged into it — Base UI's
* spelling of `asChild`. The shadcn CLI rewrites `asChild` into `render` when
* it installs into a Base UI project, so this component has to understand
* both; it is built on Radix either way, since it ships with the block.
* @type React.ReactElement | undefined
*/
render?: React.ReactElement;
}
function SortableItem({
value,
asTrigger,
asChild,
render,
className,
children,
ref,
...props
}: SortableItemProps & { ref?: React.Ref<HTMLDivElement> }) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: value });
const context = React.useMemo<SortableItemContextProps>(
() => ({
attributes,
listeners,
isDragging,
}),
[attributes, listeners, isDragging],
);
const style: React.CSSProperties = {
opacity: isDragging ? 0.5 : 1,
transform: CSS.Translate.toString(transform),
transition,
};
const Comp = asChild || render ? Slot : "div";
// Base UI nests the children inside the `render` element; Radix's `asChild`
// expects them to already be there.
const content = render
? children === undefined
? render
: React.cloneElement(render, undefined, children)
: children;
return (
<SortableItemContext.Provider value={context}>
<Comp
data-state={isDragging ? "dragging" : undefined}
className={cn(
"data-[state=dragging]:cursor-grabbing",
{ "cursor-grab": !isDragging && asTrigger },
className,
)}
ref={composeRefs(ref, setNodeRef as React.Ref<HTMLDivElement>)}
style={style}
{...(asTrigger ? attributes : {})}
{...(asTrigger ? listeners : {})}
{...props}
>
{content}
</Comp>
</SortableItemContext.Provider>
);
}
interface SortableDragHandleProps extends React.ComponentProps<typeof Button> {
withHandle?: boolean;
}
function SortableDragHandle({
className,
ref,
...props
}: SortableDragHandleProps) {
const { attributes, listeners, isDragging } = useSortableItem();
return (
<Button
ref={composeRefs(ref)}
data-state={isDragging ? "dragging" : undefined}
className={cn(
"cursor-grab data-[state=dragging]:cursor-grabbing",
className,
)}
{...attributes}
{...listeners}
{...props}
/>
);
}
export { Sortable, SortableDragHandle, SortableItem, SortableOverlay };
src/components/custom/slider.tsx
// Props to https://github.com/shadcn-ui/ui/issues/885#issuecomment-2059600641
"use client";
import { cn } from "@/lib/utils";
import * as SliderPrimitive from "@radix-ui/react-slider";
import * as React from "react";
function Slider({
className,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const initialValue = Array.isArray(props.value)
? props.value
: [props.min, props.max];
return (
<SliderPrimitive.Root
data-slot="slider"
className={cn(
"relative flex w-full touch-none items-center select-none",
className,
)}
{...props}
>
<SliderPrimitive.Track className="bg-secondary relative h-2 w-full grow overflow-hidden rounded-full">
<SliderPrimitive.Range className="bg-primary absolute h-full" />
</SliderPrimitive.Track>
{initialValue.map((_, index) => (
<React.Fragment key={index}>
<SliderPrimitive.Thumb className="border-primary bg-background focus-visible:border-ring focus-visible:ring-ring/50 block h-4 w-4 rounded-full border-2 transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50" />
</React.Fragment>
))}
</SliderPrimitive.Root>
);
}
export { Slider };
src/components/custom/date-picker-with-range.tsx
"use client";
import type { DatePreset } from "@/components/data-table/types";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import { Input } from "@/components/ui/input";
import { Kbd } from "@/components/ui/kbd";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { presets as defaultPresets } from "@/constants/date-preset";
import { useDebounce } from "@/hooks/use-debounce";
import { cn } from "@/lib/utils";
import { format } from "date-fns";
import { Calendar as CalendarIcon } from "lucide-react";
import * as React from "react";
import type { DateRange } from "react-day-picker";
interface DatePickerWithRangeProps
extends React.HTMLAttributes<HTMLDivElement> {
date: DateRange | undefined;
setDate: (date: DateRange | undefined) => void;
presets?: DatePreset[];
}
export function DatePickerWithRange({
className,
date,
setDate,
presets = defaultPresets,
}: DatePickerWithRangeProps) {
const [open, setOpen] = React.useState(false);
React.useEffect(() => {
const down = (e: KeyboardEvent) => {
if (!open) return;
presets.map((preset) => {
if (preset.shortcut === e.key) {
setDate({ from: preset.from, to: preset.to });
}
});
};
document.addEventListener("keydown", down);
return () => document.removeEventListener("keydown", down);
}, [setDate, presets, open]);
return (
<div className={cn("grid gap-2", className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
id="date"
variant="outline"
className={cn(
"hover:bg-muted/50 max-w-full justify-start truncate text-left font-normal shadow-none",
!date && "text-muted-foreground",
)}
>
<CalendarIcon className="h-4 w-4" />
{date?.from ? (
date.to ? (
<span className="truncate">
{format(date.from, "LLL dd, y")} -{" "}
{format(date.to, "LLL dd, y")}
</span>
) : (
format(date.from, "LLL dd, y")
)
) : (
<span>Pick a date</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<div className="flex flex-col justify-between sm:flex-row">
<div className="hidden sm:block">
<DatePresets
onSelect={setDate}
selected={date}
presets={presets}
/>
</div>
<div className="block p-3 sm:hidden">
<DatePresetsSelect
onSelect={setDate}
selected={date}
presets={presets}
/>
</div>
<Separator orientation="vertical" className="h-auto w-px" />
<Calendar
// `initialFocus` until react-day-picker 9 deprecated it; gone in
// 10, which is what shadcn's `calendar` installs today.
autoFocus
mode="range"
defaultMonth={date?.from}
selected={date}
onSelect={setDate}
numberOfMonths={1}
/>
</div>
<Separator />
<CustomDateRange onSelect={setDate} selected={date} />
</PopoverContent>
</Popover>
</div>
);
}
function DatePresets({
selected,
onSelect,
presets,
}: {
selected: DateRange | undefined;
onSelect: (date: DateRange | undefined) => void;
presets: DatePreset[];
}) {
return (
<div className="flex flex-col gap-2 p-3">
<p className="text-muted-foreground mx-3 text-xs uppercase">Date Range</p>
<div className="grid gap-1">
{presets.map(({ label, shortcut, from, to }) => {
const isActive = selected?.from === from && selected?.to === to;
return (
<Button
key={label}
variant={isActive ? "outline" : "ghost"}
onClick={() => onSelect({ from, to })}
className={cn(
"flex items-center justify-between gap-6",
!isActive && "border border-transparent!",
)}
>
<span className="mr-auto">{label}</span>
<Kbd className="uppercase">{shortcut}</Kbd>
</Button>
);
})}
</div>
</div>
);
}
function DatePresetsSelect({
selected,
onSelect,
presets,
}: {
selected: DateRange | undefined;
onSelect: (date: DateRange | undefined) => void;
presets: DatePreset[];
}) {
function findPreset(from?: Date, to?: Date) {
return presets.find((p) => p.from === from && p.to === to)?.shortcut;
}
const [value, setValue] = React.useState<string | undefined>(
findPreset(selected?.from, selected?.to),
);
React.useEffect(() => {
const preset = findPreset(selected?.from, selected?.to);
if (preset === value) return;
setValue(preset);
}, [selected, presets]);
return (
<Select
value={value}
onValueChange={(v) => {
const preset = presets.find((p) => p.shortcut === v);
if (preset) {
onSelect({ from: preset.from, to: preset.to });
}
}}
>
{/* REMINDER: the label is rendered here rather than through the select
value's placeholder. Base UI has no `placeholder` prop there — it
reads the label off the root's `items` array — and `placeholder` is a
valid HTML attribute, so passing it typechecks and then renders an
empty trigger. Picking the muted colour by hand keeps the placeholder
looking the same on both libraries. */}
<SelectTrigger className={cn(!value && "text-muted-foreground")}>
{presets.find((preset) => preset.shortcut === value)?.label ??
"Date Presets"}
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Date Presets</SelectLabel>
{presets.map(({ label, shortcut }) => {
return (
<SelectItem
key={label}
value={shortcut}
className="flex items-center justify-between [&>span:last-child]:flex [&>span:last-child]:w-full [&>span:last-child]:justify-between"
>
<span>{label}</span>
<Kbd className="ml-2 uppercase">{shortcut}</Kbd>
</SelectItem>
);
})}
</SelectGroup>
</SelectContent>
</Select>
);
}
// REMINDER: We can add min max date range validation https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#setting_maximum_and_minimum_dates_and_times
function CustomDateRange({
selected,
onSelect,
}: {
selected: DateRange | undefined;
onSelect: (date: DateRange | undefined) => void;
}) {
const [dateFrom, setDateFrom] = React.useState<Date | undefined>(
selected?.from,
);
const [dateTo, setDateTo] = React.useState<Date | undefined>(selected?.to);
const debounceDateFrom = useDebounce(dateFrom, 1000);
const debounceDateTo = useDebounce(dateTo, 1000);
const formatDateForInput = (date: Date | undefined): string => {
if (!date) return "";
const utcDate = new Date(date.getTime() - date.getTimezoneOffset() * 60000);
return utcDate.toISOString().slice(0, 16);
};
React.useEffect(() => {
onSelect({ from: debounceDateFrom, to: debounceDateTo });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debounceDateFrom, debounceDateTo]);
return (
<div className="flex flex-col gap-2 p-3">
<p className="text-muted-foreground text-xs uppercase">Custom Range</p>
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid w-full gap-1.5">
<Label htmlFor="from">Start</Label>
<Input
key={formatDateForInput(selected?.from)}
type="datetime-local"
id="from"
name="from"
defaultValue={formatDateForInput(selected?.from)}
onChange={(e) => {
const newDate = new Date(e.target.value);
if (!Number.isNaN(newDate.getTime())) {
setDateFrom(newDate);
}
}}
disabled={!selected?.from}
/>
</div>
<div className="grid w-full gap-1.5">
<Label htmlFor="to">End</Label>
<Input
key={formatDateForInput(selected?.to)}
type="datetime-local"
id="to"
name="to"
defaultValue={formatDateForInput(selected?.to)}
onChange={(e) => {
const newDate = new Date(e.target.value);
if (!Number.isNaN(newDate.getTime())) {
setDateTo(newDate);
}
}}
disabled={!selected?.to}
/>
</div>
</div>
</div>
);
}
src/providers/controls.tsx
import { useLocalStorage } from "@/hooks/use-local-storage";
import { CONTROLS_KEY } from "@/lib/constants/local-storage";
import { createContext, useContext } from "react";
interface ControlsContextType {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export const ControlsContext = createContext<ControlsContextType | null>(null);
export function ControlsProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useLocalStorage(CONTROLS_KEY, true);
return (
<ControlsContext.Provider value={{ open, setOpen }}>
<div
// REMINDER: access the data-expanded state with tailwind via `group-data-[expanded=true]/controls:block`
// In tailwindcss v4, we could even use `group-data-expanded/controls:block`
className="group/controls"
data-expanded={open}
>
{children}
</div>
</ControlsContext.Provider>
);
}
export function useControls() {
const context = useContext(ControlsContext);
if (!context) {
throw new Error("useControls must be used within a ControlsProvider");
}
return context as ControlsContextType;
}
src/lib/store/context.ts
/**
* React Context for BYOS Store
*/
"use client";
import { createContext, useContext } from "react";
import type { StoreAdapter } from "./adapter/types";
import type { SchemaDefinition } from "./schema/types";
/**
* Context value for the store provider
*/
export interface StoreContextValue<T extends Record<string, unknown>> {
adapter: StoreAdapter<T>;
schema: SchemaDefinition;
tableId: string;
}
/**
* React context for the store adapter
* @internal
*/
export const StoreContext = createContext<StoreContextValue<
Record<string, unknown>
> | null>(null);
/**
* Hook to access the store context
* Returns null if used outside of DataTableStoreProvider
*/
export function useStoreContext<
T extends Record<string, unknown> = Record<string, unknown>,
>(): StoreContextValue<T> {
const context = useContext(StoreContext) as StoreContextValue<T> | null;
if (!context) {
throw new Error(
"useStoreContext must be used within a DataTableStoreProvider",
);
}
return context;
}
src/lib/store/adapter/index.ts
/**
* Adapter Interface Exports
*/
export type {
StoreAdapter,
CreateAdapterOptions,
AdapterFactory,
InternalStoreAdapter,
AdapterType,
} from "./types";
src/lib/store/adapter/types.ts
/**
* Adapter Interface for BYOS (Bring Your Own Store)
*
* This interface defines the contract that all store adapters must implement.
* It is designed to be compatible with React 18's useSyncExternalStore.
*/
import type { SchemaDefinition, StoreSnapshot } from "../schema/types";
/**
* Store adapter interface that all adapters must implement
*/
export interface StoreAdapter<T extends Record<string, unknown>> {
/**
* Subscribe to state changes.
* Compatible with useSyncExternalStore's subscribe parameter.
*
* @param listener - Callback to invoke when state changes
* @returns Unsubscribe function
*/
subscribe(listener: () => void): () => void;
/**
* Get the current state snapshot.
* Compatible with useSyncExternalStore's getSnapshot parameter.
*
* @returns Current state snapshot with version
*/
getSnapshot(): StoreSnapshot<T>;
/**
* Get the server-side state snapshot (for SSR).
* Only implemented by URL-based adapters.
*
* @returns Server state snapshot
*/
getServerSnapshot?(): StoreSnapshot<T>;
/**
* Update state with partial values.
* Must use immutable updates (new references for changed values).
*
* @param partial - Partial state to merge
*/
setState(partial: Partial<T>): void;
/**
* Update a single field value.
*
* @param key - Field key
* @param value - New value
*/
setField<K extends keyof T>(key: K, value: T[K]): void;
/**
* Reset state to defaults.
*
* @param fields - Optional array of fields to reset. If omitted, resets all.
*/
reset(fields?: (keyof T)[]): void;
/**
* Pause state updates (for live mode).
* While paused, setState calls are queued.
*/
pause(): void;
/**
* Resume state updates.
* Applies any queued state changes.
*/
resume(): void;
/**
* Check if updates are paused.
*/
isPaused(): boolean;
/**
* Cleanup resources when adapter is destroyed.
*/
destroy(): void;
/**
* Get the unique table ID for this adapter.
*/
getTableId(): string;
/**
* Get the schema definition used by this adapter.
*/
getSchema(): SchemaDefinition;
/**
* Get the default values from the schema.
*/
getDefaults(): T;
}
/**
* Options for creating an adapter
*/
export interface CreateAdapterOptions<T extends Record<string, unknown>> {
/**
* Unique ID for this table (required for multi-table support)
*/
id: string;
/**
* Initial state to override defaults
*/
initialState?: Partial<T>;
}
/**
* Factory function type for creating adapters
*/
export type AdapterFactory<T extends Record<string, unknown>> = (
schema: SchemaDefinition,
options: CreateAdapterOptions<T>,
) => StoreAdapter<T>;
/**
* Available adapter types
*/
export type AdapterType = "nuqs" | "zustand";
/**
* Internal adapter with additional methods for providers
* @internal
*/
export interface InternalStoreAdapter<T extends Record<string, unknown>>
extends StoreAdapter<T> {
/**
* Internal method to sync external state (used by nuqs adapter)
* @internal
*/
_syncState?(state: T): void;
/**
* Internal method to get the state setter (used by nuqs adapter)
* @internal
*/
_getStateSetter?(): ((partial: Partial<T>) => void) | null;
/**
* Internal method to set the state setter (used by nuqs adapter)
* @internal
*/
_setStateSetter?(setter: (partial: Partial<T>) => void): void;
}
src/lib/store/schema/index.ts
import { getSchemaDefaults } from "./serialization";
import type { InferSchemaType, Schema, SchemaDefinition } from "./types";
/**
* Schema System for BYOS (Bring Your Own Store)
*
* Provides a fluent API for defining filter schemas with type inference.
*
* @example
* ```typescript
* import { createSchema, field } from '@/lib/store/schema';
*
* const schema = createSchema({
* regions: field.array(field.string()).default([]).delimiter(','),
* latency: field.array(field.number()).delimiter('-'),
* host: field.string().default(''),
* live: field.boolean().default(false),
* });
*
* type FilterState = typeof schema._type;
* ```
*/
export { field } from "./field";
export type {
FieldBuilder,
FieldConfig,
SchemaDefinition,
InferSchemaType,
Schema,
StoreSnapshot,
AdapterOptions,
} from "./types";
export {
getSchemaDefaults,
serializeState,
parseState,
validateState,
mergeWithDefaults,
isStateEqual,
stateToSearchString,
} from "./serialization";
/**
* Create a schema from field definitions
*
* @param definition - Object mapping field names to field builders
* @returns Schema object with definition, defaults, and type inference
*
* @example
* ```typescript
* const schema = createSchema({
* level: field.array(field.stringLiteral(['error', 'warn', 'info'])).default([]),
* latency: field.array(field.number()).delimiter('-'),
* host: field.string(),
* });
* ```
*/
export function createSchema<T extends SchemaDefinition>(
definition: T,
): Schema<T> {
const defaults = getSchemaDefaults(definition);
return {
definition,
defaults,
// This is a type-only property for inference
_type: defaults,
};
}
src/lib/store/schema/types.ts
/**
* Schema Types for BYOS (Bring Your Own Store)
*
* These types define the structure for filter schemas with a fluent API.
*/
// Primitive field types
export type PrimitiveType =
| "string"
| "number"
| "boolean"
| "timestamp"
| "stringLiteral";
// Field configuration stored internally
export interface FieldConfig<T> {
type: PrimitiveType | "array" | "sort";
defaultValue: T;
delimiter: string;
serialize: (value: T) => string;
parse: (value: string) => T | null;
// For stringLiteral type
literals?: readonly string[];
// For array type
itemConfig?: FieldConfig<unknown>;
}
// Field builder interface (fluent API)
export interface FieldBuilder<T> {
/**
* Set the default value for this field
*/
default(value: T): FieldBuilder<T>;
/**
* Set the delimiter for serialization (used for arrays)
*/
delimiter(separator: string): FieldBuilder<T>;
/**
* Custom serialization function
*/
serialize(fn: (value: T) => string): FieldBuilder<T>;
/**
* Custom parse function
*/
parse(fn: (value: string) => T | null): FieldBuilder<T>;
/**
* Internal config - do not use directly
* @internal
*/
readonly _config: FieldConfig<T>;
}
// Schema definition as a record of field builders
export type SchemaDefinition = Record<string, FieldBuilder<any>>;
// Infer the TypeScript type from a schema definition
export type InferSchemaType<T extends SchemaDefinition> = {
[K in keyof T]: T[K] extends FieldBuilder<infer U> ? U : never;
};
// Schema object returned by createSchema
export interface Schema<T extends SchemaDefinition> {
/**
* The raw schema definition
*/
readonly definition: T;
/**
* Get default values for all fields
*/
readonly defaults: InferSchemaType<T>;
/**
* Inferred TypeScript type (for type inference only)
*/
readonly _type: InferSchemaType<T>;
}
// Store snapshot with version for change detection
export interface StoreSnapshot<T> {
state: T;
version: number;
}
// Adapter options passed during creation
export interface AdapterOptions {
/**
* Unique ID for this table (required for multi-table support)
*/
id: string;
/**
* Initial state to override defaults
*/
initialState?: Record<string, unknown>;
}
src/lib/store/schema/field.ts
/**
* Field Builders for Schema Definition
*
* Provides a fluent API for defining filter field types with serialization.
*/
import {
ARRAY_DELIMITER,
SLIDER_DELIMITER,
SORT_DELIMITER,
} from "@/lib/delimiters";
import type { FieldBuilder, FieldConfig } from "./types";
// Helper to create a field builder from config
function createFieldBuilder<T>(config: FieldConfig<T>): FieldBuilder<T> {
const builder: FieldBuilder<T> = {
default(value: T) {
return createFieldBuilder({ ...config, defaultValue: value });
},
delimiter(separator: string) {
// Update serialize/parse functions when delimiter changes for arrays
if (config.type === "array" && config.itemConfig) {
const itemConfig = config.itemConfig as FieldConfig<unknown>;
return createFieldBuilder({
...config,
delimiter: separator,
serialize: (value: T) => {
if (!Array.isArray(value)) return "";
return value
.map((item) => itemConfig.serialize(item))
.join(separator);
},
parse: (str: string) => {
if (!str) return config.defaultValue;
const items = str.split(separator);
const parsed = items
.map((item) => itemConfig.parse(item))
.filter(
(item): item is NonNullable<typeof item> => item !== null,
);
return parsed as T;
},
});
}
return createFieldBuilder({ ...config, delimiter: separator });
},
serialize(fn: (value: T) => string) {
return createFieldBuilder({ ...config, serialize: fn });
},
parse(fn: (value: string) => T | null) {
return createFieldBuilder({ ...config, parse: fn });
},
get _config() {
return config;
},
};
return builder;
}
// String field
function string(): FieldBuilder<string | null> {
return createFieldBuilder<string | null>({
type: "string",
defaultValue: null,
delimiter: "",
serialize: (value) => (value === null ? "" : String(value)),
parse: (str) => (str === "" ? null : str),
});
}
// Number field (integer)
function number(): FieldBuilder<number | null> {
return createFieldBuilder<number | null>({
type: "number",
defaultValue: null,
delimiter: "",
serialize: (value) => (value === null ? "" : String(value)),
parse: (str) => {
if (str === "") return null;
const num = parseInt(str, 10);
return isNaN(num) ? null : num;
},
});
}
// Boolean field
function boolean(): FieldBuilder<boolean | null> {
return createFieldBuilder<boolean | null>({
type: "boolean",
defaultValue: null,
delimiter: "",
serialize: (value) => (value === null ? "" : String(value)),
parse: (str) => {
if (str === "") return null;
if (str === "true") return true;
if (str === "false") return false;
return null;
},
});
}
// Timestamp field (Date)
function timestamp(): FieldBuilder<Date | null> {
return createFieldBuilder<Date | null>({
type: "timestamp",
defaultValue: null,
delimiter: "",
serialize: (value) => (value === null ? "" : String(value.getTime())),
parse: (str) => {
if (str === "") return null;
const time = parseInt(str, 10);
if (isNaN(time)) return null;
const date = new Date(time);
return isNaN(date.getTime()) ? null : date;
},
});
}
// String literal field (enum-like)
function stringLiteral<T extends readonly string[]>(
literals: T,
): FieldBuilder<T[number] | null> {
return createFieldBuilder<T[number] | null>({
type: "stringLiteral",
defaultValue: null,
delimiter: "",
literals,
serialize: (value) => (value === null ? "" : String(value)),
parse: (str) => {
if (str === "") return null;
return literals.includes(str as T[number]) ? (str as T[number]) : null;
},
});
}
// Array field
function array<T>(itemBuilder: FieldBuilder<T>): FieldBuilder<T[]> {
const itemConfig = itemBuilder._config;
const defaultDelimiter =
itemConfig.type === "number" ? SLIDER_DELIMITER : ARRAY_DELIMITER;
return createFieldBuilder<T[]>({
type: "array",
defaultValue: [],
delimiter: defaultDelimiter,
itemConfig: itemConfig as FieldConfig<unknown>,
serialize: (value) => {
if (!Array.isArray(value) || value.length === 0) return "";
return value
.map((item) => itemConfig.serialize(item))
.join(defaultDelimiter);
},
parse: (str) => {
if (!str) return [];
const items = str.split(defaultDelimiter);
const parsed = items
.map((item) => itemConfig.parse(item))
.filter((item): item is T => item !== null);
return parsed;
},
});
}
// Sort field { id: string, desc: boolean }
function sort(): FieldBuilder<{ id: string; desc: boolean } | null> {
return createFieldBuilder<{ id: string; desc: boolean } | null>({
type: "sort",
defaultValue: null,
delimiter: SORT_DELIMITER,
serialize: (value) => {
if (value === null) return "";
return `${value.id}${SORT_DELIMITER}${value.desc ? "desc" : "asc"}`;
},
parse: (str) => {
if (!str) return null;
const [id, desc] = str.split(SORT_DELIMITER);
if (!id) return null;
return { id, desc: desc === "desc" };
},
});
}
// Export field builders
export const field = {
string,
number,
boolean,
timestamp,
stringLiteral,
array,
sort,
};
src/lib/store/schema/serialization.ts
/**
* Serialization Utilities for Schema
*
* Functions for serializing state to strings and parsing strings back to state.
*/
import type { FieldBuilder, InferSchemaType, SchemaDefinition } from "./types";
/**
* Get default values from a schema definition
*/
export function getSchemaDefaults<T extends SchemaDefinition>(
schema: T,
): InferSchemaType<T> {
const defaults: Record<string, unknown> = {};
for (const [key, fieldBuilder] of Object.entries(schema)) {
defaults[key] = fieldBuilder._config.defaultValue;
}
return defaults as InferSchemaType<T>;
}
/**
* Serialize state to a string map (for URL params or storage)
*/
export function serializeState<T extends SchemaDefinition>(
schema: T,
state: Partial<InferSchemaType<T>>,
): Record<string, string> {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(state)) {
const fieldBuilder = schema[key] as FieldBuilder<unknown> | undefined;
if (!fieldBuilder) continue;
const serialized = fieldBuilder._config.serialize(value);
if (serialized !== "") {
result[key] = serialized;
}
}
return result;
}
/**
* Parse a string map back to state
*/
export function parseState<T extends SchemaDefinition>(
schema: T,
stringMap: Record<string, string | string[] | undefined>,
): Partial<InferSchemaType<T>> {
const result: Record<string, unknown> = {};
for (const [key, fieldBuilder] of Object.entries(schema)) {
const rawValue = stringMap[key];
if (rawValue === undefined) continue;
// Handle array values from URL (Next.js can pass arrays)
const strValue = Array.isArray(rawValue) ? rawValue.join(",") : rawValue;
const parsed = (fieldBuilder as FieldBuilder<unknown>)._config.parse(
strValue,
);
if (parsed !== null) {
result[key] = parsed;
}
}
return result as Partial<InferSchemaType<T>>;
}
/**
* Validate and coerce state, returning defaults for invalid values
*/
export function validateState<T extends SchemaDefinition>(
schema: T,
state: unknown,
): InferSchemaType<T> {
const defaults = getSchemaDefaults(schema);
if (!state || typeof state !== "object") {
return defaults;
}
const result: Record<string, unknown> = { ...defaults };
for (const [key, fieldBuilder] of Object.entries(schema)) {
const value = (state as Record<string, unknown>)[key];
if (value === undefined) continue;
// Serialize and re-parse to validate
const config = (fieldBuilder as FieldBuilder<unknown>)._config;
const serialized = config.serialize(value);
const parsed = config.parse(serialized);
if (parsed !== null) {
result[key] = parsed;
}
// If invalid, default is already in result
}
return result as InferSchemaType<T>;
}
/**
* Merge partial state with defaults
*/
export function mergeWithDefaults<T extends SchemaDefinition>(
schema: T,
partial: Partial<InferSchemaType<T>>,
): InferSchemaType<T> {
const defaults = getSchemaDefaults(schema);
return { ...defaults, ...partial };
}
/**
* Check if two states are equal (shallow comparison for primitives, deep for arrays)
*/
export function isStateEqual<T extends Record<string, unknown>>(
a: T,
b: T,
): boolean {
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
for (const key of keysA) {
const valA = a[key];
const valB = b[key];
if (valA === valB) continue;
// Handle arrays
if (Array.isArray(valA) && Array.isArray(valB)) {
if (valA.length !== valB.length) return false;
for (let i = 0; i < valA.length; i++) {
const itemA = valA[i];
const itemB = valB[i];
if (itemA instanceof Date && itemB instanceof Date) {
if (itemA.getTime() !== itemB.getTime()) return false;
} else if (itemA !== itemB) {
return false;
}
}
continue;
}
// Handle dates
if (valA instanceof Date && valB instanceof Date) {
if (valA.getTime() !== valB.getTime()) return false;
continue;
}
// Handle objects (sort field)
if (
valA !== null &&
valB !== null &&
typeof valA === "object" &&
typeof valB === "object"
) {
const objA = valA as Record<string, unknown>;
const objB = valB as Record<string, unknown>;
if (!isStateEqual(objA, objB)) return false;
continue;
}
return false;
}
return true;
}
/**
* Create a URL search string from state
*/
export function stateToSearchString<T extends SchemaDefinition>(
schema: T,
state: Partial<InferSchemaType<T>>,
): string {
const serialized = serializeState(schema, state);
const params = new URLSearchParams();
for (const [key, value] of Object.entries(serialized)) {
if (value) {
params.set(key, value);
}
}
const str = params.toString();
return str ? `?${str}` : "";
}
src/lib/store/parser/index.ts
/**
* Text Parser Exports
*/
export { createTextParser } from "./text-parser";
export type { TextParser, TextParserOptions } from "./types";
src/lib/store/parser/types.ts
/**
* Text Parser Types
*/
import type { InferSchemaType, SchemaDefinition } from "../schema/types";
/**
* Options for creating a text parser
*/
export interface TextParserOptions {
/**
* Field aliases for shorthand (e.g., { l: 'level', r: 'regions' })
*/
aliases?: Record<string, string>;
/**
* Delimiter between field:value pairs (default: ' ')
*/
fieldDelimiter?: string;
/**
* Delimiter between field and value (default: ':')
*/
keyValueDelimiter?: string;
}
/**
* Text parser interface
*/
export interface TextParser<T extends SchemaDefinition> {
/**
* Parse a text input string into filter state
*
* @example
* ```typescript
* parser.parse('regions:ams,gru latency:0-1000');
* // => { regions: ['ams', 'gru'], latency: [0, 1000] }
* ```
*/
parse(input: string): Partial<InferSchemaType<T>>;
/**
* Serialize filter state to text format
*
* @example
* ```typescript
* parser.serialize({ regions: ['ams', 'gru'], latency: [0, 1000] });
* // => 'regions:ams,gru latency:0-1000'
* ```
*/
serialize(state: Partial<InferSchemaType<T>>): string;
/**
* Get the current word at caret position
*/
getWordAtCaret(
input: string,
caretPosition: number,
): {
word: string;
start: number;
end: number;
field: string | null;
value: string | null;
};
/**
* Replace the current word at caret position
*/
replaceWordAtCaret(
input: string,
caretPosition: number,
replacement: string,
): {
newInput: string;
newCaretPosition: number;
};
/**
* Get suggestions for autocomplete
*/
getSuggestions(
input: string,
caretPosition: number,
fieldOptions: Record<string, string[]>,
): {
type: "field" | "value";
field?: string;
suggestions: string[];
};
}
src/lib/store/parser/text-parser.ts
/**
* Text Parser Implementation
*
* Standalone module for parsing filter command text input.
*/
import type {
FieldBuilder,
InferSchemaType,
SchemaDefinition,
} from "../schema/types";
import type { TextParser, TextParserOptions } from "./types";
/**
* Create a text parser for filter command input
*
* @example
* ```typescript
* import { createTextParser } from '@/lib/store/parser';
*
* const parser = createTextParser(schema.definition, {
* aliases: { l: 'level', r: 'regions' },
* });
*
* // Parse text to state
* const state = parser.parse('level:error,warn regions:ams');
*
* // Serialize state to text
* const text = parser.serialize({ level: ['error', 'warn'], regions: ['ams'] });
* ```
*/
export function createTextParser<T extends SchemaDefinition>(
schema: T,
options: TextParserOptions = {},
): TextParser<T> {
const {
aliases = {},
fieldDelimiter = " ",
keyValueDelimiter = ":",
} = options;
// Build reverse alias map
const reverseAliases: Record<string, string> = {};
for (const [alias, field] of Object.entries(aliases)) {
reverseAliases[field] = alias;
}
// Resolve alias to field name
const resolveAlias = (key: string): string => {
return aliases[key] || key;
};
return {
parse(input: string): Partial<InferSchemaType<T>> {
const result: Record<string, unknown> = {};
if (!input.trim()) {
return result as Partial<InferSchemaType<T>>;
}
// Split by field delimiter, but handle quoted values
const parts = input
.trim()
.split(new RegExp(`\\s*${escapeRegex(fieldDelimiter)}\\s*`));
for (const part of parts) {
if (!part) continue;
const colonIndex = part.indexOf(keyValueDelimiter);
if (colonIndex === -1) continue;
const rawKey = part.slice(0, colonIndex).trim();
const rawValue = part.slice(colonIndex + 1).trim();
if (!rawKey || !rawValue) continue;
const fieldKey = resolveAlias(rawKey);
const fieldBuilder = schema[fieldKey] as
| FieldBuilder<unknown>
| undefined;
if (!fieldBuilder) continue;
try {
const parsed = fieldBuilder._config.parse(rawValue);
if (parsed !== null) {
result[fieldKey] = parsed;
}
} catch {
// Skip invalid values
}
}
return result as Partial<InferSchemaType<T>>;
},
serialize(state: Partial<InferSchemaType<T>>): string {
const parts: string[] = [];
for (const [key, value] of Object.entries(state)) {
if (value === null || value === undefined) continue;
if (Array.isArray(value) && value.length === 0) continue;
const fieldBuilder = schema[key] as FieldBuilder<unknown> | undefined;
if (!fieldBuilder) continue;
try {
const serialized = fieldBuilder._config.serialize(value);
if (serialized) {
parts.push(`${key}${keyValueDelimiter}${serialized}`);
}
} catch {
// Skip invalid values
}
}
return parts.join(fieldDelimiter);
},
getWordAtCaret(input: string, caretPosition: number) {
// Find word boundaries
let start = caretPosition;
let end = caretPosition;
// Move start backwards to find word start
while (start > 0 && input[start - 1] !== fieldDelimiter) {
start--;
}
// Move end forwards to find word end
while (end < input.length && input[end] !== fieldDelimiter) {
end++;
}
const word = input.slice(start, end);
const colonIndex = word.indexOf(keyValueDelimiter);
let field: string | null = null;
let value: string | null = null;
if (colonIndex !== -1) {
field = resolveAlias(word.slice(0, colonIndex));
value = word.slice(colonIndex + 1);
} else {
// Could be a partial field name
field = null;
value = null;
}
return { word, start, end, field, value };
},
replaceWordAtCaret(
input: string,
caretPosition: number,
replacement: string,
) {
const { start, end } = this.getWordAtCaret(input, caretPosition);
const before = input.slice(0, start);
const after = input.slice(end);
const newInput = before + replacement + after;
const newCaretPosition = start + replacement.length;
return { newInput, newCaretPosition };
},
getSuggestions(
input: string,
caretPosition: number,
fieldOptions: Record<string, string[]>,
) {
const { word, field, value } = this.getWordAtCaret(input, caretPosition);
// If we have a field and are typing a value
if (field && value !== null) {
const options = fieldOptions[field] || [];
const filtered = options.filter((opt) =>
opt.toLowerCase().includes(value.toLowerCase()),
);
return {
type: "value" as const,
field,
suggestions: filtered,
};
}
// Otherwise, suggest field names
const schemaKeys = Object.keys(schema);
const aliasKeys = Object.keys(aliases);
const allFields = [...schemaKeys, ...aliasKeys];
const filtered = word
? allFields.filter((f) =>
f.toLowerCase().startsWith(word.toLowerCase()),
)
: allFields;
return {
type: "field" as const,
suggestions: filtered,
};
},
};
}
/**
* Escape special regex characters
*/
function escapeRegex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
src/lib/store/provider/DataTableStoreProvider.tsx
/**
* DataTableStoreProvider - Main provider for BYOS
*
* Wraps components with store adapter context.
*/
"use client";
import React, { useEffect, useMemo } from "react";
import type { StoreAdapter } from "../adapter/types";
import { StoreContext, type StoreContextValue } from "../context";
export interface DataTableStoreProviderProps<
T extends Record<string, unknown>,
> {
/**
* The store adapter to use
*/
adapter: StoreAdapter<T>;
/**
* Child components
*/
children: React.ReactNode;
}
/**
* Provider component for data table store
*
* @example
* ```typescript
* const adapter = useNuqsAdapter(schema.definition, { id: 'my-table' });
*
* return (
* <DataTableStoreProvider adapter={adapter}>
* <DataTable />
* </DataTableStoreProvider>
* );
* ```
*/
export function DataTableStoreProvider<T extends Record<string, unknown>>({
adapter,
children,
}: DataTableStoreProviderProps<T>) {
const value = useMemo<StoreContextValue<T>>(
() => ({
adapter,
schema: adapter.getSchema(),
tableId: adapter.getTableId(),
}),
[adapter],
);
// Cleanup on unmount
useEffect(() => {
return () => {
adapter.destroy();
};
}, [adapter]);
return (
<StoreContext.Provider
value={value as StoreContextValue<Record<string, unknown>>}
>
{children}
</StoreContext.Provider>
);
}
src/lib/store/hooks/index.ts
/**
* BYOS Hooks Exports
*/
export { useFilterState } from "./useFilterState";
export { useFilterActions, type FilterActions } from "./useFilterActions";
export { useFilterField, type FilterFieldResult } from "./useFilterField";
export { useReactTableSync } from "./useReactTableSync";
src/lib/store/hooks/useFilterState.ts
/**
* useFilterState Hook
*
* Read filter state from the store adapter.
*/
"use client";
import { useCallback, useSyncExternalStore } from "react";
import { useStoreContext } from "../context";
/**
* Hook to read filter state from the adapter
*
* @example
* ```typescript
* // Read entire state
* const state = useFilterState();
*
* // Read with selector (for performance)
* const regions = useFilterState(s => s.regions);
* ```
*/
export function useFilterState<T extends Record<string, unknown>, R = T>(
selector?: (state: T) => R,
): R {
const context = useStoreContext();
if (!context) {
throw new Error(
"useFilterState must be used within a DataTableStoreProvider",
);
}
const { adapter } = context;
const subscribe = useCallback(
(onStoreChange: () => void) => adapter.subscribe(onStoreChange),
[adapter],
);
const getSnapshot = useCallback(() => {
const snapshot = adapter.getSnapshot();
const state = snapshot.state as T;
return selector ? selector(state) : (state as unknown as R);
}, [adapter, selector]);
const getServerSnapshot = useCallback(() => {
const snapshot = adapter.getServerSnapshot?.() ?? adapter.getSnapshot();
const state = snapshot.state as T;
return selector ? selector(state) : (state as unknown as R);
}, [adapter, selector]);
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}
src/lib/store/hooks/useFilterActions.ts
/**
* useFilterActions Hook
*
* Get actions to modify filter state.
*/
"use client";
import { useCallback, useMemo } from "react";
import { useStoreContext } from "../context";
/**
* Actions returned by useFilterActions
*/
export interface FilterActions<T extends Record<string, unknown>> {
/**
* Set a single filter field value
*/
setFilter: <K extends keyof T>(key: K, value: T[K]) => void;
/**
* Set multiple filter fields at once
*/
setFilters: (partial: Partial<T>) => void;
/**
* Reset a single filter field to its default value
*/
resetFilter: (key: keyof T) => void;
/**
* Reset all filters to default values
*/
resetAllFilters: () => void;
/**
* Pause state updates (for live mode)
*/
pause: () => void;
/**
* Resume state updates
*/
resume: () => void;
/**
* Check if updates are paused
*/
isPaused: () => boolean;
}
/**
* Hook to get filter actions
*
* @example
* ```typescript
* const { setFilter, setFilters, resetAllFilters, pause, resume } = useFilterActions();
*
* // Set single field
* setFilter('regions', ['ams', 'gru']);
*
* // Set multiple fields
* setFilters({ regions: ['ams'], host: 'api.example.com' });
*
* // Reset all
* resetAllFilters();
*
* // Pause/resume for live mode
* pause();
* resume();
* ```
*/
export function useFilterActions<
T extends Record<string, unknown>,
>(): FilterActions<T> {
const context = useStoreContext();
if (!context) {
throw new Error(
"useFilterActions must be used within a DataTableStoreProvider",
);
}
const { adapter } = context;
const setFilter = useCallback(
<K extends keyof T>(key: K, value: T[K]) => {
adapter.setField(key as string, value);
},
[adapter],
);
const setFilters = useCallback(
(partial: Partial<T>) => {
adapter.setState(partial as Partial<Record<string, unknown>>);
},
[adapter],
);
const resetFilter = useCallback(
(key: keyof T) => {
adapter.reset([key as string]);
},
[adapter],
);
const resetAllFilters = useCallback(() => {
adapter.reset();
}, [adapter]);
const pause = useCallback(() => {
adapter.pause();
}, [adapter]);
const resume = useCallback(() => {
adapter.resume();
}, [adapter]);
const isPaused = useCallback(() => {
return adapter.isPaused();
}, [adapter]);
return useMemo(
() => ({
setFilter,
setFilters,
resetFilter,
resetAllFilters,
pause,
resume,
isPaused,
}),
[
setFilter,
setFilters,
resetFilter,
resetAllFilters,
pause,
resume,
isPaused,
],
);
}
src/lib/store/hooks/useFilterField.ts
/**
* useFilterField Hook
*
* Hook for working with a single filter field.
*/
"use client";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useStoreContext } from "../context";
/**
* Return type for useFilterField
*/
export interface FilterFieldResult<T> {
/**
* Current value of the field
*/
value: T;
/**
* Set the field value
*/
setValue: (value: T) => void;
/**
* Reset the field to its default value
*/
reset: () => void;
}
/**
* Hook to work with a single filter field
*
* @example
* ```typescript
* const { value, setValue, reset } = useFilterField<FilterState, 'regions'>('regions');
*
* // Read value
* console.log(value); // ['ams', 'gru']
*
* // Update value
* setValue(['ams', 'gru', 'fra']);
*
* // Reset to default
* reset();
* ```
*/
export function useFilterField<
T extends Record<string, unknown>,
K extends keyof T,
>(key: K): FilterFieldResult<T[K]> {
const context = useStoreContext();
if (!context) {
throw new Error(
"useFilterField must be used within a DataTableStoreProvider",
);
}
const { adapter } = context;
const subscribe = useCallback(
(onStoreChange: () => void) => adapter.subscribe(onStoreChange),
[adapter],
);
const getSnapshot = useCallback(() => {
const snapshot = adapter.getSnapshot();
return (snapshot.state as T)[key];
}, [adapter, key]);
const getServerSnapshot = useCallback(() => {
const snapshot = adapter.getServerSnapshot?.() ?? adapter.getSnapshot();
return (snapshot.state as T)[key];
}, [adapter, key]);
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
const setValue = useCallback(
(newValue: T[K]) => {
adapter.setField(key as string, newValue);
},
[adapter, key],
);
const reset = useCallback(() => {
adapter.reset([key as string]);
}, [adapter, key]);
return useMemo(() => ({ value, setValue, reset }), [value, setValue, reset]);
}
src/lib/store/hooks/useReactTableSync.ts
/**
* useReactTableSync Hook
*
* Syncs BYOS filter state with React Table's columnFilters.
* This hook provides bidirectional synchronization between the adapter state
* and React Table's internal state.
*/
"use client";
import type { DataTableFilterField } from "@/components/data-table/types";
import type { DataTableFeatures } from "@/lib/table/features";
import type {
ColumnFiltersState,
ReactTable,
RowData,
} from "@tanstack/react-table";
import { useCallback, useEffect, useRef } from "react";
import { useFilterActions } from "./useFilterActions";
import { useFilterState } from "./useFilterState";
interface UseReactTableSyncOptions<TData extends RowData> {
/**
* React Table instance
*/
// `ReactTable` rather than the core `Table`: this hook reads `table.state`,
// which `useTable` adds on top of the core instance.
table: ReactTable<DataTableFeatures, TData>;
/**
* Filter field definitions (to know which fields to sync)
*/
filterFields: DataTableFilterField<TData>[];
/**
* Callback when column filters change (from table)
*/
onColumnFiltersChange?: (filters: ColumnFiltersState) => void;
}
/**
* Hook to synchronize BYOS adapter state with React Table
*
* @example
* ```typescript
* const table = useTable({ features, ... });
*
* useReactTableSync({
* table,
* filterFields,
* });
* ```
*/
export function useReactTableSync<TData extends RowData>({
table,
filterFields,
onColumnFiltersChange,
}: UseReactTableSyncOptions<TData>) {
const filterState = useFilterState<Record<string, unknown>>();
const { setFilters } = useFilterActions<Record<string, unknown>>();
// Track if we're currently syncing to avoid loops
const isSyncingRef = useRef(false);
// Sync BYOS state → React Table
useEffect(() => {
if (isSyncingRef.current) return;
isSyncingRef.current = true;
// Convert filter state to column filters format
for (const field of filterFields) {
const fieldKey = field.value as string;
const value = filterState[fieldKey];
const column = table.getColumn(fieldKey);
if (column) {
// Only update if value is different
const currentValue = column.getFilterValue();
if (!isEqual(currentValue, value)) {
column.setFilterValue(value ?? undefined);
}
}
}
isSyncingRef.current = false;
}, [filterState, filterFields, table]);
// Sync React Table → BYOS state (via callback)
const syncFromTable = useCallback(() => {
if (isSyncingRef.current) return;
isSyncingRef.current = true;
const columnFilters = table.state.columnFilters;
const updates: Record<string, unknown> = {};
for (const field of filterFields) {
const fieldKey = field.value as string;
const filter = columnFilters.find((f) => f.id === fieldKey);
updates[fieldKey] = filter?.value ?? null;
}
setFilters(updates);
onColumnFiltersChange?.(columnFilters);
isSyncingRef.current = false;
}, [table, filterFields, setFilters, onColumnFiltersChange]);
return {
syncFromTable,
};
}
/**
* Simple equality check for filter values
*/
function isEqual(a: unknown, b: unknown): boolean {
if (a === b) return true;
if (a === null || b === null) return a === b;
if (a === undefined || b === undefined) return a === b;
if (Array.isArray(a) && Array.isArray(b)) {
if (a.length !== b.length) return false;
return a.every((val, i) => isEqual(val, b[i]));
}
if (a instanceof Date && b instanceof Date) {
return a.getTime() === b.getTime();
}
if (typeof a === "object" && typeof b === "object") {
const keysA = Object.keys(a as object);
const keysB = Object.keys(b as object);
if (keysA.length !== keysB.length) return false;
return keysA.every((key) =>
isEqual(
(a as Record<string, unknown>)[key],
(b as Record<string, unknown>)[key],
),
);
}
return false;
}
src/lib/store/adapters/memory/index.ts
/**
* In-Memory Adapter for BYOS
*
* A lightweight adapter that stores filter state in refs with a subscriber
* pattern, compatible with React's useSyncExternalStore. No URL sync or
* external store needed — ideal for the builder where state is ephemeral.
*/
"use client";
import { useCallback, useMemo, useRef } from "react";
import type { InternalStoreAdapter } from "../../adapter/types";
import { getSchemaDefaults } from "../../schema/serialization";
import type { SchemaDefinition, StoreSnapshot } from "../../schema/types";
export function useMemoryAdapter<T extends Record<string, unknown>>(
schema: SchemaDefinition,
options?: { id?: string },
): InternalStoreAdapter<T> {
const defaults = useMemo(() => getSchemaDefaults(schema) as T, [schema]);
const stateRef = useRef<T>({ ...defaults });
const versionRef = useRef(0);
const listenersRef = useRef(new Set<() => void>());
const pausedRef = useRef(false);
const pendingRef = useRef<Partial<T> | null>(null);
const notify = useCallback(() => {
for (const listener of listenersRef.current) {
listener();
}
}, []);
const adapter = useMemo<InternalStoreAdapter<T>>(() => {
return {
subscribe(listener: () => void) {
listenersRef.current.add(listener);
return () => {
listenersRef.current.delete(listener);
};
},
getSnapshot(): StoreSnapshot<T> {
return {
state: stateRef.current,
version: versionRef.current,
};
},
getServerSnapshot(): StoreSnapshot<T> {
return {
state: { ...defaults } as T,
version: 0,
};
},
setState(partial: Partial<T>) {
if (pausedRef.current) {
pendingRef.current = {
...(pendingRef.current ?? {}),
...partial,
} as Partial<T>;
return;
}
stateRef.current = { ...stateRef.current, ...partial };
versionRef.current++;
notify();
},
setField<K extends keyof T>(key: K, value: T[K]) {
this.setState({ [key]: value } as unknown as Partial<T>);
},
reset(fields?: (keyof T)[]) {
if (fields) {
const resetPartial: Partial<T> = {};
for (const field of fields) {
resetPartial[field] = defaults[field];
}
this.setState(resetPartial);
} else {
stateRef.current = { ...defaults };
versionRef.current++;
notify();
}
},
pause() {
pausedRef.current = true;
},
resume() {
pausedRef.current = false;
if (pendingRef.current) {
const pending = pendingRef.current;
pendingRef.current = null;
this.setState(pending);
}
},
isPaused() {
return pausedRef.current;
},
destroy() {
listenersRef.current.clear();
},
getTableId() {
return options?.id ?? "memory";
},
getSchema() {
return schema;
},
getDefaults() {
return defaults;
},
};
}, [schema, defaults, notify]);
return adapter;
}
src/lib/format.ts
import { format } from "date-fns";
export function formatLatency(ms: number): string {
if (ms >= 1000) {
return (
new Intl.NumberFormat("en-US", {
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(ms / 1000) + "s"
);
}
return (
new Intl.NumberFormat("en-US", { maximumFractionDigits: 3 }).format(ms) +
"ms"
);
}
export function formatMilliseconds(value: number) {
return new Intl.NumberFormat("en-US", { maximumFractionDigits: 3 }).format(
value,
);
}
export function formatDate(value: Date | string) {
return format(new Date(`${value}`), "LLL dd, y HH:mm");
}
export function formatCompactNumber(value: number) {
if (value >= 100 && value < 1000) {
return value.toString(); // Keep the number as is if it's in the hundreds
} else if (value >= 1000 && value < 1000000) {
return (value / 1000).toFixed(1) + "k"; // Convert to 'k' for thousands
} else if (value >= 1000000) {
return (value / 1000000).toFixed(1) + "M"; // Convert to 'M' for millions
} else {
return value.toString(); // Optionally handle numbers less than 100 if needed
}
}
src/lib/style.ts
import { buttonVariants } from "@/components/ui/button";
/**
* The unprefixed `rounded` / `rounded-*` utilities of a class string, e.g.
* `"rounded-lg"`.
* Variant-prefixed ones (`in-data-[slot=button-group]:rounded-lg`) are
* conditional, so they say nothing about the box at rest and are skipped.
*/
export function getRadiusClassName(className: string, fallback = "rounded-md") {
const radius = className
.split(/\s+/)
.filter((token) => token === "rounded" || token.startsWith("rounded-"))
.join(" ");
return radius || fallback;
}
/**
* The radius of the installed shadcn style, for the boxes these blocks draw
* themselves (filter groups, dropdowns, floating bars, focus rings).
*
* A style does not live in `--radius`: `lyra` keeps `--radius: 0.625rem` and
* writes `rounded-none` into every component instead, so a hardcoded
* `rounded-lg` stays round on a style that is square everywhere else. The one
* place each style spells its radius out where we can read it is the
* `buttonVariants` string, so that is where it comes from.
*/
export const boxRadiusClassName = getRadiusClassName(
buttonVariants({ variant: "outline" }),
);
/**
* The resting background utilities of a class string, light and dark
* (`bg-background dark:bg-input/30`). Interactive ones (`hover:bg-muted`,
* `aria-expanded:bg-muted`) are skipped, and so is `bg-clip-*`, which is not a
* colour.
*/
export function getBackgroundClassName(className: string) {
return className
.split(/\s+/)
.filter((token) => /^(dark:)?bg-(?!clip-)/.test(token))
.join(" ");
}
/**
* The resting border utilities of a class string, light and dark
* (`border border-border dark:border-input`). A style may name a colour twice
* (nova: `border-transparent` in the base, `border-border` in the variant); the
* tokens keep their order, so `cn` resolves them the way it does for the button.
*/
export function getBorderClassName(className: string) {
return className
.split(/\s+/)
.filter((token) => /^(dark:)?border(-|$)/.test(token))
.join(" ");
}
/**
* Radius, border and background of the style's outlined controls, for a box that sits
* next to them and should read as one of them — the checkbox filter list beside
* the date picker and the inputs, which most styles tint in dark mode.
*/
export const boxSurfaceClassName = [
boxRadiusClassName,
getBorderClassName(buttonVariants({ variant: "outline" })),
getBackgroundClassName(buttonVariants({ variant: "outline" })),
]
.filter(Boolean)
.join(" ");
src/lib/delimiters.ts
export const ARRAY_DELIMITER = ",";
export const SLIDER_DELIMITER = "-";
export const SPACE_DELIMITER = "_";
// NOTE: intentionally the same character as SLIDER_DELIMITER. Sliders and
// timeranges both serialize as `min-max`, and the parser picks the branch from
// the column's filter type, not the delimiter. Keep these two in sync — changing
// one without the other silently breaks whichever filter kept the old value.
export const RANGE_DELIMITER = "-";
export const SORT_DELIMITER = ".";
src/lib/is-array.ts
export function isArrayOfNumbers(arr: unknown): arr is number[] {
if (!Array.isArray(arr)) return false;
return arr.every((item) => typeof item === "number");
}
export function isArrayOfDates(arr: unknown): arr is Date[] {
if (!Array.isArray(arr)) return false;
return arr.every((item) => item instanceof Date);
}
export function isArrayOfStrings(arr: unknown): arr is string[] {
if (!Array.isArray(arr)) return false;
return arr.every((item) => typeof item === "string");
}
export function isArrayOfBooleans(arr: unknown): arr is boolean[] {
if (!Array.isArray(arr)) return false;
return arr.every((item) => typeof item === "boolean");
}
src/lib/compose-refs.ts
// @see https://github.com/radix-ui/primitives/blob/main/packages/react/compose-refs/src/composeRefs.tsx
import * as React from "react";
type PossibleRef<T> = React.Ref<T> | undefined;
/**
* Set a given ref to a given value
* This utility takes care of different types of refs: callback refs and RefObject(s)
*/
function setRef<T>(ref: PossibleRef<T>, value: T) {
if (typeof ref === "function") {
ref(value);
} else if (ref !== null && ref !== undefined) {
(ref as React.MutableRefObject<T>).current = value;
}
}
/**
* A utility to compose multiple refs together
* Accepts callback refs and RefObject(s)
*/
function composeRefs<T>(...refs: PossibleRef<T>[]) {
return (node: T) => refs.forEach((ref) => setRef(ref, node));
}
/**
* A custom hook that composes multiple refs
* Accepts callback refs and RefObject(s)
*/
function useComposedRefs<T>(...refs: PossibleRef<T>[]) {
// eslint-disable-next-line react-hooks/exhaustive-deps
return React.useCallback(composeRefs(...refs), refs);
}
export { composeRefs, useComposedRefs };
src/lib/table/filterfns.ts
import { FilterFn } from "@tanstack/react-table";
import { isSameDay } from "date-fns";
import { isArrayOfDates } from "../is-array";
/**
* `FilterFn` is generic over `TFeatures`/`TData` in v9, and both parameters are
* invariant (`in out`). A `const` cannot be generic, and the `filterFns` slot on
* `tableFeatures()` is typed `Record<string, FilterFn<any, any>>` — so `any` is
* the only pair that fits here, and it is exactly what the registry expects.
* These predicates only ever touch `row.getValue()`, so nothing is lost.
*/
type AnyFilterFn = FilterFn<any, any>;
export const inDateRange: AnyFilterFn = (row, columnId, value) => {
const date = new Date(row.getValue(columnId));
const [start, end] = value as Date[];
if (isNaN(date.getTime())) return false;
// if no end date, check if it's the same day
if (!end) return isSameDay(date, start);
// Inclusive bounds to match server-side filtering
return date.getTime() >= start.getTime() && date.getTime() <= end.getTime();
};
inDateRange.autoRemove = (val: unknown) =>
!Array.isArray(val) || !val.length || !isArrayOfDates(val);
export const arrSome: AnyFilterFn = (row, columnId, filterValue) => {
if (!Array.isArray(filterValue)) return false;
return filterValue.some((val) => row.getValue<unknown[]>(columnId) === val);
};
arrSome.autoRemove = (val: unknown) => !Array.isArray(val) || !val?.length;
src/lib/table/features.ts
import { getFacetedUniqueValuesFlattened } from "@/lib/data-table/faceted";
import {
columnFacetingFeature,
columnFilteringFeature,
columnOrderingFeature,
columnResizingFeature,
columnSizingFeature,
columnVisibilityFeature,
createFacetedMinMaxValues,
createFacetedRowModel,
createFilteredRowModel,
createPaginatedRowModel,
createSortedRowModel,
filterFn_arrIncludes,
filterFn_arrIncludesSome,
filterFn_equals,
filterFn_includesString,
filterFn_inNumberRange,
filterFn_weakEquals,
rowPaginationFeature,
rowSelectionFeature,
rowSortingFeature,
sortFn_alphanumeric,
sortFn_datetime,
sortFn_text,
tableFeatures,
} from "@tanstack/react-table";
import { arrSome, inDateRange } from "./filterfns";
/**
* The feature set every data-table block is built against.
*
* TanStack Table v9 no longer bundles features implicitly — each one has to be
* registered here, along with the row-model factory that powers it. Registering
* the set in one module (rather than per table) keeps the blocks consistent and
* gives consumers a single file to edit when they need a feature we don't ship.
*
* Deliberately *not* `stockFeatures`: that pulls in all 17 stock features plus
* every built-in filter/sort function, which defeats the point of shipping
* installable blocks with a small footprint.
*/
export const dataTableFeatures = tableFeatures({
columnFilteringFeature,
filteredRowModel: createFilteredRowModel(),
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
columnFacetingFeature,
facetedRowModel: createFacetedRowModel(),
facetedMinMaxValues: createFacetedMinMaxValues(),
// Array columns are a first-class citizen here (`regions: ["ams", "fra"]`),
// so the flattening variant is the default rather than an opt-in: the stock
// factory would count `["ams", "fra"]` as a single facet value.
facetedUniqueValues: getFacetedUniqueValuesFlattened(),
columnVisibilityFeature,
columnOrderingFeature,
rowSelectionFeature,
// v8's single `ColumnSizing` is split in two: sizes vs. the drag interaction.
columnSizingFeature,
columnResizingFeature,
/**
* Only the paginated blocks use this, but the registry ships one feature set:
* `TFeatures` is invariant, so a second set could not share the provider or
* any shared component type.
*
* Registering it is NOT free. `getRowModel()` routes through
* `paginatedRowModel` whenever this slot exists and `manualPagination` is
* falsy, so any table that paginates server-side (`DataTableInfinite`) must
* set `manualPagination: true` or it silently renders only the first
* `pageSize` (default 10) rows of each fetch.
*/
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel(),
/**
* `columnDef.filterFn` defaults to `'auto'`, which resolves by data type to
* one of `includesString | inNumberRange | equals | arrIncludes |
* inDateRange | weakEquals`. In v9 an unregistered name does not filter at
* all (it warns in dev and returns `undefined`), so the whole auto set has to
* be present for columns that don't name a filter explicitly.
*
* `inDateRange` intentionally shadows the v9 built-in of the same name: ours
* treats a missing end date as "same day" and uses inclusive bounds so the
* client agrees with server-side filtering. That equivalence is pinned by
* `lib/filters/__tests__/conformance-tanstack.test.ts`.
*/
filterFns: {
// The `'auto'` set (`inDateRange` is the sixth, ours, below).
includesString: filterFn_includesString,
inNumberRange: filterFn_inNumberRange,
equals: filterFn_equals,
arrIncludes: filterFn_arrIncludes,
weakEquals: filterFn_weakEquals,
// Not auto-resolved — registered because columns name it directly.
arrIncludesSome: filterFn_arrIncludesSome,
// Ours.
inDateRange,
arrSome,
},
/**
* `columnDef.sortFn` also defaults to `'auto'`, which resolves to `datetime`,
* `alphanumeric`, or `text`. Unlike filters, an unregistered sort name falls
* back to `sortFn_basic` — correct for numbers, wrong for dates and mixed
* alphanumeric strings, so these three are registered.
*/
sortFns: {
datetime: sortFn_datetime,
alphanumeric: sortFn_alphanumeric,
text: sortFn_text,
},
});
/**
* The feature set as a type, for the `TFeatures` parameter that v9 threads
* through `Table`, `Row`, `Column`, `Cell` and `ColumnDef`.
*
* Every shared component pins this type rather than staying generic over
* `TFeatures`. That is not a shortcut, it is the only thing that compiles:
* `TFeatures` is invariant in v9 (`in out`), and TypeScript cannot resolve the
* feature-map lookup behind an unresolved type parameter — so a component
* generic over `TFeatures` can call no feature API at all (`getCanSort`,
* `getFacetedRowModel`, …), and `Table<any, …>` is not a supertype to fall back
* on either.
*
* Registering an extra feature therefore means editing `dataTableFeatures`
* above. Because this type is `typeof dataTableFeatures`, every component
* follows automatically — which is the shadcn-registry model anyway, since
* these files are copied into your project.
*/
export type DataTableFeatures = typeof dataTableFeatures;
src/lib/constants/local-storage.ts
// Column visibility state per table
export const getColumnVisibilityKey = (tableId: string) =>
`data-table-visibility-${tableId}`;
// Column order state per table
export const getColumnOrderKey = (tableId: string) =>
`data-table-column-order-${tableId}`;
// Filter command search history per table
export const getCommandHistoryKey = (tableId: string) =>
`data-table-command-${tableId}`;
// Controls panel open/close state (global)
export const CONTROLS_KEY = "data-table-controls";
src/lib/data-table/types.ts
import { z } from "zod";
export const facetMetadataSchema = z.object({
rows: z.array(z.object({ value: z.any(), total: z.number() })),
total: z.number(),
min: z.number().optional(),
max: z.number().optional(),
});
export type FacetMetadataSchema = z.infer<typeof facetMetadataSchema>;
export type BaseChartSchema = { timestamp: number; [key: string]: number };
src/lib/actions/types.ts
/**
* The wire contract for row actions.
*
* Everything here crosses the network as JSON, so it is deliberately free of
* Drizzle, React, and handler code. The server declares actions next to their
* handlers (`createActionHandler`), projects them to `ActionDescriptor[]` for
* the list response, and stamps each row with the ids it currently qualifies
* for. The client renders buttons from that JSON and never learns what an
* action *does*.
*
* `_actions` on a row is a hint. The handler's WHERE — the id set (or filter)
* intersected with the action's `when` guard — is the authority, so a row that
* changed between fetch and click is skipped, never mis-applied, and the
* response's `applied` may be lower than the number of ids sent.
*/
export type ActionScope = "row" | "bulk" | "filter";
export type ActionVariant = "default" | "destructive";
/** What the client sees. Built by an explicit pick-list, never by omitting keys. */
export type ActionDescriptor = {
/** Stable identifier — it is a URL segment and lands in audit logs. */
id: string;
label: string;
scope: ActionScope[];
variant?: ActionVariant;
/**
* Confirmation copy. `{count}` is replaced with the affected row count and
* `{one|other}` picks a form by it: `"Delete {count} {log|logs}?"`.
*/
confirm?: string;
/** Where to POST. Composed by the server from its `basePath`. */
href: string;
/**
* Upper bound on `ids.length` the server accepts. Set on `bulk` actions by
* the transport (`createActionHandler`'s `maxIds`) so the client can refuse
* an oversized selection before the request instead of after it.
*/
maxIds?: number;
};
/**
* The POST body. `ids` serves both `row` and `bulk` scope; `filter` applies
* the action to every row matching a filter-values object in the same shape
* the list endpoint reads from its search params.
*
* `expected_count` lets the client confirm against the number it showed the
* user — the rows matching `filter` alone. The server compares it to that
* same set (before the action's `when` guard narrows it) and answers
* `count_mismatch` when it has drifted; the client may resend without it to
* apply regardless. `applied` can still be lower than `expected_count`
* because of the guard — that is by design, not drift.
*
* `cmd_id` is client-generated and flows through to the handler and the audit
* hook. Delivery is at-least-once: the server keeps no ledger, so a retry after
* an unknown outcome re-runs the handler.
*/
export type ActionRequest =
| { scope: "ids"; ids: string[]; cmd_id: string }
| {
scope: "filter";
filter: Record<string, unknown>;
expected_count?: number;
cmd_id: string;
};
export type ActionResponse = { applied: number };
export type ActionErrorCode =
| "unknown_action"
| "scope_not_allowed"
| "invalid_request"
| "count_mismatch"
| "forbidden"
| "failed";
export type ActionError = {
error: ActionErrorCode;
/** Present on `count_mismatch`: what the server counted. */
actual?: number;
};
/** Per-row list of action ids the row currently qualifies for. */
export const ROW_ACTIONS_KEY = "_actions";
export type WithRowActions<TRow> = TRow & {
[ROW_ACTIONS_KEY]: string[];
};
src/lib/actions/validate.ts
import type { ActionDescriptor, ActionScope, ActionVariant } from "./types";
/**
* Descriptor validation for the client.
*
* `meta.actions` is server-authored, and in the headless model that server is
* not necessarily ours — the table is pointed at an endpoint and renders what
* comes back. A descriptor is therefore untrusted input that ends up choosing a
* URL the client will POST a list of row ids to. Nothing downstream re-checks
* it: `postAction` sends wherever `href` says.
*
* So descriptors are validated once, on the way in, and anything that fails is
* dropped rather than rendered. A button that cannot be sent safely is worse
* than no button.
*/
const SCOPES: ReadonlySet<string> = new Set<ActionScope>([
"row",
"bulk",
"filter",
]);
const VARIANTS: ReadonlySet<string> = new Set<ActionVariant>([
"default",
"destructive",
]);
/** Copy is rendered, not executed, but an unbounded string is still a DoS. */
const MAX_TEXT_LENGTH = 500;
/** An id is a URL segment and an audit-log key. */
const MAX_ID_LENGTH = 100;
const MAX_HREF_LENGTH = 2048;
/** C0 controls plus DEL — an encoding bug, or smuggling past a first-line check. */
// eslint-disable-next-line no-control-regex
const CONTROL_CHARS = /[\u0000-\u001F\u007F]/;
export type ActionValidationOptions = {
/**
* Origins allowed in addition to the page's own.
*
* Only needed when the actions endpoint lives on another host — the common
* case is a relative `href`, which is always same-origin and needs nothing
* here. Each entry is compared as an origin (`https://api.example.com`), so a
* path in the entry is ignored.
*/
allowedOrigins?: readonly string[];
/**
* The page's own origin. Defaults to `window.location.origin`, and is passed
* explicitly by tests and by server-side rendering.
*/
selfOrigin?: string;
};
function normalizeOrigin(value: string): string | null {
try {
return new URL(value).origin;
} catch {
return null;
}
}
function currentOrigin(options?: ActionValidationOptions): string | null {
if (options?.selfOrigin) return normalizeOrigin(options.selfOrigin);
if (typeof window !== "undefined" && window.location?.origin) {
return window.location.origin;
}
return null;
}
/**
* Is this `href` one we are willing to POST row ids to?
*
* - A root-relative path (`/api/actions/replay`) is same-origin by
* construction and is allowed.
* - A protocol-relative URL (`//evil.example.com/x`) reads like a path and is
* not one. Rejected.
* - An absolute `http(s)` URL is allowed only when its origin is the page's own
* or explicitly allow-listed.
* - Everything else — `javascript:`, `data:`, `blob:`, or a directory-relative
* path that would resolve against whatever page the table happens to be on —
* is rejected.
*/
export function isSafeActionHref(
href: unknown,
options?: ActionValidationOptions,
): href is string {
if (typeof href !== "string" || href.length === 0) return false;
if (href.length > MAX_HREF_LENGTH) return false;
if (CONTROL_CHARS.test(href)) return false;
// Protocol-relative, in both spellings. WHATWG treats `\` as `/` for special
// schemes, so `/\evil.example.com/x` reads here as a root-relative path and
// resolves in the browser as `https://evil.example.com/x` — which would POST
// row ids to an origin the allow-list exists to gate.
if (href.startsWith("//") || href.startsWith("/\\")) return false;
if (href.startsWith("/")) return true;
const parsed = (() => {
try {
return new URL(href);
} catch {
return null;
}
})();
if (!parsed) return false;
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
const allowed = new Set(
(options?.allowedOrigins ?? [])
.map(normalizeOrigin)
.filter((origin): origin is string => origin !== null),
);
const self = currentOrigin(options);
if (self) allowed.add(self);
return allowed.has(parsed.origin);
}
/** Why a descriptor was dropped. Surfaced in the console, not the UI. */
export type ActionRejection = {
/** The `id` when it was readable, otherwise the index in the array. */
id: string;
reason: string;
};
function isNonEmptyString(value: unknown, max: number): value is string {
return typeof value === "string" && value.length > 0 && value.length <= max;
}
/**
* Validate one descriptor. Returns the reason it failed, or `null` when it is
* safe to render.
*/
export function validateActionDescriptor(
value: unknown,
options?: ActionValidationOptions,
): string | null {
if (typeof value !== "object" || value === null) return "not an object";
const action = value as Record<string, unknown>;
if (!isNonEmptyString(action.id, MAX_ID_LENGTH)) return "invalid id";
if (!isNonEmptyString(action.label, MAX_TEXT_LENGTH)) return "invalid label";
if (!Array.isArray(action.scope) || action.scope.length === 0) {
return "invalid scope";
}
if (!action.scope.every((scope) => SCOPES.has(scope as string))) {
return "unknown scope";
}
if (action.variant !== undefined && !VARIANTS.has(action.variant as string)) {
return "unknown variant";
}
if (
action.confirm !== undefined &&
!isNonEmptyString(action.confirm, MAX_TEXT_LENGTH)
) {
return "invalid confirm";
}
if (
action.maxIds !== undefined &&
(typeof action.maxIds !== "number" ||
!Number.isInteger(action.maxIds) ||
action.maxIds <= 0)
) {
return "invalid maxIds";
}
if (!isSafeActionHref(action.href, options)) return "unsafe href";
return null;
}
/**
* Keep the descriptors that are safe to render; report the rest.
*
* Returns the input array itself when nothing was dropped, so a provider can
* memoize on the result without losing referential stability every render.
*/
export function sanitizeActionDescriptors(
actions: readonly unknown[] | undefined,
options?: ActionValidationOptions,
): { actions: ActionDescriptor[]; rejected: ActionRejection[] } {
if (!Array.isArray(actions)) return { actions: [], rejected: [] };
const kept: ActionDescriptor[] = [];
const rejected: ActionRejection[] = [];
const seen = new Set<string>();
actions.forEach((action, index) => {
const reason = validateActionDescriptor(action, options);
if (reason) {
const id = (action as { id?: unknown } | null)?.id;
rejected.push({ id: typeof id === "string" ? id : `#${index}`, reason });
return;
}
const descriptor = action as ActionDescriptor;
// Ids address actions in the UI and in audit logs; a duplicate makes both
// ambiguous, and the second one would be unreachable anyway.
if (seen.has(descriptor.id)) {
rejected.push({ id: descriptor.id, reason: "duplicate id" });
return;
}
seen.add(descriptor.id);
kept.push(descriptor);
});
return {
actions: rejected.length === 0 ? (actions as ActionDescriptor[]) : kept,
rejected,
};
}
src/lib/data-table/faceted.ts
import type { DataTableFilterField } from "@/components/data-table/types";
import type { RowData, RowModel, Table as TTable } from "@tanstack/react-table";
import type { FacetMetadataSchema } from "./types";
/**
* Drop-in replacement for TanStack's `createFacetedUniqueValues` that flattens
* array column values. The built-in version treats `["a", "b"]` as one unique
* value; this version counts each item individually.
*
* Works for both array and non-array columns.
*
* The signature mirrors `createFacetedUniqueValues` so this can be handed to the
* `facetedUniqueValues` slot on `tableFeatures()` in its place — which is the
* only way it is ever used. `TFeatures` is `any` because the slot itself is
* untyped (`(table: any, columnId: string) => ...`) and because naming the
* concrete feature set here would make this module import the features module
* that already imports it.
*/
export function getFacetedUniqueValuesFlattened<
TData extends RowData = any,
>(): (table: TTable<any, TData>, columnId: string) => () => Map<any, number> {
return (table, columnId) => {
return () => {
const facetedRowModel: RowModel<any, TData> | undefined = table
.getColumn(columnId)
?.getFacetedRowModel();
if (!facetedRowModel) return new Map();
const counts = new Map<unknown, number>();
for (const row of facetedRowModel.flatRows) {
const value = row.getValue(columnId);
if (Array.isArray(value)) {
for (const item of value) {
counts.set(item, (counts.get(item) ?? 0) + 1);
}
} else if (value != null) {
counts.set(value, (counts.get(value) ?? 0) + 1);
}
}
return counts;
};
};
}
/**
* Reads pre-computed facets off a server response.
*
* The table argument is accepted only to match the shape the data-table
* provider expects and is deliberately typed `unknown`: these helpers never
* touch it. That also keeps them assignable whatever feature set the calling
* table was built from — `TFeatures` is invariant in v9, so naming a concrete
* one here would lock out customised tables.
*/
export function getFacetedUniqueValues(
facets?: Record<string, FacetMetadataSchema>,
) {
return (_: unknown, columnId: string): Map<string, number> => {
return new Map(
facets?.[columnId]?.rows?.map(({ value, total }) => [value, total]) || [],
);
};
}
export function getFacetedMinMaxValues(
facets?: Record<string, FacetMetadataSchema>,
) {
return (_: unknown, columnId: string): [number, number] | undefined => {
const min = facets?.[columnId]?.min;
const max = facets?.[columnId]?.max;
if (typeof min === "number" && typeof max === "number") return [min, max];
if (typeof min === "number") return [min, min];
if (typeof max === "number") return [max, max];
return undefined;
};
}
/**
* Fold server-computed facets into the filter fields.
*
* A checkbox filter needs its options and a slider needs its bounds, and
* neither is knowable from the schema alone — they depend on the data. Both
* the `/drizzle` client and the schema builder had grown their own copy of
* this merge, which is exactly the sort of thing a table pointed at an
* endpoint has to do for itself.
*
* A field that already carries options keeps them: a schema that spelled out
* its enum values is stating the domain, which is a stronger claim than
* "these are the values present in the rows we happened to load".
*/
export function applyFacets<TData>(
filterFields: readonly DataTableFilterField<TData>[],
facets: Record<string, FacetMetadataSchema> | undefined,
): DataTableFilterField<TData>[] {
if (!facets) return filterFields as DataTableFilterField<TData>[];
return filterFields.map((field) => {
const facet = facets[field.value as string];
if (!facet) return field;
if ("options" in field && field.options && field.options.length > 0) {
return field;
}
const options = (facet.rows ?? []).map(({ value }) => ({
label: `${value}`,
value: value as string | number | boolean,
}));
if (field.type === "slider") {
return {
...field,
min: facet.min ?? field.min,
max: facet.max ?? field.max,
options,
};
}
return { ...field, options };
});
}
src/lib/table-schema/types.ts
import type {
DatePreset,
Option,
} from "@/components/data-table/types";
import type { JSX } from "react";
export type ColKind =
| "string"
| "number"
| "boolean"
| "timestamp"
| "enum"
| "array"
| "record"
| "select";
/** The set of filter UI types. Used as the `F` generic on `ColBuilder<T, F>`. */
export type FilterType = "input" | "checkbox" | "slider" | "timerange";
export type DisplayConfig =
| { type: "text"; colorMap?: Record<string, string> }
| { type: "code"; colorMap?: Record<string, string> }
| { type: "boolean"; colorMap?: Record<string, string> }
| { type: "star"; colorMap?: Record<string, string> }
| { type: "badge"; colorMap?: Record<string, string> }
| { type: "timestamp"; colorMap?: Record<string, string> }
| { type: "number"; unit?: string; colorMap?: Record<string, string> }
| {
type: "bar";
min?: number;
max?: number;
unit?: string;
color?: string;
colorMap?: Record<string, string>;
}
| {
type: "heatmap";
min?: number;
max?: number;
unit?: string;
color?: string;
colorMap?: Record<string, string>;
}
| {
type: "gauge";
min?: number;
max?: number;
unit?: string;
color?: string;
colorMap?: Record<string, string>;
}
| { type: "status-code"; colorMap?: Record<string, string> }
| { type: "level-indicator"; colorMap?: Record<string, string> }
| {
type: "custom";
cell: (value: unknown, row: unknown) => JSX.Element | null;
colorMap?: Record<string, string>;
};
export type SheetConfig = {
label?: string;
component?: (row: unknown) => JSX.Element | null | string;
condition?: (row: unknown) => boolean;
className?: string;
skeletonClassName?: string;
};
/**
* A fluent builder for a single table column.
*
* `T` — TypeScript type of the column's data value (inferred by `InferTableType`).
* `F` — union of filter UI types valid for this col kind (compile-time constraint):
*
* | Factory | Allowed filter types |
* |--------------------|-----------------------------------------|
* | `col.string()` | `"input"` |
* | `col.number()` | `"input" \| "slider" \| "checkbox"` |
* | `col.boolean()` | `"checkbox"` |
* | `col.timestamp()` | `"timerange"` |
* | `col.enum()` | `"checkbox"` |
* | `col.array()` | `"checkbox"` |
* | `col.record()` | `never` — `filterable()` is an error |
*
* Calling `filterable(type)` with a type not in `F` is a **compile-time error**.
*/
export interface ColBuilder<T, F extends FilterType = FilterType> {
/**
* @internal The serializable half of the column. Read it through
* {@link resolveColumn} / {@link resolveColumns} rather than directly.
*/
readonly _descriptor: ColumnDescriptor;
/**
* @internal The non-serializable half — the closures. Disjoint from
* `_descriptor`: the two share no property name.
*/
readonly _renderers: ColRenderers;
/**
* Sets the column header label shown in the table and filter sidebar.
*
* @example
* col.string().label("Host")
* col.enum(LEVELS).label("Severity")
*/
label(text: string): ColBuilder<T, F>;
/**
* Attaches a human-readable description of the column's domain meaning.
*
* Not shown in the UI. Used by AI agents and MCP tools (via `toJSON()`) to
* understand what the column represents.
*
* @example
* col.number().label("Latency").description("Round-trip time from request to response, in ms")
* col.enum(LEVELS).label("Level").description("Log severity: error > warn > info > debug")
*/
description(text: string): ColBuilder<T, F>;
/**
* Sets how the column value is rendered in table cells.
*
* Built-in display types:
* - `"text"` — plain text, truncated with tooltip on overflow
* - `"code"` — monospace font (IDs, hashes, paths, hostnames)
* - `"boolean"` — checkmark / dash icon
* - `"badge"` — colored chip (enums, categories, tags)
* - `"timestamp"` — relative time ("3m ago"), absolute datetime on hover
* - `"number"` — formatted number with optional `unit` suffix
* - `"bar"` — horizontal bar with `min`/`max` range and optional `unit`
* - `"heatmap"` — background color intensity based on `min`/`max` range
* - `"custom"` — developer-supplied JSX renderer (not serializable)
*
* @example
* col.string().display("code")
* col.number().display("number", { unit: "ms" })
* col.number().display("bar", { min: 0, max: 5000, unit: "ms" })
* col.number().display("heatmap", { min: 0, max: 100 })
* col.enum(LEVELS).display("custom", { cell: (value) => <LevelBadge value={value} /> })
*/
display(
type:
| "text"
| "code"
| "boolean"
| "star"
| "badge"
| "timestamp"
| "status-code"
| "level-indicator",
options?: { colorMap?: Record<string, string> },
): ColBuilder<T, F>;
display(
type: "number",
options?: { unit?: string; colorMap?: Record<string, string> },
): ColBuilder<T, F>;
display(
type: "bar",
options: {
min: number;
max: number;
unit?: string;
colorMap?: Record<string, string>;
},
): ColBuilder<T, F>;
display(
type: "heatmap",
options: {
min: number;
max: number;
color?: string;
colorMap?: Record<string, string>;
},
): ColBuilder<T, F>;
display(
type: "custom",
options: {
cell: (value: unknown, row: unknown) => JSX.Element | null;
colorMap?: Record<string, string>;
},
): ColBuilder<T, F>;
display(
type: "heatmap" | "bar" | "gauge",
options?: {
min?: number;
max?: number;
unit?: string;
color?: string;
},
): ColBuilder<T, F>;
/**
* Enables filtering for this column using its default filter type.
*
* For `col.record()` (`F = never`) this is a **compile-time error**.
* Use `.notFilterable()` on record columns instead (it is already the default).
*/
filterable(...args: [F] extends [never] ? [never] : []): ColBuilder<T, F>;
/**
* Enables filtering with an explicit filter type and optional configuration.
*
* Only types in `F` are accepted — passing an invalid type is a **compile-time error**:
* - `"input"` — free-text or number search field
* - `"timerange"` — date range picker (with optional `presets`)
* - `"checkbox"` — multi-select from a list of `options`
* - `"slider"` — numeric range with required `min` / `max` bounds
*
* @example
* col.string().filterable("input")
* col.timestamp().filterable("timerange")
* col.enum(LEVELS).filterable("checkbox", { options: LEVELS.map(v => ({ label: v, value: v })) })
* col.number().filterable("slider", { min: 0, max: 5000 })
*/
filterable(type: F & "input"): ColBuilder<T, "input">;
filterable(
type: F & "timerange",
options?: { presets?: DatePreset[] },
): ColBuilder<T, "timerange">;
filterable(
type: F & "checkbox",
options?: {
options?: Option[];
component?: (props: Option) => JSX.Element | null;
},
): ColBuilder<T, "checkbox">;
filterable(
type: F & "slider",
options: { min: number; max: number; unit?: string },
): ColBuilder<T, "slider">;
/**
* Removes filtering from this column.
*
* After calling `.notFilterable()`, subsequent `.filterable()` calls are
* **compile-time errors** (`F` becomes `never`).
*
* @example
* col.string().label("Request ID").notFilterable().hidden()
*/
notFilterable(): ColBuilder<T, never>;
/**
* Opens the filter accordion for this column by default in the filter sidebar.
*
* Only applies to filterable columns. Use for high-priority filters that
* users should see immediately without expanding the sidebar manually.
*
* @example
* col.enum(LEVELS).label("Level").filterable("checkbox").defaultOpen()
*/
defaultOpen(): ColBuilder<T, F>;
/**
* Excludes this column from the command palette filter search.
*
* Useful for columns whose filter state is managed elsewhere (e.g. a date
* picker in the toolbar) or for UI-only state fields like `cursor`.
*
* @example
* col.timestamp().label("Date").filterable("timerange").commandDisabled()
*/
commandDisabled(): ColBuilder<T, F>;
/**
* Hides the column by default (not shown on first render).
*
* Hidden columns appear in the column visibility menu and can be toggled on.
* Use `getDefaultColumnVisibility(schema)` to derive the initial visibility map.
*
* @example
* col.number().label("DNS").filterable("slider", { min: 0, max: 5000 }).hidden()
*/
hidden(): ColBuilder<T, F>;
/**
* Hides the column header label in the table while keeping the column visible.
*
* The label is still used in the filter sidebar and other UI elements.
*
* @example
* col.enum(LEVELS).label("Level").hideHeader()
*/
hideHeader(): ColBuilder<T, F>;
/**
* Enables column resizing via a drag handle in the header.
*
* Without this, `size` locks the column to a fixed width (`minSize === size`).
* With this, the column can be freely resized; `size` becomes the initial width only.
*
* @example
* col.string().label("Host").size(125).resizable()
*/
resizable(): ColBuilder<T, F>;
/**
* Sets a fixed column width in pixels.
*
* Without `.resizable()`, both `size` and `minSize` are set to this value.
* With `.resizable()`, only `size` is set (initial width), allowing free resizing.
*
* @example
* col.enum(LEVELS).label("Level").size(27)
* col.timestamp().label("Date").size(200)
*/
size(px: number): ColBuilder<T, F>;
/**
* Sets a minimum width in pixels for a flexing column.
*
* A column without `.size()` flexes to absorb the table's leftover width;
* `.minSize()` is its floor — without one, truncating cells let the browser
* compress the flexing column to nothing before anything overflows.
*
* @example
* col.timestamp().label("Date").minSize(200)
*/
minSize(px: number): ColBuilder<T, F>;
/**
* Enables click-to-sort on the column header.
*
* Renders a `DataTableColumnHeader` with ascending/descending/none sort controls.
*
* @example
* col.timestamp().label("Date").sortable()
* col.number().label("Latency").filterable("slider", { min: 0, max: 5000 }).sortable()
* col.presets.timestamp().sortable(false) // switch a preset's sorting off
*/
sortable(enabled?: boolean): ColBuilder<T, F>;
/**
* Marks the data field as potentially `undefined` in the row type.
*
* Changes `T` to `T | undefined` in the inferred schema type (`InferTableType`).
* Use when the column field may be absent from some rows.
*
* @example
* col.number().optional().label("Percentile").notFilterable().hidden()
* col.string().optional().label("Message").notFilterable()
*/
optional(): ColBuilder<T | undefined, F>;
/**
* Includes this column in the row detail drawer (`DataTableSheet`).
*
* Pass a `SheetConfig` to customize label, renderer, visibility condition,
* and layout class. Calling `.sheet()` with no arguments uses defaults
* (label from `.label()`, no custom renderer).
*
* @example
* col.timestamp().label("Date").sheet()
* col.string().label("Host").sheet({ skeletonClassName: "w-24" })
* col.number().label("Latency").sheet({
* component: (row) => <>{row.latency}ms</>,
* skeletonClassName: "w-16",
* })
*/
sheet(config?: SheetConfig): ColBuilder<T, F>;
/**
* Marks the column as sheet-only: hidden, not filterable, and excluded
* from the column visibility dropdown (`enableHiding: false`).
*
* Convenience for `.hidden().notFilterable()` + `enableHiding: false`.
*
* @example
* col.record().label("Headers").sheetOnly().sheet({ ... })
* col.string().optional().label("Message").sheetOnly().sheet({ ... })
*/
sheetOnly(): ColBuilder<T, never>;
}
export type TableSchemaDefinition = Record<string, ColBuilder<unknown, any>>;
// Infer the data row type from a table schema definition
export type InferTableType<T extends TableSchemaDefinition> = {
[K in keyof T]: T[K] extends ColBuilder<infer U, any> ? U : never;
};
// ── Serializable descriptors (function-free) ────────────────────────────────
//
// `ColumnDescriptor` is TOTAL: no serializable property of a column may live
// outside it. `ColRenderers` holds everything that cannot survive JSON. The two
// are disjoint — they share no property name — and that disjointness is
// asserted at compile time in `descriptor-laws.test-d.ts`.
/** A JSON value. Used for `Provenance.args`. */
export type JsonValue =
| string
| number
| boolean
| null
| readonly JsonValue[]
| { readonly [key: string]: JsonValue };
/** Serializable display config — excludes the `custom` variant (contains JSX). */
export type SerializableDisplayConfig = Exclude<
DisplayConfig,
{ type: "custom" }
>;
/** Alias kept for symmetry with the other `*Descriptor` names. */
export type DisplayDescriptor = SerializableDisplayConfig;
/** A checkbox option, stripped to its serializable fields. */
export type OptionDescriptor = {
label: string;
value: string | number | boolean;
};
/**
* A timerange preset with its bounds as ISO 8601 instants.
*
* Known residual loss: presets are usually *relative* ("last hour") and computed
* at construction, so a schema saved on Monday and loaded on Friday round-trips
* byte-exactly but yields a stale range. A relative descriptor
* (`{ duration: "PT1H" }`) is the correct long-term model; the point of storing
* them here is that the loss stops being silent.
*/
export type DatePresetDescriptor = {
label: string;
shortcut: string;
/** ISO 8601 instant. */
from: string;
/** ISO 8601 instant. */
to: string;
};
export type FilterDescriptor = {
type: FilterType;
defaultOpen: boolean;
commandDisabled: boolean;
options?: OptionDescriptor[];
min?: number;
max?: number;
unit?: string;
presets?: DatePresetDescriptor[];
};
export type SheetDescriptor = {
label?: string;
className?: string;
skeletonClassName?: string;
};
/**
* How the column came to be. Recorded at construction so `schemaToTypeScript`
* can read it instead of reverse-engineering the shape of the descriptor.
*/
export type Provenance =
| { source: "manual" }
| { source: "preset"; preset: string; args: readonly JsonValue[] }
| { source: "inferred"; rule: string };
export type ColumnDescriptorCommon = {
label: string;
description?: string;
optional: boolean;
display: DisplayDescriptor;
size?: number;
minSize?: number;
hidden: boolean;
enableHiding: boolean;
hideHeader: boolean;
resizable: boolean;
sortable: boolean;
filter: FilterDescriptor | null;
sheet: SheetDescriptor | null;
provenance: Provenance;
};
/**
* Everything about a column that survives JSON.
*
* `arrayItem` and `enumValues` are **required** on their respective kinds — an
* `array` column can no longer exist without a described item type, which is
* what stopped `string[]` columns from silently degrading to `string`.
*/
export type ColumnDescriptor = ColumnDescriptorCommon &
(
| { kind: "array"; arrayItem: ColumnDescriptor }
| { kind: "enum"; enumValues: readonly string[] }
| { kind: Exclude<ColKind, "array" | "enum"> }
);
/** Everything that CANNOT survive JSON. Disjoint from `ColumnDescriptor`. */
export type ColRenderers = {
cell?: (value: unknown, row: unknown) => JSX.Element | null;
filterComponent?: (props: Option) => JSX.Element | null;
sheetComponent?: (row: unknown) => JSX.Element | null | string;
sheetCondition?: (row: unknown) => boolean;
};
/** The public read model: one column's complete state, both halves. */
export type ResolvedColumn = ColumnDescriptor & {
readonly renderers: ColRenderers;
};
/** `ResolvedColumn` plus the key it is registered under in the definition. */
export type ResolvedColumnEntry = ResolvedColumn & { readonly key: string };
/**
* The current `SchemaJSON` version. Bump when the descriptor shape changes and
* add a step to `migrateSchemaJSON`. The runtime constant lives in
* `serialize.ts` — this module stays type-only so it can keep shipping in the
* `data-table` block.
*/
export type SchemaJSONVersion = 1;
export type SchemaJSON = {
version: SchemaJSONVersion;
columns: Array<ColumnDescriptor & { key: string }>;
};
src/react-table.d.ts
import "@tanstack/react-table";
declare module "@tanstack/react-table" {
// https://github.com/TanStack/table/issues/44#issuecomment-1377024296
//
// v9 also offers per-table `tableMeta`/`columnMeta` slots on `tableFeatures()`,
// which avoid augmenting a global. They are not usable here: the meta types
// below reference `Row<TFeatures, TData>`, and a slot lives inside the very
// features object whose type would have to be named to write that reference.
// Declaration merging takes `TFeatures` as a parameter instead, so it stays
// valid for any feature set a consumer builds.
interface TableMeta<
in out TFeatures extends TableFeatures,
in out TData extends RowData,
> {
getRowClassName?: (row: Row<TFeatures, TData>) => string;
}
interface ColumnMeta<
in out TFeatures extends TableFeatures,
in out TData extends RowData,
TValue extends CellData = CellData,
> {
headerClassName?: string;
cellClassName?: string;
label?: string;
kind?: string;
}
// NOTE: v8 needed a `FilterFns` augmentation plus a `ColumnFiltersOptions`
// workaround (https://github.com/TanStack/table/discussions/4554) to register
// `inDateRange`/`arrSome`. In v9 both are replaced by the `filterFns` slot on
// `tableFeatures()` — see `lib/table/features.ts`.
}
src/constants/date-preset.ts
import { DatePreset } from "@/components/data-table/types";
import { addDays, addHours, endOfDay, startOfDay } from "date-fns";
export const presets = [
{
label: "Today",
from: startOfDay(new Date()),
to: endOfDay(new Date()),
shortcut: "d", // day
},
{
label: "Yesterday",
from: startOfDay(addDays(new Date(), -1)),
to: endOfDay(addDays(new Date(), -1)),
shortcut: "y",
},
{
label: "Last hour",
from: addHours(new Date(), -1),
to: new Date(),
shortcut: "h",
},
{
label: "Last 7 days",
from: startOfDay(addDays(new Date(), -7)),
to: endOfDay(new Date()),
shortcut: "w",
},
{
label: "Last 14 days",
from: startOfDay(addDays(new Date(), -14)),
to: endOfDay(new Date()),
shortcut: "b", // bi-weekly
},
{
label: "Last 30 days",
from: startOfDay(addDays(new Date(), -30)),
to: endOfDay(new Date()),
shortcut: "m",
},
] satisfies DatePreset[];
src/hooks/use-debounce.ts
import * as React from "react";
// consider using https://github.com/xnimorz/use-debounce
export function useDebounce<T>(value: T, delay?: number): T {
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
React.useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay ?? 500);
return () => {
clearTimeout(timer);
};
}, [value, delay]);
return debouncedValue;
}
src/hooks/use-hot-key.ts
import { useEffect, useRef } from "react";
export function useHotKey(
callback: () => void,
key: string,
options?: { shift?: boolean },
): void {
// Use ref to always have the latest callback without re-registering the listener
const callbackRef = useRef(callback);
// eslint-disable-next-line react-hooks/refs
callbackRef.current = callback;
useEffect(() => {
function handler(e: KeyboardEvent) {
const shiftMatch = options?.shift ? e.shiftKey : !e.shiftKey;
if (
e.key.toLowerCase() === key.toLowerCase() &&
(e.metaKey || e.ctrlKey) &&
shiftMatch
) {
// e.preventDefault();
callbackRef.current();
}
}
window.addEventListener("keydown", handler);
return () => {
window.removeEventListener("keydown", handler);
};
}, [key, options?.shift]);
}
src/hooks/use-media-query.ts
import * as React from "react";
export function useMediaQuery(query: string) {
const [value, setValue] = React.useState(false);
React.useEffect(() => {
function onChange(event: MediaQueryListEvent) {
setValue(event.matches);
}
const result = matchMedia(query);
result.addEventListener("change", onChange);
setValue(result.matches);
return () => result.removeEventListener("change", onChange);
}, [query]);
return value;
}
src/hooks/use-local-storage.ts
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
function getItemFromLocalStorage<T>(key: string, fallback: T): T {
if (typeof window === "undefined") return fallback;
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : fallback;
} catch {
return fallback;
}
}
export function useLocalStorage<T>(
key: string,
initialValue: T,
): [T, React.Dispatch<React.SetStateAction<T>>] {
// Render `initialValue` first, even in the browser: the server has no access
// to localStorage, so reading it during the initial render makes the first
// client render differ from the server HTML and hydration fails. The stored
// value is picked up right after mount instead.
const [storedValue, setStoredValue] = useState<T>(initialValue);
const initialValueRef = useRef(initialValue);
// Which key was last written to, so the post-mount read never overwrites a
// fresh write (e.g. an interaction that lands before the effect runs). Scoped
// to the key rather than a bare boolean: a write followed by a `key` change
// before the mount effect flushed would otherwise skip hydration for the new
// key, which never had a write of its own.
const dirtyKeyRef = useRef<string | null>(null);
const isMountRunRef = useRef(true);
// Declared before the effect below so it runs first: effects fire in hook
// order, and the read needs the defaults belonging to the *current* key, not
// the ones captured on the first render.
useEffect(() => {
initialValueRef.current = initialValue;
});
useEffect(() => {
const isMountRun = isMountRunRef.current;
isMountRunRef.current = false;
// Keep a value written before this effect ran; a changed `key` still wins.
if (isMountRun && dirtyKeyRef.current === key) return;
dirtyKeyRef.current = null;
setStoredValue(getItemFromLocalStorage(key, initialValueRef.current));
}, [key]);
const setValue: React.Dispatch<React.SetStateAction<T>> = useCallback(
(value) => {
dirtyKeyRef.current = key;
setStoredValue((prev) => {
const newValue = value instanceof Function ? value(prev) : value;
// Save to localStorage asynchronously to avoid blocking UI
queueMicrotask(() => {
try {
window.localStorage.setItem(key, JSON.stringify(newValue));
} catch {
// Ignore localStorage errors (quota exceeded, etc.)
}
});
return newValue;
});
},
[key],
);
return [storedValue, setValue];
}
src/lib/data-table/interval.ts
/**
* The bucket-size ladder for time-series aggregation.
*
* Thirteen rungs, from one second to 384 minutes (6.4 hours), plus a
* fall-through at 768 minutes for anything longer. Given how long a range is,
* this picks a bucket that yields a readable number of points.
*
* This lived inline in a demo route with no test at any level. It is exported
* here so `DrizzleQueryScope.bucketMs` can be tested directly rather than
* through a chart query.
*/
const INTERVALS: readonly { thresholdMinutes: number; intervalMs: number }[] = [
{ thresholdMinutes: 1, intervalMs: 1_000 }, // 1 second
{ thresholdMinutes: 5, intervalMs: 5_000 }, // 5 seconds
{ thresholdMinutes: 10, intervalMs: 10_000 }, // 10 seconds
{ thresholdMinutes: 30, intervalMs: 30_000 }, // 30 seconds
{ thresholdMinutes: 60, intervalMs: 60_000 }, // 1 minute
{ thresholdMinutes: 120, intervalMs: 120_000 }, // 2 minutes
{ thresholdMinutes: 240, intervalMs: 240_000 }, // 4 minutes
{ thresholdMinutes: 480, intervalMs: 480_000 }, // 8 minutes
{ thresholdMinutes: 1_440, intervalMs: 1_440_000 }, // 24 minutes
{ thresholdMinutes: 2_880, intervalMs: 2_880_000 }, // 48 minutes
{ thresholdMinutes: 5_760, intervalMs: 5_760_000 }, // 96 minutes
{ thresholdMinutes: 11_520, intervalMs: 11_520_000 }, // 192 minutes
{ thresholdMinutes: 23_040, intervalMs: 23_040_000 }, // 384 minutes
];
/** The bucket used for any range longer than the last threshold. */
const MAX_INTERVAL_MS = 46_080_000; // 768 minutes
/**
* Pick a bucket size for a range of `durationMs`.
*
* Negative durations are treated as their absolute value, so callers do not
* have to order the bounds first.
*/
export function evaluateIntervalMs(durationMs: number): number {
if (!Number.isFinite(durationMs)) return MAX_INTERVAL_MS;
const durationMinutes = Math.abs(durationMs) / 60_000;
for (const { thresholdMinutes, intervalMs } of INTERVALS) {
if (durationMinutes < thresholdMinutes) return intervalMs;
}
return MAX_INTERVAL_MS;
}