@structuralists/scaffolding 0.11.0 → 0.13.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/.storybook/preview.tsx +42 -0
- package/AGENTS.md +9 -0
- package/README.md +1 -1
- 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 +110 -26
- package/src/forms/plan.md +115 -23
- package/src/forms/state/useFormState/FormDebugger.test.tsx +4 -3
- package/src/forms/state/useFormState/deriveErrors.test.ts +129 -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/inspectable.test.ts +9 -9
- package/src/forms/state/useFormState/inspectable.ts +5 -7
- package/src/forms/state/useFormState/types.ts +2 -2
- package/src/forms/state/useFormState/useFieldBinding.test.tsx +34 -0
- package/src/forms/state/useFormState/useFormState.stories.tsx +214 -10
- package/src/forms/state/useFormState/useFormState.test-d.ts +436 -0
- package/src/forms/state/useFormState/useFormState.test.tsx +2 -2
- package/src/forms/state/validations/types.ts +79 -17
- package/src/forms/state/validations/walk.test.ts +272 -19
- package/src/forms/state/validations/walk.ts +97 -25
- package/tokens.css +55 -0
package/.storybook/preview.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Preview } from '@storybook/react-vite';
|
|
2
|
+
import { useEffect } from 'storybook/preview-api';
|
|
2
3
|
import { MemoryRouter } from 'react-router';
|
|
3
4
|
import * as prettier from 'prettier/standalone';
|
|
4
5
|
import * as babel from 'prettier/plugins/babel';
|
|
@@ -11,6 +12,28 @@ import '../tokens.css';
|
|
|
11
12
|
const formatCache = new Map<string, string>();
|
|
12
13
|
|
|
13
14
|
const preview: Preview = {
|
|
15
|
+
globalTypes: {
|
|
16
|
+
theme: {
|
|
17
|
+
description: 'Design-token theme (sets data-theme on <html>)',
|
|
18
|
+
toolbar: {
|
|
19
|
+
title: 'Theme',
|
|
20
|
+
icon: 'paintbrush',
|
|
21
|
+
items: [
|
|
22
|
+
{ value: 'system', title: 'System (default)' },
|
|
23
|
+
{ value: 'light-warm', title: 'Light / Warm' },
|
|
24
|
+
{ value: 'light-paper', title: 'Light / Paper' },
|
|
25
|
+
{ value: 'light-sepia', title: 'Light / Sepia' },
|
|
26
|
+
{ value: 'dark-warm', title: 'Dark / Warm' },
|
|
27
|
+
{ value: 'dark-neutral', title: 'Dark / Neutral' },
|
|
28
|
+
{ value: 'dark-dimmed', title: 'Dark / Dimmed' },
|
|
29
|
+
],
|
|
30
|
+
dynamicTitle: true,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
initialGlobals: {
|
|
35
|
+
theme: 'system',
|
|
36
|
+
},
|
|
14
37
|
parameters: {
|
|
15
38
|
layout: 'padded',
|
|
16
39
|
|
|
@@ -71,6 +94,25 @@ const preview: Preview = {
|
|
|
71
94
|
}
|
|
72
95
|
},
|
|
73
96
|
decorators: [
|
|
97
|
+
// Theme toggle: mirrors the toolbar selection onto <html data-theme="…">,
|
|
98
|
+
// matching how consuming apps select a theme (see tokens.css). 'system'
|
|
99
|
+
// leaves the attribute unset so the prefers-color-scheme fallback applies —
|
|
100
|
+
// that is also the initial global, so the vitest story run renders
|
|
101
|
+
// identically to before the toggle existed.
|
|
102
|
+
(Story, context) => {
|
|
103
|
+
const theme = context.globals.theme as string | undefined;
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const root = document.documentElement;
|
|
107
|
+
if (!theme || theme === 'system') {
|
|
108
|
+
root.removeAttribute('data-theme');
|
|
109
|
+
} else {
|
|
110
|
+
root.setAttribute('data-theme', theme);
|
|
111
|
+
}
|
|
112
|
+
}, [theme]);
|
|
113
|
+
|
|
114
|
+
return <Story />;
|
|
115
|
+
},
|
|
74
116
|
(Story) => (
|
|
75
117
|
<MemoryRouter>
|
|
76
118
|
<Story />
|
package/AGENTS.md
CHANGED
|
@@ -54,6 +54,15 @@ Built-in React hooks (`useState`, `useEffect`, etc.) keep their stock
|
|
|
54
54
|
positional signatures — this rule applies only to hooks defined in this
|
|
55
55
|
package.
|
|
56
56
|
|
|
57
|
+
## Storybook theme toggle
|
|
58
|
+
|
|
59
|
+
The Storybook toolbar has a Theme control (paintbrush icon) listing all six
|
|
60
|
+
`tokens.css` themes plus "System (default)". It is wired in
|
|
61
|
+
`.storybook/preview.tsx` via `globalTypes` + a global decorator that sets
|
|
62
|
+
`data-theme` on `<html>` — the same mechanism consuming apps use. "System"
|
|
63
|
+
leaves the attribute unset (prefers-color-scheme fallback) and is the initial
|
|
64
|
+
value, so the vitest story run is unaffected by the toggle.
|
|
65
|
+
|
|
57
66
|
## Testing
|
|
58
67
|
|
|
59
68
|
### Story tests (the main gate)
|
package/README.md
CHANGED
|
@@ -53,7 +53,7 @@ export const SignupCard = () => {
|
|
|
53
53
|
};
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
Browse every component (with live controls) in the Storybook: `bun run storybook`.
|
|
56
|
+
Browse every component (with live controls) in the Storybook: `bun run storybook`. The toolbar's Theme control (paintbrush icon) previews any of the themes above — "System (default)" leaves `data-theme` unset, following system preference.
|
|
57
57
|
|
|
58
58
|
## Consumer notes
|
|
59
59
|
|
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
|
@@ -50,25 +50,71 @@ function composition.
|
|
|
50
50
|
|
|
51
51
|
## What a constraints key may map to
|
|
52
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
|
+
|
|
53
56
|
```ts
|
|
54
57
|
type FieldConstraint<F> =
|
|
58
|
+
// leaf forms — legal for ANY field type, structural fields included
|
|
55
59
|
| FieldValidator<F> // one validator
|
|
56
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: … }
|
|
57
64
|
```
|
|
58
65
|
|
|
59
66
|
The array form is the everyday way to stack validators on a field at the
|
|
60
|
-
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:
|
|
61
69
|
|
|
62
70
|
```ts
|
|
63
71
|
constraints: {
|
|
64
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
|
+
},
|
|
65
83
|
}
|
|
66
84
|
```
|
|
67
85
|
|
|
68
|
-
|
|
69
|
-
*reusable, named* composite validators): validators run in array
|
|
70
|
-
the first error is the field's error, and later validators don't run
|
|
71
|
-
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; a
|
|
108
|
+
"required list" is a leaf validator on the list field.
|
|
109
|
+
- **`F` stays naked in the structural arms.** Nullable sections/lists work
|
|
110
|
+
purely by distribution; wrapping the checked type in `NonNullable` breaks
|
|
111
|
+
it and blows the recursion stack (TS2589).
|
|
112
|
+
- **An `each` spec validates every element.** Failures are addressed with
|
|
113
|
+
the numeric index step (`['drivers', 3, 'name']`) — the same step
|
|
114
|
+
semantics as `read()`/`Path`, so `errorAt` and the bindings look element
|
|
115
|
+
errors up like any other path. Elements fail independently
|
|
116
|
+
(first-error-wins applies within one field's validator array, not across
|
|
117
|
+
elements); an empty list passes.
|
|
72
118
|
|
|
73
119
|
## Aggregation: `perField`
|
|
74
120
|
|
|
@@ -79,7 +125,11 @@ const constraints = perField({
|
|
|
79
125
|
```
|
|
80
126
|
|
|
81
127
|
`perField` is the entry point that produces a `Validations<FormType>`-shaped
|
|
82
|
-
value while preserving the precise types of each individual validator.
|
|
128
|
+
value while preserving the precise types of each individual validator. It
|
|
129
|
+
currently admits only the leaf forms (a validator or validator array per
|
|
130
|
+
key) — extending it to pre-built nested/`each` specs is plan phase 4
|
|
131
|
+
(composition hardening); until then, write structural constraints inline in
|
|
132
|
+
the `useFormState` call.
|
|
83
133
|
|
|
84
134
|
### The precision-preserving ceremony, by call-site shape
|
|
85
135
|
|
|
@@ -137,10 +187,11 @@ unreliable in some intersections.
|
|
|
137
187
|
|
|
138
188
|
## `Refine<FormType, typeof constraints>`
|
|
139
189
|
|
|
140
|
-
Walks the constraints object,
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
190
|
+
Walks the constraints object recursively, mirroring the grammar: leaves
|
|
191
|
+
apply `Exclude<FormType[K], __excludes>` from their validators' markers,
|
|
192
|
+
nested specs recurse into the section, `each` specs refine the element type
|
|
193
|
+
and flow it through `Array<...>`. Fields without a constraint pass through
|
|
194
|
+
unchanged, at every level. The result is the type handed to `onSubmit`.
|
|
144
195
|
|
|
145
196
|
```ts
|
|
146
197
|
type FormType = { a: string | undefined; b: number };
|
|
@@ -148,6 +199,18 @@ type C = typeof constraints; // a: notEmpty
|
|
|
148
199
|
type SubmitType = Refine<FormType, C>; // { a: string; b: number }
|
|
149
200
|
```
|
|
150
201
|
|
|
202
|
+
`Refine` opens with an identity gate — `Validations<T> extends V ? T :
|
|
203
|
+
RefineObject<T, V>` — and the gate is load-bearing, not an optimization:
|
|
204
|
+
with constraints omitted the hook's default `V = Validations<T>` would
|
|
205
|
+
distribute `FieldConstraint<F> | undefined` through the walk at every key
|
|
206
|
+
and hand back unions of structurally-identical mapped *copies* of every
|
|
207
|
+
section — assignable to `T` but not identity-equal (mangled hover types;
|
|
208
|
+
the "without constraints, onSubmit receives the unrefined form type" probe
|
|
209
|
+
fails). Any concrete constraints literal is strictly narrower than
|
|
210
|
+
`Validations<T>`, so the gate stays open and the real walk runs. Nested
|
|
211
|
+
occurrences don't need their own gate: a concrete literal's `keyof V` holds
|
|
212
|
+
only the keys actually written, so uncovered fields take `T[K]` verbatim.
|
|
213
|
+
|
|
151
214
|
Per-field dispatch lives in `RefineField<F, C>`, whose constraint parameter
|
|
152
215
|
is deliberately **naked** so unions of constraint types distribute. Two
|
|
153
216
|
regimes of "multiple markers per field" follow from that, both sound:
|
|
@@ -169,10 +232,17 @@ regimes of "multiple markers per field" follow from that, both sound:
|
|
|
169
232
|
per-branch refinements* (`Exclude<F, A> | Exclude<F, B>`), never a
|
|
170
233
|
narrowing the running branch didn't earn.
|
|
171
234
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
235
|
+
Both regimes survive recursion verbatim — the same soundness holds for
|
|
236
|
+
union-typed constraints inside nested and `each` specs (probed at depth in
|
|
237
|
+
`useFormState.test-d.ts`).
|
|
238
|
+
|
|
239
|
+
Branch order inside `RefineField`: `Refinement` first — validator functions
|
|
240
|
+
are objects, and a marked validator must not fall into a structural arm —
|
|
241
|
+
then arrays, then the structural arms, which interrogate the FIELD type
|
|
242
|
+
before the constraint's shape (`F extends FormValueList` is asked before
|
|
243
|
+
looking for an `each` key, so an object field literally named `each`
|
|
244
|
+
refines as a nested spec). A bare marker-less validator on a structural
|
|
245
|
+
field lands in `RefineObject` with an empty `keyof C` — an identity map.
|
|
176
246
|
|
|
177
247
|
## Hook surface
|
|
178
248
|
|
|
@@ -189,10 +259,16 @@ useFormState({
|
|
|
189
259
|
Validation failures surface as a structured list, not a keyed record:
|
|
190
260
|
|
|
191
261
|
```ts
|
|
192
|
-
type FormError<T> = { path: Path<T>; error: string }; //
|
|
262
|
+
type FormError<T> = { path: Path<T>; error: string }; // one entry per failing constrained node
|
|
193
263
|
type FormErrors<T> = readonly FormError<T>[]; // isValid === (errors.length === 0)
|
|
194
264
|
```
|
|
195
265
|
|
|
266
|
+
Paths are as deep as the failing node: a root leaf contributes `['email']`,
|
|
267
|
+
a nested-spec leaf `['homeAddress', 'postalCode']`, a leaf inside a list
|
|
268
|
+
element carries the numeric index step (`['drivers', 3, 'name']`). Sibling
|
|
269
|
+
nodes fail independently — first-error-wins applies *within* one validator
|
|
270
|
+
array, not across fields or list elements.
|
|
271
|
+
|
|
196
272
|
Read one field's message with the typed accessor, never by hand-assembled
|
|
197
273
|
keys — serialized path strings (`'drivers.0.name'`) are deliberately not
|
|
198
274
|
exposed:
|
|
@@ -390,9 +466,11 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
390
466
|
cheap); `isValid` is its emptiness, derived inline in the hook. Its
|
|
391
467
|
loop delegates per-entry semantics to `state/validations/walk.ts` and
|
|
392
468
|
carries two documented honest widenings: the `Object.keys` cast, and
|
|
393
|
-
`failure.path as Path<T>` — `[key]`
|
|
394
|
-
|
|
395
|
-
|
|
469
|
+
`failure.path as Path<T>` — the walk extends the `[key]` seed only
|
|
470
|
+
along keys of specs type-checked against `T`'s subtree, so every
|
|
471
|
+
returned path is a valid `Path<T>`, but TS cannot compute `Path<T>`
|
|
472
|
+
for an unresolved generic `T` to see the correlation. It also hosts
|
|
473
|
+
the cast-free `ConstraintEntry`
|
|
396
474
|
assignment that polices grammar growth (see the `state/validations/` bullet).
|
|
397
475
|
- `errorAt.ts` — the typed error lookup.
|
|
398
476
|
- `useFieldBinding.ts` — field binding: owns the touched list and builds
|
|
@@ -417,8 +495,8 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
417
495
|
bottom-right, portaled to `<body>`) that opens a live `JsonTable` view
|
|
418
496
|
of the form's internal state. Only an *open* debugger window
|
|
419
497
|
subscribes (`useSyncExternalStore`), so an unused Debugger costs
|
|
420
|
-
~nothing. `inspectable.ts` converts
|
|
421
|
-
can render.
|
|
498
|
+
~nothing. `inspectable.ts` converts Sets to string leaves `JsonTable`
|
|
499
|
+
can render (objects and arrays it renders natively).
|
|
422
500
|
- `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`,
|
|
423
501
|
`read()`), plus `write()` (the immutable-update mirror of `read()` behind
|
|
424
502
|
granular field writes) and `pathsEqual` (the one definition of structural
|
|
@@ -427,14 +505,20 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
427
505
|
Union handling is governed by the "Union policy" section above; the
|
|
428
506
|
policy's type-level guts (`IsDisallowedFormUnion`, `UnionPolicyCheck`,
|
|
429
507
|
`DisallowedFormUnion`) live with the value model in `state/useFormState/types.ts`.
|
|
430
|
-
- `state/validations/` — `perField`,
|
|
431
|
-
`
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
508
|
+
- `state/validations/` — `perField`, the recursive grammar
|
|
509
|
+
(`Validations<T>` / `FieldConstraint<F>` / `ListConstraint<E>`),
|
|
510
|
+
`Refine<T,V>`, `Refinement<>` infra, plus `walk.ts`, the recursive runtime
|
|
511
|
+
walk the hook delegates to (`validateEntry` — returns every failure in the
|
|
512
|
+
entry's subtree, addresses accumulated as `PathStep[]`s). `Validations<T>`
|
|
513
|
+
accepts bare `(val) => string | null` functions too — they simply narrow
|
|
514
|
+
nothing. The walk disambiguates a structural spec against the VALUE at
|
|
515
|
+
the path (array ⇒ `{ each }`, run against every element with the index
|
|
516
|
+
as the path step; object ⇒ nested spec; absent ⇒ skip), never against
|
|
517
|
+
the constraint's shape.
|
|
518
|
+
The walk's entry type (`ConstraintEntry`) is how the compiler polices
|
|
435
519
|
the error loop in `state/useFormState/deriveErrors.ts`: `constraints[key]` is
|
|
436
520
|
*assigned* to it, never cast,
|
|
437
|
-
so a grammar form the walk doesn't understand (a
|
|
521
|
+
so a grammar form the walk doesn't understand (a future `self` slot, say)
|
|
438
522
|
is a compile error at the assignment — a cast there would silently accept
|
|
439
523
|
new grammar and misinterpret it at runtime (e.g. call an array as a
|
|
440
524
|
function). Keep it cast-free. The one widening cast inside `validateEntry`
|