@spark-web/design-system
Version:
--- title: Design System ---
414 lines (330 loc) • 13.9 kB
Markdown
# 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