@spark-web/design-system
Version:
--- title: Design System ---
422 lines (338 loc) • 14 kB
Markdown
# Internal admin — detail 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 the detail view of a single record in an
internal admin interface. The page shows structured data and contextual
sub-tables in a two-column layout, with record-level actions surfaced through a
header dropdown.
## When to use this pattern
Any single-record detail/profile view — 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/action-dropdown/CLAUDE.md` — dropdown construction,
ordering, hide vs. disable
- `node_modules/@spark-web/modal-dialog/CLAUDE.md` — ContentDialog API, admin
modal sizing, destructive modal anatomy
- `node_modules/@spark-web/data-table/CLAUDE.md` — DataTable API, loading/empty
states, expandable rows
- `node_modules/@spark-web/tabs/CLAUDE.md` — Tabs API, internal-admin background
override, null guard
- `node_modules/@spark-web/section-card/CLAUDE.md` — SectionCard API
- `node_modules/@spark-web/section-header/CLAUDE.md` — SectionHeader API, for
SectionCard headers
- `node_modules/@spark-web/badge/CLAUDE.md` — status tone mapping
- `node_modules/@spark-web/columns/CLAUDE.md` — responsive two-column layout
- `node_modules/@spark-web/box/CLAUDE.md` — flex layout utilities
- `node_modules/@spark-web/stack/CLAUDE.md` — vertical stacking and gap
- `node_modules/@spark-web/heading/CLAUDE.md` — detail-page H1
- `node_modules/@spark-web/alert/CLAUDE.md` — page-level feedback Alert
---
## Page structure
```
Outer wrapper Stack paddingX="xlarge" paddingY="xxlarge" gap="xlarge"
Header Box spaceBetween row: [Heading level="1" + Badge] | [ActionDropdown]
Page-level feedback Alert conditional — only for inline (non-modal) action feedback
Modals all ContentDialog modals declared here, controlled by openModal state
Content Columns template=[1,1] gap="xlarge" collapseBelow="desktop"
Left column Stack gap="xlarge" — primary record fields + primary sub-tables
Right column Stack gap="xlarge" — secondary/contextual sections
SectionCard (per section)
DataTable PAGE_SIZE=5 items; see node_modules/@spark-web/data-table/CLAUDE.md
TablePagination consumer-supplied — only when total > PAGE_SIZE
```
---
## Section 1 — Outer wrapper
```tsx
<Stack height="full" paddingX="xlarge" paddingY="xxlarge" gap="xlarge">
```
All spacing uses Spark tokens. This is distinct from list-page spacing
(`padding="large"` on a neutral-background Stack) — do not mix the two.
---
## Section 2 — Page header
```tsx
<Box
display="flex"
flexDirection={{ mobile: 'column', tablet: 'row' }}
justifyContent={{ tablet: 'spaceBetween' }}
gap="medium"
>
<Box
display="flex"
flexDirection={{ mobile: 'columnReverse', tablet: 'row' }}
alignItems={{ mobile: 'start', tablet: 'center' }}
gap={{ mobile: 'medium', tablet: 'small' }}
>
<Heading level="1">{recordTitle}</Heading>
<Badge tone={statusTone}>{statusLabel}</Badge>
</Box>
{actions.length > 0 && (
<Box css={{ minWidth: 130 }}>
<ActionDropdown label="Actions" actions={actions} />
</Box>
)}
</Box>
```
Rules:
- Status badge follows the heading — never precedes it
- Only render `ActionDropdown` when `actions.length > 0`
- No action buttons directly in the header — always use `ActionDropdown`
---
## Section 3 — Actions dropdown
See `node_modules/@spark-web/action-dropdown/CLAUDE.md` for full API and
ordering rules.
Page-level decisions:
- **Order**: non-destructive actions first, restore-type actions second,
`tone: 'critical'` destructive actions always last
- **Hide vs. disable**: hide actions permanently unavailable for the current
record state (conditional spread); disable actions temporarily unavailable
(e.g. mutation in-flight)
- **Direct vs. modal**: direct actions (password reset, restore, activate) call
the mutation inline and surface feedback via page-level `actionStatus` Alert;
destructive actions (delete, suspend) always open a `ContentDialog` modal
first
---
## Section 4 — Page-level feedback
```tsx
{
actionStatus && (
<Alert tone={actionStatus.isSuccessful ? 'positive' : 'critical'}>
<Text>{actionStatus.message}</Text>
</Alert>
);
}
```
State shape: `{ isSuccessful: boolean; message: string } | undefined`
Rendered between the header and content columns. Only used for direct inline
mutations. Modal confirmations own their own error Alert inside the
`ContentDialog` — see `node_modules/@spark-web/modal-dialog/CLAUDE.md`.
---
## Section 5 — Confirmation modals
See `node_modules/@spark-web/modal-dialog/CLAUDE.md` for ContentDialog API,
sizing, form-in-modal anatomy, and the full destructive modal pattern.
Declare all modals in the component JSX, controlled by a single `openModal`
state union:
```ts
const [openModal, setOpenModal] = useState<'delete' | 'suspend' | null>(null);
```
```tsx
{
openModal === 'delete' && recordId && (
<DeleteModal
isOpen
onToggle={() => setOpenModal(null)}
recordId={recordId}
onSuccess={() => {
refetch();
setOpenModal(null);
}}
/>
);
}
```
---
## Section 6 — Content columns
```tsx
<Columns gap="xlarge" template={[1, 1]} collapseBelow="desktop">
<Stack gap="xlarge">{/* left column */}</Stack>
<Stack gap="xlarge">{/* right column */}</Stack>
</Columns>
```
Always equal columns (`template={[1, 1]}`), always collapse below desktop.
---
## Section 7 — Section cards
Each content section is wrapped in a `SectionCard` from
`@spark-web/section-card`, composed with `SectionHeader` from
`@spark-web/section-header` for the card header:
```tsx
import { SectionCard } from '@spark-web/section-card';
import { SectionHeader } from '@spark-web/section-header';
<SectionCard header={<SectionHeader label="Section Title" />}>
{/* section content */}
</SectionCard>;
```
| Prop | Type | Notes |
| ---------- | ----------- | ------------------------------------------------------- |
| `children` | `ReactNode` | Required — main content area |
| `header` | `ReactNode` | Optional — renders above the content; use SectionHeader |
| `footer` | `ReactNode` | Optional — rendered below a Divider |
The header text (`label`), optional `action`, and optional `controls` belong to
`SectionHeader` — see `node_modules/@spark-web/section-header/CLAUDE.md`.
Return `null` for sections conditionally hidden — never render an empty card.
Working in portal-hub? Apply the substitutions in
`node_modules/@spark-web/design-system/patterns/internal-admin/portal-hub.md`.
---
## Section 8 — Section data tables
See `node_modules/@spark-web/data-table/CLAUDE.md` for DataTable API, column
definitions, and loading/empty state props.
Detail-page-specific rules (differ from list pages):
- **`PAGE_SIZE = 5`** — always 5 items per section table (not 20 like list
pages)
- **Pagination threshold**: render `TablePagination` only when
`total > PAGE_SIZE`. `TablePagination` is NOT a spark-web component (COMPONENT
GAP) — 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)`
- **Reset page**: reset to 1 when the record context changes (e.g. `userId`)
```tsx
const PAGE_SIZE = 5;
const [page, setPage] = useState(1);
useEffect(() => {
setPage(1);
}, [recordId]);
// Client-side pagination — fetch all, slice
const allItems = data?.items ?? [];
const total = allItems.length;
const pageItems = allItems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
// Server-side pagination — skip/take API
const { data } = useQuery({ skip: (page - 1) * PAGE_SIZE, take: PAGE_SIZE });
const total = countData?.count ?? 0;
const pageItems = data?.items ?? [];
```
```tsx
<Stack gap="large">
<DataTable
items={pageItems}
columns={columns}
isLoading={isLoading}
emptyState={
<Text tone="muted" size="small" align="center">
No items.
</Text>
}
/>
{/* COMPONENT GAP: TablePagination — see the pagination rules above */}
{total > PAGE_SIZE && (
<TablePagination
total={total}
pageSize={PAGE_SIZE}
dataShowing={pageItems.length}
onChange={setPage}
current={page}
/>
)}
</Stack>
```
---
## Section 9 — Tabbed sections
See `node_modules/@spark-web/tabs/CLAUDE.md` for the Tabs API, dynamic tab
construction, the required internal-admin background override, and the null
guard pattern.
Use tabs when a section has multiple sub-views (e.g. Email / SMS history). Each
tab panel follows the same PAGE_SIZE=5 and pagination rules as Section 8.
---
## Structural skeleton
```tsx
<Stack height="full" paddingX="xlarge" paddingY="xxlarge" gap="xlarge">
{/* Header */}
<Box
display="flex"
flexDirection={{ mobile: 'column', tablet: 'row' }}
justifyContent={{ tablet: 'spaceBetween' }}
gap="medium"
>
<Box
display="flex"
flexDirection={{ mobile: 'columnReverse', tablet: 'row' }}
alignItems={{ mobile: 'start', tablet: 'center' }}
gap={{ mobile: 'medium', tablet: 'small' }}
>
<Heading level="1">{recordTitle}</Heading>
<Badge tone={statusTone}>{statusLabel}</Badge>
</Box>
{actions.length > 0 && (
<Box css={{ minWidth: 130 }}>
<ActionDropdown label="Actions" actions={actions} />
</Box>
)}
</Box>
{/* Page-level feedback — direct actions only */}
{actionStatus && (
<Alert tone={actionStatus.isSuccessful ? 'positive' : 'critical'}>
<Text>{actionStatus.message}</Text>
</Alert>
)}
{/* Modals */}
{openModal === 'delete' && recordId && (
<DeleteModal
isOpen
onToggle={() => setOpenModal(null)}
recordId={recordId}
onSuccess={() => {
refetch();
setOpenModal(null);
}}
/>
)}
{/* Content */}
<Columns gap="xlarge" template={[1, 1]} collapseBelow="desktop">
<Stack gap="xlarge">
<SectionCard header={<SectionHeader label="Details" />}>
{/* fields */}
</SectionCard>
</Stack>
<Stack gap="xlarge">
<SectionCard header={<SectionHeader label="History" />}>
{/* table + pagination */}
</SectionCard>
</Stack>
</Columns>
</Stack>
```
---
## Documented exceptions summary
These raw CSS values are required and have no Spark token equivalent. Use them
exactly as written.
| Value | Property | Reason |
| --------------- | ---------------------------- | -------------------------------------------------------------------- |
| `minWidth: 130` | ActionDropdown container Box | Prevents dropdown collapse on narrow content; no Spark minWidth prop |
---
## Do NOTs
- NEVER apply list-page spacing to a detail page — outer wrapper uses
`paddingX="xlarge" paddingY="xxlarge"`, not `padding="large"`
- NEVER place a destructive action before non-destructive actions in the
dropdown
- NEVER call a destructive mutation directly from a dropdown item — open a modal
- NEVER surface modal errors as page-level Alerts — see
`node_modules/@spark-web/modal-dialog/CLAUDE.md`
- NEVER use `PAGE_SIZE = 20` on section tables — always 5 on detail pages
- NEVER render `TablePagination` when `total <= PAGE_SIZE`
- NEVER render an empty `SectionCard` for hidden sections — return `null`
- NEVER render `ActionDropdown` when `actions` is empty — gate with
`actions.length > 0`
- NEVER render `Tabs` inside `SectionCard` without the background override — see
`node_modules/@spark-web/tabs/CLAUDE.md`
- NEVER use `Container` as the outer page wrapper
---
## Validation checklist
Run before marking any detail-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. Outer wrapper is
`Stack height="full" paddingX="xlarge" paddingY="xxlarge" gap="xlarge"` — not
list-page spacing, not Container.
2. Header: `Heading level="1"` first, `Badge` after it; ActionDropdown only when
`actions.length > 0`; no inline action buttons in the header.
3. Dropdown action order: non-destructive → restore-type → critical destructive
last; permanently unavailable actions hidden, temporarily unavailable actions
disabled.
4. Destructive actions open a ContentDialog modal; direct actions surface
feedback via the page-level Alert between header and content — modal errors
render inside the modal, never as page-level Alerts.
5. All modals declared in JSX, controlled by a single `openModal` union state.
6. Content is `Columns template={[1, 1]} gap="xlarge" collapseBelow="desktop"`.
7. Every content section is wrapped in a SectionCard (or the documented
consumer-overlay equivalent); conditionally hidden sections return `null`.
8. Section tables use `PAGE_SIZE = 5`; pagination only when `total > PAGE_SIZE`;
page resets when the record context changes.
9. Tabs inside SectionCard use the internal-admin background override per
`node_modules/@spark-web/tabs/CLAUDE.md`.
10. No raw CSS beyond the Documented exceptions table; every component is
`@spark-web/*` or an explicit consumer-overlay substitute; gaps flagged with
`// COMPONENT GAP:`.