f-ui
Components

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 controlled dataList={handle} when you compose chrome (recommended for full pages).
  • Wrap or paint a cell without replacing kindrenderCell for extra copy / badges / criticality, cellClassName for the <td>. See Cell Customization.

Compared to Ant Design Pro Query List

Ant Design Pro query listf-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 filtersrenderColumnFilterDataListColumnFilter
PaginationDataListPagination
Batch bar (selected count + summary + actions)DataListSelectionBar (summary + actions slots)
Empty stateStandalone <Table> owns DataListEmpty (no data vs no matches); QueryList keeps Empty at the shell so filters stay usable

Features

AreaBehavior
Field schemadefineDataListSchema — text, number, currency, status, link, date, …
Copyable cellsFieldConfig.copyable → hover Copy Affordance; suppressed when Access masked/pending
Cell customizationFour layers: accessorkind → schema renderCell (wrap formatted DOM) → columns.cellClassName on <td>. See Cell Customization
Column layoutcolumns prop — size, align, pinned, grow, resizable, cellClassName per field
SortHeader caret → handle.onSortColumn; multi-sort ordinal when enabled
SelectionId-based handle.selection; checkbox column bridges core selection
Column managerShow / hide / reorder / pin / resize (toolbar affordance)
Field accessFieldConfig.access + ambient AccessPolicy — see Access
DensityCompact / normal / comfortable row heights (DataListDensityToggleopt-in, not QueryList default)
Column-header filterPluggable renderColumnFilter slot; chrome supplies popover editors
Expandable rowsexpandable.expandedRowRender — Ant Table.expandable parity; chevron column + full-width detail <tr> (non-virtual)
Nested rowsnested — read-only child Table with independent columns via getSubRows (mutually exclusive with expandable)
Row groupingrowGrouping — contiguous-run group bands above leaf rows (getGroupKey + renderBand); sticky bands under header by default; non-virtual only
Empty / loading / errorRecipe owns Empty + Error; first-load default is skeleton (loadingVariant="spinner" opt-in)
Recipe formsControlled dataList={handle} or uncontrolled schema + data
Data loadingClient full load (createInMemoryListAdapter({ load })) or server adapter per page — see Data Loading
Chrome regionsIndependent install (@f-ui-plus/data-list-chrome) — compose, don't bundle

Interactions

EventBehavior
Click sortable headerToggles sort via onSortColumn (manual / server-side)
Row checkboxselection.toggle(id) — survives refetch when getRowId is stable
Header checkboxselectAllOnPage() / clear() for current page
renderColumnFilterOpens column filter popover; commits to filters.setField
DataListSearchInputDebounced keyword → filters.setSearch
DataListPaginationsetParams({ page, pageSize }) on the shared handle
Expand chevronToggles a full-width detail row via expandable.expandedRowRender or nested child table (requires getRowId)
Selection bar appearsWhen selection.count > 0; clear (×) calls selection.clear()
Query Filter SearchStaged 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 | DataListLoadMore

Stack 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 DataListProviderDataListShell (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:

UseComponent
Selection alert strip above the table (default)DataListSelectionBar (placed above the body)
Embedded in a list card footerDataListSelectionBar 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-chrome
FUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/table @f-ui-plus/data-list-view @f-ui-plus/data-list-chrome
FUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/table @f-ui-plus/data-list-view @f-ui-plus/data-list-chrome
FUI_PLUS_REGISTRY_TOKEN=xxx bunx shadcn@latest add @f-ui-plus/table @f-ui-plus/data-list-view @f-ui-plus/data-list-chrome

Runtime 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 Tabledata 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 dataRecipeDemo
Already in memory / no networkdata={rows}Uncontrolled Recipe
<~300, one API full loadcreateInMemoryListAdapter({ load })Client Full Load
Larger / will growCustom adapter (or createServerListAdapter); one page per requestServer Offset Pagination
Feed / infinite listpagination.mode: "cursor"Infinite Scroll (height-capped scroll is the unbounded path; button Load more when the table has no height cap)
Batch “all matching”selectAllMatchingSelect 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…
No rows
Rows per page
"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…
No rows
Rows per page
"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 GatewayLinActive9803,100.002026-02-11
Billing ServiceAdaActive1,2804,200.002026-01-04
Image PipelineMeiPaused210760.002026-03-02
Legacy ExportSamArchived1240.002025-11-22
Search IndexRaviActive5,4009,900.002026-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 ServiceAdaActive1,2804,200.002026-01-04
Auth GatewayLinActive9803,100.002026-02-11
Image PipelineMeiPaused210760.002026-03-02
Search IndexRaviActive5,4009,900.002026-03-19
Legacy ExportSamArchived1240.002025-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.

LayerAPIJob
1 GetaccessorWhich value
2 FormatkindMoney, date, status tag, empty dash
3 ContentFieldConfig.renderCell(formatted, row)Wrap the already-formatted node (badge, extra copy, CellCriticalityValue)
4 Paintcolumns.cellClassNameClass on the <td>. Function per row; undefined clears host paint
Host wantsUse
Money / date / enum chip looks rightLayer 2 — kind
Extra words, icon, badge, before→afterLayer 3 — renderCell
Semantic status colour + mandatory iconLayer 3 + CellCriticalityValue
Cell background / left bar, not a ringLayer 4 — cellClassName
Whole row attentiongetRowClassName
Fully custom value (no kind)f.custom({ render }) as layer 2, then optional renderCell wrap

Do

  • Pass formatted through — it is already money / date / Status Tag / empty placeholder.
  • Put host copy in renderCell (second line, muted suffix, reason slot).
  • Return undefined from a cellClassName function when the row should not paint.
  • Keep f.enum({ render: "status" }) and renderCell on the same field.

Don't

  • Replace kind with f.custom just to add a badge.
  • Name a column overlay render — enum already owns render: "status" \| "label".
  • Rebuild a value-state ring with cellClassName (border-destructive, kit ring classes).
  • Expect renderCell to wrap an editing control (Editable Table field.component).
  • Decorate Access-masked / pending / schema-mask values — the kernel skips the wrap.
  • Expect clipboard text to include the badge — copyable copies 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-0412Bulk
GSK-114
BSH-021
"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-04420.00
GSK-111,800.00
BSH-0218.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-04Positive420.00
GSK-11Critical1,800.00Over $1,000
BSH-02Positive18.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-04PO
GSK-11EstimatedStale
BSH-02PO
"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-0412BulkPositive420.00PO
GSK-114Critical1,800.00Estimated
BSH-021Positive18.00PO
"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 ServiceAdaActive1,2804,200.00
Auth GatewayLinActive9803,100.00
Image PipelineMeiPaused210760.00
Search IndexRaviActive5,4009,900.00
Legacy ExportSamArchived1240.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 ServiceAdaActive4,200.00
Auth GatewayLinActive3,100.00
Image PipelineMeiPaused760.00
Legacy ExportSamArchived40.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 ServiceAdaActive4,200.00
Auth GatewayLinActive3,100.00
Image PipelineMeiPaused760.00
Legacy ExportSamArchived40.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-1001Acme Corp4,280.00Pending
SO-1002Northwind Traders1,290.00Shipped
SO-1003Globex0.00Cancelled
"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-100Motor controller104
B-220Harness kit88
C-310Sensor pack50
"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
ConcernGuidance
DOM costSticky 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 costVertical 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 scrollingrowGrouping + view.mode: "virtual" is unsupported in v1 (bands ignored; dev error). Prefer a maxHeight scrollport when you need bands.
Very large leaf setsPrefer server pagination / filters so the scrollport stays bounded. Sticky bands are for visible workbench density, not unbounded dumps.
Opt outstickyBands: 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 Service4,200.00
Auth Gateway3,100.00
1–2 of 5
Rows per page
"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 ServiceAdaActive4,200.00
Auth GatewayLinActive3,100.00
Image PipelineMeiPaused760.00
Search IndexRaviActive9,900.00
Legacy ExportSamArchived40.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.

Orders
SO-1001AdaPending
SO-1002LinShipped
SO-1003MeiPending
1–3 of 4
Rows per page
"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 Service4,200.00
Auth Gateway3,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.

Applications
Billing ServiceAdaActive4,200.00
Auth GatewayLinActive3,100.00
Image PipelineMeiPaused760.00
1–3 of 5
Rows per page
"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-IDFReady2026-07-28 10:00
ESP32-C3 Technical ReferenceReady2026-07-27 14:20
Wi-Fi API OverviewPending2026-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.

End of list
"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…
End of list
"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).

NameAmount
Auth Gateway3,100.00
Billing Service4,200.00
Image Pipeline760.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.

Actions
esp32-doc-01_technical_reference_manual_en.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.

Actions
esp32-doc-01_technical_reference_manual_en.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU 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.pdfFailedMinerU local backend request failed (endpoint: http://192.168.2.188:28000): RemoteProtocolError: Server disconnected without sending a response.
guide_getting_started.pdfReady
guide_api_overview.pdfReady
guide_deploy.pdfReady
guide_security.pdfReady
"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

PropTypeNotes
dataListDataListHandle<TData>Controlled form. Mutually exclusive with schema.
schemaDataListSchema<TData>Uncontrolled form; internal useDataList.
datareadonly TData[]Uncontrolled static rows.
adapterDataListAdapter<TData>Uncontrolled server adapter (XOR data).
getRowId(row, i) => stringRequired for selection.
columnsPartial<Record<keyof schema, DataListTableColumnOptions>>Per-field layout — see below.
filteringboolean | "none"Uncontrolled: enables filter fields.
selectionbooleanUncontrolled: checkbox column.
paginationMode"offset" | "cursor"Uncontrolled pagination mode.
renderColumnFilter({ field, dataList }) => ReactNodeColumn-header filter slot.
density / defaultDensity / onDensityChangeRow density.
onRowClick(row) => voidRow click handler.
rowActionsRowAction<TData>[]Right-pinned Actions column.
rowActionsDisplay"inline" | "menu"Default "inline".
maxInlineRowActionsnumberInline links before More overflow (default 3).
expandableTableExpandableConfig<TData>Ant Table.expandableexpandedRowRender, optional controlled expandedRowKeys / onExpandedRowKeysChange, rowExpandable, defaultExpandedRowKeys, defaultExpandAllRows. Requires getRowId. Non-virtual only. Mutually exclusive with nested.
nestedTableNestedConfig<TData, TChild>Read-only master–detail child Table — columns, getSubRows, optional getSubRowId / expand-key controls / rowExpandable. Requires getRowId. Mutually exclusive with expandable.
rowGroupingTableRowGroupingConfig<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.
fillHeightbooleanTable container becomes the vertical scroll port (min-h-0 flex-1). QueryList table view defaults this on.
maxHeightnumber | stringCap 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).

OptionTypeNotes
size / minSize / maxSizenumberWidth hints for resize.
resizablebooleanDefault true.
growbooleanAbsorb leftover width.
pinned"left" | "right"Freeze the column.
align"start" | "end"Overrides kind default (number / currency end-align).
headerClassNamestringHeader cell class.
cellClassNamestring | ((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)

ComponentRole
DataListProviderPublishes handle to context for nested regions.
DataListShellLayout: stats, toolbar, footer, fullscreen.
DataListToolbarTitle, tabs slot, actions + tools slots, fullscreen button.
DataListToolbarTabsQuick-filter tabs (app-driven).
DataListSearchInputLive keyword search.
DataListPaginationPage navigation + page size.
DataListSelectionBarBatch bar — summary + actions slots; use variant="embedded" in shell footers.
DataListSelectAllMatchingToggle"Select all N matching" CTA for the selection bar.
DataListBatchActionButtonBatch action button with confirm for destructive ops — awaits onClick, closes on success (same policy as Confirm).
DataListColumnFilterColumn-header filter popover (via table slot).
DataListEmptyEmpty / no-results states (Table recipe mounts this when standalone; QueryList shell owns Empty for filter-usable pages).
DataListErrorStateError panel with message + retry overlay (table renders it on adapter failure).
DataListFailureSummaryHost-owned list-top banner for systemic item failures (title / description / actions).
DataListTruncatedTextTruncate long diagnostics with Tooltip for the full string; empty → em dash placeholder.
DataListFooterToolbarAnt Design Pro FooterToolbar — docked full-width batch bar at the content bottom for long pages.
DataListLoadMoreCursor "load more" button (reads handle.pagination.cursor).
DataListLoadMoreSentinelOwned 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.

On this page