f-ui
Design

List Surfaces

Pick List, Related List, QueryList, or Table for every collection job — including row actions, checkboxes, and batch.

Four kit names look like “list.” They are four jobs. If you pick the wrong one, operators get a fake batch bar on five members, or a resource index with no filters.

You are buildingUseNot
Stacked rows inside a card / dialog (title + description)ListQueryList, Table, a hand-rolled ul
Object Page region (title + count + Add + Loading/Empty/Error)Related List + List or Table bodyQueryList inside the card
Full resource index (filters, pagination, selection, batch)QueryList view="table" (default) or view="list"Related List, presentational List with a handle
Columnar compare / sort / dense fieldsTable (alone or as QueryList / Related List body)List
Tight line-item write gridEditable TableRelated List

CRUD Page Patterns owns page vs region Create placement. This page owns which collection painter and where checkboxes / batch / row actions live.

If You See This

Walk the left column. Stop at the first match. Do not invent a fifth SKU.

What you are looking atUseDo not
The page is the collection (Orders, users, tickets)QueryList (view="table" or view="list")Related List, presentational List + DataListHandle
Object Page card titled Members / Activity / AttachmentsRelated List + List or TableQueryList, a bare h2 + stacked buttons
Five people on a roleRelated List + List + row Remove + region AddCheckboxes + Delete selected
Closed catalog of assigned keysRelated List + grouped List + Formily Save; Add is a pickerCheckboxes on the assigned list
Files / amounts / sortable columns on an objectRelated List + TableHand-rolled <table>, List pretending to be columns
Mass download / delete on that child tableRelated List + Table selection + toolbar actionList leading checkboxes + kit batch footer
Dialog: pick N people / keysList + Checkbox in leadingrowSelection, QueryList, a batch bar
Resource index, row-first (title + description)QueryList view="list" + rowLayoutA ProList recipe, sniffing schema, presentational List
Same index, columns / sort / compareQueryList view="table" (default)Related List
Index with no mass opsQueryList with recipe.selection omitted (default off)Copy-pasting checkboxes because the body is list-shaped
Line items the operator edits on a formEditable TableRelated List
Empty / loading / error for a regionRelated List statusBlank ul, Result as Empty, Spin over a missing toolbar
Empty / loading / error for the pageQueryList shell Empty / Error / skeletonRelated List status on the index

Hard bans

  • Do not put rowSelection / batchActions / DataListHandle on presentational List.
  • Do not wrap a Related List body in QueryList.
  • Do not use Related List as the resource index.
  • Do not sniff schema to pick QueryList view. The host sets view="table" or view="list".
  • Do not invent a ProList recipe. The list-shaped index is QueryList view="list".
  • Do not add an operator table↔list toggle in v1.

Who Owns The Checkbox

SurfaceRow actionsCheckboxesBatch / mass bar
Presentational ListList.Item actions[]Host may compose Checkbox into leading (picker only)No kit footer
ListView / QueryList view="list"Shared rowActionsSame handle as TableQueryList footer when you pass batchActions
Related List + ListList.Item actionsDefault offRegion Add, not mass-update
Related List + TableTable rowActionsOptionalOptional toolbar action, or View All → QueryList
Standalone TableOptionalselection / handleHost chrome if needed

Anti-Patterns

Each pair is the same job done wrong, then right. Copy the Right column.

Batch Bar On Members

Problem: checkboxes on Related List + List, plus Delete selected as the region primary. Operators confuse “who is on this role” with “who is marked for delete,” and you skipped the Add picker.

Do: ghost Remove on each row; solid Add members on the toolbar. Multi-pick happens in the Add dialog (leading Checkbox), not on the assigned list.

Wrong

Members

2
  • Ada Lovelace
    ada@example.com
    Owner
  • Grace Hopper
    grace@example.com
    Member

Right

Members

2
  • Ada Lovelace
    ada@example.com
    Owner
  • Grace Hopper
    grace@example.com
    Member
"use client";

import { Trash2Icon } from "lucide-react";

import { List } from "@/components/f-ui/list/list";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { DesignCompare } from "@/demos/_design/design-compare";
import { IconGhostButton } from "@/demos/_shared/icon-ghost-button";

function MemberRows({ withCheckboxes }: { withCheckboxes: boolean }) {
  return (
    <List>
      <List.Item
        extra="Owner"
        leading={withCheckboxes ? <Checkbox checked={false} aria-label="Select Ada" /> : undefined}
        actions={
          withCheckboxes
            ? undefined
            : [
                <IconGhostButton key="rm" label="Remove Ada">
                  <Trash2Icon className="size-4" />
                </IconGhostButton>,
              ]
        }
      >
        <List.Meta description="ada@example.com" title="Ada Lovelace" />
      </List.Item>
      <List.Item
        extra="Member"
        leading={withCheckboxes ? <Checkbox checked={false} aria-label="Select Grace" /> : undefined}
        actions={
          withCheckboxes
            ? undefined
            : [
                <IconGhostButton key="rm" label="Remove Grace">
                  <Trash2Icon className="size-4" />
                </IconGhostButton>,
              ]
        }
      >
        <List.Meta description="grace@example.com" title="Grace Hopper" />
      </List.Item>
    </List>
  );
}

export function ListSurfacesBatchOnRelatedListDemo() {
  return (
    <DesignCompare
      wrong={
        <RelatedList
          actions={<Button type="button">Delete selected</Button>}
          count={2}
          title="Members"
        >
          <MemberRows withCheckboxes />
        </RelatedList>
      }
      right={
        <RelatedList
          actions={<Button type="button">Add members</Button>}
          count={2}
          title="Members"
        >
          <MemberRows withCheckboxes={false} />
        </RelatedList>
      }
    />
  );
}
<RelatedList title="Members" count={members.length} actions={<Button>Add members</Button>}>
  <List>
    {members.map((row) => (
      <List.Item
        key={row.id}
        actions={[<Button key="rm" variant="ghost" size="icon" aria-label="Remove" />]}
      >
        <List.Meta title={row.name} description={row.email} />
      </List.Item>
    ))}
  </List>
</RelatedList>

QueryList Inside A Region Card

Problem: Search / filters / list-report chrome stuffed into Attachments. Related List is preview chrome, not a filtered index.

Do: Related List + Table (or List). Operators who need the full report use View All → QueryList.

Wrong

Attachments

2

Search and filters belong on QueryList, not inside this card

quote.pdf240 KB
packing-list.xlsx18 KB

Right

Attachments

2
quote.pdf240 KB
packing-list.xlsx18 KB
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { f } from "@/components/f-ui/field-types/catalog";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Table } from "@/components/f-ui/table/table";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";

interface AttachmentRow {
  id: string;
  name: string;
  size: string;
}

const ATTACHMENTS: AttachmentRow[] = [
  { id: "1", name: "quote.pdf", size: "240 KB" },
  { id: "2", name: "packing-list.xlsx", size: "18 KB" },
];

const attachmentSchema = defineDataListSchema<AttachmentRow>({
  name: f.text({ label: "Name" }),
  size: f.text({ label: "Size" }),
});

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false } },
});

function AttachmentsTable({ listCode }: { listCode: string }) {
  return (
    <Table
      data={ATTACHMENTS}
      defaultPageSize="all"
      getRowId={(row) => row.id}
      listCode={listCode}
      schema={attachmentSchema}
    />
  );
}

function ListSurfacesQuerylistInRegionDemoInner() {
  return (
    <DesignCompare
      right={
        <RelatedList
          actions={<Button type="button">Add attachment</Button>}
          count={2}
          title="Attachments"
        >
          <AttachmentsTable listCode="list-surfaces-region-right" />
        </RelatedList>
      }
      wrong={
        <RelatedList
          actions={<Button type="button">Add attachment</Button>}
          count={2}
          title="Attachments"
        >
          <div className="space-y-2 p-3">
            <p className="text-muted-foreground rounded-md border border-dashed px-3 py-2 text-sm">
              Search and filters belong on QueryList, not inside this card
            </p>
            <AttachmentsTable listCode="list-surfaces-region-wrong" />
          </div>
        </RelatedList>
      }
    />
  );
}

/** Wrong: list-report chrome inside a region card. Right: Related List + Table only. */
export function ListSurfacesQuerylistInRegionDemo() {
  return (
    <QueryClientProvider client={queryClient}>
      <ListSurfacesQuerylistInRegionDemoInner />
    </QueryClientProvider>
  );
}

Problem: the page is Orders, but the body is a Related List (count + region Add, no QueryList handle). You lose filters, pagination, and batch.

Do: page header Create + QueryList. Related List stays on the object page.

Wrong

Orders

Orders

128
  • ORD-1001
    Rush
  • ORD-1002
    Standard
  • ORD-1003
    Hold

Right

Orders

  • ORD-1001
    Rush
  • ORD-1002
    Standard
  • ORD-1003
    Hold
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 { List } from "@/components/f-ui/list/list";
import { QueryList } from "@/components/f-ui/query-list/query-list";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Button } from "@/components/ui/button";
import { DesignCompare } from "@/demos/_design/design-compare";

interface DemoOrder {
  id: string;
  orderNumber: string;
  notes: string;
}

const SEED: DemoOrder[] = [
  { id: "1", orderNumber: "ORD-1001", notes: "Rush" },
  { id: "2", orderNumber: "ORD-1002", notes: "Standard" },
  { id: "3", orderNumber: "ORD-1003", notes: "Hold" },
];

const schema = defineDataListSchema<DemoOrder>({
  orderNumber: f.text({ label: "Order #" }),
  notes: f.text({ label: "Notes" }),
});

const adapter = createInMemoryListAdapter<DemoOrder>({ items: SEED });

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});

function IndexWrong() {
  return (
    <div className="space-y-3">
      <div className="flex items-center justify-between">
        <p className="text-lg font-semibold">Orders</p>
        <Button type="button">Create</Button>
      </div>
      <RelatedList
        actions={<Button type="button">Add</Button>}
        count={128}
        title="Orders"
      >
        <List>
          {SEED.map((row) => (
            <List.Item key={row.id}>
              <List.Meta description={row.notes} title={row.orderNumber} />
            </List.Item>
          ))}
        </List>
      </RelatedList>
    </div>
  );
}

function IndexRight() {
  const [params, setParams] = useState<Record<string, unknown>>({});

  return (
    <div className="space-y-3">
      <div className="flex items-center justify-between">
        <p className="text-lg font-semibold">Orders</p>
        <Button type="button">Create</Button>
      </div>
      <QueryList<DemoOrder>
        adapter={adapter}
        fillHeight={false}
        getRowId={(row) => row.id}
        listCode="list-surfaces-index-right"
        onParamsChange={(updates) => setParams((prev) => ({ ...prev, ...updates }))}
        params={params}
        recipe={{ filtering: false, paginationMode: "offset" }}
        rowLayout={{ title: "orderNumber", description: "notes" }}
        schema={schema}
        view="list"
      />
    </div>
  );
}

function ListSurfacesRelatedListAsIndexDemoInner() {
  return <DesignCompare right={<IndexRight />} wrong={<IndexWrong />} />;
}

/** Wrong: Related List as the resource index. Right: page header Create + QueryList. */
export function ListSurfacesRelatedListAsIndexDemo() {
  return (
    <QueryClientProvider client={queryClient}>
      <ListSurfacesRelatedListAsIndexDemoInner />
    </QueryClientProvider>
  );
}
<PageContainer title="Orders" extra={<Button>Create</Button>}>
  <QueryList view="list" rowLayout={{ title: "orderNumber", description: "notes" }}  />
</PageContainer>

Bare Heading Plus Stacked Tools

Problem: a hand-rolled h2, Add/Refresh stacked in a corner, then a naked List. That is a broken region, not a custom layout.

Do: Related List owns title, count, and one toolbar row.

Wrong

Members

  • Ada Lovelace
    ada@example.com
    Owner
  • Grace Hopper
    grace@example.com
    Member

Right

Members

2
  • Ada Lovelace
    ada@example.com
    Owner
  • Grace Hopper
    grace@example.com
    Member
"use client";

import { Trash2Icon } from "lucide-react";

import { List } from "@/components/f-ui/list/list";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Button } from "@/components/ui/button";
import { IconGhostButton } from "@/demos/_shared/icon-ghost-button";
import { DesignCompare } from "@/demos/_design/design-compare";

function MemberRows() {
  return (
    <List>
      <List.Item
        actions={[
          <IconGhostButton key="rm" label="Remove Ada">
            <Trash2Icon className="size-4" />
          </IconGhostButton>,
        ]}
        extra="Owner"
      >
        <List.Meta description="ada@example.com" title="Ada Lovelace" />
      </List.Item>
      <List.Item
        extra="Member"
        actions={[
          <IconGhostButton key="rm" label="Remove Grace">
            <Trash2Icon className="size-4" />
          </IconGhostButton>,
        ]}
      >
        <List.Meta description="grace@example.com" title="Grace Hopper" />
      </List.Item>
    </List>
  );
}

/** Wrong: hand-rolled heading + stacked Add. Right: Related List owns title, count, toolbar. */
export function ListSurfacesBareListRegionDemo() {
  return (
    <DesignCompare
      right={
        <RelatedList
          actions={<Button type="button">Add members</Button>}
          count={2}
          title="Members"
        >
          <MemberRows />
        </RelatedList>
      }
      wrong={
        <div className="space-y-3">
          <h2 className="text-base font-semibold">Members</h2>
          <div className="flex flex-col items-end gap-2">
            <Button type="button">Add members</Button>
            <Button type="button" variant="ghost">
              Refresh
            </Button>
          </div>
          <MemberRows />
        </div>
      }
    />
  );
}

Every Job

Object Page Members — No Batch Bar

Live Members region: count, primary Add, ghost Remove.

Members

2
  • Ada Lovelace
    ada@example.com
    Owner
  • Grace Hopper
    grace@example.com
    Member
"use client";

import { Trash2Icon } from "lucide-react";

import { List } from "@/components/f-ui/list/list";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Button } from "@/components/ui/button";
import { IconGhostButton } from "@/demos/_shared/icon-ghost-button";

export function RelatedListMembersDemo() {
  return (
    <RelatedList
      title="Members"
      count={2}
      actions={
        <Button type="button" variant="default">
          Add members
        </Button>
      }
    >
      <List>
        <List.Item
          extra="Owner"
          actions={[
            <IconGhostButton key="rm" label="Remove">
              <Trash2Icon className="size-4" />
            </IconGhostButton>,
          ]}
        >
          <List.Meta title="Ada Lovelace" description="ada@example.com" />
        </List.Item>
        <List.Item
          extra="Member"
          actions={[
            <IconGhostButton key="rm" label="Remove">
              <Trash2Icon className="size-4" />
            </IconGhostButton>,
          ]}
        >
          <List.Meta title="Grace Hopper" description="grace@example.com" />
        </List.Item>
      </List>
    </RelatedList>
  );
}

Assigned Keys — Draft Remove, Then Save

Closed catalogs (a dozen assignable keys) do not need a batch bar. Remove stages a Formily draft; Save is the write. Add uses a picker modal with checkboxes.

Assigned capabilities

3
  • Orders

    • Read orders
      View the orders index and detail.
    • Write orders
      Create and edit orders.
  • Files

    • Read files
      Download attachments.
"use client";

import { useMemo } from "react";
import { observer } from "@formily/react";
import { Trash2Icon } from "lucide-react";

import { Form } from "@/components/f-ui/formily/form";
import { FormActions } from "@/components/f-ui/formily/form-actions";
import { createForm } from "@/components/f-ui/formily/internals/create-form";
import { List } from "@/components/f-ui/list/list";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Button } from "@/components/ui/button";
import { IconGhostButton } from "@/demos/_shared/icon-ghost-button";

type Capability = { key: string; group: string; title: string; description: string };

const CATALOG: Capability[] = [
  { key: "orders.read", group: "Orders", title: "Read orders", description: "View the orders index and detail." },
  { key: "orders.write", group: "Orders", title: "Write orders", description: "Create and edit orders." },
  { key: "files.read", group: "Files", title: "Read files", description: "Download attachments." },
];

type FormValues = { keys: string[] };

/** Assigned keys on an object page: row Remove stages a draft; Save is the write. No checkboxes. */
export const RelatedListDraftRemoveDemo = observer(function RelatedListDraftRemoveDemo() {
  const form = useMemo(
    () => createForm<FormValues>({ values: { keys: CATALOG.map((item) => item.key) } }),
    [],
  );

  const assigned = CATALOG.filter((item) => form.values.keys.includes(item.key));
  const groups = [...new Set(assigned.map((item) => item.group))];

  return (
    <RelatedList count={assigned.length} title="Assigned capabilities">
      <Form
        form={form}
        onSubmit={async () => undefined}
      >
        <List>
          {groups.map((group) => (
            <List.Group heading={group} key={group}>
              {assigned
                .filter((item) => item.group === group)
                .map((item) => (
                  <List.Item
                    actions={[
                      <IconGhostButton
                        key="rm"
                        label={`Remove ${item.title}`}
                        onClick={() => {
                          form.setValues({
                            keys: form.values.keys.filter((key) => key !== item.key),
                          });
                        }}
                      >
                        <Trash2Icon className="size-4" />
                      </IconGhostButton>,
                    ]}
                    key={item.key}
                  >
                    <List.Meta description={item.description} title={item.title} />
                  </List.Item>
                ))}
            </List.Group>
          ))}
        </List>
        <FormActions align="end" className="pt-3" offset={false}>
          <Button type="submit">Save</Button>
        </FormActions>
      </Form>
    </RelatedList>
  );
});

Picker Dialog — Composed Checkbox

When the list is a picker, put a Checkbox in List.Item leading. That is host state, not rowSelection.

  • Ada Lovelace
    ada@example.com
  • Grace Hopper
    grace@example.com
  • Alan Turing
    alan@example.com
"use client";

import { useState } from "react";

import { List } from "@/components/f-ui/list/list";
import { Checkbox } from "@/components/ui/checkbox";

const PEOPLE = [
  { id: "ada", name: "Ada Lovelace", email: "ada@example.com" },
  { id: "grace", name: "Grace Hopper", email: "grace@example.com" },
  { id: "alan", name: "Alan Turing", email: "alan@example.com" },
];

/** Dialog / Transfer-style picker: host Checkbox in `leading`. Not a batch bar. */
export function ListLeadingPickerDemo() {
  const [picked, setPicked] = useState<string[]>(["ada"]);

  return (
    <List>
      {PEOPLE.map((person) => {
        const checked = picked.includes(person.id);
        return (
          <List.Item
            key={person.id}
            leading={
              <Checkbox
                aria-label={`Select ${person.name}`}
                checked={checked}
                onCheckedChange={(value) => {
                  const on = value === true;
                  setPicked((current) =>
                    on
                      ? [...current, person.id]
                      : current.filter((id) => id !== person.id),
                  );
                }}
              />
            }
          >
            <List.Meta description={person.email} title={person.name} />
          </List.Item>
        );
      })}
    </List>
  );
}

Attachments — Table Body

Columnar children use kit Table, not a hand-rolled <table>. Default: no selection.

Attachments

2
quote.pdf240 KB
packing-list.xlsx18 KB
"use client";

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

import { defineDataListSchema } from "@/components/f-ui/data-list-internals/schema/define-data-list-schema";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Table } from "@/components/f-ui/table/table";
import { f } from "@/components/f-ui/field-types/catalog";
import { Button } from "@/components/ui/button";

interface AttachmentRow {
  id: string;
  name: string;
  size: string;
}

const ATTACHMENTS: AttachmentRow[] = [
  { id: "1", name: "quote.pdf", size: "240 KB" },
  { id: "2", name: "packing-list.xlsx", size: "18 KB" },
];

const attachmentSchema = defineDataListSchema<AttachmentRow>({
  name: f.text({ label: "Name" }),
  size: f.text({ label: "Size" }),
});

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false } },
});

function RelatedListTableDemoInner() {
  return (
    <RelatedList
      title="Attachments"
      count={2}
      actions={
        <Button type="button" variant="default">
          Add attachment
        </Button>
      }
    >
      <Table
        schema={attachmentSchema}
        data={ATTACHMENTS}
        listCode="related-list-attachments-demo"
        getRowId={(row) => row.id}
        defaultPageSize="all"
      />
    </RelatedList>
  );
}

export function RelatedListTableDemo() {
  return (
    <QueryClientProvider client={queryClient}>
      <RelatedListTableDemoInner />
    </QueryClientProvider>
  );
}

Attachments — Mass Ops On The Child Table

When this child does need mass download / delete, put checkboxes on Table, and the mass action on the Related List toolbar (ghost/outline). Add stays the solid primary. Do not put that machine on List.

Attachments

3
quote.pdf240 KB
packing-list.xlsx18 KB
label.pdf18 KB
"use client";

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 { useDataList } from "@/components/f-ui/data-list-internals/use-data-list";
import { f } from "@/components/f-ui/field-types/catalog";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Table } from "@/components/f-ui/table/table";
import { Button } from "@/components/ui/button";

interface AttachmentRow {
  id: string;
  name: string;
  size: string;
}

const ATTACHMENTS: AttachmentRow[] = [
  { id: "1", name: "quote.pdf", size: "240 KB" },
  { id: "2", name: "packing-list.xlsx", size: "18 KB" },
  { id: "3", name: "label.pdf", size: "18 KB" },
];

const attachmentSchema = defineDataListSchema<AttachmentRow>({
  name: f.text({ label: "Name" }),
  size: f.text({ label: "Size" }),
});

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false } },
});

function RelatedListTableSelectionDemoInner() {
  const handle = useDataList({
    schema: attachmentSchema,
    listCode: "related-list-attachments-select-demo",
    data: ATTACHMENTS,
    getRowId: (row) => row.id,
    defaultPageSize: "all",
    features: { selection: true },
  });

  const selectedCount = handle.selection.count;

  return (
    <RelatedList
      actions={<Button type="button">Add attachment</Button>}
      count={ATTACHMENTS.length}
      title="Attachments"
      tools={
        <Button
          disabled={selectedCount === 0}
          type="button"
          variant="outline"
          onClick={() => {
            toast.success(`Download ${selectedCount}`);
            handle.selection.clear();
          }}
        >
          Download selected
        </Button>
      }
    >
      <Table dataList={handle} />
    </RelatedList>
  );
}

/** Mass ops on a columnar child: Table checkboxes + toolbar action. Not List checkboxes. */
export function RelatedListTableSelectionDemo() {
  return (
    <QueryClientProvider client={queryClient}>
      <RelatedListTableSelectionDemoInner />
    </QueryClientProvider>
  );
}

Region Loading, Empty, And Error

Switch Demo status. The toolbar (including Add) stays mounted; Empty has no second Add; Error shows Retry. Do not leave a blank ul.

Members

2
  • Ada Lovelace
    ada@example.com
  • Grace Hopper
    grace@example.com
"use client";

import { useState } from "react";

import { List } from "@/components/f-ui/list/list";
import {
  RelatedList,
  type RelatedListStatus,
} from "@/components/f-ui/related-list/related-list";
import { Button } from "@/components/ui/button";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";

const STATUS_OPTIONS: { value: RelatedListStatus; label: string }[] = [
  { value: "populated", label: "Populated" },
  { value: "loading", label: "Loading" },
  { value: "empty", label: "Empty" },
  { value: "error", label: "Error" },
];

export function RelatedListStatusDemo() {
  const [status, setStatus] = useState<RelatedListStatus>("populated");

  return (
    <RelatedList
      title="Members"
      count={status === "populated" ? 2 : 0}
      status={status}
      emptyTitle="No members yet."
      emptyDescription="Add people who should have this role."
      errorTitle="Couldn't load this list"
      errorDescription="Check your connection and try again."
      onRetry={() => setStatus("populated")}
      tools={
        <label className="flex items-center gap-2 text-sm text-muted-foreground">
          <span className="whitespace-nowrap">Demo status</span>
          <Select
            value={status}
            onValueChange={(value) => setStatus(value as RelatedListStatus)}
          >
            <SelectTrigger aria-label="Demo status" className="w-32">
              <SelectValue />
            </SelectTrigger>
            <SelectContent>
              {STATUS_OPTIONS.map((opt) => (
                <SelectItem key={opt.value} value={opt.value}>
                  {opt.label}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </label>
      }
      actions={
        <Button type="button" variant="default">
          Add members
        </Button>
      }
    >
      <List>
        <List.Item>
          <List.Meta title="Ada Lovelace" description="ada@example.com" />
        </List.Item>
        <List.Item>
          <List.Meta title="Grace Hopper" description="grace@example.com" />
        </List.Item>
      </List>
    </RelatedList>
  );
}

Resource Index — QueryList List View With Batch

Same handle as Table: selection, batch, row actions. No column manager. This is the only list-shaped surface that ships a batch bar.

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>
  );
}
<QueryList
  view="list"
  rowLayout={{ title: "orderNumber", description: "notes", extra: ["amount"] }}
  recipe={{ filtering: false, selection: true, paginationMode: "offset" }}
  rowActions={[{ id: "edit", label: "Edit", onClick: (row) => { /* … */ } }]}
  batchActions={[{ id: "export", label: "Export", onClick: ({ clear }) => clear() }]}
  schema={schema}
  listCode="orders"
  adapter={adapter}
  params={params}
  onParamsChange={setParams}
  getRowId={(row) => row.id}
/>

Resource Index — QueryList List View Without Batch

view="list" does not turn checkboxes on. Omit recipe.selection (and batchActions) when the job is browse + identity Open only — put Open on f.link, do not pass rowActions.

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>
  );
}

Headless compose uses ListView inside DataListShell — same painter QueryList uses. Showcase: /showcases/orders-list.

Row paint has three rungs, all on QueryList view="list": field-key rowLayout, slot functions, or renderItem returning a List.Item (wrap defaultItem or replace it). Same element presentational List renderItem already returns; QueryList adds the third defaultItem argument and clones measurement chrome.

A height-constrained list body with pagination.mode: "cursor" loads on scroll: ListView virtualizes its rows and owns the sentinel. Without a height cap it stays the button DataListLoadMore recipe. Details: Query List — Infinite Scroll.

Decision Tree

Is this the resource index (the page is the collection)?
  yes → QueryList
         need columns / sort / compare? → view="table" (default)
         row-first title + description? → view="list" + rowLayout
         need mass ops? → recipe.selection + batchActions (opt-in, either view)
  no  → Is this an Object Page region (title + count + Add)?
         yes → Related List
                identity / grouped keys? → List body (row actions, no kit batch)
                columnar files / amounts? → Table body
                need mass update on this child? → Table selection + toolbar action,
                  or View All → QueryList
                loading / empty / error? → RelatedList status (do not blank the body)
         no  → dialog / card / picker?
                yes → List (optional leading Checkbox; no kit batch footer)
                write grid of lines? → Editable Table

On this page