@structuralists/scaffolding 0.10.2 → 0.12.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.
Files changed (35) hide show
  1. package/eslint.config.mjs +3 -3
  2. package/package.json +1 -1
  3. package/src/components/Json/JsonTable/JsonLeafNode.tsx +20 -17
  4. package/src/components/Json/JsonTable/JsonTable.stories.tsx +87 -0
  5. package/src/components/Json/JsonTable/index.tsx +13 -6
  6. package/src/components/Json/JsonTable/styles.module.css +20 -0
  7. package/src/components/Json/JsonTable/types.ts +3 -5
  8. package/src/forms/CLAUDE.md +195 -41
  9. package/src/forms/elements/Input/index.tsx +2 -0
  10. package/src/forms/elements/Input/types.ts +2 -1
  11. package/src/forms/plan.md +146 -29
  12. package/src/forms/state/bindings/SingleSelectForForm.tsx +45 -0
  13. package/src/forms/state/bindings/TextInputForForm.tsx +45 -0
  14. package/src/forms/state/path/path.test.ts +71 -1
  15. package/src/forms/state/path/path.ts +50 -0
  16. package/src/forms/state/useFormState/FormDebugger.test.tsx +4 -3
  17. package/src/forms/state/useFormState/FormDebugger.tsx +1 -0
  18. package/src/forms/state/useFormState/deriveErrors.test.ts +94 -0
  19. package/src/forms/state/useFormState/deriveErrors.ts +10 -10
  20. package/src/forms/state/useFormState/errorAt.test.ts +3 -3
  21. package/src/forms/state/useFormState/errorAt.ts +8 -12
  22. package/src/forms/state/useFormState/inspectable.test.ts +9 -9
  23. package/src/forms/state/useFormState/inspectable.ts +5 -7
  24. package/src/forms/state/useFormState/types.ts +35 -4
  25. package/src/forms/state/useFormState/useFieldBinding.test.tsx +165 -0
  26. package/src/forms/state/useFormState/useFieldBinding.ts +71 -0
  27. package/src/forms/state/useFormState/useFormDebugger.test.tsx +1 -0
  28. package/src/forms/state/useFormState/useFormState.stories.tsx +190 -5
  29. package/src/forms/state/useFormState/useFormState.test-d.ts +516 -1
  30. package/src/forms/state/useFormState/useFormState.test.tsx +2 -2
  31. package/src/forms/state/useFormState/useFormState.ts +12 -3
  32. package/src/forms/state/validations/types.ts +77 -17
  33. package/src/forms/state/validations/walk.test.ts +159 -19
  34. package/src/forms/state/validations/walk.ts +86 -25
  35. package/tokens.css +55 -0
@@ -2,9 +2,9 @@ import { describe, test, expect } from 'bun:test';
2
2
  import { errorAt } from './errorAt';
3
3
  import type { FormErrors } from './types';
4
4
 
5
- // The flat grammar only produces single-key paths today, but errorAt's
6
- // equality must already be exact over multi-step and numeric-step paths —
7
- // the recursive grammar (plan phases 2–3) reuses it unchanged.
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).
8
8
  type Form = {
9
9
  email: string | undefined;
10
10
  address: { city: string | undefined };
@@ -1,11 +1,12 @@
1
+ import { pathsEqual } from '../path/path';
1
2
  import type { Path, PathStep } from '../path/types';
2
3
  import type { FormErrors, FormValuesObject } from './types';
3
4
 
4
5
  // Typed lookup into the structured `{ path, error }[]` error list — the
5
- // sanctioned way to read one field's error. Path equality is structural:
6
- // same steps, same order, no prefix matching. With first-error-wins
7
- // validation there is at most one entry per path today; should collect-all
8
- // ever land, the first entry stays the one shown.
6
+ // sanctioned way to read one field's error. Path equality is structural
7
+ // (`pathsEqual`): same steps, same order, no prefix matching. With
8
+ // first-error-wins validation there is at most one entry per path today;
9
+ // should collect-all ever land, the first entry stays the one shown.
9
10
  export const errorAt = <T extends FormValuesObject>(
10
11
  errors: FormErrors<T>,
11
12
  path: Path<T>,
@@ -14,14 +15,9 @@ export const errorAt = <T extends FormValuesObject>(
14
15
  // prove it for an unresolved T. Same honest widening as `Cursor.at`.
15
16
  const steps = path as readonly PathStep[];
16
17
 
17
- const match = errors.find((candidate) => {
18
- const candidateSteps = candidate.path as readonly PathStep[];
19
-
20
- return (
21
- candidateSteps.length === steps.length &&
22
- candidateSteps.every((step, index) => step === steps[index])
23
- );
24
- });
18
+ const match = errors.find((candidate) =>
19
+ pathsEqual(candidate.path as readonly PathStep[], steps),
20
+ );
25
21
 
26
22
  return match?.error;
27
23
  };
@@ -11,15 +11,15 @@ describe('toInspectable', () => {
11
11
  expect(toInspectable(true)).toBe(true);
12
12
  });
13
13
 
14
- test('converts arrays to index-keyed objects', () => {
15
- expect(toInspectable(['a', 'b'])).toEqual({ 0: 'a', 1: 'b' });
14
+ test('keeps arrays as arrays (JsonTable renders them natively)', () => {
15
+ expect(toInspectable(['a', 'b'])).toEqual(['a', 'b']);
16
16
  });
17
17
 
18
- test('converts arrays of objects recursively', () => {
19
- expect(toInspectable([{ name: 'ada' }, { name: 'bo' }])).toEqual({
20
- 0: { name: 'ada' },
21
- 1: { name: 'bo' },
22
- });
18
+ test('walks array elements recursively (Sets may hide inside)', () => {
19
+ expect(toInspectable([{ roles: new Set(['admin']) }, { name: 'bo' }])).toEqual([
20
+ { roles: 'Set(1) { "admin" }' },
21
+ { name: 'bo' },
22
+ ]);
23
23
  });
24
24
 
25
25
  test('renders Sets as a descriptive string leaf', () => {
@@ -35,8 +35,8 @@ describe('toInspectable', () => {
35
35
  };
36
36
  expect(toInspectable(form)).toEqual({
37
37
  email: 'a@b.co',
38
- address: { city: undefined, tags: { 0: 'home' } },
39
- drivers: { 0: { name: 'ada', incidents: {} } },
38
+ address: { city: undefined, tags: ['home'] },
39
+ drivers: [{ name: 'ada', incidents: [] }],
40
40
  });
41
41
  });
42
42
  });
@@ -1,10 +1,10 @@
1
1
  // Converts a form-state snapshot into a shape JsonTable renders without
2
- // throwing. JsonTable dispatches on "plain object vs leaf": arrays and Sets
3
- // fall to the leaf renderer, and an array of objects (FormValueList) would
4
- // throw as a React child. A debugger must render *any* legal form state, so:
2
+ // throwing. JsonTable recurses into plain objects and arrays; everything
3
+ // else falls to the leaf renderer, and a Set there would throw as a React
4
+ // child. A debugger must render *any* legal form state, so:
5
5
  //
6
- // - arrays → index-keyed plain objects ({ 0: ..., 1: ... }), recursively
7
6
  // - Sets → a descriptive string leaf: `Set(2) { "a", "b" }`
7
+ // - arrays → walked recursively (a Set may hide inside), kept as arrays
8
8
  // - objects → walked recursively
9
9
  // - leaves → passed through untouched
10
10
  const isPlainObject = (value: unknown): value is Record<string, unknown> =>
@@ -20,9 +20,7 @@ export const toInspectable = (value: unknown): unknown => {
20
20
  }
21
21
 
22
22
  if (Array.isArray(value)) {
23
- return Object.fromEntries(
24
- value.map((element, index) => [index, toInspectable(element)]),
25
- );
23
+ return value.map(toInspectable);
26
24
  }
27
25
 
28
26
  if (isPlainObject(value)) {
@@ -1,7 +1,7 @@
1
1
  // Type-only imports; FormDebugger.tsx and path/types.ts import value types
2
2
  // from this file in turn, but the cycles never exist at runtime.
3
3
  import type { FormDebuggerComponent } from './FormDebugger';
4
- import type { Path } from '../path/types';
4
+ import type { Path, ValueAt } from '../path/types';
5
5
 
6
6
  export type FormValueSimple = string | number | bigint | boolean | string[] | Set<string> | undefined | null;
7
7
 
@@ -78,8 +78,8 @@ export type UnionPolicyCheck<T> = HasDisallowedUnion<T> extends true
78
78
  : unknown;
79
79
 
80
80
  // Structured error model: one entry per failing constrained node, addressed
81
- // by a typed path (single-key paths on the flat grammar; deeper addresses
82
- // arrive with the recursive grammar). Deliberately a plain list — at form
81
+ // by a typed path as deep as the node (root leaves get single-key paths,
82
+ // nested-spec leaves get the full address). Deliberately a plain list — at form
83
83
  // scale a linear scan is fine, and serialized string keys ('drivers.0.name')
84
84
  // are never exposed. Read a field's error with `errorAt` (./errorAt.ts),
85
85
  // never by hand-assembled keys.
@@ -90,6 +90,28 @@ export type FormError<T extends FormValuesObject> = {
90
90
 
91
91
  export type FormErrors<T extends FormValuesObject> = readonly FormError<T>[];
92
92
 
93
+ // What a form-aware element declares it needs from a field binding: it
94
+ // *displays* `Display` and *emits* `Emit`. A `FormFieldProps<V>` is
95
+ // assignable exactly when V sits between them (`Emit ⊆ V ⊆ Display`), which
96
+ // is how "a number-typed path bound to a text-shaped element" becomes a
97
+ // compile error at the `formFieldProps` prop — no generics needed on the
98
+ // wrapper, structural assignability does the checking.
99
+ export type FieldBinding<Display, Emit = Display> = {
100
+ value: Display;
101
+ onChange: (val: Emit) => void;
102
+ // Already display-policy-aware (see `useFieldBinding`): undefined until
103
+ // the field's error should be SHOWN, so elements render what they're
104
+ // given and stay policy-free.
105
+ errorMessage: string | undefined;
106
+ // Feeds touched tracking; wire it to the element's blur (or, for
107
+ // commit-style elements like selects, to the commit).
108
+ onBlur: () => void;
109
+ // room to grow: name/id derivation, disabled, ...
110
+ };
111
+
112
+ // The bundle `getFormFieldPropsAt(path)` returns for the field at that path.
113
+ export type FormFieldProps<V> = FieldBinding<V>;
114
+
93
115
  // What the hook publishes (via `useFormDebugger`) to its Debugger after
94
116
  // every commit. Snapshots are
95
117
  // replaced whole (never mutated) so `useSyncExternalStore` consumers can
@@ -99,11 +121,14 @@ export type FormDebugSnapshot<T extends FormValuesObject> = {
99
121
  errors: FormErrors<T>;
100
122
  isValid: boolean;
101
123
  submitAttempted: boolean;
124
+ touched: readonly Path<T>[];
102
125
  };
103
126
 
104
127
  export type FormHelpers<T extends FormValuesObject> = {
105
128
  values: T;
106
- // todo: likely remove once other setter are available
129
+ // Whole-value replacement. No longer the only write path (field-level
130
+ // writes go through `getFormFieldPropsAt(path).onChange`); whether this
131
+ // survives long-term is a separate decision.
107
132
  onValueChanges: (val: T | ((prev: T) => T)) => void;
108
133
  // Live-derived from the current values on every render; UIs that only want
109
134
  // errors after a submit attempt gate on `submitAttempted`.
@@ -111,6 +136,12 @@ export type FormHelpers<T extends FormValuesObject> = {
111
136
  isValid: boolean;
112
137
  submitAttempted: boolean;
113
138
  submit: () => void;
139
+ // One expression wires a field: value, typed onChange (an immutable write
140
+ // at the path), display-policy-aware errorMessage, and onBlur (touched
141
+ // tracking). `ValueAt<T, P>` types value/onChange end-to-end, so binding a
142
+ // wrong-shaped path to an element fails to compile at the element's
143
+ // `formFieldProps` prop.
144
+ getFormFieldPropsAt: <P extends Path<T>>(path: P) => FormFieldProps<ValueAt<T, P>>;
114
145
  // Dev-time introspection overlay bound to this form instance: a fixed
115
146
  // trigger that opens a window showing the form's live internal state.
116
147
  // Render it anywhere (it portals to <body>); omit it and nothing mounts.
@@ -0,0 +1,165 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { act, renderHook } from '@testing-library/react';
3
+ import { useFieldBinding } from './useFieldBinding';
4
+ import { useFormState } from './useFormState';
5
+ import { notEmpty } from '../validators/validators';
6
+
7
+ // Field binding exercised through useFormState — the composition consumers
8
+ // see. Pins the three behaviors item 6 added: granular path writes (the
9
+ // immutable mirror of read()), touched tracking fed by onBlur, and the
10
+ // error-display policy living inside errorMessage.
11
+
12
+ type Address = { city: string | undefined; zip: string | undefined };
13
+
14
+ type ProfileForm = {
15
+ email: string | undefined;
16
+ homeAddress: Address;
17
+ mailingAddress: Address | undefined;
18
+ pets: Array<{ name: string | undefined }>;
19
+ };
20
+
21
+ const initialValues: ProfileForm = {
22
+ email: undefined,
23
+ homeAddress: { city: undefined, zip: undefined },
24
+ mailingAddress: undefined,
25
+ pets: [{ name: 'Rex' }, { name: 'Milou' }],
26
+ };
27
+
28
+ const setup = () =>
29
+ renderHook(() =>
30
+ useFormState({
31
+ initialValues,
32
+ constraints: { email: notEmpty('email') },
33
+ }),
34
+ );
35
+
36
+ describe('getFormFieldPropsAt — value and writes', () => {
37
+ test('reads the value at a deep path', () => {
38
+ const { result } = setup();
39
+ expect(result.current.getFormFieldPropsAt(['pets', 0, 'name']).value).toBe(
40
+ 'Rex',
41
+ );
42
+ expect(
43
+ result.current.getFormFieldPropsAt(['homeAddress', 'city']).value,
44
+ ).toBeUndefined();
45
+ });
46
+
47
+ test('a path through an absent section reads undefined and writes as a no-op', () => {
48
+ const { result } = setup();
49
+ const field = result.current.getFormFieldPropsAt(['mailingAddress', 'city']);
50
+ expect(field.value).toBeUndefined();
51
+
52
+ act(() => {
53
+ field.onChange('Paris');
54
+ });
55
+ // The write mirrored read()'s dead-step semantics: nothing changed.
56
+ expect(result.current.values).toBe(initialValues);
57
+ });
58
+
59
+ test('onChange writes at the path immutably, preserving sibling identity', () => {
60
+ const { result } = setup();
61
+
62
+ act(() => {
63
+ result.current.getFormFieldPropsAt(['homeAddress', 'city']).onChange('Paris');
64
+ });
65
+
66
+ expect(result.current.values.homeAddress.city).toBe('Paris');
67
+ // Untouched branches keep their identity — only the spine was cloned.
68
+ expect(result.current.values.pets).toBe(initialValues.pets);
69
+ expect(result.current.values).not.toBe(initialValues);
70
+ expect(initialValues.homeAddress.city).toBeUndefined();
71
+ });
72
+
73
+ test('onChange into a list element rewrites only that element', () => {
74
+ const { result } = setup();
75
+
76
+ act(() => {
77
+ result.current.getFormFieldPropsAt(['pets', 1, 'name']).onChange('Snowy');
78
+ });
79
+
80
+ expect(result.current.values.pets[1].name).toBe('Snowy');
81
+ expect(result.current.values.pets[0]).toBe(initialValues.pets[0]);
82
+ });
83
+ });
84
+
85
+ describe('getFormFieldPropsAt — error-display policy', () => {
86
+ test('an untouched field shows no error before a submit attempt', () => {
87
+ const { result } = setup();
88
+ // The error exists in the raw list …
89
+ expect(result.current.errors).toEqual([
90
+ { path: ['email'], error: "'email' cannot be empty" },
91
+ ]);
92
+ // … but the binding withholds it until touched or submit-attempted.
93
+ expect(
94
+ result.current.getFormFieldPropsAt(['email']).errorMessage,
95
+ ).toBeUndefined();
96
+ });
97
+
98
+ test('onBlur marks the field touched and unlocks its error — only its own', () => {
99
+ const { result } = setup();
100
+
101
+ act(() => {
102
+ result.current.getFormFieldPropsAt(['email']).onBlur();
103
+ });
104
+
105
+ expect(result.current.getFormFieldPropsAt(['email']).errorMessage).toBe(
106
+ "'email' cannot be empty",
107
+ );
108
+ // Another field stays untouched: same policy, independent state.
109
+ expect(
110
+ result.current.getFormFieldPropsAt(['homeAddress', 'city']).errorMessage,
111
+ ).toBeUndefined();
112
+ });
113
+
114
+ test('a submit attempt unlocks every field’s error', () => {
115
+ const { result } = setup();
116
+
117
+ act(() => {
118
+ result.current.submit();
119
+ });
120
+
121
+ expect(result.current.getFormFieldPropsAt(['email']).errorMessage).toBe(
122
+ "'email' cannot be empty",
123
+ );
124
+ });
125
+
126
+ test('a touched field’s error clears once the value is fixed', () => {
127
+ const { result } = setup();
128
+
129
+ act(() => {
130
+ result.current.getFormFieldPropsAt(['email']).onBlur();
131
+ });
132
+ act(() => {
133
+ result.current.getFormFieldPropsAt(['email']).onChange('a@b.co');
134
+ });
135
+
136
+ expect(
137
+ result.current.getFormFieldPropsAt(['email']).errorMessage,
138
+ ).toBeUndefined();
139
+ });
140
+
141
+ test('repeat blurs on the same path keep the touched list stable', () => {
142
+ // At the useFieldBinding boundary, where the touched list is returned.
143
+ const { result } = renderHook(() =>
144
+ useFieldBinding({
145
+ values: initialValues,
146
+ onValueChanges: () => {},
147
+ errors: [],
148
+ submitAttempted: false,
149
+ }),
150
+ );
151
+
152
+ act(() => {
153
+ result.current.getFormFieldPropsAt(['email']).onBlur();
154
+ });
155
+ const touchedAfterFirst = result.current.touched;
156
+ expect(touchedAfterFirst).toEqual([['email']]);
157
+
158
+ act(() => {
159
+ result.current.getFormFieldPropsAt(['email']).onBlur();
160
+ });
161
+ // Re-blurring an already-touched path is a state no-op — the setter
162
+ // returns the same array, so nothing downstream sees a change.
163
+ expect(result.current.touched).toBe(touchedAfterFirst);
164
+ });
165
+ });
@@ -0,0 +1,71 @@
1
+ import { useState } from 'react';
2
+ import { errorAt } from './errorAt';
3
+ import type { FormErrors, FormFieldProps, FormValuesObject } from './types';
4
+ import { pathsEqual, read, write } from '../path/path';
5
+ import type { CursorStep, Path, PathStep, ValueAt } from '../path/types';
6
+
7
+ export type UseFieldBindingArgs<T extends FormValuesObject> = {
8
+ values: T;
9
+ onValueChanges: (val: T | ((prev: T) => T)) => void;
10
+ errors: FormErrors<T>;
11
+ submitAttempted: boolean;
12
+ };
13
+
14
+ // Field binding: owns per-field touched state and builds
15
+ // `getFormFieldPropsAt`, the one-expression wiring for a field (see
16
+ // FormHelpers in ./types.ts).
17
+ //
18
+ // THE error-display policy lives here, in `errorMessage`, and nowhere else:
19
+ // a field's error is shown once the field has been touched (blurred at
20
+ // least once) OR a submit has been attempted. Elements render the message
21
+ // they are given and stay policy-free; anything wanting a different policy
22
+ // (e.g. always-live display) reads `errors`/`errorAt` directly instead.
23
+ export const useFieldBinding = <T extends FormValuesObject>(
24
+ args: UseFieldBindingArgs<T>,
25
+ ) => {
26
+ const { values, onValueChanges, errors, submitAttempted } = args;
27
+
28
+ // Same representation as FormErrors: a plain list of typed paths compared
29
+ // structurally (pathsEqual). At form scale a linear scan is fine.
30
+ const [touched, setTouched] = useState<readonly Path<T>[]>([]);
31
+
32
+ const getFormFieldPropsAt = <P extends Path<T>>(
33
+ path: P,
34
+ ): FormFieldProps<ValueAt<T, P>> => {
35
+ // Path<T> is always a PathStep tuple; the conditional type just can't
36
+ // prove it for an unresolved T. Same honest widening as `Cursor.at`.
37
+ const steps = path as readonly PathStep[];
38
+ const keySteps: readonly CursorStep[] = steps.map((key) => ({
39
+ kind: 'key',
40
+ key,
41
+ }));
42
+
43
+ const isTouched = touched.some((candidate) =>
44
+ pathsEqual(candidate as readonly PathStep[], steps),
45
+ );
46
+
47
+ return {
48
+ // read() resolves exactly what ValueAt<T, P> promises — including the
49
+ // `| undefined` picked up through nullable ancestors — TS just can't
50
+ // correlate them for generic T. Honest cast, documented in the cast
51
+ // doctrine (src/forms/CLAUDE.md).
52
+ value: read(values, keySteps) as ValueAt<T, P>,
53
+ // write() replaces the value at the path and clones only the spine —
54
+ // the result is the same T shape. Same honest correlation as `value`.
55
+ onChange: (val) =>
56
+ onValueChanges((prev) => write(prev, steps, val) as T),
57
+ errorMessage:
58
+ submitAttempted || isTouched ? errorAt(errors, path) : undefined,
59
+ onBlur: () =>
60
+ setTouched((prev) =>
61
+ prev.some((candidate) =>
62
+ pathsEqual(candidate as readonly PathStep[], steps),
63
+ )
64
+ ? prev
65
+ : [...prev, path],
66
+ ),
67
+ };
68
+ };
69
+
70
+ return { touched, getFormFieldPropsAt };
71
+ };
@@ -21,6 +21,7 @@ const Host = () => {
21
21
  errors: [],
22
22
  isValid: true,
23
23
  submitAttempted: false,
24
+ touched: [],
24
25
  },
25
26
  });
26
27
 
@@ -9,6 +9,9 @@ import { Field } from '../../elements/Field';
9
9
  import { Input } from '../../elements/Input';
10
10
  import { Button } from '../../elements/Button';
11
11
  import { SingleSelect } from '../../elements/Select';
12
+ import type { SelectOption } from '../../elements/Select';
13
+ import { SingleSelectForForm } from '../bindings/SingleSelectForForm';
14
+ import { TextInputForForm } from '../bindings/TextInputForForm';
12
15
 
13
16
  const meta: Meta = {
14
17
  title: 'Forms/useFormState',
@@ -43,6 +46,10 @@ const ROLE_OPTIONS = [
43
46
  { value: 'manager', label: 'Manager' },
44
47
  ];
45
48
 
49
+ // Deliberately hand-wired — the contrast case for the FieldBinding story
50
+ // below: every field spells out its own read, spread-update, error lookup,
51
+ // and submit gating. `getFormFieldPropsAt` + the ForForm wrappers collapse
52
+ // those four decisions into one expression per field.
46
53
  const SignupDemo = () => {
47
54
  const [submitted, setSubmitted] = useState<SubmittedValues | null>(null);
48
55
 
@@ -259,6 +266,181 @@ export const LiveValidity: Story = {
259
266
  },
260
267
  };
261
268
 
269
+ type CoverageType = 'liability' | 'comprehensive';
270
+
271
+ type QuoteFormValues = {
272
+ email: string | undefined;
273
+ coverageType: CoverageType | null;
274
+ homeAddress: {
275
+ city: string | undefined;
276
+ postalCode: string | undefined;
277
+ };
278
+ };
279
+
280
+ // What onSubmit receives: notEmpty strips undefined from email and null
281
+ // from coverageType, and the NESTED spec on homeAddress refines its
282
+ // constrained leaves in place. The setSubmitted(vals) call below compiling
283
+ // is the proof that refinement still flows end-to-end when fields are wired
284
+ // through bindings.
285
+ type SubmittedQuote = {
286
+ email: string;
287
+ coverageType: CoverageType;
288
+ homeAddress: { city: string; postalCode: string };
289
+ };
290
+
291
+ const COVERAGE_OPTIONS: SelectOption<CoverageType>[] = [
292
+ { value: 'liability', label: 'Liability' },
293
+ { value: 'comprehensive', label: 'Comprehensive' },
294
+ ];
295
+
296
+ // The item-6 target: one expression wires a field. getFormFieldPropsAt
297
+ // bundles value + typed onChange (an immutable write at the path) +
298
+ // display-policy-aware errorMessage + onBlur, and the ForForm wrappers
299
+ // take the bundle as a single prop. Note the deep paths into homeAddress —
300
+ // no hand-spread updates anywhere.
301
+ const FieldBindingDemo = () => {
302
+ const [submitted, setSubmitted] = useState<SubmittedQuote | null>(null);
303
+
304
+ const { getFormFieldPropsAt, submit } = useFormState({
305
+ initialValues: {
306
+ email: undefined,
307
+ coverageType: null,
308
+ homeAddress: { city: undefined, postalCode: undefined },
309
+ } as QuoteFormValues,
310
+ constraints: {
311
+ email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
312
+ coverageType: notEmpty('coverageType'),
313
+ // A nested spec: errors inside homeAddress get real multi-step
314
+ // addresses (['homeAddress', 'postalCode']), which is exactly what the
315
+ // deep-path bindings below read via errorMessage.
316
+ homeAddress: {
317
+ city: notEmpty('city'),
318
+ postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
319
+ },
320
+ },
321
+ onSubmit: (vals) => setSubmitted(vals),
322
+ });
323
+
324
+ return (
325
+ <form
326
+ style={{ maxWidth: 420, display: 'grid', gap: 16 }}
327
+ onSubmit={(e) => {
328
+ e.preventDefault();
329
+ submit();
330
+ }}
331
+ >
332
+ <TextInputForForm
333
+ label="Email"
334
+ type="email"
335
+ placeholder="you@example.com"
336
+ hint="Blur the empty field to see touched-gated errors"
337
+ formFieldProps={getFormFieldPropsAt(['email'])}
338
+ />
339
+
340
+ <SingleSelectForForm
341
+ label="Coverage"
342
+ options={COVERAGE_OPTIONS}
343
+ placeholder="Pick a coverage"
344
+ formFieldProps={getFormFieldPropsAt(['coverageType'])}
345
+ />
346
+
347
+ <TextInputForForm
348
+ label="City"
349
+ formFieldProps={getFormFieldPropsAt(['homeAddress', 'city'])}
350
+ />
351
+
352
+ <TextInputForForm
353
+ label="Postal code"
354
+ formFieldProps={getFormFieldPropsAt(['homeAddress', 'postalCode'])}
355
+ />
356
+
357
+ <div>
358
+ <Button type="submit" variant="primary">
359
+ Get quote
360
+ </Button>
361
+ </div>
362
+
363
+ {submitted && (
364
+ <pre
365
+ style={{
366
+ background: 'var(--ui-surface-muted, #f4f4f4)',
367
+ padding: 12,
368
+ borderRadius: 6,
369
+ fontSize: 12,
370
+ margin: 0,
371
+ }}
372
+ >
373
+ {`onSubmit received (narrowed type):\n${JSON.stringify(submitted, null, 2)}`}
374
+ </pre>
375
+ )}
376
+ </form>
377
+ );
378
+ };
379
+
380
+ export const FieldBinding: Story = {
381
+ render: () => (
382
+ <div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
383
+ <FieldBindingDemo />
384
+ </div>
385
+ ),
386
+ // Walks the binding flow: touched/blur gating of errorMessage (before any
387
+ // submit attempt), deep-path writes through the wrappers, submit-attempt
388
+ // gating for untouched fields, and the narrowed payload reaching onSubmit.
389
+ play: async ({ canvasElement }) => {
390
+ const canvas = within(canvasElement);
391
+ const body = within(canvasElement.ownerDocument.body);
392
+
393
+ // Nothing shown initially: the email error exists in the raw list, but
394
+ // errorMessage withholds it until the field is touched.
395
+ await expect(
396
+ canvas.queryByText("'email' cannot be empty"),
397
+ ).not.toBeInTheDocument();
398
+
399
+ // Blurring the empty email field marks it touched — its error appears
400
+ // without any submit attempt, and only its own.
401
+ await userEvent.click(canvas.getByLabelText(/^Email/));
402
+ await userEvent.tab();
403
+ await expect(canvas.getByText("'email' cannot be empty")).toBeInTheDocument();
404
+ await expect(
405
+ canvas.queryByText("'coverageType' cannot be empty"),
406
+ ).not.toBeInTheDocument();
407
+
408
+ // Deep-path writes flow through the wrapper onChange.
409
+ await userEvent.type(canvas.getByLabelText(/^City/), 'Lyon');
410
+ await expect(canvas.getByLabelText(/^City/)).toHaveValue('Lyon');
411
+
412
+ // A submit attempt unlocks the untouched fields' errors too — including
413
+ // the NESTED postalCode error, addressed ['homeAddress', 'postalCode']
414
+ // by the recursive walk and surfaced by its deep-path binding.
415
+ await userEvent.click(canvas.getByRole('button', { name: 'Get quote' }));
416
+ await expect(
417
+ canvas.getByText("'coverageType' cannot be empty"),
418
+ ).toBeInTheDocument();
419
+ await expect(
420
+ canvas.getByText("'postalCode' cannot be empty"),
421
+ ).toBeInTheDocument();
422
+ // The touched City field passed its nested constraint — no error.
423
+ await expect(canvas.queryByText("'city' cannot be empty")).not.toBeInTheDocument();
424
+
425
+ // Fix the remaining fields; committing a select option counts as its touch.
426
+ await userEvent.type(canvas.getByLabelText(/^Postal code/), '69001');
427
+ await expect(
428
+ canvas.queryByText("'postalCode' cannot be empty"),
429
+ ).not.toBeInTheDocument();
430
+ await userEvent.type(canvas.getByLabelText(/^Email/), 'will@example.com');
431
+ await userEvent.click(canvas.getByRole('button', { name: 'Coverage' }));
432
+ // The option list is portaled — query the document, not the canvas.
433
+ await userEvent.click(await body.findByRole('option', { name: 'Liability' }));
434
+ await expect(
435
+ canvas.queryByText("'coverageType' cannot be empty"),
436
+ ).not.toBeInTheDocument();
437
+
438
+ // Valid submit delivers the narrowed payload.
439
+ await userEvent.click(canvas.getByRole('button', { name: 'Get quote' }));
440
+ await expect(canvas.getByText(/onSubmit received/)).toBeInTheDocument();
441
+ },
442
+ };
443
+
262
444
  type DebuggerDemoValues = {
263
445
  email: string | undefined;
264
446
  nickname: string | undefined;
@@ -387,24 +569,27 @@ export const WithDebugger: Story = {
387
569
 
388
570
  // Open: live state, including errors the form itself isn't showing yet
389
571
  // (its display is submit-gated; the debugger sees the raw truth).
572
+ // String leaves render with surrounding quotes (Chrome-inspector style).
390
573
  await userEvent.click(body.getByRole('button', { name: 'signup form' }));
391
574
  await expect(body.getByText('isValid')).toBeInTheDocument();
392
- await expect(body.getByText("'email' cannot be empty")).toBeInTheDocument();
393
575
  await expect(
394
- body.getByText("'nickname' cannot be empty"),
576
+ body.getByText('"\'email\' cannot be empty"'),
577
+ ).toBeInTheDocument();
578
+ await expect(
579
+ body.getByText('"\'nickname\' cannot be empty"'),
395
580
  ).toBeInTheDocument();
396
581
 
397
582
  // Live update while open: value appears, its error entry drops out.
398
583
  await userEvent.type(canvas.getByLabelText(/^Nickname/), 'will');
399
- await expect(body.getByText('will')).toBeInTheDocument();
584
+ await expect(body.getByText('"will"')).toBeInTheDocument();
400
585
  await expect(
401
- body.queryByText("'nickname' cannot be empty"),
586
+ body.queryByText('"\'nickname\' cannot be empty"'),
402
587
  ).not.toBeInTheDocument();
403
588
 
404
589
  // List values render in the window (index-keyed).
405
590
  await userEvent.type(canvas.getByLabelText(/^Tags/), 'typescript');
406
591
  await userEvent.click(canvas.getByRole('button', { name: 'Add' }));
407
- await expect(body.getByText('typescript')).toBeInTheDocument();
592
+ await expect(body.getByText('"typescript"')).toBeInTheDocument();
408
593
 
409
594
  // Close: window unmounts, trigger stays.
410
595
  await userEvent.click(body.getByRole('button', { name: 'signup form' }));