{
  "name": "FilteredDataTable",
  "summary": "The DataTable pattern with its filters, search, sort and page kept in the address — so a reload, a shared link and Back show the same listing — and a refilter that keeps the rows on screen until the new ones land.",
  "useWhen": [
    "a DataTable people filter to find a set and come back to: a link sent to a colleague, a bookmark, a reload after lunch",
    "the listing is paged or sorted on a server, so the query is the whole state and the rows are its answer",
    "Back is expected to undo a filter, the way it undoes a page"
  ],
  "useInstead": [
    {
      "when": "a table with no filters, or one short enough to read whole",
      "use": "the DataTable pattern alone",
      "why": "an address that says nothing a plain one does not is noise in every link"
    },
    {
      "when": "a filter set that only applies once confirmed — many fields, a date range, a builder",
      "use": "a Drawer of filters with Apply, whose applied values are then written to the address as here",
      "why": "writing each field to the address as it changes would refetch on every half-built filter"
    }
  ],
  "composes": [
    "Table",
    "TableHead",
    "TableBody",
    "TableRow",
    "TableCell",
    "SearchField",
    "Select",
    "MultiSelect",
    "TagGroup",
    "Tag",
    "Button",
    "Pagination",
    "TableBatchBar",
    "ProgressBar",
    "EmptyState",
    "Alert",
    "Skeleton",
    "StatusIndicator"
  ],
  "structure": [
    "Everything in the DataTable pattern holds: toolbar, active filters, batch bar, sortable headers, the three states. This pattern is where its query lives and how it changes.",
    "The address holds the listing: the search, each filter, the sort and the page size and page, as query parameters after the route — `#/agents?q=invoice&status=paused&team=finance&team=legal&sort=-runs7d&page=2&size=20`. A value at its default is left out, so the plain address is the default view and a link carries only what was chosen. A filter of several values repeats its key; a descending sort is the column with a minus.",
    "Not in the address: the selection, which is the reader's and not the listing's — a link must not tick rows for whoever opens it — and the columns shown and their widths, which are a viewer's own view, kept in their browser and passed to `useTableColumns` as `initial`.",
    "Read the address defensively. It is typed, pasted and kept for months: a status since renamed, a team since deleted, page \"abc\" — each falls back to its default. Never an error page for a query, and a page past the end is the last page, with the address corrected in place.",
    "A deliberate change — a filter, a sort, a page, the page size — pushes an entry, so Back undoes it. Typing a search replaces the entry, so Back does not unspell it. Debounce the write, not the value: start the timer from the search box's `onChange`, and let only typing start it. A debounced value written by an effect fires after a Back pressed while it was waiting, and writes the old search over it.",
    "A change to anything but the page starts again at page 1. A change to which rows match — anything but the page — clears the selection, including one that arrives by Back: acting on rows chosen from another listing is how a bulk action surprises someone.",
    "The controls follow the address, not the other way round. When it changes by Back or a link, each control shows what it says — the SearchField its search, unless the user is mid-typing; the Select and MultiSelect their values, read from the address directly; the sort through `useTableSort`'s `setSort`, only when the address says something the hook does not, so a header press is not undone before the address catches up.",
    "Tell the page as soon as the address is written, not on `hashchange` or `popstate`: those come a task later, and a second pick in an open MultiSelect before then is built on the first pick's stale value, and replaces it.",
    "A refilter keeps the rows it has on screen until the next ones land, with `aria-busy` on their container and an indeterminate ProgressBar (size sm, labelled \"Updating agents\") in space kept for it above them. Skeletons are for the first load only. Swapping the table for skeletons on every change jumps the page under the pointer, and unmounts the header a sort was just pressed on, dropping focus to the page.",
    "The result count, announced once the rows land (DataTable's live region), says what the filters found; while they load, it is empty, so nothing stale is read."
  ],
  "drivenBy": {
    "hook": "useTableSort",
    "note": "Start it from the address (`useTableSort(fromAddress)`), write its `sort` to the address when a header changes it, and call `setSort` when the address changes by Back or a link — only when the two differ. It holds the sort; the address is where the sort is kept."
  },
  "states": {
    "loading": {
      "must": "The first load: DataTable's header and skeleton rows. A refilter: the last rows stay, `aria-busy` on their container and an indeterminate ProgressBar above them, until the new ones replace them.",
      "why": "replacing the rows on every refilter moves every control on the page and drops focus from the header that was pressed",
      "a11y": "the ProgressBar has a `label`; the count's live region is empty while loading and says the new count once the rows land"
    },
    "empty": {
      "must": "No rows match: an EmptyState, `reason=\"no-results\"`, saying what was searched for and offering Clear filters. The address keeps the filters, so a shared link to an empty listing still says what matched nothing.",
      "why": "clearing the filters from the address on an empty result would turn a colleague's link into a different listing with no explanation"
    },
    "error": {
      "must": "An Alert with Try again; the controls and the address keep the query, so Try again and a reload retry the same listing.",
      "why": "an error that resets the filters makes the user rebuild the query they were retrying",
      "a11y": "Alert takes `role=\"alert\"` from its intent — do not add your own"
    }
  },
  "propsUsed": {
    "SearchField": [
      "size",
      "value",
      "onChange",
      "placeholder",
      "aria-label"
    ],
    "Select": [
      "size",
      "options"
    ],
    "MultiSelect": [
      "size",
      "aria-label",
      "options",
      "value",
      "onChange",
      "hideTags",
      "placeholder"
    ],
    "TagGroup": [
      "label",
      "items",
      "onRemove"
    ],
    "Pagination": [
      "page",
      "pageCount",
      "onPageChange",
      "showPageSize",
      "pageSize",
      "pageSizeOptions",
      "onPageSizeChange",
      "labels"
    ],
    "TableCell": [
      "sortDirection",
      "onSort"
    ],
    "TableBatchBar": [
      "count",
      "total",
      "onClear"
    ],
    "ProgressBar": [
      "label",
      "size"
    ],
    "EmptyState": [
      "reason",
      "title",
      "description",
      "action"
    ],
    "Alert": [
      "intent",
      "title",
      "actions"
    ]
  },
  "variantsUsed": {
    "ProgressBar": {
      "size": [
        "sm"
      ]
    },
    "EmptyState": {
      "reason": [
        "no-results"
      ]
    },
    "Alert": {
      "intent": [
        "error"
      ]
    }
  },
  "a11y": {
    "requires": [
      "focus kept where it was through a refilter — on the header pressed, in the search box being typed in — which is why the rows are kept rather than remounted",
      "a `label` on the refilter's ProgressBar, saying what is updating",
      "the result count in DataTable's live region once the rows land"
    ],
    "notes": [
      "Back undoing a filter is announced by the count's live region like any other refilter, and the controls show the value Back restored."
    ]
  },
  "antiPatterns": [
    {
      "dont": "filters held only in component state",
      "do": "the address, with defaults left out",
      "why": "a reload throws the listing away, a link opens someone else's default view, and Back leaves the page instead of undoing the last filter"
    },
    {
      "dont": "a history entry for each keystroke of a search",
      "do": "replace the entry while typing; push a filter, a sort or a page",
      "why": "Back unspells the search letter by letter before it undoes anything the user chose"
    },
    {
      "dont": "a debounced value written to the address by an effect",
      "do": "a timer started from the search box's `onChange`",
      "why": "a Back pressed while it waits is overwritten a quarter of a second later by the old search"
    },
    {
      "dont": "the selection in the address",
      "why": "a shared link would tick rows for the colleague who opens it, and a bulk action would reach rows they never chose"
    },
    {
      "dont": "an error page for a query value the app no longer knows",
      "do": "that value's default",
      "why": "an old bookmark is still the way someone gets to the list; one renamed status should not lock them out of it"
    },
    {
      "dont": "skeletons on every refilter",
      "do": "the last rows, busy, until the next land",
      "why": "the header just pressed is unmounted, focus falls to the page, and every control below the table jumps twice"
    }
  ],
  "example": {
    "file": "dist/meta/patterns/examples/FilteredDataTable.tsx",
    "source": "/**\n * The FilteredDataTable pattern, worked: the invoices of the DataTable\n * example, paged on a server, with the search, filters, sort and page in the\n * address. A reload, a shared link and Back show the same listing, and a\n * refilter keeps the rows on screen until the next ones land.\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 (`#/invoices?q=acme&status=overdue&page=2`) so the\n * example needs no router; with one, `readAddress` and `writeAddress` are the\n * router's. The rows come in through `fetchInvoices`.\n *\n * The DataTable example shows row actions; this one leaves them out to keep\n * to what is new.\n */\nimport { useCallback, useEffect, useRef, useState } from 'react';\nimport {\n  Alert,\n  Button,\n  EmptyState,\n  MultiSelect,\n  Pagination,\n  ProgressBar,\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';\nexport type InvoiceColumn = 'customer' | 'amount' | 'due';\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\n/** The listing: everything the address holds, and all the server needs. */\nexport interface InvoiceQuery {\n  search: string;\n  statuses: InvoiceStatus[];\n  sort: TableSort<InvoiceColumn>;\n  page: number;\n  pageSize: number;\n}\n\nexport interface InvoicePage {\n  rows: Invoice[];\n  /** Every invoice the query matches, on every page. */\n  total: number;\n}\n\n/** What a bulk action reaches: ticked ids, or every match but the unticked. */\nexport type InvoiceTargets =\n  { ids: string[] } | { query: InvoiceQuery; except: string[] };\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};\nconst STATUSES = Object.keys(STATUS) as InvoiceStatus[];\nconst STATUS_OPTIONS = STATUSES.map((s) => ({\n  value: s,\n  label: STATUS[s].label,\n}));\nconst COLUMNS: InvoiceColumn[] = ['customer', 'amount', 'due'];\nconst DEFAULT_SORT: TableSort<InvoiceColumn> = {\n  column: 'due',\n  direction: 'ascending',\n};\nconst PAGE_SIZES = [10, 20, 50];\n\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/* ------------------------------------------------------------ the address */\n\n/**\n * Read defensively: an address is typed, pasted and kept for months. A value\n * that means nothing now (a status since renamed, page \"abc\") falls back to\n * its default, never to an error page.\n */\nexport function readQuery(params: string): InvoiceQuery {\n  const p = new URLSearchParams(params);\n  const sort = p.get('sort') ?? '';\n  const column = sort.replace(/^-/, '') as InvoiceColumn;\n  const page = Number(p.get('page'));\n  const size = Number(p.get('size'));\n  return {\n    search: p.get('q')?.trim() ?? '',\n    statuses: [...new Set(p.getAll('status'))].filter((s): s is InvoiceStatus =>\n      STATUSES.includes(s as InvoiceStatus),\n    ),\n    sort: COLUMNS.includes(column)\n      ? {\n          column,\n          direction: sort.startsWith('-') ? 'descending' : 'ascending',\n        }\n      : DEFAULT_SORT,\n    page: Number.isInteger(page) && page > 1 ? page : 1,\n    pageSize: PAGE_SIZES.includes(size) ? size : PAGE_SIZES[0],\n  };\n}\n\n/** A value at its default is left out, so the plain address is the default view. */\nexport function writeQuery(q: InvoiceQuery): string {\n  const p = new URLSearchParams();\n  if (q.search) p.set('q', q.search);\n  for (const s of q.statuses) p.append('status', s);\n  if (\n    q.sort.column !== DEFAULT_SORT.column ||\n    q.sort.direction !== DEFAULT_SORT.direction\n  )\n    p.set(\n      'sort',\n      `${q.sort.direction === 'descending' ? '-' : ''}${q.sort.column}`,\n    );\n  if (q.page > 1) p.set('page', String(q.page));\n  if (q.pageSize !== PAGE_SIZES[0]) p.set('size', String(q.pageSize));\n  return p.toString();\n}\n\nfunction readAddress(): string {\n  const hash = window.location.hash;\n  const i = hash.indexOf('?');\n  return i < 0 ? '' : hash.slice(i + 1);\n}\n\n/**\n * The address's query, and a way to write it. Writing tells the page at\n * once rather than waiting for `popstate`, which comes a task later: a second\n * pick in an open MultiSelect before then would be built on the first pick's\n * stale value, and replace it.\n */\nfunction useAddress() {\n  const [params, setParams] = useState(readAddress);\n  useEffect(() => {\n    const onBack = () => setParams(readAddress());\n    window.addEventListener('popstate', onBack);\n    return () => window.removeEventListener('popstate', onBack);\n  }, []);\n  const write = useCallback((next: string, replace: boolean) => {\n    const route = window.location.hash.split('?')[0] || '#';\n    const url = `${route}${next ? `?${next}` : ''}`;\n    if (replace) window.history.replaceState(null, '', url);\n    else window.history.pushState(null, '', url);\n    setParams(next);\n  }, []);\n  return [params, write] as const;\n}\n\n/** Which rows match: everything but the page. A change drops the selection. */\nconst matchKey = (q: InvoiceQuery) =>\n  JSON.stringify([q.search, q.statuses, q.sort, q.pageSize]);\nconst sortKey = (s: TableSort<InvoiceColumn> | null) =>\n  s ? `${s.column}:${s.direction}` : '';\n\n/* ---------------------------------------------------------------- the page */\n\ntype Load =\n  | { status: 'loading' }\n  | { status: 'error'; error: Error }\n  | { status: 'ready'; data: InvoicePage };\n\nexport function InvoicesListing({\n  fetchInvoices,\n  onRemind,\n}: {\n  fetchInvoices: (\n    query: InvoiceQuery,\n    signal: AbortSignal,\n  ) => Promise<InvoicePage>;\n  onRemind?: (targets: InvoiceTargets) => void;\n}) {\n  const [params, writeAddress] = useAddress();\n  const query = readQuery(params);\n\n  /**\n   * Change the listing. Anything but the page starts again at page 1. A\n   * deliberate change pushes an entry, so Back undoes it; typing a search\n   * replaces, so Back does not unspell it.\n   */\n  const change = (patch: Partial<InvoiceQuery>, replace = false) => {\n    // From the address itself, not this render: a search written by the\n    // timer must not undo a filter picked while it waited.\n    const next = { ...readQuery(readAddress()), ...patch };\n    if (!('page' in patch)) next.page = 1;\n    writeAddress(writeQuery(next), replace);\n  };\n\n  /* The rows. A newer query aborts the older, so a slow answer to an old\n   * filter cannot land over a newer one. */\n  const [attempt, setAttempt] = useState(0);\n  const [load, setLoad] = useState<Load>({ status: 'loading' });\n  const key = writeQuery(query);\n  useEffect(() => {\n    const controller = new AbortController();\n    setLoad({ status: 'loading' });\n    fetchInvoices(readQuery(key), controller.signal).then(\n      (data) => setLoad({ status: 'ready', data }),\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    // The query and the attempt are what refetch. `fetchInvoices` may be a\n    // new function every render, so depending on it would refetch on each.\n  }, [key, attempt]);\n\n  /* A refilter keeps the last rows on screen, busy, until the next land.\n   * Skeletons are for the first load only: swapping the table for them on\n   * every change unmounts the header a sort was just pressed on. */\n  const [kept, setKept] = useState<InvoicePage | null>(null);\n  if (load.status === 'ready' && kept !== load.data) setKept(load.data);\n  const data =\n    load.status === 'ready'\n      ? load.data\n      : load.status === 'loading'\n        ? kept\n        : null;\n  const refreshing = load.status === 'loading' && data !== null;\n\n  /* What is typed. Typing writes the address once it pauses. The timer is\n   * started from the box's onChange, and only typing starts it, so a Back\n   * pressed while it waits is never overwritten by the old search. */\n  const [search, setSearch] = useState(query.search);\n  const typing = useRef<number | undefined>(undefined);\n  const type = (value: string) => {\n    setSearch(value);\n    window.clearTimeout(typing.current);\n    typing.current = window.setTimeout(() => {\n      typing.current = undefined;\n      change({ search: value.trim() }, true);\n    }, 250);\n  };\n  useEffect(() => {\n    if (typing.current === undefined) setSearch(query.search);\n  }, [query.search]);\n  useEffect(() => () => window.clearTimeout(typing.current), []);\n\n  const total = load.status === 'ready' ? load.data.total : undefined;\n  const selection = useTableSelection<string>({ total });\n  const { sort, setSort, sortProps } = useTableSort<InvoiceColumn>(query.sort);\n\n  /* The controls follow the address. When it changes by Back or a link: the\n   * search box shows it unless someone is typing, the sort hook is set only\n   * if it disagrees, and a change to which rows match drops the selection. */\n  const [seen, setSeen] = useState(params);\n  if (params !== seen) {\n    const before = readQuery(seen);\n    setSeen(params);\n    if (sortKey(query.sort) !== sortKey(sort)) setSort(query.sort);\n    if (matchKey(query) !== matchKey(before)) selection.clear();\n  }\n  // A header press sorts the hook; the address follows it.\n  useEffect(() => {\n    if (sort && sortKey(sort) !== sortKey(readQuery(readAddress()).sort))\n      change({ sort });\n  }, [sort]);\n\n  // A page past the end, a link kept while invoices were paid, is the last page.\n  const pageCount = data\n    ? Math.max(1, Math.ceil(data.total / query.pageSize))\n    : 1;\n  useEffect(() => {\n    if (load.status === 'ready' && query.page > pageCount)\n      change({ page: pageCount }, true);\n  }, [load.status, query.page, pageCount]);\n\n  const filtered = query.search !== '' || query.statuses.length > 0;\n  const activeFilters = [\n    ...(query.search\n      ? [{ id: 'search', label: `Search: “${query.search}”` }]\n      : []),\n    ...query.statuses.map((s) => ({\n      id: `status:${s}`,\n      label: `Status: ${STATUS[s].label}`,\n    })),\n  ];\n  const clearFilters = () => {\n    window.clearTimeout(typing.current);\n    typing.current = undefined;\n    setSearch('');\n    change({ search: '', statuses: [] });\n  };\n\n  /* Every match but the unticked goes to the server as the query: the rows\n   * on pages never loaded have no ids here. */\n  const targets = (): InvoiceTargets => {\n    const s = selection.selection;\n    return s.mode === 'all'\n      ? { query: { ...query, page: 1 }, except: [...s.except] }\n      : { ids: [...s.keys] };\n  };\n\n  const headers = (\n    <>\n      <TableCell header scope=\"col\" {...sortProps('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        {...sortProps('amount', { firstDirection: 'descending' })}\n      >\n        Amount\n      </TableCell>\n      <TableCell header scope=\"col\" {...sortProps('due')}>\n        Due\n      </TableCell>\n    </>\n  );\n\n  return (\n    <Stack gap={16}>\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={type}\n        />\n        {/* Read from the address directly, so Back shows what it restored. */}\n        <MultiSelect\n          size=\"sm\"\n          aria-label=\"Status\"\n          placeholder=\"All statuses\"\n          options={STATUS_OPTIONS}\n          value={query.statuses}\n          onChange={(statuses) =>\n            change({ statuses: statuses as InvoiceStatus[] })\n          }\n          hideTags\n        />\n      </Stack>\n\n      {filtered && (\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')) {\n                window.clearTimeout(typing.current);\n                typing.current = undefined;\n                setSearch('');\n              }\n              change({\n                ...(keys.has('search') ? { search: '' } : {}),\n                statuses: query.statuses.filter(\n                  (s) => !keys.has(`status:${s}`),\n                ),\n              });\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      {/* Not in the address: a link must not tick rows for whoever opens it. */}\n      <TableBatchBar\n        count={selection.count}\n        total={total}\n        isAllMatching={selection.isAllMatching}\n        onSelectAll={selection.selectAllMatching}\n        onClear={selection.clear}\n        tableId=\"invoice-listing\"\n      >\n        <Button\n          size=\"sm\"\n          variant=\"secondary\"\n          onPress={() => onRemind?.(targets())}\n        >\n          Send reminder\n        </Button>\n      </TableBatchBar>\n\n      {/* Empty while loading, so nothing stale is read; the count once the rows land. */}\n      <p className=\"ion-visually-hidden\" role=\"status\">\n        {load.status === 'ready'\n          ? `${load.data.total} ${load.data.total === 1 ? 'invoice' : 'invoices'}`\n          : ''}\n      </p>\n\n      {load.status === 'loading' && !data && (\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              <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: 5 }, (_, c) => (\n                    <TableCell key={c}>\n                      <Skeleton variant=\"text\" />\n                    </TableCell>\n                  ))}\n                </TableRow>\n              ))}\n            </TableBody>\n          </Table>\n        </div>\n      )}\n\n      {/* The controls and the address keep the query, so Try again and a\n          reload retry the same listing. */}\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      {/* The address keeps the filters, so a shared link to an empty listing\n          still says what matched nothing. */}\n      {data && data.total === 0 && load.status === 'ready' && (\n        <EmptyState\n          reason={filtered ? 'no-results' : 'first-run'}\n          size={filtered ? 'panel' : 'page'}\n          headingLevel={2}\n          title={\n            filtered ? 'No invoices match these filters' : 'No invoices yet'\n          }\n          description={\n            query.search\n              ? `Nothing matches “${query.search}” with these statuses.`\n              : undefined\n          }\n          action={\n            filtered && (\n              <Button variant=\"secondary\" onPress={clearFilters}>\n                Clear filters\n              </Button>\n            )\n          }\n        />\n      )}\n\n      {data && data.total > 0 && (\n        <Stack gap={8}>\n          <div aria-busy={refreshing || undefined}>\n            <Stack gap={4}>\n              {/* Room kept for it, so nothing moves when it comes and goes. */}\n              <div style={{ minBlockSize: 'var(--spacing-4)' }}>\n                {refreshing && (\n                  <ProgressBar size=\"sm\" label=\"Updating invoices\" />\n                )}\n              </div>\n              <Table id=\"invoice-listing\" aria-label=\"Invoices\">\n                <TableHead>\n                  <TableRow\n                    selection={selection.headSelection(\n                      data.rows.map((i) => i.id),\n                      'Select all invoices on this page',\n                    )}\n                  >\n                    {headers}\n                  </TableRow>\n                </TableHead>\n                <TableBody>\n                  {data.rows.map((i) => (\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                    </TableRow>\n                  ))}\n                </TableBody>\n              </Table>\n            </Stack>\n          </div>\n          <Pagination\n            aria-label=\"Invoice pages\"\n            size=\"sm\"\n            page={Math.min(query.page, pageCount)}\n            pageCount={pageCount}\n            onPageChange={(page) => change({ page })}\n            showPageSize\n            pageSize={query.pageSize}\n            pageSizeOptions={PAGE_SIZES}\n            onPageSizeChange={(pageSize) => change({ pageSize })}\n            labels={{ pageSize: 'Invoices per page' }}\n          />\n        </Stack>\n      )}\n    </Stack>\n  );\n}\n"
  }
}
