{
  "name": "DataTable",
  "summary": "A table of records with a toolbar, selection, and the three states everyone forgets.",
  "useWhen": [
    "showing a list of records the user filters, sorts, selects or acts on in bulk",
    "the row count is unknown at design time — which is what makes the empty and loading states mandatory rather than optional"
  ],
  "composes": [
    "Table",
    "TableHead",
    "TableBody",
    "TableRow",
    "TableCell",
    "Checkbox",
    "TableBatchBar",
    "Button",
    "Badge",
    "Alert",
    "SearchField",
    "MultiSelect",
    "Slider",
    "TagGroup",
    "Tag",
    "EmptyState",
    "Skeleton",
    "Pagination",
    "Menu",
    "MenuItem",
    "MenuTrigger",
    "SidePanel",
    "SidePanelLayout",
    "ContextMenu",
    "CopyButton",
    "StatusIndicator",
    "TruncatedText",
    "Timeline",
    "TimelineItem",
    "TableColumnsMenu",
    "TreeGrid"
  ],
  "structure": [
    "Where the query lives — in the address, so a reload, a link and Back keep the listing — and how a refilter keeps the rows on screen: the FilteredDataTable pattern.",
    "A toolbar above the table: a SearchField (size sm, with an aria-label naming what it searches) and filter controls — a MultiSelect with `hideTags` for any filter that can match more than one value, and a range Slider (`defaultValue={[low, high]}`, with `formatOptions` for the unit) for a quantity bounded at both ends — duration, cost — which applies on `onChangeEnd`, not per step. They stay ordinary tab stops, not a Toolbar — see Toolbar's contract.",
    "Under the toolbar, once any filter is applied: a TagGroup of the active filters, each removable on its own — one tag per value of a multi-value filter, one for a range (\"Duration: 30–120 sec\") that resets both thumbs when removed — beside a Clear all Button. When the last one goes the row goes with it, so move focus to the SearchField.",
    "Between the filters and the table, a TableBatchBar, always rendered: hidden while nothing is selected, and once anything is, the count, \"Select all N\" while fewer than all matching rows are selected, the bulk actions and \"Clear selection\". Hold the selection in `useTableSelection({ total })` and clear it whenever the filters, the sort or the page size change — but not on a page change, so rows ticked on two pages can be acted on together.",
    "Table, with an `id` passed to the bar as `tableId`, and a TableHead whose first cell carries the select-all Checkbox — `headSelection(pageKeys)`, which reaches the page on screen only — and a TableBody whose rows each carry `rowSelection(key, label)` and `isSelected(key)`.",
    "A long list that is not paged gets `maxHeight` on Table — the rows scroll inside it and the header stays at its top — so the filters above and the actions below stay in reach. A table wider than its space — on a phone, beside a SidePanel — gets `stickyFirstColumn`, so each row keeps its name in view as the rest scrolls sideways.",
    "Sortable columns: every header that sorts gets `sortDirection` — the current direction on the sorted column, `none` on the others — so each shows it can be sorted before anyone tries.",
    "A table with more columns than most people need at once gets a TableColumnsMenu at the toolbar's end, from `useTableColumns(columns)`: render the headers and cells of `visibleColumns` only, give the column that names each row `canHide: false`, and make resizable only the column whose content is long — a purpose, a description — with `canResize` and its limits. Keep the view someone chose — store `hidden` and `widths`, and pass them back as `initial` — so coming back does not undo it.",
    "A status column is a StatusIndicator in each cell — a shape, a colour and a word — not a Badge with a dot: a dot is the same shape for every status, and a column of chips competes with the data.",
    "Text of any length in a cell — a description under a row's name, anything users or agents write — is a TruncatedText on one line, not a `title` or a sliced string: cut, it is a tab stop with the whole text in a tooltip, and a screen reader reads all of it. The column shrinks to its share and the line is cut there, rather than widening the table.",
    "A row's detail, when it is short and read in place — a failed run's reason, a line item's breakdown — opens under the row: `expansion` on TableRow, with `{ label }` on the head row so the columns line up. Name each toggle for its row (\"Details for run 4821\"), not \"Expand\". Anything longer, or with its own actions, goes in a SidePanel instead.",
    "Records that nest — a total and the rows it is made of, each with the same columns, such as spend by agent and then by task — are a TreeGrid in place of the Table: `items` with their `children`, `columns` with a `cell(item)` each, the name first, figures `align: 'end'`, and `defaultExpandedKeys` open to the path that matters. It keeps the toolbar, the states and the region below; sort `items` yourself within each level. A detail is one record's own content and stays a TableRow's `expansion`.",
    "A row's detail, when it is read beside the list rather than on its own page, goes in a SidePanel: Table and SidePanel inside a SidePanelLayout, opened from a per-row Button with `aria-expanded`, and closed when its row is filtered out. The table stays usable, so the next row can be opened without closing anything; below 768px it opens as a Drawer. An ID in the detail is shown whole, with an icon-only CopyButton named for what it copies — \"Copy run ID\".",
    "A record's history — in its detail or on its own page — is a Timeline named \"History\", newest first: each change a TimelineItem saying what happened as a sentence (\"Paused the agent\"), who did it and when, with what it changed from and to as the detail. It is history, not a feed of the record's runs: an agent run's steps are AgentActivity.",
    "A row's actions, when it has more than one, in a ⋯ MenuTrigger in its last cell, named for the row — \"Actions for Invoice reconciler\". For a table people work in all day, wrap each row in a ContextMenu given the same Menu element, so a right-click or Shift+F10 opens those actions too — a shortcut, never the only way to them.",
    "One region below the table for pagination or a load-more Button.",
    "The table region is replaced — not overlaid — by the empty, loading and error states."
  ],
  "drivenBy": {
    "hook": "useTableSort",
    "note": "It holds which column the rows are sorted by and which way, and `sortProps(column)` gives each sortable header TableCell its `sortDirection` and `onSort`. Choosing a new column sorts it in its `firstDirection` (ascending unless set — pass `descending` for dates and amounts); choosing it again reverses it. It does not reorder rows: sort them yourself from `sort`, or send it to the server when the table is paged. Only the sorted column gets `aria-sort`."
  },
  "states": {
    "loading": {
      "must": "Render the table's header row and a fixed number of skeleton rows, keeping the column widths.",
      "why": "swapping the whole region for a spinner collapses the layout and moves every control on the page when the data lands",
      "a11y": "put `aria-busy=\"true\"` on the container and announce the result count in a live region once it resolves"
    },
    "empty": {
      "must": "Say which of the two empties this is: no records exist yet, or the current filters match nothing. Offer the action that resolves it — create the first record, or clear the filters.",
      "why": "\"No results\" with no way forward is the single most common dead end in enterprise SaaS, and the two cases need opposite actions",
      "a11y": "the empty state carries the heading; do not leave the region unnamed"
    },
    "error": {
      "must": "Keep the toolbar usable, show an `Alert` with `intent=\"error\"` naming what failed, and offer a retry.",
      "why": "replacing the whole screen with an error throws away the user's filters and selection, so retrying means rebuilding the query by hand",
      "a11y": "Alert picks `role=\"alert\"` from the intent — do not add your own"
    },
    "partial": {
      "must": "When some rows load and others fail, show the rows you have and an Alert above them for the rest.",
      "why": "hiding successful rows because a sibling request failed is data loss the user cannot see"
    }
  },
  "propsUsed": {
    "Table": [
      "density",
      "isStriped",
      "maxHeight",
      "stickyFirstColumn"
    ],
    "TableRow": [
      "selection",
      "isSelected",
      "expansion"
    ],
    "TableCell": [
      "scope",
      "align",
      "variant",
      "sortDirection",
      "onSort",
      "resize"
    ],
    "Checkbox": [
      "isIndeterminate",
      "isDisabled"
    ],
    "TableBatchBar": [
      "count",
      "total",
      "isAllMatching",
      "onSelectAll",
      "onClear",
      "tableId",
      "labels"
    ],
    "Button": [
      "variant",
      "size"
    ],
    "Alert": [
      "intent",
      "title",
      "actions"
    ],
    "TagGroup": [
      "label",
      "items",
      "onRemove"
    ],
    "SearchField": [
      "size",
      "value",
      "onChange",
      "placeholder",
      "aria-label"
    ],
    "MultiSelect": [
      "size",
      "aria-label",
      "options",
      "value",
      "onChange",
      "hideTags",
      "placeholder"
    ],
    "Slider": [
      "label",
      "value",
      "onChange",
      "onChangeEnd",
      "minValue",
      "maxValue",
      "step",
      "formatOptions"
    ],
    "SidePanel": [
      "isOpen",
      "onOpenChange",
      "title",
      "description",
      "footer",
      "size",
      "headingLevel"
    ],
    "ContextMenu": [
      "aria-label",
      "menu"
    ],
    "CopyButton": [
      "value",
      "isIconOnly",
      "label",
      "copiedLabel",
      "onCopyError"
    ],
    "StatusIndicator": [
      "intent",
      "children"
    ],
    "TruncatedText": [
      "children",
      "className"
    ],
    "TimelineItem": [
      "title",
      "actor",
      "timestamp",
      "icon",
      "children"
    ],
    "TableColumnsMenu": [
      "columns"
    ],
    "TreeGrid": [
      "items",
      "columns",
      "aria-label",
      "defaultExpandedKeys",
      "onAction"
    ]
  },
  "variantsUsed": {
    "Alert": {
      "intent": [
        "error"
      ]
    },
    "Button": {
      "variant": [
        "primary-brand",
        "secondary",
        "destructive"
      ]
    },
    "TableCell": {
      "align": [
        "trailing"
      ]
    }
  },
  "a11y": {
    "requires": [
      "`aria-label` on Table — it names the scroll region, which is keyboard reachable",
      "`scope=\"col\"` on every header cell",
      "an `aria-label` on every row-selection Checkbox, and `isIndeterminate` on the select-all when the selection is partial",
      "a live region announcing the row count after a filter or search changes it — otherwise a screen-reader user has no idea the table changed",
      "a sortable header's label is its accessible name — the button inside the <th> reads the column name, and `aria-sort` on the sorted <th> says the direction"
    ],
    "notes": [
      "Bulk-action Buttons appearing on selection is a change of available actions, not just of styling. Announce it."
    ]
  },
  "antiPatterns": [
    {
      "dont": "a spinner replacing the whole table region",
      "do": "skeleton rows under the real header",
      "why": "the layout collapses and every control below the table jumps when data arrives"
    },
    {
      "dont": "one empty state for both \"nothing exists\" and \"nothing matches\"",
      "why": "the resolving action is opposite in each case — create something, or clear the filter"
    },
    {
      "dont": "a clickable <tr>",
      "do": "a Link or Button inside a cell",
      "why": "a row takes no focus, so a keyboard user can never fire it, and an interactive role on <tr> is not valid HTML"
    },
    {
      "dont": "destructive bulk actions with no confirmation",
      "do": "the DestructiveConfirm pattern",
      "why": "select-all plus delete is one click away from removing everything the filter matched"
    },
    {
      "dont": "wrapping Table in your own scroll container",
      "why": "Table already owns one and makes it keyboard reachable; a second is unreachable"
    },
    {
      "dont": "a sortable column with no indicator until it is clicked",
      "do": "`sortDirection=\"none\"` on every sortable header, so the neutral arrows show which columns sort",
      "why": "a header that sorts but looks like plain text is only found by accident"
    },
    {
      "dont": "sorting only the current page of a paged table",
      "why": "the reader sees \"A–Z\" and takes it as the whole list; send the sort to the server, which knows every row"
    },
    {
      "dont": "an Input with type=\"search\" and a magnifier icon as the table's search",
      "why": "it has no searchbox role, no Escape to clear and no labelled clear button — SearchField is the same box with that behaviour"
    },
    {
      "dont": "a multi-value filter as a MultiSelect showing its own tags above the active-filters row",
      "do": "`hideTags` on the MultiSelect, and one active-filter tag per value",
      "why": "the same values drawn twice, each removable, in two places that can disagree about focus"
    },
    {
      "dont": "a header checkbox that selects every matching row, on every page",
      "do": "`headSelection(pageKeys)` for the page, and TableBatchBar's \"Select all N\" for the rest",
      "why": "one tick reaching thousands of rows nobody has seen is how a bulk action destroys data by surprise"
    },
    {
      "dont": "sending an \"all matching\" selection to the server as a list of ids",
      "do": "the filters plus `selection.except` when `selection.mode` is `all`",
      "why": "the rows on pages never loaded have no ids on the client, and a list built from the pages that were is silently short"
    },
    {
      "dont": "refetching the table on a Slider's `onChange`",
      "do": "`onChange` to move the thumbs, `onChangeEnd` to apply the filter",
      "why": "`onChange` fires on every pixel of a drag: dozens of requests for one gesture, answered out of order, and a table that flickers through every one"
    },
    {
      "dont": "a Drawer for a row's detail beside the table",
      "do": "<SidePanelLayout><Table …/><SidePanel …/></SidePanelLayout>",
      "why": "a Drawer is modal: comparing two rows means closing and reopening, and a screen reader is told the table is gone"
    },
    {
      "dont": "<span title={row.description}>{row.description.slice(0, 40)}…</span>",
      "do": "<TruncatedText>{row.description}</TruncatedText>",
      "why": "a title is hover-only, so a keyboard user never gets the rest, and the slice drops it from the page for a screen reader, find-in-page and a copy"
    },
    {
      "dont": "a record's history as a column of divs with coloured dots",
      "do": "<Timeline aria-label=\"History\"><TimelineItem title=\"Paused the agent\" actor={who} timestamp={at} /></Timeline>",
      "why": "a screen reader cannot count or skip a column of divs, and a dot's colour is not a sign of what happened — the title is"
    },
    {
      "dont": "a detail row added by hand under the row, with a counted colSpan",
      "do": "`expansion={{ 'aria-label': `Details for ${row.name}`, content }}` on the TableRow",
      "why": "a hand-built pair has no disclosure button a keyboard can reach or a screen reader can hear open, and its colSpan goes stale when a column is added"
    },
    {
      "dont": "hiding a column with CSS or leaving its cells empty",
      "do": "render the cells of `useTableColumns`' `visibleColumns`",
      "why": "a screen reader still counts and reads the hidden column, so its headers no longer match what is on screen"
    },
    {
      "dont": "a Table whose rows are indented by hand to show that some belong to others",
      "do": "<TreeGrid items={…} columns={…} />",
      "why": "indentation carries no level: a screen reader hears a flat table, and there is nothing to open or close"
    }
  ],
  "example": {
    "file": "dist/meta/patterns/examples/DataTable.tsx",
    "source": "/**\n * The DataTable pattern, worked: a list of invoices that is searched,\n * filtered, sorted, paged, selected and acted on, with its loading, empty and\n * error states. Copy it and change the record, not the structure.\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 * data comes in through `loadInvoices`; everything else is here.\n *\n * Not shown, see the pattern: the query in the address (the FilteredDataTable\n * pattern and its example), columns people choose, a row's detail in a\n * SidePanel, and DestructiveConfirm before a destructive bulk action.\n */\nimport { useEffect, useState, type SVGProps } from 'react';\nimport {\n  Alert,\n  Button,\n  EmptyState,\n  Icon,\n  Menu,\n  MenuItem,\n  MenuTrigger,\n  MultiSelect,\n  Pagination,\n  SearchField,\n  Skeleton,\n  Stack,\n  StatusIndicator,\n  Table,\n  TableBatchBar,\n  TableBody,\n  TableCell,\n  TableHead,\n  TableRow,\n  Tag,\n  TagGroup,\n  useTableSelection,\n  useTableSort,\n  type TableSort,\n} from 'ionbase-ui';\n\nexport type InvoiceStatus = 'paid' | 'open' | 'overdue';\n\nexport interface Invoice {\n  id: string;\n  customer: string;\n  /** In cents, so sums are exact. */\n  amount: number;\n  status: InvoiceStatus;\n  /** ISO date. */\n  due: string;\n}\n\ntype Column = 'customer' | 'amount' | 'due';\n\nconst STATUS: Record<\n  InvoiceStatus,\n  { label: string; intent: 'success' | 'neutral' | 'error' }\n> = {\n  paid: { label: 'Paid', intent: 'success' },\n  open: { label: 'Open', intent: 'neutral' },\n  overdue: { label: 'Overdue', intent: 'error' },\n};\n\nconst STATUS_OPTIONS = Object.entries(STATUS).map(([value, s]) => ({\n  value,\n  label: s.label,\n}));\n\nconst PAGE_SIZE = 10;\nconst money = new Intl.NumberFormat('en', {\n  style: 'currency',\n  currency: 'USD',\n});\nconst date = new Intl.DateTimeFormat('en', {\n  day: 'numeric',\n  month: 'short',\n  year: 'numeric',\n  timeZone: 'UTC',\n});\n\n/** Any SVG component is an icon. The design system does not ship a set. */\nfunction MoreIcon(props: SVGProps<SVGSVGElement>) {\n  return (\n    <svg viewBox=\"0 0 24 24\" fill=\"currentColor\" {...props}>\n      <circle cx=\"5\" cy=\"12\" r=\"2\" />\n      <circle cx=\"12\" cy=\"12\" r=\"2\" />\n      <circle cx=\"19\" cy=\"12\" r=\"2\" />\n    </svg>\n  );\n}\n\ntype Load =\n  | { status: 'loading' }\n  | { status: 'error'; error: Error }\n  | { status: 'ready'; invoices: Invoice[] };\n\nfunction byColumn(sort: TableSort<Column> | null) {\n  return (a: Invoice, b: Invoice) => {\n    if (!sort) return 0;\n    const d =\n      sort.column === 'amount'\n        ? a.amount - b.amount\n        : a[sort.column].localeCompare(b[sort.column]);\n    return sort.direction === 'ascending' ? d : -d;\n  };\n}\n\nexport function InvoicesTable({\n  loadInvoices,\n  onRemind,\n  onMarkPaid,\n}: {\n  loadInvoices: (signal: AbortSignal) => Promise<Invoice[]>;\n  onRemind?: (ids: string[]) => void;\n  onMarkPaid?: (id: string, paid: boolean) => void;\n}) {\n  /* The three states. A retry is a new attempt, and a newer attempt aborts\n   * the one before, so a slow old answer cannot overwrite a newer one. */\n  const [attempt, setAttempt] = useState(0);\n  const [load, setLoad] = useState<Load>({ status: 'loading' });\n  useEffect(() => {\n    const controller = new AbortController();\n    setLoad({ status: 'loading' });\n    loadInvoices(controller.signal).then(\n      (invoices) => setLoad({ status: 'ready', invoices }),\n      (error: unknown) => {\n        if (controller.signal.aborted) return;\n        setLoad({\n          status: 'error',\n          error: error instanceof Error ? error : new Error(String(error)),\n        });\n      },\n    );\n    return () => controller.abort();\n    // An attempt is what reloads. `loadInvoices` may be a new function every\n    // render, so depending on it would refetch on every render.\n  }, [attempt]);\n\n  const [search, setSearch] = useState('');\n  const [statuses, setStatuses] = useState<string[]>([]);\n  const [page, setPage] = useState(1);\n  const { sort, sortProps } = useTableSort<Column>({\n    column: 'due',\n    direction: 'ascending',\n  });\n\n  const all = load.status === 'ready' ? load.invoices : [];\n  const q = search.trim().toLowerCase();\n  const matching = all\n    .filter((i) => !q || i.customer.toLowerCase().includes(q))\n    .filter((i) => !statuses.length || statuses.includes(i.status))\n    .sort(byColumn(sort));\n  const pageCount = Math.max(1, Math.ceil(matching.length / PAGE_SIZE));\n  const rows = matching.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);\n\n  const selection = useTableSelection<string>({ total: matching.length });\n\n  /* A change to which rows match starts again at page 1 and drops the\n   * selection. A page change keeps it, so rows ticked on two pages can be\n   * acted on together. */\n  const refilter = () => {\n    setPage(1);\n    selection.clear();\n  };\n  const header = (column: Column, first?: 'descending') => {\n    const props = sortProps(column, { firstDirection: first });\n    return {\n      ...props,\n      onSort: () => {\n        props.onSort();\n        refilter();\n      },\n    };\n  };\n\n  const activeFilters = [\n    ...(q ? [{ id: 'search', label: `Search: “${search.trim()}”` }] : []),\n    ...statuses.map((s) => ({\n      id: `status:${s}`,\n      label: `Status: ${STATUS[s as InvoiceStatus].label}`,\n    })),\n  ];\n  const clearFilters = () => {\n    setSearch('');\n    setStatuses([]);\n    refilter();\n  };\n\n  /** The ids a bulk action reaches: the ticked ones, or every match but the unticked. */\n  const targets = () => {\n    const s = selection.selection;\n    return s.mode === 'all'\n      ? matching.map((i) => i.id).filter((id) => !s.except.has(id))\n      : [...s.keys];\n  };\n\n  /* The header row, the same in every state, so nothing moves when the\n   * rows land and a sort pressed during a load keeps its focus. */\n  const headers = (\n    <>\n      <TableCell header scope=\"col\" {...header('customer')}>\n        Customer\n      </TableCell>\n      <TableCell header scope=\"col\">\n        Status\n      </TableCell>\n      <TableCell\n        header\n        scope=\"col\"\n        align=\"trailing\"\n        {...header('amount', 'descending')}\n      >\n        Amount\n      </TableCell>\n      <TableCell header scope=\"col\" {...header('due')}>\n        Due\n      </TableCell>\n      <TableCell header scope=\"col\">\n        <span className=\"ion-visually-hidden\">Actions</span>\n      </TableCell>\n    </>\n  );\n\n  return (\n    <Stack gap={16}>\n      {/* The toolbar stays usable in every state, the error included. */}\n      <Stack direction=\"row\" wrap gap={8}>\n        <SearchField\n          size=\"sm\"\n          aria-label=\"Search invoices by customer\"\n          placeholder=\"Search customers\"\n          value={search}\n          onChange={(value) => {\n            setSearch(value);\n            refilter();\n          }}\n        />\n        {/* Its values are the active-filter tags below, so it draws none. */}\n        <MultiSelect\n          size=\"sm\"\n          aria-label=\"Status\"\n          placeholder=\"All statuses\"\n          options={STATUS_OPTIONS}\n          value={statuses}\n          onChange={(value) => {\n            setStatuses(value);\n            refilter();\n          }}\n          hideTags\n        />\n      </Stack>\n\n      {activeFilters.length > 0 && (\n        <Stack direction=\"row\" wrap align=\"end\" gap={8}>\n          <TagGroup\n            label=\"Active filters\"\n            items={activeFilters}\n            onRemove={(keys) => {\n              if (keys.has('search')) setSearch('');\n              setStatuses((s) => s.filter((v) => !keys.has(`status:${v}`)));\n              refilter();\n            }}\n          >\n            {(f) => <Tag key={f.id}>{f.label}</Tag>}\n          </TagGroup>\n          <Button size=\"sm\" variant=\"tertiary\" onPress={clearFilters}>\n            Clear all\n          </Button>\n        </Stack>\n      )}\n\n      {/* Always rendered: its count is a live region, which must be in the\n          page before the first row is ticked. Hidden while nothing is. */}\n      <TableBatchBar\n        count={selection.count}\n        total={matching.length}\n        isAllMatching={selection.isAllMatching}\n        onSelectAll={selection.selectAllMatching}\n        onClear={selection.clear}\n        tableId=\"invoices-table\"\n      >\n        <Button\n          size=\"sm\"\n          variant=\"secondary\"\n          onPress={() => onRemind?.(targets())}\n        >\n          Send reminder\n        </Button>\n      </TableBatchBar>\n\n      {/* The count after every change, or a filter is silent. */}\n      <p className=\"ion-visually-hidden\" role=\"status\">\n        {load.status === 'ready'\n          ? `${matching.length} ${matching.length === 1 ? 'invoice' : 'invoices'}`\n          : ''}\n      </p>\n\n      {load.status === 'loading' && (\n        <div aria-busy=\"true\">\n          <p className=\"ion-visually-hidden\" role=\"status\">\n            Loading invoices\n          </p>\n          <Table aria-label=\"Invoices (loading)\">\n            <TableHead>\n              {/* The selection column too, where the checkboxes will be. */}\n              <TableRow>\n                <TableCell header scope=\"col\">\n                  <span className=\"ion-visually-hidden\">Select</span>\n                </TableCell>\n                {headers}\n              </TableRow>\n            </TableHead>\n            <TableBody>\n              {Array.from({ length: 5 }, (_, r) => (\n                <TableRow key={r}>\n                  {Array.from({ length: 6 }, (_, c) => (\n                    <TableCell key={c}>\n                      <Skeleton variant=\"text\" />\n                    </TableCell>\n                  ))}\n                </TableRow>\n              ))}\n            </TableBody>\n          </Table>\n        </div>\n      )}\n\n      {load.status === 'error' && (\n        <Alert\n          intent=\"error\"\n          title=\"Invoices couldn’t load\"\n          actions={\n            <Button\n              size=\"sm\"\n              variant=\"secondary\"\n              onPress={() => setAttempt((n) => n + 1)}\n            >\n              Try again\n            </Button>\n          }\n        >\n          {load.error.message} Your search and filters are kept.\n        </Alert>\n      )}\n\n      {/* Two empties, with opposite ways out. */}\n      {load.status === 'ready' && all.length === 0 && (\n        <EmptyState\n          reason=\"first-run\"\n          size=\"page\"\n          headingLevel={2}\n          title=\"No invoices yet\"\n          description=\"Invoices appear here once you bill a customer.\"\n        />\n      )}\n      {load.status === 'ready' && all.length > 0 && matching.length === 0 && (\n        <EmptyState\n          reason=\"no-results\"\n          size=\"panel\"\n          headingLevel={2}\n          title=\"No invoices match these filters\"\n          action={\n            <Button variant=\"secondary\" onPress={clearFilters}>\n              Clear filters\n            </Button>\n          }\n        />\n      )}\n\n      {rows.length > 0 && (\n        <>\n          <Table id=\"invoices-table\" aria-label=\"Invoices\">\n            <TableHead>\n              <TableRow\n                selection={selection.headSelection(\n                  rows.map((i) => i.id),\n                  'Select all invoices on this page',\n                )}\n              >\n                {headers}\n              </TableRow>\n            </TableHead>\n            <TableBody>\n              {rows.map((i) => {\n                const paid = i.status === 'paid';\n                return (\n                  <TableRow\n                    key={i.id}\n                    isSelected={selection.isSelected(i.id)}\n                    selection={selection.rowSelection(\n                      i.id,\n                      `Select ${i.customer}, ${i.id}`,\n                    )}\n                  >\n                    <TableCell>{i.customer}</TableCell>\n                    <TableCell>\n                      <StatusIndicator intent={STATUS[i.status].intent}>\n                        {STATUS[i.status].label}\n                      </StatusIndicator>\n                    </TableCell>\n                    <TableCell align=\"trailing\">\n                      {money.format(i.amount / 100)}\n                    </TableCell>\n                    <TableCell>{date.format(new Date(i.due))}</TableCell>\n                    <TableCell align=\"trailing\">\n                      <MenuTrigger placement=\"bottom end\">\n                        <Button\n                          size=\"sm\"\n                          variant=\"tertiary\"\n                          aria-label={`Actions for ${i.customer}, ${i.id}`}\n                          startIcon={<Icon as={MoreIcon} size=\"sm\" />}\n                        />\n                        {/* A row that changes with state is a ternary, not\n                            an array of elements: see Menu's contract. */}\n                        <Menu onAction={() => onMarkPaid?.(i.id, !paid)}>\n                          {paid ? (\n                            <MenuItem key=\"reopen\">Mark as unpaid</MenuItem>\n                          ) : (\n                            <MenuItem key=\"paid\">Mark as paid</MenuItem>\n                          )}\n                        </Menu>\n                      </MenuTrigger>\n                    </TableCell>\n                  </TableRow>\n                );\n              })}\n            </TableBody>\n          </Table>\n          {pageCount > 1 && (\n            <Pagination\n              aria-label=\"Invoice pages\"\n              size=\"sm\"\n              page={page}\n              pageCount={pageCount}\n              onPageChange={setPage}\n            />\n          )}\n        </>\n      )}\n    </Stack>\n  );\n}\n"
  }
}
