@spark-web/design-system
Version:
--- title: Design System ---
344 lines (276 loc) • 12.2 kB
Markdown
# Internal admin — portal-hub consumer overlay
Consumer overlay for the **portal-hub** repo (see Consumer overlays in
`node_modules/@spark-web/design-system/patterns/CLAUDE.md`) — it overrides the
internal-admin pattern files and component-level CLAUDE.md files ONLY where it
explicitly says so; every other rule applies to portal-hub unchanged.
---
## Filter fields (overrides list-page.md Section 4)
`-web/multi-select` is not available to app code in portal-hub (it is a
dependency of the internal ui-components package only). Filter and search fields
come from `/ui-components` instead. This override applies ONLY to
component substitutions; all other Section 4 rules — search input always first,
filter ordering broadest to most specific, hidden labels, omit the section when
no filtering exists — apply unchanged. The substitutions:
- Multi-select filter dropdowns use `MultiSelectField` from
`/ui-components`
- Single-select filter dropdowns use `SelectField` from `/ui-components`
- The search input uses `TextInputField` from `/ui-components` with the
same start-adornment `SearchIcon`
Always pass `fieldProps={{ labelVisibility: 'hidden' as const }}` on both
`MultiSelectField` and `SelectField` — define it as a constant outside the
component to avoid re-renders:
```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>;
```
Note the casing: the prop is `FieldProps` (capital F) on `TextInputField` and
`fieldProps` (lowercase f) on `MultiSelectField`/`SelectField` — match the
snippet exactly.
Do NOTs (portal-hub):
- NEVER import `@spark-web/multi-select` directly in portal-hub app code; use
`MultiSelectField` from `/ui-components`
- NEVER omit the hidden-label `fieldProps` constant on SelectField filter
dropdowns — define it once outside the component and pass it everywhere
---
## Text inputs (overrides the 'Controlled usage with form state' sections in text-input/text-area CLAUDE.md)
In portal-hub, prefer the `Field`-wrapped bindings from `/ui-components`
over hand-wiring `-web/text-input` / `-web/text-area` into forms:
- Prefer `TextInputField` from `/ui-components` when used with
`react-hook-form`. Pass `placeholder` directly, and use `FieldProps` to pass
`description` (renders as muted hint text below the label):
```tsx
<TextInputField
control={control}
name="subject"
label="Subject"
placeholder="Type here..."
FieldProps={{ description: 'Enter a brief summary of the issue' }}
/>
```
- Prefer `TextAreaField` from `/ui-components` when used with
`react-hook-form`. Pass `placeholder` directly:
```tsx
<TextAreaField
control={control}
name="note"
label="Note"
placeholder="Type your note here..."
/>
```
---
## Section cards (overrides detail-page.md Section 7)
portal-hub uses a custom wrapper at `/PortalTable/SectionCard` — not
`-web/section-card`. The API differs from the Spark component:
```tsx
import { SectionCard } from '@components/PortalTable/SectionCard';
<SectionCard label="Section Title">{/* section content */}</SectionCard>;
```
| Prop | Type | Notes |
| ---------- | ----------- | ---------------------------------------------------------------------------- |
| `label` | `string` | Optional — card header text; usually provided — header only renders when set |
| `tag` | `TagProps` | Optional — tag rendered in the card header |
| `action` | `ReactNode` | Optional — right-side header control |
| `controls` | `ReactNode` | Optional — additional header controls |
Return `null` for sections conditionally hidden — never render an empty card.
---
## Modal sizing (ACCREDITATION_MODAL_CSS)
The shared admin modal size constant described in
`node_modules/@spark-web/modal-dialog/CLAUDE.md` already exists in portal-hub —
do not define a new one. The standard size constant used across all admin
confirmation modals is defined in `apps/admin-portal/src/utils/constants.tsx`:
```ts
export const ACCREDITATION_MODAL_CSS = {
width: '100vw',
maxWidth: '550px',
} as const;
```
Always pass this constant via `css={ACCREDITATION_MODAL_CSS}` — never set width
inline or use a raw pixel value.
---
## Row-as-link reference implementation
The row-as-link navigation pattern documented in
`node_modules/@spark-web/data-table/CLAUDE.md` has a reference implementation in
portal-hub at `apps/admin-portal/src/components/RowLink` (uses TanStack Router).
---
## TablePagination
portal-hub supplies its own `TablePagination` component. It is the
`TablePagination` referenced by both the list-page and detail-page patterns.
Props used by the patterns:
| Prop | Notes |
| ------------- | ------------------------------------------------- |
| `total` | Total record count — from a dedicated count query |
| `pageSize` | 20 on list pages; 5 on detail-page section tables |
| `dataShowing` | Number of rows on the current page |
| `onChange` | Page change handler |
| `current` | Current page number |
This is a confirmed COMPONENT GAP — no `-web` pagination component exists
yet. Until one ships, every consumer supplies its own pagination component.
---
## Multi-select with per-item toggle rows
When a multi-select picks N items and each selected item also needs its own
on/off flag, pair the selector with a list of **toggle rows generated from the
current selection** — one bordered row per item, label on the left and an
`-web/switch` `Switch` on the right. The toggle list binds to a
react-hook-form field whose value is a `string[]` of the IDs currently switched
on.
Use it when a per-item boolean accompanies a multi-select choice (e.g. flagging
some of the selected vendors as Authority Contacts). For a single form-wide
boolean use `ToggleField`; for choosing items with no per-item flag, the
multi-select alone is enough.
Two parts:
- **Selector** — `ComboboxField isMulti` or `MultiSelectField` from
`/ui-components` (never `@spark-web/multi-select` directly — see
Filter fields above). Its value is the set of selected items.
- **Toggle rows** — derive `{ id, name }[]` from the current selection and
render one row each, bound to a separate `string[]` field of the "on" IDs.
Render nothing when the selection is empty.
Each row is a bordered card wrapping a space-between flex row, with the Switch's
accessible name supplied via `aria-label` (so the name isn't rendered twice;
target a row with `getByRole('switch', { name })`).
### Row content
Each row's children follow a consistent layout: a label stack on the left and an
optional `Switch` on the right. The label stack contains the item name and an
optional description line (`Text size="small" tone="muted"`) providing context
such as the item's current status or effective date:
```tsx
<ToggleRow variant="active">
<Stack gap="small">
<Text>{item.name}</Text>
<Text size="small" tone="muted">
Current authority contact
</Text>
</Stack>
<Switch
aria-label={item.name}
checked
onCheckedChange={() => toggle(item.id)}
>
{''}
</Switch>
</ToggleRow>
```
The description is optional — when no secondary line is needed, drop the `Stack`
wrapper and render a single `<Text>{item.name}</Text>` directly as the left-side
child of `ToggleRow`.
### Row variants
A toggle row has three visual variants. Use a reusable `ToggleRow` component
(see Reference implementation below) to encapsulate these — never hand-write the
border/background per call site.
**Default** — standard field border, surface background. Used when the switch is
off (item not currently active):
```tsx
<Box border="field" borderRadius="medium" padding="medium" background="surface">
<Box
display="flex"
alignItems="center"
justifyContent="spaceBetween"
gap="medium"
>
{children}
</Box>
</Box>
```
**Active** — primary border with a green tint background. Used when the switch
is on (item currently active):
```tsx
<Box
border="primary"
borderRadius="medium"
padding="medium"
background="primaryLow"
>
<Box
display="flex"
alignItems="center"
justifyContent="spaceBetween"
gap="medium"
>
{children}
</Box>
</Box>
```
**Ended** — field border, reduced opacity, no switch. Used for historical items
that can no longer be changed (e.g. an authority contact whose term has ended).
Show these as read-only rows alongside active toggles when the modal displays
history for context:
```tsx
<Box
border="field"
borderRadius="medium"
padding="medium"
background="surface"
opacity={0.6}
>
<Box
display="flex"
alignItems="center"
justifyContent="spaceBetween"
gap="medium"
>
{children}
</Box>
</Box>
```
**Important:** always provide a `background` value (even `"surface"` for
non-active variants) so the Box's internal `BackgroundProvider` stays mounted
across variant changes — toggling between `background={undefined}` and a value
unmounts the provider and recreates the subtree, which breaks Switch state in
tests and can cause flicker.
### Keeping the toggle field consistent
- **Prune on change** — when an item is deselected (or an upstream condition
turns the section off), drop its id from the toggle field. Do this in the
consumer (a small `useEffect`/watch) and keep the row list presentational.
- **Shape the payload in one place** — map `(selectedIds, onIds)` →
`[{ id, <flag>: onIds.includes(id) }]` in a single helper so the field's wire
shape lives in one spot.
### Reference implementation
> **Note:** paths below are in the **portal-hub** consumer repo
> (`brighte-labs/portal-hub`), not this repository.
`ToggleRow` — a reusable row container at
`apps/admin-portal/src/components/ToggleRow/ToggleRow.tsx` that accepts a
`variant` prop (`'default' | 'active' | 'ended'`) and renders the correct
border/background/opacity combination using only Spark Box tokens. Children are
placed inside the inner flex Box.
Usage in Authority Contacts flows (portal-hub):
- **Edit AC modal** (vendor Overview) —
`apps/admin-portal/src/pages/Vendors/VendorDetail/Overview/EditAuthorityContactsModal.tsx`
— active contacts get `variant="active"` with a Switch, ended contacts get
`variant="ended"` with no Switch.
- **Create/Edit vendor-user** —
`apps/admin-portal/src/components/AuthorityContactToggleList` — the row list
(props: `name`, `control`, `vendors: { id; name }[]`, optional
`label`/`description`/`rules`).
- `apps/admin-portal/src/utils/authorityContact` → `buildVendorAcPayload` —
payload shaping.
- `apps/admin-portal/src/hooks/useAuthorityContactSync` — prunes the field on
role/selection change.
### Do NOTs (portal-hub)
- NEVER pass raw CSS to Spark `Box` layout props — they take token KEYS:
`justifyContent="spaceBetween"` (NOT `"space-between"`),
`alignItems`=`start|center|end|stretch`. An unrecognised value is silently
dropped and the row falls back to `flex-start` (the switch clumps next to the
label instead of aligning right).
- NEVER hand-write border/background per toggle row — use the `ToggleRow`
component with the appropriate `variant`.
- NEVER put visibility/eligibility logic inside the row list — the consumer
gates it and owns pruning the field.