@spark-web/design-system 5.1.4 → 5.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +38 -192
- package/package.json +20 -21
- package/patterns/CLAUDE.md +75 -35
- package/patterns/internal-admin/CLAUDE.md +7 -19
- package/patterns/internal-admin/{details-page.md → detail-page.md} +114 -51
- package/patterns/internal-admin/list-page.md +199 -137
- package/patterns/internal-admin/portal-hub.md +165 -0
- package/patterns/vendor-admin/CLAUDE.md +216 -0
- package/patterns/vendor-admin/dashboard.md +681 -0
- package/patterns/vendor-admin/form-page.md +504 -0
- package/patterns/vendor-admin/list-page.md +709 -0
- package/patterns/vendor-admin/vendor-portal.md +309 -0
- package/ai-context/layer-1-root.md +0 -158
- package/ai-context/layer-2-surface-pattern.md +0 -236
- package/ai-context/layer-3-component.md +0 -271
|
@@ -0,0 +1,709 @@
|
|
|
1
|
+
# Vendor admin — list page pattern
|
|
2
|
+
|
|
3
|
+
## Before using this pattern
|
|
4
|
+
|
|
5
|
+
Read `node_modules/@spark-web/design-system/patterns/vendor-admin/CLAUDE.md`
|
|
6
|
+
fully before implementing this pattern. The layout shell / content region, the
|
|
7
|
+
`Heading level="2"` page-title rule, the side-panel-vs-full-page rule, the
|
|
8
|
+
infinite-scroll-vs-pagination rule, the bulk-action toast rule, and the badge
|
|
9
|
+
tone mapping defined there all apply to this pattern and take precedence over
|
|
10
|
+
anything below.
|
|
11
|
+
|
|
12
|
+
Working in vendor-portal? Apply the substitutions in
|
|
13
|
+
`node_modules/@spark-web/design-system/patterns/vendor-admin/vendor-portal.md`.
|
|
14
|
+
|
|
15
|
+
## What this pattern is
|
|
16
|
+
|
|
17
|
+
A full page for displaying a searchable, filterable list of records on the
|
|
18
|
+
vendor-admin surface — leads, applications, referrals, and similar — rendered
|
|
19
|
+
inside the vendor-admin shell (fixed Header + left NavBar). It supports two
|
|
20
|
+
record-detail interactions (a slide-in side panel or, where the surface rules
|
|
21
|
+
permit, a full page) and two list-loading strategies (infinite scroll or
|
|
22
|
+
pagination). This is the most common page type on the vendor-admin surface.
|
|
23
|
+
|
|
24
|
+
## When to use this pattern
|
|
25
|
+
|
|
26
|
+
Use this pattern when the PRD describes any of the following:
|
|
27
|
+
|
|
28
|
+
- A list of records a vendor / installer / partner can search, filter, or sort
|
|
29
|
+
- A management interface where records can be viewed, assigned, exported, or
|
|
30
|
+
bulk-acted upon
|
|
31
|
+
- The words "list", "manage", "view all", "leads", "applications", "referrals",
|
|
32
|
+
"records", or "results"
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Component docs to read
|
|
37
|
+
|
|
38
|
+
Read these before implementing — they own the component-level rules:
|
|
39
|
+
|
|
40
|
+
- `node_modules/@spark-web/heading/CLAUDE.md` — Heading API, `level` values
|
|
41
|
+
- `node_modules/@spark-web/badge/CLAUDE.md` — status / count tone mapping
|
|
42
|
+
- `node_modules/@spark-web/button/CLAUDE.md` — action Button (Export) API,
|
|
43
|
+
`loading`, prominence/tone
|
|
44
|
+
- `node_modules/@spark-web/icon/CLAUDE.md` — icon sizing and tones (SearchIcon,
|
|
45
|
+
action icons)
|
|
46
|
+
- `node_modules/@spark-web/field/CLAUDE.md` — Field API, `labelVisibility`,
|
|
47
|
+
`disabled` context (DatePicker reads it)
|
|
48
|
+
- `node_modules/@spark-web/date-picker/CLAUDE.md` — DatePicker API; **must be
|
|
49
|
+
wrapped in a Field** (it reads `disabled` from Field context)
|
|
50
|
+
- `node_modules/@spark-web/multi-select/CLAUDE.md` — MultiSelect filter
|
|
51
|
+
dropdowns; grouped `options` shape
|
|
52
|
+
- `node_modules/@spark-web/a11y/CLAUDE.md` — VisuallyHidden (accessible labels)
|
|
53
|
+
- `node_modules/@spark-web/text-input/CLAUDE.md` — TextInput + InputAdornment
|
|
54
|
+
search field
|
|
55
|
+
- `node_modules/@spark-web/data-table/CLAUDE.md` — DataTable API,
|
|
56
|
+
`createColumnHelper`, `isLoading`, `emptyState`, row click
|
|
57
|
+
- `node_modules/@spark-web/stack/CLAUDE.md` — vertical stacking and gap
|
|
58
|
+
- `node_modules/@spark-web/box/CLAUDE.md` — flex layout utilities
|
|
59
|
+
- `node_modules/@spark-web/columns/CLAUDE.md` — responsive multi-column layout
|
|
60
|
+
- `node_modules/@spark-web/text/CLAUDE.md` — Text API
|
|
61
|
+
- `node_modules/@spark-web/alert/CLAUDE.md` — page-level feedback Alert
|
|
62
|
+
- `node_modules/@spark-web/checkbox/CLAUDE.md` — row-select Checkbox
|
|
63
|
+
(multi-select lists only)
|
|
64
|
+
- `node_modules/@spark-web/modal-dialog/CLAUDE.md` — export / assign modals
|
|
65
|
+
|
|
66
|
+
---
|
|
67
|
+
|
|
68
|
+
## Page structure
|
|
69
|
+
|
|
70
|
+
```
|
|
71
|
+
Page (renders into the vendor-admin shell content region — no Header/NavBar here)
|
|
72
|
+
Page Stack — gap="large", margin="xxlarge" marginBottom="none", height="full", overflow hidden
|
|
73
|
+
Alert (conditional) — page-level success/error feedback; omit if no page-level actions
|
|
74
|
+
Title row (Box flex) — Heading level="2" + optional count Badge (left); right-aligned action Button(s)
|
|
75
|
+
Filter bar (Columns) — date-range (Field+DatePicker ×2), multi-select filter, search TextInput — REQUIRED if filtering exists
|
|
76
|
+
Scroll region (Stack) — position relative, height full, overflow hidden — the side-panel/toast anchor
|
|
77
|
+
Scroll wrapper (Stack) — overflowY scroll — scrolls the table, not the page
|
|
78
|
+
DataTable — items/columns via createColumnHelper, isLoading, emptyState
|
|
79
|
+
SidePanel (conditional) — slide-in detail (COMPONENT GAP — primitives placeholder) — single selected record only
|
|
80
|
+
BulkActionToast (cond.) — bottom-anchored bulk actions (COMPONENT GAP — primitives placeholder) — multi-select, ≥1 row selected
|
|
81
|
+
Pagination (conditional) — only when paginated (COMPONENT GAP — primitives placeholder); omit when infinite-scroll
|
|
82
|
+
Modals (export / assign) — declared in JSX, open via state
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
---
|
|
86
|
+
|
|
87
|
+
## Section 1 — Page Stack (outer wrapper)
|
|
88
|
+
|
|
89
|
+
The page body is a single `Stack` that fills the shell content region and owns
|
|
90
|
+
the page rhythm. Per the surface rules' spacing rule, list pages use
|
|
91
|
+
`gap="large"` with `margin="xxlarge" marginBottom="none"` so the scroll region
|
|
92
|
+
reaches the viewport edge, `height="full"`, and clip overflow so the inner
|
|
93
|
+
scroll region — not the page — scrolls.
|
|
94
|
+
|
|
95
|
+
```tsx
|
|
96
|
+
<Stack
|
|
97
|
+
gap="large"
|
|
98
|
+
margin="xxlarge"
|
|
99
|
+
marginBottom="none"
|
|
100
|
+
height="full"
|
|
101
|
+
overflow="hidden"
|
|
102
|
+
>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Rules:
|
|
106
|
+
|
|
107
|
+
- This is a plain `Stack` — **never** `PageHeader` and **never** `Container`.
|
|
108
|
+
Container is reserved for centered form / settings pages (see form-page.md).
|
|
109
|
+
- All spacing comes from tokens (`gap`, `margin`) — no raw pixel margins.
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
## Section 2 — Page-level feedback (conditional)
|
|
114
|
+
|
|
115
|
+
When a page-level action (CSV export, bulk assign / close) can produce success
|
|
116
|
+
or error feedback, render an `<Alert>` as the first child of the page Stack,
|
|
117
|
+
above the title row. Only rendered when feedback state exists.
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
import { Alert } from '@spark-web/alert';
|
|
121
|
+
|
|
122
|
+
{
|
|
123
|
+
successMessage && (
|
|
124
|
+
<Alert tone="positive" closeLabel="Close" onClose={clearSuccess}>
|
|
125
|
+
{successMessage}
|
|
126
|
+
</Alert>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Rules:
|
|
132
|
+
|
|
133
|
+
- `tone="positive"` for success, `tone="critical"` for failure.
|
|
134
|
+
- An `Alert` with `onClose` requires `closeLabel` (discriminated union — see the
|
|
135
|
+
Alert doc).
|
|
136
|
+
- Feedback state is local component state, reset on filter change or navigation.
|
|
137
|
+
- If the page has no page-level actions, omit this section entirely.
|
|
138
|
+
|
|
139
|
+
---
|
|
140
|
+
|
|
141
|
+
## Section 3 — Title row
|
|
142
|
+
|
|
143
|
+
A flex row: the page title `Heading level="2"` (with an optional count / limit
|
|
144
|
+
`Badge` beside it) on the left, and right-aligned page-level action Button(s).
|
|
145
|
+
Per the surface rules, vendor-admin uses `Heading level="2"` for the page title
|
|
146
|
+
— **never** `PageHeader` and **never** `level="1"`.
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
<Box
|
|
150
|
+
display="flex"
|
|
151
|
+
justifyContent="spaceBetween"
|
|
152
|
+
alignItems="center"
|
|
153
|
+
flexDirection={{ mobile: 'column', tablet: 'row' }}
|
|
154
|
+
>
|
|
155
|
+
<Box display="flex" alignItems="center" gap="medium">
|
|
156
|
+
<Heading level="2">{pageTitle}</Heading>
|
|
157
|
+
{countShown && <Badge tone="info">{`${count} results`}</Badge>}
|
|
158
|
+
</Box>
|
|
159
|
+
|
|
160
|
+
<Box
|
|
161
|
+
gap="medium"
|
|
162
|
+
display="flex"
|
|
163
|
+
marginTop={{ mobile: 'medium', tablet: 'none' }}
|
|
164
|
+
>
|
|
165
|
+
<Button
|
|
166
|
+
onClick={onExport}
|
|
167
|
+
loading={isExporting}
|
|
168
|
+
prominence="none"
|
|
169
|
+
tone="neutral"
|
|
170
|
+
>
|
|
171
|
+
Export all as CSV
|
|
172
|
+
<ArrowCircleRightIcon />
|
|
173
|
+
</Button>
|
|
174
|
+
</Box>
|
|
175
|
+
</Box>
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Rules:
|
|
179
|
+
|
|
180
|
+
- The title is always `Heading level="2"`. Sub-section titles step to
|
|
181
|
+
`level="3"`.
|
|
182
|
+
- Page-level actions (Export, etc.) are right-aligned in their own `Box` — never
|
|
183
|
+
in the content/scroll area. Use the Button `loading` prop for in-flight state;
|
|
184
|
+
do not hand-roll a "Exporting… %" label unless the consumer's export hook
|
|
185
|
+
exposes progress (overlay concern).
|
|
186
|
+
- An optional count / limit indicator is a `Badge` on the left, beside the
|
|
187
|
+
title. Use a tone from the surface badge tone mapping (typically `info` for a
|
|
188
|
+
neutral count; `caution` when approaching a limit).
|
|
189
|
+
- If the page has no page-level actions, render the title row with the heading
|
|
190
|
+
only.
|
|
191
|
+
|
|
192
|
+
---
|
|
193
|
+
|
|
194
|
+
## Section 4 — Filter bar
|
|
195
|
+
|
|
196
|
+
Renders the date-range pickers, the multi-select filter, and the search input in
|
|
197
|
+
a horizontal row that collapses on small screens. Per the surface rules, filter
|
|
198
|
+
rows use `gap="medium"`.
|
|
199
|
+
|
|
200
|
+
```tsx
|
|
201
|
+
<Columns gap="medium" collapseBelow="tablet">
|
|
202
|
+
{/* Date range — always first: From then To */}
|
|
203
|
+
<Field label="From" labelVisibility="hidden">
|
|
204
|
+
<DatePicker value={startDate} onChange={setStartDate} />
|
|
205
|
+
</Field>
|
|
206
|
+
<Field label="To" labelVisibility="hidden">
|
|
207
|
+
<DatePicker value={endDate} onChange={setEndDate} />
|
|
208
|
+
</Field>
|
|
209
|
+
|
|
210
|
+
{/* Multi-select filter */}
|
|
211
|
+
<Box>
|
|
212
|
+
<VisuallyHidden as="span" id="list-filter-label">
|
|
213
|
+
Filters
|
|
214
|
+
</VisuallyHidden>
|
|
215
|
+
<MultiSelect
|
|
216
|
+
aria-labelledby="list-filter-label"
|
|
217
|
+
options={filterOptions}
|
|
218
|
+
placeholder="Filter by..."
|
|
219
|
+
onChange={setFilters}
|
|
220
|
+
/>
|
|
221
|
+
</Box>
|
|
222
|
+
|
|
223
|
+
{/* Search — always last */}
|
|
224
|
+
<Field label="Search" labelVisibility="hidden">
|
|
225
|
+
<TextInput
|
|
226
|
+
type="search"
|
|
227
|
+
inputMode="search"
|
|
228
|
+
placeholder="Search"
|
|
229
|
+
value={search}
|
|
230
|
+
onChange={e => setSearch(e.target.value)}
|
|
231
|
+
>
|
|
232
|
+
<InputAdornment placement="start">
|
|
233
|
+
<SearchIcon size="xxsmall" tone="muted" />
|
|
234
|
+
</InputAdornment>
|
|
235
|
+
</TextInput>
|
|
236
|
+
</Field>
|
|
237
|
+
</Columns>
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Ordering (fixed): **date-range (From, then To) first → multi-select filter →
|
|
241
|
+
search last.** This is the observed vendor-admin order (date range leads, search
|
|
242
|
+
trails) and differs from internal-admin (where search is leftmost). Hold this
|
|
243
|
+
order.
|
|
244
|
+
|
|
245
|
+
Rules:
|
|
246
|
+
|
|
247
|
+
- **Date range** is two `Field` + `DatePicker` pairs, labels `"From"` / `"To"`
|
|
248
|
+
with `labelVisibility="hidden"`. `DatePicker` **must** be wrapped in a `Field`
|
|
249
|
+
— it reads `disabled` from Field context and has no standalone label. See the
|
|
250
|
+
date-picker doc.
|
|
251
|
+
- **Multi-select filter** uses `MultiSelect` from `@spark-web/multi-select`. Its
|
|
252
|
+
`options` are grouped — `Array<{ label, options }>` — which suits the
|
|
253
|
+
vendor-admin multi-facet filter (e.g. Assigned To / Intent / Status /
|
|
254
|
+
Financial Product groups in one control). For a single flat facet pass
|
|
255
|
+
`[{ label: '', options }]`. It renders no visible label, so label it for
|
|
256
|
+
assistive tech via `aria-labelledby` pointing at a `VisuallyHidden` element
|
|
257
|
+
from `@spark-web/a11y`. (vendor-portal substitutes a local
|
|
258
|
+
`MultiselectCheckbox` with the same grouped `options` / `onChange` shape —
|
|
259
|
+
COMPONENT GAP is not implied; the Spark component exists. See the overlay.)
|
|
260
|
+
Filter state is typed `SelectedOptions` — an object keyed by group label →
|
|
261
|
+
`string[]` — **not** `string[]`; do not declare `useState<string[]>` (see
|
|
262
|
+
`node_modules/@spark-web/multi-select/CLAUDE.md`).
|
|
263
|
+
- **Search** is a `TextInput` (`type="search"`) wrapped in a `Field`
|
|
264
|
+
(`label="Search"`, hidden), with a leading `InputAdornment` holding a
|
|
265
|
+
`SearchIcon size="xxsmall" tone="muted"`.
|
|
266
|
+
- Filter labels are never visible — wrap inputs in a `Field` with
|
|
267
|
+
`labelVisibility="hidden"`; the `label` is still required for accessibility,
|
|
268
|
+
and `aria-labelledby` + `VisuallyHidden` for `MultiSelect`.
|
|
269
|
+
- If no filtering or searching is needed, omit this section entirely.
|
|
270
|
+
|
|
271
|
+
---
|
|
272
|
+
|
|
273
|
+
## Section 5 — Scroll region and table
|
|
274
|
+
|
|
275
|
+
The scroll region is the **anchor** for the side panel and the bulk-action toast
|
|
276
|
+
(both are `position: absolute` within it — see Sections 6 and 7). It is a
|
|
277
|
+
`Stack` that is `position="relative"`, `height="full"`, and clips overflow; an
|
|
278
|
+
inner `Stack` scrolls the table vertically.
|
|
279
|
+
|
|
280
|
+
```tsx
|
|
281
|
+
<Stack position="relative" height="full" overflow="hidden">
|
|
282
|
+
<Stack width="full" css={{ overflowY: 'scroll' }}>
|
|
283
|
+
<DataTable
|
|
284
|
+
items={rows}
|
|
285
|
+
columns={columns}
|
|
286
|
+
isLoading={isLoading}
|
|
287
|
+
emptyState={
|
|
288
|
+
<Text tone="muted" size="small">
|
|
289
|
+
No records found. Try adjusting your filters.
|
|
290
|
+
</Text>
|
|
291
|
+
}
|
|
292
|
+
/>
|
|
293
|
+
</Stack>
|
|
294
|
+
|
|
295
|
+
{/* Section 6 — SidePanel (conditional) */}
|
|
296
|
+
{/* Section 7 — BulkActionToast (conditional) */}
|
|
297
|
+
</Stack>
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
`position` / `height` / `overflow` here are **Spark Stack props** (Box props
|
|
301
|
+
forwarded through Stack) — not raw CSS. Only `overflowY: 'scroll'` on the inner
|
|
302
|
+
Stack is a documented exception (no Spark prop for axis-specific overflow).
|
|
303
|
+
|
|
304
|
+
### Table (DataTable)
|
|
305
|
+
|
|
306
|
+
Always uses `@spark-web/data-table`. The **current** API is a single `DataTable`
|
|
307
|
+
that takes `items` + `columns` (built with `createColumnHelper`), `isLoading`,
|
|
308
|
+
and `emptyState`. (vendor-portal pins an older `data-table` whose columns are a
|
|
309
|
+
raw `ColumnDef[]` array; **document and build the current API**, not the pinned
|
|
310
|
+
one.)
|
|
311
|
+
|
|
312
|
+
```tsx
|
|
313
|
+
import { createColumnHelper, DataTable } from '@spark-web/data-table';
|
|
314
|
+
|
|
315
|
+
const columnHelper = createColumnHelper<RecordRow>();
|
|
316
|
+
|
|
317
|
+
const columns = [
|
|
318
|
+
columnHelper.accessor('name', { header: 'Name' }),
|
|
319
|
+
columnHelper.accessor('received', { header: 'Received' }),
|
|
320
|
+
columnHelper.accessor('status', {
|
|
321
|
+
header: 'Status',
|
|
322
|
+
cell: ({ getValue }) => (
|
|
323
|
+
<Badge tone={toneFor(getValue())}>{labelFor(getValue())}</Badge>
|
|
324
|
+
),
|
|
325
|
+
}),
|
|
326
|
+
];
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
Column rules:
|
|
330
|
+
|
|
331
|
+
- The **status column** is always a `<Badge>` (dot + label), tone mapped via the
|
|
332
|
+
surface badge tone mapping (read it in the vendor-admin / internal-admin rules
|
|
333
|
+
— do not re-derive; never `tone="pending"`, use `info`). Never plain text,
|
|
334
|
+
never `StatusBadge` in a table.
|
|
335
|
+
- Default column width: equal flex distribution (`size` unset). Only hardcode a
|
|
336
|
+
width for a fixed-width utility column (e.g. a row-select checkbox or icon).
|
|
337
|
+
- **Row-select checkbox column** (multi-select lists only): a leading column
|
|
338
|
+
whose cell renders `@spark-web/checkbox` `Checkbox` wired to the row's
|
|
339
|
+
selection handlers; stop click propagation so selecting a row does not also
|
|
340
|
+
open its detail panel.
|
|
341
|
+
- **Loading / empty:** pass `isLoading` and `emptyState` to `DataTable` — never
|
|
342
|
+
render an external spinner or "no results" text outside the table.
|
|
343
|
+
- **Row click → detail:** when a record has a detail view, open it via the side
|
|
344
|
+
panel (Section 6), wiring `onRowClick` (and `enableClickableRow`) per the
|
|
345
|
+
data-table doc and the surface row-interaction rules.
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Section 6 — Side-panel detail variant (conditional)
|
|
350
|
+
|
|
351
|
+
Per the surface "Detail interaction rule", vendor-admin opens a record's detail
|
|
352
|
+
in a **slide-in side panel** anchored to the scroll region — not a navigate-to
|
|
353
|
+
detail page. Render the panel as a sibling of the scroll wrapper, inside the
|
|
354
|
+
`position="relative"` scroll region.
|
|
355
|
+
|
|
356
|
+
```tsx
|
|
357
|
+
{
|
|
358
|
+
/* COMPONENT GAP: SidePanel/Drawer needed — not yet in Spark — see Section 6
|
|
359
|
+
rules for the protocol and prop contract. */
|
|
360
|
+
}
|
|
361
|
+
{
|
|
362
|
+
selectedRecordId && selectedRows.length <= 1 && (
|
|
363
|
+
<Box
|
|
364
|
+
position="absolute"
|
|
365
|
+
top={0}
|
|
366
|
+
bottom={0}
|
|
367
|
+
right={0}
|
|
368
|
+
background="surface"
|
|
369
|
+
shadow="large"
|
|
370
|
+
css={{
|
|
371
|
+
width: '90vw',
|
|
372
|
+
overflowY: 'auto',
|
|
373
|
+
['@media (min-width: 768px)']: { maxWidth: '500px' },
|
|
374
|
+
}}
|
|
375
|
+
>
|
|
376
|
+
<Stack gap="large" padding="large">
|
|
377
|
+
<Box display="flex" justifyContent="spaceBetween" alignItems="center">
|
|
378
|
+
<Heading level="3">{panelHeading}</Heading>
|
|
379
|
+
<Button prominence="none" tone="neutral" onClick={closePanel}>
|
|
380
|
+
Close
|
|
381
|
+
</Button>
|
|
382
|
+
</Box>
|
|
383
|
+
{/* record detail body */}
|
|
384
|
+
</Stack>
|
|
385
|
+
</Box>
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
Rules:
|
|
391
|
+
|
|
392
|
+
- **Side panel is a COMPONENT GAP** — there is no `@spark-web` drawer / side
|
|
393
|
+
panel (verification note: see the overlay). **COMPONENT GAP protocol** (this
|
|
394
|
+
applies to every gap in this file): build the placeholder from primitives,
|
|
395
|
+
mark the first use with a `// COMPONENT GAP: <Name> needed — not yet in Spark`
|
|
396
|
+
comment, and flag it for **product design review before production — the
|
|
397
|
+
placeholder is a stop-gap; do not ship as-is**. Prop contract: `heading`
|
|
398
|
+
(string), `onHide` (`() => void`, wired to the close Button), record body as
|
|
399
|
+
children. (In vendor-portal, apply the overlay's `SidePanel` — see the overlay
|
|
400
|
+
for its props.)
|
|
401
|
+
- **When to use a panel vs a full page** (surface rule): use the panel for
|
|
402
|
+
inspecting / lightly acting on a single record while staying in the list;
|
|
403
|
+
reserve a full page for heavyweight standalone flows (multi-step forms,
|
|
404
|
+
settings). Settings is a full page (`Container`-centered), not a panel.
|
|
405
|
+
- Open the panel by setting the selected record — typically via a URL query
|
|
406
|
+
param so the panel is deep-linkable — and render it **only when a single
|
|
407
|
+
record is selected** (`selectedRows.length <= 1`). It overlays the list rather
|
|
408
|
+
than reflowing it.
|
|
409
|
+
|
|
410
|
+
---
|
|
411
|
+
|
|
412
|
+
## Section 7 — Bulk-action toast (conditional)
|
|
413
|
+
|
|
414
|
+
Per the surface "Bulk actions rule", when the list is multi-select and at least
|
|
415
|
+
one row is selected, a **bottom-anchored bulk-action toast** appears, pinned to
|
|
416
|
+
the scroll region. It exposes the actions for the current selection (e.g.
|
|
417
|
+
assign, close) and a way to clear the selection.
|
|
418
|
+
|
|
419
|
+
```tsx
|
|
420
|
+
{
|
|
421
|
+
/* COMPONENT GAP: BulkActionToast needed — not yet in Spark — protocol: see
|
|
422
|
+
Section 6. Prop contract: count (selected rows), disabled (in-flight),
|
|
423
|
+
onClose (clear selection), onActionSubmit (run the bulk action). */
|
|
424
|
+
}
|
|
425
|
+
{
|
|
426
|
+
isMultiSelect && selectedRows.length > 0 && (
|
|
427
|
+
<Box
|
|
428
|
+
position="absolute"
|
|
429
|
+
bottom={0}
|
|
430
|
+
left={0}
|
|
431
|
+
right={0}
|
|
432
|
+
background="surface"
|
|
433
|
+
shadow="large"
|
|
434
|
+
padding="medium"
|
|
435
|
+
display="flex"
|
|
436
|
+
alignItems="center"
|
|
437
|
+
justifyContent="spaceBetween"
|
|
438
|
+
>
|
|
439
|
+
<Text>{`${selectedRows.length} selected`}</Text>
|
|
440
|
+
<Box display="flex" gap="medium" alignItems="center">
|
|
441
|
+
<Button
|
|
442
|
+
prominence="high"
|
|
443
|
+
tone="primary"
|
|
444
|
+
disabled={isBulkMutationRunning}
|
|
445
|
+
onClick={runBulkAction}
|
|
446
|
+
>
|
|
447
|
+
Assign
|
|
448
|
+
</Button>
|
|
449
|
+
<Button prominence="none" tone="neutral" onClick={clearSelection}>
|
|
450
|
+
Clear
|
|
451
|
+
</Button>
|
|
452
|
+
</Box>
|
|
453
|
+
</Box>
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Rules:
|
|
459
|
+
|
|
460
|
+
- Rendered **only when** the list is multi-select **and**
|
|
461
|
+
`selectedRows.length > 0`; hidden when the selection is empty.
|
|
462
|
+
- Mutually exclusive with the side panel (Section 6) — show the panel only for a
|
|
463
|
+
single selected record, the toast only for a multi-row bulk selection.
|
|
464
|
+
- Disable the toast's actions while a bulk mutation is in flight (`disabled`).
|
|
465
|
+
- The toast container is a COMPONENT GAP — build the placeholder above from
|
|
466
|
+
primitives (`Box` anchored absolutely to the scroll region per the surface
|
|
467
|
+
rule, with a count `Text` + action/clear `Button`s) and mark the first use
|
|
468
|
+
with `// COMPONENT GAP: BulkActionToast needed — not yet in Spark` (protocol:
|
|
469
|
+
see Section 6). (In vendor-portal, apply the overlay's component.)
|
|
470
|
+
|
|
471
|
+
---
|
|
472
|
+
|
|
473
|
+
## Section 8 — Pagination (conditional, paginated lists only)
|
|
474
|
+
|
|
475
|
+
Per the surface "List loading rule", choose **infinite scroll** for large /
|
|
476
|
+
streamed / open-ended lists (fetch the next page when the scroll bottom is
|
|
477
|
+
reached and append rows — no pagination control) and **pagination** for bounded,
|
|
478
|
+
countable lists.
|
|
479
|
+
|
|
480
|
+
For paginated lists, render `TablePagination` **outside and below the scroll
|
|
481
|
+
region** — never nested inside DataTable.
|
|
482
|
+
|
|
483
|
+
```tsx
|
|
484
|
+
{
|
|
485
|
+
/* COMPONENT GAP: TablePagination needed — not yet in Spark — protocol: see
|
|
486
|
+
Section 6. Prop contract: total, pageSize, current (page), dataShowing
|
|
487
|
+
(rows on this page), onChange (next page). */
|
|
488
|
+
}
|
|
489
|
+
{
|
|
490
|
+
total > PAGE_SIZE && (
|
|
491
|
+
<Box
|
|
492
|
+
display="flex"
|
|
493
|
+
alignItems="center"
|
|
494
|
+
justifyContent="spaceBetween"
|
|
495
|
+
paddingY="medium"
|
|
496
|
+
>
|
|
497
|
+
<Text tone="muted" size="small">
|
|
498
|
+
{`Showing ${dataShowing} of ${total}`}
|
|
499
|
+
</Text>
|
|
500
|
+
<Box display="flex" gap="medium" alignItems="center">
|
|
501
|
+
<Button
|
|
502
|
+
prominence="low"
|
|
503
|
+
tone="neutral"
|
|
504
|
+
disabled={current <= 1}
|
|
505
|
+
onClick={() => onChange(current - 1)}
|
|
506
|
+
>
|
|
507
|
+
Previous
|
|
508
|
+
</Button>
|
|
509
|
+
<Button
|
|
510
|
+
prominence="low"
|
|
511
|
+
tone="neutral"
|
|
512
|
+
disabled={current * pageSize >= total}
|
|
513
|
+
onClick={() => onChange(current + 1)}
|
|
514
|
+
>
|
|
515
|
+
Next
|
|
516
|
+
</Button>
|
|
517
|
+
</Box>
|
|
518
|
+
</Box>
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
Rules:
|
|
524
|
+
|
|
525
|
+
- **Pagination is a COMPONENT GAP** — there is no `@spark-web` pagination
|
|
526
|
+
component. Build the placeholder above from primitives (Box/Text/Button) and
|
|
527
|
+
mark the first use with
|
|
528
|
+
`// COMPONENT GAP: TablePagination needed — not yet in Spark` (protocol: see
|
|
529
|
+
Section 6). (In vendor-portal, apply the overlay's pagination control — see
|
|
530
|
+
the overlay for its props.)
|
|
531
|
+
- Render pagination **only when** `total > pageSize` and use the real total from
|
|
532
|
+
a dedicated count query — never derive total from `rows.length`.
|
|
533
|
+
- **Omit pagination entirely for infinite-scroll lists.** Infinite scroll and
|
|
534
|
+
pagination are mutually exclusive — pick one per the surface rule.
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
538
|
+
## Section 9 — Modals (export / assign)
|
|
539
|
+
|
|
540
|
+
Page-level modals (an export modal, a bulk-assign modal) are declared in the JSX
|
|
541
|
+
and opened via local state. Build them on `@spark-web/modal-dialog`.
|
|
542
|
+
|
|
543
|
+
```tsx
|
|
544
|
+
<ExportModal isOpen={isExportOpen} onClose={closeExport} onExport={onExport} />
|
|
545
|
+
<AssignModal isOpen={isAssignOpen} onClose={closeAssign} onAssign={onAssign} />
|
|
546
|
+
```
|
|
547
|
+
|
|
548
|
+
Rules:
|
|
549
|
+
|
|
550
|
+
- Modals are always rendered in the tree (their open state is a prop), not
|
|
551
|
+
conditionally mounted, so close transitions work.
|
|
552
|
+
- The export modal pairs with the title-row Export button; the assign modal
|
|
553
|
+
pairs with the bulk-action toast's assign action.
|
|
554
|
+
- Use `@spark-web/modal-dialog` for the dialog chrome — never a custom overlay.
|
|
555
|
+
|
|
556
|
+
---
|
|
557
|
+
|
|
558
|
+
## Structural skeleton
|
|
559
|
+
|
|
560
|
+
Use this skeleton as the starting point for new builds and uplifts. Do not use
|
|
561
|
+
existing page implementations as a structural reference.
|
|
562
|
+
|
|
563
|
+
```tsx
|
|
564
|
+
<Stack
|
|
565
|
+
gap="large"
|
|
566
|
+
margin="xxlarge"
|
|
567
|
+
marginBottom="none"
|
|
568
|
+
height="full"
|
|
569
|
+
overflow="hidden"
|
|
570
|
+
>
|
|
571
|
+
{/* Section 2 — page-level feedback Alert (conditional) — see above */}
|
|
572
|
+
|
|
573
|
+
{/* Section 3 — title row: Heading level="2" + count Badge (left),
|
|
574
|
+
right-aligned action Button(s) — see above */}
|
|
575
|
+
|
|
576
|
+
{/* Section 4 — filter bar Columns: date-range → multi-select → search — see above */}
|
|
577
|
+
|
|
578
|
+
<Stack position="relative" height="full" overflow="hidden">
|
|
579
|
+
<Stack width="full" css={{ overflowY: 'scroll' }}>
|
|
580
|
+
{/* Section 5 — DataTable (items/columns via createColumnHelper,
|
|
581
|
+
isLoading, emptyState) — see above */}
|
|
582
|
+
</Stack>
|
|
583
|
+
|
|
584
|
+
{/* Section 6 — SidePanel placeholder (conditional: single selected record;
|
|
585
|
+
COMPONENT GAP) — see above */}
|
|
586
|
+
|
|
587
|
+
{/* Section 7 — BulkActionToast placeholder (conditional: multi-select,
|
|
588
|
+
≥1 row selected; COMPONENT GAP) — see above */}
|
|
589
|
+
</Stack>
|
|
590
|
+
|
|
591
|
+
{/* Section 8 — TablePagination placeholder (paginated lists only — omit
|
|
592
|
+
entirely for infinite scroll; COMPONENT GAP) — see above */}
|
|
593
|
+
|
|
594
|
+
{/* Section 9 — export / assign modals (declared in JSX, open via state) — see above */}
|
|
595
|
+
</Stack>
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
---
|
|
599
|
+
|
|
600
|
+
## Documented exceptions summary
|
|
601
|
+
|
|
602
|
+
These raw CSS values are required and have no Spark token / prop equivalent. Use
|
|
603
|
+
them exactly as written. Do not substitute alternatives. Everything else uses
|
|
604
|
+
Spark props / tokens (the scroll region's `position`/`height`/`overflow` are
|
|
605
|
+
Stack props, not raw CSS, and so are not listed here).
|
|
606
|
+
|
|
607
|
+
| Value | Property | Reason |
|
|
608
|
+
| ---------------------------- | -------------------- | ----------------------------------------------------------- |
|
|
609
|
+
| `overflowY: 'scroll'` | inner scroll Stack | No Spark prop for axis-specific overflow; scrolls the table |
|
|
610
|
+
| `overflowY: 'auto'` | side-panel `Box` css | No Spark prop for axis-specific overflow; scrolls the panel |
|
|
611
|
+
| `width: '90vw'` | side-panel `Box` css | Mobile panel width — no Spark token equivalent |
|
|
612
|
+
| `maxWidth: '500px'` (≥768px) | side-panel `Box` css | Tablet+ panel width cap per surface rule — no Spark token |
|
|
613
|
+
| `@media (min-width: 768px)` | side-panel `Box` css | Breakpoint for panel width; placeholder is raw-CSS sized |
|
|
614
|
+
|
|
615
|
+
The `position="absolute"`, `top={0}`, `bottom={0}`, `right={0}`, `left={0}` on
|
|
616
|
+
the side-panel / bulk-toast / pagination placeholder `Box`es are **Box props**,
|
|
617
|
+
not raw CSS, so they are not exceptions.
|
|
618
|
+
|
|
619
|
+
---
|
|
620
|
+
|
|
621
|
+
## Do NOTs
|
|
622
|
+
|
|
623
|
+
- NEVER use `PageHeader` for the page title — vendor-admin uses
|
|
624
|
+
`Heading level="2"`. Do not promote it to `level="1"`.
|
|
625
|
+
- NEVER use `<Container>` as the list-page wrapper — the page body is a `Stack`
|
|
626
|
+
with `margin="xxlarge" marginBottom="none"`. Container is for centered
|
|
627
|
+
form/settings pages only.
|
|
628
|
+
- NEVER reproduce the Header or NavBar inside the page — the shell wraps the
|
|
629
|
+
page (surface rule); the page starts at the title row.
|
|
630
|
+
- NEVER place the search input first in the filter bar — vendor-admin order is
|
|
631
|
+
date-range → multi-select → search (search last). (This is the opposite of
|
|
632
|
+
internal-admin; do not "correct" it.)
|
|
633
|
+
- NEVER use a bare `DatePicker` — it must be wrapped in a `Field` (it reads
|
|
634
|
+
`disabled` from Field context and carries the hidden label).
|
|
635
|
+
- NEVER use plain text for status values — always `<Badge>` (dot + label) with a
|
|
636
|
+
tone from the surface tone mapping; never `tone="pending"` (use `info`), never
|
|
637
|
+
`StatusBadge` in a table.
|
|
638
|
+
- NEVER render an external spinner / loading text outside DataTable — use the
|
|
639
|
+
`isLoading` prop; never omit `emptyState`.
|
|
640
|
+
- NEVER render both the side panel and the bulk-action toast at once — they are
|
|
641
|
+
mutually exclusive (panel = single record; toast = multi-row selection).
|
|
642
|
+
- NEVER render pagination for an infinite-scroll list — pick one loading
|
|
643
|
+
strategy per the surface rule; omit the pagination control entirely for
|
|
644
|
+
infinite scroll.
|
|
645
|
+
- NEVER render pagination when all records fit on one page — only when
|
|
646
|
+
`total > pageSize`; never derive `total` from `rows.length`.
|
|
647
|
+
- NEVER put pagination, the side panel, or the toast inside DataTable.
|
|
648
|
+
- NEVER substitute the documented exception values with alternatives.
|
|
649
|
+
- NEVER use the old `data-table` `ColumnDef[]` array API from vendor-portal's
|
|
650
|
+
pinned version — use the current `createColumnHelper` + `items`/`columns` API.
|
|
651
|
+
- NEVER import a side panel, pagination, or bulk-action toast from `@spark-web`
|
|
652
|
+
— they are COMPONENT GAPs. Build each as a primitives placeholder
|
|
653
|
+
(Box/Stack/Heading/Text/Button), mark it with a `// COMPONENT GAP:` comment,
|
|
654
|
+
and flag it for product design review before production — do not ship the
|
|
655
|
+
placeholder as-is. (In vendor-portal, the overlay's local components remain
|
|
656
|
+
the canonical substitution.)
|
|
657
|
+
- NEVER reach for vendor-portal's local `MultiselectCheckbox` in new code — the
|
|
658
|
+
canonical multi-select filter is `@spark-web/multi-select` `MultiSelect`; the
|
|
659
|
+
local wrapper is a retained overlay substitution only.
|
|
660
|
+
|
|
661
|
+
---
|
|
662
|
+
|
|
663
|
+
## Validation checklist
|
|
664
|
+
|
|
665
|
+
Run this checklist before marking any vendor-admin list-page task complete. Fix
|
|
666
|
+
every violation first. The uplift protocol in
|
|
667
|
+
`node_modules/@spark-web/design-system/CLAUDE.md` also runs this checklist
|
|
668
|
+
against existing pages and reports PASS/FAIL per item.
|
|
669
|
+
|
|
670
|
+
1. Page title is `Heading level="2"` (not `PageHeader`, not `level="1"`), with
|
|
671
|
+
any count indicator as a `Badge` beside it; page-level actions (Export) are
|
|
672
|
+
right-aligned Buttons in the title row, not in the content area.
|
|
673
|
+
2. Outer wrapper is a `Stack` with
|
|
674
|
+
`gap="large" margin="xxlarge" marginBottom="none" height="full" overflow="hidden"`
|
|
675
|
+
— not `Container`, not `PageHeader`; the page does not reproduce the shell
|
|
676
|
+
Header / NavBar.
|
|
677
|
+
3. If search/filtering exists: filter bar order is date-range (`Field` +
|
|
678
|
+
`DatePicker`: From then To) → multi-select filter → search (search LAST),
|
|
679
|
+
with `gap="medium"`; all labels hidden but accessible
|
|
680
|
+
(`labelVisibility="hidden"` on each `Field`; `aria-labelledby` +
|
|
681
|
+
`VisuallyHidden` for `MultiSelect`).
|
|
682
|
+
4. Every `DatePicker` is wrapped in a `Field`; the search `TextInput` has a
|
|
683
|
+
leading `InputAdornment` + `SearchIcon size="xxsmall" tone="muted"`.
|
|
684
|
+
5. DataTable uses the current API — `items` + `columns` via
|
|
685
|
+
`createColumnHelper`, with `isLoading` and `emptyState` — never the old
|
|
686
|
+
`ColumnDef[]` array API, never an external spinner/empty text.
|
|
687
|
+
6. Status columns use `<Badge>` (dot + label) with a tone from the surface tone
|
|
688
|
+
mapping — never plain text, never `StatusBadge`, never `tone="pending"`.
|
|
689
|
+
7. DataTable sits inside the scroll region: an outer
|
|
690
|
+
`Stack position="relative" height="full" overflow="hidden"` wrapping an inner
|
|
691
|
+
`Stack css={{ overflowY: 'scroll' }}` — never directly in the page Stack.
|
|
692
|
+
8. Side panel (if the record has detail) is rendered inside the scroll region,
|
|
693
|
+
only when a single record is selected (`selectedRows.length <= 1`), and is
|
|
694
|
+
flagged `// COMPONENT GAP: SidePanel/Drawer needed — not yet in Spark`.
|
|
695
|
+
9. Bulk-action toast (multi-select lists) renders only when `selectedRows.length
|
|
696
|
+
> 0`, is mutually exclusive with the side panel, disables its actions during
|
|
697
|
+
> an in-flight mutation, and is flagged as a COMPONENT GAP where no Spark
|
|
698
|
+
> equivalent exists.
|
|
699
|
+
10. List loading is exactly one of infinite scroll OR pagination (surface rule).
|
|
700
|
+
Pagination renders outside the scroll region, only when `total > pageSize`,
|
|
701
|
+
with `total` from a dedicated count query, flagged
|
|
702
|
+
`// COMPONENT GAP: TablePagination needed — not yet in Spark`; for
|
|
703
|
+
infinite-scroll lists no pagination control is rendered.
|
|
704
|
+
11. Export / assign modals are built on `@spark-web/modal-dialog`, declared in
|
|
705
|
+
the JSX with open state — not custom overlays.
|
|
706
|
+
12. No raw CSS values beyond the Documented exceptions table; every component is
|
|
707
|
+
`@spark-web/*` or an explicit consumer-overlay substitute, and anything
|
|
708
|
+
missing (side panel, pagination, bulk-action toast) is flagged with a
|
|
709
|
+
`// COMPONENT GAP:` comment.
|