@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/eslint.config.mjs
CHANGED
|
@@ -7,8 +7,11 @@ import tsParser from '@typescript-eslint/parser';
|
|
|
7
7
|
// Component primitives live under src/components/<Section>/<Component>/.
|
|
8
8
|
// "Umbrella sections" (Modals, Chat) are themselves the primitive — their
|
|
9
9
|
// sub-folders share internals and must be reached through the section barrel.
|
|
10
|
-
// Plain sections (Layout, Content,
|
|
11
|
-
//
|
|
10
|
+
// Plain sections (Layout, Content, ...) treat each component folder as its
|
|
11
|
+
// own primitive. Form elements live under src/forms/elements/<Component>/ and
|
|
12
|
+
// follow the same per-folder primitive shape; the form-state layer
|
|
13
|
+
// (src/forms/state/) is one element with no barrel — its modules import each
|
|
14
|
+
// other file-directly.
|
|
12
15
|
//
|
|
13
16
|
// Three element types so rules can distinguish roles inside a primitive:
|
|
14
17
|
// - primitive-entry → the barrel (`<primitive>/index.{ts,tsx}`)
|
|
@@ -33,6 +36,34 @@ export default [{
|
|
|
33
36
|
},
|
|
34
37
|
'boundaries/include': ['src/**/*'],
|
|
35
38
|
'boundaries/elements': [
|
|
39
|
+
// Form-element primitives (src/forms/elements/<Component>/) — same
|
|
40
|
+
// shape and rules as plain-section component primitives.
|
|
41
|
+
{
|
|
42
|
+
type: 'primitive-entry',
|
|
43
|
+
pattern: 'src/forms/elements/*/index.{ts,tsx}',
|
|
44
|
+
mode: 'full',
|
|
45
|
+
capture: ['name'],
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
type: 'primitive-dogfood',
|
|
49
|
+
pattern: 'src/forms/elements/*/**/*.{stories,test}.{ts,tsx}',
|
|
50
|
+
mode: 'full',
|
|
51
|
+
capture: ['name'],
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
type: 'primitive',
|
|
55
|
+
pattern: 'src/forms/elements/*',
|
|
56
|
+
mode: 'folder',
|
|
57
|
+
capture: ['name'],
|
|
58
|
+
},
|
|
59
|
+
// The form-state layer, as a single element. No barrel: its modules
|
|
60
|
+
// (useFormState, validations, validators, path) import each other
|
|
61
|
+
// file-directly by design.
|
|
62
|
+
{
|
|
63
|
+
type: 'forms-state',
|
|
64
|
+
pattern: 'src/forms/state',
|
|
65
|
+
mode: 'folder',
|
|
66
|
+
},
|
|
36
67
|
// Plain-section component primitives (depth-3 under src/components)
|
|
37
68
|
{
|
|
38
69
|
type: 'primitive-entry',
|
|
@@ -100,6 +131,29 @@ export default [{
|
|
|
100
131
|
message:
|
|
101
132
|
"Files inside a primitive folder must not import their own index barrel; import the neighbor directly.",
|
|
102
133
|
},
|
|
134
|
+
// Presentational primitives (components and form elements) must
|
|
135
|
+
// not reach into the form-state layer. The sanctioned bridges run
|
|
136
|
+
// the other way: state's FormDebugger imports the JsonTable barrel
|
|
137
|
+
// (dev tooling), and the form-aware wrappers live on the state
|
|
138
|
+
// side (state/bindings/). State stories may also use element barrels.
|
|
139
|
+
{
|
|
140
|
+
from: [
|
|
141
|
+
{ type: 'primitive' },
|
|
142
|
+
{ type: 'primitive-entry' },
|
|
143
|
+
{ type: 'primitive-dogfood' },
|
|
144
|
+
],
|
|
145
|
+
disallow: [{ to: { type: 'forms-state' } }],
|
|
146
|
+
message:
|
|
147
|
+
'Form elements and components must not import the form-state layer; the bridge layer lives on the state side.',
|
|
148
|
+
},
|
|
149
|
+
// When state files import a component/element primitive (the
|
|
150
|
+
// sanctioned bridge direction), they go through its barrel like
|
|
151
|
+
// any other external importer.
|
|
152
|
+
{
|
|
153
|
+
from: [{ type: 'forms-state' }],
|
|
154
|
+
disallow: [{ to: { type: 'primitive' } }],
|
|
155
|
+
message: "External imports must go through the folder's index barrel.",
|
|
156
|
+
},
|
|
103
157
|
],
|
|
104
158
|
},
|
|
105
159
|
],
|
package/package.json
CHANGED
|
@@ -3,10 +3,10 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
|
3
3
|
import { ChatRecipientsHeader } from './index';
|
|
4
4
|
import type { PillComboboxOption } from '../PillCombobox/types';
|
|
5
5
|
import { Stack } from '../../Layout/Stack';
|
|
6
|
-
import { Textarea } from '
|
|
7
|
-
import { Input } from '
|
|
8
|
-
import { Field } from '
|
|
9
|
-
import { Button } from '
|
|
6
|
+
import { Textarea } from '../../../forms/elements/Textarea';
|
|
7
|
+
import { Input } from '../../../forms/elements/Input';
|
|
8
|
+
import { Field } from '../../../forms/elements/Field';
|
|
9
|
+
import { Button } from '../../../forms/elements/Button';
|
|
10
10
|
import { MediumModal } from '../../Modals';
|
|
11
11
|
import { Toggle } from '../../../storybook';
|
|
12
12
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
2
|
import { Bar } from './index';
|
|
3
|
-
import { Button } from '
|
|
3
|
+
import { Button } from '../../../forms/elements/Button';
|
|
4
4
|
import { Debug } from '../Debug';
|
|
5
5
|
|
|
6
6
|
const meta: Meta<typeof Bar> = {
|
|
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
|
|
2
2
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
3
3
|
import { Panels } from './index';
|
|
4
4
|
import { Bar } from '../Bar';
|
|
5
|
-
import { Button } from '
|
|
5
|
+
import { Button } from '../../../forms/elements/Button';
|
|
6
6
|
import { Text } from '../../Content/Text';
|
|
7
7
|
import { Heading } from '../../Content/Heading';
|
|
8
8
|
import { Stack } from '../Stack';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
2
|
import { LargeModal } from './index';
|
|
3
|
-
import { Button } from '
|
|
3
|
+
import { Button } from '../../../forms/elements/Button';
|
|
4
4
|
import { Stack } from '../../Layout/Stack';
|
|
5
5
|
import { Text } from '../../Content/Text';
|
|
6
6
|
import { Lorem, Toggle } from '../../../storybook';
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
2
|
import { MediumModal } from './index';
|
|
3
|
-
import { Button } from '
|
|
4
|
-
import { Field } from '
|
|
5
|
-
import { Input } from '
|
|
3
|
+
import { Button } from '../../../forms/elements/Button';
|
|
4
|
+
import { Field } from '../../../forms/elements/Field';
|
|
5
|
+
import { Input } from '../../../forms/elements/Input';
|
|
6
6
|
import { Stack } from '../../Layout/Stack';
|
|
7
7
|
import { Text } from '../../Content/Text';
|
|
8
8
|
import { Lorem, Toggle } from '../../../storybook';
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
2
|
import { useRef, useState } from 'react';
|
|
3
3
|
import { Popover } from './index';
|
|
4
|
-
import { Button } from '
|
|
5
|
-
import { Input } from '
|
|
6
|
-
import { Field } from '
|
|
4
|
+
import { Button } from '../../../forms/elements/Button';
|
|
5
|
+
import { Input } from '../../../forms/elements/Input';
|
|
6
|
+
import { Field } from '../../../forms/elements/Field';
|
|
7
7
|
import { Text } from '../../Content/Text';
|
|
8
8
|
const meta: Meta<typeof Popover> = {
|
|
9
9
|
title: 'Overlays/Popover',
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
2
|
import { Tooltip } from './index';
|
|
3
3
|
import type { TooltipPlacement } from './types';
|
|
4
|
-
import { Button } from '
|
|
4
|
+
import { Button } from '../../../forms/elements/Button';
|
|
5
5
|
|
|
6
6
|
const meta: Meta<typeof Tooltip> = {
|
|
7
7
|
title: 'Overlays/Tooltip',
|
|
@@ -17,7 +17,7 @@ import { LinedStack } from '../../Primitives/LinedStack';
|
|
|
17
17
|
import { LongText } from '../../Primitives/LongText';
|
|
18
18
|
import { Panels } from '../../Layout/Panels';
|
|
19
19
|
import { Bar } from '../../Layout/Bar';
|
|
20
|
-
import { Button } from '
|
|
20
|
+
import { Button } from '../../../forms/elements/Button';
|
|
21
21
|
import { Heading } from '../../Content/Heading';
|
|
22
22
|
import { Stack } from '../../Layout/Stack';
|
|
23
23
|
|
package/src/forms/CLAUDE.md
CHANGED
|
@@ -1,9 +1,25 @@
|
|
|
1
1
|
# src/forms
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
The forms umbrella, split into two named subtrees:
|
|
4
|
+
|
|
5
|
+
- `elements/` — the presentational form elements (`Input`, `Field`, `Select`,
|
|
6
|
+
`Button`, `Textarea`, …), one component primitive per folder, same shape
|
|
7
|
+
and eslint-boundaries rules as `src/components/<Section>/<Component>/`.
|
|
8
|
+
Elements are state-free: they must not import the state layer (enforced by
|
|
9
|
+
eslint `boundaries`).
|
|
10
|
+
- `state/` — the strongly-typed React form state hook and its machinery.
|
|
11
|
+
Everything below this heading is about the state layer.
|
|
12
|
+
|
|
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.
|
|
18
|
+
|
|
19
|
+
The headline feature of the state layer is **validation that propagates type
|
|
20
|
+
refinements to the submit handler** — passing the right constraints means the
|
|
21
|
+
submit handler's `values` argument has a tighter type than the initial form
|
|
22
|
+
values.
|
|
7
23
|
|
|
8
24
|
## Mental model
|
|
9
25
|
|
|
@@ -185,12 +201,75 @@ exposed:
|
|
|
185
201
|
errorAt(errors, ['email']); // string | undefined
|
|
186
202
|
```
|
|
187
203
|
|
|
188
|
-
`errorAt` (useFormState/errorAt.ts) matches by exact structural step
|
|
189
|
-
equality (no prefix matching); with
|
|
190
|
-
most one entry per path, and if
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
build on.
|
|
204
|
+
`errorAt` (state/useFormState/errorAt.ts) matches by exact structural step
|
|
205
|
+
equality (`pathsEqual` in state/path/path.ts — no prefix matching); with
|
|
206
|
+
first-error-wins validation there is at most one entry per path, and if
|
|
207
|
+
collect-all ever lands the first entry stays the one shown. The
|
|
208
|
+
path-addressed model is what the recursive grammar (errors just carry
|
|
209
|
+
longer paths) and `getFormFieldPropsAt`'s `errorMessage` build on. `errorAt`
|
|
210
|
+
is raw truth (no display gating); `errorMessage` is the display-policy-aware
|
|
211
|
+
reading (see "Field binding").
|
|
212
|
+
|
|
213
|
+
## Field binding: `getFormFieldPropsAt(path)` + `state/bindings/`
|
|
214
|
+
|
|
215
|
+
One expression wires a field to the form:
|
|
216
|
+
|
|
217
|
+
```tsx
|
|
218
|
+
<TextInputForForm label="City"
|
|
219
|
+
formFieldProps={getFormFieldPropsAt(['homeAddress', 'city'])} />
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`getFormFieldPropsAt` is a member of the hook's return value, typed by the
|
|
223
|
+
`state/path/` machinery:
|
|
224
|
+
|
|
225
|
+
```ts
|
|
226
|
+
// on FormHelpers<T>:
|
|
227
|
+
getFormFieldPropsAt<P extends Path<T>>(path: P): FormFieldProps<ValueAt<T, P>>;
|
|
228
|
+
|
|
229
|
+
type FormFieldProps<V> = FieldBinding<V>;
|
|
230
|
+
type FieldBinding<Display, Emit = Display> = {
|
|
231
|
+
value: Display; // read(values, path)
|
|
232
|
+
onChange: (val: Emit) => void; // immutable write(values, path, val)
|
|
233
|
+
errorMessage: string | undefined; // display-policy-aware (below)
|
|
234
|
+
onBlur: () => void; // feeds touched tracking
|
|
235
|
+
};
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
The pieces, and where each one's semantics live:
|
|
239
|
+
|
|
240
|
+
- **Granular path writes** — `write()` in `state/path/path.ts`, the
|
|
241
|
+
immutable-update mirror of `read()`: clones only the spine to the written
|
|
242
|
+
leaf, so untouched siblings keep identity. Dead-step semantics mirror
|
|
243
|
+
read() returning undefined: writing through an absent/null ancestor, a
|
|
244
|
+
shape mismatch, or an out-of-bounds index is an identity-preserving no-op
|
|
245
|
+
— you can't edit a field of an absent section; materialize the section
|
|
246
|
+
first. (Appending/splicing lists is item-8 territory, not a path write.)
|
|
247
|
+
`onValueChanges` (whole-value replacement) still exists but is no longer
|
|
248
|
+
the only write path.
|
|
249
|
+
- **Touched tracking** — real state in `useFieldBinding`: a `Path<T>[]`
|
|
250
|
+
compared with `pathsEqual`, fed by `onBlur`, visible in the Debugger
|
|
251
|
+
snapshot. Commit-style elements (selects) call `onBlur` on commit — the
|
|
252
|
+
commit IS their blur moment.
|
|
253
|
+
- **THE error-display policy** lives inside `errorMessage` and nowhere
|
|
254
|
+
else: show a field's error once the field is touched OR a submit has been
|
|
255
|
+
attempted. Elements render what they're given and stay policy-free. A UI
|
|
256
|
+
wanting a different policy (e.g. always-live) reads `errors`/`errorAt`
|
|
257
|
+
directly.
|
|
258
|
+
|
|
259
|
+
### The element wrappers (`state/bindings/`)
|
|
260
|
+
|
|
261
|
+
The **wrapper style** is the prototyped shorthand (per the plan's deciding
|
|
262
|
+
criterion — simplest for agents to work with): `TextInputForForm` and
|
|
263
|
+
`SingleSelectForForm` compose `Field` (label/hint/error presentation)
|
|
264
|
+
around the element and accept the bundle as one `formFieldProps` prop.
|
|
265
|
+
The wrapper declares what it can display and emit via
|
|
266
|
+
`FieldBinding<Display, Emit>` — e.g. the text input takes
|
|
267
|
+
`FieldBinding<string | null | undefined, string>` — and a
|
|
268
|
+
`FormFieldProps<V>` is assignable exactly when `Emit ⊆ V ⊆ Display`. That
|
|
269
|
+
one structural check is the end-to-end type safety: binding a number- or
|
|
270
|
+
boolean-typed path (or a literal-union field, for free text) to a
|
|
271
|
+
text-shaped element fails to compile at the `formFieldProps` prop, with no
|
|
272
|
+
generics at the use site.
|
|
194
273
|
|
|
195
274
|
## Union policy — what form state may hold
|
|
196
275
|
|
|
@@ -222,7 +301,7 @@ in three layers:
|
|
|
222
301
|
1. **At the `useFormState` boundary**: an illegal form type makes the call
|
|
223
302
|
fail with an unsatisfiable `'ERROR: form state disallows unions of
|
|
224
303
|
objects/lists …'` property whose type names the offending keys
|
|
225
|
-
(`UnionPolicyCheck<T>` in `useFormState/types.ts`).
|
|
304
|
+
(`UnionPolicyCheck<T>` in `state/useFormState/types.ts`).
|
|
226
305
|
2. **`Path<T>` refuses to descend** into a disallowed union — the field
|
|
227
306
|
stays addressable as a leaf (reading it yields the union, which is
|
|
228
307
|
honest), but no paths below it exist, so `.at(…)`/path literals into it
|
|
@@ -292,12 +371,13 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
292
371
|
|
|
293
372
|
## Layout
|
|
294
373
|
|
|
295
|
-
- `useFormState/` — the hook + the value-model types (`FormValueSimple`,
|
|
374
|
+
- `state/useFormState/` — the hook + the value-model types (`FormValueSimple`,
|
|
296
375
|
`FormValuesObject`, `FormValueList`). Hook surface: `{ values,
|
|
297
|
-
onValueChanges, errors, isValid, submitAttempted, submit,
|
|
376
|
+
onValueChanges, errors, isValid, submitAttempted, submit,
|
|
377
|
+
getFormFieldPropsAt, Debugger }`.
|
|
298
378
|
`useFormState.ts` itself is deliberately **plumbing only**: it holds the
|
|
299
|
-
values `useState` (a bare `useState
|
|
300
|
-
|
|
379
|
+
values `useState` (a bare `useState`; the granular path writes layer on
|
|
380
|
+
top of its setter via `useFieldBinding`), links up the
|
|
301
381
|
modules below, and recomposes their outputs into `FormHelpers<T>`. Each
|
|
302
382
|
slice lives in its own file so it is independently comprehensible and
|
|
303
383
|
unit-testable at its own boundary:
|
|
@@ -308,13 +388,19 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
308
388
|
(React-free) derivation of the structured `FormErrors<T>` list (see
|
|
309
389
|
"The error model"), re-run every render (validators are pure and
|
|
310
390
|
cheap); `isValid` is its emptiness, derived inline in the hook. Its
|
|
311
|
-
loop delegates per-entry semantics to `validations/walk.ts` and
|
|
391
|
+
loop delegates per-entry semantics to `state/validations/walk.ts` and
|
|
312
392
|
carries two documented honest widenings: the `Object.keys` cast, and
|
|
313
393
|
`failure.path as Path<T>` — `[key]` is a valid single-key `Path<T>`,
|
|
314
394
|
but TS cannot compute `Path<T>` for an unresolved generic `T` to see
|
|
315
395
|
the correlation. It also hosts the cast-free `FlatConstraintEntry`
|
|
316
|
-
assignment that polices grammar growth (see the `validations/` bullet).
|
|
396
|
+
assignment that polices grammar growth (see the `state/validations/` bullet).
|
|
317
397
|
- `errorAt.ts` — the typed error lookup.
|
|
398
|
+
- `useFieldBinding.ts` — field binding: owns the touched list and builds
|
|
399
|
+
`getFormFieldPropsAt` (see "Field binding" above — the error-display
|
|
400
|
+
policy lives here). Two documented honest casts correlate `read()`/
|
|
401
|
+
`write()` results with `ValueAt<T, P>`/`T` for generic `T`; both are
|
|
402
|
+
truths the runtime upholds by construction, same family as `errorAt`'s
|
|
403
|
+
path widening.
|
|
318
404
|
- `useFormSubmit.ts` — submit orchestration: owns `submitAttempted`
|
|
319
405
|
(lets UIs gate error display) and the validity gate in front of
|
|
320
406
|
`onSubmit`; its `submit()` performs the one honest *refinement* cast
|
|
@@ -333,18 +419,20 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
333
419
|
subscribes (`useSyncExternalStore`), so an unused Debugger costs
|
|
334
420
|
~nothing. `inspectable.ts` converts arrays/Sets to shapes `JsonTable`
|
|
335
421
|
can render.
|
|
336
|
-
- `path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`,
|
|
337
|
-
|
|
338
|
-
|
|
422
|
+
- `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`,
|
|
423
|
+
`read()`), plus `write()` (the immutable-update mirror of `read()` behind
|
|
424
|
+
granular field writes) and `pathsEqual` (the one definition of structural
|
|
425
|
+
path equality — `errorAt` and touched tracking both use it). Coupled to
|
|
426
|
+
the form value model intentionally.
|
|
339
427
|
Union handling is governed by the "Union policy" section above; the
|
|
340
428
|
policy's type-level guts (`IsDisallowedFormUnion`, `UnionPolicyCheck`,
|
|
341
|
-
`DisallowedFormUnion`) live with the value model in `useFormState/types.ts`.
|
|
342
|
-
- `validations/` — `perField`, `Validations<T>` / `FieldConstraint<F>`,
|
|
429
|
+
`DisallowedFormUnion`) live with the value model in `state/useFormState/types.ts`.
|
|
430
|
+
- `state/validations/` — `perField`, `Validations<T>` / `FieldConstraint<F>`,
|
|
343
431
|
`Refine<T,V>`, `Refinement<>` infra, plus `walk.ts`, the runtime walk the
|
|
344
432
|
hook delegates to (`validateEntry`). `Validations<T>` accepts bare
|
|
345
433
|
`(val) => string | null` functions too — they simply narrow nothing.
|
|
346
434
|
The walk's entry type (`FlatConstraintEntry`) is how the compiler polices
|
|
347
|
-
the error loop in `useFormState/deriveErrors.ts`: `constraints[key]` is
|
|
435
|
+
the error loop in `state/useFormState/deriveErrors.ts`: `constraints[key]` is
|
|
348
436
|
*assigned* to it, never cast,
|
|
349
437
|
so a grammar form the walk doesn't understand (a nested spec, an `each`)
|
|
350
438
|
is a compile error at the assignment — a cast there would silently accept
|
|
@@ -352,7 +440,10 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
352
440
|
function). Keep it cast-free. The one widening cast inside `validateEntry`
|
|
353
441
|
mirrors `allOf`'s part-call: honest contravariant widening of a single,
|
|
354
442
|
already-normalized validator.
|
|
355
|
-
- `
|
|
443
|
+
- `state/bindings/` — the form-aware element wrappers (`TextInputForForm`,
|
|
444
|
+
`SingleSelectForForm`), the second sanctioned state→elements bridge (see
|
|
445
|
+
"Field binding" above). Element imports go through the element barrels.
|
|
446
|
+
- `state/validators/` — the standard-library validators (`notEmpty`, `minLength`,
|
|
356
447
|
`matches`, `min`) plus `allOf`, which composes validators on one field and
|
|
357
448
|
carries the union of the parts' refinements. `allOf` derives its input type
|
|
358
449
|
as the intersection of the parts' inputs from the const tuple (not a
|
|
@@ -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;
|
|
@@ -2,8 +2,8 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
|
2
2
|
import { useState } from 'react';
|
|
3
3
|
import { MultiSelect } from './index';
|
|
4
4
|
import { Field } from '../../Field';
|
|
5
|
-
import { Stack } from '
|
|
6
|
-
import { Text } from '
|
|
5
|
+
import { Stack } from '../../../../components/Layout/Stack';
|
|
6
|
+
import { Text } from '../../../../components/Content/Text';
|
|
7
7
|
|
|
8
8
|
const meta: Meta<typeof MultiSelect> = {
|
|
9
9
|
title: 'Forms/MultiSelect',
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useRef, useState } from 'react';
|
|
2
|
-
import { Popover } from '
|
|
2
|
+
import { Popover } from '../../../../components/Overlays/Popover';
|
|
3
3
|
import { OptionList } from '../internal/OptionList';
|
|
4
4
|
import { SelectTrigger } from '../internal/SelectTrigger';
|
|
5
5
|
import type { MultiSelectProps } from './types';
|
package/src/{components/Forms → forms/elements}/Select/SingleSelect/SingleSelect.stories.tsx
RENAMED
|
@@ -3,9 +3,9 @@ import { useState } from 'react';
|
|
|
3
3
|
import { SingleSelect } from './index';
|
|
4
4
|
import { Button } from '../../Button';
|
|
5
5
|
import { Field } from '../../Field';
|
|
6
|
-
import { MediumModal } from '
|
|
7
|
-
import { Stack } from '
|
|
8
|
-
import { Text } from '
|
|
6
|
+
import { MediumModal } from '../../../../components/Modals';
|
|
7
|
+
import { Stack } from '../../../../components/Layout/Stack';
|
|
8
|
+
import { Text } from '../../../../components/Content/Text';
|
|
9
9
|
import { Toggle } from '../../../../storybook';
|
|
10
10
|
|
|
11
11
|
const meta: Meta<typeof SingleSelect> = {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useRef, useState } from 'react';
|
|
2
|
-
import { Popover } from '
|
|
2
|
+
import { Popover } from '../../../../components/Overlays/Popover';
|
|
3
3
|
import { OptionList } from '../internal/OptionList';
|
|
4
4
|
import { SelectTrigger } from '../internal/SelectTrigger';
|
|
5
5
|
import type { SingleSelectProps } from './types';
|