@spark-web/design-system
Version:
--- title: Design System ---
476 lines (385 loc) • 17.3 kB
Markdown
# 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`.