@structuralists/scaffolding 0.12.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.
@@ -1,4 +1,5 @@
1
1
  import type { Preview } from '@storybook/react-vite';
2
+ import { useEffect } from 'storybook/preview-api';
2
3
  import { MemoryRouter } from 'react-router';
3
4
  import * as prettier from 'prettier/standalone';
4
5
  import * as babel from 'prettier/plugins/babel';
@@ -11,6 +12,28 @@ import '../tokens.css';
11
12
  const formatCache = new Map<string, string>();
12
13
 
13
14
  const preview: Preview = {
15
+ globalTypes: {
16
+ theme: {
17
+ description: 'Design-token theme (sets data-theme on <html>)',
18
+ toolbar: {
19
+ title: 'Theme',
20
+ icon: 'paintbrush',
21
+ items: [
22
+ { value: 'system', title: 'System (default)' },
23
+ { value: 'light-warm', title: 'Light / Warm' },
24
+ { value: 'light-paper', title: 'Light / Paper' },
25
+ { value: 'light-sepia', title: 'Light / Sepia' },
26
+ { value: 'dark-warm', title: 'Dark / Warm' },
27
+ { value: 'dark-neutral', title: 'Dark / Neutral' },
28
+ { value: 'dark-dimmed', title: 'Dark / Dimmed' },
29
+ ],
30
+ dynamicTitle: true,
31
+ },
32
+ },
33
+ },
34
+ initialGlobals: {
35
+ theme: 'system',
36
+ },
14
37
  parameters: {
15
38
  layout: 'padded',
16
39
 
@@ -24,6 +47,9 @@ const preview: Preview = {
24
47
  options: {
25
48
  storySort: {
26
49
  order: [
50
+ // Demo: polished illustrations of what can be built — first thing
51
+ // a visitor sees.
52
+ 'Demo',
27
53
  // Showcase: how the pieces fit together.
28
54
  'Composition',
29
55
  // Main library, alphabetical.
@@ -71,6 +97,25 @@ const preview: Preview = {
71
97
  }
72
98
  },
73
99
  decorators: [
100
+ // Theme toggle: mirrors the toolbar selection onto <html data-theme="…">,
101
+ // matching how consuming apps select a theme (see tokens.css). 'system'
102
+ // leaves the attribute unset so the prefers-color-scheme fallback applies —
103
+ // that is also the initial global, so the vitest story run renders
104
+ // identically to before the toggle existed.
105
+ (Story, context) => {
106
+ const theme = context.globals.theme as string | undefined;
107
+
108
+ useEffect(() => {
109
+ const root = document.documentElement;
110
+ if (!theme || theme === 'system') {
111
+ root.removeAttribute('data-theme');
112
+ } else {
113
+ root.setAttribute('data-theme', theme);
114
+ }
115
+ }, [theme]);
116
+
117
+ return <Story />;
118
+ },
74
119
  (Story) => (
75
120
  <MemoryRouter>
76
121
  <Story />
package/AGENTS.md CHANGED
@@ -54,6 +54,26 @@ Built-in React hooks (`useState`, `useEffect`, etc.) keep their stock
54
54
  positional signatures — this rule applies only to hooks defined in this
55
55
  package.
56
56
 
57
+ ## Storybook theme toggle
58
+
59
+ The Storybook toolbar has a Theme control (paintbrush icon) listing all six
60
+ `tokens.css` themes plus "System (default)". It is wired in
61
+ `.storybook/preview.tsx` via `globalTypes` + a global decorator that sets
62
+ `data-theme` on `<html>` — the same mechanism consuming apps use. "System"
63
+ leaves the attribute unset (prefers-color-scheme fallback) and is the initial
64
+ value, so the vitest story run is unaffected by the toggle.
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
+
57
77
  ## Testing
58
78
 
59
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`.
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.12.0",
3
+ "version": "0.14.0",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -104,13 +104,17 @@ Grammar doctrine, in force at the type level and in the runtime walk:
104
104
  - **A nested spec on an absent (null/undefined) section validates
105
105
  nothing**, at the type level (only the present branch refines;
106
106
  nullability survives around the refined interior) and at runtime (the
107
- walk skips — nothing to walk). Same for `each` over a null list.
107
+ walk skips — nothing to walk). Same for `each` over a null list; a
108
+ "required list" is a leaf validator on the list field.
108
109
  - **`F` stays naked in the structural arms.** Nullable sections/lists work
109
110
  purely by distribution; wrapping the checked type in `NonNullable` breaks
110
111
  it and blows the recursion stack (TS2589).
111
- - **Runtime status of `each`:** type-level only until plan phase 3. An
112
- `each` constraint on a present list makes the walk THROW a clear error
113
- instead of silently not validating.
112
+ - **An `each` spec validates every element.** Failures are addressed with
113
+ the numeric index step (`['drivers', 3, 'name']`) the same step
114
+ semantics as `read()`/`Path`, so `errorAt` and the bindings look element
115
+ errors up like any other path. Elements fail independently
116
+ (first-error-wins applies within one field's validator array, not across
117
+ elements); an empty list passes.
114
118
 
115
119
  ## Aggregation: `perField`
116
120
 
@@ -260,9 +264,10 @@ type FormErrors<T> = readonly FormError<T>[]; // isValid === (errors.le
260
264
  ```
261
265
 
262
266
  Paths are as deep as the failing node: a root leaf contributes `['email']`,
263
- a nested-spec leaf `['homeAddress', 'postalCode']`. Sibling nodes fail
264
- independently first-error-wins applies *within* one validator array, not
265
- across fields.
267
+ a nested-spec leaf `['homeAddress', 'postalCode']`, a leaf inside a list
268
+ element carries the numeric index step (`['drivers', 3, 'name']`). Sibling
269
+ nodes fail independently — first-error-wins applies *within* one validator
270
+ array, not across fields or list elements.
266
271
 
267
272
  Read one field's message with the typed accessor, never by hand-assembled
268
273
  keys — serialized path strings (`'drivers.0.name'`) are deliberately not
@@ -507,8 +512,9 @@ precision end-to-end. So we wire it up before adding any consumers.
507
512
  entry's subtree, addresses accumulated as `PathStep[]`s). `Validations<T>`
508
513
  accepts bare `(val) => string | null` functions too — they simply narrow
509
514
  nothing. The walk disambiguates a structural spec against the VALUE at
510
- the path (array ⇒ `{ each }`, which throws until phase 3; object
511
- nested spec; absent ⇒ skip), never against the constraint's shape.
515
+ the path (array ⇒ `{ each }`, run against every element with the index
516
+ as the path step; object ⇒ nested spec; absent ⇒ skip), never against
517
+ the constraint's shape.
512
518
  The walk's entry type (`ConstraintEntry`) is how the compiler polices
513
519
  the error loop in `state/useFormState/deriveErrors.ts`: `constraints[key]` is
514
520
  *assigned* to it, never cast,
package/src/forms/plan.md CHANGED
@@ -57,6 +57,7 @@ TS wall can't strand finished work behind it.
57
57
  identity gate for the default-`V` case.
58
58
  - **Phase 2 (nested object specs + recursive `Refine`): ✅ done** — see
59
59
  "Phases" below.
60
+ - **Phase 3 (list `each` runtime): ✅ done** — see "Phases" below.
60
61
 
61
62
  ## Goal
62
63
 
@@ -248,6 +249,11 @@ multi-second check time:
248
249
  0.87 s, 133,956 instantiations, 57,762 types (+7.1% instantiations over
249
250
  post-item-6; matches the spike's equivalent non-stress milestone at
250
251
  134,441 almost exactly)
252
+ - post-phase-3 (`each` runtime + the `ListValidation` story with its
253
+ numeric-path bindings; no new type machinery): check 0.84 s, 136,741
254
+ instantiations, 58,813 types (+1.8% over the pre-phase-3 HEAD at
255
+ 134,330/58,034 — mostly the story's new hook call site and deep
256
+ `Path`/`ValueAt` bindings)
251
257
 
252
258
  ## Runtime consequences (can't be dodged)
253
259
 
@@ -320,15 +326,20 @@ tests, story updates where visible, probe ratchet.
320
326
  predicted — nested errors just carry longer paths). **The `each` TYPE
321
327
  arm landed here too** (the spike proved the grammar whole, and carving
322
328
  it out of `RefineField` would have been artificial), but the runtime
323
- walk for it is phase 3 — until then an `each` constraint on a present
324
- list THROWS from the walk (pinned in walk.test.ts) rather than silently
325
- not validating; on a null list it skips, which already matches phase-3
326
- semantics.
327
- 3. **List `each` specs — runtime.** The walk visits every element; error
328
- paths carry the numeric step (`['drivers', 3, 'name']`), replacing the
329
- phase-2 throw. (The type level — refined element flowing through
330
- `Array<...>` — already landed with phase 2.) Two probes the spike says
331
- to keep pinned are already in the phase-2 suite: the `audit`-style
329
+ walk for it was phase 3 — in the interim an `each` constraint on a
330
+ present list THREW from the walk (pinned in walk.test.ts) rather than
331
+ silently not validating; on a null list it skipped, which already
332
+ matched phase-3 semantics.
333
+ 3. **List `each` specs — runtime.** *done* — the walk visits every
334
+ element; error paths carry the numeric step (`['drivers', 3, 'name']`),
335
+ replacing the phase-2 throw. (The type level — refined element flowing
336
+ through `Array<...>` — already landed with phase 2.) Absent/null list
337
+ skip, same decided semantics as absent sections; elements fail
338
+ independently; walk-level semantics pinned React-free in walk.test.ts,
339
+ the typed boundary in deriveErrors.test.ts, element errors through
340
+ `errorMessage` at numeric paths in useFieldBinding.test.tsx, and the
341
+ visible flow in the `ListValidation` story. Two probes the spike said
342
+ to keep pinned were already in the phase-2 suite: the `audit`-style
332
343
  "object field literally named `each`" disambiguation probe, and the
333
344
  `each: <bare validator>` negative (rejected by TypeScript's weak-type
334
345
  check — an obscure checker rule worth a canary).
@@ -75,10 +75,12 @@ describe('deriveFormErrors', () => {
75
75
  });
76
76
  });
77
77
 
78
- // The recursive grammar: nested object specs walk the value tree and
79
- // address failures with real multi-step paths. (List `each` specs are
80
- // type-level only until plan phase 3 the walk throws on one; pinned in
81
- // walk.test.ts.)
78
+ // The recursive grammar: nested object specs and list `each` specs walk the
79
+ // value tree and address failures with real multi-step paths numeric steps
80
+ // for list elements. Walk-level semantics (element iteration, sibling
81
+ // independence, absent-value skips) are pinned in walk.test.ts; these tests
82
+ // pin the typed boundary: a constraints literal checked against the form
83
+ // type produces `Path<T>`-addressed entries.
82
84
 
83
85
  type ProfileForm = {
84
86
  email: string | undefined;
@@ -87,12 +89,16 @@ type ProfileForm = {
87
89
  postalCode: string | undefined;
88
90
  };
89
91
  mailingAddress: { city: string | undefined } | undefined;
92
+ pets: Array<{ name: string | undefined }>;
93
+ pastPolicies: Array<{ insurer: string | undefined }> | null;
90
94
  };
91
95
 
92
96
  const emptyProfile: ProfileForm = {
93
97
  email: undefined,
94
98
  homeAddress: { city: undefined, postalCode: undefined },
95
99
  mailingAddress: undefined,
100
+ pets: [],
101
+ pastPolicies: null,
96
102
  };
97
103
 
98
104
  describe('deriveFormErrors — nested object specs', () => {
@@ -168,3 +174,32 @@ describe('deriveFormErrors — nested object specs', () => {
168
174
  ]);
169
175
  });
170
176
  });
177
+
178
+ describe('deriveFormErrors — list `each` specs', () => {
179
+ test('each element failure is addressed with the numeric index step', () => {
180
+ const profile: ProfileForm = {
181
+ ...emptyProfile,
182
+ pets: [{ name: 'Rex' }, { name: undefined }],
183
+ };
184
+ const errors = deriveFormErrors(profile, {
185
+ pets: { each: { name: notEmpty('name') } },
186
+ });
187
+ expect(errors).toEqual([
188
+ { path: ['pets', 1, 'name'], error: "'name' cannot be empty" },
189
+ ]);
190
+ });
191
+
192
+ test('an `each` spec on a null list is skipped; a leaf validator is how a list is required', () => {
193
+ const errors = deriveFormErrors(emptyProfile, {
194
+ pastPolicies: { each: { insurer: notEmpty('insurer') } },
195
+ });
196
+ expect(errors).toEqual([]);
197
+
198
+ const required = deriveFormErrors(emptyProfile, {
199
+ pastPolicies: notEmpty('pastPolicies'),
200
+ });
201
+ expect(required).toEqual([
202
+ { path: ['pastPolicies'], error: "'pastPolicies' cannot be empty" },
203
+ ]);
204
+ });
205
+ });
@@ -3,8 +3,8 @@ import { errorAt } from './errorAt';
3
3
  import type { FormErrors } from './types';
4
4
 
5
5
  // errorAt's equality must be exact over multi-step and numeric-step paths —
6
- // the recursive grammar produces real multi-step addresses (nested specs
7
- // today; numeric steps arrive with runtime `each` in plan phase 3).
6
+ // the recursive grammar produces real multi-step addresses (nested specs,
7
+ // and numeric index steps from runtime `each` over list elements).
8
8
  type Form = {
9
9
  email: string | undefined;
10
10
  address: { city: string | undefined };
@@ -138,6 +138,40 @@ describe('getFormFieldPropsAt — error-display policy', () => {
138
138
  ).toBeUndefined();
139
139
  });
140
140
 
141
+ test('element errors from an `each` spec surface at numeric paths, per element', () => {
142
+ const { result } = renderHook(() =>
143
+ useFormState({
144
+ initialValues,
145
+ constraints: { pets: { each: { name: notEmpty('name') } } },
146
+ }),
147
+ );
148
+
149
+ act(() => {
150
+ result.current.getFormFieldPropsAt(['pets', 1, 'name']).onChange(undefined);
151
+ });
152
+
153
+ // The raw list addresses the failing element by its index step …
154
+ expect(result.current.errors).toEqual([
155
+ { path: ['pets', 1, 'name'], error: "'name' cannot be empty" },
156
+ ]);
157
+ // … and errorMessage applies the same display policy at that path.
158
+ expect(
159
+ result.current.getFormFieldPropsAt(['pets', 1, 'name']).errorMessage,
160
+ ).toBeUndefined();
161
+
162
+ act(() => {
163
+ result.current.getFormFieldPropsAt(['pets', 1, 'name']).onBlur();
164
+ });
165
+
166
+ expect(
167
+ result.current.getFormFieldPropsAt(['pets', 1, 'name']).errorMessage,
168
+ ).toBe("'name' cannot be empty");
169
+ // The sibling element passed — no error at its own numeric path.
170
+ expect(
171
+ result.current.getFormFieldPropsAt(['pets', 0, 'name']).errorMessage,
172
+ ).toBeUndefined();
173
+ });
174
+
141
175
  test('repeat blurs on the same path keep the touched list stable', () => {
142
176
  // At the useFieldBinding boundary, where the touched list is returned.
143
177
  const { result } = renderHook(() =>
@@ -441,6 +441,188 @@ export const FieldBinding: Story = {
441
441
  },
442
442
  };
443
443
 
444
+ type FleetFormValues = {
445
+ fleetName: string | undefined;
446
+ drivers: Array<{
447
+ name: string | undefined;
448
+ licenseNumber: string | undefined;
449
+ }>;
450
+ };
451
+
452
+ // What onSubmit receives: the `each` spec refines the ELEMENT type and the
453
+ // refinement flows through Array<...> — every driver's constrained fields
454
+ // lose undefined. The setSubmitted(vals) call compiling is the proof.
455
+ type SubmittedFleet = {
456
+ fleetName: string;
457
+ drivers: Array<{ name: string; licenseNumber: string }>;
458
+ };
459
+
460
+ // Phase 3 of the recursive grammar: a `{ each: … }` spec validates EVERY
461
+ // list element, and failures are addressed with the numeric index step —
462
+ // ['drivers', 1, 'name'] — the same step semantics the bindings read. Each
463
+ // driver's fields are bound at their numeric paths, so element errors land
464
+ // on exactly the element that failed.
465
+ const ListValidationDemo = () => {
466
+ const [submitted, setSubmitted] = useState<SubmittedFleet | null>(null);
467
+
468
+ const { values, errors, getFormFieldPropsAt, submit } = useFormState({
469
+ initialValues: {
470
+ fleetName: undefined,
471
+ drivers: [
472
+ { name: undefined, licenseNumber: undefined },
473
+ { name: undefined, licenseNumber: undefined },
474
+ ],
475
+ } as FleetFormValues,
476
+ constraints: {
477
+ fleetName: notEmpty('fleetName'),
478
+ drivers: {
479
+ each: {
480
+ name: notEmpty('name'),
481
+ licenseNumber: [
482
+ notEmpty('licenseNumber'),
483
+ minLength('licenseNumber', 5),
484
+ ],
485
+ },
486
+ },
487
+ },
488
+ onSubmit: (vals) => setSubmitted(vals),
489
+ });
490
+
491
+ return (
492
+ <form
493
+ style={{ maxWidth: 420, display: 'grid', gap: 16 }}
494
+ onSubmit={(e) => {
495
+ e.preventDefault();
496
+ submit();
497
+ }}
498
+ >
499
+ <TextInputForForm
500
+ label="Fleet name"
501
+ formFieldProps={getFormFieldPropsAt(['fleetName'])}
502
+ />
503
+
504
+ {values.drivers.map((_, index) => (
505
+ // Index keys are fine here: the list is fixed-size. Stable element
506
+ // keys for editable lists are plan item 8.
507
+ <fieldset
508
+ key={index}
509
+ style={{ display: 'grid', gap: 12, border: '1px solid var(--ui-border, #ddd)', borderRadius: 6, padding: 12 }}
510
+ >
511
+ <legend style={{ fontSize: 13, padding: '0 4px' }}>
512
+ Driver {index + 1}
513
+ </legend>
514
+ <TextInputForForm
515
+ label={`Driver ${index + 1} name`}
516
+ formFieldProps={getFormFieldPropsAt(['drivers', index, 'name'])}
517
+ />
518
+ <TextInputForForm
519
+ label={`Driver ${index + 1} license`}
520
+ hint="At least 5 characters"
521
+ formFieldProps={getFormFieldPropsAt([
522
+ 'drivers',
523
+ index,
524
+ 'licenseNumber',
525
+ ])}
526
+ />
527
+ </fieldset>
528
+ ))}
529
+
530
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
531
+ <Button type="submit" variant="primary">
532
+ Save fleet
533
+ </Button>
534
+ {/* errorAt reads one element's raw error at its numeric path — no
535
+ display gating, the live truth the bindings' errorMessage sits
536
+ on top of. */}
537
+ <span style={{ fontSize: 13 }} data-testid="raw-driver-2-name-error">
538
+ {"raw ['drivers', 1, 'name']: "}
539
+ {errorAt(errors, ['drivers', 1, 'name']) ?? '—'}
540
+ </span>
541
+ </div>
542
+
543
+ {submitted && (
544
+ <pre
545
+ style={{
546
+ background: 'var(--ui-surface-muted, #f4f4f4)',
547
+ padding: 12,
548
+ borderRadius: 6,
549
+ fontSize: 12,
550
+ margin: 0,
551
+ }}
552
+ >
553
+ {`onSubmit received (narrowed type):\n${JSON.stringify(submitted, null, 2)}`}
554
+ </pre>
555
+ )}
556
+ </form>
557
+ );
558
+ };
559
+
560
+ export const ListValidation: Story = {
561
+ render: () => (
562
+ <div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
563
+ <ListValidationDemo />
564
+ </div>
565
+ ),
566
+ // Walks the per-element flow: errorAt at a numeric path is live from the
567
+ // start, element errors surface per element (not per list), fixing one
568
+ // element leaves its siblings' errors alone, and the refined element type
569
+ // flows through Array<...> to onSubmit.
570
+ play: async ({ canvasElement }) => {
571
+ const canvas = within(canvasElement);
572
+
573
+ // errorAt is raw truth — the numeric-path lookup sees driver 2's error
574
+ // before anything is touched or submitted. (Exact-text queries below
575
+ // don't match this line — its text includes the path prefix.)
576
+ await expect(
577
+ canvas.getByTestId('raw-driver-2-name-error'),
578
+ ).toHaveTextContent("'name' cannot be empty");
579
+ // The bindings withhold everything until touched/submit-attempted.
580
+ await expect(
581
+ canvas.queryAllByText("'name' cannot be empty"),
582
+ ).toHaveLength(0);
583
+
584
+ // Fix driver 1's name, then submit: every remaining element error
585
+ // appears at its own element — driver 1 name stays clean.
586
+ await userEvent.type(canvas.getByLabelText(/^Driver 1 name/), 'Ayrton');
587
+ await userEvent.click(canvas.getByRole('button', { name: 'Save fleet' }));
588
+ await expect(
589
+ canvas.getAllByText("'name' cannot be empty"),
590
+ ).toHaveLength(1); // driver 2's field only — driver 1's name passed
591
+ await expect(
592
+ canvas.getAllByText("'licenseNumber' cannot be empty"),
593
+ ).toHaveLength(2); // both drivers' licenses
594
+ await expect(
595
+ canvas.getByText("'fleetName' cannot be empty"),
596
+ ).toBeInTheDocument();
597
+
598
+ // Validator arrays keep first-error-wins semantics INSIDE an element:
599
+ // a short license moves that element (and only it) to the minLength
600
+ // message.
601
+ await userEvent.type(canvas.getByLabelText(/^Driver 1 license/), 'abc');
602
+ await expect(
603
+ canvas.getByText("'licenseNumber' must be at least 5 characters"),
604
+ ).toBeInTheDocument();
605
+ await expect(
606
+ canvas.getAllByText("'licenseNumber' cannot be empty"),
607
+ ).toHaveLength(1); // driver 2 still on notEmpty
608
+
609
+ // Fix everything; the narrowed payload reaches onSubmit.
610
+ await userEvent.type(canvas.getByLabelText(/^Driver 1 license/), 'de');
611
+ await userEvent.type(canvas.getByLabelText(/^Driver 2 name/), 'Michele');
612
+ await userEvent.type(
613
+ canvas.getByLabelText(/^Driver 2 license/),
614
+ 'XK-4471',
615
+ );
616
+ await userEvent.type(canvas.getByLabelText(/^Fleet name/), 'Scuderia');
617
+ // Driver 2's name is fixed — the raw errorAt lookup clears live.
618
+ await expect(
619
+ canvas.getByTestId('raw-driver-2-name-error'),
620
+ ).toHaveTextContent('—');
621
+ await userEvent.click(canvas.getByRole('button', { name: 'Save fleet' }));
622
+ await expect(canvas.getByText(/onSubmit received/)).toBeInTheDocument();
623
+ },
624
+ };
625
+
444
626
  type DebuggerDemoValues = {
445
627
  email: string | undefined;
446
628
  nickname: string | undefined;
@@ -43,11 +43,13 @@ export type FieldConstraint<F> =
43
43
  | (F extends FormValuesObject ? Validations<F> : never)
44
44
  | (F extends FormValueList ? ListConstraint<F[number]> : never);
45
45
 
46
- // The constraint form for a list field: a spec applied to each element.
47
- // NOTE: the type-level grammar admits `each` (proven with the rest of the
48
- // recursive grammar by the phase-2 type spike), but the runtime walk for it
49
- // is plan phase 3 until it lands, an `each` constraint throws a clear
50
- // error from the walk instead of silently not validating.
46
+ // The constraint form for a list field: a spec applied to each element. At
47
+ // runtime the walk validates EVERY element of a present list against the
48
+ // `each` spec, and each failure carries the numeric index step
49
+ // (`['drivers', 3, 'name']`)the same step semantics as `read()`/`Path`,
50
+ // so `errorAt` and the bindings look element errors up like any other path.
51
+ // An absent (null/undefined) list has no elements to walk and skips — a
52
+ // "required list" is a leaf validator on the list field instead.
51
53
  export type ListConstraint<Element extends FormValuesObject> = {
52
54
  readonly each: Validations<Element>;
53
55
  // room to grow, e.g. a `self` slot for list-level rules (min count,
@@ -196,20 +196,133 @@ describe('validateEntry — value-model disambiguation', () => {
196
196
  });
197
197
  });
198
198
 
199
- describe('validateEntry — list `each` specs (runtime pending, phase 3)', () => {
200
- test('an `each` spec on a present list throws instead of silently not validating', () => {
201
- expect(() =>
202
- validateEntry(
203
- { each: { name: fail('name required') } },
204
- [{ name: undefined }],
205
- ['drivers'],
206
- ),
207
- ).toThrow(/each.*not validated at runtime yet.*drivers/);
208
- });
209
-
210
- test('an `each` spec on a null list is skipped — nothing to walk, matching phase-3 semantics', () => {
199
+ describe('validateEntry — list `each` specs', () => {
200
+ test('the element spec runs against every element, failures addressed by numeric step', () => {
201
+ const result = validateEntry(
202
+ { each: { name: fail('name required') } },
203
+ [{ name: undefined }, { name: undefined }],
204
+ ['drivers'],
205
+ );
206
+ expect(result).toEqual([
207
+ { path: ['drivers', 0, 'name'], error: 'name required' },
208
+ { path: ['drivers', 1, 'name'], error: 'name required' },
209
+ ]);
210
+ });
211
+
212
+ test('only failing elements contribute — the numeric step is the element index, not a count', () => {
213
+ const named = { name: 'ok' };
214
+ const result = validateEntry(
215
+ {
216
+ each: {
217
+ name: (val: unknown) => (val == null ? 'name required' : null),
218
+ },
219
+ },
220
+ [named, named, named, { name: undefined }],
221
+ ['drivers'],
222
+ );
223
+ expect(result).toEqual([
224
+ { path: ['drivers', 3, 'name'], error: 'name required' },
225
+ ]);
226
+ });
227
+
228
+ test('elements fail independently: a failing element does not stop the others', () => {
229
+ const spy = mock(() => null);
230
+ validateEntry(
231
+ { each: { name: fail('boom'), age: spy } },
232
+ [{ name: 'a', age: 1 }, { name: 'b', age: 2 }],
233
+ ['drivers'],
234
+ );
235
+ expect(spy).toHaveBeenCalledTimes(2);
236
+ expect(spy).toHaveBeenCalledWith(1);
237
+ expect(spy).toHaveBeenCalledWith(2);
238
+ });
239
+
240
+ test('element validators receive the element field value, not the list', () => {
241
+ const spy = mock((val: string) => (val === 'Rex' ? null : 'not Rex'));
242
+ expect(
243
+ validateEntry({ each: { name: spy } }, [{ name: 'Rex' }], ['pets']),
244
+ ).toEqual([]);
245
+ expect(spy).toHaveBeenCalledWith('Rex');
246
+ });
247
+
248
+ test('an empty list yields no errors', () => {
249
+ expect(
250
+ validateEntry({ each: { name: fail('x') } }, [], ['drivers']),
251
+ ).toEqual([]);
252
+ });
253
+
254
+ test('an `each` spec on a null list is skipped — nothing to walk', () => {
255
+ // Same decided semantics as absent sections: the type level refines only
256
+ // the present branch of a nullable list, so the runtime mirror is to
257
+ // skip. A "required list" is a leaf validator on the list field.
211
258
  expect(
212
259
  validateEntry({ each: { insurer: fail('x') } }, null, ['pastPolicies']),
213
260
  ).toEqual([]);
214
261
  });
262
+
263
+ test('an `each` spec on an undefined list is skipped', () => {
264
+ const never = mock(() => 'never reached');
265
+ expect(
266
+ validateEntry({ each: { insurer: never } }, undefined, ['pastPolicies']),
267
+ ).toEqual([]);
268
+ expect(never).not.toHaveBeenCalled();
269
+ });
270
+
271
+ test('each-in-each: failures at list-in-list depth carry both numeric steps', () => {
272
+ const result = validateEntry(
273
+ { each: { incidents: { each: { date: fail('bad date') } } } },
274
+ [
275
+ { incidents: [] },
276
+ { incidents: [{ date: undefined }, { date: undefined }] },
277
+ ],
278
+ ['drivers'],
279
+ );
280
+ expect(result).toEqual([
281
+ { path: ['drivers', 1, 'incidents', 0, 'date'], error: 'bad date' },
282
+ { path: ['drivers', 1, 'incidents', 1, 'date'], error: 'bad date' },
283
+ ]);
284
+ });
285
+
286
+ test('a nested object spec inside an each spec extends the address past the index', () => {
287
+ const result = validateEntry(
288
+ { each: { garagingAddress: { city: fail('city required') } } },
289
+ [{ garagingAddress: { city: undefined } }],
290
+ ['vehicles'],
291
+ );
292
+ expect(result).toEqual([
293
+ {
294
+ path: ['vehicles', 0, 'garagingAddress', 'city'],
295
+ error: 'city required',
296
+ },
297
+ ]);
298
+ });
299
+
300
+ test('leaf semantics hold inside elements: validator arrays are first-error-wins per field', () => {
301
+ const result = validateEntry(
302
+ { each: { name: [pass, fail('second'), fail('third')] } },
303
+ [{ name: 'x' }],
304
+ ['drivers'],
305
+ );
306
+ expect(result).toEqual([
307
+ { path: ['drivers', 0, 'name'], error: 'second' },
308
+ ]);
309
+ });
310
+
311
+ test('a whole-list leaf validator on the list field still runs against the list value', () => {
312
+ // Leaf forms stay legal on structural fields — a validator (array) on a
313
+ // list field validates the list as a value, no `each` involved.
314
+ const spy = mock((val: unknown[]) =>
315
+ val.length > 0 ? null : 'at least one driver',
316
+ );
317
+ expect(validateEntry([spy], [], ['drivers'])).toEqual([
318
+ { path: ['drivers'], error: 'at least one driver' },
319
+ ]);
320
+ expect(spy).toHaveBeenCalledWith([]);
321
+ });
322
+
323
+ test('a spec without `each` on a list value (untyped JS) is skipped, mirroring the null-child skip', () => {
324
+ expect(
325
+ validateEntry({ name: fail('x') }, [{ name: undefined }], ['drivers']),
326
+ ).toEqual([]);
327
+ });
215
328
  });
@@ -2,10 +2,10 @@ import type { PathStep } from '../path/types';
2
2
 
3
3
  // The runtime walk over a constraints object, kept separate from the hook so
4
4
  // its semantics are unit-testable without React. The grammar is recursive
5
- // (nested object specs; list `each` specs arrive at runtime in plan phase 3),
6
- // so the walk is a tree walk: it accumulates a `PathStep[]` address as it
7
- // descends, and the hook's structured `{path, error}[]` error model
8
- // (`FormErrors<T>`) consumes those addresses directly.
5
+ // (nested object specs, list `each` specs), so the walk is a tree walk: it
6
+ // accumulates a `PathStep[]` address as it descends, and the hook's
7
+ // structured `{path, error}[]` error model (`FormErrors<T>`) consumes those
8
+ // addresses directly.
9
9
 
10
10
  export type ValidationError = {
11
11
  readonly path: readonly PathStep[];
@@ -47,18 +47,22 @@ type ConstraintSpec = {
47
47
  // An empty array passes, mirroring its refinement (`Exclude<F, never>`).
48
48
  //
49
49
  // Structural semantics — interpretation is directed by the value model:
50
- // - value is an array ⇒ the spec is a `{ each: … }` list constraint.
51
- // Runtime `each` walking is plan phase 3; until it lands the walk THROWS
52
- // rather than silently not validating.
50
+ // - value is an array ⇒ the spec is a `{ each: … }` list constraint: the
51
+ // element spec runs against EVERY element, and each element's failures gain
52
+ // the numeric index step (`['drivers', 3, 'name']`) — the same step
53
+ // semantics as `read()`/`Path`, so `errorAt` and the bindings look them up
54
+ // like any other path. Elements fail independently; an empty list has
55
+ // nothing to walk and passes.
53
56
  // - value is an object ⇒ the spec is a nested `Validations`: recurse into
54
57
  // each constrained key, extending the path. (An object field owning a key
55
58
  // literally named `each` lands here — the value directs, so its spec is a
56
59
  // nested spec like any other.)
57
60
  // - value is absent (null/undefined) ⇒ SKIP: a nested spec on an absent
58
- // section validates nothing (decided with the type spike the type level
59
- // refines only the present branch of a nullable section, and this is the
60
- // honest runtime mirror). A "required section" is a LEAF validator on the
61
- // section field instead. A non-object value (unreachable through the typed
61
+ // section validates nothing, and an `each` spec on an absent list has no
62
+ // elements to walk (decided with the type spike the type level refines
63
+ // only the present branch of a nullable section/list, and this is the
64
+ // honest runtime mirror). A "required section" or "required list" is a
65
+ // LEAF validator on the field instead. A non-object value (unreachable through the typed
62
66
  // grammar) has nothing to walk either, mirroring read()'s dead-step
63
67
  // semantics.
64
68
  // Predicate (not inline checks) because `Array.isArray`'s `arg is any[]`
@@ -92,10 +96,17 @@ export const validateEntry = (
92
96
  if (value == null || typeof value !== 'object') return [];
93
97
 
94
98
  if (Array.isArray(value)) {
95
- throw new Error(
96
- `list 'each' constraints are not validated at runtime yet (forms plan ` +
97
- `phase 3)constraint at path [${path.join(', ')}]`,
98
- );
99
+ // Unreachable through the typed grammar (a list field's only structural
100
+ // form is `{ each: }`), so a missing `each` can only come from untyped
101
+ // JSnothing to run, mirroring the null-child skip below.
102
+ const each = entry.each;
103
+ if (each == null) return [];
104
+
105
+ const errors: ValidationError[] = [];
106
+ (value as unknown[]).forEach((element, index) => {
107
+ errors.push(...validateEntry(each, element, [...path, index]));
108
+ });
109
+ return errors;
99
110
  }
100
111
 
101
112
  const errors: ValidationError[] = [];
@@ -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
+ };