@structuralists/scaffolding 0.4.1 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@structuralists/scaffolding",
3
- "version": "0.4.1",
3
+ "version": "0.5.0",
4
4
  "main": "./index.ts",
5
5
  "types": "./index.ts",
6
6
  "exports": {
@@ -28,25 +28,25 @@
28
28
  "react-router": "^7.0.0"
29
29
  },
30
30
  "devDependencies": {
31
- "@happy-dom/global-registrator": "^20.9.0",
32
- "@storybook/addon-docs": "^10.3.6",
33
- "@storybook/react": "^10.3.6",
34
- "@storybook/react-vite": "^10.3.6",
31
+ "@happy-dom/global-registrator": "^20.10.6",
32
+ "@storybook/addon-docs": "^10.4.6",
33
+ "@storybook/react": "^10.4.6",
34
+ "@storybook/react-vite": "^10.4.6",
35
35
  "@testing-library/dom": "^10.4.1",
36
36
  "@testing-library/react": "^16.3.2",
37
37
  "@types/bun": "latest",
38
- "@types/react": "^19.2.14",
38
+ "@types/react": "^19.2.17",
39
39
  "@types/react-dom": "^19.2.3",
40
- "@typescript-eslint/parser": "^8.59.1",
41
- "@vitejs/plugin-react": "^6.0.1",
42
- "eslint": "^10.3.0",
40
+ "@typescript-eslint/parser": "^8.62.1",
41
+ "@vitejs/plugin-react": "^6.0.3",
42
+ "eslint": "^10.6.0",
43
43
  "eslint-plugin-boundaries": "^6.0.2",
44
- "eslint-plugin-storybook": "10.3.6",
45
- "prettier": "^3.8.3",
46
- "react-router": "^7.14.2",
47
- "storybook": "^10.3.6",
44
+ "eslint-plugin-storybook": "10.4.6",
45
+ "prettier": "^3.9.4",
46
+ "react-router": "^7.18.1",
47
+ "storybook": "^10.4.6",
48
48
  "typescript": "^6.0.3",
49
- "vite": "^8.0.10",
50
- "vitest": "^4.1.5"
49
+ "vite": "^8.1.3",
50
+ "vitest": "^4.1.9"
51
51
  }
52
52
  }
@@ -35,24 +35,36 @@ function composition.
35
35
 
36
36
  ```ts
37
37
  const constraints = perField({
38
- a: (val: string | undefined) => !val ? "'a' cannot be empty" : null,
39
- }) as const satisfies Validations<FormType>;
38
+ a: (val: string | undefined) => (!val ? "'a' cannot be empty" : null),
39
+ }) satisfies Validations<FormType>;
40
40
  ```
41
41
 
42
42
  `perField` is the entry point that produces a `Validations<FormType>`-shaped
43
43
  value while preserving the precise types of each individual validator.
44
44
 
45
- ### Why `as const satisfies` (both required)
46
-
47
- - **`as const`** freezes the object's literal type including each
48
- validator's specific function type and any refinement markers attached
49
- to it. Without `as const`, function types widen, markers are lost,
50
- `Refine<>` has nothing to walk.
51
- - **`satisfies Validations<FormType>`** checks shape against `FormType`
52
- (every key valid, every validator's input compatible) without widening
53
- the inferred type the way an annotation `: Validations<FormType>` would.
54
-
55
- The combination = "type-check this shape, but don't lose any precision."
45
+ ### The precision-preserving ceremony, by call-site shape
46
+
47
+ The enemy is widening: if the constraints object's inferred type loses each
48
+ validator's specific function type, the refinement markers are lost and
49
+ `Refine<>` has nothing to walk. What prevents widening depends on where the
50
+ constraints object is written:
51
+
52
+ - **Inline in the `useFormState` call no ceremony at all.** The hook's
53
+ `const V extends Validations<T>` type parameter freezes the literal type,
54
+ and the generic bound shape-checks against the form type. This is the
55
+ everyday path.
56
+ - **Pre-built via `perField` — add `satisfies Validations<FormType>`.**
57
+ `perField`'s own `const` type parameter plays the freezing role (`as const`
58
+ cannot be applied to a call expression — TS1355), and `satisfies` checks
59
+ shape against `FormType` (every key valid, every validator's input
60
+ compatible) without widening the way an annotation
61
+ `: Validations<FormType>` would.
62
+ - **Pre-built as a bare object literal — `as const satisfies
63
+ Validations<FormType>`,** both parts required: `as const` freezes,
64
+ `satisfies` checks.
65
+
66
+ Never *annotate* a constraints binding (`: Validations<FormType>`) — that is
67
+ the widening failure mode all three patterns exist to avoid.
56
68
 
57
69
  ## Standard-library validators carry refinements
58
70
 
@@ -134,11 +146,22 @@ precision end-to-end. So we wire it up before adding any consumers.
134
146
  ## Layout
135
147
 
136
148
  - `useFormState/` — the hook + the value-model types (`FormValueSimple`,
137
- `FormValuesObject`, `FormValueList`).
149
+ `FormValuesObject`, `FormValueList`). Hook surface: `{ values,
150
+ onValueChanges, errors, isValid, submitAttempted, submit }`; `errors` is
151
+ live-derived from current values each render (validators are pure and
152
+ cheap), `submitAttempted` lets UIs gate error display, and `submit()`
153
+ performs the one honest cast to `Refine<T, V>` — earned because the
154
+ validators just passed at runtime.
138
155
  - `path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
139
156
  Will be used for granular setters and for surfacing per-field
140
157
  errors/touched state. Coupled to the form value model intentionally.
141
- - (planned) `validations/` — `perField`, `Validations<T>`, `Refine<T,V>`,
142
- `Refinement<>` infra.
143
- - (planned) `validators/`the standard-library validators
144
- (`notEmpty`, `oneOf`, `matches`, …) each carrying its own refinement.
158
+ - `validations/` — `perField`, `Validations<T>`, `Refine<T,V>`,
159
+ `Refinement<>` infra. `Validations<T>` accepts bare
160
+ `(val) => string | null` functions too they simply narrow nothing.
161
+ - `validators/` the standard-library validators (`notEmpty`, `minLength`,
162
+ `matches`, `min`) plus `allOf`, which composes validators on one field and
163
+ carries the union of the parts' refinements. `allOf` derives its input type
164
+ as the intersection of the parts' inputs from the const tuple (not a
165
+ standalone inferred type parameter — that breaks without an outer
166
+ contextual type, e.g. inside inline constraints). `oneOf` is deferred:
167
+ exclusion-only markers can't express keep-only narrowing yet.
@@ -0,0 +1,270 @@
1
+ # Plan: composable, recursive `Validations`
2
+
3
+ Status: **proposed** — nothing below is built yet. The baseline (flat
4
+ `Validations<T>`, one validator per key, shallow `Refine`) is on
5
+ `forms/useformstate-baseline`.
6
+
7
+ ## Goal
8
+
9
+ Evolve `Validations<T>` so a constraints object can:
10
+
11
+ 1. hold **multiple validators per key** (an array),
12
+ 2. **recurse into nested object** form state,
13
+ 3. apply a spec **to each element of a list** (`FormValueList`),
14
+ 4. **compose** all of the above freely — while the refinement machinery
15
+ (`Refinement` markers → `Refine<T, V>` → narrowed `onSubmit`) keeps
16
+ working at every level.
17
+
18
+ Plus two items that ride on top, independent of the type work:
19
+
20
+ 5. split the forms area into **'form elements'** and **'form state'**
21
+ (see the section at the end),
22
+ 6. **field binding**: a `getFormFieldPropsAt(path)` helper plus
23
+ form-element shorthands (`TextInputForForm` style) so wiring a field is
24
+ one expression (see the section at the end).
25
+
26
+ ## Target grammar
27
+
28
+ What a key can map to is directed by the *field's* type, never guessed from
29
+ the constraint's shape (see "Disambiguation" below):
30
+
31
+ ```ts
32
+ type FieldValidator<F> = (val: F) => string | null; // possibly & Refinement<X>
33
+
34
+ type FieldConstraint<F> =
35
+ // leaf forms — allowed for any field type
36
+ | FieldValidator<F>
37
+ | readonly FieldValidator<F>[]
38
+ // structural forms — allowed only where the field type permits
39
+ | (F extends FormValuesObject ? Validations<F> : never)
40
+ | (F extends FormValueList ? ListConstraint<F[number]> : never);
41
+
42
+ type ListConstraint<Element extends FormValuesObject> = {
43
+ readonly each: Validations<Element>;
44
+ // room to grow, e.g.: readonly self?: readonly FieldValidator<Element[]>[]
45
+ // for list-level rules (min count, uniqueness) — NOT in scope for v1
46
+ };
47
+
48
+ type Validations<T extends FormValuesObject> = {
49
+ readonly [K in keyof T]?: FieldConstraint<T[K]>;
50
+ };
51
+ ```
52
+
53
+ Example of the full composition:
54
+
55
+ ```ts
56
+ useFormState({
57
+ initialValues: {} as InsuranceQuoteForm,
58
+ constraints: {
59
+ email: [notEmpty('email'), matches('email', /@/, 'a valid email')], // (1)
60
+ homeAddress: { // (2)
61
+ city: notEmpty('city'),
62
+ postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
63
+ },
64
+ drivers: { // (3)
65
+ each: {
66
+ name: notEmpty('name'),
67
+ incidents: { each: { date: notEmpty('date') } }, // (4) nested list-in-list
68
+ },
69
+ },
70
+ },
71
+ onSubmit: (values) => {
72
+ values.email; // string
73
+ values.homeAddress.city; // string
74
+ values.drivers[0].incidents[0].date; // string
75
+ },
76
+ });
77
+ ```
78
+
79
+ ## Refinement through the grammar
80
+
81
+ `Refine` becomes recursive, mirroring the grammar. Sketch:
82
+
83
+ ```ts
84
+ type ExcludedOf<V> = V extends Refinement<infer X> ? X : never;
85
+
86
+ type RefineField<F, C> =
87
+ C extends Refinement<infer X> ? Exclude<F, X> // single marked validator
88
+ : C extends readonly unknown[] ? Exclude<F, ExcludedOf<C[number]>> // validator array: union of excludes
89
+ : C extends { readonly each: infer E } ? F extends FormValueList
90
+ ? Array<RefineObject<F[number], E>> : F // per-element
91
+ : C extends object ? F extends FormValuesObject
92
+ ? RefineObject<F, C> : F // nested spec
93
+ : F; // bare fn / no marker
94
+
95
+ type RefineObject<T, V> = { [K in keyof T]: K extends keyof V ? RefineField<T[K], V[K]> : T[K] };
96
+ ```
97
+
98
+ Branch-order constraints discovered in the baseline, which this sketch bakes
99
+ in:
100
+
101
+ - **Check `Refinement` before `object`** — validator functions are objects.
102
+ - **A bare function** (no marker) falls to the final `F` arm — narrows nothing.
103
+ - The runtime cast in `submit()` stays the same single honest cast: valid ⇒
104
+ every constrained node passed, at every depth, which is what the recursive
105
+ `Refine` now encodes.
106
+
107
+ ### Disambiguation
108
+
109
+ A `FormValuesObject` field could theoretically own a key named `each`, making
110
+ a nested `Validations` look like a `ListConstraint`. This never bites because
111
+ interpretation is **directed by `T`**: for an object-typed field only the
112
+ `Validations<F>` branch is live; for a list-typed field only `ListConstraint`.
113
+ Both the type level (`F extends ...` conditionals) and the runtime walker must
114
+ branch on the *value model*, never on constraint shape alone.
115
+
116
+ ## ⚠️ The recursion budget — the actual risk
117
+
118
+ The baseline's `Refine` is a single shallow mapped type; this plan makes it
119
+ recursive. Prior explorations of this design died on TS recursion limits with
120
+ realistic state, and a recursive `Refine` is the prime suspect for where that
121
+ happened. Discipline for every phase below:
122
+
123
+ - **Extend the recursion probe first** (`useFormState.test-d.ts`,
124
+ `InsuranceQuoteForm`): add the phase's construct at realistic scale *before*
125
+ implementing, so the failure mode is visible the moment it appears.
126
+ - Watch `tsc` wall-time, not just success — a 10× check-time regression is a
127
+ failure even if it compiles.
128
+ - If a phase hits the wall, stop and record the ceiling in the learning map
129
+ before reaching for tricks (interface-based lazy recursion, depth caps).
130
+
131
+ ## Runtime consequences (can't be dodged)
132
+
133
+ - **The validator walk becomes a tree walk.** Recursion mirrors `read()` in
134
+ `path/path.ts`; share the traversal or keep them deliberately parallel.
135
+ - **`FormErrors` can no longer be `Partial<Record<keyof T, string>>`.**
136
+ Decided: errors become a plain list of structured entries,
137
+
138
+ ```ts
139
+ type FormError<T> = { path: Path<T>; error: string };
140
+ // errors: FormError<T>[] isValid: errors.length === 0
141
+ ```
142
+
143
+ reusing `path/`'s typed representation (this is the "surfacing per-field
144
+ errors" role path/ was built for). **We will never expose
145
+ `'drivers.0.name'`-style serialized-string key derivation.** At the scale
146
+ these forms operate, a linear scan over `{path, error}[]` is fine. If fast
147
+ lookup is ever genuinely needed, a string-keyed map is permitted strictly
148
+ as an *internal* implementation detail — the concatenation scheme must be
149
+ fully encapsulated and opaque to everything outside it.
150
+ - **`errors` display wiring** in stories/components goes through a typed
151
+ accessor (e.g. `errorAt(errors, path)` / a cursor-based lookup), never
152
+ through hand-assembled keys.
153
+
154
+ ## Phases
155
+
156
+ Each phase lands with: type tests (inline-call context on the chunky form —
157
+ contextual typing differs there, learned the hard way with `allOf`), runtime
158
+ tests, story updates where visible, probe ratchet.
159
+
160
+ 1. **Validator arrays per key.** Pure sugar over `allOf` at the constraint
161
+ site (`allOf` remains for building reusable composite validators).
162
+ First-error-wins per field, same as `allOf`. Excludes union via
163
+ `ExcludedOf<C[number]>`.
164
+ 2. **Nested object specs + recursive `Refine`.** The risk phase — the probe
165
+ ratchet matters most here. Errors become `{path, error}[]` in this phase
166
+ (nested fields need addresses).
167
+ 3. **List `each` specs.** Runtime walks every element; error paths carry the
168
+ numeric step (`['drivers', 3, 'name']`). Refined element type flows
169
+ through `Array<...>`.
170
+ 4. **Composition hardening.** Deep mixed fixtures (list-in-object-in-list),
171
+ `perField` still the entry point for pre-built specs, docs
172
+ (forms/CLAUDE.md) updated to the new grammar.
173
+
174
+ ## 5. Split forms into 'form elements' and 'form state'
175
+
176
+ Today the form world lives in two places with unrelated names:
177
+
178
+ - `src/components/Forms/` — the presentational **elements** (`Input`,
179
+ `Field`, `Select`, `Button`, `Textarea`, …)
180
+ - `src/forms/` — the **state** layer (`useFormState`, `validations/`,
181
+ `validators/`, `path/`)
182
+
183
+ The intent: make that split explicit and named — 'form elements' vs
184
+ 'form state' — rather than the current accidental `Forms`-vs-`forms`
185
+ distinction. Recorded as direction; the target layout/naming is an open
186
+ decision (rename in place? move both under one `forms/` umbrella with
187
+ `elements/` and `state/` subtrees?).
188
+
189
+ Blast radius to account for when this happens (not now):
190
+
191
+ - the eslint `boundaries` element patterns in `eslint.config.mjs` are keyed
192
+ to `src/components/<Section>/<Component>/` shapes,
193
+ - barrel exports in `src/index.ts` (all the element components are public),
194
+ - Storybook titles (`Forms/...` prefix) and story globs,
195
+ - forms/CLAUDE.md and this plan's own paths.
196
+
197
+ Independent of phases 1–4; can land before, between, or after them.
198
+
199
+ ## 6. Field binding: `getFormFieldPropsAt(path)` + element shorthands
200
+
201
+ Today every field is wired by hand in JSX: read `values.x`, spread-update via
202
+ `onValueChanges`, look up the error, gate it on `submitAttempted`. That's
203
+ four decisions per field, all boilerplate. Target usage:
204
+
205
+ ```tsx
206
+ <TextInputForForm formFieldProps={getFormFieldPropsAt(['homeAddress', 'city'])} />
207
+ ```
208
+
209
+ ### `getFormFieldPropsAt(path)`
210
+
211
+ A member of the hook's return value (it needs `values`, `errors`, and the
212
+ setter), typed by the `path/` machinery:
213
+
214
+ ```ts
215
+ type FormFieldProps<V> = {
216
+ value: V;
217
+ onChange: (val: V) => void;
218
+ errorMessage: string | undefined; // display-policy-aware (see below)
219
+ onBlur: () => void; // feeds touched tracking
220
+ // room to grow: name/id derivation, disabled, ...
221
+ };
222
+
223
+ // on FormHelpers<T>:
224
+ getFormFieldPropsAt<P extends Path<T>>(path: P): FormFieldProps<ValueAt<T, P>>;
225
+ ```
226
+
227
+ Consequences this pulls in deliberately:
228
+
229
+ - **Granular setters via path arrive here** — this retires the "likely
230
+ remove" todo on `onValueChanges` as the only write path. Writing at a path
231
+ is the immutable-update mirror of `read()` in `path/path.ts`.
232
+ - **Touched tracking becomes real state** (`onBlur` feeds it). The
233
+ error-display policy (submitAttempted / touched gating) moves *inside*
234
+ `errorMessage`, so consuming elements render what they're given and stay
235
+ policy-free.
236
+ - **`errorMessage` looks up the structured `{path, error}[]` list** by path
237
+ equality — the typed accessor from the error-model decision, not string
238
+ keys.
239
+ - Type safety composes end-to-end: `ValueAt<T, P>` types `value`/`onChange`,
240
+ so binding a `number`-typed path to a text-shaped element is a compile
241
+ error at the `formFieldProps` prop.
242
+
243
+ ### Element shorthands
244
+
245
+ Form elements get a form-aware flavor that accepts the bundle as one prop —
246
+ current lean: wrapper components (`TextInputForForm`, `SingleSelectForForm`,
247
+ …) taking `formFieldProps: FormFieldProps<V>` alongside the element's
248
+ presentational props. The alternative is union-typed props on the existing
249
+ elements themselves. **Deciding criterion (per Will): whichever is simpler
250
+ for agents to work with** — wrappers keep each component's prop surface
251
+ small and self-evident, unions avoid a parallel component set; prototype the
252
+ wrapper style first, `TextInputForForm` probably, but not settled.
253
+
254
+ Placement note: these wrappers are the bridge between 'form elements' and
255
+ 'form state', so where they live should fall out of the split in item 5 —
256
+ they are the one layer allowed to know about both sides.
257
+
258
+ ## Open decisions
259
+
260
+ - **Whole-value + structural constraints on the same key** (e.g. validate
261
+ `homeAddress` as a unit *and* its fields). Not expressible in this grammar.
262
+ Recommendation: punt from v1; if needed, later extend the structural forms
263
+ with a reserved `self` slot rather than overloading arrays.
264
+ - **Array semantics: first-error-wins vs collect-all.** v1: first-error-wins
265
+ (consistent with `allOf`). Collect-all becomes interesting only when the
266
+ UI can show multiple messages per field — note the `{path, error}[]` error
267
+ model already accommodates multiple entries per path when that day comes.
268
+
269
+ *(Resolved: error path format. Structured `{path, error}[]` with typed
270
+ paths — never exposed serialized-string keys. See "Runtime consequences".)*
@@ -6,8 +6,18 @@ export type FormValueList = FormValuesObject[];
6
6
 
7
7
  export type FormValue = FormValuesObject | FormValueList | FormValueSimple;
8
8
 
9
+ export type FormErrors<T extends FormValuesObject> = Partial<
10
+ Record<keyof T, string>
11
+ >;
12
+
9
13
  export type FormHelpers<T extends FormValuesObject> = {
10
14
  values: T;
11
15
  // todo: likely remove once other setter are available
12
16
  onValueChanges: (val: T | ((prev: T) => T)) => void;
17
+ // Live-derived from the current values on every render; UIs that only want
18
+ // errors after a submit attempt gate on `submitAttempted`.
19
+ errors: FormErrors<T>;
20
+ isValid: boolean;
21
+ submitAttempted: boolean;
22
+ submit: () => void;
13
23
  };
@@ -0,0 +1,196 @@
1
+ import { useState } from 'react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { useFormState } from './useFormState';
4
+ import { allOf, matches, minLength, notEmpty } from '../validators/validators';
5
+ import { Field } from '../../components/Forms/Field';
6
+ import { Input } from '../../components/Forms/Input';
7
+ import { Button } from '../../components/Forms/Button';
8
+ import { SingleSelect } from '../../components/Forms/Select';
9
+
10
+ const meta: Meta = {
11
+ title: 'Forms/useFormState',
12
+ };
13
+
14
+ export default meta;
15
+ type Story = StoryObj;
16
+
17
+ type SignupFormValues = {
18
+ email: string | undefined;
19
+ displayName: string | undefined;
20
+ role: string | null;
21
+ inviteCode: string | undefined;
22
+ };
23
+
24
+ // What onSubmit receives: SignupFormValues with each constrained field
25
+ // narrowed by its validator's refinement. `email`/`displayName` lose
26
+ // `undefined`, `role` loses `null`; `inviteCode` is unconstrained and keeps
27
+ // its full type. The `setSubmitted(vals)` call below only compiles because
28
+ // the hook actually delivers this narrowed type — the story doubles as a
29
+ // compile-time proof of the headline feature.
30
+ type SubmittedValues = {
31
+ email: string;
32
+ displayName: string;
33
+ role: string;
34
+ inviteCode: string | undefined;
35
+ };
36
+
37
+ const ROLE_OPTIONS = [
38
+ { value: 'engineer', label: 'Engineer' },
39
+ { value: 'designer', label: 'Designer' },
40
+ { value: 'manager', label: 'Manager' },
41
+ ];
42
+
43
+ const SignupDemo = () => {
44
+ const [submitted, setSubmitted] = useState<SubmittedValues | null>(null);
45
+
46
+ const { values, onValueChanges, errors, submitAttempted, submit } =
47
+ useFormState({
48
+ initialValues: {
49
+ email: undefined,
50
+ displayName: undefined,
51
+ role: null,
52
+ inviteCode: undefined,
53
+ } as SignupFormValues,
54
+ constraints: {
55
+ email: allOf(notEmpty('email'), matches('email', /@/, 'a valid email')),
56
+ displayName: allOf(notEmpty('displayName'), minLength('displayName', 3)),
57
+ role: notEmpty('role'),
58
+ },
59
+ onSubmit: (vals) => setSubmitted(vals),
60
+ });
61
+
62
+ const shownErrors = submitAttempted ? errors : {};
63
+
64
+ return (
65
+ <form
66
+ style={{ maxWidth: 420, display: 'grid', gap: 16 }}
67
+ onSubmit={(e) => {
68
+ e.preventDefault();
69
+ submit();
70
+ }}
71
+ >
72
+ <Field label="Email" error={shownErrors.email} htmlFor="signup-email">
73
+ <Input
74
+ id="signup-email"
75
+ type="email"
76
+ value={values.email ?? ''}
77
+ onChange={(e) => {
78
+ const email = e.target.value;
79
+ onValueChanges((prev) => ({ ...prev, email }));
80
+ }}
81
+ placeholder="you@example.com"
82
+ />
83
+ </Field>
84
+
85
+ <Field
86
+ label="Display name"
87
+ hint="At least 3 characters"
88
+ error={shownErrors.displayName}
89
+ htmlFor="signup-display-name"
90
+ >
91
+ <Input
92
+ id="signup-display-name"
93
+ value={values.displayName ?? ''}
94
+ onChange={(e) => {
95
+ const displayName = e.target.value;
96
+ onValueChanges((prev) => ({ ...prev, displayName }));
97
+ }}
98
+ />
99
+ </Field>
100
+
101
+ <Field label="Role" error={shownErrors.role}>
102
+ <SingleSelect
103
+ options={ROLE_OPTIONS}
104
+ value={values.role}
105
+ onChange={(role) => onValueChanges((prev) => ({ ...prev, role }))}
106
+ placeholder="Pick a role"
107
+ ariaLabel="Role"
108
+ />
109
+ </Field>
110
+
111
+ <Field label="Invite code" hint="Optional — no constraint on this field">
112
+ <Input
113
+ id="signup-invite-code"
114
+ value={values.inviteCode ?? ''}
115
+ onChange={(e) => {
116
+ const inviteCode = e.target.value;
117
+ onValueChanges((prev) => ({ ...prev, inviteCode }));
118
+ }}
119
+ />
120
+ </Field>
121
+
122
+ <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
123
+ <Button type="submit" variant="primary">
124
+ Sign up
125
+ </Button>
126
+ {submitAttempted && Object.keys(errors).length > 0 && (
127
+ <span style={{ color: 'var(--ui-danger, #c33)', fontSize: 13 }}>
128
+ Fix the highlighted fields
129
+ </span>
130
+ )}
131
+ </div>
132
+
133
+ {submitted && (
134
+ <pre
135
+ style={{
136
+ background: 'var(--ui-surface-muted, #f4f4f4)',
137
+ padding: 12,
138
+ borderRadius: 6,
139
+ fontSize: 12,
140
+ margin: 0,
141
+ }}
142
+ >
143
+ {`onSubmit received (narrowed type):\n${JSON.stringify(submitted, null, 2)}`}
144
+ </pre>
145
+ )}
146
+ </form>
147
+ );
148
+ };
149
+
150
+ export const SignupForm: Story = {
151
+ render: () => (
152
+ <div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
153
+ <SignupDemo />
154
+ </div>
155
+ ),
156
+ };
157
+
158
+ const LiveValidityDemo = () => {
159
+ const { values, onValueChanges, errors, isValid } = useFormState({
160
+ initialValues: { nickname: undefined } as { nickname: string | undefined },
161
+ constraints: {
162
+ nickname: allOf(notEmpty('nickname'), minLength('nickname', 3)),
163
+ },
164
+ });
165
+
166
+ return (
167
+ <div style={{ maxWidth: 420, display: 'grid', gap: 12 }}>
168
+ <Field
169
+ label="Nickname"
170
+ hint="Errors here are live — not gated on a submit attempt"
171
+ error={errors.nickname}
172
+ htmlFor="live-nickname"
173
+ >
174
+ <Input
175
+ id="live-nickname"
176
+ value={values.nickname ?? ''}
177
+ onChange={(e) => {
178
+ const nickname = e.target.value;
179
+ onValueChanges((prev) => ({ ...prev, nickname }));
180
+ }}
181
+ />
182
+ </Field>
183
+ <div style={{ fontSize: 13 }}>
184
+ isValid: <strong>{String(isValid)}</strong>
185
+ </div>
186
+ </div>
187
+ );
188
+ };
189
+
190
+ export const LiveValidity: Story = {
191
+ render: () => (
192
+ <div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
193
+ <LiveValidityDemo />
194
+ </div>
195
+ ),
196
+ };