@structuralists/scaffolding 0.10.0 → 0.10.2
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 +67 -31
- 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}/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 +42 -32
- package/src/forms/{useFormState → state/useFormState}/FormDebugger.tsx +3 -2
- package/src/forms/state/useFormState/deriveErrors.test.ts +76 -0
- package/src/forms/state/useFormState/deriveErrors.ts +35 -0
- package/src/forms/{useFormState → state/useFormState}/types.ts +2 -1
- package/src/forms/state/useFormState/useFormDebugger.test.tsx +57 -0
- package/src/forms/state/useFormState/useFormDebugger.ts +38 -0
- package/src/forms/{useFormState → state/useFormState}/useFormState.stories.tsx +4 -4
- package/src/forms/state/useFormState/useFormState.ts +69 -0
- package/src/forms/state/useFormState/useFormSubmit.test.tsx +86 -0
- package/src/forms/state/useFormState/useFormSubmit.ts +38 -0
- package/src/forms/{validations → state/validations}/walk.ts +3 -2
- 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/useFormState/useFormState.ts +0 -109
- /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/index.tsx +0 -0
- /package/src/{components/Forms → forms/elements}/Input/styles.module.css +0 -0
- /package/src/{components/Forms → forms/elements}/Input/types.ts +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}/path.test.ts +0 -0
- /package/src/forms/{path → state/path}/path.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}/errorAt.test.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/errorAt.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}/useFormState.test-d.ts +0 -0
- /package/src/forms/{useFormState → state/useFormState}/useFormState.test.tsx +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/{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 bridge runs
|
|
136
|
+
// the other way: state's FormDebugger imports the JsonTable barrel
|
|
137
|
+
// (dev tooling), and the item-6 form-aware wrappers will live with
|
|
138
|
+
// the state side. 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,24 @@
|
|
|
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
|
+
The one sanctioned state→elements-side dependency is the Debugger bridge:
|
|
14
|
+
`state/useFormState/FormDebugger.tsx` imports the `JsonTable` barrel from
|
|
15
|
+
`src/components/Json/` (dev tooling). The item-6 form-aware wrappers will be
|
|
16
|
+
the second bridge, living on the state side.
|
|
17
|
+
|
|
18
|
+
The headline feature of the state layer is **validation that propagates type
|
|
19
|
+
refinements to the submit handler** — passing the right constraints means the
|
|
20
|
+
submit handler's `values` argument has a tighter type than the initial form
|
|
21
|
+
values.
|
|
7
22
|
|
|
8
23
|
## Mental model
|
|
9
24
|
|
|
@@ -185,7 +200,7 @@ exposed:
|
|
|
185
200
|
errorAt(errors, ['email']); // string | undefined
|
|
186
201
|
```
|
|
187
202
|
|
|
188
|
-
`errorAt` (useFormState/errorAt.ts) matches by exact structural step
|
|
203
|
+
`errorAt` (state/useFormState/errorAt.ts) matches by exact structural step
|
|
189
204
|
equality (no prefix matching); with first-error-wins validation there is at
|
|
190
205
|
most one entry per path, and if collect-all ever lands the first entry stays
|
|
191
206
|
the one shown. The path-addressed model is what the recursive grammar
|
|
@@ -222,7 +237,7 @@ in three layers:
|
|
|
222
237
|
1. **At the `useFormState` boundary**: an illegal form type makes the call
|
|
223
238
|
fail with an unsatisfiable `'ERROR: form state disallows unions of
|
|
224
239
|
objects/lists …'` property whose type names the offending keys
|
|
225
|
-
(`UnionPolicyCheck<T>` in `useFormState/types.ts`).
|
|
240
|
+
(`UnionPolicyCheck<T>` in `state/useFormState/types.ts`).
|
|
226
241
|
2. **`Path<T>` refuses to descend** into a disallowed union — the field
|
|
227
242
|
stays addressable as a leaf (reading it yields the union, which is
|
|
228
243
|
honest), but no paths below it exist, so `.at(…)`/path literals into it
|
|
@@ -292,46 +307,67 @@ precision end-to-end. So we wire it up before adding any consumers.
|
|
|
292
307
|
|
|
293
308
|
## Layout
|
|
294
309
|
|
|
295
|
-
- `useFormState/` — the hook + the value-model types (`FormValueSimple`,
|
|
310
|
+
- `state/useFormState/` — the hook + the value-model types (`FormValueSimple`,
|
|
296
311
|
`FormValuesObject`, `FormValueList`). Hook surface: `{ values,
|
|
297
|
-
onValueChanges, errors, isValid, submitAttempted, submit, Debugger }
|
|
298
|
-
`
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
312
|
+
onValueChanges, errors, isValid, submitAttempted, submit, Debugger }`.
|
|
313
|
+
`useFormState.ts` itself is deliberately **plumbing only**: it holds the
|
|
314
|
+
values `useState` (a bare `useState` today; granular path-based setters
|
|
315
|
+
are what would earn values state a module of its own), links up the
|
|
316
|
+
modules below, and recomposes their outputs into `FormHelpers<T>`. Each
|
|
317
|
+
slice lives in its own file so it is independently comprehensible and
|
|
318
|
+
unit-testable at its own boundary:
|
|
319
|
+
- `types.ts` — the value model, the union-policy gate
|
|
320
|
+
(`UnionPolicyCheck` and friends), the `FormError`/`FormErrors` model,
|
|
321
|
+
`FormDebugSnapshot`, `FormHelpers`.
|
|
322
|
+
- `deriveErrors.ts` — `deriveFormErrors(values, constraints)`, the pure
|
|
323
|
+
(React-free) derivation of the structured `FormErrors<T>` list (see
|
|
324
|
+
"The error model"), re-run every render (validators are pure and
|
|
325
|
+
cheap); `isValid` is its emptiness, derived inline in the hook. Its
|
|
326
|
+
loop delegates per-entry semantics to `state/validations/walk.ts` and
|
|
327
|
+
carries two documented honest widenings: the `Object.keys` cast, and
|
|
328
|
+
`failure.path as Path<T>` — `[key]` is a valid single-key `Path<T>`,
|
|
329
|
+
but TS cannot compute `Path<T>` for an unresolved generic `T` to see
|
|
330
|
+
the correlation. It also hosts the cast-free `FlatConstraintEntry`
|
|
331
|
+
assignment that polices grammar growth (see the `state/validations/` bullet).
|
|
332
|
+
- `errorAt.ts` — the typed error lookup.
|
|
333
|
+
- `useFormSubmit.ts` — submit orchestration: owns `submitAttempted`
|
|
334
|
+
(lets UIs gate error display) and the validity gate in front of
|
|
335
|
+
`onSubmit`; its `submit()` performs the one honest *refinement* cast
|
|
336
|
+
to `Refine<T, V>` — earned because the validators just passed at
|
|
337
|
+
runtime. `V` is not inferable from `onSubmit`'s parameter position,
|
|
338
|
+
so `useFormState` applies `<T, V>` explicitly.
|
|
339
|
+
- `useFormDebugger.ts` — debugger plumbing: creates one `snapshotStore`
|
|
340
|
+
+ one Debugger component per hook instance (lazy ref — the component's
|
|
341
|
+
identity must stay stable across renders or it would remount, and lose
|
|
342
|
+
its open/closed state, on every keystroke) and publishes the
|
|
343
|
+
`FormDebugSnapshot` after every commit.
|
|
344
|
+
- `FormDebugger.tsx` / `snapshotStore.ts` / `inspectable.ts` — the
|
|
345
|
+
`Debugger` itself: a per-instance dev-time overlay (fixed trigger,
|
|
346
|
+
bottom-right, portaled to `<body>`) that opens a live `JsonTable` view
|
|
347
|
+
of the form's internal state. Only an *open* debugger window
|
|
348
|
+
subscribes (`useSyncExternalStore`), so an unused Debugger costs
|
|
349
|
+
~nothing. `inspectable.ts` converts arrays/Sets to shapes `JsonTable`
|
|
350
|
+
can render.
|
|
351
|
+
- `state/path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
|
|
317
352
|
Will be used for granular setters and for surfacing per-field
|
|
318
353
|
errors/touched state. Coupled to the form value model intentionally.
|
|
319
354
|
Union handling is governed by the "Union policy" section above; the
|
|
320
355
|
policy's type-level guts (`IsDisallowedFormUnion`, `UnionPolicyCheck`,
|
|
321
|
-
`DisallowedFormUnion`) live with the value model in `useFormState/types.ts`.
|
|
322
|
-
- `validations/` — `perField`, `Validations<T>` / `FieldConstraint<F>`,
|
|
356
|
+
`DisallowedFormUnion`) live with the value model in `state/useFormState/types.ts`.
|
|
357
|
+
- `state/validations/` — `perField`, `Validations<T>` / `FieldConstraint<F>`,
|
|
323
358
|
`Refine<T,V>`, `Refinement<>` infra, plus `walk.ts`, the runtime walk the
|
|
324
359
|
hook delegates to (`validateEntry`). `Validations<T>` accepts bare
|
|
325
360
|
`(val) => string | null` functions too — they simply narrow nothing.
|
|
326
361
|
The walk's entry type (`FlatConstraintEntry`) is how the compiler polices
|
|
327
|
-
the
|
|
362
|
+
the error loop in `state/useFormState/deriveErrors.ts`: `constraints[key]` is
|
|
363
|
+
*assigned* to it, never cast,
|
|
328
364
|
so a grammar form the walk doesn't understand (a nested spec, an `each`)
|
|
329
365
|
is a compile error at the assignment — a cast there would silently accept
|
|
330
366
|
new grammar and misinterpret it at runtime (e.g. call an array as a
|
|
331
367
|
function). Keep it cast-free. The one widening cast inside `validateEntry`
|
|
332
368
|
mirrors `allOf`'s part-call: honest contravariant widening of a single,
|
|
333
369
|
already-normalized validator.
|
|
334
|
-
- `validators/` — the standard-library validators (`notEmpty`, `minLength`,
|
|
370
|
+
- `state/validators/` — the standard-library validators (`notEmpty`, `minLength`,
|
|
335
371
|
`matches`, `min`) plus `allOf`, which composes validators on one field and
|
|
336
372
|
carries the union of the parts' refinements. `allOf` derives its input type
|
|
337
373
|
as the intersection of the parts' inputs from the const tuple (not a
|
|
@@ -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';
|
package/src/forms/plan.md
CHANGED
|
@@ -16,23 +16,25 @@ 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.
|
|
37
|
+
error model and the split; `Path`/`ValueAt` already validated. ← *current*
|
|
36
38
|
6. **Type spike, then items 2/3/4** — the recursion risk zone. Throwaway
|
|
37
39
|
`.test-d.ts` of the full grammar at `InsuranceQuoteForm` scale *before*
|
|
38
40
|
any runtime work; three outcomes (works / slow / intractable) each with a
|
|
@@ -123,7 +125,7 @@ useFormState({
|
|
|
123
125
|
```ts
|
|
124
126
|
type ExcludedOf<V> = V extends Refinement<infer X> ? X : never;
|
|
125
127
|
// 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.
|
|
128
|
+
// excludes — it and SoundExcludedOf landed with phase 1 in state/validations/types.ts.
|
|
127
129
|
|
|
128
130
|
type RefineField<F, C> =
|
|
129
131
|
C extends Refinement<infer X> ? Exclude<F, X> // single marked validator
|
|
@@ -189,7 +191,7 @@ multi-second check time:
|
|
|
189
191
|
## Runtime consequences (can't be dodged)
|
|
190
192
|
|
|
191
193
|
- **The validator walk becomes a tree walk.** Recursion mirrors `read()` in
|
|
192
|
-
`path/path.ts`; share the traversal or keep them deliberately parallel.
|
|
194
|
+
`state/path/path.ts`; share the traversal or keep them deliberately parallel.
|
|
193
195
|
- **`FormErrors` can no longer be `Partial<Record<keyof T, string>>`.**
|
|
194
196
|
✅ *Landed with working-order step 3* (on the flat baseline, single-key
|
|
195
197
|
paths). Decided: errors become a plain list of structured entries,
|
|
@@ -199,7 +201,7 @@ multi-second check time:
|
|
|
199
201
|
// errors: FormError<T>[] isValid: errors.length === 0
|
|
200
202
|
```
|
|
201
203
|
|
|
202
|
-
reusing `path/`'s typed representation (this is the "surfacing per-field
|
|
204
|
+
reusing `state/path/`'s typed representation (this is the "surfacing per-field
|
|
203
205
|
errors" role path/ was built for). **We will never expose
|
|
204
206
|
`'drivers.0.name'`-style serialized-string key derivation.** At the scale
|
|
205
207
|
these forms operate, a linear scan over `{path, error}[]` is fine. If fast
|
|
@@ -208,7 +210,7 @@ multi-second check time:
|
|
|
208
210
|
fully encapsulated and opaque to everything outside it.
|
|
209
211
|
- **`errors` display wiring** in stories/components goes through a typed
|
|
210
212
|
accessor, never through hand-assembled keys. ✅ *Decided and landed with
|
|
211
|
-
step 3*: a standalone `errorAt(errors, path)` (useFormState/errorAt.ts) —
|
|
213
|
+
step 3*: a standalone `errorAt(errors, path)` (state/useFormState/errorAt.ts) —
|
|
212
214
|
structural step-equality lookup, first entry wins. Chosen over a
|
|
213
215
|
cursor-based lookup as the smallest surface that keeps stories readable
|
|
214
216
|
on the flat grammar; item 6's `errorMessage` reuses it internally.
|
|
@@ -228,7 +230,7 @@ tests, story updates where visible, probe ratchet.
|
|
|
228
230
|
marker computation. ✅ *done* — plus a byproduct: `RefineField`
|
|
229
231
|
distributes over a naked constraint parameter, which made union-typed
|
|
230
232
|
*single* constraints (`cond ? v1 : v2`) sound too (union of per-branch
|
|
231
|
-
refinements, pinned in `validations/types.test-d.ts`).
|
|
233
|
+
refinements, pinned in `state/validations/types.test-d.ts`).
|
|
232
234
|
2. **Nested object specs + recursive `Refine`.** The risk phase — the probe
|
|
233
235
|
ratchet matters most here. (The `{path, error}[]` error model already
|
|
234
236
|
landed in working-order step 3 — nested errors just carry longer paths;
|
|
@@ -240,30 +242,38 @@ tests, story updates where visible, probe ratchet.
|
|
|
240
242
|
`perField` still the entry point for pre-built specs, docs
|
|
241
243
|
(forms/CLAUDE.md) updated to the new grammar.
|
|
242
244
|
|
|
243
|
-
## 5. Split forms into 'form elements' and 'form state'
|
|
245
|
+
## 5. Split forms into 'form elements' and 'form state' ✅ done
|
|
244
246
|
|
|
245
|
-
|
|
247
|
+
The form world used to live in two places with unrelated names
|
|
248
|
+
(`src/components/Forms/` vs `src/forms/` — an accidental `Forms`-vs-`forms`
|
|
249
|
+
distinction). The layout decision landed on **one `src/forms/` umbrella with
|
|
250
|
+
two named subtrees**:
|
|
246
251
|
|
|
247
|
-
- `src/
|
|
248
|
-
`Field`, `Select`, `Button`, `Textarea`, …)
|
|
249
|
-
|
|
252
|
+
- `src/forms/elements/` — the presentational **elements** (`Input`,
|
|
253
|
+
`Field`, `Select`, `Button`, `Textarea`, …), one primitive per folder,
|
|
254
|
+
same shape as `src/components/<Section>/<Component>/`
|
|
255
|
+
- `src/forms/state/` — the **state** layer (`useFormState/`, `validations/`,
|
|
250
256
|
`validators/`, `path/`)
|
|
251
257
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
-
|
|
261
|
-
|
|
262
|
-
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
258
|
+
Blast radius that was handled when this landed:
|
|
259
|
+
|
|
260
|
+
- the eslint `boundaries` element patterns in `eslint.config.mjs` (were keyed
|
|
261
|
+
to `src/components/<Section>/<Component>/` shapes): element primitives got
|
|
262
|
+
matching patterns at their new home, `src/forms/state` became its own
|
|
263
|
+
element type, and two directional rules were added — elements/components
|
|
264
|
+
must not import the state layer, and state files importing a primitive go
|
|
265
|
+
through its barrel,
|
|
266
|
+
- barrel exports in `src/index.ts` (all the element components are public —
|
|
267
|
+
paths moved, exported surface unchanged),
|
|
268
|
+
- Storybook titles (`Forms/...` prefix) and story globs: titles kept as-is —
|
|
269
|
+
elements stay `Forms/<Component>`, the state story stays
|
|
270
|
+
`Forms/useFormState`; the story glob (`src/**/*.stories.*`) needed no
|
|
271
|
+
change,
|
|
272
|
+
- forms/CLAUDE.md and this plan's own paths,
|
|
273
|
+
- `FormDebugger.tsx` imports `JsonTable` from `src/components/Json/` — the
|
|
274
|
+
sanctioned state→elements bridge dependency (audit finding 3.3; the
|
|
275
|
+
Debugger is a designated bridge layer). Explicitly allowed for in the new
|
|
276
|
+
boundaries rules.
|
|
267
277
|
|
|
268
278
|
## 6. Field binding: `getFormFieldPropsAt(path)` + element shorthands
|
|
269
279
|
|
|
@@ -278,7 +288,7 @@ four decisions per field, all boilerplate. Target usage:
|
|
|
278
288
|
### `getFormFieldPropsAt(path)`
|
|
279
289
|
|
|
280
290
|
A member of the hook's return value (it needs `values`, `errors`, and the
|
|
281
|
-
setter), typed by the `path/` machinery:
|
|
291
|
+
setter), typed by the `state/path/` machinery:
|
|
282
292
|
|
|
283
293
|
```ts
|
|
284
294
|
type FormFieldProps<V> = {
|
|
@@ -297,7 +307,7 @@ Consequences this pulls in deliberately:
|
|
|
297
307
|
|
|
298
308
|
- **Granular setters via path arrive here** — this retires the "likely
|
|
299
309
|
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`.
|
|
310
|
+
is the immutable-update mirror of `read()` in `state/path/path.ts`.
|
|
301
311
|
- **Touched tracking becomes real state** (`onBlur` feeds it). The
|
|
302
312
|
error-display policy (submitAttempted / touched gating) moves *inside*
|
|
303
313
|
`errorMessage`, so consuming elements render what they're given and stay
|
|
@@ -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';
|
|
@@ -16,7 +16,8 @@ export type FormDebuggerProps = {
|
|
|
16
16
|
export type FormDebuggerComponent = (props: FormDebuggerProps) => ReactElement;
|
|
17
17
|
|
|
18
18
|
// Builds the per-hook-instance Debugger component. Called once per
|
|
19
|
-
// `useFormState` instance (from
|
|
19
|
+
// `useFormState` instance (from `useFormDebugger`'s lazy ref) so the
|
|
20
|
+
// returned component's
|
|
20
21
|
// identity is stable across renders — a component recreated each render
|
|
21
22
|
// would remount, and lose its open/closed state, on every keystroke.
|
|
22
23
|
export const createFormDebugger = <T extends FormValuesObject>(
|