f-ui
Components

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

AreaBehavior
Bodyview="table"TableView; view="list"ListView over the same handle
ChromeQuery Filter, toolbar, empty / error at the shell, pagination, optional batch footer
SelectionOpt-in recipe.selection — same for both views
List paintrowLayout field keys or slot functions; renderItem(row, ctx, defaultItem) returns a List.Item
Column managerTable view only
Density / fullscreenOpt-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-list
FUI_PLUS_REGISTRY_TOKEN=xxx npx shadcn@latest add @f-ui-plus/query-list
FUI_PLUS_REGISTRY_TOKEN=xxx yarn dlx shadcn@latest add @f-ui-plus/query-list
FUI_PLUS_REGISTRY_TOKEN=xxx bunx shadcn@latest add @f-ui-plus/query-list

Install 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-1001Acme CorpConfirmedElectronics2,499.0052026-03-01
ORD-1002Globex IncShippedApparel849.50122026-03-03
ORD-1003InitechConfirmedHome120.0022026-03-05
ORD-1004Umbrella CoCancelledElectronics399.0012026-03-06
ORD-1005Stark IndustriesShippedElectronics9,800.0032026-03-08
ORD-1006Wayne EnterprisesConfirmedApparel540.0082026-03-10
ORD-1007OscorpShippedHome2,100.0042026-03-11
ORD-1008CyberdyneConfirmedElectronics15,000.00202026-03-12
ORD-1009Soylent CorpCancelledHome75.0062026-03-13
ORD-1010HooliShippedApparel430.0072026-03-14
1–10 of 12
Rows per page
"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:

PathHow
QueryList recipe (most pages)Pass stats={<StatisticGroup>…</StatisticGroup>} — between QueryFilter and the list card
Headless useDataListQueryPagePass 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

Orders
6
Total amount
$14,207.50
Unshipped
4
ORD-1001AcmeConfirmed2,499.00
ORD-1002GlobexShipped849.50
ORD-1003InitechConfirmed120.00
ORD-1004UmbrellaCancelled399.00
ORD-1005StarkShipped9,800.00
ORD-1006WayneConfirmed540.00
6 rows
Rows per page
"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

Orders
Total amount
Shipped
Cancelled
ORD-1001Acme CorpConfirmedElectronics2,499.0052026-03-01
ORD-1002Globex IncShippedApparel849.50122026-03-03
ORD-1003InitechConfirmedHome120.0022026-03-05
ORD-1004Umbrella CoCancelledElectronics399.0012026-03-06
ORD-1005Stark IndustriesShippedElectronics9,800.0032026-03-08
ORD-1006Wayne EnterprisesConfirmedApparel540.0082026-03-10
ORD-1007OscorpShippedHome2,100.0042026-03-11
ORD-1008CyberdyneConfirmedElectronics15,000.00202026-03-12
ORD-1009Soylent CorpCancelledHome75.0062026-03-13
ORD-1010HooliShippedApparel430.0072026-03-14
1–10 of 12
Rows per page
"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.

4 rows
Rows per page
"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).

3 rows
Rows per page
"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" }).

3 rows
Rows per page
"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 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
5 rows
Rows per page
"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
5 rows
Rows per page
"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.

End of list
"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: useDataListQueryPageDataListProviderDataListShellTableView / 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.

PropTypeDefaultDescription
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) => ReactElementReturns 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.selectionbooleanfalseOpt-in checkboxes + handle selection. Same for both views.
batchActionsBatchAction[]Footer mass ops. Only meaningful with selection.
rowActionsRowAction[]Per-row actions on Table or ListView.
statsReactNodeKPIs between Query Filter and the list card.
pendingbooleanChrome Loading without mounting the list query.
onRowClick(row) => voidForwarded 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.

On this page