@spark-web/design-system 5.1.4 → 5.1.5

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.
@@ -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.