Table
Data-list Table view — Ant Design-aligned query list composition with filters, toolbar, selection batch bar, and field-schema columns.
Plus Registry
This component ships from registry.plus.json, not the public registry.json. Set up @f-ui-plus and a token on the Installation page, then install @f-ui-plus/table (and @f-ui-plus/data-list-chrome when composing regions).
The Table is the first list body in the data-list family — the matrix view in Ant Design's data-list spec. It renders columns from a field schema over a shared DataListHandle (sort, filter, selection, pagination). Search, toolbar, batch bar, and pagination live in data-list-chrome and compose around the table, matching Ant Design Pro's query list page layout.
Pair the table with Query Filter for staged form search at the top, or use live chrome filters (keyword search + column-header filters) for reactive filtering.
When To Use
- List pages with a data matrix — applications, orders, users, audit logs (Ant Design Pro query list / research list).
- Choose a data-loading recipe — small directories (<~300 simple rows) load once and paginate/filter/sort on the client; larger or growing sets use a server adapter per page (see Data Loading).
- Columns should follow data semantics — define fields once in
defineDataListSchema; per-view layout (align,pinned,grow) stays on the Table. - One handle, many regions — toolbar search, column filters, pagination, and the selection batch bar all read the same
DataListHandle. - Batch operations after row selection — checkbox column + bottom bar with summary aggregates and action buttons (AntD batch operations).
- Use uncontrolled
<Table schema data … />for a standalone table; use controlleddataList={handle}when you compose chrome (recommended for full pages). - Wrap or paint a cell without replacing
kind—renderCellfor extra copy / badges / criticality,cellClassNamefor the<td>. See Cell Customization.
Compared to Ant Design Pro Query List
| Ant Design Pro query list | f-ui data-list family |
|---|---|
| Top query form (staged submit) | Query Filter in shell stats slot |
| Toolbar (create / export / search; density & fullscreen opt-in) | DataListToolbar + action slots |
| Quick-filter tabs (all / unshipped) | DataListToolbarTabs — app maps onChange to data |
| Table (sort / selection / column settings; density opt-in) | Table + column manager (+ optional density) |
| Column-header filters | renderColumnFilter → DataListColumnFilter |
| Pagination | DataListPagination |
| Batch bar (selected count + summary + actions) | DataListSelectionBar (summary + actions slots) |
| Empty state | Standalone <Table> owns DataListEmpty (no data vs no matches); QueryList keeps Empty at the shell so filters stay usable |
Features
| Area | Behavior |
|---|---|
| Field schema | defineDataListSchema — text, number, currency, status, link, date, … |
| Copyable cells | FieldConfig.copyable → hover Copy Affordance; suppressed when Access masked/pending |
| Cell customization | Four layers: accessor → kind → schema renderCell (wrap formatted DOM) → columns.cellClassName on <td>. See Cell Customization |
| Column layout | columns prop — size, align, pinned, grow, resizable, cellClassName per field |
| Sort | Header caret → handle.onSortColumn; multi-sort ordinal when enabled |
| Selection | Id-based handle.selection; checkbox column bridges core selection |
| Column manager | Show / hide / reorder / pin / resize (toolbar affordance) |
| Field access | FieldConfig.access + ambient AccessPolicy — see Access |
| Density | Compact / normal / comfortable row heights (DataListDensityToggle — opt-in, not QueryList default) |
| Column-header filter | Pluggable renderColumnFilter slot; chrome supplies popover editors |
| Expandable rows | expandable.expandedRowRender — Ant Table.expandable parity; chevron column + full-width detail <tr> (non-virtual) |
| Nested rows | nested — read-only child Table with independent columns via getSubRows (mutually exclusive with expandable) |
| Row grouping | rowGrouping — contiguous-run group bands above leaf rows (getGroupKey + renderBand); sticky bands under header by default; non-virtual only |
| Empty / loading / error | Recipe owns Empty + Error; first-load default is skeleton (loadingVariant="spinner" opt-in) |
| Recipe forms | Controlled dataList={handle} or uncontrolled schema + data |
| Data loading | Client full load (createInMemoryListAdapter({ load })) or server adapter per page — see Data Loading |
| Chrome regions | Independent install (@f-ui-plus/data-list-chrome) — compose, don't bundle |
Interactions
| Event | Behavior |
|---|---|
| Click sortable header | Toggles sort via onSortColumn (manual / server-side) |
| Row checkbox | selection.toggle(id) — survives refetch when getRowId is stable |
| Header checkbox | selectAllOnPage() / clear() for current page |
renderColumnFilter | Opens column filter popover; commits to filters.setField |
DataListSearchInput | Debounced keyword → filters.setSearch |
DataListPagination | setParams({ page, pageSize }) on the shared handle |
| Expand chevron | Toggles a full-width detail row via expandable.expandedRowRender or nested child table (requires getRowId) |
| Selection bar appears | When selection.count > 0; clear (×) calls selection.clear() |
| Query Filter Search | Staged submit — your page updates data or adapter params, then table reacts |
Composition
Full list pages assemble regions around one useDataList handle (AntD data-list family):
DataListProvider (handle → context)
└── DataListShell
├── stats slot
│ ├── QueryFilter (variant="plain") ← inquire
│ └── StatisticGroup (card KPIs) ← filter-scoped by default
├── toolbar → DataListToolbar
│ ├── DataListToolbarTabs (quick filters)
│ ├── actions slot (create / export …)
│ ├── tools slot (search / column manager; density + fullscreen opt-in)
│ └── fullscreen toggle only when host passes `onToggleFullscreen`
├── body
│ └── Table (or TableView via useTable)
└── footer
├── DataListSelectionBar (summary + batch actions)
└── DataListPagination | DataListLoadMoreStack inquire filters above KPIs in stats. Prefer filter-scoped cards derived from the same result set as the table — see List Page Statistics and Statistic.
Recommended query-list idiom: useDataListQueryPage returns { handle, view, providerProps, tableViewProps, slots, selectedRows }. Wire DataListProvider → DataListShell (pass slots at the call site) → TableView (spread tableViewProps, add presentational props). Card/border styling belongs on DataListShell (className), not on the table recipe.
Batch Bar Placement
Ant Design Pro surfaces batch operations in a selection alert strip directly above the table. Choose:
| Use | Component |
|---|---|
| Selection alert strip above the table (default) | DataListSelectionBar (placed above the body) |
| Embedded in a list card footer | DataListSelectionBar variant="embedded" |
Docked batch bar at content bottom (long pages, Ant Pro FooterToolbar) | DataListFooterToolbar |
All take summary + actions slots; the count comes from handle.selection.count.
Install @f-ui-plus/table for the view, @f-ui-plus/data-list-view for shared formatters and cells, and @f-ui-plus/data-list-chrome for regions. Core logic is in @f-ui-plus/data-list-internals (no UI).
Installing
Configure @f-ui-plus and FUI_PLUS_REGISTRY_TOKEN as in Installation — Plus Registry.
FUI_PLUS_REGISTRY_TOKEN=xxx pnpm dlx shadcn@latest add @f-ui-plus/table @f-ui-plus/data-list-view @f-ui-plus/data-list-chromeFUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/table @f-ui-plus/data-list-view @f-ui-plus/data-list-chromeFUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/table @f-ui-plus/data-list-view @f-ui-plus/data-list-chromeFUI_PLUS_REGISTRY_TOKEN=xxx bunx shadcn@latest add @f-ui-plus/table @f-ui-plus/data-list-view @f-ui-plus/data-list-chromeRuntime npm deps include @tanstack/react-table, @tanstack/react-query, @dnd-kit/*, and lucide-react. Registry pulls @f-ui-plus/data-list-internals, shadcn primitives, and open f-ui items (empty-value-placeholder, label-tag, status-tag, currency-format, …). For the query form, also install @f-ui-plus/query-filter and @f-ui-plus/formily.
Wrap the app in QueryClientProvider — the core uses TanStack Query for adapter fetches.
Usage
Controlled handle + composed regions (recommended for list pages):
const handle = useDataList({
schema,
listCode: "apps",
data: rows,
getRowId: (r) => r.id,
features: { filters: true, selection: true, pagination: { mode: "offset" } },
});
<DataListProvider dataList={handle}>
<DataListShell stats={<QueryFilter variant="plain" … />} footer={<><DataListSelectionBar … /><DataListPagination /></>}>
<Table dataList={handle} renderColumnFilter={…} />
</DataListShell>
</DataListProvider>Standalone table (no chrome):
<Table schema={schema} data={rows} getRowId={(r) => r.id} selection filtering />Examples
Examples are grouped like Ant Design Table — data loading first (where rows come from), then basic usage, cell customization, table capabilities, filtering, chrome, and loading / edge cases. Full assembled pages live on Query List.
Data Loading
Pick a recipe from the matrix, then copy the matching demo. Soft guidance: <~300 simple rows → client full load; larger or unbounded growth → server offset (or infinite scroll).
| My data | Recipe | Demo |
|---|---|---|
| Already in memory / no network | data={rows} | Uncontrolled Recipe |
| <~300, one API full load | createInMemoryListAdapter({ load }) | Client Full Load |
| Larger / will grow | Custom adapter (or createServerListAdapter); one page per request | Server Offset Pagination |
| Feed / infinite list | pagination.mode: "cursor" | Infinite Scroll (height-capped scroll is the unbounded path; button Load more when the table has no height cap) |
| Batch “all matching” | selectAllMatching | Select All Across Pages |
Client Full Load
One network fetch loads the directory (~40 rows). After that, search, sort, and pagination run in memory via applyQuery. Use this for small directories and modal pickers.
0 selected across all pages
| Loading… | ||||
"use client";
import { useMemo } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DataListPagination } from "@/components/f-ui/data-list-chrome/data-list-pagination";
import { DataListSearchInput } from "@/components/f-ui/data-list-chrome/data-list-search-input";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { createInMemoryListAdapter } from "@/components/f-ui/data-list-internals/adapters/in-memory-list-adapter";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { Table } from "@/components/f-ui/table/table";
import {
createDirectoryMembers,
delay,
type DirectoryMember,
} from "@/demos/table/directory-demo-data";
import { f } from "@/components/f-ui/field-types/catalog";
const ROWS = createDirectoryMembers(40);
const schema = defineDataListSchema<DirectoryMember>({
name: f.text({ label: "Name", sortable: true }),
email: f.text({ label: "Email" }),
department: f.text({ label: "Department", sortable: true }),
status: f.enum({ render: "status", label: "Status", variants: {
active: { label: "Active" },
invited: { label: "Invited" },
} }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
/**
* One network fetch loads the full directory; search, sort, and pagination
* then run in memory via applyQuery (client full-load recipe).
*/
function TableClientFullLoadDemoInner() {
const adapter = useMemo(
() =>
createInMemoryListAdapter<DirectoryMember>({
load: async (signal) => {
await delay(400, signal);
return ROWS;
},
}),
[],
);
const handle = useDataList({
schema,
listCode: "demo-table-client-full-load",
adapter,
getRowId: (row) => row.id,
defaultPageSize: 5,
features: {
filters: true,
selection: true,
pagination: { mode: "offset" },
},
});
return (
<DataListProvider dataList={handle}>
<div className="space-y-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<DataListSearchInput className="sm:max-w-xs" dataList={handle} />
<p className="text-muted-foreground text-xs tabular-nums">
{handle.selection.count} selected across all pages
</p>
</div>
<Table dataList={handle} stickyHeader />
<DataListPagination
dataList={handle}
pageSizeOptions={[5, 10]}
className="border-border border-t pt-2"
/>
</div>
</DataListProvider>
);
}
export function TableClientFullLoadDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableClientFullLoadDemoInner />
</QueryClientProvider>
);
}Server Offset Pagination
Do not download the full set. A fake server adapter delays, then runs applyQuery on ~120 rows — the same contract as a real paginated API. Search uses minChars={2}. Selection stores IDs across pages (Ant preserveSelectedRowKeys equivalent). For a full Ant Pro query-list page, see Query List — Full Page.
0 selected across all pages
| Loading… | ||||
"use client";
import { useMemo } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DataListPagination } from "@/components/f-ui/data-list-chrome/data-list-pagination";
import { DataListSearchInput } from "@/components/f-ui/data-list-chrome/data-list-search-input";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { applyQuery } from "@/components/f-ui/data-list-internals/adapters/apply-query";
import type { DataListAdapter } from "@/components/f-ui/data-list-internals/adapters/data-list-adapter";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { Table } from "@/components/f-ui/table/table";
import {
createDirectoryMembers,
delay,
type DirectoryMember,
} from "@/demos/table/directory-demo-data";
import { f } from "@/components/f-ui/field-types/catalog";
const ALL_ROWS = createDirectoryMembers(120);
const schema = defineDataListSchema<DirectoryMember>({
name: f.text({ label: "Name", sortable: true }),
email: f.text({ label: "Email" }),
department: f.text({ label: "Department", sortable: true }),
status: f.enum({ render: "status", label: "Status", variants: {
active: { label: "Active" },
invited: { label: "Invited" },
} }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
/**
* Fake server adapter: every page / search / sort pays a network delay, then
* applies the same filter → sort → page pipeline a real API would.
*/
function createFakeServerDirectoryAdapter(
allRows: readonly DirectoryMember[],
): DataListAdapter<DirectoryMember> {
const adapter: DataListAdapter<DirectoryMember> = async (request) => {
await delay(350, request.signal);
return applyQuery(allRows, request);
};
adapter.versionKey = ["demo-table-server-offset", allRows.length];
return adapter;
}
/**
* Large directories should not download in one shot. Each page, search, and sort
* is a request; selection stores IDs across pages.
*/
function TableServerOffsetDemoInner() {
const adapter = useMemo(
() => createFakeServerDirectoryAdapter(ALL_ROWS),
[],
);
const handle = useDataList({
schema,
listCode: "demo-table-server-offset",
adapter,
getRowId: (row) => row.id,
defaultPageSize: 5,
features: {
filters: true,
selection: true,
pagination: { mode: "offset" },
},
});
return (
<DataListProvider dataList={handle}>
<div className="space-y-3 rounded-xl border bg-card p-4 shadow-sm">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<DataListSearchInput
className="sm:max-w-xs"
dataList={handle}
minChars={2}
placeholder="Search (min 2 characters)…"
/>
<p className="text-muted-foreground text-xs tabular-nums">
{handle.selection.count} selected across all pages
</p>
</div>
<Table dataList={handle} stickyHeader />
<DataListPagination
dataList={handle}
pageSizeOptions={[5, 10, 20]}
className="border-border border-t pt-2"
/>
</div>
</DataListProvider>
);
}
export function TableServerOffsetDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableServerOffsetDemoInner />
</QueryClientProvider>
);
}Basic Usage
Controlled Handle
Minimal table over an external useDataList handle — useful when you already own the handle and only need the matrix body. Click column headers to sort.
| Auth Gateway | Lin | Active | 980 | 3,100.00 | 2026-02-11 |
| Billing Service | Ada | Active | 1,280 | 4,200.00 | 2026-01-04 |
| Image Pipeline | Mei | Paused | 210 | 760.00 | 2026-03-02 |
| Legacy Export | Sam | Archived | 12 | 40.00 | 2025-11-22 |
| Search Index | Ravi | Active | 5,400 | 9,900.00 | 2026-03-19 |
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoApp {
id: string;
name: string;
owner: string;
status: "active" | "paused" | "archived";
calls: number;
amount: number;
createdAt: string;
}
const DEMO_APPS: DemoApp[] = [
{ id: "1", name: "Billing Service", owner: "Ada", status: "active", calls: 1280, amount: 4200, createdAt: "2026-01-04" },
{ id: "2", name: "Auth Gateway", owner: "Lin", status: "active", calls: 980, amount: 3100, createdAt: "2026-02-11" },
{ id: "3", name: "Image Pipeline", owner: "Mei", status: "paused", calls: 210, amount: 760, createdAt: "2026-03-02" },
{ id: "4", name: "Search Index", owner: "Ravi", status: "active", calls: 5400, amount: 9900, createdAt: "2026-03-19" },
{ id: "5", name: "Legacy Export", owner: "Sam", status: "archived", calls: 12, amount: 40, createdAt: "2025-11-22" },
];
const demoAppSchema = defineDataListSchema<DemoApp>({
name: f.text({ label: "Name", sortable: true }),
owner: f.text({ label: "Owner", sortable: true }),
status: f.enum({ render: "status", label: "Status", variants: {
active: { label: "Active" },
paused: { label: "Paused" },
archived: { label: "Archived" },
} }),
calls: f.number({ label: "Calls", sortable: true }),
amount: f.currency({ label: "Amount", currency: "USD", sortable: true }),
createdAt: f.date({ label: "Created", sortable: true }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TableBasicDemoInner() {
const handle = useDataList({
schema: demoAppSchema,
listCode: "demo-apps-basic",
data: DEMO_APPS,
getRowId: (r) => r.id,
defaultSort: [{ field: "name", order: "asc" }],
});
return (
<div className="rounded-xl border bg-card p-4 shadow-sm">
<Table
dataList={handle}
columns={{ amount: { align: "end" }, calls: { align: "end" } }}
/>
</div>
);
}
/** Controlled recipe — you own `useDataList`; the table renders the matrix only. */
export function TableBasicDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableBasicDemoInner />
</QueryClientProvider>
);
}Uncontrolled Recipe
Pass schema + data; the table builds its own handle internally. Good for embedded tables inside forms or detail panels.
| Billing Service | Ada | Active | 1,280 | 4,200.00 | 2026-01-04 |
| Auth Gateway | Lin | Active | 980 | 3,100.00 | 2026-02-11 |
| Image Pipeline | Mei | Paused | 210 | 760.00 | 2026-03-02 |
| Search Index | Ravi | Active | 5,400 | 9,900.00 | 2026-03-19 |
| Legacy Export | Sam | Archived | 12 | 40.00 | 2025-11-22 |
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoApp {
id: string;
name: string;
owner: string;
status: "active" | "paused" | "archived";
calls: number;
amount: number;
createdAt: string;
}
const DEMO_APPS: DemoApp[] = [
{ id: "1", name: "Billing Service", owner: "Ada", status: "active", calls: 1280, amount: 4200, createdAt: "2026-01-04" },
{ id: "2", name: "Auth Gateway", owner: "Lin", status: "active", calls: 980, amount: 3100, createdAt: "2026-02-11" },
{ id: "3", name: "Image Pipeline", owner: "Mei", status: "paused", calls: 210, amount: 760, createdAt: "2026-03-02" },
{ id: "4", name: "Search Index", owner: "Ravi", status: "active", calls: 5400, amount: 9900, createdAt: "2026-03-19" },
{ id: "5", name: "Legacy Export", owner: "Sam", status: "archived", calls: 12, amount: 40, createdAt: "2025-11-22" },
];
const demoAppSchema = defineDataListSchema<DemoApp>({
name: f.text({ label: "Name", sortable: true }),
owner: f.text({ label: "Owner", sortable: true }),
status: f.enum({ render: "status", label: "Status", variants: {
active: { label: "Active" },
paused: { label: "Paused" },
archived: { label: "Archived" },
} }),
calls: f.number({ label: "Calls", sortable: true }),
amount: f.currency({ label: "Amount", currency: "USD", sortable: true }),
createdAt: f.date({ label: "Created", sortable: true }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
/** Uncontrolled recipe — pass `schema` + `data`; the table owns its handle. */
export function TableUncontrolledDemo() {
return (
<QueryClientProvider client={queryClient}>
<div className="rounded-xl border bg-card p-4 shadow-sm">
<Table
schema={demoAppSchema}
data={DEMO_APPS}
listCode="demo-apps-uncontrolled"
getRowId={(r) => r.id}
columns={{ amount: { align: "end" }, calls: { align: "end" } }}
/>
</div>
</QueryClientProvider>
);
}Cell Customization
Do not replace kind with a custom renderer. Customization is four layers. Change review, threshold colour, source badges, and SLA copy are compositions of those layers — not separate APIs.
| Layer | API | Job |
|---|---|---|
| 1 Get | accessor | Which value |
| 2 Format | kind | Money, date, status tag, empty dash |
| 3 Content | FieldConfig.renderCell(formatted, row) | Wrap the already-formatted node (badge, extra copy, CellCriticalityValue) |
| 4 Paint | columns.cellClassName | Class on the <td>. Function per row; undefined clears host paint |
| Host wants | Use |
|---|---|
| Money / date / enum chip looks right | Layer 2 — kind |
| Extra words, icon, badge, before→after | Layer 3 — renderCell |
| Semantic status colour + mandatory icon | Layer 3 + CellCriticalityValue |
| Cell background / left bar, not a ring | Layer 4 — cellClassName |
| Whole row attention | getRowClassName |
| Fully custom value (no kind) | f.custom({ render }) as layer 2, then optional renderCell wrap |
Do
- Pass
formattedthrough — it is already money / date / Status Tag / empty placeholder. - Put host copy in
renderCell(second line, muted suffix,reasonslot). - Return
undefinedfrom acellClassNamefunction when the row should not paint. - Keep
f.enum({ render: "status" })andrenderCellon the same field.
Don't
- Replace
kindwithf.customjust to add a badge. - Name a column overlay
render— enum already ownsrender: "status" \| "label". - Rebuild a value-state ring with
cellClassName(border-destructive, kit ring classes). - Expect
renderCellto wrap an editing control (Editable Tablefield.component). - Decorate Access-masked / pending / schema-
maskvalues — the kernel skips the wrap. - Expect clipboard text to include the badge —
copyablecopies layer 2, not the DOM.
Shared API: Field Types — renderCell. Write-grid compositions: Editable Table — Cell Customization.
Extra Copy Beside The Value
Qty stays a formatted number. renderCell adds a muted Bulk suffix when qty >= 10. Open the Code tab — the first argument is the kernel node, not String(row.qty).
| BRK-04 | 12Bulk |
| GSK-11 | 4 |
| BSH-02 | 1 |
"use client";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
import {
CELL_DEMO_ROWS,
TableCellDemoShell,
type CellDemoLine,
} from "./table-cell-customization-shared";
const schema = defineDataListSchema<CellDemoLine>({
sku: f.text({ label: "SKU" }),
qty: f.number({
label: "Qty",
renderCell: (formatted, row) =>
row.qty >= 10 ? (
<span className="inline-flex items-center gap-1">
{formatted}
<span className="text-muted-foreground text-xs">Bulk</span>
</span>
) : (
formatted
),
}),
});
function Inner() {
const handle = useDataList({
schema,
listCode: "demo-cell-wrap-copy",
data: CELL_DEMO_ROWS,
getRowId: (r) => r.id,
});
return <Table dataList={handle} columns={{ qty: { align: "end" } }} />;
}
/** Layer 3: keep kind formatting, add host copy beside the value. */
export function TableCellWrapCopyDemo() {
return (
<TableCellDemoShell>
<Inner />
</TableCellDemoShell>
);
}Cell Wash Without A Ring
Amount over $1,000 paints the <td> with bg-warning/10. Rows under the threshold return undefined and stay unpainted. This is not a value-state ring.
| BRK-04 | 420.00 |
| GSK-11 | 1,800.00 |
| BSH-02 | 18.00 |
"use client";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
import {
CELL_DEMO_ROWS,
TableCellDemoShell,
type CellDemoLine,
} from "./table-cell-customization-shared";
const schema = defineDataListSchema<CellDemoLine>({
sku: f.text({ label: "SKU" }),
amount: f.currency({ label: "Amount", currency: "USD", measure: "none" }),
});
function Inner() {
const handle = useDataList({
schema,
listCode: "demo-cell-wash",
data: CELL_DEMO_ROWS,
getRowId: (r) => r.id,
});
return (
<Table
dataList={handle}
columns={{
amount: {
align: "end",
cellClassName: (row) =>
row.amount > 1000 ? "bg-warning/10" : undefined,
},
}}
/>
);
}
/** Layer 4: paint the td. Returning undefined clears host paint. */
export function TableCellWashDemo() {
return (
<TableCellDemoShell>
<Inner />
</TableCellDemoShell>
);
}Semantic Cell Attention
Colour on the value plus a mandatory icon (CellCriticalityValue inside renderCell). Host reason is optional copy — the kit never ships “was” / “changed from”.
| BRK-04 | Positive420.00 |
| GSK-11 | Critical1,800.00Over $1,000 |
| BSH-02 | Positive18.00 |
"use client";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { Table } from "@/components/f-ui/table/table";
import { CellCriticalityValue } from "@/components/f-ui/table/lib/cell-criticality";
import { f } from "@/components/f-ui/field-types/catalog";
import {
CELL_DEMO_ROWS,
TableCellDemoShell,
type CellDemoLine,
} from "./table-cell-customization-shared";
const schema = defineDataListSchema<CellDemoLine>({
sku: f.text({ label: "SKU" }),
amount: f.currency({
label: "Amount",
currency: "USD",
measure: "none",
renderCell: (formatted, row) => (
<CellCriticalityValue
criticality={row.amount > 1000 ? "warning" : "success"}
reason={row.amount > 1000 ? "Over $1,000" : undefined}
>
{formatted}
</CellCriticalityValue>
),
}),
});
function Inner() {
const handle = useDataList({
schema,
listCode: "demo-cell-criticality",
data: CELL_DEMO_ROWS,
getRowId: (r) => r.id,
});
return <Table dataList={handle} columns={{ amount: { align: "end" } }} />;
}
/** Layer 3 + helper: colour on the value plus a mandatory icon. */
export function TableCellCriticalityDemo() {
return (
<TableCellDemoShell>
<Inner />
</TableCellDemoShell>
);
}Status Tag Plus Host Badge
f.enum({ render: "status" }) still renders the Status Tag. renderCell wraps it with a Stale suffix on one row. The enum render key and the host function are different properties.
| BRK-04 | PO |
| GSK-11 | EstimatedStale |
| BSH-02 | PO |
"use client";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
import {
CELL_DEMO_ROWS,
TableCellDemoShell,
type CellDemoLine,
} from "./table-cell-customization-shared";
const schema = defineDataListSchema<CellDemoLine>({
sku: f.text({ label: "SKU" }),
source: f.enum({
render: "status",
label: "Source",
variants: {
po: { label: "PO", tone: "success" },
estimated: { label: "Estimated", tone: "warning" },
},
renderCell: (formatted, row) =>
row.stale ? (
<span className="inline-flex items-center gap-1">
{formatted}
<span className="text-muted-foreground text-xs">Stale</span>
</span>
) : (
formatted
),
}),
});
function Inner() {
const handle = useDataList({
schema,
listCode: "demo-cell-status-badge",
data: CELL_DEMO_ROWS,
getRowId: (r) => r.id,
});
return <Table dataList={handle} />;
}
/** Enum `render: "status"` and host `renderCell` coexist on the same field. */
export function TableCellStatusBadgeDemo() {
return (
<TableCellDemoShell>
<Inner />
</TableCellDemoShell>
);
}Combined Layers
Wrap copy, criticality, and td wash on one grid — the usual scan after you have seen each layer alone.
| BRK-04 | 12Bulk | Positive420.00 | PO |
| GSK-11 | 4 | Critical1,800.00 | Estimated |
| BSH-02 | 1 | Positive18.00 | PO |
"use client";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { Table } from "@/components/f-ui/table/table";
import { CellCriticalityValue } from "@/components/f-ui/table/lib/cell-criticality";
import { f } from "@/components/f-ui/field-types/catalog";
import {
CELL_DEMO_ROWS,
TableCellDemoShell,
type CellDemoLine,
} from "./table-cell-customization-shared";
const schema = defineDataListSchema<CellDemoLine>({
sku: f.text({ label: "SKU" }),
qty: f.number({
label: "Qty",
renderCell: (formatted, row) =>
row.qty >= 10 ? (
<span className="inline-flex items-center gap-1">
{formatted}
<span className="text-muted-foreground text-xs">Bulk</span>
</span>
) : (
formatted
),
}),
amount: f.currency({
label: "Amount",
currency: "USD",
measure: "none",
renderCell: (formatted, row) => (
<CellCriticalityValue criticality={row.amount > 1000 ? "warning" : "success"}>
{formatted}
</CellCriticalityValue>
),
}),
source: f.enum({
render: "status",
label: "Source",
variants: {
po: { label: "PO", tone: "success" },
estimated: { label: "Estimated", tone: "warning" },
},
}),
});
function Inner() {
const handle = useDataList({
schema,
listCode: "demo-cell-customization",
data: CELL_DEMO_ROWS,
getRowId: (r) => r.id,
});
return (
<Table
dataList={handle}
columns={{
qty: { align: "end" },
amount: {
align: "end",
cellClassName: (row) =>
row.amount > 1000 ? "bg-warning/10" : undefined,
},
}}
/>
);
}
/** Combined scan: wrap copy + criticality + td wash on one grid. */
export function TableCellCustomizationDemo() {
return (
<TableCellDemoShell>
<Inner />
</TableCellDemoShell>
);
}Selection And Row Actions
Row Selection
selection + getRowId adds a checkbox column; the header checkbox selects or clears the current page.
| Billing Service | Ada | Active | 1,280 | 4,200.00 | |
| Auth Gateway | Lin | Active | 980 | 3,100.00 | |
| Image Pipeline | Mei | Paused | 210 | 760.00 | |
| Search Index | Ravi | Active | 5,400 | 9,900.00 | |
| Legacy Export | Sam | Archived | 12 | 40.00 |
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoApp {
id: string;
name: string;
owner: string;
status: "active" | "paused" | "archived";
calls: number;
amount: number;
}
const DEMO_APPS: DemoApp[] = [
{ id: "1", name: "Billing Service", owner: "Ada", status: "active", calls: 1280, amount: 4200 },
{ id: "2", name: "Auth Gateway", owner: "Lin", status: "active", calls: 980, amount: 3100 },
{ id: "3", name: "Image Pipeline", owner: "Mei", status: "paused", calls: 210, amount: 760 },
{ id: "4", name: "Search Index", owner: "Ravi", status: "active", calls: 5400, amount: 9900 },
{ id: "5", name: "Legacy Export", owner: "Sam", status: "archived", calls: 12, amount: 40 },
];
const demoAppSchema = defineDataListSchema<DemoApp>({
name: f.text({ label: "Name", sortable: true }),
owner: f.text({ label: "Owner" }),
status: f.enum({ render: "status", label: "Status", variants: {
active: { label: "Active" },
paused: { label: "Paused" },
archived: { label: "Archived" },
} }),
calls: f.number({ label: "Calls", sortable: true }),
amount: f.currency({ label: "Amount", currency: "USD", sortable: true }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
/** Row checkboxes require stable `getRowId`; header checkbox selects the current page. */
export function TableSelectionDemo() {
return (
<QueryClientProvider client={queryClient}>
<div className="rounded-xl border bg-card p-4 shadow-sm">
<Table
schema={demoAppSchema}
data={DEMO_APPS}
listCode="demo-apps-selection"
getRowId={(r) => r.id}
selection
columns={{ amount: { align: "end" }, calls: { align: "end" } }}
/>
</div>
</QueryClientProvider>
);
}Controlled Selection
Pass an object to features.selection on a useDataList handle to control the selected ids from outside — the same controlled/uncontrolled pattern as Ant Design's rowSelection.selectedRowKeys. The Table/QueryList shorthand selection prop stays boolean-only. onChange fires only on user interaction (never on external value sync), so it is safe to feed a form field or URL state straight back in:
const handle = useDataList({
schema, listCode, adapter, getRowId: (r) => r.id,
features: {
selection: {
value: selectedIds, // controlled ids (omit for uncontrolled)
defaultValue: [], // uncontrolled initial (ignored when value is set)
onChange: (ids) => setSelectedIds(ids),
},
},
});Selected ids are preserved across pages/filters (Ant preserveSelectedRowKeys equivalent). All-matching mode is internal and may not persist under controlled mode when the parent echoes onChange.
Row Actions
Pass rowActions for a right-pinned Actions column. Default rowActionsDisplay="inline" shows text links; overflow collapses into More. Destructive actions (or any action with confirm) open a confirm popover that awaits onClick (void | Promise<void>), shows loading on Confirm, and closes on success — the same contract as Confirm. Rejection keeps the popover open; the caller owns toast.
Danger surface (safe-then-danger layout; full guidance + demos: Row Actions Column):
- Kit orders non-destructive actions before destructive in the primary cluster and overflow; overflow inserts a divider before the first destructive item.
- Danger-only rows (only destructive primaries): keep them inline when count ≤
maxInline— do not bury a sole Delete in More. placement: "trailing": at most one always-visible action after the separator. Prefer trailing when Delete must stay visible beside many safes; not mandatory for every destructive.- Fiori xor: if the table has toolbar/batch Delete, do not also ship row Delete (inline or trailing) for the same destroy semantics — and the reverse.
Use href for navigable actions and onClick for commands. SPA hosts must mount a host LinkProvider or href is a native <a> (full document reload). linkComponent remains a local override when one table needs a different Link. For imperative dialogs outside the row cell, use ConfirmProvider / useConfirm or ConfirmAction.
const rowActions = [
{
id: "edit",
label: "Edit",
hidden: (row) => row.status === "archived",
onClick: editApp,
},
{ id: "duplicate", label: "Duplicate", onClick: duplicateApp },
{
id: "delete",
label: "Delete",
variant: "destructive",
placement: "trailing",
hidden: (row) => row.status === "archived",
onClick: deleteApp,
},
] satisfies RowAction<App>[];Archived rows hide Edit and Delete (no leftover hairline). Active / paused rows keep trailing Delete after the primary cluster.
Actions | ||||
|---|---|---|---|---|
| Billing Service | Ada | Active | 4,200.00 | |
| Auth Gateway | Lin | Active | 3,100.00 | |
| Image Pipeline | Mei | Paused | 760.00 | |
| Legacy Export | Sam | Archived | 40.00 |
"use client";
import { CopyIcon, PencilIcon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import type { RowAction } from "@/components/f-ui/data-list-internals/row-actions/row-action-types";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoApp {
id: string;
name: string;
owner: string;
status: "active" | "paused" | "archived";
amount: number;
}
const DEMO_APPS: DemoApp[] = [
{ id: "1", name: "Billing Service", owner: "Ada", status: "active", amount: 4200 },
{ id: "2", name: "Auth Gateway", owner: "Lin", status: "active", amount: 3100 },
{ id: "3", name: "Image Pipeline", owner: "Mei", status: "paused", amount: 760 },
{ id: "4", name: "Legacy Export", owner: "Sam", status: "archived", amount: 40 },
];
const demoAppSchema = defineDataListSchema<DemoApp>({
name: f.text({ label: "Name", sortable: true }),
owner: f.text({ label: "Owner" }),
status: f.enum({ render: "status", label: "Status", variants: {
active: { label: "Active" },
paused: { label: "Paused" },
archived: { label: "Archived" },
} }),
amount: f.currency({ label: "Amount", currency: "USD", sortable: true }),
});
const rowActions: RowAction<DemoApp>[] = [
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-3.5" />,
hidden: (row) => row.status === "archived",
onClick: (r) => {
toast.info(`Edit ${r.name}`);
},
},
{
id: "duplicate",
label: "Duplicate",
icon: <CopyIcon className="size-3.5" />,
onClick: (r) => {
toast.info(`Duplicate ${r.name}`);
},
},
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
placement: "trailing",
hidden: (row) => row.status === "archived",
confirm: { description: "This permanently deletes the application." },
onClick: (r) => {
toast.success(`Deleted ${r.name}`);
},
},
];
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TableRowActionsDemoInner() {
const handle = useDataList({
schema: demoAppSchema,
listCode: "demo-apps-row-actions",
data: DEMO_APPS,
getRowId: (r) => r.id,
});
return (
<div className="rounded-xl border bg-card p-4 shadow-sm">
<Table
dataList={handle}
columns={{ amount: { align: "end" } }}
rowActions={rowActions}
rowActionsPresentation="icon"
maxInlineRowActions={2}
/>
</div>
);
}
/** Right-pinned Actions: Edit + Duplicate inline; trailing Delete; hide both on archived. */
export function TableRowActionsDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableRowActionsDemoInner />
</QueryClientProvider>
);
}Actions | ||||
|---|---|---|---|---|
| Billing Service | Ada | Active | 4,200.00 | |
| Auth Gateway | Lin | Active | 3,100.00 | |
| Image Pipeline | Mei | Paused | 760.00 | |
| Legacy Export | Sam | Archived | 40.00 |
"use client";
import { CopyIcon, PencilIcon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import type { RowAction } from "@/components/f-ui/data-list-internals/row-actions/row-action-types";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoApp {
id: string;
name: string;
owner: string;
status: "active" | "paused" | "archived";
amount: number;
}
const DEMO_APPS: DemoApp[] = [
{ id: "1", name: "Billing Service", owner: "Ada", status: "active", amount: 4200 },
{ id: "2", name: "Auth Gateway", owner: "Lin", status: "active", amount: 3100 },
{ id: "3", name: "Image Pipeline", owner: "Mei", status: "paused", amount: 760 },
{ id: "4", name: "Legacy Export", owner: "Sam", status: "archived", amount: 40 },
];
const demoAppSchema = defineDataListSchema<DemoApp>({
name: f.text({ label: "Name", sortable: true }),
owner: f.text({ label: "Owner" }),
status: f.enum({ render: "status", label: "Status", variants: {
active: { label: "Active" },
paused: { label: "Paused" },
archived: { label: "Archived" },
} }),
amount: f.currency({ label: "Amount", currency: "USD", sortable: true }),
});
const rowActions: RowAction<DemoApp>[] = [
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-3.5" />,
hidden: (row) => row.status === "archived",
onClick: (r) => {
toast.info(`Edit ${r.name}`);
},
},
{
id: "duplicate",
label: "Duplicate",
icon: <CopyIcon className="size-3.5" />,
onClick: (r) => {
toast.info(`Duplicate ${r.name}`);
},
},
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
placement: "trailing",
hidden: (row) => row.status === "archived",
confirm: { description: "This permanently deletes the application." },
onClick: (r) => {
toast.success(`Deleted ${r.name}`);
},
},
];
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TableRowActionsDemoInner() {
const handle = useDataList({
schema: demoAppSchema,
listCode: "demo-apps-row-actions",
data: DEMO_APPS,
getRowId: (r) => r.id,
});
return (
<div className="rounded-xl border bg-card p-4 shadow-sm">
<Table
dataList={handle}
columns={{ amount: { align: "end" } }}
rowActions={rowActions}
rowActionsPresentation="icon"
maxInlineRowActions={2}
/>
</div>
);
}
/** Right-pinned Actions: Edit + Duplicate inline; trailing Delete; hide both on archived. */
export function TableRowActionsDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableRowActionsDemoInner />
</QueryClientProvider>
);
}Expandable Rows
Pass expandable when the matrix should stay narrow but each record has a richer detail block — ship-to address, line items, audit metadata. The table injects Ant's EXPAND_COLUMN chevron (selection → expand → data) and renders expandedRowRender in a full-width row beneath the data row (same slot as Editable Table row detail). rowExpandable hides the chevron when a record has nothing to show. Requires stable getRowId. Not supported with view.mode: "virtual" in v1.
Click the chevron on SO-1001 or SO-1002 to reveal line items and shipping fields. SO-1003 is cancelled — no expand control.
| SO-1001 | Acme Corp | 4,280.00 | Pending | |
| SO-1002 | Northwind Traders | 1,290.00 | Shipped | |
| SO-1003 | Globex | 0.00 | Cancelled |
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { EmptyValuePlaceholder } from "@/components/f-ui/empty-value-placeholder";
import { Table } from "@/components/f-ui/table/table";
import { formatMoneyForDisplay } from "@/components/f-ui/currency-format/currency-format";
import { f } from "@/components/f-ui/field-types/catalog";
interface OrderLine {
sku: string;
name: string;
qty: number;
}
interface DemoOrder {
id: string;
customer: string;
total: number;
status: "pending" | "shipped" | "cancelled";
shipTo: string;
poNumber: string;
lines: OrderLine[];
}
const ORDERS: DemoOrder[] = [
{
id: "SO-1001",
customer: "Acme Corp",
total: 4280,
status: "pending",
shipTo: "123 Harbor Way, Seattle, WA",
poNumber: "PO-7781",
lines: [
{ sku: "SKU-001", name: "Wrench set", qty: 2 },
{ sku: "SKU-010", name: "Socket kit", qty: 1 },
],
},
{
id: "SO-1002",
customer: "Northwind Traders",
total: 1290,
status: "shipped",
shipTo: "88 Market St, Portland, OR",
poNumber: "PO-4410",
lines: [{ sku: "SKU-020", name: "Drill bits", qty: 4 }],
},
{
id: "SO-1003",
customer: "Globex",
total: 0,
status: "cancelled",
shipTo: "",
poNumber: "",
lines: [],
},
];
const orderSchema = defineDataListSchema<DemoOrder>({
id: f.text({ label: "Order #", sortable: true }),
customer: f.text({ label: "Customer", sortable: true }),
total: f.currency({ label: "Total", currency: "USD", sortable: true }),
status: f.enum({ render: "status", label: "Status", variants: {
pending: { label: "Pending", tone: "warning" },
shipped: { label: "Shipped", tone: "success" },
cancelled: { label: "Cancelled", tone: "neutral" },
} }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function OrderDetailPanel({ order }: { order: DemoOrder }) {
if (order.lines.length === 0) {
return <p className="text-sm text-muted-foreground">No line items on this cancelled order.</p>;
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">Ship to</div>
<div className="text-sm">
{order.shipTo.trim() ? (
order.shipTo
) : (
<EmptyValuePlaceholder />
)}
</div>
</div>
<div className="space-y-1">
<div className="text-xs font-medium text-muted-foreground">PO number</div>
<div className="text-sm">
{order.poNumber.trim() ? (
order.poNumber
) : (
<EmptyValuePlaceholder />
)}
</div>
</div>
<div className="space-y-2 sm:col-span-2 lg:col-span-3">
<div className="text-xs font-medium text-muted-foreground">Line items</div>
<ul className="divide-border divide-y rounded-lg border text-sm">
{order.lines.map((line) => (
<li key={line.sku} className="flex items-center justify-between gap-3 px-3 py-2">
<span>
<span className="font-medium">{line.sku}</span>
<span className="text-muted-foreground"> · {line.name}</span>
</span>
<span className="tabular-nums text-muted-foreground">Qty {line.qty}</span>
</li>
))}
</ul>
<div className="text-end text-sm tabular-nums">
Order total {formatMoneyForDisplay(order.total, "USD")}
</div>
</div>
</div>
);
}
/**
* Ant Table `expandable` parity — primary columns stay scannable; chevron reveals a
* full-width detail panel beneath the row (non-virtual modes only).
*/
export function TableExpandableDemo() {
return (
<QueryClientProvider client={queryClient}>
<div className="rounded-xl border bg-card p-4 shadow-sm">
<Table
schema={orderSchema}
data={ORDERS}
listCode="demo-orders-expandable"
getRowId={(r) => r.id}
columns={{ total: { align: "end" } }}
expandable={{
expandedRowRender: (order) => <OrderDetailPanel order={order} />,
rowExpandable: (order) => order.status !== "cancelled",
}}
/>
</div>
</QueryClientProvider>
);
}Nested Rows
Use nested when children are a different entity type (master–detail), not progressive disclosure of the same row. Pass independent child columns (TanStack ColumnDefs) and getSubRows. The expanded region is an inset child Table (same visual language as Editable Table nested) — not a freeform panel and not a Card-in-Card. nested and expandable are mutually exclusive (dev throw if both are set). Empty children show kit region Empty — intentional nested chrome (different entity density), not a third list-level Empty product. Requires getRowId.
Expand A-100 to see allocation qty / note; B-220 shows Empty (no allocations). Remaining is a host-derived parent column.
| A-100 | Motor controller | 10 | 4 | |
| B-220 | Harness kit | 8 | 8 | |
| C-310 | Sensor pack | 5 | 0 |
"use client";
import type { ColumnDef } from "@tanstack/react-table";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
interface Claim {
id: string;
qty: number;
note: string;
}
interface CatalogLine {
id: string;
sku: string;
item: string;
capacity: number;
claims: Claim[];
}
function claimedQty(row: CatalogLine): number {
return (row.claims ?? []).reduce((sum, c) => sum + (Number(c.qty) || 0), 0);
}
function remainingQty(row: CatalogLine): number {
return Number(row.capacity) - claimedQty(row);
}
const LINES: CatalogLine[] = [
{
id: "l1",
sku: "A-100",
item: "Motor controller",
capacity: 10,
claims: [
{ id: "c1", qty: 4, note: "Batch A" },
{ id: "c2", qty: 2, note: "Batch B" },
],
},
{
id: "l2",
sku: "B-220",
item: "Harness kit",
capacity: 8,
claims: [],
},
{
id: "l3",
sku: "C-310",
item: "Sensor pack",
capacity: 5,
claims: [{ id: "c3", qty: 5, note: "Full allocation" }],
},
];
const parentSchema = defineDataListSchema<CatalogLine>({
sku: f.text({ label: "SKU", sortable: true }),
item: f.text({ label: "Item", sortable: true }),
capacity: f.number({ label: "Capacity", sortable: true }),
remaining: f.number({
label: "Remaining",
accessor: (row) => remainingQty(row as CatalogLine),
}),
});
const claimColumns: ColumnDef<Claim, unknown>[] = [
{ id: "qty", accessorKey: "qty", header: "Claim qty", size: 110 },
{ id: "note", accessorKey: "note", header: "Note" },
];
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
/**
* Read-only master–detail — parent Table expands to an inset child Table with
* an independent schema (Ant Nested rhythm). Prefer this over `expandable`
* when children are a different entity type.
*/
export function TableNestedDemo() {
return (
<QueryClientProvider client={queryClient}>
<div className="rounded-xl border bg-card p-4 shadow-sm">
<Table
schema={parentSchema}
data={LINES}
listCode="demo-catalog-nested"
getRowId={(r) => r.id}
columns={{ capacity: { align: "end" }, remaining: { align: "end" } }}
nested={{
columns: claimColumns,
getSubRows: (row) => row.claims,
getSubRowId: (c) => c.id,
}}
/>
</div>
</QueryClientProvider>
);
}Row Grouping
Pass rowGrouping when consecutive leaf rows share a parent key and you need a muted in-grid band above each run — shipment header, pack totals, section label. Bands are view chrome (not rows in data). Partition is contiguous-run: same getGroupKey values must be adjacent (pre-sort if needed; non-contiguous keys warn in dev). Always expanded in v1 — no collapse. Band chrome is horizontally sticky to the table scrollport (viewport-width strip) so group actions stay visible when leaf columns overflow. Not supported with view.mode: "virtual" (bands ignored; dev error).
<Table
schema={schema}
data={rows}
getRowId={(r) => r.id}
maxHeight={480}
rowGrouping={{
getGroupKey: (r) => r.shipmentId,
stickyBands: true, // default — stick band under header while leaves scroll
renderBand: ({ groupKey, rows, sample }) => (
<span>
Shipment {groupKey} · {rows.length} lines · {sample.warehouse}
</span>
),
}}
/>When Sticky Bands Help
Use stickyBands (default true) when operators scroll a tall scrollport (maxHeight / fill height) and must keep the current group's actions (Add claim, ledger totals, section label) under the column header while leaf rows move. Typical fit:
- Dozens of groups, each with a handful to a few dozen leaf rows (e.g. shipments → claim lines).
- Wide matrices where the band hosts a primary CTA that must stay in the visible strip.
- Non-virtual bodies — you already chose scrollport over virtualization to keep bands.
Set stickyBands: false when you only need the band as a static section label (no vertical stick) — lighter DOM (single table) and fine for short lists without a vertical scrollport.
How It Works (Essence)
Vertical sticky bands are native CSS position: sticky, not a JS “current group” tracker.
The rule that matters: a sticky element can pin relative to the scrollport, but it cannot escape its containing block. When the bottom of that block scrolls up to the stick line, the sticky element is pulled away with it. Stack several of those blocks and you get hand-off: the next group’s band arrives, the previous band leaves — both stay sticky the whole time. There is no toggle between sticky and static, so there is no flicker at the boundary.
Why the kit splits the body: in a single HTML table, sticky cells are constrained by the table wrapper in Chromium (not each tbody). Put every group’s band in one tall table and all bands share one containing block — they stack on the same stick line instead of handing off. Multi-tbody alone does not fix that on Chrome. So with stickyBands: true the kit renders:
scrollport
sticky column-header block
for each group:
[data-slot=table-group] ← containing block = band + that group’s leaves
sticky band (always sticky)
mini-table (that group’s leaf rows only)Each group wrapper is tall enough to own its leaves; when you scroll into the next group, the previous wrapper’s bottom edge carries its band away. Column widths stay aligned via a shared colgroup / min-width. Horizontal sticky for band actions is separate: chrome is a viewport-width strip (left: 0) so CTAs stay visible while leaf columns scroll sideways.
What we deliberately do not do: detect the “active” band in JS and flip position, or clone one overlay bar that swaps copy. Those can fake the UX but are not CSS hand-off (and the sticky/static flip flickers). AG Grid’s sticky group rows are a custom row-container system — same job, different mechanism; f-ui stays on CSS sticky + per-group containing blocks.
Performance And Limits
| Concern | Guidance |
|---|---|
| DOM cost | Sticky bands use 1 header table + 1 table per group (shared colgroup widths) — the price of per-group containing blocks. Fine for typical workbench group counts; avoid thousands of tiny groups in one scrollport. |
| Scroll cost | Vertical scroll stays CSS sticky — no per-frame React re-renders for hand-off. One ResizeObserver on the scrollport publishes width / header-height CSS variables for all bands. |
| Virtual scrolling | rowGrouping + view.mode: "virtual" is unsupported in v1 (bands ignored; dev error). Prefer a maxHeight scrollport when you need bands. |
| Very large leaf sets | Prefer server pagination / filters so the scrollport stays bounded. Sticky bands are for visible workbench density, not unbounded dumps. |
| Opt out | stickyBands: false keeps contiguous-run bands in a single table (horizontal sticky only) when vertical stick is unnecessary. |
Contiguous Keys
Pre-sort (or server-order) so identical getGroupKey values are adjacent. Non-contiguous keys still render, but emit a dev warning — bands will repeat for each run.
Select All Across Pages
When every row on the page is selected and more matches exist, Select all N matching appears in the selection bar. Batch actions receive mode: "all-matching" with selectionFilter (JSON-Logic).
| Billing Service | 4,200.00 | |
| Auth Gateway | 3,100.00 |
"use client";
import { toast } from "sonner";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import type { BatchAction } from "@/components/f-ui/data-list-internals/selection/batch-action-types";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { DataListSelectionBar } from "@/components/f-ui/data-list-chrome/data-list-selection-bar";
import { DataListSelectAllMatchingToggle } from "@/components/f-ui/data-list-chrome/data-list-select-all-matching-toggle";
import { DataListBatchActionButton } from "@/components/f-ui/data-list-chrome/data-list-batch-action-button";
import { DataListPagination } from "@/components/f-ui/data-list-chrome/data-list-pagination";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoApp {
id: string;
name: string;
amount: number;
}
const DEMO_APPS: DemoApp[] = [
{ id: "1", name: "Billing Service", amount: 4200 },
{ id: "2", name: "Auth Gateway", amount: 3100 },
{ id: "3", name: "Image Pipeline", amount: 760 },
{ id: "4", name: "Search Index", amount: 9900 },
{ id: "5", name: "Legacy Export", amount: 40 },
];
const demoAppSchema = defineDataListSchema<DemoApp>({
name: f.text({ label: "Name", sortable: true }),
amount: f.currency({ label: "Amount", currency: "USD", sortable: true }),
});
const deleteAction: BatchAction<DemoApp> = {
id: "delete",
label: "Delete",
variant: "destructive",
onClick: (payload) => {
toast.success(
payload.mode === "all-matching"
? `Deleting all ${payload.count} matching`
: `Deleting ${payload.count} selected`,
);
payload.clear();
},
};
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TableSelectAllMatchingDemoInner() {
const handle = useDataList({
schema: demoAppSchema,
listCode: "demo-apps-all-matching",
data: DEMO_APPS,
getRowId: (r) => r.id,
defaultPageSize: 2,
features: { selection: true, pagination: { mode: "offset" } },
});
return (
<DataListProvider dataList={handle}>
<div className="space-y-0 overflow-hidden rounded-xl border bg-card shadow-sm">
<DataListSelectionBar
summary={<DataListSelectAllMatchingToggle />}
actions={<DataListBatchActionButton action={deleteAction} />}
/>
<div className="p-4">
<Table dataList={handle} columns={{ amount: { align: "end" } }} />
</div>
<DataListPagination className="border-border border-t px-4 py-2" />
</div>
</DataListProvider>
);
}
/** Select every row on the page, then choose Select all N matching for server-side batch scope. */
export function TableSelectAllMatchingDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableSelectAllMatchingDemoInner />
</QueryClientProvider>
);
}Filtering
Column-Header Filters
Inject DataListColumnFilter through renderColumnFilter for per-column filter popovers (live filter, not staged Query Filter). Try the keyword search and a status filter on a column header.
| Billing Service | Ada | Active | 4,200.00 |
| Auth Gateway | Lin | Active | 3,100.00 |
| Image Pipeline | Mei | Paused | 760.00 |
| Search Index | Ravi | Active | 9,900.00 |
| Legacy Export | Sam | Archived | 40.00 |
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { DataListColumnFilter } from "@/components/f-ui/data-list-chrome/data-list-column-filter";
import { DataListSearchInput } from "@/components/f-ui/data-list-chrome/data-list-search-input";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoApp {
id: string;
name: string;
owner: string;
status: "active" | "paused" | "archived";
amount: number;
}
const DEMO_APPS: DemoApp[] = [
{ id: "1", name: "Billing Service", owner: "Ada", status: "active", amount: 4200 },
{ id: "2", name: "Auth Gateway", owner: "Lin", status: "active", amount: 3100 },
{ id: "3", name: "Image Pipeline", owner: "Mei", status: "paused", amount: 760 },
{ id: "4", name: "Search Index", owner: "Ravi", status: "active", amount: 9900 },
{ id: "5", name: "Legacy Export", owner: "Sam", status: "archived", amount: 40 },
];
const demoAppSchema = defineDataListSchema<DemoApp>({
name: f.text({ label: "Name", sortable: true }),
owner: f.text({ label: "Owner" }),
status: f.enum({ render: "status", label: "Status", variants: {
active: { label: "Active" },
paused: { label: "Paused" },
archived: { label: "Archived" },
} }),
amount: f.currency({ label: "Amount", currency: "USD", sortable: true }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TableColumnFilterDemoInner() {
const handle = useDataList({
schema: demoAppSchema,
listCode: "demo-apps-colfilter",
data: DEMO_APPS,
getRowId: (r) => r.id,
features: { filters: true },
});
return (
<div className="space-y-3">
<DataListSearchInput dataList={handle} className="max-w-xs" />
<div className="rounded-xl border bg-card p-4 shadow-sm">
<Table
dataList={handle}
columns={{ amount: { align: "end" } }}
renderColumnFilter={({ field, dataList }) => (
<DataListColumnFilter field={field} dataList={dataList} />
)}
/>
</div>
</div>
);
}
/** Per-column filter popovers commit live to the shared handle (distinct from staged Query Filter). */
export function TableColumnFilterDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableColumnFilterDemoInner />
</QueryClientProvider>
);
}Filter Panel
DataListFilterPanel gives the list a staged left-rail editor: users adjust multiple fields, then commit with Apply or reset with Clear. Compose it through the DataListShell filters slot for a two-column layout (panel rail + table body). The same panel works for QueryList view="list" / ListView bodies.
| SO-1001 | Ada | Pending |
| SO-1002 | Lin | Shipped |
| SO-1003 | Mei | Pending |
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { DataListActiveFilters } from "@/components/f-ui/data-list-chrome/data-list-active-filters";
import { DataListFilterPanel } from "@/components/f-ui/data-list-chrome/data-list-filter-panel";
import { DataListPagination } from "@/components/f-ui/data-list-chrome/data-list-pagination";
import { DataListSearchInput } from "@/components/f-ui/data-list-chrome/data-list-search-input";
import { DataListToolbar } from "@/components/f-ui/data-list-chrome/data-list-toolbar";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { DataListShell } from "@/components/f-ui/data-list-chrome/layout/data-list-shell";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoOrder {
id: string;
orderNumber: string;
customer: string;
status: "pending" | "shipped" | "cancelled";
}
const DEMO_ORDERS: DemoOrder[] = [
{ id: "1", orderNumber: "SO-1001", customer: "Ada", status: "pending" },
{ id: "2", orderNumber: "SO-1002", customer: "Lin", status: "shipped" },
{ id: "3", orderNumber: "SO-1003", customer: "Mei", status: "pending" },
{ id: "4", orderNumber: "SO-1004", customer: "Ravi", status: "cancelled" },
];
const demoOrderSchema = defineDataListSchema<DemoOrder>({
orderNumber: f.text({ label: "Order #" }),
customer: f.text({ label: "Customer" }),
status: f.enum({ render: "status", label: "Status", variants: {
pending: { label: "Pending" },
shipped: { label: "Shipped" },
cancelled: { label: "Cancelled" },
} }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TableFilterPanelDemoInner() {
const handle = useDataList({
schema: demoOrderSchema,
listCode: "demo-orders-filter-panel",
data: DEMO_ORDERS,
getRowId: (row) => row.id,
defaultPageSize: 3,
features: { filters: true, pagination: { mode: "offset" } },
});
return (
<DataListProvider dataList={handle}>
<DataListShell
className="h-[min(640px,80vh)] min-h-[420px] rounded-xl border bg-card p-3 shadow-sm"
filters={<DataListFilterPanel title="Filters" />}
toolbar={<DataListToolbar title="Orders" toolsStart={<DataListSearchInput className="w-56" />} />}
footer={<DataListPagination />}
>
<DataListActiveFilters className="pb-2" />
<Table dataList={handle} fillHeight />
</DataListShell>
</DataListProvider>
);
}
/** Two-column shell with a staged left filter panel (Apply / Clear) and a table body. */
export function TableFilterPanelDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableFilterPanelDemoInner />
</QueryClientProvider>
);
}Empty And No Results
Standalone <Table> owns Empty — mount the recipe alone; do not wrap isEmpty ? <DataListEmpty /> : <Table>. DataListEmpty distinguishes no data from no matches by reading the active filter count. Search for a term that matches nothing, then use Clear filters to recover. (QueryList keeps Empty at the shell so filters stay usable, and does not also mount <Table> when empty — one Empty owner.)
| Billing Service | 4,200.00 |
| Auth Gateway | 3,100.00 |
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { DataListSearchInput } from "@/components/f-ui/data-list-chrome/data-list-search-input";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoApp {
id: string;
name: string;
amount: number;
}
const DEMO_APPS: DemoApp[] = [
{ id: "1", name: "Billing Service", amount: 4200 },
{ id: "2", name: "Auth Gateway", amount: 3100 },
];
const demoAppSchema = defineDataListSchema<DemoApp>({
name: f.text({ label: "Name", sortable: true }),
amount: f.currency({ label: "Amount", currency: "USD", sortable: true }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TableEmptyDemoInner() {
const handle = useDataList({
schema: demoAppSchema,
listCode: "demo-apps-empty",
data: DEMO_APPS,
getRowId: (r) => r.id,
features: { filters: true },
});
return (
<DataListProvider dataList={handle}>
<div className="space-y-3">
<DataListSearchInput dataList={handle} className="max-w-xs" placeholder="Search name…" />
<div className="rounded-xl border bg-card p-4 shadow-sm">
{/* Table owns empty */}
<Table dataList={handle} columns={{ amount: { align: "end" } }} />
</div>
</div>
</DataListProvider>
);
}
/** Search for a term that matches nothing to see the no-results empty state and Clear filters. */
export function TableEmptyDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableEmptyDemoInner />
</QueryClientProvider>
);
}Query List Page
Assembled filter + toolbar + table/list + batch: use Query List (@f-ui-plus/query-list). Default body is this Table; view="list" paints the same handle as rows. Minimal server-offset table without the full recipe: Server Offset Pagination.
Chrome Regions
Compose search, active-filter chips, selection bar, pagination, and the table body around one handle without the full query form. Smaller footprint when you only need live chrome filters.
| Billing Service | Ada | Active | 4,200.00 | |
| Auth Gateway | Lin | Active | 3,100.00 | |
| Image Pipeline | Mei | Paused | 760.00 |
"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { DataListShell } from "@/components/f-ui/data-list-chrome/layout/data-list-shell";
import { DataListToolbar } from "@/components/f-ui/data-list-chrome/data-list-toolbar";
import { DataListSearchInput } from "@/components/f-ui/data-list-chrome/data-list-search-input";
import { DataListPagination } from "@/components/f-ui/data-list-chrome/data-list-pagination";
import { DataListSelectionBar } from "@/components/f-ui/data-list-chrome/data-list-selection-bar";
import { DataListColumnFilter } from "@/components/f-ui/data-list-chrome/data-list-column-filter";
import { DataListActiveFilters } from "@/components/f-ui/data-list-chrome/data-list-active-filters";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoApp {
id: string;
name: string;
owner: string;
status: "active" | "paused" | "archived";
amount: number;
}
const DEMO_APPS: DemoApp[] = [
{ id: "1", name: "Billing Service", owner: "Ada", status: "active", amount: 4200 },
{ id: "2", name: "Auth Gateway", owner: "Lin", status: "active", amount: 3100 },
{ id: "3", name: "Image Pipeline", owner: "Mei", status: "paused", amount: 760 },
{ id: "4", name: "Search Index", owner: "Ravi", status: "active", amount: 9900 },
{ id: "5", name: "Legacy Export", owner: "Sam", status: "archived", amount: 40 },
];
const demoAppSchema = defineDataListSchema<DemoApp>({
name: f.text({ label: "Name", sortable: true }),
owner: f.text({ label: "Owner" }),
status: f.enum({ render: "status", label: "Status", variants: {
active: { label: "Active" },
paused: { label: "Paused" },
archived: { label: "Archived" },
} }),
amount: f.currency({ label: "Amount", currency: "USD", sortable: true }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TableChromeDemoInner() {
const handle = useDataList({
schema: demoAppSchema,
listCode: "demo-apps-chrome",
data: DEMO_APPS,
getRowId: (r) => r.id,
defaultPageSize: 3,
features: { filters: true, selection: true, pagination: { mode: "offset" } },
});
return (
<DataListProvider dataList={handle}>
<DataListShell
className="h-[min(520px,70vh)] min-h-[360px] gap-0 overflow-hidden rounded-xl border bg-card p-4 shadow-sm"
toolbar={
<DataListToolbar
title="Applications"
toolsStart={<DataListSearchInput className="sm:w-56" />}
/>
}
footer={<DataListPagination className="border-border border-t pt-2" />}
>
<DataListActiveFilters className="mb-2 shrink-0" />
<DataListSelectionBar variant="embedded" className="mb-2 rounded-none border-0" />
<Table
dataList={handle}
fillHeight
columns={{ amount: { align: "end" } }}
renderColumnFilter={({ field, dataList }) => (
<DataListColumnFilter field={field} dataList={dataList} />
)}
/>
</DataListShell>
</DataListProvider>
);
}
/** Compose chrome regions around one handle — search, chips, selection bar, pagination, and the table body. */
export function TableChromeDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableChromeDemoInner />
</QueryClientProvider>
);
}Region Table Toolbar
When a card / section owns a table (embedded collection, Hub child section, Related List, Documents card, …), put the section title and table tools on one horizontal row — title left; ghost Refresh, Columns, and optional Add on the right. Prefer sharing one useTable view so DataListColumnManager drives the same TableView. This is region chrome, not a Related-List-only rule. Playbook: CRUD Page Patterns — Region Table Toolbar. Dogfood: /showcases/orders-detail-related.
Documents
Actions | |||
|---|---|---|---|
| Getting Started with ESP-IDF | Ready | 2026-07-28 10:00 | |
| ESP32-C3 Technical Reference | Ready | 2026-07-27 14:20 | |
| Wi-Fi API Overview | Pending | 2026-07-26 09:15 |
"use client";
import { useMemo, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { PlusIcon, Trash2Icon } from "lucide-react";
import { toast } from "sonner";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import type { RowAction } from "@/components/f-ui/data-list-internals/row-actions/row-action-types";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { f } from "@/components/f-ui/field-types/catalog";
import { DataListColumnManager } from "@/components/f-ui/table/table-parts/column-manager";
import { TableView } from "@/components/f-ui/table/table-parts/table-view";
import { useTable } from "@/components/f-ui/table/use-table";
import { useTableI18n } from "@/components/f-ui/table/hooks/use-table-i18n";
import { Button } from "@/components/ui/button";
import { SectionCard } from "@/components/f-ui/section-card/section-card";
import { RegionRefreshButton } from "@/features/showcases/lib/detail-showcase-shared";
/**
* Canonical region table chrome for docs (any section that owns a table):
* title left + ghost Refresh + Columns + Add on ONE horizontal row.
* Contrast the playbook anti-pattern “Floating tool stack”.
*/
type DocRow = {
id: string;
title: string;
status: "ready" | "pending";
updatedAt: string;
};
const SEED: DocRow[] = [
{
id: "1",
title: "Getting Started with ESP-IDF",
status: "ready",
updatedAt: "2026-07-28T10:00:00Z",
},
{
id: "2",
title: "ESP32-C3 Technical Reference",
status: "ready",
updatedAt: "2026-07-27T14:20:00Z",
},
{
id: "3",
title: "Wi-Fi API Overview",
status: "pending",
updatedAt: "2026-07-26T09:15:00Z",
},
];
const schema = defineDataListSchema<DocRow>({
title: f.text({ label: "Title" }),
status: f.enum({
render: "status",
label: "Status",
variants: {
ready: { label: "Ready", tone: "success" },
pending: { label: "Pending", tone: "warning" },
},
}),
updatedAt: f.datetime({ label: "Updated" }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TableRegionToolbarDemoInner() {
const [rows, setRows] = useState(SEED);
const { t } = useTableI18n();
const handle = useDataList({
schema,
listCode: "demo-region-docs",
data: rows,
getRowId: (r) => r.id,
defaultPageSize: "all",
});
const rowActions = useMemo<RowAction<DocRow>[]>(
() => [
{
id: "delete",
label: "Delete",
icon: <Trash2Icon className="size-3.5" />,
variant: "destructive",
placement: "trailing",
onClick: (row) => {
setRows((prev) => prev.filter((r) => r.id !== row.id));
toast.success("Deleted");
},
},
],
[],
);
const view = useTable(handle, {
rowActions,
rowActionsPresentation: "icon",
maxInlineRowActions: 1,
});
return (
<DataListProvider dataList={handle}>
<SectionCard
title="Documents"
tools={
<>
<RegionRefreshButton
onRefresh={() => {
void handle.data.refetch();
toast.success("Refreshed");
}}
/>
<DataListColumnManager manager={view.columnSettings.manager} t={t} />
</>
}
actions={
<Button
type="button"
onClick={() => {
setRows((prev) => [
{
id: `d-${crypto.randomUUID()}`,
title: "New document",
status: "pending",
updatedAt: new Date().toISOString(),
},
...prev,
]);
toast.success("Added");
}}
>
<PlusIcon className="size-4 shrink-0" />
Add
</Button>
}
>
<TableView
table={view.table}
density={view.density}
sort={handle.sort}
onSortColumn={handle.onSortColumn}
selection={handle.selection}
getRowId={handle.getRowId}
dataList={handle}
/>
</SectionCard>
</DataListProvider>
);
}
/** Region table chrome — one-row title + Refresh + Columns + Add (not a floating stack). */
export function TableRegionToolbarDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableRegionToolbarDemoInner />
</QueryClientProvider>
);
}Anti-Pattern: Floating Tool Stack
Do not stack Refresh / Columns / Add vertically in the top-right corner above the table with a dead whitespace band and no shared baseline with the section title. Do not style Refresh as a solid page primary. Reject that layout in review wherever a section owns a table.
Infinite Scroll
Two recipes share pagination.mode: "cursor" (cursor append). They are not the same chrome.
Scroll (default for height-constrained tables). fillHeight or maxHeight turns on TableView virtualization (TanStack virtualized infinite). A sentinel inside [data-slot=table-scroll-y] fetches the next page. Do not also mount DataListLoadMore. QueryList table view with pagination.mode: "cursor" uses this path because fillHeight defaults on. List-view scroll infinite: Query List — Infinite Scroll.
"use client";
import { useMemo } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DataListAdapter } from "@/components/f-ui/data-list-internals/adapters/data-list-adapter";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoApp {
id: string;
name: string;
amount: number;
}
const DEMO_APPS: DemoApp[] = Array.from({ length: 120 }, (_, i) => ({
id: String(i + 1),
name: `Service ${i + 1}`,
amount: ((i * 137) % 9900) + 40,
}));
const demoAppSchema = defineDataListSchema<DemoApp>({
name: f.text({ label: "Name", sortable: true }),
amount: f.currency({ label: "Amount", currency: "USD", measure: "none" }),
});
const PAGE_SIZE = 20;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function createCursorAdapter(items: readonly DemoApp[]): DataListAdapter<DemoApp> {
const adapter: DataListAdapter<DemoApp> = async (req) => {
const offset = req.cursor ? Number.parseInt(req.cursor, 10) : 0;
const size = typeof req.pageSize === "number" ? req.pageSize : PAGE_SIZE;
const slice = items.slice(offset, offset + size);
const nextOffset = offset + size;
const hasNext = nextOffset < items.length;
return {
items: slice,
totalItems: items.length,
nextCursor: hasNext ? String(nextOffset) : null,
hasNext,
};
};
adapter.versionKey = ["demo-apps-infinite-scroll", String(items.length)];
return adapter;
}
function TableInfiniteScrollDemoInner() {
const adapter = useMemo(() => createCursorAdapter(DEMO_APPS), []);
const handle = useDataList({
schema: demoAppSchema,
listCode: "demo-apps-infinite-scroll",
adapter,
getRowId: (r) => r.id,
defaultPageSize: PAGE_SIZE,
features: { pagination: { mode: "cursor" } },
});
return (
<DataListProvider dataList={handle}>
<div className="rounded-xl border bg-card p-4 shadow-sm">
<Table
dataList={handle}
maxHeight={320}
columns={{ amount: { align: "end" } }}
/>
</div>
</DataListProvider>
);
}
/** Cursor infinite + height-capped Table (auto virtual + in-body sentinel). */
export function TableInfiniteScrollDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableInfiniteScrollDemoInner />
</QueryClientProvider>
);
}Button Load more. No height cap — the table grows in document flow. Place DataListLoadMore under the table. Do not put DataListLoadMoreSentinel outside the table scrollport (it would observe the wrong root).
| Loading… | |
"use client";
import { useMemo } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DataListAdapter } from "@/components/f-ui/data-list-internals/adapters/data-list-adapter";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { DataListLoadMore } from "@/components/f-ui/data-list-chrome/data-list-load-more";
import { DataListProvider } from "@/components/f-ui/data-list-chrome/layout/data-list-provider";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoApp {
id: string;
name: string;
amount: number;
}
const DEMO_APPS: DemoApp[] = [
{ id: "1", name: "Billing Service", amount: 4200 },
{ id: "2", name: "Auth Gateway", amount: 3100 },
{ id: "3", name: "Image Pipeline", amount: 760 },
{ id: "4", name: "Search Index", amount: 9900 },
{ id: "5", name: "Legacy Export", amount: 40 },
];
const demoAppSchema = defineDataListSchema<DemoApp>({
name: f.text({ label: "Name", sortable: true }),
amount: f.currency({ label: "Amount", currency: "USD", sortable: true }),
});
const PAGE_SIZE = 2;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function createCursorAdapter(items: readonly DemoApp[]): DataListAdapter<DemoApp> {
const adapter: DataListAdapter<DemoApp> = async (req) => {
const offset = req.cursor ? Number.parseInt(req.cursor, 10) : 0;
const size = typeof req.pageSize === "number" ? req.pageSize : PAGE_SIZE;
const slice = items.slice(offset, offset + size);
const nextOffset = offset + size;
const hasNext = nextOffset < items.length;
return {
items: slice,
totalItems: items.length,
nextCursor: hasNext ? String(nextOffset) : null,
hasNext,
};
};
adapter.versionKey = ["demo-apps-infinite", String(items.length)];
return adapter;
}
function TableInfiniteDemoInner() {
const adapter = useMemo(() => createCursorAdapter(DEMO_APPS), []);
const handle = useDataList({
schema: demoAppSchema,
listCode: "demo-apps-infinite",
adapter,
getRowId: (r) => r.id,
defaultPageSize: PAGE_SIZE,
features: { pagination: { mode: "cursor" } },
});
return (
<DataListProvider dataList={handle}>
<div className="space-y-3 rounded-xl border bg-card p-4 shadow-sm">
<Table dataList={handle} columns={{ amount: { align: "end" } }} />
<DataListLoadMore dataList={handle} />
</div>
</DataListProvider>
);
}
/** Cursor pagination with a manual Load more button (`paginationMode="cursor"`). */
export function TableInfiniteDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableInfiniteDemoInner />
</QueryClientProvider>
);
}Headless Usage
useTable(handle, options) is the Table view-model: it builds the TanStack table instance, column defs from your field schema, and the column-manager / density state — with no UI lock-in. Render the returned table through any markup. The demo below drives a plain HTML <table>; production code composes the styled TableView (what <Table> does internally).
| Name | Amount |
|---|---|
| Auth Gateway | 3,100.00 |
| Billing Service | 4,200.00 |
| Image Pipeline | 760.00 |
"use client";
import { flexRender } from "@tanstack/react-table";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { useTable } from "@/components/f-ui/table/use-table";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoApp {
id: string;
name: string;
amount: number;
}
const DEMO_APPS: DemoApp[] = [
{ id: "1", name: "Billing Service", amount: 4200 },
{ id: "2", name: "Auth Gateway", amount: 3100 },
{ id: "3", name: "Image Pipeline", amount: 760 },
];
const demoAppSchema = defineDataListSchema<DemoApp>({
name: f.text({ label: "Name", sortable: true }),
amount: f.currency({ label: "Amount", currency: "USD", sortable: true }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TableHeadlessDemoInner() {
const handle = useDataList({
schema: demoAppSchema,
listCode: "demo-apps-headless",
data: DEMO_APPS,
getRowId: (r) => r.id,
defaultSort: [{ field: "name", order: "asc" }],
});
const { table } = useTable(handle, {
columns: { amount: { align: "end" } },
});
return (
<table className="w-full border-collapse text-sm">
<thead>
{table.getHeaderGroups().map((group) => (
<tr key={group.id} className="border-b">
{group.headers.map((header) => (
<th key={header.id} className="px-3 py-2 text-left font-medium">
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</th>
))}
</tr>
))}
</thead>
<tbody>
{table.getRowModel().rows.map((row) => (
<tr key={row.id} className="border-b last:border-0">
{row.getVisibleCells().map((cell) => (
<td key={cell.id} className="px-3 py-2">
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</td>
))}
</tr>
))}
</tbody>
</table>
);
}
/** `useTable` returns a TanStack instance — render with your own markup or compose `TableView`. */
export function TableHeadlessDemo() {
return (
<QueryClientProvider client={queryClient}>
<div className="rounded-xl border bg-card p-4 shadow-sm">
<TableHeadlessDemoInner />
</div>
</QueryClientProvider>
);
}Edge Cases And Errors
Standalone <Table> owns Empty (DataListEmpty) and adapter Error (DataListErrorState + Retry via refetch). With fillHeight, both sit in the remaining body and center; without it they use a min-h-48 floor. On QueryList pages the shell keeps Empty/Error so Query Filter stays usable — do not double-wrap Empty around a mounted Table. Prefer Table / the data-list family for list surfaces.
Adapter Error And Retry
When the adapter rejects, the table replaces its body with DataListErrorState, which shows the message and a Retry button wired to refetch. Turn off Simulate error, then click Retry to recover. Adapter failure replaces the body; item-level failures keep the populated table (see below).
| Loading… | ||||
"use client";
import { useMemo, useRef, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import type { DataListAdapter } from "@/components/f-ui/data-list-internals/adapters/data-list-adapter";
import { createInMemoryListAdapter } from "@/components/f-ui/data-list-internals/adapters/in-memory-list-adapter";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoApp {
id: string;
name: string;
owner: string;
status: "active" | "paused" | "archived";
calls: number;
amount: number;
createdAt: string;
}
const DEMO_APPS: DemoApp[] = [
{ id: "1", name: "Billing Service", owner: "Ada", status: "active", calls: 1280, amount: 4200, createdAt: "2026-01-04" },
{ id: "2", name: "Auth Gateway", owner: "Lin", status: "active", calls: 980, amount: 3100, createdAt: "2026-02-11" },
];
const demoAppSchema = defineDataListSchema<DemoApp>({
name: f.text({ label: "Name", sortable: true }),
owner: f.text({ label: "Owner" }),
status: f.enum({ render: "status", label: "Status", variants: {
active: { label: "Active" },
paused: { label: "Paused" },
archived: { label: "Archived" },
} }),
calls: f.number({ label: "Calls" }),
amount: f.currency({ label: "Amount", currency: "USD" }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TableErrorDemoInner() {
const [fail, setFail] = useState(true);
const failRef = useRef(fail);
failRef.current = fail;
const adapter = useMemo<DataListAdapter<DemoApp>>(() => {
const inner = createInMemoryListAdapter<DemoApp>({ items: DEMO_APPS });
return async (req) => {
if (failRef.current) {
throw new Error("Simulated list failure — turn off the switch, then Retry.");
}
return inner(req);
};
}, []);
const handle = useDataList({
schema: demoAppSchema,
listCode: "demo-apps-error",
adapter,
getRowId: (r) => r.id,
});
return (
<div className="space-y-3">
<div className="bg-card border-border flex items-center gap-2.5 rounded-lg border px-3 py-2">
<Switch
id="table-error-demo-fail"
size="sm"
checked={fail}
onCheckedChange={(checked) => {
failRef.current = checked;
setFail(checked);
if (checked) void handle.data.refetch();
}}
aria-label="Simulate adapter error"
/>
<Label htmlFor="table-error-demo-fail" className="text-sm font-normal">
Simulate error
</Label>
</div>
<div className="rounded-xl border bg-card p-4 shadow-sm">
<Table dataList={handle} columns={{ amount: { align: "end" } }} />
</div>
</div>
);
}
/** Adapter failure replaces the table body with `DataListErrorState` and a Retry action. */
export function TableErrorDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableErrorDemoInner />
</QueryClientProvider>
);
}Anti-Pattern
Do not render a full Alert / Result under every failed row when many items fail. Use a StatusTag plus truncated reason (and an optional DataListFailureSummary when failures are systemic), and a Dialog for bulk action results.
Failed Rows (Compact)
A populated list can still include domain failed rows. Keep row height stable with StatusTag + DataListTruncatedText (hover for the full Tooltip). When failures look systemic (this demo uses ≥3), show DataListFailureSummary above the table with a recovery action.
24 of 26 documents failed
Processing backend disconnected. Rows below stay usable.
Actions | |||
|---|---|---|---|
| esp32-doc-01_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-02_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-03_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-04_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-05_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-06_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-07_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-08_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-09_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-10_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-11_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-12_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-13_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-14_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-15_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-16_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-17_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-18_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-19_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-20_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. |
"use client";
import { useMemo, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RefreshCwIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { DataListFailureSummary } from "@/components/f-ui/data-list-chrome/data-list-failure-summary";
import { DataListTruncatedText } from "@/components/f-ui/data-list-chrome/data-list-truncated-text";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import type { RowAction } from "@/components/f-ui/data-list-internals/row-actions/row-action-types";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
const LONG_ERROR =
"MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response.";
const FAILURE_SUMMARY_THRESHOLD = 3;
type DocStatus = "ready" | "processing" | "failed";
interface DemoDoc {
id: string;
name: string;
status: DocStatus;
errorMessage: string;
}
function buildDocs(): DemoDoc[] {
const failed: DemoDoc[] = Array.from({ length: 24 }, (_, i) => ({
id: `f-${i}`,
name: `esp32-doc-${String(i + 1).padStart(2, "0")}_technical_reference_manual_en.pdf`,
status: "failed" as const,
errorMessage: LONG_ERROR,
}));
return [
...failed,
{
id: "ok-1",
name: "guide_getting_started.pdf",
status: "ready",
errorMessage: "",
},
{
id: "p-1",
name: "architecture_overview.pdf",
status: "processing",
errorMessage: "",
},
];
}
const schema = defineDataListSchema<DemoDoc>({
name: f.text({ label: "Name", sortable: true }),
status: f.enum({
render: "status",
label: "Status",
variants: {
ready: { label: "Ready", tone: "success" },
processing: { label: "Processing", tone: "warning" },
failed: { label: "Failed", tone: "destructive" },
},
}),
errorMessage: f.custom({
label: "Reason",
render: (value) => (
<DataListTruncatedText>
{typeof value === "string" ? value : ""}
</DataListTruncatedText>
),
}),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TableFailedRowsDemoInner() {
const [docs, setDocs] = useState(buildDocs);
const [statusFilter, setStatusFilter] = useState<DocStatus | "all">("all");
const visible = useMemo(
() =>
statusFilter === "all"
? docs
: docs.filter((d) => d.status === statusFilter),
[docs, statusFilter],
);
const failedCount = docs.filter((d) => d.status === "failed").length;
const showSummary = failedCount >= FAILURE_SUMMARY_THRESHOLD;
const handle = useDataList({
schema,
listCode: "demo-docs-failed-rows",
data: visible,
getRowId: (r) => r.id,
});
const rowActions: RowAction<DemoDoc>[] = [
{
id: "retry",
label: "Retry",
icon: <RefreshCwIcon className="size-3.5" />,
hidden: (row) => row.status !== "failed",
onClick: (row) => {
setDocs((prev) =>
prev.map((d) =>
d.id === row.id
? { ...d, status: "ready", errorMessage: "" }
: d,
),
);
},
},
];
return (
<div className="space-y-3">
{showSummary ? (
<DataListFailureSummary
title={`${failedCount} of ${docs.length} documents failed`}
description="Processing backend disconnected. Rows below stay usable."
actions={
<>
<Button
type="button"
variant="outline"
onClick={() => setStatusFilter("failed")}
>
Filter failed
</Button>
<Button
type="button"
variant="outline"
onClick={() =>
setDocs((prev) =>
prev.map((d) =>
d.status === "failed"
? { ...d, status: "ready", errorMessage: "" }
: d,
),
)
}
>
Retry failed
</Button>
</>
}
/>
) : null}
{statusFilter !== "all" ? (
<Button
type="button"
variant="ghost"
onClick={() => setStatusFilter("all")}
>
Clear status filter
</Button>
) : null}
<div className="rounded-xl border bg-card p-4 shadow-sm">
<Table
dataList={handle}
rowActions={rowActions}
rowActionsPresentation="icon"
columns={{
name: { size: 280 },
status: { size: 120 },
errorMessage: { size: 320, grow: true },
}}
/>
</div>
</div>
);
}
/** Populated list with many failed rows: compact StatusTag + truncated reason + summary banner. */
export function TableFailedRowsDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableFailedRowsDemoInner />
</QueryClientProvider>
);
}Bulk Action Partial Success
Sync / Retry (and similar batch) results belong in a Dialog Message View — counts first, then the failed items — not as full-row Alerts dumped into the table. Run Retry failed in the demo to open the result dialog.
12 of 16 documents failed
Processing backend disconnected. Rows below stay usable.
Actions | |||
|---|---|---|---|
| esp32-doc-01_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-02_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-03_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-04_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-05_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-06_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-07_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-08_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-09_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-10_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-11_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| esp32-doc-12_technical_reference_manual_en.pdf | Failed | MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response. | |
| guide_getting_started.pdf | Ready | — | |
| guide_api_overview.pdf | Ready | — | |
| guide_deploy.pdf | Ready | — | |
| guide_security.pdf | Ready | — |
"use client";
import { useMemo, useRef, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { RefreshCwIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { DataListFailureSummary } from "@/components/f-ui/data-list-chrome/data-list-failure-summary";
import { DataListTruncatedText } from "@/components/f-ui/data-list-chrome/data-list-truncated-text";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import type { RowAction } from "@/components/f-ui/data-list-internals/row-actions/row-action-types";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
const LONG_ERROR =
"MinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response.";
const SHORT_REASON = "Server disconnected without sending a response";
const FAILURE_SUMMARY_THRESHOLD = 3;
const DIALOG_FAILED_LIST_LIMIT = 20;
const RETRY_DELAY_MS = 600;
type DocStatus = "ready" | "processing" | "failed";
interface DemoDoc {
id: string;
name: string;
status: DocStatus;
errorMessage: string;
}
interface BulkRetryResult {
succeeded: number;
failed: DemoDoc[];
}
function buildDocs(): DemoDoc[] {
const failed: DemoDoc[] = Array.from({ length: 12 }, (_, i) => ({
id: `f-${i}`,
name: `esp32-doc-${String(i + 1).padStart(2, "0")}_technical_reference_manual_en.pdf`,
status: "failed" as const,
errorMessage: LONG_ERROR,
}));
const ready: DemoDoc[] = Array.from({ length: 4 }, (_, i) => ({
id: `ok-${i}`,
name: `guide_${["getting_started", "api_overview", "deploy", "security"][i]}.pdf`,
status: "ready" as const,
errorMessage: "",
}));
return [...failed, ...ready];
}
function delay(ms: number) {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms);
});
}
const schema = defineDataListSchema<DemoDoc>({
name: f.text({ label: "Name", sortable: true }),
status: f.enum({
render: "status",
label: "Status",
variants: {
ready: { label: "Ready", tone: "success" },
processing: { label: "Processing", tone: "info" },
failed: { label: "Failed", tone: "destructive" },
},
}),
errorMessage: f.custom({
label: "Reason",
render: (value) => (
<DataListTruncatedText>
{typeof value === "string" ? value : ""}
</DataListTruncatedText>
),
}),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function TableBulkPartialDemoInner() {
const [docs, setDocs] = useState(buildDocs);
const [statusFilter, setStatusFilter] = useState<DocStatus | "all">("all");
const [retrying, setRetrying] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [lastResult, setLastResult] = useState<BulkRetryResult | null>(null);
const docsRef = useRef(docs);
docsRef.current = docs;
const visible = useMemo(
() =>
statusFilter === "all"
? docs
: docs.filter((d) => d.status === statusFilter),
[docs, statusFilter],
);
const failedCount = docs.filter((d) => d.status === "failed").length;
const showSummary = failedCount >= FAILURE_SUMMARY_THRESHOLD;
const handle = useDataList({
schema,
listCode: "demo-docs-bulk-partial",
data: visible,
getRowId: (r) => r.id,
});
const runBulkRetry = async (clearCount: number) => {
if (retrying) return;
const failedIds = docs
.filter((d) => d.status === "failed")
.map((d) => d.id);
if (failedIds.length === 0) return;
const toClearIds = failedIds.slice(0, Math.min(clearCount, failedIds.length));
const clearIds = new Set(toClearIds);
setRetrying(true);
await delay(RETRY_DELAY_MS);
const prev = docsRef.current;
let succeeded = 0;
const next = prev.map((d) => {
if (clearIds.has(d.id) && d.status === "failed") {
succeeded += 1;
return { ...d, status: "ready" as const, errorMessage: "" };
}
return d;
});
const remainingFailed = next.filter((d) => d.status === "failed");
setDocs(next);
setLastResult({ succeeded, failed: remainingFailed });
setDialogOpen(true);
setRetrying(false);
};
const rowActions: RowAction<DemoDoc>[] = [
{
id: "retry",
label: "Retry",
icon: <RefreshCwIcon className="size-3.5" />,
hidden: (row) => row.status !== "failed",
onClick: (row) => {
setDocs((prev) =>
prev.map((d) =>
d.id === row.id
? { ...d, status: "ready", errorMessage: "" }
: d,
),
);
},
},
];
const dialogFailed = lastResult?.failed ?? [];
const dialogFailedVisible = dialogFailed.slice(0, DIALOG_FAILED_LIST_LIMIT);
const dialogFailedOverflow = Math.max(
0,
dialogFailed.length - DIALOG_FAILED_LIST_LIMIT,
);
return (
<div className="space-y-3">
{showSummary ? (
<DataListFailureSummary
title={`${failedCount} of ${docs.length} documents failed`}
description="Processing backend disconnected. Rows below stay usable."
actions={
<>
<Button
type="button"
variant="outline"
onClick={() => setStatusFilter("failed")}
>
Filter failed
</Button>
<Button
type="button"
variant="outline"
disabled={retrying || failedCount === 0}
onClick={() => void runBulkRetry(4)}
>
{retrying ? "Retrying…" : "Retry failed"}
</Button>
</>
}
/>
) : null}
{statusFilter !== "all" ? (
<Button
type="button"
variant="ghost"
onClick={() => setStatusFilter("all")}
>
Clear status filter
</Button>
) : null}
<div className="rounded-xl border bg-card p-4 shadow-sm">
<Table
dataList={handle}
rowActions={rowActions}
rowActionsPresentation="icon"
columns={{
name: { size: 280 },
status: { size: 120 },
errorMessage: { size: 320, grow: true },
}}
/>
</div>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Retry finished</DialogTitle>
<DialogDescription>
{lastResult ? (
<span className="tabular-nums">
{lastResult.succeeded} succeeded · {lastResult.failed.length}{" "}
failed
</span>
) : null}
</DialogDescription>
</DialogHeader>
{dialogFailedVisible.length > 0 ? (
<ul className="max-h-48 space-y-2 overflow-y-auto text-sm">
{dialogFailedVisible.map((doc) => (
<li key={doc.id} className="min-w-0">
<p className="truncate font-medium">{doc.name}</p>
<p className="truncate text-muted-foreground">
{SHORT_REASON}
</p>
</li>
))}
{dialogFailedOverflow > 0 ? (
<li className="text-muted-foreground">
+{dialogFailedOverflow} more
</li>
) : null}
</ul>
) : (
<p className="text-sm text-muted-foreground">
All selected documents recovered
</p>
)}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setDialogOpen(false)}
>
Close
</Button>
<Button
type="button"
disabled={retrying || dialogFailed.length === 0}
onClick={() => void runBulkRetry(2)}
>
{retrying ? "Retrying…" : "Retry failed again"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
/** Bulk retry with partial success: Dialog Message View for counts + failed items. */
export function TableBulkPartialDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableBulkPartialDemoInner />
</QueryClientProvider>
);
}Skeleton Loading
First load defaults to column-aligned skeleton rows (loadingVariant default "skeleton"). Pass loadingVariant="spinner" for a centered spinner on small / unknown-shape surfaces. Refetches keep prior rows and use a subtle overlay (variant does not apply).
| Loading… | |
"use client";
import { useMemo } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DataListAdapter } from "@/components/f-ui/data-list-internals/adapters/data-list-adapter";
import { createInMemoryListAdapter } from "@/components/f-ui/data-list-internals/adapters/in-memory-list-adapter";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoApp {
id: string;
name: string;
amount: number;
}
const DEMO_APPS: DemoApp[] = [
{ id: "1", name: "Billing Service", amount: 4200 },
{ id: "2", name: "Auth Gateway", amount: 3100 },
{ id: "3", name: "Image Pipeline", amount: 760 },
];
const demoAppSchema = defineDataListSchema<DemoApp>({
name: f.text({ label: "Name", sortable: true }),
amount: f.currency({ label: "Amount", currency: "USD", sortable: true }),
});
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function createSlowAdapter(): DataListAdapter<DemoApp> {
const inner = createInMemoryListAdapter<DemoApp>({ items: DEMO_APPS });
const adapter: DataListAdapter<DemoApp> = async (req) => {
await new Promise((r) => setTimeout(r, 1200));
return inner(req);
};
adapter.versionKey = ["demo-apps-skeleton"];
return adapter;
}
function TableSkeletonDemoInner() {
const adapter = useMemo(() => createSlowAdapter(), []);
const handle = useDataList({
schema: demoAppSchema,
listCode: "demo-apps-skeleton",
adapter,
getRowId: (r) => r.id,
});
return (
<div className="rounded-xl border bg-card p-4 shadow-sm">
<Table
dataList={handle}
columns={{ amount: { align: "end" } }}
loadingVariant="skeleton"
/>
</div>
);
}
/** Column-aligned skeleton rows on first load (default); refetches still use the spinner overlay. */
export function TableSkeletonDemo() {
return (
<QueryClientProvider client={queryClient}>
<TableSkeletonDemoInner />
</QueryClientProvider>
);
}API Reference
Table
| Prop | Type | Notes |
|---|---|---|
dataList | DataListHandle<TData> | Controlled form. Mutually exclusive with schema. |
schema | DataListSchema<TData> | Uncontrolled form; internal useDataList. |
data | readonly TData[] | Uncontrolled static rows. |
adapter | DataListAdapter<TData> | Uncontrolled server adapter (XOR data). |
getRowId | (row, i) => string | Required for selection. |
columns | Partial<Record<keyof schema, DataListTableColumnOptions>> | Per-field layout — see below. |
filtering | boolean | "none" | Uncontrolled: enables filter fields. |
selection | boolean | Uncontrolled: checkbox column. |
paginationMode | "offset" | "cursor" | Uncontrolled pagination mode. |
renderColumnFilter | ({ field, dataList }) => ReactNode | Column-header filter slot. |
density / defaultDensity / onDensityChange | — | Row density. |
onRowClick | (row) => void | Row click handler. |
rowActions | RowAction<TData>[] | Right-pinned Actions column. |
rowActionsDisplay | "inline" | "menu" | Default "inline". |
maxInlineRowActions | number | Inline links before More overflow (default 3). |
expandable | TableExpandableConfig<TData> | Ant Table.expandable — expandedRowRender, optional controlled expandedRowKeys / onExpandedRowKeysChange, rowExpandable, defaultExpandedRowKeys, defaultExpandAllRows. Requires getRowId. Non-virtual only. Mutually exclusive with nested. |
nested | TableNestedConfig<TData, TChild> | Read-only master–detail child Table — columns, getSubRows, optional getSubRowId / expand-key controls / rowExpandable. Requires getRowId. Mutually exclusive with expandable. |
rowGrouping | TableRowGroupingConfig<TData> | Contiguous-run group bands — getGroupKey, optional renderBand / bandClassName / stickyBands (default true). Bands are not in data. Non-virtual only. See Row Grouping for sticky hand-off and performance. |
loadingVariant | "skeleton" | "spinner" | First-load body. Default "skeleton"; "spinner" is opt-in. |
fillHeight | boolean | Table container becomes the vertical scroll port (min-h-0 flex-1). QueryList table view defaults this on. |
maxHeight | number | string | Cap the body scrollport (px number or CSS length). Prefer this on standalone tables instead of a flex parent. |
virtual | { estimateRowHeight?: number; overscan?: number } | Overrides for auto or host virtual. Infinite + fillHeight / maxHeight auto-enables virtualization and the in-body sentinel. expandable / rowGrouping remain non-virtual. |
columns (DataListTableColumnOptions)
Layout only — formatting stays on the field schema (kind / renderCell).
| Option | Type | Notes |
|---|---|---|
size / minSize / maxSize | number | Width hints for resize. |
resizable | boolean | Default true. |
grow | boolean | Absorb leftover width. |
pinned | "left" | "right" | Freeze the column. |
align | "start" | "end" | Overrides kind default (number / currency end-align). |
headerClassName | string | Header cell class. |
cellClassName | string | ((row) => string | undefined) | Layer 4: class on the <td>. Function is evaluated per row; undefined clears host paint. Do not apply value-state ring classes. |
Content wrap lives on the schema: FieldConfig.renderCell?: (formatted, row) => ReactNode. See Field Types — renderCell.
QueryList
Assembled resource-index recipe — props and view="list" examples live on Query List.
Chrome regions (compose around the table)
| Component | Role |
|---|---|
DataListProvider | Publishes handle to context for nested regions. |
DataListShell | Layout: stats, toolbar, footer, fullscreen. |
DataListToolbar | Title, tabs slot, actions + tools slots, fullscreen button. |
DataListToolbarTabs | Quick-filter tabs (app-driven). |
DataListSearchInput | Live keyword search. |
DataListPagination | Page navigation + page size. |
DataListSelectionBar | Batch bar — summary + actions slots; use variant="embedded" in shell footers. |
DataListSelectAllMatchingToggle | "Select all N matching" CTA for the selection bar. |
DataListBatchActionButton | Batch action button with confirm for destructive ops — awaits onClick, closes on success (same policy as Confirm). |
DataListColumnFilter | Column-header filter popover (via table slot). |
DataListEmpty | Empty / no-results states (Table recipe mounts this when standalone; QueryList shell owns Empty for filter-usable pages). |
DataListErrorState | Error panel with message + retry overlay (table renders it on adapter failure). |
DataListFailureSummary | Host-owned list-top banner for systemic item failures (title / description / actions). |
DataListTruncatedText | Truncate long diagnostics with Tooltip for the full string; empty → em dash placeholder. |
DataListFooterToolbar | Ant Design Pro FooterToolbar — docked full-width batch bar at the content bottom for long pages. |
DataListLoadMore | Cursor "load more" button (reads handle.pagination.cursor). |
DataListLoadMoreSentinel | Owned by TableView for scroll infinite; do not duplicate in QueryList. |
Hook
useTable(handle, options) → the Table view-model.
Options (UseTableOptions): columns (per-field layout), linkComponent, columnSettings / onColumnSettingsSave (persisted show/hide/reorder/pin/resize), density / defaultDensity / onDensityChange, expandable (freeform detail), nested (read-only child Table).
Returns (UseTableReturn): table (TanStack instance), density / setDensity, and columnSettings ({ manager, applied, defaultSettings, columnLabels }) for wiring DataListColumnManager. When expandable or nested is set, also returns expand (isExpanded, toggle, expandAll, collapseAll, …) and renderRowDetail — spread the latter onto TableView. <Table> wires these automatically; use them directly for custom toolbars (Expand all / Collapse all).
useDataListQueryPage(options) (from @f-ui-plus/data-list-chrome) returns { handle, view, providerProps, tableViewProps, slots, selectedRows } for query-list pages — bind toolbar controls to view, spread tableViewProps onto TableView, and pass chrome slots at the call site.
See @f-ui-plus/data-list-internals for useDataList / DataListHandle.