UNPKG

@spark-web/design-system

Version:

--- title: Design System ---

476 lines (385 loc) 17.3 kB
# 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`.