{
  "name": "ListDetail",
  "summary": "A list of records beside the one selected — people, tickets, messages, files. Arrow down the list and the detail beside it follows, without anything to open or close; the selection is in the address.",
  "useWhen": [
    "the user works down a list reading each item in turn — members, an inbox, a queue of tickets — and wants the next without going back",
    "each record is read or lightly acted on in its detail, not a task that must be finished before anything else",
    "a row has a name and a line or two, not columns to compare down"
  ],
  "useInstead": [
    {
      "when": "the rows have columns compared down — status, owner, duration side by side",
      "use": "the DataTable pattern, whose SidePanel opens from a per-row Details button",
      "why": "a Table's rows hold links and buttons of their own, so its selection cannot follow focus; the detail opens on a press there, and stays open while the next is opened"
    },
    {
      "when": "the record is the whole of what the user came for — minutes in it, sections, tabs, a history",
      "use": "a page of its own, linked from the list",
      "why": "a side view of a third of the width is a preview; a record worked in needs the whole page and its own address"
    },
    {
      "when": "the detail is a form that must be saved or cancelled",
      "use": "Drawer or Modal",
      "why": "a detail that changes as focus moves would throw away what was typed the moment the user arrows to the next row"
    }
  ],
  "composes": [
    "PageHeader",
    "SearchField",
    "List",
    "SidePanelLayout",
    "SidePanel",
    "Avatar",
    "Badge",
    "DescriptionList",
    "DescriptionListItem",
    "CopyButton",
    "Link",
    "Button",
    "Skeleton",
    "Alert",
    "EmptyState",
    "StatusIndicator"
  ],
  "structure": [
    "A PageHeader naming the collection — \"Members\" — then, over the list only, a SearchField (size sm, an `aria-label` naming what it searches) when the list is long enough to need one.",
    "A SidePanelLayout: the List first, the SidePanel second — reading order is DOM order, and the detail is the detail of the list before it.",
    "Each row: `label` the record's name, `description` the line that tells two alike apart (an email, a subject), `leading` an Avatar or Icon (hidden from assistive technology, so it repeats nothing the label lacks), `meta` a Badge for one fact worth scanning for (\"Admin\"). Nothing interactive in the row: the row is the control.",
    "At 768px and wider, `selectionMode=\"single\"`: the selection follows focus, so tabbing in selects the first row and each ↓ selects the next. The SidePanel is open while a row is selected, with `autoFocus={false}` — it opened because of where focus already is, and focus stays on the row. Tab goes on into the panel, next in the DOM; Escape there closes it and returns focus to the row. Enter or a double click (`onAction`) moves focus to the panel's title, for the user who wants to read on.",
    "Below 768px, where the SidePanel opens as a modal Drawer, no selection mode: a row opens on a press (`onAction`), never on arrival, or tabbing into the list would open a dialog. Closing the Drawer returns focus to the row.",
    "The selection is in the address — `#/members/usr_ada`, `/members/usr_ada` — so a link, a reload and a shared URL open the same record. Replace the history entry as the selection moves, never push: with a push, every row arrowed past is a Back press, and Back no longer leaves the page. The list and the address are one page: moving the selection must not mount the page again.",
    "The panel's title is the record's name and its description one fact about it (\"Workspace admin\"). What the list already fetched shows at once — a DescriptionList, IDs and addresses with an icon-only CopyButton named for what it copies (\"Copy email\"). Anything that needs its own request loads inside the panel, below them.",
    "A selected row the search hides: the panel closes with it, and opens again when the row comes back. Detail of a row the user can no longer see is a lie about the list.",
    "An address whose ID matches nothing: the list as usual, and the panel open with an EmptyState — `reason=\"no-results\"`, `size=\"panel\"` — saying so (\"There is no member with this ID\") and a Button that clears the selection. Not a full-page 404: the page exists; one record in it does not."
  ],
  "states": {
    "loading": {
      "must": "The list: Skeleton rows in its place, `aria-busy` on their container and a status saying what is loading. The detail's own request: a Skeleton inside the panel, under the facts the list already had — never the whole panel blanked.",
      "why": "blanking the panel on each ↓ while the next record loads makes arrowing down the list a flicker, and hides what was already known",
      "a11y": "the panel's loading is a status inside it, not a live announcement per row — the row's name is already being read as the selection moves"
    },
    "empty": {
      "must": "No records: an EmptyState in the list's place, `size=\"page\"`, and no panel. A search that matches nothing: the List's `renderEmptyState` saying what was searched for (\"No one matches “zzz”.\"), the SearchField still there to change it.",
      "why": "an empty grid is announced as \"0 rows\" and nothing else; the user must be told whether the collection is empty or their search is"
    },
    "error": {
      "must": "The list failing is the page failing: the FullPageError pattern. The detail's own request failing: an Alert inside the panel naming what is missing, with Try again, while the list and the facts it had stay.",
      "why": "one record's missing part is not a reason to take away the list, or the rest of the record",
      "a11y": "the panel's Alert takes `role=\"alert\"` from its intent — do not add your own"
    }
  },
  "propsUsed": {
    "PageHeader": [
      "title",
      "titleId",
      "description"
    ],
    "SearchField": [
      "size",
      "value",
      "onChange",
      "placeholder"
    ],
    "List": [
      "items",
      "selectionMode",
      "selectedKeys",
      "onSelectionChange",
      "onAction",
      "renderEmptyState"
    ],
    "SidePanel": [
      "id",
      "size",
      "autoFocus",
      "isOpen",
      "onOpenChange",
      "title",
      "description"
    ],
    "Avatar": [
      "size",
      "initials"
    ],
    "Badge": [
      "size"
    ],
    "DescriptionList": [
      "layout"
    ],
    "DescriptionListItem": [
      "term"
    ],
    "CopyButton": [
      "value",
      "isIconOnly",
      "size",
      "label",
      "copiedLabel"
    ],
    "EmptyState": [
      "reason",
      "size",
      "headingLevel",
      "title",
      "description",
      "action"
    ],
    "Alert": [
      "intent",
      "title",
      "actions"
    ],
    "Skeleton": [
      "variant",
      "lines"
    ]
  },
  "variantsUsed": {
    "List": {
      "selectionMode": [
        "single",
        "none"
      ]
    },
    "SidePanel": {
      "size": [
        "sm",
        "md"
      ]
    },
    "EmptyState": {
      "reason": [
        "first-run",
        "no-results"
      ],
      "size": [
        "page",
        "panel"
      ]
    },
    "Alert": {
      "intent": [
        "error"
      ]
    }
  },
  "a11y": {
    "requires": [
      "the List's `aria-label` or `aria-labelledby`, naming the collection",
      "`autoFocus={false}` on the SidePanel beside a single-selection List: with it on, tabbing into the list selects the first row, opens the panel and takes focus out of the list before a key has been pressed",
      "rows that open on a press, not on focus, where the panel is a Drawer: a modal dialog must not open because focus arrived somewhere"
    ],
    "notes": [
      "Selection following focus is the listbox convention for a selection that is not disruptive, and a panel updating beside the list is not: focus stays put, and the row's name — read as it is selected — says which record the panel shows.",
      "The panel is a named region, so a screen reader user can reach it by landmark as well as by Tab."
    ]
  },
  "antiPatterns": [
    {
      "dont": "a SidePanel that takes focus on open beside a List whose selection follows focus",
      "do": "`autoFocus={false}`",
      "why": "Tab into the list selects the first row, the panel opens and takes focus, and the list can never be walked"
    },
    {
      "dont": "pushing a history entry for each selection",
      "do": "replace the entry",
      "why": "arrowing down ten rows makes Back take ten presses to leave the page"
    },
    {
      "dont": "a panel that opens as soon as a row is focused, on a phone",
      "do": "no selection mode below 768px, and `onAction` opening the row",
      "why": "there the panel is a modal Drawer: arriving in the list would open a dialog and trap the user in it"
    },
    {
      "dont": "a full-page 404 for an ID that matches no record in a list that loaded",
      "do": "the list, and the panel saying there is no such record",
      "why": "the page is there; one record is not, and the rest of the list is the way forward"
    },
    {
      "dont": "buttons or links inside the rows",
      "do": "the actions in the panel",
      "why": "a row that is also a selection target and holds its own controls cannot follow focus: → into a row's button would change nothing the panel shows, and the user loses track of which is which"
    }
  ],
  "example": {
    "file": "dist/meta/patterns/examples/ListDetail.tsx",
    "source": "/**\n * The ListDetail pattern, worked: a workspace's members in a List, the one\n * selected in a SidePanel beside it, and the selection in the address. Arrow\n * down the list and the detail follows; nothing opens or closes.\n *\n * It is complete as it stands: `ionbase-ui` and React are its only imports,\n * and the build type-checks it against the package's published types. The\n * address is the hash (`#/members/usr_ada`) so the example needs no router;\n * with one, `readSelected` and `writeSelected` are the router's. The data\n * comes in through `loadMembers` and `loadOwnedAgents`.\n */\nimport { useEffect, useState } from 'react';\nimport {\n  Alert,\n  Avatar,\n  Badge,\n  Button,\n  CopyButton,\n  DescriptionList,\n  DescriptionListItem,\n  EmptyState,\n  List,\n  PageHeader,\n  SearchField,\n  SidePanel,\n  SidePanelLayout,\n  Skeleton,\n  Stack,\n  type ListItem,\n} from 'ionbase-ui';\n\nexport interface Member {\n  id: string;\n  name: string;\n  initials: string;\n  email: string;\n  role: 'admin' | 'member';\n  team: string;\n}\n\nexport interface OwnedAgent {\n  id: string;\n  name: string;\n}\n\ntype Load<T> =\n  | { status: 'loading' }\n  | { status: 'error'; error: Error }\n  | { status: 'ready'; data: T };\n\n/** One request with its three states; a newer one aborts the older. */\nfunction useLoad<T>(\n  load: (signal: AbortSignal) => Promise<T>,\n  key: string,\n): Load<T> & { retry: () => void } {\n  const [attempt, setAttempt] = useState(0);\n  const [state, setState] = useState<Load<T>>({ status: 'loading' });\n  useEffect(() => {\n    const controller = new AbortController();\n    setState({ status: 'loading' });\n    load(controller.signal).then(\n      (data) => setState({ status: 'ready', data }),\n      (error: unknown) => {\n        if (controller.signal.aborted) return;\n        setState({\n          status: 'error',\n          error: error instanceof Error ? error : new Error(String(error)),\n        });\n      },\n    );\n    return () => controller.abort();\n    // `key` is what the request depends on; `load` is new every render.\n  }, [key, attempt]);\n  return { ...state, retry: () => setAttempt((n) => n + 1) };\n}\n\n/* ----------------------------------------------------- the address, a width */\n\nconst ROUTE = '#/members';\n\nfunction readSelected(): string | null {\n  const hash = window.location.hash;\n  const id = hash.startsWith(`${ROUTE}/`) ? hash.slice(ROUTE.length + 1) : '';\n  return id ? decodeURIComponent(id) : null;\n}\n\n/**\n * The selection in the address, so a link, a reload and a shared URL open the\n * same record. Replaced, never pushed: the selection moves with every arrow\n * key, and an entry per row would make Back walk the list instead of leaving.\n */\nfunction useSelectedInAddress() {\n  const [selected, setSelected] = useState(readSelected);\n  useEffect(() => {\n    const onBack = () => setSelected(readSelected());\n    window.addEventListener('popstate', onBack);\n    return () => window.removeEventListener('popstate', onBack);\n  }, []);\n  const write = (id: string | null) => {\n    window.history.replaceState(\n      null,\n      '',\n      id ? `${ROUTE}/${encodeURIComponent(id)}` : ROUTE,\n    );\n    setSelected(id);\n  };\n  return [selected, write] as const;\n}\n\n/** SidePanel's own breakpoint: below it, the panel opens as a modal Drawer. */\nfunction useNarrow() {\n  const query = '(width < 768px)';\n  const [narrow, setNarrow] = useState(() => window.matchMedia(query).matches);\n  useEffect(() => {\n    const media = window.matchMedia(query);\n    const on = () => setNarrow(media.matches);\n    media.addEventListener('change', on);\n    return () => media.removeEventListener('change', on);\n  }, []);\n  return narrow;\n}\n\n/* ---------------------------------------------------------------- the page */\n\nexport function MembersDirectory({\n  loadMembers,\n  loadOwnedAgents,\n}: {\n  loadMembers: (signal: AbortSignal) => Promise<Member[]>;\n  loadOwnedAgents: (\n    memberId: string,\n    signal: AbortSignal,\n  ) => Promise<OwnedAgent[]>;\n}) {\n  const members = useLoad(loadMembers, 'members');\n\n  // The list is the page: when it cannot load, the page has nothing to show.\n  // This is the FullPageError pattern, inside the shell.\n  if (members.status === 'error')\n    return (\n      <Stack gap={24}>\n        <PageHeader title=\"Members\" />\n        <EmptyState\n          reason=\"error\"\n          size=\"page\"\n          headingLevel={2}\n          title=\"Members couldn’t load\"\n          description={`${members.error.message} Everyone keeps their access; only this list is missing.`}\n          action={\n            <Button variant=\"secondary\" onPress={members.retry}>\n              Try again\n            </Button>\n          }\n        />\n      </Stack>\n    );\n\n  return (\n    <Stack gap={24}>\n      <PageHeader\n        title=\"Members\"\n        description=\"Everyone in this workspace, and their role.\"\n      />\n\n      {members.status === 'loading' && (\n        <div aria-busy=\"true\">\n          <p className=\"ion-visually-hidden\" role=\"status\">\n            Loading members\n          </p>\n          <Stack gap={16}>\n            {[0, 1, 2, 3].map((i) => (\n              <Skeleton key={i} variant=\"text\" lines={2} />\n            ))}\n          </Stack>\n        </div>\n      )}\n\n      {/* No records is the page's empty state, and no panel. */}\n      {members.status === 'ready' && members.data.length === 0 && (\n        <EmptyState\n          reason=\"first-run\"\n          size=\"page\"\n          headingLevel={2}\n          title=\"No one else is here yet\"\n          description=\"People appear here once they accept an invitation.\"\n        />\n      )}\n\n      {members.status === 'ready' && members.data.length > 0 && (\n        <Directory members={members.data} loadOwnedAgents={loadOwnedAgents} />\n      )}\n    </Stack>\n  );\n}\n\nfunction Directory({\n  members,\n  loadOwnedAgents,\n}: {\n  members: Member[];\n  loadOwnedAgents: (\n    memberId: string,\n    signal: AbortSignal,\n  ) => Promise<OwnedAgent[]>;\n}) {\n  const narrow = useNarrow();\n  const [selectedId, select] = useSelectedInAddress();\n  const [search, setSearch] = useState('');\n  const q = search.trim().toLowerCase();\n  const shown = members.filter(\n    (m) => !q || m.name.toLowerCase().includes(q) || m.email.includes(q),\n  );\n\n  // An id that matches no one is said in the panel, not a 404: the page is\n  // there, one record is not. A row the search hides closes the panel, and\n  // opens it again when the row comes back.\n  const selected = selectedId\n    ? (members.find((m) => m.id === selectedId) ?? 'unknown')\n    : null;\n  const isOpen =\n    selected === 'unknown' || (selected !== null && shown.includes(selected));\n\n  // Nothing interactive in a row: the row is the control.\n  const items: ListItem[] = shown.map((m) => ({\n    id: m.id,\n    label: m.name,\n    description: m.email,\n    leading: <Avatar size=\"sm\" initials={m.initials} />,\n    meta: m.role === 'admin' && <Badge size=\"sm\">Admin</Badge>,\n  }));\n\n  return (\n    <Stack gap={16}>\n      <SearchField\n        size=\"sm\"\n        aria-label=\"Search members\"\n        placeholder=\"Search by name or email\"\n        value={search}\n        onChange={setSearch}\n      />\n      {/* The List first, then the panel: reading order is DOM order. */}\n      <SidePanelLayout>\n        <List\n          aria-label=\"Members\"\n          items={items}\n          // Wide: the selection follows focus and the panel follows the\n          // selection. Narrow: the panel is a modal Drawer, so a row opens it\n          // on a press, never on arrival, or tabbing in would open a dialog.\n          {...(narrow\n            ? { onAction: select }\n            : {\n                selectionMode: 'single' as const,\n                selectedKeys:\n                  selected && selected !== 'unknown' ? [selected.id] : [],\n                onSelectionChange: (keys: Set<string>) =>\n                  select([...keys][0] ?? null),\n                // Enter or a double click goes on into the detail.\n                onAction: () =>\n                  document\n                    .querySelector<HTMLElement>('#member-panel h2')\n                    ?.focus(),\n              })}\n          renderEmptyState={() => `No one matches “${search.trim()}”.`}\n        />\n        <SidePanel\n          id=\"member-panel\"\n          size=\"sm\"\n          // It opened because of where focus already is; focus stays on the row.\n          autoFocus={false}\n          isOpen={isOpen}\n          onOpenChange={(open) => !open && select(null)}\n          title={\n            selected === 'unknown' ? 'Member not found' : (selected?.name ?? '')\n          }\n          description={\n            selected && selected !== 'unknown'\n              ? selected.role === 'admin'\n                ? 'Workspace admin'\n                : 'Member'\n              : undefined\n          }\n        >\n          {selected === 'unknown' ? (\n            <EmptyState\n              reason=\"no-results\"\n              size=\"panel\"\n              headingLevel={3}\n              title=\"There is no member with this ID\"\n              description=\"They may have left the workspace, or the link is wrong.\"\n              action={\n                <Button\n                  size=\"sm\"\n                  variant=\"secondary\"\n                  onPress={() => select(null)}\n                >\n                  Show everyone\n                </Button>\n              }\n            />\n          ) : (\n            selected && (\n              <MemberDetail\n                key={selected.id}\n                member={selected}\n                loadOwnedAgents={loadOwnedAgents}\n              />\n            )\n          )}\n        </SidePanel>\n      </SidePanelLayout>\n    </Stack>\n  );\n}\n\nfunction MemberDetail({\n  member,\n  loadOwnedAgents,\n}: {\n  member: Member;\n  loadOwnedAgents: (\n    memberId: string,\n    signal: AbortSignal,\n  ) => Promise<OwnedAgent[]>;\n}) {\n  const agents = useLoad(\n    (signal) => loadOwnedAgents(member.id, signal),\n    member.id,\n  );\n\n  return (\n    <Stack gap={24}>\n      {/* What the list already had shows at once; only the agents wait. */}\n      <DescriptionList layout=\"stacked\">\n        <DescriptionListItem term=\"Email\">\n          <Stack direction=\"row\" align=\"center\" gap={4}>\n            <span>{member.email}</span>\n            <CopyButton\n              value={member.email}\n              isIconOnly\n              size=\"sm\"\n              label=\"Copy email\"\n              copiedLabel=\"Email copied\"\n            />\n          </Stack>\n        </DescriptionListItem>\n        <DescriptionListItem term=\"Team\">{member.team}</DescriptionListItem>\n      </DescriptionList>\n\n      <section aria-labelledby=\"owned-agents\">\n        <Stack gap={8}>\n          <h3 id=\"owned-agents\" className=\"ion-text-body-sm ion-text--semibold\">\n            Agents they own\n          </h3>\n          {/* Its own request: a Skeleton under the facts, never the panel blanked. */}\n          {agents.status === 'loading' && (\n            <div aria-busy=\"true\">\n              <span className=\"ion-visually-hidden\" role=\"status\">\n                Loading agents\n              </span>\n              <Skeleton variant=\"text\" lines={2} />\n            </div>\n          )}\n          {/* One record's missing part: the list and the rest of it stay. */}\n          {agents.status === 'error' && (\n            <Alert\n              intent=\"error\"\n              title=\"Their agents couldn’t load\"\n              actions={\n                <Button size=\"sm\" variant=\"secondary\" onPress={agents.retry}>\n                  Try again\n                </Button>\n              }\n            >\n              {agents.error.message} The rest of their details are up to date.\n            </Alert>\n          )}\n          {agents.status === 'ready' &&\n            (agents.data.length === 0 ? (\n              <p className=\"ion-text-body-sm\">Owns no agents.</p>\n            ) : (\n              <Stack as=\"ul\" gap={4}>\n                {agents.data.map((a) => (\n                  <li key={a.id}>{a.name}</li>\n                ))}\n              </Stack>\n            ))}\n        </Stack>\n      </section>\n    </Stack>\n  );\n}\n"
  }
}
