Data Table
Schema-driven TanStack Table with toolbar, filters, URL state, batch actions, and JSON Logic — published on the plus f-ui registry.
The Data Table module is built on a region architecture: useDataTable() produces a DataTableHandle, <DataTableRoot> provides context, and named region components (DataTableToolbar, DataTableBody, DataTablePagination, …) compose bespoke layouts. <DataTable> is the turnkey recipe for the common case — it assembles those regions with curated props (filtering, paginationMode, chrome, …).
Author tables with a declarative TableSchema (defineTableSchema), pass a static data array or a ListQueryAdapter, and opt into capabilities via features. Filter surfaces are selected with filtering; pagination UX with paginationMode. Hooks such as useTableSearchParams sync sort, filters, and paging to the URL (nuqs). Default density is normal; use defaultDensity="compact" for tighter rows.
Built-in UI strings (toolbar, filter panel, operators, pagination, …) resolve through useDataTableI18n() and FuiI18nProvider—not useDataTable options. See Internationalization for wiring and reactive locale when users switch language without a reload.
Folder layout for maintainers is described in the data-table README beside the source.
Installing
This item is on the plus registry (registry.plus.json), not the open index. Configure @f-ui-plus, set FUI_PLUS_REGISTRY_TOKEN, and verify access as described in Installation — Plus Registry, then run:
FUI_PLUS_REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-plus/data-tableFUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/data-tableFUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/data-tableFUI_PLUS_REGISTRY_TOKEN=xxx bun x shadcn@latest add @f-ui-plus/data-tablePrerequisites / Peers
- nuqs — URL state for filters, sort, and pagination params. Wrap your app with
NuqsAdapterfrom your router's nuqs adapter. Only required if you useuseTableSearchParams; the in-memoryuseLocalTableParamsvariant has no router dependency. - @tanstack/react-table — table core (declared in the registry item).
- @tanstack/react-query — the table always loads rows through a
ListQueryAdapter. Wrap your app with<QueryClientProvider>. Sync adapters (createInMemoryListAdapter,createPrePagedAdapter, and synccreateHybridListAdaptersources) useinitialDataso the first frame is not a loading state. - shadcn components resolved via
registryDependencies(button, select, dialog, sheet, pagination, context-menu, etc.).
URL ⇄ wire naming boundary
The data-table uses camelCase for URL parameters (?page=2&pageSize=50&sortBy=createdAt&sortOrder=desc) and JSON-Logic var references. Adapters are responsible for translating to whatever wire format their backend expects — snake_case for REST, GraphQL field names, etc. URL state stays camelCase regardless of how each adapter chooses to talk to its server.
Region Architecture
Provider + named regions — no compound namespacing (DataTable.Toolbar). Mount only the regions you need; skip empty chrome bands.
| Layer | Role |
|---|---|
useDataTable() | Opt-in orchestrator → DataTableHandle |
<DataTableRoot dataTable={handle}> | Shell, context, fillHeight / stickyHeader |
| Named regions | DataTableToolbar, DataTableViewTabs, DataTableBody, DataTablePagination, DataTableLoadMore, … |
<DataTable> | Recipe that assembles regions for the 80% case |
Every region accepts an optional dataTable prop and resolves the same handle from context when nested under <DataTableRoot>. For bespoke layouts, call useDataTable() yourself and compose regions directly.
Compose named regions instead of the recipe. Swap DataTablePagination for DataTableLoadMore (cursor) or add DataTableViewTabs above the body for preset tabs.
| ORD-1001 | Acme Corp | Confirmed | $2,499.00 |
| ORD-1002 | Globex Inc | Shipped | $849.50 |
| ORD-1003 | Initech | Draft | $120.00 |
| ORD-1004 | Umbrella LLC | Delivered | $3,200.00 |
| ORD-1005 | Soylent Corp | Cancelled | $45.99 |
| ORD-1006 | Wayne Enterprises | Confirmed | $18,750.00 |
| ORD-1007 | Stark Industries | Shipped | $5,600.00 |
| ORD-1008 | Oscorp | Draft | $299.99 |
"use client";
import { useMemo } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DataTableBody } from "@/components/f-ui/data-table/data-table-parts/data-table-body";
import { DataTablePagination } from "@/components/f-ui/data-table/data-table-parts/data-table-pagination";
import { DataTableRoot } from "@/components/f-ui/data-table/data-table-parts/data-table-root";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { useDataTable } from "@/components/f-ui/data-table/use-data-table";
import { type Order, SEED_ORDERS, STATUS_VARIANTS } from "./demo-data";
const TABLE_CODE = "docs-dt-bespoke-regions";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function DataTableBespokeRegionsDemoInner() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
size: 110,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 140,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 100,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 100,
},
},
{},
),
[],
);
const dt = useDataTable<Order>({
schema,
data: SEED_ORDERS,
tableCode: TABLE_CODE,
getRowId: (r) => r.id,
defaultPageSize: 8,
features: {},
});
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Compose named regions instead of the recipe. Swap{" "}
<code className="text-foreground">DataTablePagination</code> for{" "}
<code className="text-foreground">DataTableLoadMore</code> (cursor) or
add <code className="text-foreground">DataTableViewTabs</code> above the
body for preset tabs.
</p>
<div className="h-[min(400px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
<DataTableRoot dataTable={dt} fillHeight className="min-h-0">
{/* <DataTableViewTabs /> — preset filter tabs (surface B) */}
<DataTableBody />
<DataTablePagination />
{/* <DataTableLoadMore /> — cursor "load more" footer */}
</DataTableRoot>
</div>
</div>
);
}
/** Compose named regions instead of the `<DataTable>` recipe. */
export function DataTableBespokeRegionsDemo() {
return (
<QueryClientProvider client={queryClient}>
<DataTableBespokeRegionsDemoInner />
</QueryClientProvider>
);
}Static data prop
Pass a data array for in-memory lists (mutually exclusive with adapter). The hook runs client-side sort, filter, and pagination over the array — ideal for modals, drawers, and docs demos.
<DataTable
schema={schema}
data={rows}
getRowId={(r) => r.id}
filtering="none"
disablePagination
features={{}}
/>| ORD-1001 | Acme Corp | Confirmed | $2,499.00 |
| ORD-1002 | Globex Inc | Shipped | $849.50 |
| ORD-1003 | Initech | Draft | $120.00 |
| ORD-1004 | Umbrella LLC | Delivered | $3,200.00 |
| ORD-1005 | Soylent Corp | Cancelled | $45.99 |
| ORD-1006 | Wayne Enterprises | Confirmed | $18,750.00 |
"use client";
import { useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { type Order, SEED_ORDERS, STATUS_VARIANTS } from "./demo-data";
const TABLE_CODE = "docs-dt-minimal-static";
/** Smallest recipe: static rows, no filter chrome, no pagination footer. */
export function DataTableMinimalStaticDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
size: 110,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 140,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 100,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 100,
},
},
{},
),
[],
);
const rows = useMemo(() => SEED_ORDERS.slice(0, 6), []);
return (
<div className="h-[min(280px,42vh)] min-h-[200px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={rows}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
filtering="none"
disablePagination
features={{}}
/>
</div>
);
}For adapter-shaped in-memory data (legacy call sites), createInMemoryListAdapter({ items }) still works.
Static rows via the data prop and no URL sync. For server paging, pass an adapter and add useTableSearchParams.
| ORD-1001 | Acme Corp | Confirmed | $2,499.00 |
| ORD-1002 | Globex Inc | Shipped | $849.50 |
| ORD-1003 | Initech | Draft | $120.00 |
| ORD-1004 | Umbrella LLC | Delivered | $3,200.00 |
| ORD-1005 | Soylent Corp | Cancelled | $45.99 |
| ORD-1006 | Wayne Enterprises | Confirmed | $18,750.00 |
| ORD-1007 | Stark Industries | Shipped | $5,600.00 |
| ORD-1008 | Oscorp | Draft | $299.99 |
"use client";
import { useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
type Order,
SEED_ORDERS,
STATUS_VARIANTS,
} from "@/demos/data-table/demo-data";
const TABLE_CODE = "docs-dt-embedded";
/**
* In-memory static slice: `data={rows}` + optional `disablePagination` to hide
* the footer. No URL sync in this example.
*/
export function DataTableEmbeddedDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
size: 110,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 140,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 100,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 100,
},
},
{},
),
[],
);
const rows = useMemo(() => SEED_ORDERS.slice(0, 8), []);
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Static rows via the <code>data</code> prop and no URL sync. For
server paging, pass an <code>adapter</code> and add{" "}
<code>useTableSearchParams</code>.
</p>
<div className="h-[min(320px,50vh)] min-h-[220px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={rows}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
filtering="none"
disablePagination
/>
</div>
</div>
);
}Features opt-in
New tables should pass features explicitly on useDataTable / <DataTable>. Absent keys stay off — no filter compilation, selection state, or editing surface.
features key | Gates |
|---|---|
filters | Filter pipeline (field compilation + filter state) |
selection | Row selection + batch actions |
editing | Inline / row / batch editing |
columns | Column manager + resizing |
pagination | { mode?: "offset" | "cursor" | "infinite" } |
<DataTable> derives features from recipe props (filtering, paginationMode, batchActions, …). Direct useDataTable callers pass features explicitly — absent key = off.
useDataTable({
schema,
data: rows,
features: { filters: true, pagination: { mode: "offset" } },
});Filter surfaces (filtering)
The recipe filtering prop selects one of five surfaces. Column-header filters (surface C) compile from schema filter config and work when features.filters is on, even with filtering="none".
filtering | Surface | UI |
|---|---|---|
"none" | — | No filter chrome |
"search" | A | Global keyword search in the toolbar |
"tabs" | B | Segmented preset tabs (<DataTableViewTabs>) |
| (column headers) | C | Per-column header dropdowns (schema filter; filtering="none" + features.filters) |
"faceted" | D | Facet chips (facets prop + <DataTableFacetedFilter>) |
"builder" | E | Add-filter + chips + optional side panel |
Search only (surface A)
Global keyword search in the toolbar — no filter builder or side panel.
| ORD-1001 | Acme Corp | Confirmed | $2,499.00 |
| ORD-1002 | Globex Inc | Shipped | $849.50 |
| ORD-1003 | Initech | Draft | $120.00 |
| ORD-1004 | Umbrella LLC | Delivered | $3,200.00 |
| ORD-1005 | Soylent Corp | Cancelled | $45.99 |
| ORD-1006 | Wayne Enterprises | Confirmed | $18,750.00 |
| ORD-1007 | Stark Industries | Shipped | $5,600.00 |
| ORD-1008 | Oscorp | Draft | $299.99 |
| ORD-1009 | Cyberdyne Systems | Confirmed | $14,200.00 |
| ORD-1010 | Wonka Industries | Delivered | $89.00 |
| ORD-1011 | Acme Corp | Shipped | $1,340.00 |
| ORD-1012 | Globex Inc | Confirmed | $210.50 |
| ORD-1013 | Initech | Draft | $7,800.00 |
| ORD-1014 | Umbrella LLC | Shipped | $450.00 |
| ORD-1015 | Soylent Corp | Confirmed | $1,100.00 |
| ORD-1016 | Wayne Enterprises | Delivered | $9,300.00 |
| ORD-1017 | Stark Industries | Draft | $65.00 |
| ORD-1018 | Oscorp | Cancelled | $2,100.00 |
| ORD-1019 | Cyberdyne Systems | Confirmed | $32,000.00 |
| ORD-1020 | Wonka Industries | Shipped | $175.00 |
"use client";
import { useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { type Order, SEED_ORDERS, STATUS_VARIANTS } from "./demo-data";
const TABLE_CODE = "docs-dt-search-only";
/** Default search toolbar only (surface A) — no builder, panel, or facets. */
export function DataTableSearchOnlyDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
size: 110,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 140,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 100,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 100,
},
},
{},
),
[],
);
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Global keyword search in the toolbar — no filter builder or side panel.
</p>
<div className="h-[min(380px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={SEED_ORDERS}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
filtering="search"
disablePagination
/>
</div>
</div>
);
}View tabs (surface B)
Preset tabs apply saved filter models. Pass initialFilterPresets or wire remote save/delete callbacks.
Preset tabs apply saved filter models — no search bar or builder panel.
| ORD-1001 | Acme Corp | Confirmed | High |
| ORD-1002 | Globex Inc | Shipped | Medium |
| ORD-1003 | Initech | Draft | Low |
| ORD-1004 | Umbrella LLC | Delivered | Medium |
| ORD-1005 | Soylent Corp | Cancelled | Low |
| ORD-1006 | Wayne Enterprises | Confirmed | Urgent |
| ORD-1007 | Stark Industries | Shipped | High |
| ORD-1008 | Oscorp | Draft | Medium |
| ORD-1009 | Cyberdyne Systems | Confirmed | High |
| ORD-1010 | Wonka Industries | Delivered | Low |
| ORD-1011 | Acme Corp | Shipped | Medium |
| ORD-1012 | Globex Inc | Confirmed | Low |
| ORD-1013 | Initech | Draft | Urgent |
| ORD-1014 | Umbrella LLC | Shipped | High |
| ORD-1015 | Soylent Corp | Confirmed | Medium |
| ORD-1016 | Wayne Enterprises | Delivered | High |
| ORD-1017 | Stark Industries | Draft | Low |
| ORD-1018 | Oscorp | Cancelled | Medium |
| ORD-1019 | Cyberdyne Systems | Confirmed | Urgent |
| ORD-1020 | Wonka Industries | Shipped | Low |
"use client";
import { useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
type Order,
SEED_ORDERS,
STATUS_VARIANTS,
PRIORITY_VARIANTS,
} from "./demo-data";
const TABLE_CODE = "docs-dt-view-tabs";
/** Segmented preset tabs (surface B) over the shared filter model. */
export function DataTableViewTabsDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
size: 110,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 140,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 100,
filter: {
valueType: "enum",
paramKey: "status",
logicVar: "status",
options: Object.entries(STATUS_VARIANTS).map(([value, { label }]) => ({
value,
label,
})),
},
},
priority: {
kind: "status",
label: "Priority",
sortKey: "priority",
variants: PRIORITY_VARIANTS,
size: 90,
filter: {
valueType: "enum",
paramKey: "priority",
logicVar: "priority",
options: Object.entries(PRIORITY_VARIANTS).map(([value, { label }]) => ({
value,
label,
})),
},
},
},
{},
),
[],
);
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Preset tabs apply saved filter models — no search bar or builder panel.
</p>
<div className="h-[min(380px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={SEED_ORDERS}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
filtering="tabs"
disablePagination
initialFilterPresets={[
{
id: "preset-all",
name: "All orders",
filterModel: {},
createdAt: new Date("2026-01-01").toISOString(),
updatedAt: new Date("2026-01-01").toISOString(),
},
{
id: "preset-shipped",
name: "Shipped",
filterModel: {
status: { op: "equals", value: "shipped" },
},
createdAt: new Date("2026-01-02").toISOString(),
updatedAt: new Date("2026-01-02").toISOString(),
},
{
id: "preset-urgent",
name: "Urgent",
filterModel: {
priority: { op: "equals", value: "urgent" },
},
createdAt: new Date("2026-01-03").toISOString(),
updatedAt: new Date("2026-01-03").toISOString(),
},
]}
/>
</div>
</div>
);
}Column-header filters (surface C)
Per-column filter icons in headers — no search bar, builder, or facet chips.
| ORD-1001 | Acme Corp | Confirmed | High | $2,499.00 |
| ORD-1002 | Globex Inc | Shipped | Medium | $849.50 |
| ORD-1003 | Initech | Draft | Low | $120.00 |
| ORD-1004 | Umbrella LLC | Delivered | Medium | $3,200.00 |
| ORD-1005 | Soylent Corp | Cancelled | Low | $45.99 |
| ORD-1006 | Wayne Enterprises | Confirmed | Urgent | $18,750.00 |
| ORD-1007 | Stark Industries | Shipped | High | $5,600.00 |
| ORD-1008 | Oscorp | Draft | Medium | $299.99 |
| ORD-1009 | Cyberdyne Systems | Confirmed | High | $14,200.00 |
| ORD-1010 | Wonka Industries | Delivered | Low | $89.00 |
| ORD-1011 | Acme Corp | Shipped | Medium | $1,340.00 |
| ORD-1012 | Globex Inc | Confirmed | Low | $210.50 |
| ORD-1013 | Initech | Draft | Urgent | $7,800.00 |
| ORD-1014 | Umbrella LLC | Shipped | High | $450.00 |
| ORD-1015 | Soylent Corp | Confirmed | Medium | $1,100.00 |
| ORD-1016 | Wayne Enterprises | Delivered | High | $9,300.00 |
| ORD-1017 | Stark Industries | Draft | Low | $65.00 |
| ORD-1018 | Oscorp | Cancelled | Medium | $2,100.00 |
| ORD-1019 | Cyberdyne Systems | Confirmed | Urgent | $32,000.00 |
| ORD-1020 | Wonka Industries | Shipped | Low | $175.00 |
"use client";
import { useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
type Order,
SEED_ORDERS,
STATUS_VARIANTS,
PRIORITY_VARIANTS,
} from "./demo-data";
const TABLE_CODE = "docs-dt-column-header";
/**
* Column-header filters (surface C): schema `filter` compiles to `filterField`
* column meta via `generateColumnDefs`. Recipe uses `filtering="none"` so only
* header dropdowns edit the shared filter model.
*/
export function DataTableColumnHeaderDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
size: 110,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 140,
filter: {
valueType: "string",
paramKey: "customer",
logicVar: "customer",
placeholder: "Search customer…",
},
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 100,
filter: {
valueType: "enum",
paramKey: "status",
logicVar: "status",
options: Object.entries(STATUS_VARIANTS).map(([value, { label }]) => ({
value,
label,
})),
},
},
priority: {
kind: "status",
label: "Priority",
sortKey: "priority",
variants: PRIORITY_VARIANTS,
size: 90,
filter: {
valueType: "enum",
paramKey: "priority",
logicVar: "priority",
options: Object.entries(PRIORITY_VARIANTS).map(([value, { label }]) => ({
value,
label,
})),
},
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 100,
},
},
{},
),
[],
);
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Per-column filter icons in headers — no search bar, builder, or facet
chips.
</p>
<div className="h-[min(380px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={SEED_ORDERS}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
filtering="none"
features={{ filters: true }}
disablePagination
/>
</div>
</div>
);
}Faceted filter (surface D)
Facet chips narrow governed fields without opening the builder panel.
| ORD-1001 | Acme Corp | Confirmed | $2,499.00 |
| ORD-1002 | Globex Inc | Shipped | $849.50 |
| ORD-1003 | Initech | Draft | $120.00 |
| ORD-1004 | Umbrella LLC | Delivered | $3,200.00 |
| ORD-1005 | Soylent Corp | Cancelled | $45.99 |
| ORD-1006 | Wayne Enterprises | Confirmed | $18,750.00 |
| ORD-1007 | Stark Industries | Shipped | $5,600.00 |
| ORD-1008 | Oscorp | Draft | $299.99 |
| ORD-1009 | Cyberdyne Systems | Confirmed | $14,200.00 |
| ORD-1010 | Wonka Industries | Delivered | $89.00 |
| ORD-1011 | Acme Corp | Shipped | $1,340.00 |
| ORD-1012 | Globex Inc | Confirmed | $210.50 |
| ORD-1013 | Initech | Draft | $7,800.00 |
| ORD-1014 | Umbrella LLC | Shipped | $450.00 |
| ORD-1015 | Soylent Corp | Confirmed | $1,100.00 |
| ORD-1016 | Wayne Enterprises | Delivered | $9,300.00 |
| ORD-1017 | Stark Industries | Draft | $65.00 |
| ORD-1018 | Oscorp | Cancelled | $2,100.00 |
| ORD-1019 | Cyberdyne Systems | Confirmed | $32,000.00 |
| ORD-1020 | Wonka Industries | Shipped | $175.00 |
"use client";
import { useMemo } from "react";
import type { FacetCapability } from "@/components/f-ui/data-table/contract/capabilities";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { type Order, SEED_ORDERS, STATUS_VARIANTS } from "./demo-data";
const TABLE_CODE = "docs-dt-faceted-filter";
const ORDER_STATUS_FACETS: ReadonlyArray<FacetCapability> = [
{
id: "status:all",
dimension: "status",
default: true,
neutral: true,
governs: ["status"],
},
{
id: "status:shipped",
dimension: "status",
neutral: false,
governs: ["status"],
},
{
id: "status:delivered",
dimension: "status",
neutral: false,
governs: ["status"],
},
];
/** Faceted multi-select toolbar (surface D). */
export function DataTableFacetedFilterDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
size: 110,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 140,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 100,
filter: false, // R8: facets (D) govern status — no column-header (C).
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 100,
},
},
{},
),
[],
);
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Facet chips narrow governed fields without opening the builder panel.
</p>
<div className="h-[min(380px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={SEED_ORDERS}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
filtering="faceted"
facets={ORDER_STATUS_FACETS}
disablePagination
/>
</div>
</div>
);
}Filter builder (surface E)
The default bazza-style builder: chips, add-filter, and optional side panel. Tune layout with filters={{ surface, chips }} (see Builder layout below).
Bazza-style chips and side panel — the default builder surface.
| ORD-1001 | Acme Corp | Confirmed | High | $2,499.00 |
| ORD-1002 | Globex Inc | Shipped | Medium | $849.50 |
| ORD-1003 | Initech | Draft | Low | $120.00 |
| ORD-1004 | Umbrella LLC | Delivered | Medium | $3,200.00 |
| ORD-1005 | Soylent Corp | Cancelled | Low | $45.99 |
| ORD-1006 | Wayne Enterprises | Confirmed | Urgent | $18,750.00 |
| ORD-1007 | Stark Industries | Shipped | High | $5,600.00 |
| ORD-1008 | Oscorp | Draft | Medium | $299.99 |
| ORD-1009 | Cyberdyne Systems | Confirmed | High | $14,200.00 |
| ORD-1010 | Wonka Industries | Delivered | Low | $89.00 |
"use client";
import { useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
type Order,
SEED_ORDERS,
STATUS_VARIANTS,
PRIORITY_VARIANTS,
} from "./demo-data";
const TABLE_CODE = "docs-dt-builder";
/** Filter builder + chips (surface E) via `filtering="builder"`. */
export function DataTableBuilderDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
size: 110,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 140,
filter: {
valueType: "string",
paramKey: "customer",
logicVar: "customer",
placeholder: "Search customer…",
},
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 100,
filter: {
valueType: "enum",
paramKey: "status",
logicVar: "status",
options: Object.entries(STATUS_VARIANTS).map(([value, { label }]) => ({
value,
label,
})),
},
},
priority: {
kind: "status",
label: "Priority",
sortKey: "priority",
variants: PRIORITY_VARIANTS,
size: 90,
filter: {
valueType: "enum",
paramKey: "priority",
logicVar: "priority",
options: Object.entries(PRIORITY_VARIANTS).map(([value, { label }]) => ({
value,
label,
})),
},
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 100,
filter: {
valueType: "number",
paramKey: "amount",
logicVar: "amount",
},
},
},
{
filterGroups: [
{ label: "Order", fields: ["customer", "status", "priority"] },
{ label: "Details", fields: ["amount"] },
],
},
),
[],
);
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Bazza-style chips and side panel — the default builder surface.
</p>
<div className="h-[min(400px,55vh)] min-h-[280px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={SEED_ORDERS}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
filtering="builder"
defaultPageSize={10}
/>
</div>
</div>
);
}Pagination modes (paginationMode)
paginationMode | Footer region | Data pipeline |
|---|---|---|
"offset" (default) | <DataTablePagination> numbered pager | Offset / page index |
"cursor" | <DataTableLoadMore> manual button | Cursor pages |
"infinite" | <DataTableLoadMoreSentinel> auto-fetch | Cursor pages |
Use disablePagination to hide the footer and show all rows (defaultPageSize: "all"). Cursor and infinite modes require an adapter that returns nextCursor / hasNext.
Scroll the table body — the sentinel fetches the next cursor page automatically (120 rows, 8 per page).
| Loading… | |||
"use client";
import { useMemo } from "react";
import type { ListQueryAdapter } from "@/components/f-ui/data-table/adapters/list-query-contract";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { type Order, expandSeedOrders, STATUS_VARIANTS } from "./demo-data";
const TABLE_CODE = "docs-dt-infinite-scroll";
const PAGE_SIZE = 8;
function createCursorPagedOrdersAdapter(
items: readonly Order[],
): ListQueryAdapter<Order> {
const adapter: ListQueryAdapter<Order> = async (request) => {
const offset = request.cursor ? Number.parseInt(request.cursor, 10) : 0;
const pageSize = Math.max(1, request.pageSize);
const slice = items.slice(offset, offset + pageSize);
const nextOffset = offset + pageSize;
const hasNext = nextOffset < items.length;
return {
items: slice,
totalItems: items.length,
nextCursor: hasNext ? String(nextOffset) : null,
hasNext,
};
};
adapter.versionKey = ["demo-infinite-scroll", String(items.length)];
return adapter;
}
/** Cursor pipeline with auto load-more sentinel (surface infinite). */
export function DataTableInfiniteScrollDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
size: 110,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 140,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 100,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 100,
},
},
{},
),
[],
);
const rows = useMemo(() => expandSeedOrders(6), []);
const adapter = useMemo(
() => createCursorPagedOrdersAdapter(rows),
[rows],
);
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Scroll the table body — the sentinel fetches the next cursor page
automatically ({rows.length} rows, {PAGE_SIZE} per page).
</p>
<div className="h-[min(400px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
adapter={adapter}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
defaultPageSize={PAGE_SIZE}
paginationMode="infinite"
filtering="none"
/>
</div>
</div>
);
}Infinite scroll (live API)
Same cursor / sentinel pipeline as above, but each page is fetched from the public demo orders list API (POST /api/v1/demo/demo-orders/list). The adapter wraps offset pagination as opaque cursors ("2" = page 2). Ensure the database is migrated and seeded (pnpm db:migrate, pnpm run db:seed:demo-orders).
Scroll the table body — each page loads 25 rows from the demo orders API. Requires a migrated, seeded database (pnpm db:migrate, pnpm run db:seed:demo-orders).
| Loading… | ||||
"use client";
import { useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { createDemoOrdersCursorListAdapter } from "@/features/demo-orders/adapters/create-demo-orders-cursor-list-adapter";
import { type Order, STATUS_VARIANTS } from "./demo-data";
const TABLE_CODE = "docs-dt-infinite-api";
const PAGE_SIZE = 25;
/**
* Infinite scroll against the live demo-orders list API (`POST …/demo/demo-orders/list`).
* Each sentinel fetch requests the next offset page via a cursor wrapper.
*/
export function DataTableInfiniteScrollApiDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
pinned: "left",
size: 120,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 160,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 110,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 110,
},
createdAt: {
kind: "date",
label: "Created",
sortKey: "createdAt",
size: 120,
},
},
{},
),
[],
);
const adapter = useMemo(() => createDemoOrdersCursorListAdapter(), []);
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Scroll the table body — each page loads{" "}
<span className="text-foreground font-medium">{PAGE_SIZE}</span> rows from
the demo orders API. Requires a migrated, seeded database (
<code className="text-foreground">pnpm db:migrate</code>,{" "}
<code className="text-foreground">pnpm run db:seed:demo-orders</code>).
</p>
<div className="h-[min(400px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
adapter={adapter}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
defaultPageSize={PAGE_SIZE}
paginationMode="infinite"
filtering="none"
/>
</div>
</div>
);
}Status Tag and formatters
Schema enum columns split by field intent:
| Intent | Column kind | Component | Variant map |
|---|---|---|---|
| Lifecycle / outcome / severity (status, priority) | "status" | StatusTag | StatusVariantMap — { label, tone, icon? } per key; omit icon in tables; use icon: "auto" for tone presets |
| Category / dimension / role (category, channel, type) | "label" | LabelTag | LabelVariantMap — { label, accent? } per key; optional accent: "auto" hashes to chart accents |
Decision rule: if the value changing implies good/bad/urgent → kind: "status". Otherwise → kind: "label". Never use semantic status tones on categorical label fields. See Tag Selection for the full scenario matrix (Badge vs tags, filter chips, review checklist).
import type { LabelVariantMap, StatusVariantMap } from "@/components/f-ui/data-table/schema/types";
const STATUS_VARIANTS: StatusVariantMap = {
active: { label: "Active", tone: "success" },
pending: { label: "Pending", tone: "warning", icon: "auto" },
processing: { label: "Processing", tone: "info", icon: "spin" },
};
const CATEGORY_VARIANTS: LabelVariantMap = {
electronics: { label: "Electronics" },
apparel: { label: "Apparel", accent: "accent-2" },
other: { label: "Other", accent: "auto" },
};
// schema column
{ key: "status", kind: "status", variants: STATUS_VARIANTS }
{ key: "category", kind: "label", variants: CATEGORY_VARIANTS }<StatusTag> and <LabelTag> are also available in custom cell renderers:
import { LabelTag } from "@/components/f-ui/label-tag";
import { resolveLabelAccent } from "@/components/f-ui/label-tag-accent";
import { StatusTag } from "@/components/f-ui/status-tag";
cell: ({ row }) => (
<StatusTag tone="success">{row.original.status}</StatusTag>
);
cell: ({ row }) => (
<LabelTag accent={resolveLabelAccent(row.original.category)}>
{row.original.category}
</LabelTag>
);lib/formatters.ts provides pure display helpers aligned with the data-display spec:
| Helper | Output |
|---|---|
formatTableNumber(n) | Thousands-separated number |
formatAmount(n, { currency, symbol }) | $1,204.00 |
formatAbsoluteDateTime(d) | YYYY-MM-DD HH:mm |
formatDateRange(a, b) | 2018-12-08 ~ 2019-12-07 |
formatRelativeTime(d) | just now / 5 minutes ago / tiered fallbacks |
emptyOr(v) | - for null/empty (aligns with empty-cell placeholder) |
Choosing a configuration
Pick one data mode and add features as needed:
| Situation | Start here |
|---|---|
| Parent already has rows (modal, drawer) | data={rows} or createInMemoryListAdapter — often no nuqs |
| Standard list: sort & paging in the URL, data from a fetcher | adapter + useTableSearchParams |
| Users need ad-hoc filters, shareable links | filtering="builder" (or another surface) + URL hook |
| Workflows with multi-select and bulk actions | features.selection + batchActions |
| API accepts JSON Logic (or you translate to SQL) | buildJsonLogicFilter + adapter |
| Infinite feed or load-more | paginationMode="infinite" or "cursor" + cursor adapter |
| Custom chrome layout | <DataTableRoot> + named regions |
| Operators want more rows visible | defaultDensity="compact" (default remains normal) |
Editing
Opt-in editing is configured on useDataTable({ editing: { … } }) or the same object on the uncontrolled <DataTable>. Add an edit field on schema columns that should participate.
Editing strategies
Every editable column with an edit config routes through DataTableEditableCell, which resolves a field editor from the column kind (edit: true) or an explicit override. Cell and batch strategies compose Inline Edit; row strategy reuses the same editors without per-cell Inline Edit chrome.
scope × commit | Read mode | Per-cell chrome | Commit boundary |
|---|---|---|---|
| cell + immediate | Hover pencil + click display → edit | Save / discard on active cell | commitCell on save; default commitMode: "manual" |
| row + immediate | Normal cells until row edit starts | No pencil; no per-cell save/cancel | Row actions save/cancel via commitRow — start editing with startRowEdit from a row action, not by clicking a cell |
| cell + batch | Hover pencil + click display → edit | Cross discards cell draft (no per-cell save) | DataTableEditingBar Save all / Cancel all |
Cell editing (text)
Use scope: "cell" and commit: "immediate" for per-cell save/cancel. The demo uses the product default commitMode: "manual" — blur and Enter do not commit unless you override. Set commitMode to blur-or-enter or blur when you want focus-based commit instead.
Column authoring: edit: true on a column (editor inferred from kind), or edit: { validate, editor, … } for overrides.
| ORD-1001 | Acme Corp | Ship with padded envelope.
Call before delivery. |
| ORD-1002 | Globex Inc | — |
| ORD-1003 | Initech | Awaiting payment confirmation |
| ORD-1004 | Umbrella LLC | — |
| ORD-1005 | Soylent Corp | Customer requested cancellation |
"use client";
import { useMemo, useState } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { applyOrderCellEdit } from "@/demos/data-table/demo-editing-commit";
import { type Order, SEED_ORDERS } from "@/demos/data-table/demo-data";
const TABLE_CODE = "docs-dt-inline-edit";
export function DataTableInlineEditDemo() {
const [rows, setRows] = useState(() =>
SEED_ORDERS.slice(0, 5).map((row, index) =>
index === 0
? { ...row, notes: "Ship with padded envelope.\nCall before delivery." }
: row,
),
);
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
size: 100,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 140,
edit: true,
},
notes: {
kind: "text",
label: "Notes",
sortKey: "notes",
size: 200,
multiline: true,
edit: true,
},
},
{},
),
[],
);
return (
<div className="h-[min(360px,55vh)] min-h-[240px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={rows}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
filtering="none"
disablePagination
features={{ editing: {
enabled: true,
scope: "cell",
commit: "immediate",
commitMode: "manual",
async onCommit(payload) {
if (payload.strategy !== "cell") return;
const patch = applyOrderCellEdit(payload.columnId, payload.value);
setRows((prev) =>
prev.map((r) =>
r.id === payload.rowId ? { ...r, ...patch } : r,
),
);
},
} }}
/>
</div>
);
}Field-type editors (cell)
Set edit: true on any column whose kind supports editing. The table infers the control from kind (and status / label variants for enum options).
All editable cells use the Inline Edit read/edit row: value on the left, pencil on hover at the right; in edit mode, editor on the left and save/cancel (or cancel-only for batch) on the right. Pickers portal from the editor (filter-density controls in the edit row).
Column kind | Editor | Read display |
|---|---|---|
text | Text input | Truncated string |
number | Number input | Formatted decimal |
currency | Currency input | Formatted money |
date | Date picker | Formatted date |
datetime | Date picker | Formatted date-time |
boolean | Checkbox | Yes / No labels |
status | Select | Status tag |
label | Select | Label tag |
Order # stays read-only in the field-types demo.
| ORD-1001 | Acme Corp | Confirmed | High | Electronics | $2,499.00 | 5 | 2026-03-01 | Yes | Expedite if possible |
| ORD-1002 | Globex Inc | Shipped | Medium | Apparel | $849.50 | 12 | 2026-03-03 | Yes | — |
| ORD-1003 | Initech | Draft | Low | Food | $120.00 | 2 | 2026-03-05 | Yes | Awaiting payment confirmation |
| ORD-1004 | Umbrella LLC | Delivered | Medium | Electronics | $3,200.00 | 1 | 2026-02-20 | Yes | — |
| ORD-1005 | Soylent Corp | Cancelled | Low | Food | $45.99 | 3 | 2026-03-10 | Yes | Customer requested cancellation |
| ORD-1006 | Wayne Enterprises | Confirmed | Urgent | Electronics | $18,750.00 | 50 | 2026-03-12 | Yes | Bulk order — warehouse B |
"use client";
import { useMemo, useState } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { applyOrderCellEdit } from "@/demos/data-table/demo-editing-commit";
import {
type Order,
CATEGORY_VARIANTS,
PRIORITY_VARIANTS,
SEED_ORDERS,
STATUS_VARIANTS,
} from "@/demos/data-table/demo-data";
const TABLE_CODE = "docs-dt-inline-edit-field-types";
/**
* Showcases every inferred field editor (`edit: true` on column `kind`):
* string, number, currency, date, boolean, and enum (badge).
*/
export function DataTableInlineEditFieldTypesDemo() {
const [rows, setRows] = useState(() => SEED_ORDERS.slice(0, 6));
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
size: 108,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 132,
edit: true,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 118,
edit: true,
},
priority: {
kind: "label",
label: "Priority",
sortKey: "priority",
variants: PRIORITY_VARIANTS,
size: 104,
edit: true,
},
category: {
kind: "label",
label: "Category",
sortKey: "category",
variants: CATEGORY_VARIANTS,
size: 118,
edit: true,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 112,
edit: true,
},
quantity: {
kind: "number",
label: "Qty",
sortKey: "quantity",
decimals: 0,
size: 72,
edit: true,
},
orderDate: {
kind: "date",
label: "Order date",
sortKey: "orderDate",
size: 120,
edit: true,
},
shipped: {
kind: "boolean",
label: "Shipped",
trueLabel: "Yes",
falseLabel: "No",
size: 88,
edit: true,
},
notes: {
kind: "text",
label: "Notes",
sortKey: "notes",
size: 148,
edit: true,
},
},
{},
),
[],
);
return (
<div className="h-[min(440px,62vh)] min-h-[300px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={rows}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
filtering="none"
disablePagination
editing={{
enabled: true,
scope: "cell",
commit: "immediate",
commitMode: "manual",
async onCommit(payload) {
if (payload.strategy !== "cell") return;
const patch = applyOrderCellEdit(payload.columnId, payload.value);
setRows((prev) =>
prev.map((r) =>
r.id === payload.rowId ? { ...r, ...patch } : r,
),
);
},
}}
/>
</div>
);
}Datetime column
datetime columns share the date picker editor (valueType: "datetime"). Store ISO strings (or values your adapter accepts) and coerce in onCommit if needed.
| ORD-1001 | Acme Corp | Mar 1, 2026 14:30 |
| ORD-1002 | Globex Inc | Mar 3, 2026 14:30 |
| ORD-1003 | Initech | Mar 5, 2026 14:30 |
| ORD-1004 | Umbrella LLC | Feb 20, 2026 14:30 |
"use client";
import { useMemo, useState } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { applyOrderCellEdit } from "@/demos/data-table/demo-editing-commit";
import { type Order, SEED_ORDERS } from "@/demos/data-table/demo-data";
const TABLE_CODE = "docs-dt-inline-edit-datetime";
/** `datetime` columns use the same date picker editor as `date` (`valueType: "datetime"`). */
export function DataTableInlineEditDatetimeDemo() {
const [rows, setRows] = useState(() =>
SEED_ORDERS.slice(0, 4).map((r) => ({
...r,
createdAt: `${r.createdAt}T14:30:00.000Z`,
})),
);
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
size: 108,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 140,
},
createdAt: {
kind: "datetime",
label: "Created",
sortKey: "createdAt",
format: "MMM d, yyyy HH:mm",
size: 168,
edit: true,
},
},
{},
),
[],
);
return (
<div className="h-[min(280px,42vh)] min-h-[200px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={rows}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
filtering="none"
disablePagination
editing={{
enabled: true,
scope: "cell",
commit: "immediate",
commitMode: "manual",
async onCommit(payload) {
if (payload.strategy !== "cell") return;
const patch = applyOrderCellEdit(payload.columnId, payload.value);
setRows((prev) =>
prev.map((r) =>
r.id === payload.rowId ? { ...r, ...patch } : r,
),
);
},
}}
/>
</div>
);
}Row editing
While a row is active, editable columns render editors only (no read/edit toggle or pencil). Use scope: "row" and rowActions (for example Edit row) that calls editing.startRowEdit(rowId, row, snapshotValues). The actions column swaps to save/cancel while the row is active. onCommit receives { strategy: "row", row, rowId, previousValues, values }.
Use the pencil action to enter row edit mode; save or cancel from the actions column.
Actions | |||||||
|---|---|---|---|---|---|---|---|
| ORD-1001 | Acme Corp | Confirmed | $2,499.00 | 5 | No | Expedite if possible | |
| ORD-1002 | Globex Inc | Shipped | $849.50 | 12 | Yes | — | |
| ORD-1003 | Initech | Draft | $120.00 | 2 | No | Awaiting payment confirmation | |
| ORD-1004 | Umbrella LLC | Delivered | $3,200.00 | 1 | Yes | — | |
| ORD-1005 | Soylent Corp | Cancelled | $45.99 | 3 | No | Customer requested cancellation |
"use client";
import { useMemo, useRef, useState } from "react";
import { PencilIcon } from "lucide-react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import type { UseDataTableEditingReturn } from "@/components/f-ui/data-table/hooks/use-data-table-editing";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import type { RowAction } from "@/components/f-ui/data-table/schema/types";
import { useDataTable } from "@/components/f-ui/data-table/use-data-table";
import { applyOrderRowEdit } from "@/demos/data-table/demo-editing-commit";
import {
type Order,
SEED_ORDERS,
STATUS_VARIANTS,
} from "@/demos/data-table/demo-data";
const TABLE_CODE = "docs-dt-edit-row";
function rowEditSnapshot(row: Order): Record<string, unknown> {
return {
customer: row.customer,
status: row.status,
amount: row.amount,
quantity: row.quantity,
shipped: row.shipped,
notes: row.notes,
};
}
export function DataTableRowEditingDemo() {
const [rows, setRows] = useState(() => SEED_ORDERS.slice(0, 5));
const editingRef = useRef<UseDataTableEditingReturn<Order> | null>(null);
const rowActions = useMemo<RowAction<Order>[]>(
() => [
{
id: "edit-row",
label: "Edit row",
inline: true,
icon: <PencilIcon className="size-4" aria-hidden />,
onClick: (row) => {
editingRef.current?.startRowEdit(row.id, row, rowEditSnapshot(row));
},
},
],
[],
);
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
size: 100,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 132,
edit: true,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 112,
edit: true,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 108,
edit: true,
},
quantity: {
kind: "number",
label: "Qty",
sortKey: "quantity",
decimals: 0,
size: 72,
edit: true,
},
shipped: {
kind: "boolean",
label: "Shipped",
trueLabel: "Yes",
falseLabel: "No",
size: 88,
edit: true,
},
notes: {
kind: "text",
label: "Notes",
sortKey: "notes",
size: 140,
edit: true,
},
},
{},
),
[],
);
const dt = useDataTable<Order>({
schema,
data: rows,
tableCode: TABLE_CODE,
getRowId: (r) => r.id,
defaultPageSize: "all",
rowActions,
rowActionsDisplay: "inline",
editing: {
enabled: true,
scope: "row",
commit: "immediate",
async onCommit(payload) {
if (payload.strategy !== "row") return;
const patch = applyOrderRowEdit(payload.values);
setRows((prev) =>
prev.map((r) =>
r.id === payload.rowId ? { ...r, ...patch } : r,
),
);
},
},
});
editingRef.current = dt.editing;
return (
<div className="h-[min(400px,58vh)] min-h-[260px] overflow-hidden rounded-md border">
<DataTable<Order>
dataTable={dt}
fillHeight
className="min-h-0"
filtering="none"
disablePagination
/>
</div>
);
}Table editing
Use scope: "cell" and commit: "batch" to accumulate cell drafts and flush them in one onCommit payload: { strategy: "batch", changes }.
Staging flow (batch buffer):
- Hover a cell, click the value or pencil, edit in place.
- Edits stage as you type; blur does not exit the cell — only cross (discard) or Save all ends the batch session for that cell.
- Staged values render in medium weight until you flush or cancel.
DataTableEditingBaris always visible in batch mode (Save all disabled until something is staged).
| ORD-1001 | Acme Corp | Confirmed | $2,499.00 | Expedite if possible |
| ORD-1002 | Globex Inc | Shipped | $849.50 | — |
| ORD-1003 | Initech | Draft | $120.00 | Awaiting payment confirmation |
| ORD-1004 | Umbrella LLC | Delivered | $3,200.00 | — |
| ORD-1005 | Soylent Corp | Cancelled | $45.99 | Customer requested cancellation |
"use client";
import { useMemo, useState } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { applyOrderCellEdit } from "@/demos/data-table/demo-editing-commit";
import {
type Order,
SEED_ORDERS,
STATUS_VARIANTS,
} from "@/demos/data-table/demo-data";
const TABLE_CODE = "docs-dt-edit-table";
/** Batch staging demo: inline text + popover pickers, then Save all. */
export function DataTableTableEditingDemo() {
const [rows, setRows] = useState(() => SEED_ORDERS.slice(0, 5));
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
size: 100,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 140,
edit: true,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 112,
edit: true,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 112,
edit: true,
},
notes: {
kind: "text",
label: "Notes",
sortKey: "notes",
size: 160,
edit: true,
},
},
{},
),
[],
);
return (
<div className="h-[min(360px,55vh)] min-h-[240px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={rows}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
filtering="none"
disablePagination
editing={{
enabled: true,
scope: "cell",
commit: "batch",
async onCommit(payload) {
if (payload.strategy !== "batch") return;
setRows((prev) =>
payload.changes.reduce((acc, change) => {
const patch = applyOrderCellEdit(
change.columnId,
change.value,
);
return acc.map((r) =>
r.id === change.rowId ? { ...r, ...patch } : r,
);
}, prev),
);
},
}}
/>
</div>
);
}Editing options (API)
| Option | Description |
|---|---|
editing.enabled | When true, exposes the editing surface on the table handle. |
editing.scope | "cell" (per-cell) or "row" (row-wide editors after startRowEdit). |
editing.commit | "immediate" (commit per cell/row) or "batch" (stage cell drafts; flush via commitAll / DataTableEditingBar). |
editing.commitMode | Passed to Inline Edit for commit === "immediate" only. Batch always uses manual on cells (blur/Enter do not end edit); drafts stage on change. |
editing.onCommit | Called with EditMutationInput (strategy: "cell", "row", or "batch") after validation succeeds. |
editing.mutation | Alternative to onCommit: async adapter invoked with the same payload shape. |
editing.validateCell | Optional async validator for cell-shaped payloads (including each entry in batch commits). |
editing.validateRow | Optional async validator for row drafts (cross-field rules; field validate receives siblings()). |
Column edit | true infers editor from column kind; or edit: { validate, editor, presentation, … }. |
editing.state | Controlled: current { cellDrafts, activeRowId? }; use together with onStateChange. |
editing.onStateChange | Controlled: receives the next ControlledDataTableEditingState when hook actions mutate drafts. |
Client mode (API window, then in-browser list)
One request loads the first 300 rows from the public demo list API (POST /api/v1/demo/demo-orders/list, Gostart-shaped envelope). After that, filter, sort, and pagination are handled entirely in the browser via the same in-memory list pipeline as createInMemoryListAdapter (see createClientWindowListQueryAdapter). The table may show a brief loading state on first paint until the window is fetched; ensure the database is migrated and seeded (pnpm db:migrate, pnpm run db:seed:demo-orders).
| Loading… | |||||||||||||
"use client";
import { useMemo } from "react";
import { createClientWindowListQueryAdapter } from "@/components/f-ui/data-table/adapters/create-client-window-list-adapter";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { createDemoOrdersListAdapter } from "@/features/demo-orders/adapters/create-demo-orders-list-adapter";
import {
type Order,
STATUS_VARIANTS,
PRIORITY_VARIANTS,
CATEGORY_VARIANTS,
} from "@/demos/data-table/demo-data";
const TABLE_CODE = "docs-dt-client-1k";
/** First page size for the one-time API load; filter/sort/page run in-browser after that. */
const CLIENT_WINDOW = 300;
/** Static options for the channel column’s async-select filter (matches demo order data). */
const CLIENT_DEMO_CHANNEL_FILTER_OPTIONS: ReadonlyArray<{
value: string;
label: string;
}> = [
{ value: "web", label: "Web storefront" },
{ value: "phone", label: "Phone" },
{ value: "edi", label: "EDI" },
{ value: "pos", label: "POS" },
];
function clientDemoChannelFilterOptionsHook() {
return { data: CLIENT_DEMO_CHANNEL_FILTER_OPTIONS, isLoading: false };
}
/**
* Fetches the first 300 orders from the real demo list API, then filters,
* sorts, and paginates in the browser (in-memory pipeline). Schema includes
* every field on `Order` (same surface as the showcases / filters examples).
*/
export function DataTableClientModeDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
pinned: "left",
size: 120,
filter: {
valueType: "string",
paramKey: "orderNumber",
logicVar: "orderNumber",
},
},
id: {
kind: "text",
label: "ID",
sortKey: "id",
size: 100,
filter: {
valueType: "string",
paramKey: "id",
logicVar: "id",
},
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 180,
filter: {
valueType: "string",
paramKey: "customer",
logicVar: "customer",
placeholder: "Search customer…",
},
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 120,
filter: {
valueType: "enum",
paramKey: "status",
logicVar: "status",
options: Object.entries(STATUS_VARIANTS).map(
([value, { label }]) => ({ value, label }),
),
},
},
priority: {
kind: "status",
label: "Priority",
sortKey: "priority",
variants: PRIORITY_VARIANTS,
size: 100,
filter: {
valueType: "enum",
paramKey: "priority",
logicVar: "priority",
options: Object.entries(PRIORITY_VARIANTS).map(
([value, { label }]) => ({ value, label }),
),
},
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 120,
filter: {
valueType: "number",
paramKey: "amount",
logicVar: "amount",
unit: "USD",
},
},
quantity: {
kind: "number",
label: "Qty",
sortKey: "quantity",
decimals: 0,
size: 72,
filter: {
valueType: "number",
paramKey: "quantity",
logicVar: "quantity",
},
},
orderDate: {
kind: "date",
label: "Order date",
sortKey: "orderDate",
size: 120,
filter: {
valueType: "date",
paramKey: "orderDate",
logicVar: "orderDate",
},
},
createdAt: {
kind: "date",
label: "Created at",
sortKey: "createdAt",
size: 120,
filter: {
valueType: "date",
paramKey: "createdAt",
logicVar: "createdAt",
},
},
channel: {
kind: "text",
label: "Channel",
sortKey: "channel",
size: 120,
filter: {
valueType: "enum",
paramKey: "channel",
logicVar: "channel",
options: CLIENT_DEMO_CHANNEL_FILTER_OPTIONS,
optionsHook: clientDemoChannelFilterOptionsHook,
},
},
shipped: {
kind: "boolean",
label: "Shipped?",
sortKey: "shipped",
trueLabel: "Yes",
falseLabel: "No",
size: 88,
filter: {
valueType: "boolean",
paramKey: "shipped",
logicVar: "shipped",
},
},
shippedDate: {
kind: "date",
label: "Shipped date",
sortKey: "shippedDate",
size: 120,
filter: {
valueType: "date",
paramKey: "shippedDate",
logicVar: "shippedDate",
nullable: true,
},
},
category: {
kind: "label",
label: "Category",
sortKey: "category",
variants: CATEGORY_VARIANTS,
size: 120,
filter: {
valueType: "enum",
paramKey: "category",
logicVar: "category",
options: Object.entries(CATEGORY_VARIANTS).map(
([value, { label }]) => ({ value, label }),
),
},
},
notes: {
kind: "text",
label: "Notes",
sortKey: "notes",
size: 200,
filter: {
valueType: "string",
paramKey: "notes",
logicVar: "notes",
nullable: true,
},
},
},
{
filterGroups: [
{
label: "Order",
fields: [
"orderNumber",
"id",
"customer",
"status",
"priority",
"channel",
],
},
{
label: "Details",
fields: [
"amount",
"quantity",
"orderDate",
"createdAt",
"shipped",
"shippedDate",
"category",
"notes",
],
},
],
},
),
[],
);
const listApi = useMemo(() => createDemoOrdersListAdapter(), []);
const adapter = useMemo(
() =>
createClientWindowListQueryAdapter<Order>({
loadWindow: (signal) =>
Promise.resolve(
listApi({
page: 1,
pageSize: CLIENT_WINDOW,
filter: null,
sort: undefined,
signal,
}),
).then((r) => r.items),
}),
[listApi],
);
return (
<DataTable<Order>
fillHeight
className="h-[min(520px,75vh)] min-h-[280px]"
schema={schema}
adapter={adapter}
getRowId={(r) => r.id}
tableCode={TABLE_CODE}
defaultPageSize={20}
pageSizeOptions={[10, 20, 50, 100, "all"]}
showDensityControl
filtering="builder"
features={{ filters: true, columns: { manager: true } }}
/>
);
}List or report page (URL + adapter, no filter UI)
Use for browse-heavy screens: sorting and paging should bookmark and survive refresh. Wire createInMemoryListAdapter or your API adapter with useTableSearchParams (nuqs). Enable showDensityControl when users may change row height. Below: ten columns, pinned Order #, filter UI off (filters omitted).
| ORD-1001 | Acme Corp | Confirmed | High | $2,499.00 | 5 | 2026-03-01 | — | No | Electronics |
| ORD-1002 | Globex Inc | Shipped | Medium | $849.50 | 12 | 2026-03-03 | 2026-03-07 | Yes | Apparel |
| ORD-1003 | Initech | Draft | Low | $120.00 | 2 | 2026-03-05 | — | No | Food |
| ORD-1004 | Umbrella LLC | Delivered | Medium | $3,200.00 | 1 | 2026-02-20 | 2026-02-25 | Yes | Electronics |
| ORD-1005 | Soylent Corp | Cancelled | Low | $45.99 | 3 | 2026-03-10 | — | No | Food |
| ORD-1006 | Wayne Enterprises | Confirmed | Urgent | $18,750.00 | 50 | 2026-03-12 | — | No | Electronics |
| ORD-1007 | Stark Industries | Shipped | High | $5,600.00 | 8 | 2026-03-08 | 2026-03-14 | Yes | Electronics |
| ORD-1008 | Oscorp | Draft | Medium | $299.99 | 4 | 2026-03-15 | — | No | Home |
| ORD-1009 | Cyberdyne Systems | Confirmed | High | $14,200.00 | 20 | 2026-03-11 | — | No | Electronics |
| ORD-1010 | Wonka Industries | Delivered | Low | $89.00 | 6 | 2026-02-28 | 2026-03-04 | Yes | Food |
"use client";
import { useMemo } from "react";
import { parseAsInteger } from "nuqs";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
type Order,
SEED_ORDERS,
STATUS_VARIANTS,
PRIORITY_VARIANTS,
CATEGORY_VARIANTS,
} from "./demo-data";
export function DataTableReadonlyDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
pinned: "left",
size: 120,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 160,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 110,
},
priority: {
kind: "status",
label: "Priority",
sortKey: "priority",
variants: PRIORITY_VARIANTS,
size: 100,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 120,
},
quantity: {
kind: "number",
label: "Qty",
sortKey: "quantity",
decimals: 0,
size: 80,
},
orderDate: {
kind: "date",
label: "Order Date",
sortKey: "orderDate",
size: 120,
},
shippedDate: {
kind: "date",
label: "Shipped",
sortKey: "shippedDate",
size: 120,
},
shipped: {
kind: "boolean",
label: "Shipped?",
trueLabel: "Yes",
falseLabel: "No",
size: 90,
},
category: {
kind: "label",
label: "Category",
sortKey: "category",
variants: CATEGORY_VARIANTS,
size: 110,
},
},
{},
),
[],
);
const { params, setParams } = useTableSearchParams(schema, {
urlParamPrefix: "dt_ro_",
extraParsers: {
pageSize: parseAsInteger.withDefault(10),
},
});
return (
<DataTable<Order>
fillHeight
className="h-[min(520px,75vh)] min-h-[280px]"
schema={schema}
data={SEED_ORDERS}
tableCode="docs-dt-readonly"
params={params}
onParamsChange={setParams}
filtering="none"
showDensityControl
/>
);
}Filters and shareable URLs
Use when analysts or admins need multi-criteria filters and shareable filter state. Keep useTableSearchParams and set filtering="builder" (surface E). Filter map syncs through the same params object. Column filters use valueType and optional presentation on filter config (not legacy dataType).
Filter state syncs to the URL via nuqs. Active filters: 0
| ORD-1001 | Acme Corp | Confirmed | High | $2,499.00 | 5 | 2026-03-01 | No | Electronics |
| ORD-1002 | Globex Inc | Shipped | Medium | $849.50 | 12 | 2026-03-03 | Yes | Apparel |
| ORD-1003 | Initech | Draft | Low | $120.00 | 2 | 2026-03-05 | No | Food |
| ORD-1004 | Umbrella LLC | Delivered | Medium | $3,200.00 | 1 | 2026-02-20 | Yes | Electronics |
| ORD-1005 | Soylent Corp | Cancelled | Low | $45.99 | 3 | 2026-03-10 | No | Food |
| ORD-1006 | Wayne Enterprises | Confirmed | Urgent | $18,750.00 | 50 | 2026-03-12 | No | Electronics |
| ORD-1007 | Stark Industries | Shipped | High | $5,600.00 | 8 | 2026-03-08 | Yes | Electronics |
| ORD-1008 | Oscorp | Draft | Medium | $299.99 | 4 | 2026-03-15 | No | Home |
| ORD-1009 | Cyberdyne Systems | Confirmed | High | $14,200.00 | 20 | 2026-03-11 | No | Electronics |
| ORD-1010 | Wonka Industries | Delivered | Low | $89.00 | 6 | 2026-02-28 | Yes | Food |
| ORD-1011 | Acme Corp | Shipped | Medium | $1,340.00 | 10 | 2026-03-06 | Yes | Apparel |
| ORD-1012 | Globex Inc | Confirmed | Low | $210.50 | 3 | 2026-03-14 | No | Home |
| ORD-1013 | Initech | Draft | Urgent | $7,800.00 | 15 | 2026-03-16 | No | Electronics |
| ORD-1014 | Umbrella LLC | Shipped | High | $450.00 | 2 | 2026-03-09 | Yes | Home |
| ORD-1015 | Soylent Corp | Confirmed | Medium | $1,100.00 | 7 | 2026-03-13 | No | Apparel |
| ORD-1016 | Wayne Enterprises | Delivered | High | $9,300.00 | 25 | 2026-02-15 | Yes | Electronics |
| ORD-1017 | Stark Industries | Draft | Low | $65.00 | 1 | 2026-03-17 | No | Food |
| ORD-1018 | Oscorp | Cancelled | Medium | $2,100.00 | 9 | 2026-03-02 | No | Apparel |
| ORD-1019 | Cyberdyne Systems | Confirmed | Urgent | $32,000.00 | 100 | 2026-03-18 | No | Electronics |
| ORD-1020 | Wonka Industries | Shipped | Low | $175.00 | 4 | 2026-03-04 | Yes | Food |
"use client";
import { useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
type Order,
SEED_ORDERS,
STATUS_VARIANTS,
PRIORITY_VARIANTS,
CATEGORY_VARIANTS,
} from "./demo-data";
export function DataTableFiltersDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
pinned: "left",
size: 120,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 160,
filter: {
valueType: "string",
paramKey: "customer",
logicVar: "customer",
placeholder: "Search customer…",
},
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 110,
filter: {
valueType: "enum",
paramKey: "status",
logicVar: "status",
options: Object.entries(STATUS_VARIANTS).map(
([value, { label }]) => ({
value,
label,
}),
),
},
},
priority: {
kind: "status",
label: "Priority",
sortKey: "priority",
variants: PRIORITY_VARIANTS,
size: 100,
filter: {
valueType: "enum",
paramKey: "priority",
logicVar: "priority",
options: Object.entries(PRIORITY_VARIANTS).map(
([value, { label }]) => ({
value,
label,
}),
),
},
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 120,
filter: {
valueType: "number",
paramKey: "amount",
logicVar: "amount",
defaultOperator: "between",
},
},
quantity: {
kind: "number",
label: "Qty",
sortKey: "quantity",
decimals: 0,
size: 80,
},
orderDate: {
kind: "date",
label: "Order Date",
sortKey: "orderDate",
size: 130,
filter: {
valueType: "date",
paramKey: "orderDate",
logicVar: "orderDate",
},
},
shipped: {
kind: "boolean",
label: "Shipped?",
trueLabel: "Yes",
falseLabel: "No",
size: 90,
},
category: {
kind: "label",
label: "Category",
sortKey: "category",
variants: CATEGORY_VARIANTS,
size: 110,
filter: {
valueType: "enum",
paramKey: "category",
logicVar: "category",
options: Object.entries(CATEGORY_VARIANTS).map(
([value, { label }]) => ({
value,
label,
}),
),
},
},
},
{
filterGroups: [
{ label: "Order", fields: ["customer", "status", "priority"] },
{ label: "Details", fields: ["amount", "orderDate", "category"] },
],
},
),
[],
);
const { params, setParams, activeFilterCount } = useTableSearchParams(
schema,
{ urlParamPrefix: "dt_fl_" },
);
return (
<div className="space-y-3">
<p className="text-muted-foreground text-xs">
Filter state syncs to the URL via nuqs. Active filters:{" "}
<span className="text-foreground font-medium tabular-nums">
{activeFilterCount}
</span>
</p>
<DataTable<Order>
fillHeight
className="h-[min(520px,75vh)] min-h-[280px]"
schema={schema}
data={SEED_ORDERS}
getRowId={(r) => r.id}
tableCode="docs-dt-filters"
params={params}
onParamsChange={setParams}
filtering="builder"
showDensityControl
/>
</div>
);
}Selection and batch actions
Use for queues (approve, ship, archive): batchActions (recipe derives features.selection), rowActions, optional rowActionsDisplay. Confirm destructive batch work in your product before enabling.
Select rows for batch actions. Use the kebab menu in the trailing column for per-row actions.
Actions | ||||||
|---|---|---|---|---|---|---|
| ORD-1001 | Acme Corp | Confirmed | High | $2,499.00 | ||
| ORD-1002 | Globex Inc | Shipped | Medium | $849.50 | ||
| ORD-1003 | Initech | Draft | Low | $120.00 | ||
| ORD-1004 | Umbrella LLC | Delivered | Medium | $3,200.00 | ||
| ORD-1005 | Soylent Corp | Cancelled | Low | $45.99 | ||
| ORD-1006 | Wayne Enterprises | Confirmed | Urgent | $18,750.00 | ||
| ORD-1007 | Stark Industries | Shipped | High | $5,600.00 | ||
| ORD-1008 | Oscorp | Draft | Medium | $299.99 | ||
| ORD-1009 | Cyberdyne Systems | Confirmed | High | $14,200.00 | ||
| ORD-1010 | Wonka Industries | Delivered | Low | $89.00 | ||
| ORD-1011 | Acme Corp | Shipped | Medium | $1,340.00 | ||
| ORD-1012 | Globex Inc | Confirmed | Low | $210.50 | ||
| ORD-1013 | Initech | Draft | Urgent | $7,800.00 | ||
| ORD-1014 | Umbrella LLC | Shipped | High | $450.00 | ||
| ORD-1015 | Soylent Corp | Confirmed | Medium | $1,100.00 | ||
| ORD-1016 | Wayne Enterprises | Delivered | High | $9,300.00 | ||
| ORD-1017 | Stark Industries | Draft | Low | $65.00 | ||
| ORD-1018 | Oscorp | Cancelled | Medium | $2,100.00 | ||
| ORD-1019 | Cyberdyne Systems | Confirmed | Urgent | $32,000.00 | ||
| ORD-1020 | Wonka Industries | Shipped | Low | $175.00 |
"use client";
import { useMemo } from "react";
import {
EyeIcon,
PencilIcon,
Trash2Icon,
TruckIcon,
XCircleIcon,
} from "lucide-react";
import { toast } from "sonner";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import type {
BatchAction,
RowAction,
} from "@/components/f-ui/data-table/schema/types";
import {
type Order,
PRIORITY_VARIANTS,
SEED_ORDERS,
STATUS_VARIANTS,
} from "./demo-data";
export function DataTableActionsDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
pinned: "left",
size: 120,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 160,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 110,
},
priority: {
kind: "status",
label: "Priority",
sortKey: "priority",
variants: PRIORITY_VARIANTS,
size: 100,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 120,
},
},
{},
),
[],
);
const { params, setParams } = useTableSearchParams(schema, {
urlParamPrefix: "dt_ac_",
});
const rowActions = useMemo<RowAction<Order>[]>(
() => [
{
id: "view",
label: "View details",
icon: <EyeIcon className="size-4" />,
onClick: (row) => toast.info(`View: ${row.orderNumber}`),
},
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-4" />,
onClick: (row) => toast.success(`Edit: ${row.orderNumber}`),
},
{
id: "cancel",
label: "Cancel order",
icon: <XCircleIcon className="size-4" />,
variant: "destructive",
onClick: (row) => toast.warning(`Cancel: ${row.orderNumber}`),
disabled: (row) =>
row.status === "cancelled" || row.status === "delivered",
},
],
[],
);
const batchActions = useMemo<BatchAction<Order>[]>(
() => [
{
id: "ship",
label: "Mark shipped",
icon: <TruckIcon className="size-4" />,
onClick: ({ rows, count }) => {
toast.success(`Shipping ${rows.length || count} order(s)`);
},
disabled: (rows) => {
const allShipped = rows.every((r) => r.shipped);
return allShipped ? "All selected orders are already shipped" : false;
},
},
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-4" />,
variant: "destructive",
confirmBeforeRun: true,
onClick: ({ rows, count }) => {
toast.success(`Deleted ${rows.length || count} order(s)`);
},
},
],
[],
);
return (
<div className="space-y-3">
<p className="text-muted-foreground text-xs">
Select rows for batch actions. Use the kebab menu in the trailing
column for per-row actions.
</p>
<DataTable<Order>
fillHeight
className="h-[min(520px,75vh)] min-h-[280px]"
schema={schema}
data={SEED_ORDERS}
getRowId={(r) => r.id}
tableCode="docs-dt-actions"
params={params}
onParamsChange={setParams}
rowActions={rowActions}
batchActions={batchActions}
filtering="none"
features={{ selection: true }}
showDensityControl
/>
</div>
);
}Server-shaped list + JSON Logic
Use when the backend expects a JSON Logic (or equivalent) payload built from the current filter model. The demo previews the rule your adapter would send.
null (no active filters)
| Loading… | ||||
"use client";
import { useCallback, useMemo, useState } from "react";
import { parseAsInteger } from "nuqs";
import { useQueryClient } from "@tanstack/react-query";
import { createInMemoryListAdapter } from "@/components/f-ui/data-table/adapters/in-memory-list-adapter";
import type {
JsonLogicRule,
ListQueryAdapter,
} from "@/components/f-ui/data-table/adapters/list-query-contract";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
type Order,
SEED_ORDERS,
STATUS_VARIANTS,
CATEGORY_VARIANTS,
} from "./demo-data";
export function DataTableServerJsonLogicDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
pinned: "left",
size: 120,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 160,
filter: {
valueType: "string",
paramKey: "customer",
logicVar: "customer",
},
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 110,
filter: {
valueType: "enum",
paramKey: "status",
logicVar: "status",
options: Object.entries(STATUS_VARIANTS).map(
([value, { label }]) => ({
value,
label,
}),
),
},
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 120,
filter: {
valueType: "number",
paramKey: "amount",
logicVar: "amount",
defaultOperator: "between",
},
},
category: {
kind: "label",
label: "Category",
sortKey: "category",
variants: CATEGORY_VARIANTS,
size: 110,
filter: {
valueType: "enum",
paramKey: "category",
logicVar: "category",
options: Object.entries(CATEGORY_VARIANTS).map(
([value, { label }]) => ({
value,
label,
}),
),
},
},
},
{
filterGroups: [
{
label: "Filters",
fields: ["customer", "status", "amount", "category"],
},
],
},
),
[],
);
const { params, setParams } = useTableSearchParams(schema, {
urlParamPrefix: "dt_jl_",
extraParsers: {
pageSize: parseAsInteger.withDefault(10),
},
});
const baseAdapter = useMemo(
() => createInMemoryListAdapter<Order>({ items: SEED_ORDERS }),
[],
);
const [latestFilter, setLatestFilter] = useState<JsonLogicRule | null>(null);
// Pedagogical shim: capture each adapter call's `request.filter` into local
// state so the debug panel below can render the live JSON-Logic payload.
const adapter = useCallback<ListQueryAdapter<Order>>(
async (request) => {
setLatestFilter(request.filter ?? null);
return baseAdapter(request);
},
[baseAdapter],
);
const queryClient = useQueryClient();
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
className="border-input bg-background hover:bg-muted/50 rounded-md border px-2 py-1 text-xs"
onClick={() => {
queryClient.invalidateQueries({
queryKey: ["data-table", "docs-dt-json-logic"],
});
}}
>
Refetch
</button>
</div>
<div className="border-input bg-muted/30 rounded-md border p-3 font-mono text-[11px] leading-relaxed">
<div className="text-muted-foreground mb-1">
adapter request.filter →
</div>
<pre className="text-foreground max-h-32 overflow-auto whitespace-pre-wrap break-all">
{latestFilter == null
? "null (no active filters)"
: JSON.stringify(latestFilter, null, 2)}
</pre>
</div>
<DataTable<Order>
fillHeight
className="h-[min(520px,75vh)] min-h-[280px]"
schema={schema}
adapter={adapter}
getRowId={(r) => r.id}
tableCode="docs-dt-json-logic"
params={params}
onParamsChange={setParams}
filtering="builder"
showDensityControl
/>
</div>
);
}Compact density (optional)
Default row padding is normal. Switch to compact for dense monitoring or ops layouts; keep showDensityControl if users should override.
Default density is normal. Set defaultDensity="compact" (and optional showDensityControl) when operators need more rows on screen.
| ORD-1001 | Acme Corp | Confirmed | High |
| ORD-1002 | Globex Inc | Shipped | Medium |
| ORD-1003 | Initech | Draft | Low |
| ORD-1004 | Umbrella LLC | Delivered | Medium |
| ORD-1005 | Soylent Corp | Cancelled | Low |
| ORD-1006 | Wayne Enterprises | Confirmed | Urgent |
| ORD-1007 | Stark Industries | Shipped | High |
| ORD-1008 | Oscorp | Draft | Medium |
"use client";
import { useMemo } from "react";
import { parseAsInteger } from "nuqs";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
type Order,
SEED_ORDERS,
STATUS_VARIANTS,
PRIORITY_VARIANTS,
} from "@/demos/data-table/demo-data";
const TABLE_CODE = "docs-dt-compact-density";
/** Dense rows for dashboards or power users; default product density remains `normal`. */
export function DataTableCompactDensityDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
size: 108,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 150,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 100,
},
priority: {
kind: "status",
label: "Priority",
sortKey: "priority",
variants: PRIORITY_VARIANTS,
size: 90,
},
},
{},
),
[],
);
const { params, setParams } = useTableSearchParams(schema, {
urlParamPrefix: "dt_cd_",
extraParsers: {
pageSize: parseAsInteger.withDefault(8),
},
});
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Default density is <strong>normal</strong>. Set{" "}
<code>defaultDensity="compact"</code> (and optional{" "}
<code>showDensityControl</code>) when operators need more rows on
screen.
</p>
<div className="h-[min(360px,55vh)] min-h-[240px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={SEED_ORDERS}
tableCode={TABLE_CODE}
params={params}
onParamsChange={setParams}
filtering="none"
showDensityControl
defaultDensity="compact"
/>
</div>
</div>
);
}Loading State
On first load (data.isLoading), the real column header stays and only the body switches — by default to density-aware skeleton rows aligned to column widths (interaction is locked by the root scrim). Set loadingVariant="spinner" for a centered spinner instead (Ant-style). Background refetches keep the current rows and show the interaction scrim only. Pass loadingOverride when your upstream has its own loading signal.
Default skeleton — real header, column-aligned skeleton rows.
| Loading… | |||
loadingVariant="spinner" — centered spinner under the same header (Ant-style).
| Loading… | |||
"use client";
import { useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { type Order, SEED_ORDERS, STATUS_VARIANTS } from "./demo-data";
/**
* First-load states: the real column header always stays; only the body
* switches — skeleton rows (default) or a centered spinner
* (`loadingVariant="spinner"`).
*/
export function DataTableLoadingStateDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: { kind: "text", label: "Order #", size: 110 },
customer: { kind: "text", label: "Customer", size: 140 },
status: {
kind: "status",
label: "Status",
variants: STATUS_VARIANTS,
size: 100,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 100,
},
},
{},
),
[],
);
return (
<div className="space-y-4">
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Default skeleton — real header, column-aligned skeleton rows.
</p>
<div className="h-[min(280px,42vh)] min-h-[200px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={SEED_ORDERS}
tableCode="docs-dt-loading-skeleton"
getRowId={(r) => r.id}
filtering="none"
disablePagination
loadingOverride
/>
</div>
</div>
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
<code>loadingVariant="spinner"</code> — centered spinner
under the same header (Ant-style).
</p>
<div className="h-[min(280px,42vh)] min-h-[200px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={SEED_ORDERS}
tableCode="docs-dt-loading-spinner"
getRowId={(r) => r.id}
filtering="none"
disablePagination
loadingOverride
loadingVariant="spinner"
/>
</div>
</div>
</div>
);
}Empty State
Zero rows render the guided empty state: a muted icon, a reason, and — when filters are active — a clear-filters action. Custom icon / illustration / title / description / action slots are available on <DataTableEmptyState> for bespoke layouts.
No Data
Zero rows in the dataset — centered inbox icon and primary message.
"use client";
import { useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { type Order, STATUS_VARIANTS } from "./demo-data";
const TABLE_CODE = "docs-dt-empty-state";
/** Zero rows: default `no-data` empty state (inbox icon + message). */
export function DataTableEmptyStateDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: { kind: "text", label: "Order #", size: 110 },
customer: { kind: "text", label: "Customer", size: 140 },
status: {
kind: "status",
label: "Status",
variants: STATUS_VARIANTS,
size: 100,
},
},
{},
),
[],
);
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Zero rows in the dataset — centered inbox icon and primary message.
</p>
<div className="h-[min(280px,42vh)] min-h-[200px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={[]}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
filtering="none"
disablePagination
features={{}}
/>
</div>
</div>
);
}No Matches
Rows exist but the current filters exclude every row — the no-matches variant shows a search icon and a clear-filters link.
Active filter chip with zero matching rows — empty body only, no side panel.
| ORD-1001 | Acme Corp | Confirmed |
| ORD-1002 | Globex Inc | Shipped |
| ORD-1003 | Initech | Draft |
| ORD-1004 | Umbrella LLC | Delivered |
| ORD-1005 | Soylent Corp | Cancelled |
| ORD-1006 | Wayne Enterprises | Confirmed |
| ORD-1007 | Stark Industries | Shipped |
| ORD-1008 | Oscorp | Draft |
| ORD-1009 | Cyberdyne Systems | Confirmed |
| ORD-1010 | Wonka Industries | Delivered |
| ORD-1011 | Acme Corp | Shipped |
| ORD-1012 | Globex Inc | Confirmed |
| ORD-1013 | Initech | Draft |
| ORD-1014 | Umbrella LLC | Shipped |
| ORD-1015 | Soylent Corp | Confirmed |
| ORD-1016 | Wayne Enterprises | Delivered |
| ORD-1017 | Stark Industries | Draft |
| ORD-1018 | Oscorp | Cancelled |
| ORD-1019 | Cyberdyne Systems | Confirmed |
| ORD-1020 | Wonka Industries | Shipped |
"use client";
import { useEffect, useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useDataTable } from "@/components/f-ui/data-table/use-data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { type Order, SEED_ORDERS, STATUS_VARIANTS } from "./demo-data";
const TABLE_CODE = "docs-dt-empty-no-matches";
/** Impossible customer substring — reads naturally in the filter chip. */
const NO_MATCH_QUERY = "NoSuchCustomer";
/**
* Rows exist but an active filter yields zero matches — `no-matches` empty state
* with SearchX icon and clear-filters action.
*/
export function DataTableEmptyNoMatchesDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: { kind: "text", label: "Order #", size: 110 },
customer: {
kind: "text",
label: "Customer",
size: 140,
filter: {
valueType: "string",
paramKey: "customer",
logicVar: "customer",
},
},
status: {
kind: "status",
label: "Status",
variants: STATUS_VARIANTS,
size: 100,
},
},
{},
),
[],
);
const dataTable = useDataTable<Order>({
schema,
tableCode: TABLE_CODE,
data: SEED_ORDERS,
getRowId: (r) => r.id,
features: { filters: true },
});
useEffect(() => {
if (dataTable.filters.activeCount === 0) {
dataTable.filters.setField("customer", {
op: "contains",
value: NO_MATCH_QUERY,
});
}
}, [dataTable]);
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Active filter chip with zero matching rows — empty body only, no side
panel.
</p>
<div className="h-[min(280px,42vh)] min-h-[200px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
dataTable={dataTable}
filters={{ surface: "bar" }}
disablePagination
/>
</div>
</div>
);
}Sparse Pagination
Few rows per page with many total pages: the footer leads with the item range (1–10 of 847) so the current slice stays legible, while the ellipsis strip keeps the first and last pages one click away.
| ORD-0001 | Acme Corp | Confirmed | $2,499.00 |
| ORD-0002 | Globex Inc | Shipped | $849.50 |
| ORD-0003 | Initech | Draft | $120.00 |
| ORD-0004 | Umbrella LLC | Delivered | $3,200.00 |
| ORD-0005 | Soylent Corp | Cancelled | $45.99 |
| ORD-0006 | Wayne Enterprises | Confirmed | $18,750.00 |
| ORD-0007 | Stark Industries | Shipped | $5,600.00 |
| ORD-0008 | Oscorp | Draft | $299.99 |
| ORD-0009 | Cyberdyne Systems | Confirmed | $14,200.00 |
| ORD-0010 | Wonka Industries | Delivered | $89.00 |
"use client";
import { useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import { type Order, SEED_ORDERS, STATUS_VARIANTS } from "./demo-data";
const TABLE_CODE = "docs-dt-sparse-pagination";
/** 847 rows at 10 per page: range summary (`1–10 of 847`) + ellipsis strip. */
export function DataTableSparsePaginationDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
size: 110,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 140,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 100,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 100,
},
},
{},
),
[],
);
const rows = useMemo<Order[]>(
() =>
Array.from({ length: 847 }, (_, i) => {
const seed = SEED_ORDERS[i % SEED_ORDERS.length]!;
return {
...seed,
id: `sparse-${i + 1}`,
orderNumber: `ORD-${String(i + 1).padStart(4, "0")}`,
};
}),
[],
);
return (
<div className="h-[min(480px,68vh)] min-h-[280px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={rows}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
filtering="none"
defaultPageSize={10}
pageSizeOptions={[10, 20, 50]}
features={{ pagination: { mode: "offset" } }}
/>
</div>
);
}Filters & URL State
The Filters and shareable URLs example above shows builder filters with URL sync. This section documents builder layout (filters={{ surface, chips }}), presets, the command palette, and headless usage.
Builder layout (bar, panel, chips)
Use with filtering="builder". The filters prop tunes builder chrome — not the surface selector (that is filtering).
filters.surface | UI |
|---|---|
"bar" | Toolbar add-filter + saved-filters + chips |
"panel" | Left filter panel |
"both" | Bar controls + panel |
filters.chips | Default |
|---|---|
| omitted | "infobar" when a surface is enabled |
"infobar" | Chip row below the toolbar |
"inline" | Chips in the toolbar (chrome="inline-chips") |
"hidden" | No chip row |
<DataTable
schema={schema}
data={rows}
params={params}
onParamsChange={setParams}
filtering="builder"
filters={{ surface: "bar" }}
/>Filter Chips (Infobar)
Each active filter appears as a chip: field label, operator, value (when needed), and remove (×). To change columns, remove a chip and use + Filter in the toolbar.
By default the chip row sits below the toolbar (filters={{ surface: "bar" }} or "both" with default chips). Hide chips with filters={{ surface: "panel", chips: "hidden" }} (panel-only demos).
Bar only
Compact table on seed data — chips, + Filter, and clear all; no filter panel.
Default bazza-style bar: chips, add filter, and clear all — no side panel.
| ORD-1001 | Acme Corp | Confirmed | High | $2,499.00 | 5 | 2026-03-01 | 2026-03-01 | No | Electronics | web | Expedite if possible |
| ORD-1002 | Globex Inc | Shipped | Medium | $849.50 | 12 | 2026-03-03 | 2026-03-03 | Yes | Apparel | phone | — |
| ORD-1003 | Initech | Draft | Low | $120.00 | 2 | 2026-03-05 | 2026-03-05 | No | Food | edi | Awaiting payment confirmation |
| ORD-1004 | Umbrella LLC | Delivered | Medium | $3,200.00 | 1 | 2026-02-20 | 2026-02-20 | Yes | Electronics | pos | — |
| ORD-1005 | Soylent Corp | Cancelled | Low | $45.99 | 3 | 2026-03-10 | 2026-03-10 | No | Food | web | Customer requested cancellation |
| ORD-1006 | Wayne Enterprises | Confirmed | Urgent | $18,750.00 | 50 | 2026-03-12 | 2026-03-12 | No | Electronics | phone | Bulk order — warehouse B |
| ORD-1007 | Stark Industries | Shipped | High | $5,600.00 | 8 | 2026-03-08 | 2026-03-08 | Yes | Electronics | edi | — |
| ORD-1008 | Oscorp | Draft | Medium | $299.99 | 4 | 2026-03-15 | 2026-03-15 | No | Home | pos | Gift wrapping requested |
| ORD-1009 | Cyberdyne Systems | Confirmed | High | $14,200.00 | 20 | 2026-03-11 | 2026-03-11 | No | Electronics | web | — |
| ORD-1010 | Wonka Industries | Delivered | Low | $89.00 | 6 | 2026-02-28 | 2026-02-28 | Yes | Food | phone | — |
| ORD-1011 | Acme Corp | Shipped | Medium | $1,340.00 | 10 | 2026-03-06 | 2026-03-06 | Yes | Apparel | edi | Second batch |
| ORD-1012 | Globex Inc | Confirmed | Low | $210.50 | 3 | 2026-03-14 | 2026-03-14 | No | Home | pos | — |
| ORD-1013 | Initech | Draft | Urgent | $7,800.00 | 15 | 2026-03-16 | 2026-03-16 | No | Electronics | web | Pending manager approval |
| ORD-1014 | Umbrella LLC | Shipped | High | $450.00 | 2 | 2026-03-09 | 2026-03-09 | Yes | Home | phone | — |
| ORD-1015 | Soylent Corp | Confirmed | Medium | $1,100.00 | 7 | 2026-03-13 | 2026-03-13 | No | Apparel | edi | — |
| ORD-1016 | Wayne Enterprises | Delivered | High | $9,300.00 | 25 | 2026-02-15 | 2026-02-15 | Yes | Electronics | pos | Repeat order |
| ORD-1017 | Stark Industries | Draft | Low | $65.00 | 1 | 2026-03-17 | 2026-03-17 | No | Food | web | — |
| ORD-1018 | Oscorp | Cancelled | Medium | $2,100.00 | 9 | 2026-03-02 | 2026-03-02 | No | Apparel | phone | Duplicate order |
| ORD-1019 | Cyberdyne Systems | Confirmed | Urgent | $32,000.00 | 100 | 2026-03-18 | 2026-03-18 | No | Electronics | edi | Priority shipment required |
| ORD-1020 | Wonka Industries | Shipped | Low | $175.00 | 4 | 2026-03-04 | 2026-03-04 | Yes | Food | pos | — |
"use client";
import { useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { SEED_ORDERS, type Order } from "@/demos/data-table/demo-data";
import { createOrdersFilterSurfaceSchema } from "@/demos/data-table/filter-surface-demos-schema";
const TABLE_CODE = "docs-dt-filter-bar-only";
export function DataTableFilterBarOnlyDemo() {
const schema = useMemo(() => createOrdersFilterSurfaceSchema(), []);
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Default <strong>bazza-style</strong> bar: chips, add filter, and clear
all — no side panel.
</p>
<div className="h-[min(360px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={SEED_ORDERS}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
filters={{ surface: "bar" }}
disablePagination
/>
</div>
</div>
);
}Filter Panel (Zoho-style)
A 280px sticky-header / sticky-footer left sidebar. Field rows expand to registry-driven value editors (valueType + presentation). Apply batches changes inside the panel and flushes them in one shot. Edits made elsewhere (chips, preset apply) commit immediately to the same applied state.
<DataTable
schema={schema}
data={rows}
params={params}
onParamsChange={setParams}
filtering="builder"
filters={{ surface: "panel" }}
filterPanelProps={{
title: "Filter Deals By",
sections: [
{
id: "fields",
label: "Field Filters",
content: { kind: "fields", fieldKeys: "*" },
},
],
}}
/>Panel only
Bar hidden; filters are edited and applied from the sidebar.
Zoho-style panel only: stage field rows, then Apply Filter. The toolbar bar is hidden.
| ORD-1001 | Acme Corp | Confirmed | High | $2,499.00 | 5 | 2026-03-01 | 2026-03-01 | No | Electronics | web | Expedite if possible |
| ORD-1002 | Globex Inc | Shipped | Medium | $849.50 | 12 | 2026-03-03 | 2026-03-03 | Yes | Apparel | phone | — |
| ORD-1003 | Initech | Draft | Low | $120.00 | 2 | 2026-03-05 | 2026-03-05 | No | Food | edi | Awaiting payment confirmation |
| ORD-1004 | Umbrella LLC | Delivered | Medium | $3,200.00 | 1 | 2026-02-20 | 2026-02-20 | Yes | Electronics | pos | — |
| ORD-1005 | Soylent Corp | Cancelled | Low | $45.99 | 3 | 2026-03-10 | 2026-03-10 | No | Food | web | Customer requested cancellation |
| ORD-1006 | Wayne Enterprises | Confirmed | Urgent | $18,750.00 | 50 | 2026-03-12 | 2026-03-12 | No | Electronics | phone | Bulk order — warehouse B |
| ORD-1007 | Stark Industries | Shipped | High | $5,600.00 | 8 | 2026-03-08 | 2026-03-08 | Yes | Electronics | edi | — |
| ORD-1008 | Oscorp | Draft | Medium | $299.99 | 4 | 2026-03-15 | 2026-03-15 | No | Home | pos | Gift wrapping requested |
| ORD-1009 | Cyberdyne Systems | Confirmed | High | $14,200.00 | 20 | 2026-03-11 | 2026-03-11 | No | Electronics | web | — |
| ORD-1010 | Wonka Industries | Delivered | Low | $89.00 | 6 | 2026-02-28 | 2026-02-28 | Yes | Food | phone | — |
| ORD-1011 | Acme Corp | Shipped | Medium | $1,340.00 | 10 | 2026-03-06 | 2026-03-06 | Yes | Apparel | edi | Second batch |
| ORD-1012 | Globex Inc | Confirmed | Low | $210.50 | 3 | 2026-03-14 | 2026-03-14 | No | Home | pos | — |
| ORD-1013 | Initech | Draft | Urgent | $7,800.00 | 15 | 2026-03-16 | 2026-03-16 | No | Electronics | web | Pending manager approval |
| ORD-1014 | Umbrella LLC | Shipped | High | $450.00 | 2 | 2026-03-09 | 2026-03-09 | Yes | Home | phone | — |
| ORD-1015 | Soylent Corp | Confirmed | Medium | $1,100.00 | 7 | 2026-03-13 | 2026-03-13 | No | Apparel | edi | — |
| ORD-1016 | Wayne Enterprises | Delivered | High | $9,300.00 | 25 | 2026-02-15 | 2026-02-15 | Yes | Electronics | pos | Repeat order |
| ORD-1017 | Stark Industries | Draft | Low | $65.00 | 1 | 2026-03-17 | 2026-03-17 | No | Food | web | — |
| ORD-1018 | Oscorp | Cancelled | Medium | $2,100.00 | 9 | 2026-03-02 | 2026-03-02 | No | Apparel | phone | Duplicate order |
| ORD-1019 | Cyberdyne Systems | Confirmed | Urgent | $32,000.00 | 100 | 2026-03-18 | 2026-03-18 | No | Electronics | edi | Priority shipment required |
| ORD-1020 | Wonka Industries | Shipped | Low | $175.00 | 4 | 2026-03-04 | 2026-03-04 | Yes | Food | pos | — |
"use client";
import { useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { SEED_ORDERS, type Order } from "@/demos/data-table/demo-data";
import { createOrdersFilterSurfaceSchema } from "@/demos/data-table/filter-surface-demos-schema";
const TABLE_CODE = "docs-dt-filter-panel-only";
export function DataTableFilterPanelOnlyDemo() {
const schema = useMemo(() => createOrdersFilterSurfaceSchema(), []);
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
<strong>Zoho-style</strong> panel only: stage field rows, then{" "}
<strong>Apply Filter</strong>. The toolbar bar is hidden.
</p>
<div className="h-[min(360px,55vh)] min-h-[260px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={SEED_ORDERS}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
disablePagination
filters={{ surface: "panel", chips: "hidden" }}
filterPanelProps={{ title: "Filter orders" }}
/>
</div>
</div>
);
}Bar + Panel
Use filtering="builder" with filters={{ surface: "both" }}: the panel stages edits until Apply; chips reflect applied state and can still open the value editor.
Bar and panel together: new filters are added from the panel; chip edits still commit immediately.
| ORD-1001 | Acme Corp | Confirmed | High | $2,499.00 | 5 | 2026-03-01 | 2026-03-01 | No | Electronics | web | Expedite if possible |
| ORD-1002 | Globex Inc | Shipped | Medium | $849.50 | 12 | 2026-03-03 | 2026-03-03 | Yes | Apparel | phone | — |
| ORD-1003 | Initech | Draft | Low | $120.00 | 2 | 2026-03-05 | 2026-03-05 | No | Food | edi | Awaiting payment confirmation |
| ORD-1004 | Umbrella LLC | Delivered | Medium | $3,200.00 | 1 | 2026-02-20 | 2026-02-20 | Yes | Electronics | pos | — |
| ORD-1005 | Soylent Corp | Cancelled | Low | $45.99 | 3 | 2026-03-10 | 2026-03-10 | No | Food | web | Customer requested cancellation |
| ORD-1006 | Wayne Enterprises | Confirmed | Urgent | $18,750.00 | 50 | 2026-03-12 | 2026-03-12 | No | Electronics | phone | Bulk order — warehouse B |
| ORD-1007 | Stark Industries | Shipped | High | $5,600.00 | 8 | 2026-03-08 | 2026-03-08 | Yes | Electronics | edi | — |
| ORD-1008 | Oscorp | Draft | Medium | $299.99 | 4 | 2026-03-15 | 2026-03-15 | No | Home | pos | Gift wrapping requested |
| ORD-1009 | Cyberdyne Systems | Confirmed | High | $14,200.00 | 20 | 2026-03-11 | 2026-03-11 | No | Electronics | web | — |
| ORD-1010 | Wonka Industries | Delivered | Low | $89.00 | 6 | 2026-02-28 | 2026-02-28 | Yes | Food | phone | — |
| ORD-1011 | Acme Corp | Shipped | Medium | $1,340.00 | 10 | 2026-03-06 | 2026-03-06 | Yes | Apparel | edi | Second batch |
| ORD-1012 | Globex Inc | Confirmed | Low | $210.50 | 3 | 2026-03-14 | 2026-03-14 | No | Home | pos | — |
| ORD-1013 | Initech | Draft | Urgent | $7,800.00 | 15 | 2026-03-16 | 2026-03-16 | No | Electronics | web | Pending manager approval |
| ORD-1014 | Umbrella LLC | Shipped | High | $450.00 | 2 | 2026-03-09 | 2026-03-09 | Yes | Home | phone | — |
| ORD-1015 | Soylent Corp | Confirmed | Medium | $1,100.00 | 7 | 2026-03-13 | 2026-03-13 | No | Apparel | edi | — |
| ORD-1016 | Wayne Enterprises | Delivered | High | $9,300.00 | 25 | 2026-02-15 | 2026-02-15 | Yes | Electronics | pos | Repeat order |
| ORD-1017 | Stark Industries | Draft | Low | $65.00 | 1 | 2026-03-17 | 2026-03-17 | No | Food | web | — |
| ORD-1018 | Oscorp | Cancelled | Medium | $2,100.00 | 9 | 2026-03-02 | 2026-03-02 | No | Apparel | phone | Duplicate order |
| ORD-1019 | Cyberdyne Systems | Confirmed | Urgent | $32,000.00 | 100 | 2026-03-18 | 2026-03-18 | No | Electronics | edi | Priority shipment required |
| ORD-1020 | Wonka Industries | Shipped | Low | $175.00 | 4 | 2026-03-04 | 2026-03-04 | Yes | Food | pos | — |
"use client";
import { useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { SEED_ORDERS, type Order } from "@/demos/data-table/demo-data";
import { createOrdersFilterSurfaceSchema } from "@/demos/data-table/filter-surface-demos-schema";
const TABLE_CODE = "docs-dt-filter-bar-panel";
export function DataTableFilterBarPanelDemo() {
const schema = useMemo(() => createOrdersFilterSurfaceSchema(), []);
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Bar and panel together: new filters are added from the panel; chip edits
still commit immediately.
</p>
<div className="h-[min(380px,55vh)] min-h-[280px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={SEED_ORDERS}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
disablePagination
filtering="builder"
filterPanelProps={{ title: "Filter orders" }}
/>
</div>
</div>
);
}Saved Filters (presets)
A dropdown rendered in the panel header lists user-saved presets and a footer Save filter button captures the current panel draft—staged rows you have edited but not yet applied—together with any panel criteria state, as a new preset. The dropdown supports inline rename and delete via a per-row menu. Persistence falls back to localStorage keyed by tableCode when no remote callbacks are provided.
<DataTable
schema={schema}
data={rows}
params={params}
onParamsChange={setParams}
filtering="builder"
filters={{ surface: "panel" }}
initialFilterPresets={[
{
id: "preset-shipped",
name: "Shipped orders",
filterModel: { shipped: { op: "is_true" } },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
]}
filterPanelProps={{
title: "Filter Orders By",
enableSavedFilters: true, // defaults to true once any preset exists
}}
onFilterPresetSave={async (preset) => {
await fetch("/api/presets", { method: "POST", body: JSON.stringify(preset) });
}}
onFilterPresetDelete={async (id) => {
await fetch(`/api/presets/${id}`, { method: "DELETE" });
}}
/>Live demo
Saved presets in the panel header (seeded below). New saves persist to localStorage keyed by tableCode.
| ORD-1001 | Acme Corp | Confirmed | High | $2,499.00 | 5 | 2026-03-01 | 2026-03-01 | No | Electronics | web | Expedite if possible |
| ORD-1002 | Globex Inc | Shipped | Medium | $849.50 | 12 | 2026-03-03 | 2026-03-03 | Yes | Apparel | phone | — |
| ORD-1003 | Initech | Draft | Low | $120.00 | 2 | 2026-03-05 | 2026-03-05 | No | Food | edi | Awaiting payment confirmation |
| ORD-1004 | Umbrella LLC | Delivered | Medium | $3,200.00 | 1 | 2026-02-20 | 2026-02-20 | Yes | Electronics | pos | — |
| ORD-1005 | Soylent Corp | Cancelled | Low | $45.99 | 3 | 2026-03-10 | 2026-03-10 | No | Food | web | Customer requested cancellation |
| ORD-1006 | Wayne Enterprises | Confirmed | Urgent | $18,750.00 | 50 | 2026-03-12 | 2026-03-12 | No | Electronics | phone | Bulk order — warehouse B |
| ORD-1007 | Stark Industries | Shipped | High | $5,600.00 | 8 | 2026-03-08 | 2026-03-08 | Yes | Electronics | edi | — |
| ORD-1008 | Oscorp | Draft | Medium | $299.99 | 4 | 2026-03-15 | 2026-03-15 | No | Home | pos | Gift wrapping requested |
| ORD-1009 | Cyberdyne Systems | Confirmed | High | $14,200.00 | 20 | 2026-03-11 | 2026-03-11 | No | Electronics | web | — |
| ORD-1010 | Wonka Industries | Delivered | Low | $89.00 | 6 | 2026-02-28 | 2026-02-28 | Yes | Food | phone | — |
| ORD-1011 | Acme Corp | Shipped | Medium | $1,340.00 | 10 | 2026-03-06 | 2026-03-06 | Yes | Apparel | edi | Second batch |
| ORD-1012 | Globex Inc | Confirmed | Low | $210.50 | 3 | 2026-03-14 | 2026-03-14 | No | Home | pos | — |
| ORD-1013 | Initech | Draft | Urgent | $7,800.00 | 15 | 2026-03-16 | 2026-03-16 | No | Electronics | web | Pending manager approval |
| ORD-1014 | Umbrella LLC | Shipped | High | $450.00 | 2 | 2026-03-09 | 2026-03-09 | Yes | Home | phone | — |
| ORD-1015 | Soylent Corp | Confirmed | Medium | $1,100.00 | 7 | 2026-03-13 | 2026-03-13 | No | Apparel | edi | — |
| ORD-1016 | Wayne Enterprises | Delivered | High | $9,300.00 | 25 | 2026-02-15 | 2026-02-15 | Yes | Electronics | pos | Repeat order |
| ORD-1017 | Stark Industries | Draft | Low | $65.00 | 1 | 2026-03-17 | 2026-03-17 | No | Food | web | — |
| ORD-1018 | Oscorp | Cancelled | Medium | $2,100.00 | 9 | 2026-03-02 | 2026-03-02 | No | Apparel | phone | Duplicate order |
| ORD-1019 | Cyberdyne Systems | Confirmed | Urgent | $32,000.00 | 100 | 2026-03-18 | 2026-03-18 | No | Electronics | edi | Priority shipment required |
| ORD-1020 | Wonka Industries | Shipped | Low | $175.00 | 4 | 2026-03-04 | 2026-03-04 | Yes | Food | pos | — |
"use client";
import { useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { SEED_ORDERS, type Order } from "@/demos/data-table/demo-data";
import { createOrdersFilterSurfaceSchema } from "@/demos/data-table/filter-surface-demos-schema";
const TABLE_CODE = "docs-dt-filter-saved-presets";
export function DataTableFilterSavedPresetsDemo() {
const schema = useMemo(() => createOrdersFilterSurfaceSchema(), []);
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Saved presets in the panel header (seeded below). New saves persist to{" "}
<code>localStorage</code> keyed by <code>tableCode</code>.
</p>
<div className="h-[min(400px,55vh)] min-h-[280px] overflow-hidden rounded-md border">
<DataTable<Order>
fillHeight
className="min-h-0"
schema={schema}
data={SEED_ORDERS}
tableCode={TABLE_CODE}
getRowId={(r) => r.id}
disablePagination
filters={{ surface: "panel", chips: "hidden" }}
initialFilterPresets={[
{
id: "preset-shipped",
name: "Shipped orders",
filterModel: {
shipped: { op: "is_true" },
},
createdAt: new Date("2026-04-01").toISOString(),
updatedAt: new Date("2026-04-01").toISOString(),
},
{
id: "preset-pending",
name: "Pending high priority",
filterModel: {
status: {
op: "in",
values: ["draft", "confirmed"],
},
priority: {
op: "in",
values: ["high", "urgent"],
},
},
createdAt: new Date("2026-04-05").toISOString(),
updatedAt: new Date("2026-04-05").toISOString(),
},
]}
filterPanelProps={{
title: "Filter orders",
enableSavedFilters: true,
}}
/>
</div>
</div>
);
}Filter panel (Apply & Clear)
The sidebar uses a panel-local draft: changes are staged until Apply Filter commits them to the table filter state (and URL when enabled). Clear removes all active field filters and applies that immediately (Zoho-style one-shot clear), without a separate apply step for the reset.
Persistence (collapsed/open)
The filter panel’s collapsed vs open state is persisted to localStorage by default when <DataTable> supplies a stable tableCode. The storage key is f-ui:filter-panel-collapsed:{tableCode}. Pass filterPanelCollapsePersistence on filterPanelProps to replace local storage with an async load / save pair (for example a backend preference API).
<DataTable
schema={schema}
data={rows}
params={params}
onParamsChange={setParams}
tableCode="orders"
filtering="builder"
filters={{ surface: "panel" }}
filterPanelProps={{
defaultCollapsed: false,
// persistFilterPanelCollapsed: false, // opt out — memory only
// filterPanelCollapseStorageKey: "f-ui:filter-panel-collapsed:orders-secondary",
// filterPanelCollapsePersistence: {
// load: () => fetch("/api/prefs/filter-panel").then((r) => r.json()),
// save: (collapsed) =>
// fetch("/api/prefs/filter-panel", {
// method: "PUT",
// body: JSON.stringify({ collapsed }),
// }).then(() => undefined),
// },
}}
/>Command palette
The field-picker command palette is opt-in: set enableCommandPalette to mount the dialog and register the keyboard shortcut (commandPaletteHotkey: "cmdf" | "slash" | "none"; default "cmdf"). The toolbar does not include an “Add filter” button; use the filter bar’s add control, the filter panel, or the shortcut.
<DataTable
schema={schema}
data={rows}
pagination={pagination}
enableCommandPalette
commandPaletteHotkey="cmdf"
/>Wire format and operators are defined in contract/DIALECT.md (fui-query/1). presentation is client-only and is not sent on the wire.
Standalone Usage With useTableFilters
The headless hook powers custom layouts that are not wrapped in <DataTable>. Compile fields from the schema, back state with URL params when needed, and pass the same filters object to both surfaces:
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { compileTableFilterFields } from "@/components/f-ui/data-table/filter/compile-table-filter-fields";
import { useTableFilters } from "@/components/f-ui/data-table/filter/use-table-filters";
import { DataTableFilterBar } from "@/components/f-ui/data-table/data-table-parts/data-table-filter-bar";
import { DataTableFilterPanel } from "@/components/f-ui/data-table/data-table-parts/data-table-filter-panel";
const fields = compileTableFilterFields(schema);
const { params, setParams } = useTableSearchParams(schema);
const filters = useTableFilters({
fields,
params,
setParams,
onAppliedChange: (_applied, jsonLogic) => refetch(jsonLogic),
});
return (
<>
<DataTableFilterPanel filters={filters} />
<DataTableFilterBar filters={filters} variant="chips-only" />
</>
);Headless layout
Same hook as inside <DataTable>, wired to both surfaces in a custom layout (no table).
"use client";
import { useMemo } from "react";
import { useState } from "react";
import { DataTableFilterBar } from "@/components/f-ui/data-table/data-table-parts/data-table-filter-bar";
import { DataTableFilterPanel } from "@/components/f-ui/data-table/data-table-parts/data-table-filter-panel";
import { dataTableChromeRow } from "@/components/f-ui/data-table/lib/chrome-surfaces";
import { cn } from "@/lib/utils";
import { compileTableFilterFields } from "@/components/f-ui/data-table/filter/compile-table-filter-fields";
import { useTableFilters } from "@/components/f-ui/data-table/filter/use-table-filters";
import { createOrdersFilterSurfaceSchema } from "@/demos/data-table/filter-surface-demos-schema";
export function DataTableFiltersStandaloneDemo() {
const schema = useMemo(() => createOrdersFilterSurfaceSchema(), []);
const fields = useMemo(() => compileTableFilterFields(schema), [schema]);
const [params, setParamsState] = useState<Record<string, unknown>>({});
const filters = useTableFilters({
fields,
params,
setParams: (updates) => setParamsState((prev) => ({ ...prev, ...updates })),
});
return (
<div className="space-y-2">
<p className="text-muted-foreground text-xs">
Same hook as inside <code><DataTable></code>, wired to both
surfaces in a custom layout (no table).
</p>
<div className="border-border grid max-h-[320px] grid-cols-[minmax(0,260px)_1fr] gap-0 overflow-hidden rounded-md border">
<DataTableFilterPanel filters={filters} title="Filter orders" />
<div className="flex min-h-0 flex-col">
<div className={cn(dataTableChromeRow, "border-b")}>
<DataTableFilterBar filters={filters} variant="chips-only" />
</div>
<div className="bg-background text-muted-foreground min-h-0 flex-1 overflow-auto p-3 text-xs">
{filters.activeCount === 0
? "No filters applied. Use the panel or chips to start filtering."
: `${filters.activeCount} filter${filters.activeCount === 1 ? "" : "s"} applied.`}
</div>
</div>
</div>
</div>
);
}Row & Batch Actions
Row actions menu (three-dot dropdown), right-click context menu, batch action bar with selection checkboxes. "Delete" shows a destructive confirmation dialog.
Select rows for batch actions. Use the kebab menu in the trailing column for per-row actions.
Actions | ||||||
|---|---|---|---|---|---|---|
| ORD-1001 | Acme Corp | Confirmed | High | $2,499.00 | ||
| ORD-1002 | Globex Inc | Shipped | Medium | $849.50 | ||
| ORD-1003 | Initech | Draft | Low | $120.00 | ||
| ORD-1004 | Umbrella LLC | Delivered | Medium | $3,200.00 | ||
| ORD-1005 | Soylent Corp | Cancelled | Low | $45.99 | ||
| ORD-1006 | Wayne Enterprises | Confirmed | Urgent | $18,750.00 | ||
| ORD-1007 | Stark Industries | Shipped | High | $5,600.00 | ||
| ORD-1008 | Oscorp | Draft | Medium | $299.99 | ||
| ORD-1009 | Cyberdyne Systems | Confirmed | High | $14,200.00 | ||
| ORD-1010 | Wonka Industries | Delivered | Low | $89.00 | ||
| ORD-1011 | Acme Corp | Shipped | Medium | $1,340.00 | ||
| ORD-1012 | Globex Inc | Confirmed | Low | $210.50 | ||
| ORD-1013 | Initech | Draft | Urgent | $7,800.00 | ||
| ORD-1014 | Umbrella LLC | Shipped | High | $450.00 | ||
| ORD-1015 | Soylent Corp | Confirmed | Medium | $1,100.00 | ||
| ORD-1016 | Wayne Enterprises | Delivered | High | $9,300.00 | ||
| ORD-1017 | Stark Industries | Draft | Low | $65.00 | ||
| ORD-1018 | Oscorp | Cancelled | Medium | $2,100.00 | ||
| ORD-1019 | Cyberdyne Systems | Confirmed | Urgent | $32,000.00 | ||
| ORD-1020 | Wonka Industries | Shipped | Low | $175.00 |
"use client";
import { useMemo } from "react";
import {
EyeIcon,
PencilIcon,
Trash2Icon,
TruckIcon,
XCircleIcon,
} from "lucide-react";
import { toast } from "sonner";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import type {
BatchAction,
RowAction,
} from "@/components/f-ui/data-table/schema/types";
import {
type Order,
PRIORITY_VARIANTS,
SEED_ORDERS,
STATUS_VARIANTS,
} from "./demo-data";
export function DataTableActionsDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
pinned: "left",
size: 120,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 160,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 110,
},
priority: {
kind: "status",
label: "Priority",
sortKey: "priority",
variants: PRIORITY_VARIANTS,
size: 100,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 120,
},
},
{},
),
[],
);
const { params, setParams } = useTableSearchParams(schema, {
urlParamPrefix: "dt_ac_",
});
const rowActions = useMemo<RowAction<Order>[]>(
() => [
{
id: "view",
label: "View details",
icon: <EyeIcon className="size-4" />,
onClick: (row) => toast.info(`View: ${row.orderNumber}`),
},
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-4" />,
onClick: (row) => toast.success(`Edit: ${row.orderNumber}`),
},
{
id: "cancel",
label: "Cancel order",
icon: <XCircleIcon className="size-4" />,
variant: "destructive",
onClick: (row) => toast.warning(`Cancel: ${row.orderNumber}`),
disabled: (row) =>
row.status === "cancelled" || row.status === "delivered",
},
],
[],
);
const batchActions = useMemo<BatchAction<Order>[]>(
() => [
{
id: "ship",
label: "Mark shipped",
icon: <TruckIcon className="size-4" />,
onClick: ({ rows, count }) => {
toast.success(`Shipping ${rows.length || count} order(s)`);
},
disabled: (rows) => {
const allShipped = rows.every((r) => r.shipped);
return allShipped ? "All selected orders are already shipped" : false;
},
},
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-4" />,
variant: "destructive",
confirmBeforeRun: true,
onClick: ({ rows, count }) => {
toast.success(`Deleted ${rows.length || count} order(s)`);
},
},
],
[],
);
return (
<div className="space-y-3">
<p className="text-muted-foreground text-xs">
Select rows for batch actions. Use the kebab menu in the trailing
column for per-row actions.
</p>
<DataTable<Order>
fillHeight
className="h-[min(520px,75vh)] min-h-[280px]"
schema={schema}
data={SEED_ORDERS}
getRowId={(r) => r.id}
tableCode="docs-dt-actions"
params={params}
onParamsChange={setParams}
rowActions={rowActions}
batchActions={batchActions}
filtering="none"
features={{ selection: true }}
showDensityControl
/>
</div>
);
}Inline Edit integration
Editable columns with an edit config render through DataTableEditableCell, which composes Inline Edit for "cell" and "table" strategies (see Editing strategies above). Row strategy uses the same editor factory for the active row without per-cell Inline Edit chrome.
Column Manager
Toggle column visibility, drag-reorder, and pin columns using the column manager button in the toolbar.
Use the columns button in the toolbar to toggle visibility, reorder, and pin columns.
| ORD-1001 | Acme Corp | Confirmed | High | $2,499.00 | 5 | 2026-03-01 | — | No | Electronics | Expedite if possible |
| ORD-1002 | Globex Inc | Shipped | Medium | $849.50 | 12 | 2026-03-03 | 2026-03-07 | Yes | Apparel | — |
| ORD-1003 | Initech | Draft | Low | $120.00 | 2 | 2026-03-05 | — | No | Food | Awaiting payment confirmation |
| ORD-1004 | Umbrella LLC | Delivered | Medium | $3,200.00 | 1 | 2026-02-20 | 2026-02-25 | Yes | Electronics | — |
| ORD-1005 | Soylent Corp | Cancelled | Low | $45.99 | 3 | 2026-03-10 | — | No | Food | Customer requested cancellation |
| ORD-1006 | Wayne Enterprises | Confirmed | Urgent | $18,750.00 | 50 | 2026-03-12 | — | No | Electronics | Bulk order — warehouse B |
| ORD-1007 | Stark Industries | Shipped | High | $5,600.00 | 8 | 2026-03-08 | 2026-03-14 | Yes | Electronics | — |
| ORD-1008 | Oscorp | Draft | Medium | $299.99 | 4 | 2026-03-15 | — | No | Home | Gift wrapping requested |
| ORD-1009 | Cyberdyne Systems | Confirmed | High | $14,200.00 | 20 | 2026-03-11 | — | No | Electronics | — |
| ORD-1010 | Wonka Industries | Delivered | Low | $89.00 | 6 | 2026-02-28 | 2026-03-04 | Yes | Food | — |
| ORD-1011 | Acme Corp | Shipped | Medium | $1,340.00 | 10 | 2026-03-06 | 2026-03-12 | Yes | Apparel | Second batch |
| ORD-1012 | Globex Inc | Confirmed | Low | $210.50 | 3 | 2026-03-14 | — | No | Home | — |
| ORD-1013 | Initech | Draft | Urgent | $7,800.00 | 15 | 2026-03-16 | — | No | Electronics | Pending manager approval |
| ORD-1014 | Umbrella LLC | Shipped | High | $450.00 | 2 | 2026-03-09 | 2026-03-13 | Yes | Home | — |
| ORD-1015 | Soylent Corp | Confirmed | Medium | $1,100.00 | 7 | 2026-03-13 | — | No | Apparel | — |
| ORD-1016 | Wayne Enterprises | Delivered | High | $9,300.00 | 25 | 2026-02-15 | 2026-02-22 | Yes | Electronics | Repeat order |
| ORD-1017 | Stark Industries | Draft | Low | $65.00 | 1 | 2026-03-17 | — | No | Food | — |
| ORD-1018 | Oscorp | Cancelled | Medium | $2,100.00 | 9 | 2026-03-02 | — | No | Apparel | Duplicate order |
| ORD-1019 | Cyberdyne Systems | Confirmed | Urgent | $32,000.00 | 100 | 2026-03-18 | — | No | Electronics | Priority shipment required |
| ORD-1020 | Wonka Industries | Shipped | Low | $175.00 | 4 | 2026-03-04 | 2026-03-10 | Yes | Food | — |
"use client";
import { useMemo } from "react";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
type Order,
SEED_ORDERS,
STATUS_VARIANTS,
PRIORITY_VARIANTS,
CATEGORY_VARIANTS,
} from "./demo-data";
export function DataTableColumnManagerDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
pinned: "left",
size: 120,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 160,
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 110,
},
priority: {
kind: "status",
label: "Priority",
sortKey: "priority",
variants: PRIORITY_VARIANTS,
size: 100,
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 120,
},
quantity: {
kind: "number",
label: "Qty",
sortKey: "quantity",
decimals: 0,
size: 80,
},
orderDate: {
kind: "date",
label: "Order Date",
sortKey: "orderDate",
size: 120,
},
shippedDate: {
kind: "date",
label: "Shipped",
sortKey: "shippedDate",
size: 120,
},
shipped: {
kind: "boolean",
label: "Shipped?",
trueLabel: "Yes",
falseLabel: "No",
size: 90,
},
category: {
kind: "label",
label: "Category",
sortKey: "category",
variants: CATEGORY_VARIANTS,
size: 110,
},
notes: { kind: "text", label: "Notes", size: 200 },
},
{},
),
[],
);
return (
<div className="space-y-3">
<p className="text-muted-foreground text-xs">
Use the columns button in the toolbar to toggle visibility, reorder, and
pin columns.
</p>
<DataTable<Order>
fillHeight
className="h-[min(520px,75vh)] min-h-[280px]"
schema={schema}
data={SEED_ORDERS}
getRowId={(r) => r.id}
tableCode="docs-dt-columns"
filtering="none"
showDensityControl
features={{ columns: { manager: true } }}
disablePagination
/>
</div>
);
}Server-Shaped + JSON Logic
buildJsonLogicFilter converts URL params into a JSON Logic rule for POST { filter } list endpoints. The preview panel shows the live output.
null (no active filters)
| Loading… | ||||
"use client";
import { useCallback, useMemo, useState } from "react";
import { parseAsInteger } from "nuqs";
import { useQueryClient } from "@tanstack/react-query";
import { createInMemoryListAdapter } from "@/components/f-ui/data-table/adapters/in-memory-list-adapter";
import type {
JsonLogicRule,
ListQueryAdapter,
} from "@/components/f-ui/data-table/adapters/list-query-contract";
import { DataTable } from "@/components/f-ui/data-table/data-table";
import { useTableSearchParams } from "@/components/f-ui/data-table/hooks/use-table-search-params";
import { defineTableSchema } from "@/components/f-ui/data-table/schema/column-config";
import {
type Order,
SEED_ORDERS,
STATUS_VARIANTS,
CATEGORY_VARIANTS,
} from "./demo-data";
export function DataTableServerJsonLogicDemo() {
const schema = useMemo(
() =>
defineTableSchema<Order>(
{
orderNumber: {
kind: "text",
label: "Order #",
sortKey: "orderNumber",
pinned: "left",
size: 120,
},
customer: {
kind: "text",
label: "Customer",
sortKey: "customer",
size: 160,
filter: {
valueType: "string",
paramKey: "customer",
logicVar: "customer",
},
},
status: {
kind: "status",
label: "Status",
sortKey: "status",
variants: STATUS_VARIANTS,
size: 110,
filter: {
valueType: "enum",
paramKey: "status",
logicVar: "status",
options: Object.entries(STATUS_VARIANTS).map(
([value, { label }]) => ({
value,
label,
}),
),
},
},
amount: {
kind: "currency",
label: "Amount",
sortKey: "amount",
currency: "USD",
size: 120,
filter: {
valueType: "number",
paramKey: "amount",
logicVar: "amount",
defaultOperator: "between",
},
},
category: {
kind: "label",
label: "Category",
sortKey: "category",
variants: CATEGORY_VARIANTS,
size: 110,
filter: {
valueType: "enum",
paramKey: "category",
logicVar: "category",
options: Object.entries(CATEGORY_VARIANTS).map(
([value, { label }]) => ({
value,
label,
}),
),
},
},
},
{
filterGroups: [
{
label: "Filters",
fields: ["customer", "status", "amount", "category"],
},
],
},
),
[],
);
const { params, setParams } = useTableSearchParams(schema, {
urlParamPrefix: "dt_jl_",
extraParsers: {
pageSize: parseAsInteger.withDefault(10),
},
});
const baseAdapter = useMemo(
() => createInMemoryListAdapter<Order>({ items: SEED_ORDERS }),
[],
);
const [latestFilter, setLatestFilter] = useState<JsonLogicRule | null>(null);
// Pedagogical shim: capture each adapter call's `request.filter` into local
// state so the debug panel below can render the live JSON-Logic payload.
const adapter = useCallback<ListQueryAdapter<Order>>(
async (request) => {
setLatestFilter(request.filter ?? null);
return baseAdapter(request);
},
[baseAdapter],
);
const queryClient = useQueryClient();
return (
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
className="border-input bg-background hover:bg-muted/50 rounded-md border px-2 py-1 text-xs"
onClick={() => {
queryClient.invalidateQueries({
queryKey: ["data-table", "docs-dt-json-logic"],
});
}}
>
Refetch
</button>
</div>
<div className="border-input bg-muted/30 rounded-md border p-3 font-mono text-[11px] leading-relaxed">
<div className="text-muted-foreground mb-1">
adapter request.filter →
</div>
<pre className="text-foreground max-h-32 overflow-auto whitespace-pre-wrap break-all">
{latestFilter == null
? "null (no active filters)"
: JSON.stringify(latestFilter, null, 2)}
</pre>
</div>
<DataTable<Order>
fillHeight
className="h-[min(520px,75vh)] min-h-[280px]"
schema={schema}
adapter={adapter}
getRowId={(r) => r.id}
tableCode="docs-dt-json-logic"
params={params}
onParamsChange={setParams}
filtering="builder"
showDensityControl
/>
</div>
);
}Related
- Internationalization —
FuiI18nProvider, built-in locales, and per-tablelocale/ton<DataTable>. - Date Picker / Date Range Picker —
DatePicker/DateRangePickerfor filter date fields. - Currency Format and Currency Input — money columns and field editing.
- Empty Value Placeholder — empty cells use this component internally.
- Inline Edit — read/edit primitive used by Data Table cell and table batch editing strategies.