@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.
- package/eslint.config.mjs +56 -2
- package/package.json +1 -1
- package/src/components/Chat/ChatRecipientsHeader/ChatRecipientsHeader.stories.tsx +4 -4
- package/src/components/Layout/Bar/Bar.stories.tsx +1 -1
- package/src/components/Layout/Panels/Panels.stories.tsx +1 -1
- package/src/components/Layout/Stack/Stack.stories.tsx +1 -1
- package/src/components/Modals/LargeModal/LargeModal.stories.tsx +1 -1
- package/src/components/Modals/MediumModal/MediumModal.stories.tsx +3 -3
- package/src/components/Modals/internal/ModalHeader.tsx +1 -1
- package/src/components/Overlays/Popover/Popover.stories.tsx +3 -3
- package/src/components/Overlays/Tooltip/Tooltip.stories.tsx +1 -1
- package/src/components/Tables/BigTable/BigTable.stories.tsx +1 -1
- package/src/forms/CLAUDE.md +115 -24
- package/src/{components/Forms → forms/elements}/Button/Button.stories.tsx +1 -1
- package/src/{components/Forms → forms/elements}/IconButton/index.tsx +1 -1
- package/src/{components/Forms → forms/elements}/Input/index.tsx +2 -0
- package/src/{components/Forms → forms/elements}/Input/types.ts +2 -1
- package/src/{components/Forms → forms/elements}/Select/MultiSelect/MultiSelect.stories.tsx +2 -2
- package/src/{components/Forms → forms/elements}/Select/MultiSelect/index.tsx +1 -1
- package/src/{components/Forms → forms/elements}/Select/SingleSelect/SingleSelect.stories.tsx +3 -3
- package/src/{components/Forms → forms/elements}/Select/SingleSelect/index.tsx +1 -1
- package/src/forms/plan.md +84 -38
- package/src/forms/state/bindings/SingleSelectForForm.tsx +45 -0
- package/src/forms/state/bindings/TextInputForForm.tsx +45 -0
- package/src/forms/{path → state/path}/path.test.ts +71 -1
- package/src/forms/state/path/path.ts +103 -0
- package/src/forms/{useFormState → state/useFormState}/FormDebugger.tsx +2 -1
- package/src/forms/{useFormState → state/useFormState}/errorAt.ts +8 -12
- package/src/forms/{useFormState → state/useFormState}/types.ts +33 -2
- package/src/forms/state/useFormState/useFieldBinding.test.tsx +165 -0
- package/src/forms/state/useFormState/useFieldBinding.ts +71 -0
- package/src/forms/{useFormState → state/useFormState}/useFormDebugger.test.tsx +1 -0
- package/src/forms/{useFormState → state/useFormState}/useFormState.stories.tsx +167 -4
- package/src/forms/{useFormState → state/useFormState}/useFormState.test-d.ts +80 -1
- package/src/forms/{useFormState → state/useFormState}/useFormState.ts +12 -3
- package/src/index.ts +10 -10
- package/src/storybook/Composition.stories.tsx +4 -4
- package/src/storybook/_StoryUtils.stories.tsx +1 -1
- package/src/forms/path/path.ts +0 -53
- /package/src/{components/Forms → forms/elements}/Button/index.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Button/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/Button/types.ts +0 -0
- /package/src/{components/Forms → forms/elements}/ColorInput/index.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/ColorInput/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/ColorInput/types.ts +0 -0
- /package/src/{components/Forms → forms/elements}/Field/Field.stories.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Field/index.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Field/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/Field/types.ts +0 -0
- /package/src/{components/Forms → forms/elements}/IconButton/IconButton.stories.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/IconButton/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/IconButton/types.ts +0 -0
- /package/src/{components/Forms → forms/elements}/Input/Input.stories.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Input/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/SearchInput/index.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/SearchInput/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/SearchInput/types.ts +0 -0
- /package/src/{components/Forms → forms/elements}/Select/MultiSelect/types.ts +0 -0
- /package/src/{components/Forms → forms/elements}/Select/SingleSelect/types.ts +0 -0
- /package/src/{components/Forms → forms/elements}/Select/index.ts +0 -0
- /package/src/{components/Forms → forms/elements}/Select/internal/OptionList.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Select/internal/SelectTrigger.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Select/internal/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/Textarea/Textarea.stories.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Textarea/index.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Textarea/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/Textarea/types.ts +0 -0
- /package/src/forms/{path → state/path}/types.test-d.ts +0 -0
- /package/src/forms/{path → state/path}/types.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/FormDebugger.module.css +0 -0
- /package/src/forms/{useFormState → state/useFormState}/FormDebugger.test.tsx +0 -0
- /package/src/forms/{useFormState → state/useFormState}/deriveErrors.test.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/deriveErrors.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/errorAt.test.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/inspectable.test.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/inspectable.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/snapshotStore.test.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/snapshotStore.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/useFormDebugger.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/useFormState.test.tsx +0 -0
- /package/src/forms/{useFormState → state/useFormState}/useFormSubmit.test.tsx +0 -0
- /package/src/forms/{useFormState → state/useFormState}/useFormSubmit.ts +0 -0
- /package/src/forms/{validations → state/validations}/perField.ts +0 -0
- /package/src/forms/{validations → state/validations}/types.test-d.ts +0 -0
- /package/src/forms/{validations → state/validations}/types.ts +0 -0
- /package/src/forms/{validations → state/validations}/walk.test.ts +0 -0
- /package/src/forms/{validations → state/validations}/walk.ts +0 -0
- /package/src/forms/{validators → state/validators}/validators.test.ts +0 -0
- /package/src/forms/{validators → state/validators}/validators.ts +0 -0
package/src/forms/plan.md
CHANGED
|
@@ -16,25 +16,33 @@ TS wall can't strand finished work behind it.
|
|
|
16
16
|
(PR #14, released 0.6.0)
|
|
17
17
|
2. **Item 1 — validator arrays per key.** `ExcludedOf<C[number]>` already
|
|
18
18
|
proven inside `allOf`. ✅ *done* — grammar (`FieldConstraint`), distributive
|
|
19
|
-
`RefineField`, and the walk rewrite (`validations/walk.ts`, cast-free entry
|
|
19
|
+
`RefineField`, and the walk rewrite (`state/validations/walk.ts`, cast-free entry
|
|
20
20
|
dispatch, `PathStep[]`-addressed errors) landed together; probe ratchet
|
|
21
21
|
held (see the baseline note under the recursion budget).
|
|
22
22
|
3. **Error-model swap** to `{path, error}[]` on the *flat* baseline (paths
|
|
23
23
|
are single-key paths). Doesn't depend on nested constraints; hard
|
|
24
24
|
prerequisite for item 6; makes phases 2–3 runtime-plumbing-free.
|
|
25
25
|
✅ *done* — `FormError<T>`/`FormErrors<T>` (`{ path: Path<T>; error }[]`
|
|
26
|
-
in useFormState/types.ts), `isValid` derived from emptiness, and the
|
|
26
|
+
in state/useFormState/types.ts), `isValid` derived from emptiness, and the
|
|
27
27
|
interim consumer accessor is a standalone `errorAt(errors, path)`
|
|
28
|
-
(useFormState/errorAt.ts) — the smallest surface that keeps stories
|
|
28
|
+
(state/useFormState/errorAt.ts) — the smallest surface that keeps stories
|
|
29
29
|
readable; item 6 folds the same lookup into `getFormFieldPropsAt`'s
|
|
30
30
|
`errorMessage`. The Debugger needed no change (`toInspectable`
|
|
31
31
|
index-keys arrays).
|
|
32
32
|
4. **Item 5 — elements/state split.** Pure churn, no type risk; do it before
|
|
33
|
-
item 6 so the wrappers land in their final home once.
|
|
33
|
+
item 6 so the wrappers land in their final home once. ✅ *done* — one
|
|
34
|
+
`src/forms/` umbrella: elements in `src/forms/elements/<Component>/`,
|
|
35
|
+
the state layer in `src/forms/state/{useFormState,validations,validators,path}/`.
|
|
34
36
|
5. **Item 6 — field binding** (`getFormFieldPropsAt` + wrappers). Needs the
|
|
35
|
-
error model and the split; `Path`/`ValueAt` already validated.
|
|
36
|
-
|
|
37
|
-
|
|
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*
|
|
38
46
|
any runtime work; three outcomes (works / slow / intractable) each with a
|
|
39
47
|
known response. Worst case, everything above still shipped.
|
|
40
48
|
|
|
@@ -123,7 +131,7 @@ useFormState({
|
|
|
123
131
|
```ts
|
|
124
132
|
type ExcludedOf<V> = V extends Refinement<infer X> ? X : never;
|
|
125
133
|
// MemberExcludes<C>: the per-member-sound union of a validator tuple's
|
|
126
|
-
// excludes — it and SoundExcludedOf landed with phase 1 in validations/types.ts.
|
|
134
|
+
// excludes — it and SoundExcludedOf landed with phase 1 in state/validations/types.ts.
|
|
127
135
|
|
|
128
136
|
type RefineField<F, C> =
|
|
129
137
|
C extends Refinement<infer X> ? Exclude<F, X> // single marked validator
|
|
@@ -185,11 +193,18 @@ multi-second check time:
|
|
|
185
193
|
- post-step-3 (error-model swap: `Path<T>`-typed `FormError`, `errorAt`,
|
|
186
194
|
plus probes): check 0.79 s, 113,724 instantiations, 52,294 types
|
|
187
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)
|
|
188
203
|
|
|
189
204
|
## Runtime consequences (can't be dodged)
|
|
190
205
|
|
|
191
206
|
- **The validator walk becomes a tree walk.** Recursion mirrors `read()` in
|
|
192
|
-
`path/path.ts`; share the traversal or keep them deliberately parallel.
|
|
207
|
+
`state/path/path.ts`; share the traversal or keep them deliberately parallel.
|
|
193
208
|
- **`FormErrors` can no longer be `Partial<Record<keyof T, string>>`.**
|
|
194
209
|
✅ *Landed with working-order step 3* (on the flat baseline, single-key
|
|
195
210
|
paths). Decided: errors become a plain list of structured entries,
|
|
@@ -199,7 +214,7 @@ multi-second check time:
|
|
|
199
214
|
// errors: FormError<T>[] isValid: errors.length === 0
|
|
200
215
|
```
|
|
201
216
|
|
|
202
|
-
reusing `path/`'s typed representation (this is the "surfacing per-field
|
|
217
|
+
reusing `state/path/`'s typed representation (this is the "surfacing per-field
|
|
203
218
|
errors" role path/ was built for). **We will never expose
|
|
204
219
|
`'drivers.0.name'`-style serialized-string key derivation.** At the scale
|
|
205
220
|
these forms operate, a linear scan over `{path, error}[]` is fine. If fast
|
|
@@ -208,7 +223,7 @@ multi-second check time:
|
|
|
208
223
|
fully encapsulated and opaque to everything outside it.
|
|
209
224
|
- **`errors` display wiring** in stories/components goes through a typed
|
|
210
225
|
accessor, never through hand-assembled keys. ✅ *Decided and landed with
|
|
211
|
-
step 3*: a standalone `errorAt(errors, path)` (useFormState/errorAt.ts) —
|
|
226
|
+
step 3*: a standalone `errorAt(errors, path)` (state/useFormState/errorAt.ts) —
|
|
212
227
|
structural step-equality lookup, first entry wins. Chosen over a
|
|
213
228
|
cursor-based lookup as the smallest surface that keeps stories readable
|
|
214
229
|
on the flat grammar; item 6's `errorMessage` reuses it internally.
|
|
@@ -228,7 +243,7 @@ tests, story updates where visible, probe ratchet.
|
|
|
228
243
|
marker computation. ✅ *done* — plus a byproduct: `RefineField`
|
|
229
244
|
distributes over a naked constraint parameter, which made union-typed
|
|
230
245
|
*single* constraints (`cond ? v1 : v2`) sound too (union of per-branch
|
|
231
|
-
refinements, pinned in `validations/types.test-d.ts`).
|
|
246
|
+
refinements, pinned in `state/validations/types.test-d.ts`).
|
|
232
247
|
2. **Nested object specs + recursive `Refine`.** The risk phase — the probe
|
|
233
248
|
ratchet matters most here. (The `{path, error}[]` error model already
|
|
234
249
|
landed in working-order step 3 — nested errors just carry longer paths;
|
|
@@ -240,32 +255,62 @@ tests, story updates where visible, probe ratchet.
|
|
|
240
255
|
`perField` still the entry point for pre-built specs, docs
|
|
241
256
|
(forms/CLAUDE.md) updated to the new grammar.
|
|
242
257
|
|
|
243
|
-
## 5. Split forms into 'form elements' and 'form state'
|
|
258
|
+
## 5. Split forms into 'form elements' and 'form state' ✅ done
|
|
244
259
|
|
|
245
|
-
|
|
260
|
+
The form world used to live in two places with unrelated names
|
|
261
|
+
(`src/components/Forms/` vs `src/forms/` — an accidental `Forms`-vs-`forms`
|
|
262
|
+
distinction). The layout decision landed on **one `src/forms/` umbrella with
|
|
263
|
+
two named subtrees**:
|
|
246
264
|
|
|
247
|
-
- `src/
|
|
248
|
-
`Field`, `Select`, `Button`, `Textarea`, …)
|
|
249
|
-
|
|
265
|
+
- `src/forms/elements/` — the presentational **elements** (`Input`,
|
|
266
|
+
`Field`, `Select`, `Button`, `Textarea`, …), one primitive per folder,
|
|
267
|
+
same shape as `src/components/<Section>/<Component>/`
|
|
268
|
+
- `src/forms/state/` — the **state** layer (`useFormState/`, `validations/`,
|
|
250
269
|
`validators/`, `path/`)
|
|
251
270
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
-
|
|
261
|
-
|
|
262
|
-
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
271
|
+
Blast radius that was handled when this landed:
|
|
272
|
+
|
|
273
|
+
- the eslint `boundaries` element patterns in `eslint.config.mjs` (were keyed
|
|
274
|
+
to `src/components/<Section>/<Component>/` shapes): element primitives got
|
|
275
|
+
matching patterns at their new home, `src/forms/state` became its own
|
|
276
|
+
element type, and two directional rules were added — elements/components
|
|
277
|
+
must not import the state layer, and state files importing a primitive go
|
|
278
|
+
through its barrel,
|
|
279
|
+
- barrel exports in `src/index.ts` (all the element components are public —
|
|
280
|
+
paths moved, exported surface unchanged),
|
|
281
|
+
- Storybook titles (`Forms/...` prefix) and story globs: titles kept as-is —
|
|
282
|
+
elements stay `Forms/<Component>`, the state story stays
|
|
283
|
+
`Forms/useFormState`; the story glob (`src/**/*.stories.*`) needed no
|
|
284
|
+
change,
|
|
285
|
+
- forms/CLAUDE.md and this plan's own paths,
|
|
286
|
+
- `FormDebugger.tsx` imports `JsonTable` from `src/components/Json/` — the
|
|
287
|
+
sanctioned state→elements bridge dependency (audit finding 3.3; the
|
|
288
|
+
Debugger is a designated bridge layer). Explicitly allowed for in the new
|
|
289
|
+
boundaries rules.
|
|
290
|
+
|
|
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.
|
|
269
314
|
|
|
270
315
|
Today every field is wired by hand in JSX: read `values.x`, spread-update via
|
|
271
316
|
`onValueChanges`, look up the error, gate it on `submitAttempted`. That's
|
|
@@ -278,7 +323,7 @@ four decisions per field, all boilerplate. Target usage:
|
|
|
278
323
|
### `getFormFieldPropsAt(path)`
|
|
279
324
|
|
|
280
325
|
A member of the hook's return value (it needs `values`, `errors`, and the
|
|
281
|
-
setter), typed by the `path/` machinery:
|
|
326
|
+
setter), typed by the `state/path/` machinery:
|
|
282
327
|
|
|
283
328
|
```ts
|
|
284
329
|
type FormFieldProps<V> = {
|
|
@@ -297,7 +342,7 @@ Consequences this pulls in deliberately:
|
|
|
297
342
|
|
|
298
343
|
- **Granular setters via path arrive here** — this retires the "likely
|
|
299
344
|
remove" todo on `onValueChanges` as the only write path. Writing at a path
|
|
300
|
-
is the immutable-update mirror of `read()` in `path/path.ts`.
|
|
345
|
+
is the immutable-update mirror of `read()` in `state/path/path.ts`.
|
|
301
346
|
- **Touched tracking becomes real state** (`onBlur` feeds it). The
|
|
302
347
|
error-display policy (submitAttempted / touched gating) moves *inside*
|
|
303
348
|
`errorMessage`, so consuming elements render what they're given and stay
|
|
@@ -417,8 +462,9 @@ Possibly a hybrid: identity-matching as the default heuristic, owned ops as
|
|
|
417
462
|
the guaranteed path. **Research topic for when we get here** — record
|
|
418
463
|
findings in the learning map before committing to either.
|
|
419
464
|
|
|
420
|
-
Sequencing: visited tracking
|
|
421
|
-
path
|
|
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 —
|
|
422
468
|
error paths and per-element meta both need element identity once lists are
|
|
423
469
|
editable.
|
|
424
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
|
+
});
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { Cursor, CursorStep, Path, PathStep, ValueAt } from './types';
|
|
2
|
+
|
|
3
|
+
const makeCursor = <T>(steps: readonly CursorStep[]): Cursor<T> => ({
|
|
4
|
+
at<P extends Path<T>>(p: P): Cursor<ValueAt<T, P>> {
|
|
5
|
+
const keySteps: CursorStep[] = (p as readonly PathStep[]).map((key) => ({
|
|
6
|
+
kind: 'key',
|
|
7
|
+
key,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
return makeCursor<ValueAt<T, P>>([...steps, ...keySteps]);
|
|
11
|
+
},
|
|
12
|
+
narrow<U extends T>(predicate: (val: T) => val is U): Cursor<U> {
|
|
13
|
+
return makeCursor<U>([
|
|
14
|
+
...steps,
|
|
15
|
+
{ kind: 'narrow', predicate: predicate as (val: unknown) => boolean },
|
|
16
|
+
]);
|
|
17
|
+
},
|
|
18
|
+
build() {
|
|
19
|
+
return [...steps];
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const path = <T>(): Cursor<T> => makeCursor<T>([]);
|
|
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
|
+
|
|
34
|
+
// Walk steps against a value. Returns undefined if any step fails — a dead
|
|
35
|
+
// value (null or undefined) encountered mid-path, missing key, out-of-bounds
|
|
36
|
+
// index, or a narrow predicate that rejects the current value. Always
|
|
37
|
+
// undefined, never null, even when the dead value was null — ValueAt in
|
|
38
|
+
// ./types.ts mirrors this exactly (see "Union policy" in src/forms/CLAUDE.md).
|
|
39
|
+
// Callers using the typed cursor know what shape to expect; this is the
|
|
40
|
+
// runtime escape hatch that surfaces "the path didn't resolve."
|
|
41
|
+
export const read = (root: unknown, steps: readonly CursorStep[]): unknown => {
|
|
42
|
+
let cursor: unknown = root;
|
|
43
|
+
|
|
44
|
+
for (const step of steps) {
|
|
45
|
+
if (cursor == null) return undefined;
|
|
46
|
+
|
|
47
|
+
if (step.kind === 'narrow') {
|
|
48
|
+
if (!step.predicate(cursor)) return undefined;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (typeof step.key === 'number') {
|
|
53
|
+
if (!Array.isArray(cursor)) return undefined;
|
|
54
|
+
cursor = cursor[step.key];
|
|
55
|
+
} else {
|
|
56
|
+
if (typeof cursor !== 'object') return undefined;
|
|
57
|
+
cursor = (cursor as Record<string, unknown>)[step.key];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return cursor;
|
|
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
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, useSyncExternalStore } from 'react';
|
|
2
2
|
import type { ReactElement } from 'react';
|
|
3
3
|
import { createPortal } from 'react-dom';
|
|
4
|
-
import { JsonTable } from '
|
|
4
|
+
import { JsonTable } from '../../../components/Json/JsonTable';
|
|
5
5
|
import { toInspectable } from './inspectable';
|
|
6
6
|
import type { SnapshotStore } from './snapshotStore';
|
|
7
7
|
import type { FormDebugSnapshot, FormValuesObject } from './types';
|
|
@@ -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
|
|
7
|
-
// validation there is at most one entry per path today;
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|