@structuralists/scaffolding 0.10.1 → 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.
Files changed (89) hide show
  1. package/eslint.config.mjs +56 -2
  2. package/package.json +1 -1
  3. package/src/components/Chat/ChatRecipientsHeader/ChatRecipientsHeader.stories.tsx +4 -4
  4. package/src/components/Layout/Bar/Bar.stories.tsx +1 -1
  5. package/src/components/Layout/Panels/Panels.stories.tsx +1 -1
  6. package/src/components/Layout/Stack/Stack.stories.tsx +1 -1
  7. package/src/components/Modals/LargeModal/LargeModal.stories.tsx +1 -1
  8. package/src/components/Modals/MediumModal/MediumModal.stories.tsx +3 -3
  9. package/src/components/Modals/internal/ModalHeader.tsx +1 -1
  10. package/src/components/Overlays/Popover/Popover.stories.tsx +3 -3
  11. package/src/components/Overlays/Tooltip/Tooltip.stories.tsx +1 -1
  12. package/src/components/Tables/BigTable/BigTable.stories.tsx +1 -1
  13. package/src/forms/CLAUDE.md +115 -24
  14. package/src/{components/Forms → forms/elements}/Button/Button.stories.tsx +1 -1
  15. package/src/{components/Forms → forms/elements}/IconButton/index.tsx +1 -1
  16. package/src/{components/Forms → forms/elements}/Input/index.tsx +2 -0
  17. package/src/{components/Forms → forms/elements}/Input/types.ts +2 -1
  18. package/src/{components/Forms → forms/elements}/Select/MultiSelect/MultiSelect.stories.tsx +2 -2
  19. package/src/{components/Forms → forms/elements}/Select/MultiSelect/index.tsx +1 -1
  20. package/src/{components/Forms → forms/elements}/Select/SingleSelect/SingleSelect.stories.tsx +3 -3
  21. package/src/{components/Forms → forms/elements}/Select/SingleSelect/index.tsx +1 -1
  22. package/src/forms/plan.md +84 -38
  23. package/src/forms/state/bindings/SingleSelectForForm.tsx +45 -0
  24. package/src/forms/state/bindings/TextInputForForm.tsx +45 -0
  25. package/src/forms/{path → state/path}/path.test.ts +71 -1
  26. package/src/forms/state/path/path.ts +103 -0
  27. package/src/forms/{useFormState → state/useFormState}/FormDebugger.tsx +2 -1
  28. package/src/forms/{useFormState → state/useFormState}/errorAt.ts +8 -12
  29. package/src/forms/{useFormState → state/useFormState}/types.ts +33 -2
  30. package/src/forms/state/useFormState/useFieldBinding.test.tsx +165 -0
  31. package/src/forms/state/useFormState/useFieldBinding.ts +71 -0
  32. package/src/forms/{useFormState → state/useFormState}/useFormDebugger.test.tsx +1 -0
  33. package/src/forms/{useFormState → state/useFormState}/useFormState.stories.tsx +167 -4
  34. package/src/forms/{useFormState → state/useFormState}/useFormState.test-d.ts +80 -1
  35. package/src/forms/{useFormState → state/useFormState}/useFormState.ts +12 -3
  36. package/src/index.ts +10 -10
  37. package/src/storybook/Composition.stories.tsx +4 -4
  38. package/src/storybook/_StoryUtils.stories.tsx +1 -1
  39. package/src/forms/path/path.ts +0 -53
  40. /package/src/{components/Forms → forms/elements}/Button/index.tsx +0 -0
  41. /package/src/{components/Forms → forms/elements}/Button/styles.module.css +0 -0
  42. /package/src/{components/Forms → forms/elements}/Button/types.ts +0 -0
  43. /package/src/{components/Forms → forms/elements}/ColorInput/index.tsx +0 -0
  44. /package/src/{components/Forms → forms/elements}/ColorInput/styles.module.css +0 -0
  45. /package/src/{components/Forms → forms/elements}/ColorInput/types.ts +0 -0
  46. /package/src/{components/Forms → forms/elements}/Field/Field.stories.tsx +0 -0
  47. /package/src/{components/Forms → forms/elements}/Field/index.tsx +0 -0
  48. /package/src/{components/Forms → forms/elements}/Field/styles.module.css +0 -0
  49. /package/src/{components/Forms → forms/elements}/Field/types.ts +0 -0
  50. /package/src/{components/Forms → forms/elements}/IconButton/IconButton.stories.tsx +0 -0
  51. /package/src/{components/Forms → forms/elements}/IconButton/styles.module.css +0 -0
  52. /package/src/{components/Forms → forms/elements}/IconButton/types.ts +0 -0
  53. /package/src/{components/Forms → forms/elements}/Input/Input.stories.tsx +0 -0
  54. /package/src/{components/Forms → forms/elements}/Input/styles.module.css +0 -0
  55. /package/src/{components/Forms → forms/elements}/SearchInput/index.tsx +0 -0
  56. /package/src/{components/Forms → forms/elements}/SearchInput/styles.module.css +0 -0
  57. /package/src/{components/Forms → forms/elements}/SearchInput/types.ts +0 -0
  58. /package/src/{components/Forms → forms/elements}/Select/MultiSelect/types.ts +0 -0
  59. /package/src/{components/Forms → forms/elements}/Select/SingleSelect/types.ts +0 -0
  60. /package/src/{components/Forms → forms/elements}/Select/index.ts +0 -0
  61. /package/src/{components/Forms → forms/elements}/Select/internal/OptionList.tsx +0 -0
  62. /package/src/{components/Forms → forms/elements}/Select/internal/SelectTrigger.tsx +0 -0
  63. /package/src/{components/Forms → forms/elements}/Select/internal/styles.module.css +0 -0
  64. /package/src/{components/Forms → forms/elements}/Textarea/Textarea.stories.tsx +0 -0
  65. /package/src/{components/Forms → forms/elements}/Textarea/index.tsx +0 -0
  66. /package/src/{components/Forms → forms/elements}/Textarea/styles.module.css +0 -0
  67. /package/src/{components/Forms → forms/elements}/Textarea/types.ts +0 -0
  68. /package/src/forms/{path → state/path}/types.test-d.ts +0 -0
  69. /package/src/forms/{path → state/path}/types.ts +0 -0
  70. /package/src/forms/{useFormState → state/useFormState}/FormDebugger.module.css +0 -0
  71. /package/src/forms/{useFormState → state/useFormState}/FormDebugger.test.tsx +0 -0
  72. /package/src/forms/{useFormState → state/useFormState}/deriveErrors.test.ts +0 -0
  73. /package/src/forms/{useFormState → state/useFormState}/deriveErrors.ts +0 -0
  74. /package/src/forms/{useFormState → state/useFormState}/errorAt.test.ts +0 -0
  75. /package/src/forms/{useFormState → state/useFormState}/inspectable.test.ts +0 -0
  76. /package/src/forms/{useFormState → state/useFormState}/inspectable.ts +0 -0
  77. /package/src/forms/{useFormState → state/useFormState}/snapshotStore.test.ts +0 -0
  78. /package/src/forms/{useFormState → state/useFormState}/snapshotStore.ts +0 -0
  79. /package/src/forms/{useFormState → state/useFormState}/useFormDebugger.ts +0 -0
  80. /package/src/forms/{useFormState → state/useFormState}/useFormState.test.tsx +0 -0
  81. /package/src/forms/{useFormState → state/useFormState}/useFormSubmit.test.tsx +0 -0
  82. /package/src/forms/{useFormState → state/useFormState}/useFormSubmit.ts +0 -0
  83. /package/src/forms/{validations → state/validations}/perField.ts +0 -0
  84. /package/src/forms/{validations → state/validations}/types.test-d.ts +0 -0
  85. /package/src/forms/{validations → state/validations}/types.ts +0 -0
  86. /package/src/forms/{validations → state/validations}/walk.test.ts +0 -0
  87. /package/src/forms/{validations → state/validations}/walk.ts +0 -0
  88. /package/src/forms/{validators → state/validators}/validators.test.ts +0 -0
  89. /package/src/forms/{validators → state/validators}/validators.ts +0 -0
@@ -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
 
@@ -5,10 +5,13 @@ import { useFormState } from './useFormState';
5
5
  import { errorAt } from './errorAt';
6
6
  import type { FormErrors } from './types';
7
7
  import { matches, minLength, notEmpty } from '../validators/validators';
8
- import { Field } from '../../components/Forms/Field';
9
- import { Input } from '../../components/Forms/Input';
10
- import { Button } from '../../components/Forms/Button';
11
- import { SingleSelect } from '../../components/Forms/Select';
8
+ import { Field } from '../../elements/Field';
9
+ import { Input } from '../../elements/Input';
10
+ import { Button } from '../../elements/Button';
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
  };
package/src/index.ts CHANGED
@@ -3,26 +3,26 @@ export { Heading, type HeadingProps } from './components/Content/Heading';
3
3
  export { Link, type LinkProps } from './components/Content/Link';
4
4
  export { Stack, type StackProps } from './components/Layout/Stack';
5
5
  export { Grid, type GridProps } from './components/Layout/Grid';
6
- export { Button, type ButtonProps } from './components/Forms/Button';
6
+ export { Button, type ButtonProps } from './forms/elements/Button';
7
7
  export {
8
8
  IconButton,
9
9
  type IconButtonProps,
10
10
  type IconButtonVariant,
11
11
  type IconButtonSize,
12
- } from './components/Forms/IconButton';
12
+ } from './forms/elements/IconButton';
13
13
  export {
14
14
  Tooltip,
15
15
  type TooltipProps,
16
16
  type TooltipPlacement,
17
17
  } from './components/Overlays/Tooltip';
18
- export { Input, type InputProps } from './components/Forms/Input';
19
- export { SearchInput, type SearchInputProps } from './components/Forms/SearchInput';
20
- export { ColorInput, type ColorInputProps } from './components/Forms/ColorInput';
21
- export { Textarea, type TextareaProps } from './components/Forms/Textarea';
22
- export { SingleSelect, type SingleSelectProps } from './components/Forms/Select';
23
- export { MultiSelect, type MultiSelectProps } from './components/Forms/Select';
24
- export type { SelectOption, SelectSize } from './components/Forms/Select';
25
- export { Field, type FieldProps } from './components/Forms/Field';
18
+ export { Input, type InputProps } from './forms/elements/Input';
19
+ export { SearchInput, type SearchInputProps } from './forms/elements/SearchInput';
20
+ export { ColorInput, type ColorInputProps } from './forms/elements/ColorInput';
21
+ export { Textarea, type TextareaProps } from './forms/elements/Textarea';
22
+ export { SingleSelect, type SingleSelectProps } from './forms/elements/Select';
23
+ export { MultiSelect, type MultiSelectProps } from './forms/elements/Select';
24
+ export type { SelectOption, SelectSize } from './forms/elements/Select';
25
+ export { Field, type FieldProps } from './forms/elements/Field';
26
26
  export { MediumModal, LargeModal, ConfirmModal } from './components/Modals';
27
27
  export type { MediumModalProps, LargeModalProps, ConfirmModalProps } from './components/Modals';
28
28
  export { Divider } from './components/Layout/Divider';
@@ -1,12 +1,12 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { useRef, useState } from 'react';
3
- import { Button } from '../components/Forms/Button';
4
- import { Field } from '../components/Forms/Field';
5
- import { Input } from '../components/Forms/Input';
3
+ import { Button } from '../forms/elements/Button';
4
+ import { Field } from '../forms/elements/Field';
5
+ import { Input } from '../forms/elements/Input';
6
6
  import { MediumModal } from '../components/Modals/MediumModal';
7
7
  import { Menu } from '../components/Content/Menu';
8
8
  import { Popover } from '../components/Overlays/Popover';
9
- import { SingleSelect } from '../components/Forms/Select/SingleSelect';
9
+ import { SingleSelect } from '../forms/elements/Select/SingleSelect';
10
10
  import { Stack } from '../components/Layout/Stack';
11
11
  import { Text } from '../components/Content/Text';
12
12
  import { Tooltip } from '../components/Overlays/Tooltip';
@@ -1,6 +1,6 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Lorem, Placeholder, Toggle, Repeat } from './index';
3
- import { Button } from '../components/Forms/Button';
3
+ import { Button } from '../forms/elements/Button';
4
4
  import { Stack } from '../components/Layout/Stack';
5
5
  import { Text } from '../components/Content/Text';
6
6
  import { MediumModal } from '../components/Modals';