@spark-web/design-system 5.1.0 → 5.1.1
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spark-web/design-system",
|
|
3
|
-
"version": "5.1.
|
|
3
|
+
"version": "5.1.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"main": "dist/spark-web-design-system.cjs.js",
|
|
6
6
|
"module": "dist/spark-web-design-system.esm.js",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"dependencies": {
|
|
14
14
|
"@spark-web/a11y": "5.3.0",
|
|
15
15
|
"@spark-web/accordion": "5.2.0",
|
|
16
|
-
"@spark-web/action-dropdown": "2.0.
|
|
16
|
+
"@spark-web/action-dropdown": "2.0.1",
|
|
17
17
|
"@spark-web/alert": "5.2.0",
|
|
18
18
|
"@spark-web/analytics": "5.1.0",
|
|
19
19
|
"@spark-web/badge": "^5.1.1",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"@spark-web/description-list": "0.1.0",
|
|
32
32
|
"@spark-web/divider": "5.1.0",
|
|
33
33
|
"@spark-web/dropzone": "6.0.0",
|
|
34
|
-
"@spark-web/field": "5.3.
|
|
34
|
+
"@spark-web/field": "5.3.2",
|
|
35
35
|
"@spark-web/fieldset": "5.1.0",
|
|
36
36
|
"@spark-web/float-input": "6.0.0",
|
|
37
37
|
"@spark-web/heading": "5.2.0",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"@spark-web/row": "5.1.1",
|
|
53
53
|
"@spark-web/section-card": "0.1.0",
|
|
54
54
|
"@spark-web/section-header": "0.1.0",
|
|
55
|
-
"@spark-web/select": "6.0.
|
|
55
|
+
"@spark-web/select": "6.0.2",
|
|
56
56
|
"@spark-web/spinner": "5.1.1",
|
|
57
57
|
"@spark-web/ssr": "5.0.0",
|
|
58
58
|
"@spark-web/stack": "5.1.1",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
"@spark-web/switch": "5.0.3",
|
|
61
61
|
"@spark-web/tabs": "5.2.1",
|
|
62
62
|
"@spark-web/text": "5.3.1",
|
|
63
|
-
"@spark-web/text-area": "6.0.
|
|
64
|
-
"@spark-web/text-input": "6.0.
|
|
63
|
+
"@spark-web/text-area": "6.0.1",
|
|
64
|
+
"@spark-web/text-input": "6.0.2",
|
|
65
65
|
"@spark-web/text-link": "5.4.0",
|
|
66
66
|
"@spark-web/text-list": "5.1.0",
|
|
67
67
|
"@spark-web/theme": "5.13.1",
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
# Internal admin — details page pattern
|
|
2
|
+
|
|
3
|
+
## Before using this pattern
|
|
4
|
+
|
|
5
|
+
Read node_modules/@spark-web/design-system/patterns/internal-admin/CLAUDE.md
|
|
6
|
+
fully before implementing this pattern. The interaction rules, badge tone
|
|
7
|
+
mapping, and action dropdown rules defined there all apply to this pattern.
|
|
8
|
+
|
|
9
|
+
## What this pattern is
|
|
10
|
+
|
|
11
|
+
A full page layout for displaying the detail view of a single record in an
|
|
12
|
+
internal admin interface. The page shows structured data and contextual
|
|
13
|
+
sub-tables in a two-column layout, with record-level actions surfaced through a
|
|
14
|
+
header dropdown.
|
|
15
|
+
|
|
16
|
+
## When to use this pattern
|
|
17
|
+
|
|
18
|
+
Use this pattern when the PRD describes any of the following:
|
|
19
|
+
|
|
20
|
+
- A single-record view reachable by clicking a row on a list page
|
|
21
|
+
- A page showing a record's fields, history, or associated sub-records
|
|
22
|
+
- The words "detail", "profile", "view", "record page", or "user/vendor page"
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Component docs to read
|
|
27
|
+
|
|
28
|
+
Read these before implementing — they own the component-level rules:
|
|
29
|
+
|
|
30
|
+
- `packages/action-dropdown/CLAUDE.md` — dropdown construction, ordering, hide
|
|
31
|
+
vs. disable
|
|
32
|
+
- `packages/modal-dialog/CLAUDE.md` — ContentDialog API,
|
|
33
|
+
ACCREDITATION_MODAL_CSS, destructive modal anatomy
|
|
34
|
+
- `packages/data-table/CLAUDE.md` — DataTable API, loading/empty states,
|
|
35
|
+
expandable rows
|
|
36
|
+
- `packages/tabs/CLAUDE.md` — Tabs API, internal-admin background override, null
|
|
37
|
+
guard
|
|
38
|
+
- `packages/section-card/CLAUDE.md` — SectionCard API (note: portal-hub uses a
|
|
39
|
+
custom wrapper; see Section 6 below)
|
|
40
|
+
- `packages/badge/CLAUDE.md` — status tone mapping
|
|
41
|
+
- `packages/columns/CLAUDE.md` — responsive two-column layout
|
|
42
|
+
- `packages/box/CLAUDE.md` — flex layout utilities
|
|
43
|
+
- `packages/stack/CLAUDE.md` — vertical stacking and gap
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Page structure
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
Outer wrapper Stack paddingX="xlarge" paddingY="xxlarge" gap="xlarge"
|
|
51
|
+
Header Box spaceBetween row: [Heading level="1" + Badge] | [ActionDropdown]
|
|
52
|
+
Page-level feedback Alert conditional — only for inline (non-modal) action feedback
|
|
53
|
+
Modals all ContentDialog modals declared here, controlled by openModal state
|
|
54
|
+
Content Columns template=[1,1] gap="xlarge" collapseBelow="desktop"
|
|
55
|
+
Left column Stack gap="xlarge" — primary record fields + primary sub-tables
|
|
56
|
+
Right column Stack gap="xlarge" — secondary/contextual sections
|
|
57
|
+
SectionCard (per section)
|
|
58
|
+
DataTable PAGE_SIZE=5 items; see data-table/CLAUDE.md
|
|
59
|
+
TablePagination only when total > PAGE_SIZE
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Section 1 — Outer wrapper
|
|
65
|
+
|
|
66
|
+
```tsx
|
|
67
|
+
<Stack height="full" paddingX="xlarge" paddingY="xxlarge" gap="xlarge">
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
All spacing uses Spark tokens. This is distinct from list-page spacing
|
|
71
|
+
(`padding="large"` on a neutral-background Stack) — do not mix the two.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Section 2 — Page header
|
|
76
|
+
|
|
77
|
+
```tsx
|
|
78
|
+
<Box
|
|
79
|
+
display="flex"
|
|
80
|
+
flexDirection={{ mobile: 'column', tablet: 'row' }}
|
|
81
|
+
justifyContent={{ tablet: 'spaceBetween' }}
|
|
82
|
+
gap="medium"
|
|
83
|
+
>
|
|
84
|
+
<Box
|
|
85
|
+
display="flex"
|
|
86
|
+
flexDirection={{ mobile: 'columnReverse', tablet: 'row' }}
|
|
87
|
+
alignItems={{ mobile: 'start', tablet: 'center' }}
|
|
88
|
+
gap={{ mobile: 'medium', tablet: 'small' }}
|
|
89
|
+
>
|
|
90
|
+
<Heading level="1">{recordTitle}</Heading>
|
|
91
|
+
<Badge tone={statusTone}>{statusLabel}</Badge>
|
|
92
|
+
</Box>
|
|
93
|
+
|
|
94
|
+
{actions.length > 0 && (
|
|
95
|
+
<Box className={css({ minWidth: 130 })}>
|
|
96
|
+
<ActionDropdown label="Actions" actions={actions} />
|
|
97
|
+
</Box>
|
|
98
|
+
)}
|
|
99
|
+
</Box>
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Rules:
|
|
103
|
+
|
|
104
|
+
- Status badge follows the heading — never precedes it
|
|
105
|
+
- Only render `ActionDropdown` when `actions.length > 0`
|
|
106
|
+
- No action buttons directly in the header — always use `ActionDropdown`
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
## Section 3 — Actions dropdown
|
|
111
|
+
|
|
112
|
+
See `packages/action-dropdown/CLAUDE.md` for full API and ordering rules.
|
|
113
|
+
|
|
114
|
+
Page-level decisions:
|
|
115
|
+
|
|
116
|
+
- **Order**: non-destructive actions first, restore-type actions second,
|
|
117
|
+
`tone: 'critical'` destructive actions always last
|
|
118
|
+
- **Hide vs. disable**: hide actions permanently unavailable for the current
|
|
119
|
+
record state (conditional spread); disable actions temporarily unavailable
|
|
120
|
+
(e.g. mutation in-flight)
|
|
121
|
+
- **Direct vs. modal**: direct actions (password reset, restore, activate) call
|
|
122
|
+
the mutation inline and surface feedback via page-level `actionStatus` Alert;
|
|
123
|
+
destructive actions (delete, suspend) always open a `ContentDialog` modal
|
|
124
|
+
first
|
|
125
|
+
|
|
126
|
+
---
|
|
127
|
+
|
|
128
|
+
## Section 4 — Page-level feedback
|
|
129
|
+
|
|
130
|
+
```tsx
|
|
131
|
+
{
|
|
132
|
+
actionStatus && (
|
|
133
|
+
<Alert tone={actionStatus.isSuccessful ? 'positive' : 'critical'}>
|
|
134
|
+
<Text>{actionStatus.message}</Text>
|
|
135
|
+
</Alert>
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
State shape: `{ isSuccessful: boolean; message: string } | undefined`
|
|
141
|
+
|
|
142
|
+
Rendered between the header and content columns. Only used for direct inline
|
|
143
|
+
mutations. Modal confirmations own their own error Alert inside the
|
|
144
|
+
`ContentDialog` — see `packages/modal-dialog/CLAUDE.md`.
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## Section 5 — Confirmation modals
|
|
149
|
+
|
|
150
|
+
See `packages/modal-dialog/CLAUDE.md` for ContentDialog API, sizing,
|
|
151
|
+
form-in-modal anatomy, and the full destructive modal pattern.
|
|
152
|
+
|
|
153
|
+
Declare all modals in the component JSX, controlled by a single `openModal`
|
|
154
|
+
state union:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
const [openModal, setOpenModal] = useState<'delete' | 'suspend' | null>(null);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```tsx
|
|
161
|
+
{
|
|
162
|
+
openModal === 'delete' && recordId && (
|
|
163
|
+
<DeleteModal
|
|
164
|
+
isOpen
|
|
165
|
+
onToggle={() => setOpenModal(null)}
|
|
166
|
+
recordId={recordId}
|
|
167
|
+
onSuccess={() => {
|
|
168
|
+
refetch();
|
|
169
|
+
setOpenModal(null);
|
|
170
|
+
}}
|
|
171
|
+
/>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
---
|
|
177
|
+
|
|
178
|
+
## Section 6 — Content columns
|
|
179
|
+
|
|
180
|
+
```tsx
|
|
181
|
+
<Columns gap="xlarge" template={[1, 1]} collapseBelow="desktop">
|
|
182
|
+
<Stack gap="xlarge">{/* left column */}</Stack>
|
|
183
|
+
<Stack gap="xlarge">{/* right column */}</Stack>
|
|
184
|
+
</Columns>
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Always equal columns (`template={[1, 1]}`), always collapse below desktop.
|
|
188
|
+
|
|
189
|
+
---
|
|
190
|
+
|
|
191
|
+
## Section 7 — Section cards
|
|
192
|
+
|
|
193
|
+
Each content section is wrapped in a `SectionCard`.
|
|
194
|
+
|
|
195
|
+
**Portal-hub uses a custom wrapper** at `@components/PortalTable/SectionCard`
|
|
196
|
+
(not `@spark-web/section-card`). The API differs:
|
|
197
|
+
|
|
198
|
+
```tsx
|
|
199
|
+
import { SectionCard } from '@components/PortalTable/SectionCard';
|
|
200
|
+
|
|
201
|
+
<SectionCard label="Section Title">{/* section content */}</SectionCard>;
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
| Prop | Type | Notes |
|
|
205
|
+
| ---------- | ----------- | ------------------------------------- |
|
|
206
|
+
| `label` | `string` | Required — card header text |
|
|
207
|
+
| `action` | `ReactNode` | Optional — right-side header control |
|
|
208
|
+
| `controls` | `ReactNode` | Optional — additional header controls |
|
|
209
|
+
|
|
210
|
+
Return `null` for sections conditionally hidden — never render an empty card.
|
|
211
|
+
|
|
212
|
+
---
|
|
213
|
+
|
|
214
|
+
## Section 8 — Section data tables
|
|
215
|
+
|
|
216
|
+
See `packages/data-table/CLAUDE.md` for DataTable API, column definitions, and
|
|
217
|
+
loading/empty state props.
|
|
218
|
+
|
|
219
|
+
Detail-page-specific rules (differ from list pages):
|
|
220
|
+
|
|
221
|
+
- **`PAGE_SIZE = 5`** — always 5 items per section table (not 20 like list
|
|
222
|
+
pages)
|
|
223
|
+
- **Pagination threshold**: render `TablePagination` only when
|
|
224
|
+
`total > PAGE_SIZE`
|
|
225
|
+
- **Reset page**: reset to 1 when the record context changes (e.g. `userId`)
|
|
226
|
+
|
|
227
|
+
```tsx
|
|
228
|
+
const PAGE_SIZE = 5;
|
|
229
|
+
const [page, setPage] = useState(1);
|
|
230
|
+
|
|
231
|
+
useEffect(() => {
|
|
232
|
+
setPage(1);
|
|
233
|
+
}, [recordId]);
|
|
234
|
+
|
|
235
|
+
// Client-side pagination — fetch all, slice
|
|
236
|
+
const allItems = data?.items ?? [];
|
|
237
|
+
const total = allItems.length;
|
|
238
|
+
const pageItems = allItems.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
|
|
239
|
+
|
|
240
|
+
// Server-side pagination — skip/take API
|
|
241
|
+
const { data } = useQuery({ skip: (page - 1) * PAGE_SIZE, take: PAGE_SIZE });
|
|
242
|
+
const total = countData?.count ?? 0;
|
|
243
|
+
const pageItems = data?.items ?? [];
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
```tsx
|
|
247
|
+
<Stack gap="large">
|
|
248
|
+
<DataTable
|
|
249
|
+
items={pageItems}
|
|
250
|
+
columns={columns}
|
|
251
|
+
isLoading={isLoading}
|
|
252
|
+
emptyState={
|
|
253
|
+
<Text tone="muted" size="small" align="center">
|
|
254
|
+
No items.
|
|
255
|
+
</Text>
|
|
256
|
+
}
|
|
257
|
+
/>
|
|
258
|
+
{total > PAGE_SIZE && (
|
|
259
|
+
<TablePagination
|
|
260
|
+
total={total}
|
|
261
|
+
pageSize={PAGE_SIZE}
|
|
262
|
+
dataShowing={pageItems.length}
|
|
263
|
+
onChange={setPage}
|
|
264
|
+
current={page}
|
|
265
|
+
/>
|
|
266
|
+
)}
|
|
267
|
+
</Stack>
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Section 9 — Tabbed sections
|
|
273
|
+
|
|
274
|
+
See `packages/tabs/CLAUDE.md` for the Tabs API, dynamic tab construction, the
|
|
275
|
+
required internal-admin background override, and the null guard pattern.
|
|
276
|
+
|
|
277
|
+
Use tabs when a section has multiple sub-views (e.g. Email / SMS history). Each
|
|
278
|
+
tab panel follows the same PAGE_SIZE=5 and pagination rules as Section 8.
|
|
279
|
+
|
|
280
|
+
---
|
|
281
|
+
|
|
282
|
+
## Structural skeleton
|
|
283
|
+
|
|
284
|
+
```tsx
|
|
285
|
+
<Stack height="full" paddingX="xlarge" paddingY="xxlarge" gap="xlarge">
|
|
286
|
+
{/* Header */}
|
|
287
|
+
<Box
|
|
288
|
+
display="flex"
|
|
289
|
+
flexDirection={{ mobile: 'column', tablet: 'row' }}
|
|
290
|
+
justifyContent={{ tablet: 'spaceBetween' }}
|
|
291
|
+
gap="medium"
|
|
292
|
+
>
|
|
293
|
+
<Box
|
|
294
|
+
display="flex"
|
|
295
|
+
flexDirection={{ mobile: 'columnReverse', tablet: 'row' }}
|
|
296
|
+
alignItems={{ mobile: 'start', tablet: 'center' }}
|
|
297
|
+
gap={{ mobile: 'medium', tablet: 'small' }}
|
|
298
|
+
>
|
|
299
|
+
<Heading level="1">{recordTitle}</Heading>
|
|
300
|
+
<Badge tone={statusTone}>{statusLabel}</Badge>
|
|
301
|
+
</Box>
|
|
302
|
+
{actions.length > 0 && (
|
|
303
|
+
<Box className={css({ minWidth: 130 })}>
|
|
304
|
+
<ActionDropdown label="Actions" actions={actions} />
|
|
305
|
+
</Box>
|
|
306
|
+
)}
|
|
307
|
+
</Box>
|
|
308
|
+
|
|
309
|
+
{/* Page-level feedback — direct actions only */}
|
|
310
|
+
{actionStatus && (
|
|
311
|
+
<Alert tone={actionStatus.isSuccessful ? 'positive' : 'critical'}>
|
|
312
|
+
<Text>{actionStatus.message}</Text>
|
|
313
|
+
</Alert>
|
|
314
|
+
)}
|
|
315
|
+
|
|
316
|
+
{/* Modals */}
|
|
317
|
+
{openModal === 'delete' && recordId && (
|
|
318
|
+
<DeleteModal
|
|
319
|
+
isOpen
|
|
320
|
+
onToggle={() => setOpenModal(null)}
|
|
321
|
+
recordId={recordId}
|
|
322
|
+
onSuccess={() => {
|
|
323
|
+
refetch();
|
|
324
|
+
setOpenModal(null);
|
|
325
|
+
}}
|
|
326
|
+
/>
|
|
327
|
+
)}
|
|
328
|
+
|
|
329
|
+
{/* Content */}
|
|
330
|
+
<Columns gap="xlarge" template={[1, 1]} collapseBelow="desktop">
|
|
331
|
+
<Stack gap="xlarge">
|
|
332
|
+
<SectionCard label="Details">{/* fields */}</SectionCard>
|
|
333
|
+
</Stack>
|
|
334
|
+
<Stack gap="xlarge">
|
|
335
|
+
<SectionCard label="History">{/* table + pagination */}</SectionCard>
|
|
336
|
+
</Stack>
|
|
337
|
+
</Columns>
|
|
338
|
+
</Stack>
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## Do NOTs
|
|
344
|
+
|
|
345
|
+
- NEVER apply list-page spacing to a detail page — outer wrapper uses
|
|
346
|
+
`paddingX="xlarge" paddingY="xxlarge"`, not `padding="large"`
|
|
347
|
+
- NEVER place a destructive action before non-destructive actions in the
|
|
348
|
+
dropdown
|
|
349
|
+
- NEVER call a destructive mutation directly from a dropdown item — open a modal
|
|
350
|
+
- NEVER surface modal errors as page-level Alerts — see `modal-dialog/CLAUDE.md`
|
|
351
|
+
- NEVER use `PAGE_SIZE = 20` on section tables — always 5 on detail pages
|
|
352
|
+
- NEVER render `TablePagination` when `total <= PAGE_SIZE`
|
|
353
|
+
- NEVER render an empty `SectionCard` for hidden sections — return `null`
|
|
354
|
+
- NEVER render `ActionDropdown` when `actions` is empty — gate with
|
|
355
|
+
`actions.length > 0`
|
|
356
|
+
- NEVER render `Tabs` inside `SectionCard` without the background override — see
|
|
357
|
+
`tabs/CLAUDE.md`
|
|
358
|
+
- NEVER use `Container` as the outer page wrapper
|
|
@@ -23,53 +23,26 @@ Use this pattern when the PRD describes any of the following:
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
-
## Required imports
|
|
27
|
-
|
|
28
|
-
```tsx
|
|
29
|
-
import { Stack } from '@spark-web/stack';
|
|
30
|
-
import { Box } from '@spark-web/box';
|
|
31
|
-
import { Columns } from '@spark-web/columns';
|
|
32
|
-
import { Field } from '@spark-web/field';
|
|
33
|
-
import { PageHeader } from '@spark-web/page-header';
|
|
34
|
-
import { Text } from '@spark-web/text';
|
|
35
|
-
import { TextInput } from '@spark-web/text-input';
|
|
36
|
-
import { createColumnHelper, DataTable } from '@spark-web/data-table';
|
|
37
|
-
import { Badge } from '@spark-web/badge';
|
|
38
|
-
import { MeatballMenu } from '@spark-web/meatball-menu';
|
|
39
|
-
import {
|
|
40
|
-
MultiSelectField,
|
|
41
|
-
SelectField,
|
|
42
|
-
TextInputField,
|
|
43
|
-
} from '@brighte/ui-components'; // portal-hub only — @spark-web/multi-select not available
|
|
44
|
-
import { css } from '@emotion/css';
|
|
45
|
-
import { css as reactCss } from '@emotion/react';
|
|
46
|
-
import { useTheme } from '@spark-web/theme';
|
|
47
|
-
```
|
|
48
|
-
|
|
49
|
-
---
|
|
50
|
-
|
|
51
26
|
## Component docs to read
|
|
52
27
|
|
|
53
|
-
Read these
|
|
28
|
+
Read these before implementing — they own the component-level rules:
|
|
54
29
|
|
|
55
|
-
- packages/page-header/CLAUDE.md
|
|
56
|
-
- packages/data-table/CLAUDE.md
|
|
57
|
-
|
|
58
|
-
- packages/
|
|
59
|
-
- packages/
|
|
60
|
-
- packages/
|
|
61
|
-
- packages/
|
|
62
|
-
- packages/
|
|
63
|
-
- packages/
|
|
64
|
-
- packages/text
|
|
30
|
+
- `packages/page-header/CLAUDE.md` — PageHeader API, action type rules
|
|
31
|
+
- `packages/data-table/CLAUDE.md` — DataTable API, column defs, loading/empty
|
|
32
|
+
states, headerClassName tokens
|
|
33
|
+
- `packages/badge/CLAUDE.md` — status tone mapping
|
|
34
|
+
- `packages/meatball-menu/CLAUDE.md` — MeatballMenu API
|
|
35
|
+
- `packages/stack/CLAUDE.md` — vertical stacking and gap
|
|
36
|
+
- `packages/box/CLAUDE.md` — flex layout utilities
|
|
37
|
+
- `packages/columns/CLAUDE.md` — responsive multi-column layout
|
|
38
|
+
- `packages/field/CLAUDE.md` — Field API, labelVisibility
|
|
39
|
+
- `packages/text/CLAUDE.md` — Text API
|
|
40
|
+
- `packages/text-input/CLAUDE.md` — TextInput API
|
|
65
41
|
|
|
66
42
|
---
|
|
67
43
|
|
|
68
44
|
## Page structure
|
|
69
45
|
|
|
70
|
-
The page is composed in this exact order, top to bottom. Do not change the order
|
|
71
|
-
or omit any required section.
|
|
72
|
-
|
|
73
46
|
```
|
|
74
47
|
Page
|
|
75
48
|
Outer wrapper Stack — full height, no padding
|
|
@@ -86,29 +59,18 @@ Page
|
|
|
86
59
|
|
|
87
60
|
## Section 1 — Page header
|
|
88
61
|
|
|
89
|
-
|
|
90
|
-
|
|
62
|
+
Always use `PageHeader` from `@spark-web/page-header`. Never replace with a
|
|
63
|
+
manual `Stack + Heading`.
|
|
91
64
|
|
|
92
65
|
```tsx
|
|
93
66
|
<PageHeader label={pageTitle} />
|
|
94
67
|
```
|
|
95
68
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
```tsx
|
|
99
|
-
{/* With status badge */}
|
|
100
|
-
<PageHeader label={pageTitle} statusBadge={{ children: 'Active', tone: 'positive' }} />
|
|
101
|
-
|
|
102
|
-
{/* With a single action button */}
|
|
103
|
-
<PageHeader label={pageTitle} action={{ type: 'button', label: 'Add', onClick: handleAdd }} />
|
|
104
|
-
|
|
105
|
-
{/* With overflow menu (2+ actions) */}
|
|
106
|
-
<PageHeader label={pageTitle} action={{ type: 'meatball', items: [...] }} />
|
|
107
|
-
```
|
|
69
|
+
See `packages/page-header/CLAUDE.md` for action types (button, link, meatball),
|
|
70
|
+
statusBadge, and controls props.
|
|
108
71
|
|
|
109
72
|
Rules:
|
|
110
73
|
|
|
111
|
-
- Always use `PageHeader` — never replace with a manual `Stack + Heading`
|
|
112
74
|
- `PageHeader` always renders an H1 — do not pass a different heading level
|
|
113
75
|
- Page-level actions (e.g. "Add new") belong in the `action` prop, not in the
|
|
114
76
|
content area
|
|
@@ -149,15 +111,13 @@ Rules:
|
|
|
149
111
|
The outermost container fills the full viewport height.
|
|
150
112
|
|
|
151
113
|
```tsx
|
|
152
|
-
<Stack
|
|
153
|
-
height="full"
|
|
154
|
-
className={css({ minHeight: '100vh' })}
|
|
155
|
-
>
|
|
114
|
+
<Stack height="full" className={css({ minHeight: '100vh' })}>
|
|
156
115
|
```
|
|
157
116
|
|
|
158
|
-
Documented
|
|
117
|
+
Documented exception:
|
|
159
118
|
|
|
160
|
-
- `minHeight: '100vh'` — ensures page fills viewport
|
|
119
|
+
- `minHeight: '100vh'` — no Spark token equivalent; ensures page fills viewport
|
|
120
|
+
on short content
|
|
161
121
|
|
|
162
122
|
---
|
|
163
123
|
|
|
@@ -201,6 +161,17 @@ Renders filter dropdowns and a search input in a horizontal row.
|
|
|
201
161
|
const FieldProps = { labelVisibility: 'hidden' as const };
|
|
202
162
|
|
|
203
163
|
<Columns gap="large" collapseBelow="desktop">
|
|
164
|
+
<TextInputField
|
|
165
|
+
control={control}
|
|
166
|
+
name="search"
|
|
167
|
+
label="Search"
|
|
168
|
+
placeholder="Search by..."
|
|
169
|
+
FieldProps={FieldProps}
|
|
170
|
+
>
|
|
171
|
+
<InputAdornment placement="start">
|
|
172
|
+
<SearchIcon size="xxsmall" tone="muted" />
|
|
173
|
+
</InputAdornment>
|
|
174
|
+
</TextInputField>
|
|
204
175
|
<MultiSelectField
|
|
205
176
|
control={control}
|
|
206
177
|
name="fieldName"
|
|
@@ -209,14 +180,6 @@ const FieldProps = { labelVisibility: 'hidden' as const };
|
|
|
209
180
|
placeholder="Filter by..."
|
|
210
181
|
fieldProps={FieldProps}
|
|
211
182
|
/>
|
|
212
|
-
<Field label="Search">
|
|
213
|
-
<TextInput
|
|
214
|
-
type="search"
|
|
215
|
-
value={search}
|
|
216
|
-
onChange={e => setSearch(e.target.value)}
|
|
217
|
-
placeholder="Search by..."
|
|
218
|
-
/>
|
|
219
|
-
</Field>
|
|
220
183
|
</Columns>;
|
|
221
184
|
```
|
|
222
185
|
|
|
@@ -224,42 +187,23 @@ Rules:
|
|
|
224
187
|
|
|
225
188
|
- Use `Columns` from @spark-web/columns with `gap="large"`
|
|
226
189
|
- `collapseBelow="desktop"` — stacks vertically on mobile and tablet
|
|
190
|
+
- **Search input always appears first (leftmost)** in the filter row
|
|
191
|
+
- Filter dropdowns follow the search input, ordered by specificity (broadest
|
|
192
|
+
filter first, most specific last)
|
|
227
193
|
- Multi-select filter dropdowns use `MultiSelectField` from
|
|
228
194
|
`@brighte/ui-components` (portal-hub only — `@spark-web/multi-select` is not
|
|
229
|
-
available in portal-hub)
|
|
230
|
-
- Single-select filter dropdowns use `SelectField` from
|
|
231
|
-
`@brighte/ui-components`.
|
|
195
|
+
available in portal-hub)
|
|
196
|
+
- Single-select filter dropdowns use `SelectField` from `@brighte/ui-components`
|
|
232
197
|
- Always pass `fieldProps={{ labelVisibility: 'hidden' as const }}` on both
|
|
233
198
|
`MultiSelectField` and `SelectField` — define it as a constant outside the
|
|
234
|
-
component to avoid re-renders
|
|
235
|
-
label is hidden, not replaced. This rule applies to both components equally.
|
|
236
|
-
|
|
237
|
-
```tsx
|
|
238
|
-
const FieldProps = { labelVisibility: 'hidden' as const };
|
|
239
|
-
|
|
240
|
-
<SelectField
|
|
241
|
-
control={control}
|
|
242
|
-
name="vendor"
|
|
243
|
-
label="Vendor"
|
|
244
|
-
options={vendorOptions}
|
|
245
|
-
placeholder="Filter by vendor..."
|
|
246
|
-
fieldProps={FieldProps}
|
|
247
|
-
/>;
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
- Search input uses `TextInputField` from `@brighte/ui-components` with
|
|
251
|
-
`FieldProps` (same hidden label constant), with `SearchIcon` adornment on
|
|
252
|
-
start and a clear button (`XIcon`) on end
|
|
253
|
-
- Filter controls always appear left of the search input
|
|
254
|
-
- Maximum 2 filter dropdowns before the search input
|
|
255
|
-
- Search input always appears on the far right
|
|
199
|
+
component to avoid re-renders
|
|
256
200
|
- If no filtering or searching is needed, omit this section entirely
|
|
257
201
|
|
|
258
202
|
---
|
|
259
203
|
|
|
260
204
|
## Section 5 — Table scroll wrapper
|
|
261
205
|
|
|
262
|
-
Wraps the
|
|
206
|
+
Wraps the DataTable to enable vertical scrolling without the page scrolling.
|
|
263
207
|
|
|
264
208
|
```tsx
|
|
265
209
|
<Box display="flex" flexDirection="column">
|
|
@@ -271,7 +215,7 @@ Wraps the Table to enable vertical scrolling without the page scrolling.
|
|
|
271
215
|
overflowY: 'auto',
|
|
272
216
|
})}
|
|
273
217
|
>
|
|
274
|
-
<
|
|
218
|
+
<DataTable ... />
|
|
275
219
|
</div>
|
|
276
220
|
</Box>
|
|
277
221
|
```
|
|
@@ -287,12 +231,12 @@ Documented exceptions — all required for flex scroll behaviour:
|
|
|
287
231
|
|
|
288
232
|
## Section 6 — Table
|
|
289
233
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
Define columns outside the component using `createColumnHelper`. Pass `items`
|
|
293
|
-
and `columns` to `<DataTable>`.
|
|
234
|
+
Always uses `@spark-web/data-table`. See `packages/data-table/CLAUDE.md` for
|
|
235
|
+
column definition API, loading/empty states, sorting, and expansion.
|
|
294
236
|
|
|
295
|
-
|
|
237
|
+
Apply canonical list-page header styling via `headerClassName`. Use the named
|
|
238
|
+
import `import { css as reactCss } from '@emotion/react'` — `headerClassName`
|
|
239
|
+
accepts `SerializedStyles`, not a string class from `@emotion/css`.
|
|
296
240
|
|
|
297
241
|
```tsx
|
|
298
242
|
<DataTable
|
|
@@ -305,153 +249,51 @@ Always apply `className` and `headerClassName` for canonical list-page styling:
|
|
|
305
249
|
svg: { stroke: theme.color.background.primaryDark },
|
|
306
250
|
},
|
|
307
251
|
})}
|
|
308
|
-
|
|
252
|
+
items={rows}
|
|
253
|
+
columns={columns}
|
|
254
|
+
isLoading={isLoading}
|
|
255
|
+
emptyState={
|
|
256
|
+
<Text tone="muted" size="small">
|
|
257
|
+
No records found. Try adjusting your filters.
|
|
258
|
+
</Text>
|
|
259
|
+
}
|
|
309
260
|
/>
|
|
310
261
|
```
|
|
311
262
|
|
|
312
|
-
Note: `className` and `headerClassName` accept a `SerializedStyles` object from
|
|
313
|
-
`@emotion/react`'s `css` — not a string class from `@emotion/css`. Use the named
|
|
314
|
-
import `import { css as reactCss } from '@emotion/react'` to distinguish them.
|
|
315
|
-
|
|
316
263
|
Column rules:
|
|
317
264
|
|
|
318
265
|
- Default column width: equal flex distribution (`size` unset)
|
|
319
266
|
- Actions column: always last, `size: 80`, empty string `header`
|
|
320
|
-
- Status column: always
|
|
321
|
-
|
|
267
|
+
- Status column: always `<Badge>` (dot + label) — never `<StatusBadge>`, never
|
|
268
|
+
plain text
|
|
322
269
|
- Actions column: always uses `<MeatballMenu>` when 2 or more row-level actions
|
|
323
270
|
exist
|
|
324
271
|
|
|
325
272
|
Row interaction rules — see
|
|
326
|
-
|
|
273
|
+
`packages/design-system/patterns/internal-admin/CLAUDE.md`:
|
|
327
274
|
|
|
328
275
|
- Pass `onRowClick` and `enableClickableRow` when the record has a detail view
|
|
329
276
|
- Hover state is applied automatically when `enableClickableRow` is true
|
|
330
277
|
|
|
331
278
|
Status tone mapping — authoritative rules are in
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
Table states to always implement:
|
|
336
|
-
|
|
337
|
-
- Default: `items={rows}` with data
|
|
338
|
-
- Loading: `isLoading` prop — renders spinner overlay, replaces data rows
|
|
339
|
-
- Empty: `emptyState={<Text tone="muted" size="small">…</Text>}` — shown when
|
|
340
|
-
`items` is an empty array
|
|
279
|
+
`packages/design-system/patterns/internal-admin/CLAUDE.md`. Do not duplicate
|
|
280
|
+
them here.
|
|
341
281
|
|
|
342
282
|
---
|
|
343
283
|
|
|
344
284
|
## Section 7 — Pagination
|
|
345
285
|
|
|
346
|
-
|
|
347
|
-
or flag as a component gap.
|
|
348
|
-
|
|
349
|
-
- Pagination is rendered outside and below DataTable — never nested inside it
|
|
350
|
-
- Pagination is always rendered regardless of loading or empty state — never
|
|
351
|
-
conditionally hidden.
|
|
352
|
-
|
|
353
|
-
Rules:
|
|
286
|
+
Render `TablePagination` outside and below DataTable — never nested inside it.
|
|
354
287
|
|
|
355
|
-
-
|
|
356
|
-
|
|
288
|
+
- **Only rendered when `total > pageSize`** — hide it when all records fit on
|
|
289
|
+
one page
|
|
290
|
+
- Default `pageSize` for full list pages: **20**
|
|
291
|
+
- Use the real total count from a dedicated count query — never derive total
|
|
292
|
+
from the length of the current page's result set
|
|
357
293
|
- No additional wrapper needed — content area `gap="large"` handles spacing
|
|
358
294
|
|
|
359
295
|
---
|
|
360
296
|
|
|
361
|
-
## Complete assembly
|
|
362
|
-
|
|
363
|
-
```tsx
|
|
364
|
-
const col = createColumnHelper<Row>();
|
|
365
|
-
|
|
366
|
-
const columns = [
|
|
367
|
-
col.accessor('column1', {
|
|
368
|
-
header: 'Column 1',
|
|
369
|
-
cell: info => <Text size="small">{String(info.getValue())}</Text>,
|
|
370
|
-
}),
|
|
371
|
-
col.accessor('status', {
|
|
372
|
-
header: 'Status',
|
|
373
|
-
cell: info => (
|
|
374
|
-
<Badge tone={deriveTone(info.getValue())}>{info.getValue()}</Badge>
|
|
375
|
-
),
|
|
376
|
-
}),
|
|
377
|
-
col.display({
|
|
378
|
-
id: 'actions',
|
|
379
|
-
header: '',
|
|
380
|
-
size: 80,
|
|
381
|
-
cell: ({ row }) => <MeatballMenu items={rowActions(row.original)} />,
|
|
382
|
-
}),
|
|
383
|
-
];
|
|
384
|
-
|
|
385
|
-
// ---
|
|
386
|
-
|
|
387
|
-
const theme = useTheme()
|
|
388
|
-
|
|
389
|
-
<Stack height="full" className={css({ minHeight: '100vh' })}>
|
|
390
|
-
{/* Section 1: Page header */}
|
|
391
|
-
<PageHeader label={pageTitle} />
|
|
392
|
-
|
|
393
|
-
{/* Sections 3–7: Content area — neutral background */}
|
|
394
|
-
<Stack
|
|
395
|
-
padding="large"
|
|
396
|
-
gap="large"
|
|
397
|
-
className={css({
|
|
398
|
-
backgroundColor: theme.color.background.neutral,
|
|
399
|
-
flex: 1,
|
|
400
|
-
display: 'flex',
|
|
401
|
-
flexDirection: 'column',
|
|
402
|
-
overflow: 'hidden',
|
|
403
|
-
})}
|
|
404
|
-
>
|
|
405
|
-
{/* Section 4: Filters */}
|
|
406
|
-
<Columns gap="large" collapseBelow="desktop">
|
|
407
|
-
<MultiSelectField
|
|
408
|
-
control={control}
|
|
409
|
-
name="status"
|
|
410
|
-
label="Status"
|
|
411
|
-
options={statusOptions}
|
|
412
|
-
placeholder="Filter by status"
|
|
413
|
-
fieldProps={FieldProps}
|
|
414
|
-
/>
|
|
415
|
-
<Field label="Search">
|
|
416
|
-
<TextInput type="search" value={search} onChange={e => setSearch(e.target.value)} placeholder="Search by..." />
|
|
417
|
-
</Field>
|
|
418
|
-
</Columns>
|
|
419
|
-
|
|
420
|
-
{/* Section 5 + 6: Table */}
|
|
421
|
-
<Box display="flex" flexDirection="column">
|
|
422
|
-
<div className={css({
|
|
423
|
-
position: 'relative',
|
|
424
|
-
flex: 1,
|
|
425
|
-
minHeight: 0,
|
|
426
|
-
overflowY: 'auto',
|
|
427
|
-
})}>
|
|
428
|
-
<DataTable
|
|
429
|
-
className={reactCss({ width: '100%' })}
|
|
430
|
-
headerClassName={reactCss({
|
|
431
|
-
boxShadow: `inset 0 -2px 0 0 ${theme.color.background.primaryDark}`,
|
|
432
|
-
th: {
|
|
433
|
-
color: theme.color.background.primaryDark,
|
|
434
|
-
textTransform: 'capitalize',
|
|
435
|
-
svg: { stroke: theme.color.background.primaryDark },
|
|
436
|
-
},
|
|
437
|
-
})}
|
|
438
|
-
items={rows}
|
|
439
|
-
columns={columns}
|
|
440
|
-
isLoading={isLoading}
|
|
441
|
-
emptyState={
|
|
442
|
-
<Text tone="muted" size="small">No records found. Try adjusting your filters.</Text>
|
|
443
|
-
}
|
|
444
|
-
/>
|
|
445
|
-
</div>
|
|
446
|
-
|
|
447
|
-
{/* Section 7: Pagination — implement with Spark primitives */}
|
|
448
|
-
</Box>
|
|
449
|
-
</Stack>
|
|
450
|
-
</Stack>
|
|
451
|
-
```
|
|
452
|
-
|
|
453
|
-
---
|
|
454
|
-
|
|
455
297
|
## Structural skeleton
|
|
456
298
|
|
|
457
299
|
Use this skeleton as the starting point for new builds and uplifts. Do not use
|
|
@@ -473,8 +315,7 @@ existing page implementations as a structural reference.
|
|
|
473
315
|
})}
|
|
474
316
|
>
|
|
475
317
|
<Columns gap="large" collapseBelow="desktop">
|
|
476
|
-
{/* filters
|
|
477
|
-
{/* search here */}
|
|
318
|
+
{/* search first, then filters */}
|
|
478
319
|
</Columns>
|
|
479
320
|
|
|
480
321
|
<Box display="flex" flexDirection="column">
|
|
@@ -495,7 +336,15 @@ existing page implementations as a structural reference.
|
|
|
495
336
|
</div>
|
|
496
337
|
</Box>
|
|
497
338
|
|
|
498
|
-
{
|
|
339
|
+
{total > PAGE_SIZE && (
|
|
340
|
+
<TablePagination
|
|
341
|
+
total={total}
|
|
342
|
+
pageSize={PAGE_SIZE}
|
|
343
|
+
dataShowing={rows.length}
|
|
344
|
+
onChange={setPage}
|
|
345
|
+
current={page}
|
|
346
|
+
/>
|
|
347
|
+
)}
|
|
499
348
|
</Stack>
|
|
500
349
|
</Stack>
|
|
501
350
|
```
|
|
@@ -529,6 +378,12 @@ exactly as written. Do not substitute alternatives.
|
|
|
529
378
|
`<Box display="flex" flexDirection="column"> <div className={...}>` scroll
|
|
530
379
|
wrapper from Section 5. Omitting it breaks page scroll containment
|
|
531
380
|
- NEVER put pagination inside DataTable
|
|
381
|
+
- NEVER render pagination when all records fit on one page — only show it when
|
|
382
|
+
total > pageSize
|
|
383
|
+
- NEVER derive the pagination total from `items.length` — always use a dedicated
|
|
384
|
+
count query
|
|
385
|
+
- NEVER place filter dropdowns before the search input — search always comes
|
|
386
|
+
first (leftmost)
|
|
532
387
|
- NEVER use plain text for status values — always use Badge with `children`
|
|
533
388
|
- NEVER import `MeatBall` from `@spark-web/meatball` — the component is
|
|
534
389
|
`MeatballMenu` from `@spark-web/meatball-menu`
|