@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/bun.lock +192 -76
- package/package.json +15 -15
- package/src/forms/CLAUDE.md +41 -18
- package/src/forms/plan.md +270 -0
- package/src/forms/useFormState/types.ts +10 -0
- package/src/forms/useFormState/useFormState.stories.tsx +196 -0
- package/src/forms/useFormState/useFormState.test-d.ts +209 -0
- package/src/forms/useFormState/useFormState.test.tsx +134 -0
- package/src/forms/useFormState/useFormState.ts +41 -5
- package/src/forms/validations/perField.ts +11 -0
- package/src/forms/validations/types.test-d.ts +74 -1
- package/src/forms/validations/types.ts +23 -0
- package/src/forms/validators/validators.test.ts +99 -0
- package/src/forms/validators/validators.ts +95 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@structuralists/scaffolding",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
32
|
-
"@storybook/addon-docs": "^10.
|
|
33
|
-
"@storybook/react": "^10.
|
|
34
|
-
"@storybook/react-vite": "^10.
|
|
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.
|
|
38
|
+
"@types/react": "^19.2.17",
|
|
39
39
|
"@types/react-dom": "^19.2.3",
|
|
40
|
-
"@typescript-eslint/parser": "^8.
|
|
41
|
-
"@vitejs/plugin-react": "^6.0.
|
|
42
|
-
"eslint": "^10.
|
|
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.
|
|
45
|
-
"prettier": "^3.
|
|
46
|
-
"react-router": "^7.
|
|
47
|
-
"storybook": "^10.
|
|
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.
|
|
50
|
-
"vitest": "^4.1.
|
|
49
|
+
"vite": "^8.1.3",
|
|
50
|
+
"vitest": "^4.1.9"
|
|
51
51
|
}
|
|
52
52
|
}
|
package/src/forms/CLAUDE.md
CHANGED
|
@@ -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
|
-
})
|
|
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
|
-
###
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
-
|
|
142
|
-
`Refinement<>` infra.
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
};
|