@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.0",
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.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.1",
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.1",
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.0",
64
- "@spark-web/text-input": "6.0.1",
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 files at Step 4 no others:
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
- - packages/badge/CLAUDE.md
58
- - packages/meatball-menu/CLAUDE.md
59
- - packages/stack/CLAUDE.md
60
- - packages/box/CLAUDE.md
61
- - packages/columns/CLAUDE.md
62
- - packages/field/CLAUDE.md
63
- - packages/text/CLAUDE.md
64
- - packages/text-input/CLAUDE.md
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
- Renders the page title as an H1 using `PageHeader` from
90
- `@spark-web/page-header`.
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
- Optional variants — see `@spark-web/page-header` CLAUDE.md for full prop docs:
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 exceptions — no Spark token equivalent:
117
+ Documented exception:
159
118
 
160
- - `minHeight: '100vh'` — ensures page fills viewport on short content
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. Never wrap in a `Stack` + `Text` label — the
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 Table to enable vertical scrolling without the page scrolling.
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
- <Table>...</Table>
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
- The core data display. Always uses `@spark-web/data-table`.
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
- Always apply `className` and `headerClassName` for canonical list-page styling:
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 use `<Badge>` (dot + label) — never `<StatusBadge>`,
321
- never plain text
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
- node_modules/@spark-web/design-system/patterns/internal-admin/CLAUDE.md:
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
- node_modules/@spark-web/design-system/patterns/internal-admin/CLAUDE.md. Do not
333
- duplicate them here.
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
- No dedicated Spark pagination component exists. Implement using Spark primitives
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
- - Always implement when the data source is an API or database
356
- - Default pageSize: 20
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 here */}
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
- {/* pagination here */}
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`