@structuralists/scaffolding 0.10.2 → 0.12.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 +3 -3
- package/package.json +1 -1
- package/src/components/Json/JsonTable/JsonLeafNode.tsx +20 -17
- package/src/components/Json/JsonTable/JsonTable.stories.tsx +87 -0
- package/src/components/Json/JsonTable/index.tsx +13 -6
- package/src/components/Json/JsonTable/styles.module.css +20 -0
- package/src/components/Json/JsonTable/types.ts +3 -5
- package/src/forms/CLAUDE.md +195 -41
- package/src/forms/elements/Input/index.tsx +2 -0
- package/src/forms/elements/Input/types.ts +2 -1
- package/src/forms/plan.md +146 -29
- package/src/forms/state/bindings/SingleSelectForForm.tsx +45 -0
- package/src/forms/state/bindings/TextInputForForm.tsx +45 -0
- package/src/forms/state/path/path.test.ts +71 -1
- package/src/forms/state/path/path.ts +50 -0
- package/src/forms/state/useFormState/FormDebugger.test.tsx +4 -3
- package/src/forms/state/useFormState/FormDebugger.tsx +1 -0
- package/src/forms/state/useFormState/deriveErrors.test.ts +94 -0
- package/src/forms/state/useFormState/deriveErrors.ts +10 -10
- package/src/forms/state/useFormState/errorAt.test.ts +3 -3
- package/src/forms/state/useFormState/errorAt.ts +8 -12
- package/src/forms/state/useFormState/inspectable.test.ts +9 -9
- package/src/forms/state/useFormState/inspectable.ts +5 -7
- package/src/forms/state/useFormState/types.ts +35 -4
- package/src/forms/state/useFormState/useFieldBinding.test.tsx +165 -0
- package/src/forms/state/useFormState/useFieldBinding.ts +71 -0
- package/src/forms/state/useFormState/useFormDebugger.test.tsx +1 -0
- package/src/forms/state/useFormState/useFormState.stories.tsx +190 -5
- package/src/forms/state/useFormState/useFormState.test-d.ts +516 -1
- package/src/forms/state/useFormState/useFormState.test.tsx +2 -2
- package/src/forms/state/useFormState/useFormState.ts +12 -3
- package/src/forms/state/validations/types.ts +77 -17
- package/src/forms/state/validations/walk.test.ts +159 -19
- package/src/forms/state/validations/walk.ts +86 -25
- package/tokens.css +55 -0
package/eslint.config.mjs
CHANGED
|
@@ -132,10 +132,10 @@ export default [{
|
|
|
132
132
|
"Files inside a primitive folder must not import their own index barrel; import the neighbor directly.",
|
|
133
133
|
},
|
|
134
134
|
// Presentational primitives (components and form elements) must
|
|
135
|
-
// not reach into the form-state layer. The sanctioned
|
|
135
|
+
// not reach into the form-state layer. The sanctioned bridges run
|
|
136
136
|
// the other way: state's FormDebugger imports the JsonTable barrel
|
|
137
|
-
// (dev tooling), and the
|
|
138
|
-
//
|
|
137
|
+
// (dev tooling), and the form-aware wrappers live on the state
|
|
138
|
+
// side (state/bindings/). State stories may also use element barrels.
|
|
139
139
|
{
|
|
140
140
|
from: [
|
|
141
141
|
{ type: 'primitive' },
|
package/package.json
CHANGED
|
@@ -1,36 +1,39 @@
|
|
|
1
|
-
import
|
|
1
|
+
import styles from './styles.module.css';
|
|
2
2
|
|
|
3
3
|
export type JsonLeafNodeProps = {
|
|
4
4
|
value: unknown;
|
|
5
|
-
/** When true, render `null` / `undefined` via Text with the monospace font.
|
|
6
|
-
* JsonTable sets this on recursive calls so leaves inside a table inherit
|
|
7
|
-
* the same font as the surrounding monospace cells (Text otherwise sets
|
|
8
|
-
* its own font-family and would break the inheritance). */
|
|
9
|
-
isMono?: boolean;
|
|
10
5
|
};
|
|
11
6
|
|
|
12
7
|
/**
|
|
13
|
-
* Renders one value as a leaf — text only, no JSON tree viewer.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
8
|
+
* Renders one value as a leaf — text only, no JSON tree viewer. Each
|
|
9
|
+
* primitive type gets a color class so the value reads like Chrome's
|
|
10
|
+
* inspector: strings in orange (with quotes), numbers in blue, booleans
|
|
11
|
+
* in purple, `null`/`undefined` in pink. Anything else (objects, arrays,
|
|
12
|
+
* Dates, Maps, …) currently falls through to `{value}` — that will throw
|
|
13
|
+
* at render time for non-renderable types and is the placeholder until
|
|
14
|
+
* JsonTable grows specialized renderers for them.
|
|
19
15
|
*/
|
|
20
16
|
export const JsonLeafNode = (props: JsonLeafNodeProps) => {
|
|
21
|
-
const { value
|
|
22
|
-
const size = isMono ? 'small' : undefined;
|
|
17
|
+
const { value } = props;
|
|
23
18
|
|
|
24
19
|
if (value === null) {
|
|
25
|
-
return <
|
|
20
|
+
return <span className={styles.nil}>null</span>;
|
|
26
21
|
}
|
|
27
22
|
|
|
28
23
|
if (value === undefined) {
|
|
29
|
-
return <
|
|
24
|
+
return <span className={styles.nil}>undefined</span>;
|
|
30
25
|
}
|
|
31
26
|
|
|
32
27
|
if (typeof value === 'boolean') {
|
|
33
|
-
return
|
|
28
|
+
return <span className={styles.boolean}>{String(value)}</span>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (typeof value === 'string') {
|
|
32
|
+
return <span className={styles.string}>{JSON.stringify(value)}</span>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (typeof value === 'number') {
|
|
36
|
+
return <span className={styles.number}>{value}</span>;
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
return <>{value}</>;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
|
+
import { expect, within } from 'storybook/test';
|
|
2
3
|
import { JsonTable } from './index';
|
|
3
4
|
|
|
4
5
|
const meta: Meta<typeof JsonTable> = {
|
|
@@ -50,3 +51,89 @@ export const PrimitivesAtTopLevel: Story = {
|
|
|
50
51
|
</div>
|
|
51
52
|
),
|
|
52
53
|
};
|
|
54
|
+
|
|
55
|
+
export const ValueColors: Story = {
|
|
56
|
+
name: 'Value colors per primitive type',
|
|
57
|
+
render: () => (
|
|
58
|
+
<JsonTable
|
|
59
|
+
title="Primitive value colors"
|
|
60
|
+
value={{
|
|
61
|
+
aString: 'hello world',
|
|
62
|
+
anEmptyString: '',
|
|
63
|
+
aQuotedString: 'say "hi"',
|
|
64
|
+
aMultilineString: 'line one\nline two',
|
|
65
|
+
aNumber: 12345,
|
|
66
|
+
aFloat: 3.14159,
|
|
67
|
+
aTrue: true,
|
|
68
|
+
aFalse: false,
|
|
69
|
+
aNull: null,
|
|
70
|
+
anUndefined: undefined,
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
),
|
|
74
|
+
play: async ({ canvasElement }) => {
|
|
75
|
+
const canvas = within(canvasElement);
|
|
76
|
+
await expect(canvas.getByText('"hello world"')).toBeInTheDocument();
|
|
77
|
+
await expect(canvas.getByText('"say \\"hi\\""')).toBeInTheDocument();
|
|
78
|
+
await expect(canvas.getByText('"line one\\nline two"')).toBeInTheDocument();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const deepSample = {
|
|
83
|
+
id: 'org_8821',
|
|
84
|
+
name: 'Northwind Logistics',
|
|
85
|
+
region: 'us-west-2',
|
|
86
|
+
active: true,
|
|
87
|
+
founded: 2014,
|
|
88
|
+
headquarters: {
|
|
89
|
+
address: {
|
|
90
|
+
street: '120 Pier Ave',
|
|
91
|
+
city: 'Seattle',
|
|
92
|
+
state: 'WA',
|
|
93
|
+
zip: '98101',
|
|
94
|
+
coords: { lat: 47.6062, lng: -122.3321 },
|
|
95
|
+
},
|
|
96
|
+
capacity: 240,
|
|
97
|
+
isLeased: false,
|
|
98
|
+
},
|
|
99
|
+
teams: [
|
|
100
|
+
{
|
|
101
|
+
id: 'team_platform',
|
|
102
|
+
name: 'Platform',
|
|
103
|
+
headcount: 12,
|
|
104
|
+
lead: { id: 'usr_1042', name: 'Alice Park', email: 'alice@northwind.io' },
|
|
105
|
+
members: [
|
|
106
|
+
{ id: 'usr_1042', name: 'Alice Park', role: 'lead', onCall: true },
|
|
107
|
+
{ id: 'usr_1109', name: 'Ben Ortiz', role: 'engineer', onCall: false },
|
|
108
|
+
{ id: 'usr_1188', name: 'Chen Wu', role: 'engineer', onCall: false },
|
|
109
|
+
],
|
|
110
|
+
tags: ['core', 'oncall', 'sre'],
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
id: 'team_data',
|
|
114
|
+
name: 'Data',
|
|
115
|
+
headcount: 7,
|
|
116
|
+
lead: { id: 'usr_1301', name: 'Dana Reyes', email: 'dana@northwind.io' },
|
|
117
|
+
members: [
|
|
118
|
+
{ id: 'usr_1301', name: 'Dana Reyes', role: 'lead', onCall: false },
|
|
119
|
+
{ id: 'usr_1422', name: 'Evan Liu', role: 'analyst', onCall: false },
|
|
120
|
+
],
|
|
121
|
+
tags: ['analytics', 'etl'],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
integrations: [
|
|
125
|
+
{ kind: 'slack', enabled: true, channel: '#alerts', retries: 3 },
|
|
126
|
+
{ kind: 'pagerduty', enabled: true, escalation: { primary: 'usr_1042', secondary: null } },
|
|
127
|
+
{ kind: 'jira', enabled: false, project: null },
|
|
128
|
+
],
|
|
129
|
+
flags: {
|
|
130
|
+
betaFeatures: ['new-dashboard', 'inline-edit'],
|
|
131
|
+
rateLimits: { rpm: 600, burst: 1200, throttleAfter: null },
|
|
132
|
+
},
|
|
133
|
+
notes: undefined,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const DeepNested: Story = {
|
|
137
|
+
name: 'Deeply nested (objects + arrays)',
|
|
138
|
+
render: () => <JsonTable value={deepSample} title="Organization" />,
|
|
139
|
+
};
|
|
@@ -13,8 +13,13 @@ import styles from './styles.module.css';
|
|
|
13
13
|
export const JsonTable = (props: JsonTableProps) => {
|
|
14
14
|
const { value, title, isNested } = props;
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
const isObject = isPlainObject(value);
|
|
17
|
+
const isArray = Array.isArray(value);
|
|
18
|
+
|
|
19
|
+
if (isObject || isArray) {
|
|
20
|
+
const entries: [string, unknown][] = isArray
|
|
21
|
+
? value.map((item, i) => [String(i), item])
|
|
22
|
+
: Object.entries(value as Record<string, unknown>);
|
|
18
23
|
|
|
19
24
|
const headerRow = title ? (
|
|
20
25
|
<QuickTableHeaderRow>
|
|
@@ -26,12 +31,14 @@ export const JsonTable = (props: JsonTableProps) => {
|
|
|
26
31
|
<QuickTable headerRow={headerRow} hasColumnDividers hasOuterBorder={!isNested}>
|
|
27
32
|
{entries.map((entry) => {
|
|
28
33
|
const [key, child] = entry;
|
|
29
|
-
const
|
|
34
|
+
const childIsContainer = isPlainObject(child) || Array.isArray(child);
|
|
30
35
|
|
|
31
36
|
return (
|
|
32
37
|
<QuickTableRow key={key}>
|
|
33
|
-
<QuickTableCell width={1}>
|
|
34
|
-
|
|
38
|
+
<QuickTableCell width={1}>
|
|
39
|
+
<span className={styles.key}>{key}</span>
|
|
40
|
+
</QuickTableCell>
|
|
41
|
+
<QuickTableCell hasPadding={!childIsContainer}>
|
|
35
42
|
<JsonTable value={child} title={key} isNested />
|
|
36
43
|
</QuickTableCell>
|
|
37
44
|
</QuickTableRow>
|
|
@@ -43,7 +50,7 @@ export const JsonTable = (props: JsonTableProps) => {
|
|
|
43
50
|
return isNested ? table : <div className={styles.root}>{table}</div>;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
|
-
return <JsonLeafNode value={value}
|
|
53
|
+
return <JsonLeafNode value={value} />;
|
|
47
54
|
};
|
|
48
55
|
|
|
49
56
|
export type { JsonTableProps };
|
|
@@ -5,3 +5,23 @@
|
|
|
5
5
|
.root thead tr {
|
|
6
6
|
border-bottom-color: var(--ui-border-subtle);
|
|
7
7
|
}
|
|
8
|
+
|
|
9
|
+
.key {
|
|
10
|
+
color: var(--ui-json-key);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.string {
|
|
14
|
+
color: var(--ui-json-string);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.number {
|
|
18
|
+
color: var(--ui-json-number);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.boolean {
|
|
22
|
+
color: var(--ui-json-boolean);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.nil {
|
|
26
|
+
color: var(--ui-json-nil);
|
|
27
|
+
}
|
|
@@ -12,11 +12,9 @@ export type JsonTableProps = {
|
|
|
12
12
|
* labeled table. */
|
|
13
13
|
title?: ReactNode;
|
|
14
14
|
/** @internal Set by JsonTable on every recursive call (i.e. when rendering
|
|
15
|
-
* a child entry of a parent table).
|
|
15
|
+
* a child entry of a parent table). On a nested object or array,
|
|
16
16
|
* suppresses the outer border so the inner table sits flush inside its
|
|
17
|
-
* parent cell — whose padding is also dropped at the parent level
|
|
18
|
-
*
|
|
19
|
-
* via Text with the monospace font so they inherit the surrounding font
|
|
20
|
-
* set by the JsonTable root. Not intended for external callers. */
|
|
17
|
+
* parent cell — whose padding is also dropped at the parent level. Not
|
|
18
|
+
* intended for external callers. */
|
|
21
19
|
isNested?: boolean;
|
|
22
20
|
};
|
package/src/forms/CLAUDE.md
CHANGED
|
@@ -10,10 +10,11 @@ The forms umbrella, split into two named subtrees:
|
|
|
10
10
|
- `state/` — the strongly-typed React form state hook and its machinery.
|
|
11
11
|
Everything below this heading is about the state layer.
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
`state/useFormState/FormDebugger.tsx` imports the `JsonTable`
|
|
15
|
-
`src/components/Json
|
|
16
|
-
|
|
13
|
+
Two sanctioned state→elements-side bridges exist, both on the state side:
|
|
14
|
+
the Debugger (`state/useFormState/FormDebugger.tsx` imports the `JsonTable`
|
|
15
|
+
barrel from `src/components/Json/`, dev tooling) and the form-aware element
|
|
16
|
+
wrappers (`state/bindings/` — see "Field binding" below). State files
|
|
17
|
+
importing an element go through its barrel like any external importer.
|
|
17
18
|
|
|
18
19
|
The headline feature of the state layer is **validation that propagates type
|
|
19
20
|
refinements to the submit handler** — passing the right constraints means the
|
|
@@ -49,25 +50,67 @@ function composition.
|
|
|
49
50
|
|
|
50
51
|
## What a constraints key may map to
|
|
51
52
|
|
|
53
|
+
The grammar is recursive. What a key admits is directed by the *field's*
|
|
54
|
+
type, never guessed from the constraint's shape:
|
|
55
|
+
|
|
52
56
|
```ts
|
|
53
57
|
type FieldConstraint<F> =
|
|
58
|
+
// leaf forms — legal for ANY field type, structural fields included
|
|
54
59
|
| FieldValidator<F> // one validator
|
|
55
60
|
| readonly FieldValidator<F>[] // several, run in order, first error wins
|
|
61
|
+
// structural forms — only where the field type permits
|
|
62
|
+
| (F extends FormValuesObject ? Validations<F> : never) // nested spec
|
|
63
|
+
| (F extends FormValueList ? ListConstraint<F[number]> : never); // { each: … }
|
|
56
64
|
```
|
|
57
65
|
|
|
58
66
|
The array form is the everyday way to stack validators on a field at the
|
|
59
|
-
constraint site
|
|
67
|
+
constraint site; a nested spec addresses the fields of an object section;
|
|
68
|
+
`each` applies a spec to every element of a list. All compose freely:
|
|
60
69
|
|
|
61
70
|
```ts
|
|
62
71
|
constraints: {
|
|
63
72
|
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
73
|
+
homeAddress: {
|
|
74
|
+
city: notEmpty('city'),
|
|
75
|
+
postalCode: [notEmpty('postalCode'), matches('postalCode', /^\d{5}$/, '5 digits')],
|
|
76
|
+
},
|
|
77
|
+
drivers: {
|
|
78
|
+
each: {
|
|
79
|
+
name: notEmpty('name'),
|
|
80
|
+
incidents: { each: { date: notEmpty('date') } },
|
|
81
|
+
},
|
|
82
|
+
},
|
|
64
83
|
}
|
|
65
84
|
```
|
|
66
85
|
|
|
67
|
-
|
|
68
|
-
*reusable, named* composite validators): validators run in array
|
|
69
|
-
the first error is the field's error, and later validators don't run
|
|
70
|
-
one fails. An empty array passes and narrows nothing.
|
|
86
|
+
Array semantics are identical to `allOf` (which remains the tool for
|
|
87
|
+
building *reusable, named* composite validators): validators run in array
|
|
88
|
+
order, the first error is the field's error, and later validators don't run
|
|
89
|
+
once one fails. An empty array passes and narrows nothing.
|
|
90
|
+
|
|
91
|
+
Grammar doctrine, in force at the type level and in the runtime walk:
|
|
92
|
+
|
|
93
|
+
- **Disambiguation is by the value model.** An object field that owns a key
|
|
94
|
+
literally named `each` still takes a nested spec — for an object-typed
|
|
95
|
+
field only the `Validations<F>` arm is live, for a list-typed field only
|
|
96
|
+
`ListConstraint`. Both `RefineField` and the walk interrogate the
|
|
97
|
+
field/value before the constraint's shape.
|
|
98
|
+
- **Leaf forms stay legal on structural fields.** A bare validator on an
|
|
99
|
+
object field is a whole-section validator (`mailingAddress:
|
|
100
|
+
notEmpty(…)` is how "required section" is expressed); a validator array
|
|
101
|
+
on a list field validates the list as a value. Leaf and structural forms
|
|
102
|
+
are mutually exclusive per key (recorded open decision — a future `self`
|
|
103
|
+
slot).
|
|
104
|
+
- **A nested spec on an absent (null/undefined) section validates
|
|
105
|
+
nothing**, at the type level (only the present branch refines;
|
|
106
|
+
nullability survives around the refined interior) and at runtime (the
|
|
107
|
+
walk skips — nothing to walk). Same for `each` over a null list.
|
|
108
|
+
- **`F` stays naked in the structural arms.** Nullable sections/lists work
|
|
109
|
+
purely by distribution; wrapping the checked type in `NonNullable` breaks
|
|
110
|
+
it and blows the recursion stack (TS2589).
|
|
111
|
+
- **Runtime status of `each`:** type-level only until plan phase 3. An
|
|
112
|
+
`each` constraint on a present list makes the walk THROW a clear error
|
|
113
|
+
instead of silently not validating.
|
|
71
114
|
|
|
72
115
|
## Aggregation: `perField`
|
|
73
116
|
|
|
@@ -78,7 +121,11 @@ const constraints = perField({
|
|
|
78
121
|
```
|
|
79
122
|
|
|
80
123
|
`perField` is the entry point that produces a `Validations<FormType>`-shaped
|
|
81
|
-
value while preserving the precise types of each individual validator.
|
|
124
|
+
value while preserving the precise types of each individual validator. It
|
|
125
|
+
currently admits only the leaf forms (a validator or validator array per
|
|
126
|
+
key) — extending it to pre-built nested/`each` specs is plan phase 4
|
|
127
|
+
(composition hardening); until then, write structural constraints inline in
|
|
128
|
+
the `useFormState` call.
|
|
82
129
|
|
|
83
130
|
### The precision-preserving ceremony, by call-site shape
|
|
84
131
|
|
|
@@ -136,10 +183,11 @@ unreliable in some intersections.
|
|
|
136
183
|
|
|
137
184
|
## `Refine<FormType, typeof constraints>`
|
|
138
185
|
|
|
139
|
-
Walks the constraints object,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
186
|
+
Walks the constraints object recursively, mirroring the grammar: leaves
|
|
187
|
+
apply `Exclude<FormType[K], __excludes>` from their validators' markers,
|
|
188
|
+
nested specs recurse into the section, `each` specs refine the element type
|
|
189
|
+
and flow it through `Array<...>`. Fields without a constraint pass through
|
|
190
|
+
unchanged, at every level. The result is the type handed to `onSubmit`.
|
|
143
191
|
|
|
144
192
|
```ts
|
|
145
193
|
type FormType = { a: string | undefined; b: number };
|
|
@@ -147,6 +195,18 @@ type C = typeof constraints; // a: notEmpty
|
|
|
147
195
|
type SubmitType = Refine<FormType, C>; // { a: string; b: number }
|
|
148
196
|
```
|
|
149
197
|
|
|
198
|
+
`Refine` opens with an identity gate — `Validations<T> extends V ? T :
|
|
199
|
+
RefineObject<T, V>` — and the gate is load-bearing, not an optimization:
|
|
200
|
+
with constraints omitted the hook's default `V = Validations<T>` would
|
|
201
|
+
distribute `FieldConstraint<F> | undefined` through the walk at every key
|
|
202
|
+
and hand back unions of structurally-identical mapped *copies* of every
|
|
203
|
+
section — assignable to `T` but not identity-equal (mangled hover types;
|
|
204
|
+
the "without constraints, onSubmit receives the unrefined form type" probe
|
|
205
|
+
fails). Any concrete constraints literal is strictly narrower than
|
|
206
|
+
`Validations<T>`, so the gate stays open and the real walk runs. Nested
|
|
207
|
+
occurrences don't need their own gate: a concrete literal's `keyof V` holds
|
|
208
|
+
only the keys actually written, so uncovered fields take `T[K]` verbatim.
|
|
209
|
+
|
|
150
210
|
Per-field dispatch lives in `RefineField<F, C>`, whose constraint parameter
|
|
151
211
|
is deliberately **naked** so unions of constraint types distribute. Two
|
|
152
212
|
regimes of "multiple markers per field" follow from that, both sound:
|
|
@@ -168,10 +228,17 @@ regimes of "multiple markers per field" follow from that, both sound:
|
|
|
168
228
|
per-branch refinements* (`Exclude<F, A> | Exclude<F, B>`), never a
|
|
169
229
|
narrowing the running branch didn't earn.
|
|
170
230
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
231
|
+
Both regimes survive recursion verbatim — the same soundness holds for
|
|
232
|
+
union-typed constraints inside nested and `each` specs (probed at depth in
|
|
233
|
+
`useFormState.test-d.ts`).
|
|
234
|
+
|
|
235
|
+
Branch order inside `RefineField`: `Refinement` first — validator functions
|
|
236
|
+
are objects, and a marked validator must not fall into a structural arm —
|
|
237
|
+
then arrays, then the structural arms, which interrogate the FIELD type
|
|
238
|
+
before the constraint's shape (`F extends FormValueList` is asked before
|
|
239
|
+
looking for an `each` key, so an object field literally named `each`
|
|
240
|
+
refines as a nested spec). A bare marker-less validator on a structural
|
|
241
|
+
field lands in `RefineObject` with an empty `keyof C` — an identity map.
|
|
175
242
|
|
|
176
243
|
## Hook surface
|
|
177
244
|
|
|
@@ -188,10 +255,15 @@ useFormState({
|
|
|
188
255
|
Validation failures surface as a structured list, not a keyed record:
|
|
189
256
|
|
|
190
257
|
```ts
|
|
191
|
-
type FormError<T> = { path: Path<T>; error: string }; //
|
|
258
|
+
type FormError<T> = { path: Path<T>; error: string }; // one entry per failing constrained node
|
|
192
259
|
type FormErrors<T> = readonly FormError<T>[]; // isValid === (errors.length === 0)
|
|
193
260
|
```
|
|
194
261
|
|
|
262
|
+
Paths are as deep as the failing node: a root leaf contributes `['email']`,
|
|
263
|
+
a nested-spec leaf `['homeAddress', 'postalCode']`. Sibling nodes fail
|
|
264
|
+
independently — first-error-wins applies *within* one validator array, not
|
|
265
|
+
across fields.
|
|
266
|
+
|
|
195
267
|
Read one field's message with the typed accessor, never by hand-assembled
|
|
196
268
|
keys — serialized path strings (`'drivers.0.name'`) are deliberately not
|
|
197
269
|
exposed:
|
|
@@ -201,11 +273,74 @@ errorAt(errors, ['email']); // string | undefined
|
|
|
201
273
|
```
|
|
202
274
|
|
|
203
275
|
`errorAt` (state/useFormState/errorAt.ts) matches by exact structural step
|
|
204
|
-
equality (no prefix matching); with
|
|
205
|
-
most one entry per path, and if
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
build on.
|
|
276
|
+
equality (`pathsEqual` in state/path/path.ts — no prefix matching); with
|
|
277
|
+
first-error-wins validation there is at most one entry per path, and if
|
|
278
|
+
collect-all ever lands the first entry stays the one shown. The
|
|
279
|
+
path-addressed model is what the recursive grammar (errors just carry
|
|
280
|
+
longer paths) and `getFormFieldPropsAt`'s `errorMessage` build on. `errorAt`
|
|
281
|
+
is raw truth (no display gating); `errorMessage` is the display-policy-aware
|
|
282
|
+
reading (see "Field binding").
|
|
283
|
+
|
|
284
|
+
## Field binding: `getFormFieldPropsAt(path)` + `state/bindings/`
|
|
285
|
+
|
|
286
|
+
One expression wires a field to the form:
|
|
287
|
+
|
|
288
|
+
```tsx
|
|
289
|
+
<TextInputForForm label="City"
|
|
290
|
+
formFieldProps={getFormFieldPropsAt(['homeAddress', 'city'])} />
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
`getFormFieldPropsAt` is a member of the hook's return value, typed by the
|
|
294
|
+
`state/path/` machinery:
|
|
295
|
+
|
|
296
|
+
```ts
|
|
297
|
+
// on FormHelpers<T>:
|
|
298
|
+
getFormFieldPropsAt<P extends Path<T>>(path: P): FormFieldProps<ValueAt<T, P>>;
|
|
299
|
+
|
|
300
|
+
type FormFieldProps<V> = FieldBinding<V>;
|
|
301
|
+
type FieldBinding<Display, Emit = Display> = {
|
|
302
|
+
value: Display; // read(values, path)
|
|
303
|
+
onChange: (val: Emit) => void; // immutable write(values, path, val)
|
|
304
|
+
errorMessage: string | undefined; // display-policy-aware (below)
|
|
305
|
+
onBlur: () => void; // feeds touched tracking
|
|
306
|
+
};
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
The pieces, and where each one's semantics live:
|
|
310
|
+
|
|
311
|
+
- **Granular path writes** — `write()` in `state/path/path.ts`, the
|
|
312
|
+
immutable-update mirror of `read()`: clones only the spine to the written
|
|
313
|
+
leaf, so untouched siblings keep identity. Dead-step semantics mirror
|
|
314
|
+
read() returning undefined: writing through an absent/null ancestor, a
|
|
315
|
+
shape mismatch, or an out-of-bounds index is an identity-preserving no-op
|
|
316
|
+
— you can't edit a field of an absent section; materialize the section
|
|
317
|
+
first. (Appending/splicing lists is item-8 territory, not a path write.)
|
|
318
|
+
`onValueChanges` (whole-value replacement) still exists but is no longer
|
|
319
|
+
the only write path.
|
|
320
|
+
- **Touched tracking** — real state in `useFieldBinding`: a `Path<T>[]`
|
|
321
|
+
compared with `pathsEqual`, fed by `onBlur`, visible in the Debugger
|
|
322
|
+
snapshot. Commit-style elements (selects) call `onBlur` on commit — the
|
|
323
|
+
commit IS their blur moment.
|
|
324
|
+
- **THE error-display policy** lives inside `errorMessage` and nowhere
|
|
325
|
+
else: show a field's error once the field is touched OR a submit has been
|
|
326
|
+
attempted. Elements render what they're given and stay policy-free. A UI
|
|
327
|
+
wanting a different policy (e.g. always-live) reads `errors`/`errorAt`
|
|
328
|
+
directly.
|
|
329
|
+
|
|
330
|
+
### The element wrappers (`state/bindings/`)
|
|
331
|
+
|
|
332
|
+
The **wrapper style** is the prototyped shorthand (per the plan's deciding
|
|
333
|
+
criterion — simplest for agents to work with): `TextInputForForm` and
|
|
334
|
+
`SingleSelectForForm` compose `Field` (label/hint/error presentation)
|
|
335
|
+
around the element and accept the bundle as one `formFieldProps` prop.
|
|
336
|
+
The wrapper declares what it can display and emit via
|
|
337
|
+
`FieldBinding<Display, Emit>` — e.g. the text input takes
|
|
338
|
+
`FieldBinding<string | null | undefined, string>` — and a
|
|
339
|
+
`FormFieldProps<V>` is assignable exactly when `Emit ⊆ V ⊆ Display`. That
|
|
340
|
+
one structural check is the end-to-end type safety: binding a number- or
|
|
341
|
+
boolean-typed path (or a literal-union field, for free text) to a
|
|
342
|
+
text-shaped element fails to compile at the `formFieldProps` prop, with no
|
|
343
|
+
generics at the use site.
|
|
209
344
|
|
|
210
345
|
## Union policy — what form state may hold
|
|
211
346
|
|
|
@@ -309,10 +444,11 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
309
444
|
|
|
310
445
|
- `state/useFormState/` — the hook + the value-model types (`FormValueSimple`,
|
|
311
446
|
`FormValuesObject`, `FormValueList`). Hook surface: `{ values,
|
|
312
|
-
onValueChanges, errors, isValid, submitAttempted, submit,
|
|
447
|
+
onValueChanges, errors, isValid, submitAttempted, submit,
|
|
448
|
+
getFormFieldPropsAt, Debugger }`.
|
|
313
449
|
`useFormState.ts` itself is deliberately **plumbing only**: it holds the
|
|
314
|
-
values `useState` (a bare `useState
|
|
315
|
-
|
|
450
|
+
values `useState` (a bare `useState`; the granular path writes layer on
|
|
451
|
+
top of its setter via `useFieldBinding`), links up the
|
|
316
452
|
modules below, and recomposes their outputs into `FormHelpers<T>`. Each
|
|
317
453
|
slice lives in its own file so it is independently comprehensible and
|
|
318
454
|
unit-testable at its own boundary:
|
|
@@ -325,11 +461,19 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
325
461
|
cheap); `isValid` is its emptiness, derived inline in the hook. Its
|
|
326
462
|
loop delegates per-entry semantics to `state/validations/walk.ts` and
|
|
327
463
|
carries two documented honest widenings: the `Object.keys` cast, and
|
|
328
|
-
`failure.path as Path<T>` — `[key]`
|
|
329
|
-
|
|
330
|
-
|
|
464
|
+
`failure.path as Path<T>` — the walk extends the `[key]` seed only
|
|
465
|
+
along keys of specs type-checked against `T`'s subtree, so every
|
|
466
|
+
returned path is a valid `Path<T>`, but TS cannot compute `Path<T>`
|
|
467
|
+
for an unresolved generic `T` to see the correlation. It also hosts
|
|
468
|
+
the cast-free `ConstraintEntry`
|
|
331
469
|
assignment that polices grammar growth (see the `state/validations/` bullet).
|
|
332
470
|
- `errorAt.ts` — the typed error lookup.
|
|
471
|
+
- `useFieldBinding.ts` — field binding: owns the touched list and builds
|
|
472
|
+
`getFormFieldPropsAt` (see "Field binding" above — the error-display
|
|
473
|
+
policy lives here). Two documented honest casts correlate `read()`/
|
|
474
|
+
`write()` results with `ValueAt<T, P>`/`T` for generic `T`; both are
|
|
475
|
+
truths the runtime upholds by construction, same family as `errorAt`'s
|
|
476
|
+
path widening.
|
|
333
477
|
- `useFormSubmit.ts` — submit orchestration: owns `submitAttempted`
|
|
334
478
|
(lets UIs gate error display) and the validity gate in front of
|
|
335
479
|
`onSubmit`; its `submit()` performs the one honest *refinement* cast
|
|
@@ -346,27 +490,37 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
346
490
|
bottom-right, portaled to `<body>`) that opens a live `JsonTable` view
|
|
347
491
|
of the form's internal state. Only an *open* debugger window
|
|
348
492
|
subscribes (`useSyncExternalStore`), so an unused Debugger costs
|
|
349
|
-
~nothing. `inspectable.ts` converts
|
|
350
|
-
can render.
|
|
351
|
-
- `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`,
|
|
352
|
-
|
|
353
|
-
|
|
493
|
+
~nothing. `inspectable.ts` converts Sets to string leaves `JsonTable`
|
|
494
|
+
can render (objects and arrays it renders natively).
|
|
495
|
+
- `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`,
|
|
496
|
+
`read()`), plus `write()` (the immutable-update mirror of `read()` behind
|
|
497
|
+
granular field writes) and `pathsEqual` (the one definition of structural
|
|
498
|
+
path equality — `errorAt` and touched tracking both use it). Coupled to
|
|
499
|
+
the form value model intentionally.
|
|
354
500
|
Union handling is governed by the "Union policy" section above; the
|
|
355
501
|
policy's type-level guts (`IsDisallowedFormUnion`, `UnionPolicyCheck`,
|
|
356
502
|
`DisallowedFormUnion`) live with the value model in `state/useFormState/types.ts`.
|
|
357
|
-
- `state/validations/` — `perField`,
|
|
358
|
-
`
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
503
|
+
- `state/validations/` — `perField`, the recursive grammar
|
|
504
|
+
(`Validations<T>` / `FieldConstraint<F>` / `ListConstraint<E>`),
|
|
505
|
+
`Refine<T,V>`, `Refinement<>` infra, plus `walk.ts`, the recursive runtime
|
|
506
|
+
walk the hook delegates to (`validateEntry` — returns every failure in the
|
|
507
|
+
entry's subtree, addresses accumulated as `PathStep[]`s). `Validations<T>`
|
|
508
|
+
accepts bare `(val) => string | null` functions too — they simply narrow
|
|
509
|
+
nothing. The walk disambiguates a structural spec against the VALUE at
|
|
510
|
+
the path (array ⇒ `{ each }`, which throws until phase 3; object ⇒
|
|
511
|
+
nested spec; absent ⇒ skip), never against the constraint's shape.
|
|
512
|
+
The walk's entry type (`ConstraintEntry`) is how the compiler polices
|
|
362
513
|
the error loop in `state/useFormState/deriveErrors.ts`: `constraints[key]` is
|
|
363
514
|
*assigned* to it, never cast,
|
|
364
|
-
so a grammar form the walk doesn't understand (a
|
|
515
|
+
so a grammar form the walk doesn't understand (a future `self` slot, say)
|
|
365
516
|
is a compile error at the assignment — a cast there would silently accept
|
|
366
517
|
new grammar and misinterpret it at runtime (e.g. call an array as a
|
|
367
518
|
function). Keep it cast-free. The one widening cast inside `validateEntry`
|
|
368
519
|
mirrors `allOf`'s part-call: honest contravariant widening of a single,
|
|
369
520
|
already-normalized validator.
|
|
521
|
+
- `state/bindings/` — the form-aware element wrappers (`TextInputForForm`,
|
|
522
|
+
`SingleSelectForForm`), the second sanctioned state→elements bridge (see
|
|
523
|
+
"Field binding" above). Element imports go through the element barrels.
|
|
370
524
|
- `state/validators/` — the standard-library validators (`notEmpty`, `minLength`,
|
|
371
525
|
`matches`, `min`) plus `allOf`, which composes validators on one field and
|
|
372
526
|
carries the union of the parts' refinements. `allOf` derives its input type
|
|
@@ -14,6 +14,7 @@ export const Input = (props: InputProps) => {
|
|
|
14
14
|
value,
|
|
15
15
|
defaultValue,
|
|
16
16
|
onChange,
|
|
17
|
+
onBlur,
|
|
17
18
|
placeholder,
|
|
18
19
|
required,
|
|
19
20
|
autoFocus,
|
|
@@ -29,6 +30,7 @@ export const Input = (props: InputProps) => {
|
|
|
29
30
|
value={value}
|
|
30
31
|
defaultValue={defaultValue}
|
|
31
32
|
onChange={onChange}
|
|
33
|
+
onBlur={onBlur}
|
|
32
34
|
placeholder={placeholder}
|
|
33
35
|
required={required}
|
|
34
36
|
autoFocus={autoFocus}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ChangeEventHandler } from 'react';
|
|
1
|
+
import type { ChangeEventHandler, FocusEventHandler } from 'react';
|
|
2
2
|
|
|
3
3
|
export type InputSize = 'small' | 'medium';
|
|
4
4
|
export type InputType = 'text' | 'email' | 'password';
|
|
@@ -9,6 +9,7 @@ export type InputProps = {
|
|
|
9
9
|
value?: string;
|
|
10
10
|
defaultValue?: string;
|
|
11
11
|
onChange?: ChangeEventHandler<HTMLInputElement>;
|
|
12
|
+
onBlur?: FocusEventHandler<HTMLInputElement>;
|
|
12
13
|
placeholder?: string;
|
|
13
14
|
required?: boolean;
|
|
14
15
|
autoFocus?: boolean;
|