f-ui
Components

Related List

Object Page region card for a loose child collection with title, count, toolbar, and Loading/Empty/Error body.

Related List is the Salesforce-class Object Page region: one card with a title + count toolbar and a body slot for List or Table. It owns the region Loading / Empty / Error triad so hosts do not invent a second card chrome. Use Modal Form (or similar) in actions for Add — not a second Add on Empty. Job matrix (Members vs assigned keys vs attachments vs index): List Surfaces.

When To Use

  • Embed a loose child collection on an Object Page (Members, Attachments, Activity) with its own count and region Add.
  • Prefer this over hand-rolling a card + toolbar when the body is List or Table and the host owns fetch state.
  • Row ops: List.Item actions or Table rowActions. Checkboxes / batch belong on QueryList or a Related List Table body — not on presentational List.
  • Do not use Related List as the resource index — that is QueryList (view="table" or view="list") on a list page.
  • Do not use it for tight FormArray / line-item write tables — keep those as Table or Editable Table inside the form.
  • Do not wrap the body in QueryList; Related List is preview chrome, not a filtered list report.
BodyUse whenSelection / batch
ListMembers, activity, grouped assigned keysRow actions only. Add picker may use leading Checkbox. No kit batch bar
TableAttachments, amounts, sortable columnsOptional Table selection; mass ops here or View All → QueryList

Features

AreaBehavior
ToolbarOne row: title (h2) + optional count on the left; tools then actions on the right
Body slotHost passes List or Table as children when status="populated"
StatusHost-branch populated / loading / empty / error; toolbar stays mounted on every status
EmptyDefault Empty has no action — Add lives only in the toolbar
ErrorDefault Result status="error" with optional Retry via onRetry
PaddingSame card as Object Page DetailCard: p-6 + gap-4 around title and Table/List. No header divider. bodyPadding="none" bleeds the body to the card edges (escape hatch). Chrome: Object Page Cards

Installing

pnpm dlx shadcn@latest add https://ui.isaacfei.com/r/related-list.json
npx shadcn@latest add https://ui.isaacfei.com/r/related-list.json
yarn dlx shadcn@latest add https://ui.isaacfei.com/r/related-list.json
bun x shadcn@latest add https://ui.isaacfei.com/r/related-list.json

With a namespace: npx shadcn@latest add @f-ui/related-list.

registryDependencies: utils, https://ui.isaacfei.com/r/empty.json, https://ui.isaacfei.com/r/result.json, button, skeleton.

Usage

import { List } from "@/components/f-ui/list/list";
import { RelatedList } from "@/components/f-ui/related-list/related-list";
import { Button } from "@/components/ui/button";

<RelatedList
  title="Members"
  count={2}
  actions={<Button type="button">Add members</Button>}
>
  <List>
    <List.Item>
      <List.Meta title="Ada Lovelace" description="ada@example.com" />
    </List.Item>
  </List>
</RelatedList>

Examples

List Body

Members region with count, primary Add on the toolbar, and a presentational List of two people with 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>
  );
}

Table Body

Attachments-style region with kit Table. The card is the same padded shell as Notes / DetailCard — title row, then a gap, then the table. Chrome: Object Page Cards. Do not pass bodyPadding="none" (that bleeds the grid to the card border). Do not hand-roll an HTML <table>.

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

Table Body With Mass Ops

When this child collection needs mass download / delete, enable Table selection and put the mass action in tools (outline). Add stays the solid actions control. Do not put checkboxes on a List body for this job. Prefer View All → QueryList when the child is really a filtered index.

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

Toolbar Tools And Add

Ghost Refresh (or Columns) lives in tools on the same row as the title. Add lives in actions. Do not stack them in a corner. Playbook: CRUD Page Patterns — Region Table Toolbar.

Members

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

import { RefreshCwIcon } 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";

/** Ghost Refresh in `tools` shares the title baseline; Add stays the only solid control. */
export function RelatedListToolsDemo() {
  return (
    <RelatedList
      actions={<Button type="button">Add members</Button>}
      count={2}
      title="Members"
      tools={
        <IconGhostButton label="Refresh">
          <RefreshCwIcon className="size-4" />
        </IconGhostButton>
      }
    >
      <List>
        <List.Item extra="Owner">
          <List.Meta description="ada@example.com" title="Ada Lovelace" />
        </List.Item>
        <List.Item extra="Member">
          <List.Meta description="grace@example.com" title="Grace Hopper" />
        </List.Item>
      </List>
    </RelatedList>
  );
}

Assigned Keys Draft

Grouped capabilities: per-row Remove stages a Formily draft; Save writes the set. Do not put checkboxes on this list — the Add modal is the picker.

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

Loading, Empty, And Error

Switch Demo status in tools — the toolbar (including Add) stays mounted; Empty has no Add; Error shows Result with Retry. Give the card a height when you want Empty / Error centered in the remaining body; Loading skeleton stays top-aligned.

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

Composition

RelatedList
├─ toolbar (title + count | tools + actions)
└─ body
   ├─ populated → children (List | Table)
   ├─ loading → Skeleton (or loadingFallback)
   ├─ empty → Empty without action (or emptyFallback)
   └─ error → Result + Retry (or errorFallback)

Edge Cases And Errors

Do Not Wrap QueryList

Related List is Object Page preview chrome. Filters, pagination, and a batch footer belong on the resource index (QueryList). Full anti-pattern gallery: List Surfaces.

Empty Has No Second Add

Default Empty has no action. Region Add stays in the toolbar so operators always know where it lives. Do not pass emptyFallback that duplicates Add.

List Body Has No Kit Batch Bar

List.Item actions + region Add. Checkboxes on assigned Members / keys are wrong. A picker dialog may compose Checkbox into List leading.

status Does Not Fetch

You own the triad. Pass "loading" / "empty" / "error" from your query. The toolbar stays mounted on every status.

API Reference

Props

RelatedList

PropTypeDefaultDescription
titleReactNodeRegion heading (h2)
countReactNodeOptional count beside the title (left cluster)
toolsReactNodeSecondary toolbar controls (refresh, column manager, demo status)
actionsReactNodePrimary region actions (usually one solid Add)
status"populated" | "loading" | "empty" | "error""populated"Host-owned triad discriminant
emptyTitleReactNode"No data"Default Empty title
emptyDescriptionReactNodeDefault Empty description
errorTitleReactNode"Couldn't load this list"Default Result title
errorDescriptionReactNodeDefault Result subTitle
onRetry() => voidWhen set, shows outline Retry on default error Result
loadingFallbackReactNodeReplaces default skeleton bars
emptyFallbackReactNodeReplaces default Empty (do not put Add here)
errorFallbackReactNodeReplaces default Result
bodyPadding"default" | "none""default"Default shares card p-6. "none" bleeds the body to the card edges
idstringOptional section id
childrenReactNodeBody when status="populated"
classNamestringRoot section class

On this page