# Internal admin — list page pattern

## Before using this pattern

Surface rules: read
`node_modules/@spark-web/design-system/patterns/internal-admin/CLAUDE.md` in
full first — its rules all apply here.

## What this pattern is

A full page layout for displaying a searchable, filterable, paginated list of
records in an internal admin interface. This is the most common page type in
admin surfaces.

## When to use this pattern

Any list/management view of records — the registry
(`node_modules/@spark-web/design-system/patterns/CLAUDE.md`) owns surface and
feature-type classification.

---

## Component docs to read

Read these before implementing — they own the component-level rules:

- `node_modules/@spark-web/page-header/CLAUDE.md` — PageHeader API, action type
  rules
- `node_modules/@spark-web/data-table/CLAUDE.md` — DataTable API, column defs,
  loading/empty states, headerClassName tokens
- `node_modules/@spark-web/badge/CLAUDE.md` — status tone mapping
- `node_modules/@spark-web/meatball-menu/CLAUDE.md` — MeatballMenu API
- `node_modules/@spark-web/stack/CLAUDE.md` — vertical stacking and gap
- `node_modules/@spark-web/box/CLAUDE.md` — flex layout utilities
- `node_modules/@spark-web/columns/CLAUDE.md` — responsive multi-column layout
- `node_modules/@spark-web/field/CLAUDE.md` — Field API, labelVisibility
- `node_modules/@spark-web/select/CLAUDE.md` — Select API, filter dropdown
  pattern
- `node_modules/@spark-web/multi-select/CLAUDE.md` — MultiSelect filter
  dropdowns
- `node_modules/@spark-web/text/CLAUDE.md` — Text API
- `node_modules/@spark-web/text-input/CLAUDE.md` — TextInput API
- `node_modules/@spark-web/alert/CLAUDE.md` — page-level feedback Alert
- `node_modules/@spark-web/icon/CLAUDE.md` — icon sizing and tones (SearchIcon)

---

## Page structure

```
Page
  Outer wrapper Stack            — full height, no padding
  PageHeader                     — label={pageTitle}, optional statusBadge/action/controls
  Alert (conditional)            — page-level feedback only, omit if no page-level actions
  Content area Stack             — padding="large" gap="large", neutral background
    Filter container             — required if filtering or searching exists
    Table scroll wrapper         — flex scroll container (REQUIRED — never omit)
      DataTable                  — required — the data table
    Pagination                   — required if record count exceeds pageSize (custom, no Spark component)
```

---

## Section 1 — Page header

Always use `PageHeader` from `@spark-web/page-header`. Never replace with a
manual `Stack + Heading`.

```tsx
<PageHeader label={pageTitle} />
```

See `node_modules/@spark-web/page-header/CLAUDE.md` for action types (button,
link, meatball), statusBadge, and controls props.

Rules:

- `PageHeader` always renders an H1 — do not pass a different heading level
- Page-level actions (e.g. "Add new") belong in the `action` prop, not in the
  content area
- This section always renders — never omit it

---

## Section 1b — Page-level feedback (conditional)

When a page-level action (e.g. CSV export, bulk mutation) can produce success or
error feedback, render an `<Alert>` between `<PageHeader>` and the content area
Stack. Only rendered when feedback state exists.

```tsx
import { Alert } from '@spark-web/alert';

{
  actionStatus && (
    <Alert tone={actionStatus.isSuccessful ? 'positive' : 'critical'}>
      <Text>{actionStatus.message}</Text>
    </Alert>
  );
}
```

Rules:

- Never render Alert inside the neutral-background content Stack
- Never render Alert above PageHeader
- Feedback state is local component state, reset on filter change or page
  navigation
- If the page has no page-level actions, omit this section entirely

---

## Section 2 — Outer wrapper

The outermost container fills the full viewport height.

```tsx
<Stack height="full" css={{ minHeight: '100vh' }}>
```

Documented exception:

- `minHeight: '100vh'` — no Spark token equivalent; ensures page fills viewport
  on short content

---

## Section 3 — Content area

Sits inside the outer wrapper. Owns all page padding and gap between sections.

```tsx
<Stack
  padding="large"
  gap="large"
  flex={1}
  display="flex"
  flexDirection="column"
  overflow="hidden"
  css={{ backgroundColor: theme.color.background.neutral }}
>
```

Token mappings:

- `padding="large"` — all four sides, Spark token
- `gap="large"` — between all sections, Spark token
- `backgroundColor` — `theme.color.background.neutral` via `useTheme()`

Spark props (not exceptions):

- `flex={1}` — makes content area fill remaining height (Box `flex` prop)
- `display="flex"` + `flexDirection="column"` — required to activate `flex={1}`
  (Box props)
- `overflow="hidden"` — scroll containment (Box `overflow` prop)

Documented exception:

- `backgroundColor: theme.color.background.neutral` — accessed via `useTheme()`,
  not a Box prop, so it goes through the `css` prop

---

## Section 4 — Filter container

Renders filter dropdowns and a search input in a horizontal row.

```tsx
<Columns gap="large" collapseBelow="desktop">
  {/* Search — always first */}
  <Field label="Search" labelVisibility="hidden">
    <TextInput
      placeholder="Search by..."
      value={search}
      onChange={e => setSearch(e.target.value)}
    >
      <InputAdornment placement="start">
        <SearchIcon size="xxsmall" tone="muted" />
      </InputAdornment>
    </TextInput>
  </Field>

  {/* Multi-select filter */}
  <Box>
    <VisuallyHidden as="span" id="status-filter-label">
      Status
    </VisuallyHidden>
    <MultiSelect
      aria-labelledby="status-filter-label"
      options={[{ label: '', options: statusOptions }]}
      placeholder="Filter by status..."
      onChange={setStatusFilter}
    />
  </Box>

  {/* Single-select filter */}
  <Field label="Role" labelVisibility="hidden">
    <Select
      placeholder="Filter by role..."
      options={roleOptions}
      value={roleFilter}
      onChange={e => setRoleFilter(e.target.value)}
    />
  </Field>
</Columns>
```

Rules:

- Use `Columns` from @spark-web/columns with `gap="large"`
- `collapseBelow="desktop"` — stacks vertically on mobile and tablet
- **Search input always appears first (leftmost)** in the filter row
- Filter dropdowns follow the search input, ordered by specificity (broadest
  filter first, most specific last)
- Multi-select filter dropdowns use `MultiSelect` from
  `@spark-web/multi-select`. Its `options` are grouped — pass
  `[{ label: '', options }]` for a flat list. It renders no visible label, so
  label it for assistive technology via `aria-labelledby` pointing at a
  `VisuallyHidden` element from `@spark-web/a11y`
- Single-select filter dropdowns use `Select` from `@spark-web/select`, always
  wrapped in a `Field`
- Filter labels are never visible — wrap `TextInput` and `Select` in a `Field`
  with `labelVisibility="hidden"`; the `label` is still required for
  accessibility
- Working in portal-hub? Apply the substitutions in
  `node_modules/@spark-web/design-system/patterns/internal-admin/portal-hub.md`.
- If no filtering or searching is needed, omit this section entirely

---

## Section 5 — Table scroll wrapper

Wraps the DataTable to enable vertical scrolling without the page scrolling.

```tsx
<Box display="flex" flexDirection="column">
  <Box
    position="relative"
    flex={1}
    css={{ minHeight: 0, overflowY: 'auto' }}
  >
    <DataTable ... />
  </Box>
</Box>
```

Spark props and documented exceptions — all required for flex scroll behaviour:

- `position="relative"` — scroll container positioning (Box `position` prop)
- `flex={1}` — fills available height (Box `flex` prop)
- `minHeight: 0` — classic flex scroll fix, prevents overflow (documented
  exception — Box `minHeight` only accepts `0` via the prop, so this goes
  through `css`)
- `overflowY: 'auto'` — enables vertical scrolling (documented exception — no
  axis-specific overflow prop, so this goes through `css`)

---

## Section 6 — Table

Always uses `@spark-web/data-table`. See
`node_modules/@spark-web/data-table/CLAUDE.md` for column definition API,
loading/empty states, sorting, and expansion.

Apply canonical list-page header styling via `headerClassName`. Use the named
import `import { css as reactCss } from '@emotion/react'` — `headerClassName`
accepts `SerializedStyles`, not a string class from `@emotion/css`.

```tsx
<DataTable
  className={reactCss({ width: '100%' })}
  headerClassName={reactCss({
    boxShadow: `inset 0 -2px 0 0 ${theme.color.background.primaryDark}`,
    th: {
      color: theme.color.background.primaryDark,
      textTransform: 'capitalize',
      svg: { stroke: theme.color.background.primaryDark },
    },
  })}
  items={rows}
  columns={columns}
  isLoading={isLoading}
  emptyState={
    <Text tone="muted" size="small">
      No records found. Try adjusting your filters.
    </Text>
  }
/>
```

Column rules:

- Default column width: equal flex distribution (`size` unset)
- Actions column: always last, `size: 80`, empty string `header`
- Status column: always `<Badge>` (dot + label) — never `<StatusBadge>`, never
  plain text
- Actions column: always uses `<MeatballMenu>` when 2 or more row-level actions
  exist

Row interaction rules — see
`node_modules/@spark-web/design-system/patterns/internal-admin/CLAUDE.md`:

- Pass `onRowClick` and `enableClickableRow` when the record has a detail view
- Hover state is applied automatically when `enableClickableRow` is true

Status tone mapping — authoritative rules are in
`node_modules/@spark-web/design-system/patterns/internal-admin/CLAUDE.md`. Do
not duplicate them here.

---

## Section 7 — Pagination

Render `TablePagination` outside and below DataTable — never nested inside it.

```tsx
{
  /* COMPONENT GAP: TablePagination is NOT a spark-web component — build a
    placeholder from @spark-web/box + @spark-web/button (prev/next + "Show N of
    M") and flag it for product design review before production. Props:
    total, pageSize, current, dataShowing, onChange(page). */
}
{
  total > PAGE_SIZE && (
    <TablePagination
      total={total}
      pageSize={PAGE_SIZE}
      dataShowing={rows.length}
      onChange={setPage}
      current={page}
    />
  );
}
```

Render-only-when `total > pageSize`, default `pageSize` **20**, and
total-from-a-dedicated-count-query are enforced by the Do NOTs and checklist
below. No additional wrapper needed — content area `gap="large"` handles
spacing.

---

## Structural skeleton

Use this skeleton as the starting point for new builds and uplifts. Do not use
existing page implementations as a structural reference.

```tsx
<Stack height="full" css={{ minHeight: '100vh' }}>
  <PageHeader label={pageTitle} />

  <Stack /* Section 3 content-area props — copy verbatim from Section 3 */>
    <Columns gap="large" collapseBelow="desktop">
      {/* search first, then filters — Section 4 */}
    </Columns>

    {/* Section 5 scroll wrapper — copy verbatim from Section 5, wrapping: */}
    <DataTable
      items={items}
      columns={columns}
      isLoading={isLoading}
      emptyState={emptyState}
    />

    {/* COMPONENT GAP: TablePagination — see Section 7 */}
    {total > PAGE_SIZE && (
      <TablePagination
        total={total}
        pageSize={PAGE_SIZE}
        dataShowing={rows.length}
        onChange={setPage}
        current={page}
      />
    )}
  </Stack>
</Stack>
```

---

## Documented exceptions summary

These raw CSS values are required and have no Spark token equivalent. Use them
exactly as written. Do not substitute alternatives.

| Value                                       | Property                       | Reason                                           |
| ------------------------------------------- | ------------------------------ | ------------------------------------------------ |
| `minHeight: '100vh'`                        | Outer Stack                    | No Spark minHeight prop                          |
| `minHeight: 0`                              | Scroll Box                     | Flex scroll fix — Box `minHeight` only accepts 0 |
| `overflowY: 'auto'`                         | Scroll Box                     | No axis-specific overflow prop                   |
| `backgroundColor: background.neutral`       | Content area                   | Accessed via useTheme(), not a Box prop          |
| `boxShadow: inset 0 -2px ...`               | DataTable `headerClassName`    | Canonical header underline — no token equivalent |
| `textTransform: 'capitalize'`, `svg stroke` | DataTable `headerClassName` th | Canonical header text/icon styling               |
| `width: '100%'`                             | DataTable `className`          | Table fills the scroll wrapper                   |

---

## Do NOTs

- NEVER use `<Container>` as the outer page wrapper — always use
  `<Stack height="full">`. Container constrains width and removes full-height
  layout and scroll containment behaviour
- NEVER place DataTable directly inside the content area Stack — always use the
  `<Box display="flex" flexDirection="column">` + inner scroll `<Box>` wrapper
  from Section 5. Omitting it breaks page scroll containment
- NEVER put pagination inside DataTable
- NEVER render pagination when all records fit on one page — only show it when
  total > pageSize
- NEVER derive the pagination total from `items.length` — always use a dedicated
  count query
- NEVER place filter dropdowns before the search input — search always comes
  first (leftmost)
- NEVER use plain text for status values — always use Badge with `children`
- NEVER import `MeatBall` from `@spark-web/meatball` — the component is
  `MeatballMenu` from `@spark-web/meatball-menu`
- NEVER use `tone="pending"` on Badge — it does not exist; use `tone="info"` for
  pending/awaiting states
- NEVER omit the page header — every list page has an H1 title via PageHeader
- NEVER add padding to the outer Stack wrapper
- NEVER omit the flex scroll wrapper around DataTable
- NEVER omit the empty state and loading state
- NEVER place filter controls inside DataTable
- NEVER hardcode column widths except for the actions column (80px)
- NEVER substitute the documented exception values with alternatives
- NEVER replace PageHeader with a manual Stack + Heading + Text breadcrumb
- NEVER apply detail page spacing (paddingX="xlarge" paddingY="xxlarge") to a
  list page — those values belong in detail-page.md only
- NEVER render an external loading spinner/text outside DataTable — always use
  the `isLoading` prop on DataTable to show loading state
- NEVER use `Stack + Text weight="semibold"` to label a select or multi-select
  filter — labels are provided accessibly but never visibly:
  `labelVisibility="hidden"` on `Field`, or `aria-labelledby` + `VisuallyHidden`
  for `MultiSelect`
- NEVER use `className` with a string from `@emotion/css` on DataTable — use
  `SerializedStyles` from `@emotion/react`'s `css` tagged template

---

## Validation checklist

Run before marking any list-page task complete and fix every violation; the
uplift protocol in `node_modules/@spark-web/design-system/CLAUDE.md` runs this
list PASS/FAIL against existing pages.

1. PageHeader from `@spark-web/page-header` renders the page H1 via `label` — no
   manual Stack + Heading.
2. Outer wrapper is `Stack height="full"` with the documented
   `minHeight: '100vh'` exception — not Container, and no padding on the outer
   wrapper.
3. Content area Stack has `padding="large" gap="large"`, neutral background via
   the `css` prop, and `flex={1}` / `display="flex"` / `flexDirection="column"`
   / `overflow="hidden"` as Spark Box props — nothing else.
4. If search/filtering exists: filter row is
   `Columns gap="large" collapseBelow="desktop"`, search input first (leftmost),
   all labels hidden but accessible — `labelVisibility="hidden"` on each
   `Field`, `aria-labelledby` + `VisuallyHidden` for `MultiSelect` (or the
   consumer-overlay equivalent).
5. DataTable sits inside the flex scroll wrapper from Section 5 — never directly
   in the content Stack.
6. DataTable receives `isLoading` and `emptyState` — no external spinner/loading
   text.
7. Status columns use `<Badge>` (dot + label) with a tone from the surface tone
   mapping — never plain text, never StatusBadge, never `tone="pending"`.
8. Actions column (if present) is last, `size: 80`, empty header; MeatballMenu
   when 2+ row actions; row click/hover follows the surface rules; no hardcoded
   column widths except the actions column.
9. Pagination renders outside DataTable, only when `total > pageSize` (default
   20), with `total` from a dedicated count query.
10. No raw CSS values beyond the Documented exceptions table (which includes the
    Section 6 canonical header styling).
11. Every component is `@spark-web/*` or an explicit consumer-overlay
    substitute; anything missing is flagged with a `// COMPONENT GAP:` comment.
12. DataTable header uses the canonical `headerClassName` styling from Section
    6, passed as `SerializedStyles` from `@emotion/react`'s `css` — never a
    string class from `@emotion/css`.
