# Internal admin — list page pattern

## Before using this pattern

Read node_modules/@spark-web/design-system/patterns/internal-admin/CLAUDE.md
fully before implementing this pattern. The interaction rules, badge tone
mapping, row clickability rules, and overflow menu rules defined there all apply
to this pattern.

## 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

Use this pattern when the PRD describes any of the following:

- A list of records that can be searched, filtered, or sorted
- A management interface where records can be viewed or acted upon
- The words "list", "manage", "view all", "records", or "results"

---

## Component docs to read

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

- `packages/page-header/CLAUDE.md` — PageHeader API, action type rules
- `packages/data-table/CLAUDE.md` — DataTable API, column defs, loading/empty
  states, headerClassName tokens
- `packages/badge/CLAUDE.md` — status tone mapping
- `packages/meatball-menu/CLAUDE.md` — MeatballMenu API
- `packages/stack/CLAUDE.md` — vertical stacking and gap
- `packages/box/CLAUDE.md` — flex layout utilities
- `packages/columns/CLAUDE.md` — responsive multi-column layout
- `packages/field/CLAUDE.md` — Field API, labelVisibility
- `packages/text/CLAUDE.md` — Text API
- `packages/text-input/CLAUDE.md` — TextInput API

---

## 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 `packages/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" className={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"
  className={css({
    backgroundColor: theme.color.background.neutral,
    flex: 1,
    display: 'flex',
    flexDirection: 'column',
    overflow: 'hidden',
  })}
>
```

Token mappings:

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

Documented exceptions:

- `flex: 1` — makes content area fill remaining height, no Spark flex prop
- `display: 'flex'` + `flexDirection: 'column'` — required to activate flex: 1
- `overflow: 'hidden'` — scroll containment, no Spark overflow prop on Stack

---

## Section 4 — Filter container

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

```tsx
const FieldProps = { labelVisibility: 'hidden' as const };

<Columns gap="large" collapseBelow="desktop">
  <TextInputField
    control={control}
    name="search"
    label="Search"
    placeholder="Search by..."
    FieldProps={FieldProps}
  >
    <InputAdornment placement="start">
      <SearchIcon size="xxsmall" tone="muted" />
    </InputAdornment>
  </TextInputField>
  <MultiSelectField
    control={control}
    name="fieldName"
    label="Label"
    options={options}
    placeholder="Filter by..."
    fieldProps={FieldProps}
  />
</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 `MultiSelectField` from
  `@brighte/ui-components` (portal-hub only — `@spark-web/multi-select` is not
  available in portal-hub)
- Single-select filter dropdowns use `SelectField` from `@brighte/ui-components`
- Always pass `fieldProps={{ labelVisibility: 'hidden' as const }}` on both
  `MultiSelectField` and `SelectField` — define it as a constant outside the
  component to avoid re-renders
- 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">
  <div
    className={css({
      position: 'relative',
      flex: 1,
      minHeight: 0,
      overflowY: 'auto',
    })}
  >
    <DataTable ... />
  </div>
</Box>
```

Documented exceptions — all required for flex scroll behaviour:

- `position: 'relative'` — scroll container positioning
- `flex: 1` — fills available height
- `minHeight: 0` — classic flex scroll fix, prevents overflow
- `overflowY: 'auto'` — enables vertical scrolling

---

## Section 6 — Table

Always uses `@spark-web/data-table`. See `packages/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
`packages/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
`packages/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.

- **Only rendered when `total > pageSize`** — hide it when all records fit on
  one page
- Default `pageSize` for full list pages: **20**
- Use the real total count from a dedicated count query — never derive total
  from the length of the current page's result set
- 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" className={css({ minHeight: '100vh' })}>
  <PageHeader label={pageTitle} />

  <Stack
    padding="large"
    gap="large"
    className={css({
      backgroundColor: theme.color.background.neutral,
      flex: 1,
      display: 'flex',
      flexDirection: 'column',
      overflow: 'hidden',
    })}
  >
    <Columns gap="large" collapseBelow="desktop">
      {/* search first, then filters */}
    </Columns>

    <Box display="flex" flexDirection="column">
      <div
        className={css({
          position: 'relative',
          flex: 1,
          minHeight: 0,
          overflowY: 'auto',
        })}
      >
        <DataTable
          items={items}
          columns={columns}
          isLoading={isLoading}
          emptyState={emptyState}
        />
      </div>
    </Box>

    {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                 |
| `flex: 1`                                     | Content area, scroll wrapper | No Spark flex prop                      |
| `display: 'flex'` + `flexDirection: 'column'` | Content area                 | Required for flex: 1                    |
| `overflow: 'hidden'`                          | Content area                 | No Spark overflow on Stack              |
| `position: 'relative'`                        | Scroll div                   | No Spark position prop                  |
| `minHeight: 0`                                | Scroll div                   | Flex scroll fix                         |
| `overflowY: 'auto'`                           | Scroll div                   | No Spark overflow prop                  |
| `backgroundColor: background.neutral`         | Content area                 | Accessed via useTheme(), not a Box prop |

---

## 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"> <div className={...}>` scroll
  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 MultiSelectField or
  SelectField filter — always use
  `fieldProps={{ labelVisibility: 'hidden' as const }}`
- NEVER omit `fieldProps={{ labelVisibility: 'hidden' as const }}` on
  SelectField filter dropdowns — the same hidden label rule that applies to
  MultiSelectField applies to SelectField equally
- NEVER use `@spark-web/multi-select` in portal-hub — it is not installed; use
  `MultiSelectField` from `@brighte/ui-components`
- NEVER use `className` with a string from `@emotion/css` on DataTable — use
  `SerializedStyles` from `@emotion/react`'s `css` tagged template
