@structuralists/scaffolding 0.10.2 → 0.11.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.
package/eslint.config.mjs CHANGED
@@ -132,10 +132,10 @@ export default [{
132
132
  "Files inside a primitive folder must not import their own index barrel; import the neighbor directly.",
133
133
  },
134
134
  // Presentational primitives (components and form elements) must
135
- // not reach into the form-state layer. The sanctioned bridge runs
135
+ // not reach into the form-state layer. The sanctioned bridges run
136
136
  // the other way: state's FormDebugger imports the JsonTable barrel
137
- // (dev tooling), and the item-6 form-aware wrappers will live with
138
- // the state side. State stories may also use element barrels.
137
+ // (dev tooling), and the form-aware wrappers live on the state
138
+ // side (state/bindings/). State stories may also use element barrels.
139
139
  {
140
140
  from: [
141
141
  { type: 'primitive' },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.10.2",
3
+ "version": "0.11.0",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -10,10 +10,11 @@ The forms umbrella, split into two named subtrees:
10
10
  - `state/` — the strongly-typed React form state hook and its machinery.
11
11
  Everything below this heading is about the state layer.
12
12
 
13
- The one sanctioned state→elements-side dependency is the Debugger bridge:
14
- `state/useFormState/FormDebugger.tsx` imports the `JsonTable` barrel from
15
- `src/components/Json/` (dev tooling). The item-6 form-aware wrappers will be
16
- the second bridge, living on the state side.
13
+ Two sanctioned state→elements-side bridges exist, both on the state side:
14
+ the Debugger (`state/useFormState/FormDebugger.tsx` imports the `JsonTable`
15
+ barrel from `src/components/Json/`, dev tooling) and the form-aware element
16
+ wrappers (`state/bindings/` see "Field binding" below). State files
17
+ importing an element go through its barrel like any external importer.
17
18
 
18
19
  The headline feature of the state layer is **validation that propagates type
19
20
  refinements to the submit handler** — passing the right constraints means the
@@ -201,11 +202,74 @@ errorAt(errors, ['email']); // string | undefined
201
202
  ```
202
203
 
203
204
  `errorAt` (state/useFormState/errorAt.ts) matches by exact structural step
204
- equality (no prefix matching); with first-error-wins validation there is at
205
- most one entry per path, and if collect-all ever lands the first entry stays
206
- the one shown. The path-addressed model is what the recursive grammar
207
- (errors just carry longer paths) and `getFormFieldPropsAt`'s `errorMessage`
208
- build on.
205
+ equality (`pathsEqual` in state/path/path.ts — no prefix matching); with
206
+ first-error-wins validation there is at most one entry per path, and if
207
+ collect-all ever lands the first entry stays the one shown. The
208
+ path-addressed model is what the recursive grammar (errors just carry
209
+ longer paths) and `getFormFieldPropsAt`'s `errorMessage` build on. `errorAt`
210
+ is raw truth (no display gating); `errorMessage` is the display-policy-aware
211
+ reading (see "Field binding").
212
+
213
+ ## Field binding: `getFormFieldPropsAt(path)` + `state/bindings/`
214
+
215
+ One expression wires a field to the form:
216
+
217
+ ```tsx
218
+ <TextInputForForm label="City"
219
+ formFieldProps={getFormFieldPropsAt(['homeAddress', 'city'])} />
220
+ ```
221
+
222
+ `getFormFieldPropsAt` is a member of the hook's return value, typed by the
223
+ `state/path/` machinery:
224
+
225
+ ```ts
226
+ // on FormHelpers<T>:
227
+ getFormFieldPropsAt<P extends Path<T>>(path: P): FormFieldProps<ValueAt<T, P>>;
228
+
229
+ type FormFieldProps<V> = FieldBinding<V>;
230
+ type FieldBinding<Display, Emit = Display> = {
231
+ value: Display; // read(values, path)
232
+ onChange: (val: Emit) => void; // immutable write(values, path, val)
233
+ errorMessage: string | undefined; // display-policy-aware (below)
234
+ onBlur: () => void; // feeds touched tracking
235
+ };
236
+ ```
237
+
238
+ The pieces, and where each one's semantics live:
239
+
240
+ - **Granular path writes** — `write()` in `state/path/path.ts`, the
241
+ immutable-update mirror of `read()`: clones only the spine to the written
242
+ leaf, so untouched siblings keep identity. Dead-step semantics mirror
243
+ read() returning undefined: writing through an absent/null ancestor, a
244
+ shape mismatch, or an out-of-bounds index is an identity-preserving no-op
245
+ — you can't edit a field of an absent section; materialize the section
246
+ first. (Appending/splicing lists is item-8 territory, not a path write.)
247
+ `onValueChanges` (whole-value replacement) still exists but is no longer
248
+ the only write path.
249
+ - **Touched tracking** — real state in `useFieldBinding`: a `Path<T>[]`
250
+ compared with `pathsEqual`, fed by `onBlur`, visible in the Debugger
251
+ snapshot. Commit-style elements (selects) call `onBlur` on commit — the
252
+ commit IS their blur moment.
253
+ - **THE error-display policy** lives inside `errorMessage` and nowhere
254
+ else: show a field's error once the field is touched OR a submit has been
255
+ attempted. Elements render what they're given and stay policy-free. A UI
256
+ wanting a different policy (e.g. always-live) reads `errors`/`errorAt`
257
+ directly.
258
+
259
+ ### The element wrappers (`state/bindings/`)
260
+
261
+ The **wrapper style** is the prototyped shorthand (per the plan's deciding
262
+ criterion — simplest for agents to work with): `TextInputForForm` and
263
+ `SingleSelectForForm` compose `Field` (label/hint/error presentation)
264
+ around the element and accept the bundle as one `formFieldProps` prop.
265
+ The wrapper declares what it can display and emit via
266
+ `FieldBinding<Display, Emit>` — e.g. the text input takes
267
+ `FieldBinding<string | null | undefined, string>` — and a
268
+ `FormFieldProps<V>` is assignable exactly when `Emit ⊆ V ⊆ Display`. That
269
+ one structural check is the end-to-end type safety: binding a number- or
270
+ boolean-typed path (or a literal-union field, for free text) to a
271
+ text-shaped element fails to compile at the `formFieldProps` prop, with no
272
+ generics at the use site.
209
273
 
210
274
  ## Union policy — what form state may hold
211
275
 
@@ -309,10 +373,11 @@ precision end-to-end. So we wire it up before adding any consumers.
309
373
 
310
374
  - `state/useFormState/` — the hook + the value-model types (`FormValueSimple`,
311
375
  `FormValuesObject`, `FormValueList`). Hook surface: `{ values,
312
- onValueChanges, errors, isValid, submitAttempted, submit, Debugger }`.
376
+ onValueChanges, errors, isValid, submitAttempted, submit,
377
+ getFormFieldPropsAt, Debugger }`.
313
378
  `useFormState.ts` itself is deliberately **plumbing only**: it holds the
314
- values `useState` (a bare `useState` today; granular path-based setters
315
- are what would earn values state a module of its own), links up the
379
+ values `useState` (a bare `useState`; the granular path writes layer on
380
+ top of its setter via `useFieldBinding`), links up the
316
381
  modules below, and recomposes their outputs into `FormHelpers<T>`. Each
317
382
  slice lives in its own file so it is independently comprehensible and
318
383
  unit-testable at its own boundary:
@@ -330,6 +395,12 @@ precision end-to-end. So we wire it up before adding any consumers.
330
395
  the correlation. It also hosts the cast-free `FlatConstraintEntry`
331
396
  assignment that polices grammar growth (see the `state/validations/` bullet).
332
397
  - `errorAt.ts` — the typed error lookup.
398
+ - `useFieldBinding.ts` — field binding: owns the touched list and builds
399
+ `getFormFieldPropsAt` (see "Field binding" above — the error-display
400
+ policy lives here). Two documented honest casts correlate `read()`/
401
+ `write()` results with `ValueAt<T, P>`/`T` for generic `T`; both are
402
+ truths the runtime upholds by construction, same family as `errorAt`'s
403
+ path widening.
333
404
  - `useFormSubmit.ts` — submit orchestration: owns `submitAttempted`
334
405
  (lets UIs gate error display) and the validity gate in front of
335
406
  `onSubmit`; its `submit()` performs the one honest *refinement* cast
@@ -348,9 +419,11 @@ precision end-to-end. So we wire it up before adding any consumers.
348
419
  subscribes (`useSyncExternalStore`), so an unused Debugger costs
349
420
  ~nothing. `inspectable.ts` converts arrays/Sets to shapes `JsonTable`
350
421
  can render.
351
- - `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
352
- Will be used for granular setters and for surfacing per-field
353
- errors/touched state. Coupled to the form value model intentionally.
422
+ - `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`,
423
+ `read()`), plus `write()` (the immutable-update mirror of `read()` behind
424
+ granular field writes) and `pathsEqual` (the one definition of structural
425
+ path equality — `errorAt` and touched tracking both use it). Coupled to
426
+ the form value model intentionally.
354
427
  Union handling is governed by the "Union policy" section above; the
355
428
  policy's type-level guts (`IsDisallowedFormUnion`, `UnionPolicyCheck`,
356
429
  `DisallowedFormUnion`) live with the value model in `state/useFormState/types.ts`.
@@ -367,6 +440,9 @@ precision end-to-end. So we wire it up before adding any consumers.
367
440
  function). Keep it cast-free. The one widening cast inside `validateEntry`
368
441
  mirrors `allOf`'s part-call: honest contravariant widening of a single,
369
442
  already-normalized validator.
443
+ - `state/bindings/` — the form-aware element wrappers (`TextInputForForm`,
444
+ `SingleSelectForForm`), the second sanctioned state→elements bridge (see
445
+ "Field binding" above). Element imports go through the element barrels.
370
446
  - `state/validators/` — the standard-library validators (`notEmpty`, `minLength`,
371
447
  `matches`, `min`) plus `allOf`, which composes validators on one field and
372
448
  carries the union of the parts' refinements. `allOf` derives its input type
@@ -14,6 +14,7 @@ export const Input = (props: InputProps) => {
14
14
  value,
15
15
  defaultValue,
16
16
  onChange,
17
+ onBlur,
17
18
  placeholder,
18
19
  required,
19
20
  autoFocus,
@@ -29,6 +30,7 @@ export const Input = (props: InputProps) => {
29
30
  value={value}
30
31
  defaultValue={defaultValue}
31
32
  onChange={onChange}
33
+ onBlur={onBlur}
32
34
  placeholder={placeholder}
33
35
  required={required}
34
36
  autoFocus={autoFocus}
@@ -1,4 +1,4 @@
1
- import type { ChangeEventHandler } from 'react';
1
+ import type { ChangeEventHandler, FocusEventHandler } from 'react';
2
2
 
3
3
  export type InputSize = 'small' | 'medium';
4
4
  export type InputType = 'text' | 'email' | 'password';
@@ -9,6 +9,7 @@ export type InputProps = {
9
9
  value?: string;
10
10
  defaultValue?: string;
11
11
  onChange?: ChangeEventHandler<HTMLInputElement>;
12
+ onBlur?: FocusEventHandler<HTMLInputElement>;
12
13
  placeholder?: string;
13
14
  required?: boolean;
14
15
  autoFocus?: boolean;
package/src/forms/plan.md CHANGED
@@ -34,9 +34,15 @@ TS wall can't strand finished work behind it.
34
34
  `src/forms/` umbrella: elements in `src/forms/elements/<Component>/`,
35
35
  the state layer in `src/forms/state/{useFormState,validations,validators,path}/`.
36
36
  5. **Item 6 — field binding** (`getFormFieldPropsAt` + wrappers). Needs the
37
- error model and the split; `Path`/`ValueAt` already validated. *current*
38
- 6. **Type spike, then items 2/3/4** the recursion risk zone. Throwaway
39
- `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
37
+ error model and the split; `Path`/`ValueAt` already validated. *done*
38
+ `getFormFieldPropsAt` on the hook result (value / typed onChange /
39
+ policy-aware errorMessage / onBlur), `write()` as the immutable mirror of
40
+ `read()` in `state/path/path.ts`, touched tracking as real state, and the
41
+ wrapper-style element shorthands prototyped (`state/bindings/`:
42
+ `TextInputForForm`, `SingleSelectForForm`). Probe ratchet held (see the
43
+ baseline note under the recursion budget).
44
+ 6. **Type spike, then items 2/3/4** — the recursion risk zone. ← *current*
45
+ Throwaway `.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
40
46
  any runtime work; three outcomes (works / slow / intractable) each with a
41
47
  known response. Worst case, everything above still shipped.
42
48
 
@@ -187,6 +193,13 @@ multi-second check time:
187
193
  - post-step-3 (error-model swap: `Path<T>`-typed `FormError`, `errorAt`,
188
194
  plus probes): check 0.79 s, 113,724 instantiations, 52,294 types
189
195
  (+4.4% instantiations over pre-step-3 HEAD)
196
+ - post-item-5 split (0.10.1): check 0.81 s, 112,981 instantiations,
197
+ 52,504 types
198
+ - post-item-6 (field binding: `getFormFieldPropsAt`, `FieldBinding`
199
+ wrappers, plus deep-path probes at `InsuranceQuoteForm` scale): check
200
+ 0.82 s, 125,022 instantiations, 54,959 types (+10.7% instantiations over
201
+ post-item-5 — the `ValueAt` instantiations at each binding call site are
202
+ real but cheap; check time flat)
190
203
 
191
204
  ## Runtime consequences (can't be dodged)
192
205
 
@@ -275,7 +288,29 @@ Blast radius that was handled when this landed:
275
288
  Debugger is a designated bridge layer). Explicitly allowed for in the new
276
289
  boundaries rules.
277
290
 
278
- ## 6. Field binding: `getFormFieldPropsAt(path)` + element shorthands
291
+ ## 6. Field binding: `getFormFieldPropsAt(path)` + element shorthands ✅ done
292
+
293
+ Landed as specced below; deltas and decisions:
294
+
295
+ - `write()` in `state/path/path.ts` is the immutable-update mirror of
296
+ `read()` — clones only the spine, dead-step semantics mirror read()
297
+ returning undefined (writing through an absent section is an
298
+ identity-preserving no-op; materialize the section first).
299
+ - Touched is real state (`useFieldBinding`), a `Path<T>[]` compared with
300
+ `pathsEqual` — the same structural representation as `FormErrors`. It
301
+ also shows up in the Debugger snapshot.
302
+ - The error-display policy (touched-or-submitAttempted) lives inside
303
+ `errorMessage`, nowhere else — see "Field binding" in forms/CLAUDE.md.
304
+ - **Element-shorthand style: the wrapper style is the prototype**
305
+ (`TextInputForForm`, `SingleSelectForForm` in `src/forms/state/bindings/`,
306
+ the second sanctioned state→elements bridge after the Debugger). The
307
+ wrappers declare what they display/emit via `FieldBinding<Display, Emit>`
308
+ and plain structural assignability rejects wrong-shaped bindings — no
309
+ generics at the use site. Union-typed props on the elements themselves
310
+ remain the fallback if the parallel component set gets heavy; judge after
311
+ the wrapper set grows past these two.
312
+
313
+ Original spec follows.
279
314
 
280
315
  Today every field is wired by hand in JSX: read `values.x`, spread-update via
281
316
  `onValueChanges`, look up the error, gate it on `submitAttempted`. That's
@@ -427,8 +462,9 @@ Possibly a hybrid: identity-matching as the default heuristic, owned ops as
427
462
  the guaranteed path. **Research topic for when we get here** — record
428
463
  findings in the learning map before committing to either.
429
464
 
430
- Sequencing: visited tracking lands with item 6 (its `onBlur` is the write
431
- path). Keys + array-sync matter from phase 3 (list `each` specs) onward —
465
+ Sequencing: visited tracking *landed with item 6* (its `onBlur` is the
466
+ write path; `touched: Path<T>[]` in `useFieldBinding`, structural equality
467
+ via `pathsEqual`). Keys + array-sync matter from phase 3 (list `each` specs) onward —
432
468
  error paths and per-element meta both need element identity once lists are
433
469
  editable.
434
470
 
@@ -0,0 +1,45 @@
1
+ import { Field } from '../../elements/Field';
2
+ import { SingleSelect } from '../../elements/Select';
3
+ import type { SelectOption, SelectSize } from '../../elements/Select';
4
+ import type { FieldBinding } from '../useFormState/types';
5
+
6
+ export type SingleSelectForFormProps<T extends string> = {
7
+ // Displays T|null|undefined, emits T — a FormFieldProps<V> fits when the
8
+ // path's value type sits between them, so the options' literal union and
9
+ // the field's type must line up or the binding fails to compile.
10
+ formFieldProps: FieldBinding<T | null | undefined, T>;
11
+ options: SelectOption<T>[];
12
+ label: string;
13
+ hint?: string;
14
+ placeholder?: string;
15
+ size?: SelectSize;
16
+ isDisabled?: boolean;
17
+ };
18
+
19
+ // The form-aware flavor of the single select. A select has no meaningful
20
+ // blur moment — committing an option IS the completed interaction — so the
21
+ // commit marks the field touched alongside the write.
22
+ export const SingleSelectForForm = <T extends string>(
23
+ props: SingleSelectForFormProps<T>,
24
+ ) => {
25
+ const { formFieldProps, options, label, hint, placeholder, size, isDisabled } =
26
+ props;
27
+ const { value, onChange, errorMessage, onBlur } = formFieldProps;
28
+
29
+ return (
30
+ <Field label={label} hint={hint} error={errorMessage}>
31
+ <SingleSelect
32
+ options={options}
33
+ value={value ?? null}
34
+ onChange={(next) => {
35
+ onChange(next);
36
+ onBlur();
37
+ }}
38
+ size={size}
39
+ isDisabled={isDisabled}
40
+ placeholder={placeholder}
41
+ ariaLabel={label}
42
+ />
43
+ </Field>
44
+ );
45
+ };
@@ -0,0 +1,45 @@
1
+ import { Field } from '../../elements/Field';
2
+ import { Input } from '../../elements/Input';
3
+ import type { InputProps } from '../../elements/Input';
4
+ import type { FieldBinding } from '../useFormState/types';
5
+
6
+ export type TextInputForFormProps = {
7
+ // The bundle from `getFormFieldPropsAt(path)`. The binding shape declares
8
+ // what this element can do — display string-ish, emit string — so a
9
+ // FormFieldProps<V> only fits when V is a text-shaped field
10
+ // (string / string|null / string|undefined / both). A number- or
11
+ // boolean-typed path is a compile error right here.
12
+ formFieldProps: FieldBinding<string | null | undefined, string>;
13
+ label: string;
14
+ hint?: string;
15
+ placeholder?: string;
16
+ id?: string;
17
+ type?: InputProps['type'];
18
+ size?: InputProps['size'];
19
+ disabled?: boolean;
20
+ };
21
+
22
+ // The form-aware flavor of the text input: one prop wires value, change,
23
+ // error display, and touched tracking. Composes Field (label/hint/error
24
+ // presentation) around Input; the error-display policy already happened
25
+ // inside `errorMessage` — this component just renders what it's given.
26
+ export const TextInputForForm = (props: TextInputForFormProps) => {
27
+ const { formFieldProps, label, hint, placeholder, id, type, size, disabled } =
28
+ props;
29
+ const { value, onChange, errorMessage, onBlur } = formFieldProps;
30
+
31
+ return (
32
+ <Field label={label} hint={hint} error={errorMessage} htmlFor={id}>
33
+ <Input
34
+ id={id}
35
+ type={type}
36
+ size={size}
37
+ value={value ?? ''}
38
+ onChange={(e) => onChange(e.target.value)}
39
+ onBlur={onBlur}
40
+ placeholder={placeholder}
41
+ disabled={disabled}
42
+ />
43
+ </Field>
44
+ );
45
+ };
@@ -1,5 +1,5 @@
1
1
  import { describe, test, expect } from 'bun:test';
2
- import { path, read } from './path';
2
+ import { path, pathsEqual, read, write } from './path';
3
3
 
4
4
  // These tests pin read()'s runtime semantics to exactly what the type level
5
5
  // (ValueAt) promises — see the union-semantics comments in ./types.ts and
@@ -95,6 +95,65 @@ describe('read step failures', () => {
95
95
  ).toBeUndefined();
96
96
  });
97
97
 
98
+ test('write replaces the value at a deep path without mutating the original', () => {
99
+ const next = write(filled, ['address', 'city'], 'Paris') as Form;
100
+
101
+ expect(next.address?.city).toBe('Paris');
102
+ expect(filled.address?.city).toBe('London');
103
+ });
104
+
105
+ test('write clones only the spine — untouched siblings keep their identity', () => {
106
+ const next = write(filled, ['a', 'b', 'c'], 'deeper') as Form;
107
+
108
+ // The spine to the written leaf is new at every level …
109
+ expect(next).not.toBe(filled);
110
+ expect(next.a).not.toBe(filled.a);
111
+ expect(next.a?.b).not.toBe(filled.a?.b);
112
+ // … while every branch off the spine is the same object.
113
+ expect(next.address).toBe(filled.address);
114
+ expect(next.entries).toBe(filled.entries);
115
+ });
116
+
117
+ test('write into a list element clones the array and that element only', () => {
118
+ const twoEntries: Form = {
119
+ ...filled,
120
+ entries: [
121
+ { title: 'first', qty: 1 },
122
+ { title: 'second', qty: 2 },
123
+ ],
124
+ };
125
+ const next = write(twoEntries, ['entries', 1, 'qty'], 5) as Form;
126
+
127
+ expect(next.entries?.[1]).toEqual({ title: 'second', qty: 5 });
128
+ expect(next.entries).not.toBe(twoEntries.entries);
129
+ expect(next.entries?.[0]).toBe(twoEntries.entries?.[0]);
130
+ expect(twoEntries.entries?.[1].qty).toBe(2);
131
+ });
132
+
133
+ test('write with an empty path replaces the root', () => {
134
+ expect(write(filled, [], { name: 'Eve' })).toEqual({ name: 'Eve' });
135
+ });
136
+
137
+ test('write through a dead ancestor is an identity-preserving no-op', () => {
138
+ // Mirrors read() returning undefined for a dead step: you cannot edit a
139
+ // field of an absent section — materialize the section first.
140
+ expect(write(empty, ['address', 'city'], 'Paris')).toBe(empty);
141
+ expect(write(empty, ['entries', 0, 'title'], 'x')).toBe(empty);
142
+ });
143
+
144
+ test('write on a shape mismatch or out-of-bounds index is a no-op', () => {
145
+ // Numeric step on a non-array, key step on a scalar, index past the end
146
+ // (appending is list manipulation, not a path write).
147
+ expect(write(filled, ['name', 'length'], 3)).toBe(filled);
148
+ expect(write(filled, ['entries', 5, 'title'], 'x')).toBe(filled);
149
+ const list = [1, 2];
150
+ expect(write(list, ['key'], 'x')).toBe(list);
151
+ });
152
+
153
+ test('writing the value already present is an identity-preserving no-op', () => {
154
+ expect(write(filled, ['address', 'city'], 'London')).toBe(filled);
155
+ });
156
+
98
157
  test('rejecting narrow predicate returns undefined; accepting one passes through', () => {
99
158
  const isString = (val: unknown): val is string => typeof val === 'string';
100
159
  const narrowed = path<Form>()
@@ -110,3 +169,14 @@ describe('read step failures', () => {
110
169
  expect(read(filled, rejecting)).toBeUndefined();
111
170
  });
112
171
  });
172
+
173
+ describe('pathsEqual', () => {
174
+ test('structural equality over steps — same steps equal, no prefix matching', () => {
175
+ expect(pathsEqual(['a', 0, 'b'], ['a', 0, 'b'])).toBe(true);
176
+ expect(pathsEqual([], [])).toBe(true);
177
+ expect(pathsEqual(['a'], ['a', 'b'])).toBe(false);
178
+ expect(pathsEqual(['a', 0], ['a', 1])).toBe(false);
179
+ // A numeric step never equals its string spelling.
180
+ expect(pathsEqual(['a', 0], ['a', '0'])).toBe(false);
181
+ });
182
+ });
@@ -22,6 +22,15 @@ const makeCursor = <T>(steps: readonly CursorStep[]): Cursor<T> => ({
22
22
 
23
23
  export const path = <T>(): Cursor<T> => makeCursor<T>([]);
24
24
 
25
+ // Structural path equality: same steps, same order, no prefix matching.
26
+ // The one sanctioned way to compare paths — `errorAt` and touched tracking
27
+ // both build on it, so "do these address the same field?" has exactly one
28
+ // definition.
29
+ export const pathsEqual = (
30
+ a: readonly PathStep[],
31
+ b: readonly PathStep[],
32
+ ): boolean => a.length === b.length && a.every((step, index) => step === b[index]);
33
+
25
34
  // Walk steps against a value. Returns undefined if any step fails — a dead
26
35
  // value (null or undefined) encountered mid-path, missing key, out-of-bounds
27
36
  // index, or a narrow predicate that rejects the current value. Always
@@ -51,3 +60,44 @@ export const read = (root: unknown, steps: readonly CursorStep[]): unknown => {
51
60
 
52
61
  return cursor;
53
62
  };
63
+
64
+ // The immutable-update mirror of read(): returns a new root with `value`
65
+ // placed at `steps`, cloning only the containers along the path — untouched
66
+ // siblings keep their identity. Key-only steps (a `Path<T>` value), not
67
+ // CursorStep — there is no meaningful way to write "through" a narrow
68
+ // predicate.
69
+ //
70
+ // Dead-step semantics mirror read() returning undefined: a step that hits a
71
+ // null/undefined container, a shape mismatch (key step on a non-object,
72
+ // numeric step on a non-array), or an out-of-bounds index makes the whole
73
+ // write a no-op that returns `root` by identity — you can't edit a field of
74
+ // an absent section; materialize the section first. (Appending/splicing is
75
+ // list-manipulation territory, deliberately not a path write.) Writing a
76
+ // value that is already there (Object.is) is also an identity-preserving
77
+ // no-op, so state setters can bail out of re-rendering.
78
+ export const write = (
79
+ root: unknown,
80
+ steps: readonly PathStep[],
81
+ value: unknown,
82
+ ): unknown => {
83
+ if (steps.length === 0) return value;
84
+ const [head, ...rest] = steps;
85
+
86
+ if (typeof head === 'number') {
87
+ if (!Array.isArray(root)) return root;
88
+ if (head < 0 || head >= root.length) return root;
89
+ const child = write(root[head], rest, value);
90
+ if (Object.is(child, root[head])) return root;
91
+ const clone = root.slice();
92
+ clone[head] = child;
93
+ return clone;
94
+ }
95
+
96
+ if (root === null || typeof root !== 'object' || Array.isArray(root)) {
97
+ return root;
98
+ }
99
+ const record = root as Record<string, unknown>;
100
+ const child = write(record[head], rest, value);
101
+ if (Object.is(child, record[head])) return root;
102
+ return { ...record, [head]: child };
103
+ };
@@ -43,6 +43,7 @@ export const createFormDebugger = <T extends FormValuesObject>(
43
43
  submitAttempted: snapshot.submitAttempted,
44
44
  values: snapshot.values,
45
45
  errors: snapshot.errors,
46
+ touched: snapshot.touched,
46
47
  })}
47
48
  />
48
49
  </div>
@@ -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
  };
@@ -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
 
@@ -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,162 @@ 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. The setSubmitted(vals) call below compiling is the
282
+ // proof that refinement still flows end-to-end when fields are wired
283
+ // through bindings.
284
+ type SubmittedQuote = {
285
+ email: string;
286
+ coverageType: CoverageType;
287
+ homeAddress: QuoteFormValues['homeAddress'];
288
+ };
289
+
290
+ const COVERAGE_OPTIONS: SelectOption<CoverageType>[] = [
291
+ { value: 'liability', label: 'Liability' },
292
+ { value: 'comprehensive', label: 'Comprehensive' },
293
+ ];
294
+
295
+ // The item-6 target: one expression wires a field. getFormFieldPropsAt
296
+ // bundles value + typed onChange (an immutable write at the path) +
297
+ // display-policy-aware errorMessage + onBlur, and the ForForm wrappers
298
+ // take the bundle as a single prop. Note the deep paths into homeAddress —
299
+ // no hand-spread updates anywhere.
300
+ const FieldBindingDemo = () => {
301
+ const [submitted, setSubmitted] = useState<SubmittedQuote | null>(null);
302
+
303
+ const { getFormFieldPropsAt, submit } = useFormState({
304
+ initialValues: {
305
+ email: undefined,
306
+ coverageType: null,
307
+ homeAddress: { city: undefined, postalCode: undefined },
308
+ } as QuoteFormValues,
309
+ constraints: {
310
+ email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
311
+ coverageType: notEmpty('coverageType'),
312
+ },
313
+ onSubmit: (vals) => setSubmitted(vals),
314
+ });
315
+
316
+ return (
317
+ <form
318
+ style={{ maxWidth: 420, display: 'grid', gap: 16 }}
319
+ onSubmit={(e) => {
320
+ e.preventDefault();
321
+ submit();
322
+ }}
323
+ >
324
+ <TextInputForForm
325
+ label="Email"
326
+ type="email"
327
+ placeholder="you@example.com"
328
+ hint="Blur the empty field to see touched-gated errors"
329
+ formFieldProps={getFormFieldPropsAt(['email'])}
330
+ />
331
+
332
+ <SingleSelectForForm
333
+ label="Coverage"
334
+ options={COVERAGE_OPTIONS}
335
+ placeholder="Pick a coverage"
336
+ formFieldProps={getFormFieldPropsAt(['coverageType'])}
337
+ />
338
+
339
+ <TextInputForForm
340
+ label="City"
341
+ formFieldProps={getFormFieldPropsAt(['homeAddress', 'city'])}
342
+ />
343
+
344
+ <TextInputForForm
345
+ label="Postal code"
346
+ formFieldProps={getFormFieldPropsAt(['homeAddress', 'postalCode'])}
347
+ />
348
+
349
+ <div>
350
+ <Button type="submit" variant="primary">
351
+ Get quote
352
+ </Button>
353
+ </div>
354
+
355
+ {submitted && (
356
+ <pre
357
+ style={{
358
+ background: 'var(--ui-surface-muted, #f4f4f4)',
359
+ padding: 12,
360
+ borderRadius: 6,
361
+ fontSize: 12,
362
+ margin: 0,
363
+ }}
364
+ >
365
+ {`onSubmit received (narrowed type):\n${JSON.stringify(submitted, null, 2)}`}
366
+ </pre>
367
+ )}
368
+ </form>
369
+ );
370
+ };
371
+
372
+ export const FieldBinding: Story = {
373
+ render: () => (
374
+ <div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
375
+ <FieldBindingDemo />
376
+ </div>
377
+ ),
378
+ // Walks the binding flow: touched/blur gating of errorMessage (before any
379
+ // submit attempt), deep-path writes through the wrappers, submit-attempt
380
+ // gating for untouched fields, and the narrowed payload reaching onSubmit.
381
+ play: async ({ canvasElement }) => {
382
+ const canvas = within(canvasElement);
383
+ const body = within(canvasElement.ownerDocument.body);
384
+
385
+ // Nothing shown initially: the email error exists in the raw list, but
386
+ // errorMessage withholds it until the field is touched.
387
+ await expect(
388
+ canvas.queryByText("'email' cannot be empty"),
389
+ ).not.toBeInTheDocument();
390
+
391
+ // Blurring the empty email field marks it touched — its error appears
392
+ // without any submit attempt, and only its own.
393
+ await userEvent.click(canvas.getByLabelText(/^Email/));
394
+ await userEvent.tab();
395
+ await expect(canvas.getByText("'email' cannot be empty")).toBeInTheDocument();
396
+ await expect(
397
+ canvas.queryByText("'coverageType' cannot be empty"),
398
+ ).not.toBeInTheDocument();
399
+
400
+ // Deep-path writes flow through the wrapper onChange.
401
+ await userEvent.type(canvas.getByLabelText(/^City/), 'Lyon');
402
+ await expect(canvas.getByLabelText(/^City/)).toHaveValue('Lyon');
403
+
404
+ // A submit attempt unlocks the untouched coverage field's error too.
405
+ await userEvent.click(canvas.getByRole('button', { name: 'Get quote' }));
406
+ await expect(
407
+ canvas.getByText("'coverageType' cannot be empty"),
408
+ ).toBeInTheDocument();
409
+
410
+ // Fix both fields; committing a select option counts as its touch.
411
+ await userEvent.type(canvas.getByLabelText(/^Email/), 'will@example.com');
412
+ await userEvent.click(canvas.getByRole('button', { name: 'Coverage' }));
413
+ // The option list is portaled — query the document, not the canvas.
414
+ await userEvent.click(await body.findByRole('option', { name: 'Liability' }));
415
+ await expect(
416
+ canvas.queryByText("'coverageType' cannot be empty"),
417
+ ).not.toBeInTheDocument();
418
+
419
+ // Valid submit delivers the narrowed payload.
420
+ await userEvent.click(canvas.getByRole('button', { name: 'Get quote' }));
421
+ await expect(canvas.getByText(/onSubmit received/)).toBeInTheDocument();
422
+ },
423
+ };
424
+
262
425
  type DebuggerDemoValues = {
263
426
  email: string | undefined;
264
427
  nickname: string | undefined;
@@ -2,9 +2,11 @@ import { describe, it, expectTypeOf } from 'vitest';
2
2
  import { useFormState } from './useFormState';
3
3
  import { errorAt } from './errorAt';
4
4
  import { allOf, matches, min, minLength, notEmpty } from '../validators/validators';
5
- import type { FormErrors } from './types';
5
+ import type { FormErrors, FormFieldProps } from './types';
6
6
  import type { Refine, Validations } from '../validations/types';
7
7
  import type { Path, ValueAt } from '../path/types';
8
+ import type { TextInputForFormProps } from '../bindings/TextInputForForm';
9
+ import type { SingleSelectForFormProps } from '../bindings/SingleSelectForForm';
8
10
 
9
11
  // These tests exercise the headline feature end-to-end at the hook boundary:
10
12
  // the type `onSubmit` receives must be the *refined* form type. The two
@@ -425,6 +427,83 @@ describe('useFormState narrowing at realistic scale', () => {
425
427
  errorAt(errors, ['email', 'domain']);
426
428
  });
427
429
 
430
+ it('getFormFieldPropsAt infers FormFieldProps<ValueAt> at deep paths, inline at the call site', () => {
431
+ const form = useFormState({
432
+ initialValues: {} as InsuranceQuoteForm,
433
+ constraints: { email: notEmpty('email') },
434
+ });
435
+
436
+ // Flat and deep paths: value/onChange typed by ValueAt<T, P>.
437
+ expectTypeOf(form.getFormFieldPropsAt(['email'])).toEqualTypeOf<
438
+ FormFieldProps<string | undefined>
439
+ >();
440
+ expectTypeOf(
441
+ form.getFormFieldPropsAt(['homeAddress', 'city']),
442
+ ).toEqualTypeOf<FormFieldProps<string | undefined>>();
443
+ expectTypeOf(
444
+ form.getFormFieldPropsAt(['drivers', 0, 'incidents', 1, 'claimAmountUsd']),
445
+ ).toEqualTypeOf<FormFieldProps<number | null>>();
446
+ expectTypeOf(
447
+ form.getFormFieldPropsAt(['vehicles', 0, 'garagingAddress', 'postalCode']),
448
+ ).toEqualTypeOf<FormFieldProps<string | undefined>>();
449
+
450
+ // Nullable-object semantics (PR #17) flow through the binding: stepping
451
+ // through a dead ancestor adds `| undefined`, stopping AT a nullable
452
+ // field keeps its exact type.
453
+ expectTypeOf(
454
+ form.getFormFieldPropsAt(['coApplicant', 'sharesResidence']),
455
+ ).toEqualTypeOf<FormFieldProps<boolean | undefined>>();
456
+ expectTypeOf(
457
+ form.getFormFieldPropsAt(['pastPolicies', 0, 'activeUntil']),
458
+ ).toEqualTypeOf<FormFieldProps<string | null | undefined>>();
459
+ expectTypeOf(form.getFormFieldPropsAt(['employer'])).toEqualTypeOf<
460
+ FormFieldProps<string | null>
461
+ >();
462
+
463
+ // Only Path<T> is admitted.
464
+ // @ts-expect-error 'emial' is not a field of the form
465
+ form.getFormFieldPropsAt(['emial']);
466
+ // @ts-expect-error no paths exist below a scalar leaf
467
+ form.getFormFieldPropsAt(['email', 'domain']);
468
+ });
469
+
470
+ it('element wrappers accept only shape-compatible bindings', () => {
471
+ const form = useFormState({ initialValues: {} as InsuranceQuoteForm });
472
+
473
+ type TextBinding = TextInputForFormProps['formFieldProps'];
474
+
475
+ // Text-shaped fields bind: V must sit between the wrapper's emit type
476
+ // (string) and display type (string | null | undefined).
477
+ expectTypeOf(
478
+ form.getFormFieldPropsAt(['homeAddress', 'city']),
479
+ ).toMatchTypeOf<TextBinding>();
480
+ expectTypeOf(form.getFormFieldPropsAt(['employer'])).toMatchTypeOf<TextBinding>();
481
+ expectTypeOf(
482
+ form.getFormFieldPropsAt(['pastPolicies', 0, 'activeUntil']),
483
+ ).toMatchTypeOf<TextBinding>();
484
+
485
+ // Wrong-shaped bindings fail at the formFieldProps prop.
486
+ // @ts-expect-error a number-typed path cannot bind to a text element
487
+ const numberIntoText: TextBinding = form.getFormFieldPropsAt(['deductibleUsd']);
488
+ // @ts-expect-error a boolean-typed path cannot bind to a text element
489
+ const booleanIntoText: TextBinding = form.getFormFieldPropsAt(['agreedToTerms']);
490
+
491
+ // The select wrapper lines its options' literal union up with the field:
492
+ // emitting a wider type than the field holds is rejected.
493
+ type CoverageBinding = SingleSelectForFormProps<
494
+ 'liability' | 'comprehensive'
495
+ >['formFieldProps'];
496
+ expectTypeOf<
497
+ FormFieldProps<'liability' | 'comprehensive' | undefined>
498
+ >().toMatchTypeOf<CoverageBinding>();
499
+ // @ts-expect-error a plain-string field would accept values outside the options
500
+ const stringIntoSelect: CoverageBinding = form.getFormFieldPropsAt(['coverageType']);
501
+
502
+ void numberIntoText;
503
+ void booleanIntoText;
504
+ void stringIntoSelect;
505
+ });
506
+
428
507
  it('paths through optional sections and nullable lists resolve, at scale', () => {
429
508
  // The latent hole this pins: Path admitted these paths all along, but
430
509
  // ValueAt resolved them to `never` because `keyof (Obj | undefined)` is
@@ -5,6 +5,7 @@ import type {
5
5
  FormValuesObject,
6
6
  UnionPolicyCheck,
7
7
  } from './types';
8
+ import { useFieldBinding } from './useFieldBinding';
8
9
  import { useFormDebugger } from './useFormDebugger';
9
10
  import { useFormSubmit } from './useFormSubmit';
10
11
  import type { Refine, Validations } from '../validations/types';
@@ -30,8 +31,8 @@ type Args<T extends FormValuesObject, V extends Validations<T>> = {
30
31
  // Plumbing only: each slice of form state lives in its own pure function or
31
32
  // focused hook, and this hook just links them up and recomposes their
32
33
  // outputs into the `FormHelpers<T>` surface. Values state is the one slice
33
- // kept inline — today it is a bare `useState`, and the granular setters that
34
- // would earn it a module of its own arrive with the path-based grammar.
34
+ // kept inline — a bare `useState`; the granular path writes layer on top of
35
+ // its setter (`useFieldBinding` funnels `write()` results through it).
35
36
  export const useFormState = <
36
37
  T extends FormValuesObject,
37
38
  const V extends Validations<T> = Validations<T>,
@@ -53,8 +54,15 @@ export const useFormState = <
53
54
  onSubmit,
54
55
  });
55
56
 
57
+ const { touched, getFormFieldPropsAt } = useFieldBinding({
58
+ values,
59
+ onValueChanges,
60
+ errors,
61
+ submitAttempted,
62
+ });
63
+
56
64
  const Debugger = useFormDebugger({
57
- snapshot: { values, errors, isValid, submitAttempted },
65
+ snapshot: { values, errors, isValid, submitAttempted, touched },
58
66
  });
59
67
 
60
68
  return {
@@ -64,6 +72,7 @@ export const useFormState = <
64
72
  isValid,
65
73
  submitAttempted,
66
74
  submit,
75
+ getFormFieldPropsAt,
67
76
  Debugger,
68
77
  };
69
78
  };