@spark-web/design-system
Version:
--- title: Design System ---
359 lines (283 loc) • 10.7 kB
Markdown
# Internal admin — details 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, and action dropdown rules defined there all apply to this pattern.
## 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
Use this pattern when the PRD describes any of the following:
- A single-record view reachable by clicking a row on a list page
- A page showing a record's fields, history, or associated sub-records
- The words "detail", "profile", "view", "record page", or "user/vendor page"
## Component docs to read
Read these before implementing — they own the component-level rules:
- `packages/action-dropdown/CLAUDE.md` — dropdown construction, ordering, hide
vs. disable
- `packages/modal-dialog/CLAUDE.md` — ContentDialog API,
ACCREDITATION_MODAL_CSS, destructive modal anatomy
- `packages/data-table/CLAUDE.md` — DataTable API, loading/empty states,
expandable rows
- `packages/tabs/CLAUDE.md` — Tabs API, internal-admin background override, null
guard
- `packages/section-card/CLAUDE.md` — SectionCard API (note: portal-hub uses a
custom wrapper; see Section 6 below)
- `packages/badge/CLAUDE.md` — status tone mapping
- `packages/columns/CLAUDE.md` — responsive two-column layout
- `packages/box/CLAUDE.md` — flex layout utilities
- `packages/stack/CLAUDE.md` — vertical stacking and gap
## 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 data-table/CLAUDE.md
TablePagination 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 className={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 `packages/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 `packages/modal-dialog/CLAUDE.md`.
## Section 5 — Confirmation modals
See `packages/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`.
**Portal-hub uses a custom wrapper** at `@components/PortalTable/SectionCard`
(not `@spark-web/section-card`). The API differs:
```tsx
import { SectionCard } from '@components/PortalTable/SectionCard';
<SectionCard label="Section Title">{/* section content */}</SectionCard>;
```
| Prop | Type | Notes |
| ---------- | ----------- | ------------------------------------- |
| `label` | `string` | Required — card header text |
| `action` | `ReactNode` | Optional — right-side header control |
| `controls` | `ReactNode` | Optional — additional header controls |
Return `null` for sections conditionally hidden — never render an empty card.
## Section 8 — Section data tables
See `packages/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`
- **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>
}
/>
{total > PAGE_SIZE && (
<TablePagination
total={total}
pageSize={PAGE_SIZE}
dataShowing={pageItems.length}
onChange={setPage}
current={page}
/>
)}
</Stack>
```
## Section 9 — Tabbed sections
See `packages/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 className={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 label="Details">{/* fields */}</SectionCard>
</Stack>
<Stack gap="xlarge">
<SectionCard label="History">{/* table + pagination */}</SectionCard>
</Stack>
</Columns>
</Stack>
```
## 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 `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
`tabs/CLAUDE.md`
- NEVER use `Container` as the outer page wrapper