@structuralists/scaffolding 0.13.0 → 0.14.0

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.
@@ -47,6 +47,9 @@ const preview: Preview = {
47
47
  options: {
48
48
  storySort: {
49
49
  order: [
50
+ // Demo: polished illustrations of what can be built — first thing
51
+ // a visitor sees.
52
+ 'Demo',
50
53
  // Showcase: how the pieces fit together.
51
54
  'Composition',
52
55
  // Main library, alphabetical.
package/AGENTS.md CHANGED
@@ -63,6 +63,17 @@ The Storybook toolbar has a Theme control (paintbrush icon) listing all six
63
63
  leaves the attribute unset (prefers-color-scheme fallback) and is the initial
64
64
  value, so the vitest story run is unaffected by the toggle.
65
65
 
66
+ ## The Demo section
67
+
68
+ The first Storybook section (`'Demo'` in `storySort` in
69
+ `.storybook/preview.tsx`) holds polished showcase compositions — full
70
+ mini-apps built entirely from the library's public pieces, not component API
71
+ docs. Demo stories live in `src/storybook/` (like `Composition.stories.tsx`)
72
+ with titles of the form `Demo/<Name>`, keep their datasets deterministic (no
73
+ `Date.now()`/randomness at render), and double as integration tests via
74
+ `play` functions. Don't add new public components for a demo; compose from
75
+ what exists.
76
+
66
77
  ## Testing
67
78
 
68
79
  ### Story tests (the main gate)
package/README.md CHANGED
@@ -53,7 +53,7 @@ export const SignupCard = () => {
53
53
  };
54
54
  ```
55
55
 
56
- Browse every component (with live controls) in the Storybook: `bun run storybook`. The toolbar's Theme control (paintbrush icon) previews any of the themes above — "System (default)" leaves `data-theme` unset, following system preference.
56
+ Browse every component (with live controls) in the Storybook: `bun run storybook`. The sidebar opens with a **Demo** section — full mini-apps (like a CRUD Team Directory) composed entirely from the library's public pieces, showing what an app scaffolded with it looks like. The toolbar's Theme control (paintbrush icon) previews any of the themes above — "System (default)" leaves `data-theme` unset, following system preference.
57
57
 
58
58
  ## Consumer notes
59
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -23,6 +23,15 @@ What still applies: CSS modules, `--ui-*` tokens, no HTML attribute pass-through
23
23
 
24
24
  If a story util starts getting reached for by app code, that's a signal to build a real primitive, not to promote the story util. A real `Placeholder` for empty states would be a different primitive with proper typography, icon support, and action slots — not this dashed box. Keep the lanes separate.
25
25
 
26
+ ## Showcase story files live here too
27
+
28
+ Besides the utils, this folder hosts the cross-component showcase stories:
29
+ `Composition.stories.tsx` (`Composition/…`) and `Demo.stories.tsx` (`Demo/…`
30
+ — the polished mini-app demos; conventions in the root AGENTS.md "The Demo
31
+ section"). They are ordinary stories, not utils: they compose the library's
32
+ public pieces, keep their datasets deterministic, and carry `play`-function
33
+ integration tests. The relaxed util rules above don't apply to them.
34
+
26
35
  ## Adding a new util
27
36
 
28
37
  - New folder under `src/_StoryUtils/<Name>/` with `index.tsx`.
@@ -0,0 +1,467 @@
1
+ import { useRef, useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { expect, userEvent, waitFor, within } from 'storybook/test';
4
+ import {
5
+ BigTable,
6
+ badgeColumn,
7
+ booleanColumn,
8
+ currencyColumn,
9
+ dateColumn,
10
+ textColumn,
11
+ } from '../components/Tables/BigTable';
12
+ import type { ColumnDef } from '../components/Tables/BigTable';
13
+ import { Bar } from '../components/Layout/Bar';
14
+ import { Panels } from '../components/Layout/Panels';
15
+ import { Stack } from '../components/Layout/Stack';
16
+ import { Text } from '../components/Content/Text';
17
+ import { MediumModal } from '../components/Modals/MediumModal';
18
+ import { ConfirmModal } from '../components/Modals/ConfirmModal';
19
+ import { Button } from '../forms/elements/Button';
20
+ import { IconButton } from '../forms/elements/IconButton';
21
+ import type { SelectOption } from '../forms/elements/Select';
22
+ import { useFormState } from '../forms/state/useFormState/useFormState';
23
+ import { matches, notEmpty } from '../forms/state/validators/validators';
24
+ import { TextInputForForm } from '../forms/state/bindings/TextInputForForm';
25
+ import { SingleSelectForForm } from '../forms/state/bindings/SingleSelectForForm';
26
+
27
+ // The Demo section is a showcase: polished compositions of the library's
28
+ // public pieces, not component API docs. This one is the full CRUD loop —
29
+ // BigTable + MediumModal + useFormState field bindings + ConfirmModal —
30
+ // wired together the way a consuming app would.
31
+ const meta: Meta = {
32
+ title: 'Demo/Team Directory',
33
+ parameters: { layout: 'fullscreen' },
34
+ };
35
+
36
+ export default meta;
37
+ type Story = StoryObj;
38
+
39
+ const ROLES = ['engineer', 'designer', 'pm', 'sales'] as const;
40
+ type Role = (typeof ROLES)[number];
41
+
42
+ const TEAMS = ['Platform', 'Growth', 'Infra', 'Brand', 'Revenue', 'Search'] as const;
43
+ type Team = (typeof TEAMS)[number];
44
+
45
+ const ROLE_OPTIONS: SelectOption<Role>[] = [
46
+ { value: 'engineer', label: 'Engineer' },
47
+ { value: 'designer', label: 'Designer' },
48
+ { value: 'pm', label: 'PM' },
49
+ { value: 'sales', label: 'Sales' },
50
+ ];
51
+
52
+ const TEAM_OPTIONS: SelectOption<Team>[] = TEAMS.map((team) => ({
53
+ value: team,
54
+ label: team,
55
+ }));
56
+
57
+ type Member = {
58
+ id: string;
59
+ name: string;
60
+ email: string;
61
+ role: Role;
62
+ team: Team;
63
+ salary: number;
64
+ startDate: string; // ISO
65
+ isActive: boolean;
66
+ };
67
+
68
+ const FIRST_NAMES = [
69
+ 'Ada', 'Alan', 'Edsger', 'Barbara', 'Donald', 'Margaret',
70
+ 'John', 'Katherine', 'Dennis', 'Radia', 'Ken', 'Frances',
71
+ ];
72
+ const LAST_NAMES = [
73
+ 'Lovelace', 'Turing', 'Dijkstra', 'Liskov', 'Knuth',
74
+ 'Hamilton', 'Backus', 'Johnson', 'Ritchie',
75
+ ];
76
+
77
+ // Deterministic: every value is index arithmetic over literal arrays — the
78
+ // same 34 rows on every render, so the story (and its play test) never
79
+ // flickers. The (i % 12, 5i % 9) name pairing has period 36, so all 34
80
+ // name/email combos are distinct.
81
+ const SEED_MEMBERS: Member[] = Array.from({ length: 34 }, (_, i) => {
82
+ const first = FIRST_NAMES[i % FIRST_NAMES.length];
83
+ const last = LAST_NAMES[(i * 5) % LAST_NAMES.length];
84
+ return {
85
+ id: `emp_${100 + i}`,
86
+ name: `${first} ${last}`,
87
+ email: `${first.toLowerCase()}.${last.toLowerCase()}@acme.dev`,
88
+ role: ROLES[i % ROLES.length],
89
+ team: TEAMS[(i * 7) % TEAMS.length],
90
+ salary: 95_000 + (i % 7) * 8_000 + (i % 3) * 2_500,
91
+ startDate: new Date(
92
+ Date.UTC(2019 + (i % 6), (i * 5) % 12, ((i * 7) % 27) + 1),
93
+ ).toISOString(),
94
+ isActive: i % 6 !== 0,
95
+ };
96
+ });
97
+
98
+ // What the modal form holds while editing: loose everywhere a field starts
99
+ // blank. Salary is a string because it's typed into a text input; the
100
+ // digits-only constraint plus Number() at the boundary turn it back into
101
+ // a number.
102
+ type MemberFormValues = {
103
+ name: string | undefined;
104
+ email: string | undefined;
105
+ role: Role | null;
106
+ team: Team | null;
107
+ salary: string | undefined;
108
+ };
109
+
110
+ // What onSubmit receives: the constraints below strip undefined/null from
111
+ // every field. This type annotating the parent's handler compiles only
112
+ // because useFormState actually delivers the narrowed type.
113
+ type MemberFormSubmit = {
114
+ name: string;
115
+ email: string;
116
+ role: Role;
117
+ team: Team;
118
+ salary: string;
119
+ };
120
+
121
+ const EMPTY_MEMBER_FORM: MemberFormValues = {
122
+ name: undefined,
123
+ email: undefined,
124
+ role: null,
125
+ team: null,
126
+ salary: undefined,
127
+ };
128
+
129
+ type MemberFormModalProps = {
130
+ isOpen: boolean;
131
+ title: string;
132
+ submitLabel: string;
133
+ initialValues: MemberFormValues;
134
+ onCancel: () => void;
135
+ onSubmit: (values: MemberFormSubmit) => void;
136
+ };
137
+
138
+ // One modal serves both add and edit — the parent remounts it fresh per
139
+ // opening (via key) with the right initialValues, so the hook state never
140
+ // leaks between sessions. Every field is wired with one getFormFieldPropsAt
141
+ // expression; error display (touched OR submit-attempted) comes with it.
142
+ const MemberFormModal = (props: MemberFormModalProps) => {
143
+ const { isOpen, title, submitLabel, initialValues, onCancel, onSubmit } =
144
+ props;
145
+
146
+ const { getFormFieldPropsAt, submit } = useFormState({
147
+ initialValues,
148
+ constraints: {
149
+ name: notEmpty('name'),
150
+ email: [
151
+ notEmpty('email'),
152
+ matches('email', /^[^@\s]+@[^@\s]+\.[^@\s]+$/, 'a valid email'),
153
+ ],
154
+ role: notEmpty('role'),
155
+ team: notEmpty('team'),
156
+ salary: [
157
+ notEmpty('salary'),
158
+ matches('salary', /^\d+$/, 'a whole dollar amount (digits only)'),
159
+ ],
160
+ },
161
+ onSubmit,
162
+ });
163
+
164
+ return (
165
+ <MediumModal
166
+ isOpen={isOpen}
167
+ onClose={onCancel}
168
+ title={title}
169
+ footer={
170
+ <>
171
+ <Button onClick={onCancel}>Cancel</Button>
172
+ <Button variant="primary" onClick={() => submit()}>
173
+ {submitLabel}
174
+ </Button>
175
+ </>
176
+ }
177
+ >
178
+ <Stack gap={3}>
179
+ <TextInputForForm
180
+ label="Full name"
181
+ placeholder="Ada Lovelace"
182
+ formFieldProps={getFormFieldPropsAt(['name'])}
183
+ />
184
+ <TextInputForForm
185
+ label="Email"
186
+ type="email"
187
+ placeholder="ada.lovelace@acme.dev"
188
+ formFieldProps={getFormFieldPropsAt(['email'])}
189
+ />
190
+ <SingleSelectForForm
191
+ label="Role"
192
+ options={ROLE_OPTIONS}
193
+ placeholder="Pick a role"
194
+ formFieldProps={getFormFieldPropsAt(['role'])}
195
+ />
196
+ <SingleSelectForForm
197
+ label="Team"
198
+ options={TEAM_OPTIONS}
199
+ placeholder="Pick a team"
200
+ formFieldProps={getFormFieldPropsAt(['team'])}
201
+ />
202
+ <TextInputForForm
203
+ label="Salary (USD)"
204
+ hint="Whole dollars, digits only"
205
+ placeholder="120000"
206
+ formFieldProps={getFormFieldPropsAt(['salary'])}
207
+ />
208
+ </Stack>
209
+ </MediumModal>
210
+ );
211
+ };
212
+
213
+ const PencilIcon = () => (
214
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
215
+ <path
216
+ d="M11.5 2.5 L13.5 4.5 L5.5 12.5 L2.8 13.2 L3.5 10.5 Z"
217
+ stroke="currentColor"
218
+ strokeWidth="1.5"
219
+ strokeLinejoin="round"
220
+ />
221
+ </svg>
222
+ );
223
+
224
+ const TrashIcon = () => (
225
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
226
+ <path
227
+ d="M3 4.5 H13 M6.5 4.5 V3 H9.5 V4.5 M4.5 4.5 L5.2 13 H10.8 L11.5 4.5 M6.8 7 V10.7 M9.2 7 V10.7"
228
+ stroke="currentColor"
229
+ strokeWidth="1.5"
230
+ strokeLinecap="round"
231
+ strokeLinejoin="round"
232
+ />
233
+ </svg>
234
+ );
235
+
236
+ type EditorSession = { mode: 'add' } | { mode: 'edit'; member: Member };
237
+
238
+ // The session outlives the open flag so the form keeps rendering its
239
+ // contents while ModalShell plays the 200ms exit animation; sessionId keys
240
+ // a fresh remount per opening so form state never leaks between sessions.
241
+ type EditorState = {
242
+ session: EditorSession;
243
+ sessionId: number;
244
+ isOpen: boolean;
245
+ };
246
+
247
+ const TeamDirectoryDemo = () => {
248
+ const [rows, setRows] = useState<Member[]>(SEED_MEMBERS);
249
+ const [editor, setEditor] = useState<EditorState | null>(null);
250
+ const [pendingDelete, setPendingDelete] = useState<Member | null>(null);
251
+ const nextIdRef = useRef(100 + SEED_MEMBERS.length);
252
+
253
+ const openEditor = (session: EditorSession) =>
254
+ setEditor((prev) => ({
255
+ session,
256
+ sessionId: (prev?.sessionId ?? 0) + 1,
257
+ isOpen: true,
258
+ }));
259
+
260
+ const closeEditor = () =>
261
+ setEditor((prev) => (prev ? { ...prev, isOpen: false } : prev));
262
+
263
+ const handleEditorSubmit = (vals: MemberFormSubmit) => {
264
+ if (editor?.session.mode === 'edit') {
265
+ const { id } = editor.session.member;
266
+ setRows((prev) =>
267
+ prev.map((row) =>
268
+ row.id === id
269
+ ? { ...row, ...vals, salary: Number(vals.salary) }
270
+ : row,
271
+ ),
272
+ );
273
+ } else {
274
+ const id = `emp_${nextIdRef.current++}`;
275
+ setRows((prev) => [
276
+ ...prev,
277
+ {
278
+ ...vals,
279
+ salary: Number(vals.salary),
280
+ id,
281
+ startDate: '2026-07-01T00:00:00.000Z',
282
+ isActive: true,
283
+ },
284
+ ]);
285
+ }
286
+ closeEditor();
287
+ };
288
+
289
+ const columnDefs: ColumnDef<Member>[] = [
290
+ textColumn({ id: 'name', header: 'Name', value: (m) => m.name, minWidth: 160 }),
291
+ textColumn({ id: 'email', header: 'Email', value: (m) => m.email, minWidth: 220 }),
292
+ badgeColumn({ id: 'role', header: 'Role', value: (m) => m.role }),
293
+ textColumn({ id: 'team', header: 'Team', value: (m) => m.team }),
294
+ currencyColumn({ id: 'salary', header: 'Salary', value: (m) => m.salary }),
295
+ booleanColumn({ id: 'active', header: 'Active', value: (m) => m.isActive }),
296
+ dateColumn({ id: 'start', header: 'Started', value: (m) => m.startDate }),
297
+ {
298
+ id: 'actions',
299
+ header: '',
300
+ align: 'right',
301
+ cell: (m) => (
302
+ <Stack direction="row" gap={1} justify="end">
303
+ <IconButton
304
+ ariaLabel={`Edit ${m.name}`}
305
+ tooltip="Edit"
306
+ size="small"
307
+ onClick={() => openEditor({ mode: 'edit', member: m })}
308
+ >
309
+ <PencilIcon />
310
+ </IconButton>
311
+ <IconButton
312
+ ariaLabel={`Delete ${m.name}`}
313
+ tooltip="Delete"
314
+ size="small"
315
+ variant="danger"
316
+ onClick={() => setPendingDelete(m)}
317
+ >
318
+ <TrashIcon />
319
+ </IconButton>
320
+ </Stack>
321
+ ),
322
+ },
323
+ ];
324
+
325
+ return (
326
+ <div style={{ height: '100vh' }}>
327
+ <Panels
328
+ header={
329
+ <Bar
330
+ title="Team Directory"
331
+ right={
332
+ <Stack direction="row" gap={2} align="center">
333
+ <Text size="small" isMuted>
334
+ {rows.length} members
335
+ </Text>
336
+ <Button
337
+ size="small"
338
+ variant="primary"
339
+ onClick={() => openEditor({ mode: 'add' })}
340
+ >
341
+ Add member
342
+ </Button>
343
+ </Stack>
344
+ }
345
+ />
346
+ }
347
+ >
348
+ <BigTable data={rows} columnDefs={columnDefs} getRowKey={(m) => m.id} />
349
+ </Panels>
350
+
351
+ {editor && (
352
+ <MemberFormModal
353
+ key={editor.sessionId}
354
+ isOpen={editor.isOpen}
355
+ title={editor.session.mode === 'edit' ? 'Edit member' : 'Add member'}
356
+ submitLabel="Save"
357
+ initialValues={
358
+ editor.session.mode === 'edit'
359
+ ? {
360
+ name: editor.session.member.name,
361
+ email: editor.session.member.email,
362
+ role: editor.session.member.role,
363
+ team: editor.session.member.team,
364
+ salary: String(editor.session.member.salary),
365
+ }
366
+ : EMPTY_MEMBER_FORM
367
+ }
368
+ onCancel={closeEditor}
369
+ onSubmit={handleEditorSubmit}
370
+ />
371
+ )}
372
+
373
+ <ConfirmModal
374
+ isOpen={pendingDelete !== null}
375
+ title="Delete member"
376
+ description={
377
+ pendingDelete
378
+ ? `Remove ${pendingDelete.name} from the directory? This cannot be undone.`
379
+ : undefined
380
+ }
381
+ confirmLabel="Delete member"
382
+ isDanger
383
+ onConfirm={() => {
384
+ if (pendingDelete) {
385
+ const { id } = pendingDelete;
386
+ setRows((prev) => prev.filter((row) => row.id !== id));
387
+ }
388
+ setPendingDelete(null);
389
+ }}
390
+ onCancel={() => setPendingDelete(null)}
391
+ />
392
+ </div>
393
+ );
394
+ };
395
+
396
+ export const FullCrud: Story = {
397
+ name: 'Full CRUD',
398
+ render: () => <TeamDirectoryDemo />,
399
+ // Walks the whole loop: add (validation errors → fix → row appends),
400
+ // edit (same modal pre-filled → row updates), delete (confirm → row
401
+ // removed). Modals and select option lists are portaled to <body>, so
402
+ // everything inside them is queried on the document body.
403
+ play: async ({ canvasElement }) => {
404
+ const canvas = within(canvasElement);
405
+ const body = within(canvasElement.ownerDocument.body);
406
+
407
+ await expect(canvas.getByText('34 members')).toBeInTheDocument();
408
+
409
+ // — Add: a failing submit surfaces every constrained field's error.
410
+ await userEvent.click(canvas.getByRole('button', { name: 'Add member' }));
411
+ await userEvent.click(await body.findByRole('button', { name: 'Save' }));
412
+ await expect(body.getByText("'name' cannot be empty")).toBeInTheDocument();
413
+ await expect(body.getByText("'email' cannot be empty")).toBeInTheDocument();
414
+ await expect(body.getByText("'role' cannot be empty")).toBeInTheDocument();
415
+ await expect(body.getByText("'team' cannot be empty")).toBeInTheDocument();
416
+ await expect(body.getByText("'salary' cannot be empty")).toBeInTheDocument();
417
+
418
+ // Validator-array progression: notEmpty passes, matches takes over.
419
+ await userEvent.type(body.getByLabelText(/^Email/), 'not-an-email');
420
+ await expect(
421
+ body.getByText("'email' must be a valid email"),
422
+ ).toBeInTheDocument();
423
+
424
+ // Fill everything in; select option lists are portaled too.
425
+ await userEvent.type(body.getByLabelText(/^Full name/), 'Grace Hopper');
426
+ await userEvent.clear(body.getByLabelText(/^Email/));
427
+ await userEvent.type(body.getByLabelText(/^Email/), 'grace.hopper@acme.dev');
428
+ await userEvent.click(body.getByRole('button', { name: 'Role' }));
429
+ await userEvent.click(await body.findByRole('option', { name: 'Engineer' }));
430
+ await userEvent.click(body.getByRole('button', { name: 'Team' }));
431
+ await userEvent.click(await body.findByRole('option', { name: 'Platform' }));
432
+ await userEvent.type(body.getByLabelText(/^Salary/), '180000');
433
+
434
+ // A valid submit closes the modal and appends the row.
435
+ await userEvent.click(body.getByRole('button', { name: 'Save' }));
436
+ await expect(await canvas.findByText('Grace Hopper')).toBeInTheDocument();
437
+ await expect(canvas.getByText('grace.hopper@acme.dev')).toBeInTheDocument();
438
+ await expect(canvas.getByText('35 members')).toBeInTheDocument();
439
+
440
+ // — Edit: the same modal opens pre-filled; saving updates in place.
441
+ await userEvent.click(
442
+ canvas.getByRole('button', { name: 'Edit Grace Hopper' }),
443
+ );
444
+ const nameInput = await body.findByLabelText(/^Full name/);
445
+ await expect(nameInput).toHaveValue('Grace Hopper');
446
+ await expect(body.getByLabelText(/^Salary/)).toHaveValue('180000');
447
+ await userEvent.clear(nameInput);
448
+ await userEvent.type(nameInput, 'Grace Murray Hopper');
449
+ await userEvent.click(body.getByRole('button', { name: 'Save' }));
450
+ await expect(
451
+ await canvas.findByText('Grace Murray Hopper'),
452
+ ).toBeInTheDocument();
453
+ await expect(canvas.getByText('35 members')).toBeInTheDocument();
454
+
455
+ // — Delete: per-row action asks for confirmation before removing.
456
+ await userEvent.click(
457
+ canvas.getByRole('button', { name: 'Delete Grace Murray Hopper' }),
458
+ );
459
+ await userEvent.click(
460
+ await body.findByRole('button', { name: 'Delete member' }),
461
+ );
462
+ await waitFor(() =>
463
+ expect(canvas.queryByText('Grace Murray Hopper')).not.toBeInTheDocument(),
464
+ );
465
+ await expect(canvas.getByText('34 members')).toBeInTheDocument();
466
+ },
467
+ };