Query List
Schema-driven resource-index recipe — Query Filter, toolbar, table or list rows, pagination, and batch footer on one handle.
Plus Registry
Query List ships on the authenticated @f-ui-plus registry. Configure FUI_PLUS_REGISTRY_TOKEN as described in Installation — Plus Registry.
Query List is the assembled resource-index recipe: Query Filter, toolbar, pagination, and optional batch footer around one DataListHandle. Default body is Table. Pass view="list" for the same chrome painted as stacked rows through ListView / presentational List. Job matrix: List Surfaces.
When To Use
- The page is the collection (orders, users, tickets) with filters, pagination, and optional selection / batch.
- Prefer
view="table"(default) for columnar compare / sort;view="list"for row-first title + description indexes. - Do not use QueryList inside an Object Page card — that is Related List.
- Do not put schema / selection / batch on presentational List — List is card / dialog rows only.
- Do not invent a separate ProList recipe. List layout is
view="list".
Features
| Area | Behavior |
|---|---|
| Body | view="table" → TableView; view="list" → ListView over the same handle |
| Chrome | Query Filter, toolbar, empty / error at the shell, pagination, optional batch footer |
| Selection | Opt-in recipe.selection — same for both views |
| List paint | rowLayout field keys or slot functions; renderItem(row, ctx, defaultItem) returns a List.Item |
| Column manager | Table view only |
| Density / fullscreen | Opt-in (showDensityToggle / showFullscreenToggle) — most business lists leave them off |
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/query-listFUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/query-listFUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/query-listFUI_PLUS_REGISTRY_TOKEN=xxx bunx shadcn@latest add @f-ui-plus/query-listInstall query-list once; the CLI resolves Plus dependencies (Formily, Query Filter, Table, List View, Data List Chrome, Page, and open peers).
Usage
import { QueryList } from "@/components/f-ui/query-list/query-list";
<QueryList
schema={schema}
listCode="orders"
adapter={adapter}
params={params}
onParamsChange={setParams}
getRowId={(row) => row.id}
/>Examples
Full Page (Ant Design Pro)
Ant Design Pro query list composition: Query Filter, toolbar with quick-filter tabs and actions, sortable selectable table with column manager, pagination, and a batch operation bar with selection summary. Density and fullscreen are opt-in. Three rows start selected — try searching, tabbing to Unshipped, and column-header filters. Minimal server-offset table recipe: Table — Server Offset Pagination.
| ORD-1001 | Acme Corp | Confirmed | Electronics | 2,499.00 | 5 | 2026-03-01 | |
| ORD-1002 | Globex Inc | Shipped | Apparel | 849.50 | 12 | 2026-03-03 | |
| ORD-1003 | Initech | Confirmed | Home | 120.00 | 2 | 2026-03-05 | |
| ORD-1004 | Umbrella Co | Cancelled | Electronics | 399.00 | 1 | 2026-03-06 | |
| ORD-1005 | Stark Industries | Shipped | Electronics | 9,800.00 | 3 | 2026-03-08 | |
| ORD-1006 | Wayne Enterprises | Confirmed | Apparel | 540.00 | 8 | 2026-03-10 | |
| ORD-1007 | Oscorp | Shipped | Home | 2,100.00 | 4 | 2026-03-11 | |
| ORD-1008 | Cyberdyne | Confirmed | Electronics | 15,000.00 | 20 | 2026-03-12 | |
| ORD-1009 | Soylent Corp | Cancelled | Home | 75.00 | 6 | 2026-03-13 | |
| ORD-1010 | Hooli | Shipped | Apparel | 430.00 | 7 | 2026-03-14 |
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { DownloadIcon, PlusIcon, UploadIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { isListInteractionLocked } from "@/components/f-ui/data-list-internals/lib/resolve-list-loading";
import { useDataListQueryPage } from "@/components/f-ui/data-list-chrome/hooks/use-data-list-query-page";
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 { DataListToolbarTabs } from "@/components/f-ui/data-list-chrome/data-list-toolbar-tabs";
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 { DataListEmpty } from "@/components/f-ui/data-list-chrome/data-list-empty";
import { DataListErrorState } from "@/components/f-ui/data-list-chrome/data-list-error-state";
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 { DataListColumnManager } from "@/components/f-ui/table/table-parts/column-manager";
import { DataListDensityToggle } from "@/components/f-ui/table/table-parts/density-toggle";
import { TableView } from "@/components/f-ui/table/table-parts/table-view";
import { useDataListViewI18n } from "@/components/f-ui/data-list-view/hooks/use-data-list-view-i18n";
import { useTableI18n } from "@/components/f-ui/table/hooks/use-table-i18n";
import { QueryFilter } from "@/components/f-ui/query-filter/query-filter";
import { FormField } from "@/components/f-ui/formily/form-field";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoOrder {
id: string;
orderNumber: string;
customer: string;
status: "confirmed" | "shipped" | "cancelled";
category: "electronics" | "apparel" | "home";
amount: number;
quantity: number;
orderDate: string;
shipped: boolean;
}
const STATUS_VARIANTS = {
confirmed: { label: "Confirmed", tone: "success" as const },
shipped: { label: "Shipped", tone: "info" as const },
cancelled: { label: "Cancelled", tone: "destructive" as const },
};
const CATEGORY_VARIANTS = {
electronics: { label: "Electronics" },
apparel: { label: "Apparel" },
home: { label: "Home" },
};
const SEED_ORDERS: DemoOrder[] = [
{ id: "1", orderNumber: "ORD-1001", customer: "Acme Corp", status: "confirmed", category: "electronics", amount: 2499, quantity: 5, orderDate: "2026-03-01", shipped: false },
{ id: "2", orderNumber: "ORD-1002", customer: "Globex Inc", status: "shipped", category: "apparel", amount: 849.5, quantity: 12, orderDate: "2026-03-03", shipped: true },
{ id: "3", orderNumber: "ORD-1003", customer: "Initech", status: "confirmed", category: "home", amount: 120, quantity: 2, orderDate: "2026-03-05", shipped: false },
{ id: "4", orderNumber: "ORD-1004", customer: "Umbrella Co", status: "cancelled", category: "electronics", amount: 399, quantity: 1, orderDate: "2026-03-06", shipped: false },
{ id: "5", orderNumber: "ORD-1005", customer: "Stark Industries", status: "shipped", category: "electronics", amount: 9800, quantity: 3, orderDate: "2026-03-08", shipped: true },
{ id: "6", orderNumber: "ORD-1006", customer: "Wayne Enterprises", status: "confirmed", category: "apparel", amount: 540, quantity: 8, orderDate: "2026-03-10", shipped: false },
{ id: "7", orderNumber: "ORD-1007", customer: "Oscorp", status: "shipped", category: "home", amount: 2100, quantity: 4, orderDate: "2026-03-11", shipped: true },
{ id: "8", orderNumber: "ORD-1008", customer: "Cyberdyne", status: "confirmed", category: "electronics", amount: 15000, quantity: 20, orderDate: "2026-03-12", shipped: false },
{ id: "9", orderNumber: "ORD-1009", customer: "Soylent Corp", status: "cancelled", category: "home", amount: 75, quantity: 6, orderDate: "2026-03-13", shipped: false },
{ id: "10", orderNumber: "ORD-1010", customer: "Hooli", status: "shipped", category: "apparel", amount: 430, quantity: 7, orderDate: "2026-03-14", shipped: true },
{ id: "11", orderNumber: "ORD-1011", customer: "Vehement Capital", status: "confirmed", category: "home", amount: 890, quantity: 2, orderDate: "2026-03-15", shipped: false },
{ id: "12", orderNumber: "ORD-1012", customer: "Pied Piper", status: "confirmed", category: "electronics", amount: 199, quantity: 1, orderDate: "2026-03-16", shipped: false },
];
const orderSchema = defineDataListSchema<DemoOrder>({
orderNumber: f.link({ label: "Order #", sortable: true, href: (row) => `#${row.id}` }),
customer: f.text({ label: "Customer", sortable: true }),
status: f.enum({ render: "status", label: "Status", sortable: true, variants: STATUS_VARIANTS }),
category: f.enum({ render: "label", label: "Category", sortable: true, variants: CATEGORY_VARIANTS }),
amount: f.currency({ label: "Amount", currency: "USD", sortable: true }),
quantity: f.number({ label: "Qty", sortable: true }),
orderDate: f.date({ label: "Order date", sortable: true }),
});
function filterOrders(
orders: readonly DemoOrder[],
query: Record<string, unknown> | null,
tab: "all" | "unshipped",
): DemoOrder[] {
let rows = [...orders];
if (tab === "unshipped") rows = rows.filter((r) => !r.shipped);
if (!query) return rows;
const orderNumber = String(query.orderNumber ?? "").trim();
if (orderNumber) {
const lower = orderNumber.toLowerCase();
rows = rows.filter((r) => r.orderNumber.toLowerCase().includes(lower));
}
const status = query.status;
if (status !== undefined && status !== null && status !== "") {
rows = rows.filter((r) => r.status === status);
}
const customer = String(query.customer ?? "").trim();
if (customer) {
const lower = customer.toLowerCase();
rows = rows.filter((r) => r.customer.toLowerCase().includes(lower));
}
return rows;
}
const STATUS_OPTIONS = Object.entries(STATUS_VARIANTS).map(([value, v]) => ({
label: v.label,
value,
}));
const TOOLBAR_TABS = [
{ id: "all", label: "All orders" },
{ id: "unshipped", label: "Unshipped" },
] as const;
const TABLE_OPTIONS = {
columns: {
orderNumber: { grow: true, size: 120 },
amount: { align: "end", size: 110 },
quantity: { align: "end", size: 80 },
orderDate: { size: 110 },
},
} as const;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function QueryListDemoInner() {
const [tab, setTab] = useState<(typeof TOOLBAR_TABS)[number]["id"]>("all");
const [lastQuery, setLastQuery] = useState<Record<string, unknown> | null>(null);
const [queryLoading, setQueryLoading] = useState(false);
const filteredData = useMemo(
() => filterOrders(SEED_ORDERS, lastQuery, tab),
[lastQuery, tab],
);
const page = useDataListQueryPage({
schema: orderSchema,
listCode: "demo-query-list-orders",
data: filteredData,
getRowId: (r) => r.id,
defaultPageSize: 10,
defaultSort: [{ field: "orderNumber", order: "asc" }],
features: { filters: true, selection: true, pagination: { mode: "offset" } },
tableOptions: TABLE_OPTIONS,
});
const { handle, view } = page;
const { t: tableT } = useTableI18n();
const { t: viewT } = useDataListViewI18n();
const selectionSeeded = useRef(false);
useEffect(() => {
if (selectionSeeded.current || handle.data.rows.length === 0) return;
selectionSeeded.current = true;
for (let i = 0; i < Math.min(3, handle.data.rows.length); i++) {
const row = handle.data.rows[i]!;
const id = handle.getRowId!(row, i);
if (!handle.selection.isSelected(id)) handle.selection.toggle(id);
}
}, [handle.data.rows.length, handle.getRowId, handle.selection]);
const summaryQuantity = page.selectedRows.reduce((sum, row) => sum + row.quantity, 0);
const summaryAmount = page.selectedRows.reduce((sum, row) => sum + row.amount, 0);
return (
<DataListProvider {...page.providerProps}>
<DataListShell
className="h-[min(720px,85vh)] min-h-[480px] gap-0 overflow-hidden rounded-xl border bg-card p-4 shadow-sm"
stats={
<QueryFilter
variant="plain"
loading={queryLoading || isListInteractionLocked(handle.data.status)}
defaultCollapsed={false}
onFinish={(values) => {
setQueryLoading(true);
setLastQuery(values);
handle.setParams({ page: 1 });
window.setTimeout(() => setQueryLoading(false), 400);
}}
onReset={() => {
setLastQuery(null);
handle.setParams({ page: 1 });
}}
>
<FormField kind="text" name="orderNumber" label="Order #" />
<FormField kind="select" name="status" label="Status" componentProps={{ options: STATUS_OPTIONS }} />
<FormField kind="text" name="customer" label="Customer" />
</QueryFilter>
}
renderToolbar={({ isFullscreen, toggle }) => (
<DataListToolbar
className="border-border border-b py-2.5"
tabs={
<DataListToolbarTabs
tabs={TOOLBAR_TABS}
value={tab}
onChange={(id) => {
setTab(id as (typeof TOOLBAR_TABS)[number]["id"]);
handle.setParams({ page: 1 });
}}
/>
}
actions={
<>
<Button type="button" variant="outline" onClick={() => toast.info("Import orders")}>
<UploadIcon className="mr-1.5 size-4" />
Import
</Button>
<Button type="button" variant="outline" onClick={() => toast.info("Export orders")}>
<DownloadIcon className="mr-1.5 size-4" />
Export
</Button>
<Button type="button" onClick={() => toast.success("Create order")}>
<PlusIcon className="mr-1.5 size-4" />
Create order
</Button>
</>
}
toolsStart={<DataListSearchInput className="sm:w-60" />}
tools={
<>
<DataListColumnManager manager={view.columnSettings.manager} t={tableT} />
<DataListDensityToggle density={view.density} onChange={view.setDensity} t={viewT} />
</>
}
isFullscreen={isFullscreen}
onToggleFullscreen={toggle}
/>
)}
footer={
<div className="border-border flex flex-col gap-2 border-t">
<DataListSelectionBar
variant="embedded"
className="rounded-none border-0"
summary={
<>
<span>
Qty: <strong className="text-foreground">{summaryQuantity}</strong>
</span>
<span>
Amount:{" "}
<strong className="text-foreground">
{summaryAmount.toLocaleString("en-US", { style: "currency", currency: "USD" })}
</strong>
</span>
</>
}
actions={
<>
<Button type="button" variant="destructive" onClick={() => toast.warning("Cancel orders")}>
Cancel
</Button>
<Button type="button" variant="outline" onClick={() => toast.info("Export orders")}>
Export
</Button>
<Button type="button" variant="outline" onClick={() => toast.info("Mark shipped")}>
Mark shipped
</Button>
<Button type="button" onClick={() => toast.success("View orders")}>
View
</Button>
</>
}
/>
<DataListPagination className="px-3 pb-1" />
</div>
}
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<DataListActiveFilters className="mb-2 shrink-0" />
{handle.data.error ? (
<DataListErrorState
fillHeight
error={handle.data.error}
isRetrying={handle.data.status === "retrying"}
onRetry={() => void handle.data.refetch()}
/>
) : handle.data.isEmpty ? (
<DataListEmpty fillHeight />
) : (
<TableView
{...page.tableViewProps}
stickyHeader
fillHeight
renderColumnFilter={({ field, dataList }) => (
<DataListColumnFilter field={field} dataList={dataList} />
)}
/>
)}
</div>
</DataListShell>
</DataListProvider>
);
}
/** Ant Design Pro query-list page — self-contained order data, schema, and client-side filtering. */
export function QueryListDemo() {
return (
<QueryClientProvider client={queryClient}>
<QueryListDemoInner />
</QueryClientProvider>
);
}List Page With Statistics
Inquire filters on top, then a filter-scoped KPI row, then the list card:
| Path | How |
|---|---|
QueryList recipe (most pages) | Pass stats={<StatisticGroup>…</StatisticGroup>} — between QueryFilter and the list card |
Headless useDataListQueryPage | Pass slots.stats into DataListShell |
Change status / customer and click Search — cards update over the filtered set. Orders showcase: Table. See List Page Statistics.
QueryList stats
| ORD-1001 | Acme | Confirmed | 2,499.00 |
| ORD-1002 | Globex | Shipped | 849.50 |
| ORD-1003 | Initech | Confirmed | 120.00 |
| ORD-1004 | Umbrella | Cancelled | 399.00 |
| ORD-1005 | Stark | Shipped | 9,800.00 |
| ORD-1006 | Wayne | Confirmed | 540.00 |
"use client";
import { useMemo, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { createInMemoryListAdapter } from "@/components/f-ui/data-list-internals/adapters/in-memory-list-adapter";
import { QueryList } from "@/components/f-ui/query-list/query-list";
import { Statistic } from "@/components/f-ui/statistic/statistic";
import { StatisticGroup } from "@/components/f-ui/statistic/statistic-group";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoOrder {
id: string;
orderNumber: string;
customer: string;
status: "confirmed" | "shipped" | "cancelled";
amount: number;
shipped: boolean;
}
const STATUS_VARIANTS = {
confirmed: { label: "Confirmed", tone: "success" as const },
shipped: { label: "Shipped", tone: "info" as const },
cancelled: { label: "Cancelled", tone: "destructive" as const },
};
const SEED: DemoOrder[] = [
{ id: "1", orderNumber: "ORD-1001", customer: "Acme", status: "confirmed", amount: 2499, shipped: false },
{ id: "2", orderNumber: "ORD-1002", customer: "Globex", status: "shipped", amount: 849.5, shipped: true },
{ id: "3", orderNumber: "ORD-1003", customer: "Initech", status: "confirmed", amount: 120, shipped: false },
{ id: "4", orderNumber: "ORD-1004", customer: "Umbrella", status: "cancelled", amount: 399, shipped: false },
{ id: "5", orderNumber: "ORD-1005", customer: "Stark", status: "shipped", amount: 9800, shipped: true },
{ id: "6", orderNumber: "ORD-1006", customer: "Wayne", status: "confirmed", amount: 540, shipped: false },
];
const schema = defineDataListSchema<DemoOrder>({
orderNumber: f.text({ label: "Order #", sortable: true }),
customer: f.text({ label: "Customer", sortable: true }),
status: f.enum({
render: "status",
label: "Status",
sortable: true,
variants: STATUS_VARIANTS,
}),
amount: f.currency({ label: "Amount", currency: "USD", sortable: true }),
});
const adapter = createInMemoryListAdapter<DemoOrder>({ items: SEED });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
function filterScopedKpis(params: Record<string, unknown>) {
let rows = SEED;
const status = params.status;
if (status != null && status !== "") {
rows = rows.filter((r) => r.status === status);
}
const customer = String(params.customer ?? "").trim().toLowerCase();
if (customer) {
rows = rows.filter((r) => r.customer.toLowerCase().includes(customer));
}
return {
orderCount: rows.length,
totalAmount: rows.reduce((sum, r) => sum + r.amount, 0),
unshippedCount: rows.filter((r) => !r.shipped).length,
};
}
function QueryListStatsDemoInner() {
const [params, setParams] = useState<Record<string, unknown>>({});
const kpis = useMemo(() => filterScopedKpis(params), [params]);
return (
<QueryList<DemoOrder>
schema={schema}
listCode="demo-query-list-stats"
adapter={adapter}
params={params}
onParamsChange={(updates) => setParams((prev) => ({ ...prev, ...updates }))}
getRowId={(row) => row.id}
recipe={{ filtering: true, selection: false, paginationMode: "offset" }}
fillHeight={false}
queryFilterCollapsedRows={1}
queryFilterDefaultCollapsed={false}
stats={
<StatisticGroup>
<Statistic variant="card" title="Orders" value={kpis.orderCount} />
<Statistic
variant="card"
title="Total amount"
value={kpis.totalAmount}
precision={2}
prefix="$"
/>
<Statistic variant="card" title="Unshipped" value={kpis.unshippedCount} />
</StatisticGroup>
}
/>
);
}
/** QueryList recipe with `stats` — filter → StatisticGroup → table. */
export function QueryListStatsDemo() {
return (
<QueryClientProvider client={queryClient}>
<QueryListStatsDemoInner />
</QueryClientProvider>
);
}QueryList pending
When capabilities / schema are not ready yet, render chrome Loading without mounting the list query:
if (!caps) return <QueryList pending />;
return <QueryList schema={createSchema(caps)} … />;This is not the same as fetch-row loading inside a ready QueryList. Do not treat pending as Empty.
QueryList onRowClick
QueryList accepts onRowClick and forwards it to TableView or ListView (same (row) => void contract as <Table>). Nested controls — CopyableCell, row actions, selection checkboxes — must call stopPropagation so they do not also fire the row handler.
Headless shell stats
| ORD-1001 | Acme Corp | Confirmed | Electronics | 2,499.00 | 5 | 2026-03-01 | |
| ORD-1002 | Globex Inc | Shipped | Apparel | 849.50 | 12 | 2026-03-03 | |
| ORD-1003 | Initech | Confirmed | Home | 120.00 | 2 | 2026-03-05 | |
| ORD-1004 | Umbrella Co | Cancelled | Electronics | 399.00 | 1 | 2026-03-06 | |
| ORD-1005 | Stark Industries | Shipped | Electronics | 9,800.00 | 3 | 2026-03-08 | |
| ORD-1006 | Wayne Enterprises | Confirmed | Apparel | 540.00 | 8 | 2026-03-10 | |
| ORD-1007 | Oscorp | Shipped | Home | 2,100.00 | 4 | 2026-03-11 | |
| ORD-1008 | Cyberdyne | Confirmed | Electronics | 15,000.00 | 20 | 2026-03-12 | |
| ORD-1009 | Soylent Corp | Cancelled | Home | 75.00 | 6 | 2026-03-13 | |
| ORD-1010 | Hooli | Shipped | Apparel | 430.00 | 7 | 2026-03-14 |
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { keepPreviousData, QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";
import { DownloadIcon, PlusIcon, UploadIcon } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { isListInteractionLocked } from "@/components/f-ui/data-list-internals/lib/resolve-list-loading";
import { useDataListQueryPage } from "@/components/f-ui/data-list-chrome/hooks/use-data-list-query-page";
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 { DataListToolbarTabs } from "@/components/f-ui/data-list-chrome/data-list-toolbar-tabs";
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 { DataListEmpty } from "@/components/f-ui/data-list-chrome/data-list-empty";
import { DataListErrorState } from "@/components/f-ui/data-list-chrome/data-list-error-state";
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 { DataListColumnManager } from "@/components/f-ui/table/table-parts/column-manager";
import { DataListDensityToggle } from "@/components/f-ui/table/table-parts/density-toggle";
import { TableView } from "@/components/f-ui/table/table-parts/table-view";
import { useDataListViewI18n } from "@/components/f-ui/data-list-view/hooks/use-data-list-view-i18n";
import { useTableI18n } from "@/components/f-ui/table/hooks/use-table-i18n";
import { QueryFilter } from "@/components/f-ui/query-filter/query-filter";
import { FormField } from "@/components/f-ui/formily/form-field";
import { f } from "@/components/f-ui/field-types/catalog";
import { Statistic } from "@/components/f-ui/statistic/statistic";
import { StatisticGroup } from "@/components/f-ui/statistic/statistic-group";
interface DemoOrder {
id: string;
orderNumber: string;
customer: string;
status: "confirmed" | "shipped" | "cancelled";
category: "electronics" | "apparel" | "home";
amount: number;
quantity: number;
orderDate: string;
shipped: boolean;
}
const STATUS_VARIANTS = {
confirmed: { label: "Confirmed", tone: "success" as const },
shipped: { label: "Shipped", tone: "info" as const },
cancelled: { label: "Cancelled", tone: "destructive" as const },
};
const CATEGORY_VARIANTS = {
electronics: { label: "Electronics" },
apparel: { label: "Apparel" },
home: { label: "Home" },
};
const SEED_ORDERS: DemoOrder[] = [
{ id: "1", orderNumber: "ORD-1001", customer: "Acme Corp", status: "confirmed", category: "electronics", amount: 2499, quantity: 5, orderDate: "2026-03-01", shipped: false },
{ id: "2", orderNumber: "ORD-1002", customer: "Globex Inc", status: "shipped", category: "apparel", amount: 849.5, quantity: 12, orderDate: "2026-03-03", shipped: true },
{ id: "3", orderNumber: "ORD-1003", customer: "Initech", status: "confirmed", category: "home", amount: 120, quantity: 2, orderDate: "2026-03-05", shipped: false },
{ id: "4", orderNumber: "ORD-1004", customer: "Umbrella Co", status: "cancelled", category: "electronics", amount: 399, quantity: 1, orderDate: "2026-03-06", shipped: false },
{ id: "5", orderNumber: "ORD-1005", customer: "Stark Industries", status: "shipped", category: "electronics", amount: 9800, quantity: 3, orderDate: "2026-03-08", shipped: true },
{ id: "6", orderNumber: "ORD-1006", customer: "Wayne Enterprises", status: "confirmed", category: "apparel", amount: 540, quantity: 8, orderDate: "2026-03-10", shipped: false },
{ id: "7", orderNumber: "ORD-1007", customer: "Oscorp", status: "shipped", category: "home", amount: 2100, quantity: 4, orderDate: "2026-03-11", shipped: true },
{ id: "8", orderNumber: "ORD-1008", customer: "Cyberdyne", status: "confirmed", category: "electronics", amount: 15000, quantity: 20, orderDate: "2026-03-12", shipped: false },
{ id: "9", orderNumber: "ORD-1009", customer: "Soylent Corp", status: "cancelled", category: "home", amount: 75, quantity: 6, orderDate: "2026-03-13", shipped: false },
{ id: "10", orderNumber: "ORD-1010", customer: "Hooli", status: "shipped", category: "apparel", amount: 430, quantity: 7, orderDate: "2026-03-14", shipped: true },
{ id: "11", orderNumber: "ORD-1011", customer: "Vehement Capital", status: "confirmed", category: "home", amount: 890, quantity: 2, orderDate: "2026-03-15", shipped: false },
{ id: "12", orderNumber: "ORD-1012", customer: "Pied Piper", status: "confirmed", category: "electronics", amount: 199, quantity: 1, orderDate: "2026-03-16", shipped: false },
];
const orderSchema = defineDataListSchema<DemoOrder>({
orderNumber: f.link({ label: "Order #", sortable: true, href: (row) => `#${row.id}` }),
customer: f.text({ label: "Customer", sortable: true }),
status: f.enum({ render: "status", label: "Status", sortable: true, variants: STATUS_VARIANTS }),
category: f.enum({ render: "label", label: "Category", sortable: true, variants: CATEGORY_VARIANTS }),
amount: f.currency({ label: "Amount", currency: "USD", sortable: true }),
quantity: f.number({ label: "Qty", sortable: true }),
orderDate: f.date({ label: "Order date", sortable: true }),
});
function filterOrders(
orders: readonly DemoOrder[],
query: Record<string, unknown> | null,
tab: "all" | "unshipped",
): DemoOrder[] {
let rows = [...orders];
if (tab === "unshipped") rows = rows.filter((r) => !r.shipped);
if (!query) return rows;
const orderNumber = String(query.orderNumber ?? "").trim();
if (orderNumber) {
const lower = orderNumber.toLowerCase();
rows = rows.filter((r) => r.orderNumber.toLowerCase().includes(lower));
}
const status = query.status;
if (status !== undefined && status !== null && status !== "") {
rows = rows.filter((r) => r.status === status);
}
const customer = String(query.customer ?? "").trim();
if (customer) {
const lower = customer.toLowerCase();
rows = rows.filter((r) => r.customer.toLowerCase().includes(lower));
}
return rows;
}
type OrdersSummary = {
orderCount: number;
totalAmount: number;
shippedCount: number;
cancelledCount: number;
};
function summarizeOrders(rows: readonly DemoOrder[]): OrdersSummary {
return {
orderCount: rows.length,
totalAmount: rows.reduce((sum, r) => sum + r.amount, 0),
shippedCount: rows.filter((r) => r.status === "shipped").length,
cancelledCount: rows.filter((r) => r.status === "cancelled").length,
};
}
/** Simulates GET /orders/summary with the same filters as the list (no pagination). */
async function fetchOrdersSummaryMock(
query: Record<string, unknown> | null,
tab: "all" | "unshipped",
): Promise<OrdersSummary> {
await new Promise((r) => setTimeout(r, 280));
return summarizeOrders(filterOrders(SEED_ORDERS, query, tab));
}
const STATUS_OPTIONS = Object.entries(STATUS_VARIANTS).map(([value, v]) => ({
label: v.label,
value,
}));
const TOOLBAR_TABS = [
{ id: "all", label: "All orders" },
{ id: "unshipped", label: "Unshipped" },
] as const;
const TABLE_OPTIONS = {
columns: {
orderNumber: { grow: true, size: 120 },
amount: { align: "end", size: 110 },
quantity: { align: "end", size: 80 },
orderDate: { size: 110 },
},
} as const;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
function WithStatisticsDemoInner() {
const [tab, setTab] = useState<(typeof TOOLBAR_TABS)[number]["id"]>("all");
const [lastQuery, setLastQuery] = useState<Record<string, unknown> | null>(null);
const [queryLoading, setQueryLoading] = useState(false);
const summaryQuery = useQuery({
queryKey: ["demo-orders", "summary", tab, lastQuery],
queryFn: () => fetchOrdersSummaryMock(lastQuery, tab),
placeholderData: keepPreviousData,
});
const kpis = summaryQuery.data;
const kpisLoading = summaryQuery.isPending && !summaryQuery.isPlaceholderData;
const filteredData = useMemo(
() => filterOrders(SEED_ORDERS, lastQuery, tab),
[lastQuery, tab],
);
const page = useDataListQueryPage({
schema: orderSchema,
listCode: "demo-with-statistics-orders",
data: filteredData,
getRowId: (r) => r.id,
defaultPageSize: 10,
defaultSort: [{ field: "orderNumber", order: "asc" }],
features: { filters: true, selection: true, pagination: { mode: "offset" } },
tableOptions: TABLE_OPTIONS,
});
const { handle, view } = page;
const { t: tableT } = useTableI18n();
const { t: viewT } = useDataListViewI18n();
const selectionSeeded = useRef(false);
useEffect(() => {
if (selectionSeeded.current || handle.data.rows.length === 0) return;
selectionSeeded.current = true;
for (let i = 0; i < Math.min(3, handle.data.rows.length); i++) {
const row = handle.data.rows[i]!;
const id = handle.getRowId!(row, i);
if (!handle.selection.isSelected(id)) handle.selection.toggle(id);
}
}, [handle.data.rows.length, handle.getRowId, handle.selection]);
const summaryQuantity = page.selectedRows.reduce((sum, row) => sum + row.quantity, 0);
const summaryAmount = page.selectedRows.reduce((sum, row) => sum + row.amount, 0);
return (
<DataListProvider {...page.providerProps}>
<DataListShell
className="h-[min(720px,85vh)] min-h-[480px] gap-0 overflow-hidden rounded-xl border bg-card p-4 shadow-sm"
stats={
<div className="flex flex-col gap-3">
<QueryFilter
variant="plain"
loading={queryLoading || isListInteractionLocked(handle.data.status)}
defaultCollapsed={false}
onFinish={(values) => {
setQueryLoading(true);
setLastQuery(values);
handle.setParams({ page: 1 });
window.setTimeout(() => setQueryLoading(false), 400);
}}
onReset={() => {
setLastQuery(null);
handle.setParams({ page: 1 });
}}
>
<FormField kind="text" name="orderNumber" label="Order #" />
<FormField kind="select" name="status" label="Status" componentProps={{ options: STATUS_OPTIONS }} />
<FormField kind="text" name="customer" label="Customer" />
</QueryFilter>
{summaryQuery.isError ? (
<div className="text-destructive flex items-center gap-2 text-sm">
<span>Could not load statistics.</span>
<Button type="button" variant="outline" onClick={() => void summaryQuery.refetch()}>
Retry
</Button>
</div>
) : null}
<StatisticGroup>
<Statistic
variant="card"
title="Orders"
value={kpis?.orderCount ?? null}
loading={kpisLoading}
/>
<Statistic
variant="card"
title="Total amount"
value={kpis?.totalAmount ?? null}
precision={2}
prefix="$"
loading={kpisLoading}
/>
<Statistic
variant="card"
title="Shipped"
value={kpis?.shippedCount ?? null}
loading={kpisLoading}
/>
<Statistic
variant="card"
title="Cancelled"
value={kpis?.cancelledCount ?? null}
loading={kpisLoading}
/>
</StatisticGroup>
</div>
}
renderToolbar={({ isFullscreen, toggle }) => (
<DataListToolbar
className="border-border border-b py-2.5"
tabs={
<DataListToolbarTabs
tabs={TOOLBAR_TABS}
value={tab}
onChange={(id) => {
setTab(id as (typeof TOOLBAR_TABS)[number]["id"]);
handle.setParams({ page: 1 });
}}
/>
}
actions={
<>
<Button type="button" variant="outline" onClick={() => toast.info("Import orders")}>
<UploadIcon className="mr-1.5 size-4" />
Import
</Button>
<Button type="button" variant="outline" onClick={() => toast.info("Export orders")}>
<DownloadIcon className="mr-1.5 size-4" />
Export
</Button>
<Button type="button" onClick={() => toast.success("Create order")}>
<PlusIcon className="mr-1.5 size-4" />
Create order
</Button>
</>
}
toolsStart={<DataListSearchInput className="sm:w-60" />}
tools={
<>
<DataListColumnManager manager={view.columnSettings.manager} t={tableT} />
<DataListDensityToggle density={view.density} onChange={view.setDensity} t={viewT} />
</>
}
isFullscreen={isFullscreen}
onToggleFullscreen={toggle}
/>
)}
footer={
<div className="border-border flex flex-col gap-2 border-t">
<DataListSelectionBar
variant="embedded"
className="rounded-none border-0"
summary={
<>
<span>
Qty: <strong className="text-foreground">{summaryQuantity}</strong>
</span>
<span>
Amount:{" "}
<strong className="text-foreground">
{summaryAmount.toLocaleString("en-US", { style: "currency", currency: "USD" })}
</strong>
</span>
</>
}
actions={
<>
<Button type="button" variant="destructive" onClick={() => toast.warning("Cancel orders")}>
Cancel
</Button>
<Button type="button" variant="outline" onClick={() => toast.info("Export orders")}>
Export
</Button>
<Button type="button" variant="outline" onClick={() => toast.info("Mark shipped")}>
Mark shipped
</Button>
<Button type="button" onClick={() => toast.success("View orders")}>
View
</Button>
</>
}
/>
<DataListPagination className="px-3 pb-1" />
</div>
}
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
<DataListActiveFilters className="mb-2 shrink-0" />
{handle.data.error ? (
<DataListErrorState
fillHeight
error={handle.data.error}
isRetrying={handle.data.status === "retrying"}
onRetry={() => void handle.data.refetch()}
/>
) : handle.data.isEmpty ? (
<DataListEmpty fillHeight />
) : (
<TableView
{...page.tableViewProps}
stickyHeader
fillHeight
renderColumnFilter={({ field, dataList }) => (
<DataListColumnFilter field={field} dataList={dataList} />
)}
/>
)}
</div>
</DataListShell>
</DataListProvider>
);
}
/** Ant Design research-list exclusive area: inquire + filter-scoped KPI cards above the table. */
export function TableWithStatisticsDemo() {
return (
<QueryClientProvider client={queryClient}>
<WithStatisticsDemoInner />
</QueryClientProvider>
);
}QueryList view="list"
When the index is row-first rather than columnar, pass view="list" and rowLayout. Same handle, chrome, empty, error, selection, batch, and row actions — no column manager. This is the only list-shaped surface that ships a batch bar. Presentational List and Related List + List do not. Headless compose uses ListView inside DataListShell (Orders List).
Try the checkboxes and Export on the footer; Order # is the identity link; Edit is the row action. Do not sniff the schema to pick the view. Hosts set view; there is no operator table↔list toggle in v1.
"use client";
import { useState } from "react";
import { PencilIcon } from "lucide-react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { toast } from "sonner";
import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { createInMemoryListAdapter } from "@/components/f-ui/data-list-internals/adapters/in-memory-list-adapter";
import { QueryList } from "@/components/f-ui/query-list/query-list";
import { f } from "@/components/f-ui/field-types/catalog";
interface DemoOrder {
id: string;
orderNumber: string;
customer: string;
notes: string;
amount: number;
}
const SEED: DemoOrder[] = [
{ id: "1", orderNumber: "ORD-1001", customer: "Acme", notes: "Rush", amount: 2499 },
{ id: "2", orderNumber: "ORD-1002", customer: "Globex", notes: "Standard", amount: 849.5 },
{ id: "3", orderNumber: "ORD-1003", customer: "Initech", notes: "Hold for packing", amount: 120 },
{ id: "4", orderNumber: "ORD-1004", customer: "Umbrella", notes: "Cancelled hold", amount: 399 },
];
const schema = defineDataListSchema<DemoOrder>({
orderNumber: f.link({
label: "Order #",
href: (row) => `#${row.id}`,
}),
customer: f.text({ label: "Customer" }),
notes: f.text({ label: "Notes" }),
amount: f.currency({ label: "Amount", currency: "USD" }),
});
const adapter = createInMemoryListAdapter<DemoOrder>({ items: SEED });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
function QueryListListViewDemoInner() {
const [params, setParams] = useState<Record<string, unknown>>({});
return (
<QueryList<DemoOrder>
adapter={adapter}
batchActions={[
{
id: "export",
label: "Export",
onClick: ({ count, clear }) => {
toast.success(`Export ${count}`);
clear();
},
},
]}
fillHeight={false}
getRowId={(row) => row.id}
listCode="demo-query-list-list-view"
onParamsChange={(updates) => setParams((prev) => ({ ...prev, ...updates }))}
params={params}
recipe={{ filtering: false, selection: true, paginationMode: "offset" }}
rowActions={[
{
id: "edit",
label: "Edit",
icon: <PencilIcon className="size-3.5" />,
onClick: (row) => {
toast.info(`Edit ${row.orderNumber}`);
},
},
]}
rowActionsPresentation="icon"
rowLayout={{ title: "orderNumber", description: "notes", extra: ["amount"] }}
schema={schema}
view="list"
/>
);
}
/** Resource index with row body: same handle as Table — checkboxes, batch bar, row actions. */
export function QueryListListViewDemo() {
return (
<QueryClientProvider client={queryClient}>
<QueryListListViewDemoInner />
</QueryClientProvider>
);
}view="list" does not imply checkboxes. Omit recipe.selection when the job is browse + identity Open only (no Actions column).
"use client";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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 { f } from "@/components/f-ui/field-types/catalog";
import { QueryList } from "@/components/f-ui/query-list/query-list";
interface DemoOrder {
id: string;
orderNumber: string;
notes: string;
amount: number;
}
const SEED: DemoOrder[] = [
{ id: "1", orderNumber: "ORD-1001", notes: "Rush", amount: 2499 },
{ id: "2", orderNumber: "ORD-1002", notes: "Standard", amount: 849.5 },
{ id: "3", orderNumber: "ORD-1003", notes: "Hold for packing", amount: 120 },
];
const schema = defineDataListSchema<DemoOrder>({
orderNumber: f.link({
label: "Order #",
href: (row) => `#${row.id}`,
}),
notes: f.text({ label: "Notes" }),
amount: f.currency({ label: "Amount", currency: "USD" }),
});
const adapter = createInMemoryListAdapter<DemoOrder>({ items: SEED });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
function QueryListListViewBrowseDemoInner() {
const [params, setParams] = useState<Record<string, unknown>>({});
return (
<QueryList<DemoOrder>
adapter={adapter}
fillHeight={false}
getRowId={(row) => row.id}
listCode="demo-query-list-list-view-browse"
onParamsChange={(updates) => setParams((prev) => ({ ...prev, ...updates }))}
params={params}
recipe={{ filtering: false, paginationMode: "offset" }}
rowLayout={{ title: "orderNumber", description: "notes", extra: ["amount"] }}
schema={schema}
view="list"
/>
);
}
/** Resource index, row-first, no checkboxes. Open lives on the identity link — no Actions column. */
export function QueryListListViewBrowseDemo() {
return (
<QueryClientProvider client={queryClient}>
<QueryListListViewBrowseDemoInner />
</QueryClientProvider>
);
}Slot Functions
rowLayout slots are field keys or (row) => ReactNode. Keep the Meta shell (avatar, title, chip, extra) and paint only what schema cannot — initials, a host extra. Title still uses f.link via the name key; role still goes through f.enum({ render: "label" }).
- Adminada@example.comPlatform
- Viewergrace@example.comSupport
- Memberalan@example.comResearch
"use client";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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 type { LabelVariantMap } from "@/components/f-ui/data-list-internals/schema/field-config";
import { f } from "@/components/f-ui/field-types/catalog";
import { QueryList } from "@/components/f-ui/query-list/query-list";
type PersonRole = "admin" | "viewer" | "member";
interface Person {
id: string;
name: string;
email: string;
role: PersonRole;
initials: string;
team: string;
}
const SEED: Person[] = [
{
id: "1",
name: "Ada Lovelace",
email: "ada@example.com",
role: "admin",
initials: "AL",
team: "Platform",
},
{
id: "2",
name: "Grace Hopper",
email: "grace@example.com",
role: "viewer",
initials: "GH",
team: "Support",
},
{
id: "3",
name: "Alan Turing",
email: "alan@example.com",
role: "member",
initials: "AT",
team: "Research",
},
];
const ROLE_VARIANTS: LabelVariantMap = {
admin: { label: "Admin", accent: "accent-1" },
viewer: { label: "Viewer", accent: "neutral" },
member: { label: "Member", accent: "accent-3" },
};
const schema = defineDataListSchema<Person>({
name: f.link({
label: "Name",
href: (row) => `#${row.id}`,
}),
email: f.text({ label: "Email" }),
role: f.enum({
render: "label",
label: "Role",
variants: ROLE_VARIANTS,
}),
team: f.text({ label: "Team" }),
});
const adapter = createInMemoryListAdapter<Person>({ items: SEED });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
function Initials({ children }: { children: string }) {
return (
<span
aria-hidden
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-full text-xs font-medium"
>
{children}
</span>
);
}
function QueryListListViewSlotRenderDemoInner() {
const [params, setParams] = useState<Record<string, unknown>>({});
return (
<QueryList<Person>
adapter={adapter}
fillHeight={false}
getRowId={(row) => row.id}
listCode="demo-query-list-list-view-slot-render"
onParamsChange={(updates) => setParams((prev) => ({ ...prev, ...updates }))}
params={params}
recipe={{ filtering: false, paginationMode: "offset" }}
rowLayout={{
avatar: (row) => <Initials>{row.initials}</Initials>,
title: "name",
description: "email",
meta: ["role"],
extra: [(row) => row.team],
}}
schema={schema}
view="list"
/>
);
}
/** QueryList list view: rowLayout slots as field keys mixed with (row) => node. */
export function QueryListListViewSlotRenderDemo() {
return (
<QueryClientProvider client={queryClient}>
<QueryListListViewSlotRenderDemoInner />
</QueryClientProvider>
);
}Decorate Default Item
Pass rowLayout and renderItem. The third argument is the kit List.Item (checkbox, Meta, extra, actions). Tint or reclass with cloneElement — do not wrap it in a div (List is a ul).
- Thread 1Short reply.01:00
- Thread 2Can we move the ship date? The warehouse is holding the pallet until the label reprint lands, and receiving will not dock it overnight.02:01
- Thread 3Looping finance in — the credit does not match the invoice we posted last Tuesday. Please confirm the PO line and whether we should wait for the revised PDF before we close the week.03:02
- Thread 4Thanks — marked as done.04:03
- Thread 5Short reply.05:04
"use client";
import { cloneElement, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { cn } from "@/lib/utils";
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 { f } from "@/components/f-ui/field-types/catalog";
import { QueryList } from "@/components/f-ui/query-list/query-list";
import {
inboxPreviewForIndex,
type InboxThread,
} from "@/demos/table/inbox-row";
const SEED: InboxThread[] = Array.from({ length: 5 }, (_, i) => ({
id: String(i + 1),
name: `Thread ${i + 1}`,
preview: inboxPreviewForIndex(i),
updatedAt: `${String((i % 12) + 1).padStart(2, "0")}:0${i % 10}`,
unread: i % 2 === 0,
}));
const schema = defineDataListSchema<InboxThread>({
name: f.text({ label: "Thread" }),
preview: f.text({ label: "Preview" }),
updatedAt: f.text({ label: "Updated" }),
});
const adapter = createInMemoryListAdapter<InboxThread>({ items: SEED });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
function QueryListListViewDecorateDemoInner() {
const [params, setParams] = useState<Record<string, unknown>>({});
return (
<QueryList<InboxThread>
adapter={adapter}
fillHeight={false}
getRowId={(row) => row.id}
listCode="demo-query-list-list-view-decorate"
onParamsChange={(updates) => setParams((prev) => ({ ...prev, ...updates }))}
params={params}
recipe={{ filtering: false, paginationMode: "offset" }}
rowLayout={{ title: "name", description: "preview", extra: ["updatedAt"] }}
renderItem={(row, _ctx, defaultItem) =>
cloneElement(defaultItem, {
className: cn(row.unread && "bg-primary/5"),
})
}
schema={schema}
view="list"
/>
);
}
/** QueryList list view: wrap defaultItem, keep rowLayout Meta. */
export function QueryListListViewDecorateDemo() {
return (
<QueryClientProvider client={queryClient}>
<QueryListListViewDecorateDemoInner />
</QueryClientProvider>
);
}Named Row Component
To own the whole row, pass renderItem and return a List.Item — usually a named component (renderItem={(row) => <InboxRow row={row} />}). Not an item={InboxRow} prop. Kit cloneElements density, selection chrome, data-index, and the virtual ref onto that element; a named row must spread those props onto List.Item (not only ref). Use this when the row is not Meta (unread mark as leading, custom time as extra). Field-key title still truncates; description wraps like List.Meta when you keep defaultItem.
- Thread 1
Short reply.
01:00 - Thread 2
Can we move the ship date? The warehouse is holding the pallet until the label reprint lands, and receiving will not dock it overnight.
02:01 - Thread 3
Looping finance in — the credit does not match the invoice we posted last Tuesday. Please confirm the PO line and whether we should wait for the revised PDF before we close the week.
03:02 - Thread 4
Thanks — marked as done.
04:03 - Thread 5
Short reply.
05:04
"use client";
import { useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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 { f } from "@/components/f-ui/field-types/catalog";
import { QueryList } from "@/components/f-ui/query-list/query-list";
import {
InboxRow,
inboxPreviewForIndex,
type InboxThread,
} from "@/demos/table/inbox-row";
const SEED: InboxThread[] = Array.from({ length: 5 }, (_, i) => ({
id: String(i + 1),
name: `Thread ${i + 1}`,
preview: inboxPreviewForIndex(i),
updatedAt: `${String((i % 12) + 1).padStart(2, "0")}:0${i % 10}`,
unread: i % 2 === 0,
}));
const schema = defineDataListSchema<InboxThread>({
name: f.text({ label: "Thread" }),
preview: f.text({ label: "Preview" }),
updatedAt: f.text({ label: "Updated" }),
});
const adapter = createInMemoryListAdapter<InboxThread>({ items: SEED });
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
function QueryListListViewCustomItemDemoInner() {
const [params, setParams] = useState<Record<string, unknown>>({});
return (
<QueryList<InboxThread>
adapter={adapter}
fillHeight={false}
getRowId={(row) => row.id}
listCode="demo-query-list-list-view-custom-item"
onParamsChange={(updates) => setParams((prev) => ({ ...prev, ...updates }))}
params={params}
recipe={{ filtering: false, paginationMode: "offset" }}
renderItem={(row) => <InboxRow row={row} />}
schema={schema}
view="list"
/>
);
}
/** QueryList list view: named row component through renderItem. */
export function QueryListListViewCustomItemDemo() {
return (
<QueryClientProvider client={queryClient}>
<QueryListListViewCustomItemDemoInner />
</QueryClientProvider>
);
}Infinite Scroll (List View)
A height-constrained list body (fillHeight, which QueryList defaults on, or maxHeight) with pagination.mode: "cursor" virtualizes rows and mounts the sentinel inside [data-slot=data-list-rows-container]. This demo uses the same named InboxRow as Named Row Component via renderItem. Field-key rowLayout also works. getRowId is required — without it the list falls back to the button recipe. Table scroll infinite: Table — Infinite Scroll. Grouped and sticky-header lists are a later slice.
"use client";
import { useMemo, useState } 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 { QueryList } from "@/components/f-ui/query-list/query-list";
import { f } from "@/components/f-ui/field-types/catalog";
import {
InboxRow,
inboxPreviewForIndex,
type InboxThread,
} from "@/demos/table/inbox-row";
const CONVERSATIONS: InboxThread[] = Array.from({ length: 120 }, (_, i) => ({
id: String(i + 1),
name: `Thread ${i + 1}`,
preview: inboxPreviewForIndex(i),
updatedAt: `${String((i % 12) + 1).padStart(2, "0")}:0${i % 10}`,
unread: i % 5 === 0,
}));
const conversationSchema = defineDataListSchema<InboxThread>({
name: f.text({ label: "Thread" }),
preview: f.text({ label: "Preview" }),
updatedAt: f.text({ label: "Updated" }),
});
const PAGE_SIZE = 20;
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
function createCursorAdapter(
items: readonly InboxThread[],
): DataListAdapter<InboxThread> {
const adapter: DataListAdapter<InboxThread> = 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-conversations-infinite", String(items.length)];
return adapter;
}
function QueryListListViewInfiniteDemoInner() {
const adapter = useMemo(() => createCursorAdapter(CONVERSATIONS), []);
const [params, setParams] = useState<Record<string, unknown>>({});
return (
<div className="flex h-96 flex-col">
<QueryList<InboxThread>
adapter={adapter}
getRowId={(row) => row.id}
listCode="demo-conversations-infinite"
onParamsChange={(updates) => setParams((prev) => ({ ...prev, ...updates }))}
params={params}
recipe={{ filtering: false, paginationMode: "cursor" }}
renderItem={(row) => <InboxRow row={row} />}
schema={conversationSchema}
view="list"
/>
</div>
);
}
/** Cursor infinite + height-constrained QueryList list view (auto virtual + in-port sentinel). */
export function QueryListListViewInfiniteDemo() {
return (
<QueryClientProvider client={queryClient}>
<QueryListListViewInfiniteDemoInner />
</QueryClientProvider>
);
}Composition
QueryList
├─ QueryFilter (staged)
├─ stats? (StatisticGroup)
└─ list card
├─ DataListToolbar
├─ TableView | ListView
├─ DataListPagination | Load more / sentinel
└─ batch footer (when selection + batchActions)Headless: useDataListQueryPage → DataListProvider → DataListShell → TableView / ListView. Chrome regions: Table — Composition.
API Reference
Props
Assembled resource-index recipe (Plus). Default body is Table. Pass view="list" for the same handle painted as rows.
| Prop | Type | Default | Description |
|---|---|---|---|
view | "table" | "list" | "table" | Index body. Host-set; do not sniff schema. No operator toggle in v1. |
rowLayout | { avatar?, title, description?, meta?, extra? } | — | Field keys or (row) => ReactNode for view="list". title required when renderItem is omitted. Kit paints List.Item + List.Meta into defaultItem. Title field keys truncate; description field keys wrap. |
renderItem | (row, ctx, defaultItem) => ReactElement | — | Returns the row List.Item. Wrap defaultItem or replace it. Named component must spread chrome props onto List.Item (not only ref). Not an itemComponent prop. Presentational List renderItem is (item, index) with no defaultItem. |
recipe.selection | boolean | false | Opt-in checkboxes + handle selection. Same for both views. |
batchActions | BatchAction[] | — | Footer mass ops. Only meaningful with selection. |
rowActions | RowAction[] | — | Per-row actions on Table or ListView. |
stats | ReactNode | — | KPIs between Query Filter and the list card. |
pending | boolean | — | Chrome Loading without mounting the list query. |
onRowClick | (row) => void | — | Forwarded to TableView or ListView. |
Column manager is table view only. Job matrix: List Surfaces. Field schema and Table column layout: Table / Field Types.
Hook
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.