@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
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { describe, test, expect } from 'bun:test';
|
|
2
|
+
import { act, renderHook } from '@testing-library/react';
|
|
3
|
+
import { useFieldBinding } from './useFieldBinding';
|
|
4
|
+
import { useFormState } from './useFormState';
|
|
5
|
+
import { notEmpty } from '../validators/validators';
|
|
6
|
+
|
|
7
|
+
// Field binding exercised through useFormState — the composition consumers
|
|
8
|
+
// see. Pins the three behaviors item 6 added: granular path writes (the
|
|
9
|
+
// immutable mirror of read()), touched tracking fed by onBlur, and the
|
|
10
|
+
// error-display policy living inside errorMessage.
|
|
11
|
+
|
|
12
|
+
type Address = { city: string | undefined; zip: string | undefined };
|
|
13
|
+
|
|
14
|
+
type ProfileForm = {
|
|
15
|
+
email: string | undefined;
|
|
16
|
+
homeAddress: Address;
|
|
17
|
+
mailingAddress: Address | undefined;
|
|
18
|
+
pets: Array<{ name: string | undefined }>;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const initialValues: ProfileForm = {
|
|
22
|
+
email: undefined,
|
|
23
|
+
homeAddress: { city: undefined, zip: undefined },
|
|
24
|
+
mailingAddress: undefined,
|
|
25
|
+
pets: [{ name: 'Rex' }, { name: 'Milou' }],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const setup = () =>
|
|
29
|
+
renderHook(() =>
|
|
30
|
+
useFormState({
|
|
31
|
+
initialValues,
|
|
32
|
+
constraints: { email: notEmpty('email') },
|
|
33
|
+
}),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
describe('getFormFieldPropsAt — value and writes', () => {
|
|
37
|
+
test('reads the value at a deep path', () => {
|
|
38
|
+
const { result } = setup();
|
|
39
|
+
expect(result.current.getFormFieldPropsAt(['pets', 0, 'name']).value).toBe(
|
|
40
|
+
'Rex',
|
|
41
|
+
);
|
|
42
|
+
expect(
|
|
43
|
+
result.current.getFormFieldPropsAt(['homeAddress', 'city']).value,
|
|
44
|
+
).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a path through an absent section reads undefined and writes as a no-op', () => {
|
|
48
|
+
const { result } = setup();
|
|
49
|
+
const field = result.current.getFormFieldPropsAt(['mailingAddress', 'city']);
|
|
50
|
+
expect(field.value).toBeUndefined();
|
|
51
|
+
|
|
52
|
+
act(() => {
|
|
53
|
+
field.onChange('Paris');
|
|
54
|
+
});
|
|
55
|
+
// The write mirrored read()'s dead-step semantics: nothing changed.
|
|
56
|
+
expect(result.current.values).toBe(initialValues);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('onChange writes at the path immutably, preserving sibling identity', () => {
|
|
60
|
+
const { result } = setup();
|
|
61
|
+
|
|
62
|
+
act(() => {
|
|
63
|
+
result.current.getFormFieldPropsAt(['homeAddress', 'city']).onChange('Paris');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
expect(result.current.values.homeAddress.city).toBe('Paris');
|
|
67
|
+
// Untouched branches keep their identity — only the spine was cloned.
|
|
68
|
+
expect(result.current.values.pets).toBe(initialValues.pets);
|
|
69
|
+
expect(result.current.values).not.toBe(initialValues);
|
|
70
|
+
expect(initialValues.homeAddress.city).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('onChange into a list element rewrites only that element', () => {
|
|
74
|
+
const { result } = setup();
|
|
75
|
+
|
|
76
|
+
act(() => {
|
|
77
|
+
result.current.getFormFieldPropsAt(['pets', 1, 'name']).onChange('Snowy');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
expect(result.current.values.pets[1].name).toBe('Snowy');
|
|
81
|
+
expect(result.current.values.pets[0]).toBe(initialValues.pets[0]);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('getFormFieldPropsAt — error-display policy', () => {
|
|
86
|
+
test('an untouched field shows no error before a submit attempt', () => {
|
|
87
|
+
const { result } = setup();
|
|
88
|
+
// The error exists in the raw list …
|
|
89
|
+
expect(result.current.errors).toEqual([
|
|
90
|
+
{ path: ['email'], error: "'email' cannot be empty" },
|
|
91
|
+
]);
|
|
92
|
+
// … but the binding withholds it until touched or submit-attempted.
|
|
93
|
+
expect(
|
|
94
|
+
result.current.getFormFieldPropsAt(['email']).errorMessage,
|
|
95
|
+
).toBeUndefined();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('onBlur marks the field touched and unlocks its error — only its own', () => {
|
|
99
|
+
const { result } = setup();
|
|
100
|
+
|
|
101
|
+
act(() => {
|
|
102
|
+
result.current.getFormFieldPropsAt(['email']).onBlur();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
expect(result.current.getFormFieldPropsAt(['email']).errorMessage).toBe(
|
|
106
|
+
"'email' cannot be empty",
|
|
107
|
+
);
|
|
108
|
+
// Another field stays untouched: same policy, independent state.
|
|
109
|
+
expect(
|
|
110
|
+
result.current.getFormFieldPropsAt(['homeAddress', 'city']).errorMessage,
|
|
111
|
+
).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('a submit attempt unlocks every field’s error', () => {
|
|
115
|
+
const { result } = setup();
|
|
116
|
+
|
|
117
|
+
act(() => {
|
|
118
|
+
result.current.submit();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
expect(result.current.getFormFieldPropsAt(['email']).errorMessage).toBe(
|
|
122
|
+
"'email' cannot be empty",
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('a touched field’s error clears once the value is fixed', () => {
|
|
127
|
+
const { result } = setup();
|
|
128
|
+
|
|
129
|
+
act(() => {
|
|
130
|
+
result.current.getFormFieldPropsAt(['email']).onBlur();
|
|
131
|
+
});
|
|
132
|
+
act(() => {
|
|
133
|
+
result.current.getFormFieldPropsAt(['email']).onChange('a@b.co');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
expect(
|
|
137
|
+
result.current.getFormFieldPropsAt(['email']).errorMessage,
|
|
138
|
+
).toBeUndefined();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('repeat blurs on the same path keep the touched list stable', () => {
|
|
142
|
+
// At the useFieldBinding boundary, where the touched list is returned.
|
|
143
|
+
const { result } = renderHook(() =>
|
|
144
|
+
useFieldBinding({
|
|
145
|
+
values: initialValues,
|
|
146
|
+
onValueChanges: () => {},
|
|
147
|
+
errors: [],
|
|
148
|
+
submitAttempted: false,
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
act(() => {
|
|
153
|
+
result.current.getFormFieldPropsAt(['email']).onBlur();
|
|
154
|
+
});
|
|
155
|
+
const touchedAfterFirst = result.current.touched;
|
|
156
|
+
expect(touchedAfterFirst).toEqual([['email']]);
|
|
157
|
+
|
|
158
|
+
act(() => {
|
|
159
|
+
result.current.getFormFieldPropsAt(['email']).onBlur();
|
|
160
|
+
});
|
|
161
|
+
// Re-blurring an already-touched path is a state no-op — the setter
|
|
162
|
+
// returns the same array, so nothing downstream sees a change.
|
|
163
|
+
expect(result.current.touched).toBe(touchedAfterFirst);
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { errorAt } from './errorAt';
|
|
3
|
+
import type { FormErrors, FormFieldProps, FormValuesObject } from './types';
|
|
4
|
+
import { pathsEqual, read, write } from '../path/path';
|
|
5
|
+
import type { CursorStep, Path, PathStep, ValueAt } from '../path/types';
|
|
6
|
+
|
|
7
|
+
export type UseFieldBindingArgs<T extends FormValuesObject> = {
|
|
8
|
+
values: T;
|
|
9
|
+
onValueChanges: (val: T | ((prev: T) => T)) => void;
|
|
10
|
+
errors: FormErrors<T>;
|
|
11
|
+
submitAttempted: boolean;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// Field binding: owns per-field touched state and builds
|
|
15
|
+
// `getFormFieldPropsAt`, the one-expression wiring for a field (see
|
|
16
|
+
// FormHelpers in ./types.ts).
|
|
17
|
+
//
|
|
18
|
+
// THE error-display policy lives here, in `errorMessage`, and nowhere else:
|
|
19
|
+
// a field's error is shown once the field has been touched (blurred at
|
|
20
|
+
// least once) OR a submit has been attempted. Elements render the message
|
|
21
|
+
// they are given and stay policy-free; anything wanting a different policy
|
|
22
|
+
// (e.g. always-live display) reads `errors`/`errorAt` directly instead.
|
|
23
|
+
export const useFieldBinding = <T extends FormValuesObject>(
|
|
24
|
+
args: UseFieldBindingArgs<T>,
|
|
25
|
+
) => {
|
|
26
|
+
const { values, onValueChanges, errors, submitAttempted } = args;
|
|
27
|
+
|
|
28
|
+
// Same representation as FormErrors: a plain list of typed paths compared
|
|
29
|
+
// structurally (pathsEqual). At form scale a linear scan is fine.
|
|
30
|
+
const [touched, setTouched] = useState<readonly Path<T>[]>([]);
|
|
31
|
+
|
|
32
|
+
const getFormFieldPropsAt = <P extends Path<T>>(
|
|
33
|
+
path: P,
|
|
34
|
+
): FormFieldProps<ValueAt<T, P>> => {
|
|
35
|
+
// Path<T> is always a PathStep tuple; the conditional type just can't
|
|
36
|
+
// prove it for an unresolved T. Same honest widening as `Cursor.at`.
|
|
37
|
+
const steps = path as readonly PathStep[];
|
|
38
|
+
const keySteps: readonly CursorStep[] = steps.map((key) => ({
|
|
39
|
+
kind: 'key',
|
|
40
|
+
key,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
const isTouched = touched.some((candidate) =>
|
|
44
|
+
pathsEqual(candidate as readonly PathStep[], steps),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
// read() resolves exactly what ValueAt<T, P> promises — including the
|
|
49
|
+
// `| undefined` picked up through nullable ancestors — TS just can't
|
|
50
|
+
// correlate them for generic T. Honest cast, documented in the cast
|
|
51
|
+
// doctrine (src/forms/CLAUDE.md).
|
|
52
|
+
value: read(values, keySteps) as ValueAt<T, P>,
|
|
53
|
+
// write() replaces the value at the path and clones only the spine —
|
|
54
|
+
// the result is the same T shape. Same honest correlation as `value`.
|
|
55
|
+
onChange: (val) =>
|
|
56
|
+
onValueChanges((prev) => write(prev, steps, val) as T),
|
|
57
|
+
errorMessage:
|
|
58
|
+
submitAttempted || isTouched ? errorAt(errors, path) : undefined,
|
|
59
|
+
onBlur: () =>
|
|
60
|
+
setTouched((prev) =>
|
|
61
|
+
prev.some((candidate) =>
|
|
62
|
+
pathsEqual(candidate as readonly PathStep[], steps),
|
|
63
|
+
)
|
|
64
|
+
? prev
|
|
65
|
+
: [...prev, path],
|
|
66
|
+
),
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return { touched, getFormFieldPropsAt };
|
|
71
|
+
};
|
|
@@ -5,10 +5,13 @@ import { useFormState } from './useFormState';
|
|
|
5
5
|
import { errorAt } from './errorAt';
|
|
6
6
|
import type { FormErrors } from './types';
|
|
7
7
|
import { matches, minLength, notEmpty } from '../validators/validators';
|
|
8
|
-
import { Field } from '../../
|
|
9
|
-
import { Input } from '../../
|
|
10
|
-
import { Button } from '../../
|
|
11
|
-
import { SingleSelect } from '../../
|
|
8
|
+
import { Field } from '../../elements/Field';
|
|
9
|
+
import { Input } from '../../elements/Input';
|
|
10
|
+
import { Button } from '../../elements/Button';
|
|
11
|
+
import { SingleSelect } from '../../elements/Select';
|
|
12
|
+
import type { SelectOption } from '../../elements/Select';
|
|
13
|
+
import { SingleSelectForForm } from '../bindings/SingleSelectForForm';
|
|
14
|
+
import { TextInputForForm } from '../bindings/TextInputForForm';
|
|
12
15
|
|
|
13
16
|
const meta: Meta = {
|
|
14
17
|
title: 'Forms/useFormState',
|
|
@@ -43,6 +46,10 @@ const ROLE_OPTIONS = [
|
|
|
43
46
|
{ value: 'manager', label: 'Manager' },
|
|
44
47
|
];
|
|
45
48
|
|
|
49
|
+
// Deliberately hand-wired — the contrast case for the FieldBinding story
|
|
50
|
+
// below: every field spells out its own read, spread-update, error lookup,
|
|
51
|
+
// and submit gating. `getFormFieldPropsAt` + the ForForm wrappers collapse
|
|
52
|
+
// those four decisions into one expression per field.
|
|
46
53
|
const SignupDemo = () => {
|
|
47
54
|
const [submitted, setSubmitted] = useState<SubmittedValues | null>(null);
|
|
48
55
|
|
|
@@ -259,6 +266,162 @@ export const LiveValidity: Story = {
|
|
|
259
266
|
},
|
|
260
267
|
};
|
|
261
268
|
|
|
269
|
+
type CoverageType = 'liability' | 'comprehensive';
|
|
270
|
+
|
|
271
|
+
type QuoteFormValues = {
|
|
272
|
+
email: string | undefined;
|
|
273
|
+
coverageType: CoverageType | null;
|
|
274
|
+
homeAddress: {
|
|
275
|
+
city: string | undefined;
|
|
276
|
+
postalCode: string | undefined;
|
|
277
|
+
};
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
// What onSubmit receives: notEmpty strips undefined from email and null
|
|
281
|
+
// from coverageType. The setSubmitted(vals) call below compiling is the
|
|
282
|
+
// proof that refinement still flows end-to-end when fields are wired
|
|
283
|
+
// through bindings.
|
|
284
|
+
type SubmittedQuote = {
|
|
285
|
+
email: string;
|
|
286
|
+
coverageType: CoverageType;
|
|
287
|
+
homeAddress: QuoteFormValues['homeAddress'];
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const COVERAGE_OPTIONS: SelectOption<CoverageType>[] = [
|
|
291
|
+
{ value: 'liability', label: 'Liability' },
|
|
292
|
+
{ value: 'comprehensive', label: 'Comprehensive' },
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
// The item-6 target: one expression wires a field. getFormFieldPropsAt
|
|
296
|
+
// bundles value + typed onChange (an immutable write at the path) +
|
|
297
|
+
// display-policy-aware errorMessage + onBlur, and the ForForm wrappers
|
|
298
|
+
// take the bundle as a single prop. Note the deep paths into homeAddress —
|
|
299
|
+
// no hand-spread updates anywhere.
|
|
300
|
+
const FieldBindingDemo = () => {
|
|
301
|
+
const [submitted, setSubmitted] = useState<SubmittedQuote | null>(null);
|
|
302
|
+
|
|
303
|
+
const { getFormFieldPropsAt, submit } = useFormState({
|
|
304
|
+
initialValues: {
|
|
305
|
+
email: undefined,
|
|
306
|
+
coverageType: null,
|
|
307
|
+
homeAddress: { city: undefined, postalCode: undefined },
|
|
308
|
+
} as QuoteFormValues,
|
|
309
|
+
constraints: {
|
|
310
|
+
email: [notEmpty('email'), matches('email', /@/, 'a valid email')],
|
|
311
|
+
coverageType: notEmpty('coverageType'),
|
|
312
|
+
},
|
|
313
|
+
onSubmit: (vals) => setSubmitted(vals),
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
return (
|
|
317
|
+
<form
|
|
318
|
+
style={{ maxWidth: 420, display: 'grid', gap: 16 }}
|
|
319
|
+
onSubmit={(e) => {
|
|
320
|
+
e.preventDefault();
|
|
321
|
+
submit();
|
|
322
|
+
}}
|
|
323
|
+
>
|
|
324
|
+
<TextInputForForm
|
|
325
|
+
label="Email"
|
|
326
|
+
type="email"
|
|
327
|
+
placeholder="you@example.com"
|
|
328
|
+
hint="Blur the empty field to see touched-gated errors"
|
|
329
|
+
formFieldProps={getFormFieldPropsAt(['email'])}
|
|
330
|
+
/>
|
|
331
|
+
|
|
332
|
+
<SingleSelectForForm
|
|
333
|
+
label="Coverage"
|
|
334
|
+
options={COVERAGE_OPTIONS}
|
|
335
|
+
placeholder="Pick a coverage"
|
|
336
|
+
formFieldProps={getFormFieldPropsAt(['coverageType'])}
|
|
337
|
+
/>
|
|
338
|
+
|
|
339
|
+
<TextInputForForm
|
|
340
|
+
label="City"
|
|
341
|
+
formFieldProps={getFormFieldPropsAt(['homeAddress', 'city'])}
|
|
342
|
+
/>
|
|
343
|
+
|
|
344
|
+
<TextInputForForm
|
|
345
|
+
label="Postal code"
|
|
346
|
+
formFieldProps={getFormFieldPropsAt(['homeAddress', 'postalCode'])}
|
|
347
|
+
/>
|
|
348
|
+
|
|
349
|
+
<div>
|
|
350
|
+
<Button type="submit" variant="primary">
|
|
351
|
+
Get quote
|
|
352
|
+
</Button>
|
|
353
|
+
</div>
|
|
354
|
+
|
|
355
|
+
{submitted && (
|
|
356
|
+
<pre
|
|
357
|
+
style={{
|
|
358
|
+
background: 'var(--ui-surface-muted, #f4f4f4)',
|
|
359
|
+
padding: 12,
|
|
360
|
+
borderRadius: 6,
|
|
361
|
+
fontSize: 12,
|
|
362
|
+
margin: 0,
|
|
363
|
+
}}
|
|
364
|
+
>
|
|
365
|
+
{`onSubmit received (narrowed type):\n${JSON.stringify(submitted, null, 2)}`}
|
|
366
|
+
</pre>
|
|
367
|
+
)}
|
|
368
|
+
</form>
|
|
369
|
+
);
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
export const FieldBinding: Story = {
|
|
373
|
+
render: () => (
|
|
374
|
+
<div style={{ padding: 24, fontFamily: 'var(--ui-font)' }}>
|
|
375
|
+
<FieldBindingDemo />
|
|
376
|
+
</div>
|
|
377
|
+
),
|
|
378
|
+
// Walks the binding flow: touched/blur gating of errorMessage (before any
|
|
379
|
+
// submit attempt), deep-path writes through the wrappers, submit-attempt
|
|
380
|
+
// gating for untouched fields, and the narrowed payload reaching onSubmit.
|
|
381
|
+
play: async ({ canvasElement }) => {
|
|
382
|
+
const canvas = within(canvasElement);
|
|
383
|
+
const body = within(canvasElement.ownerDocument.body);
|
|
384
|
+
|
|
385
|
+
// Nothing shown initially: the email error exists in the raw list, but
|
|
386
|
+
// errorMessage withholds it until the field is touched.
|
|
387
|
+
await expect(
|
|
388
|
+
canvas.queryByText("'email' cannot be empty"),
|
|
389
|
+
).not.toBeInTheDocument();
|
|
390
|
+
|
|
391
|
+
// Blurring the empty email field marks it touched — its error appears
|
|
392
|
+
// without any submit attempt, and only its own.
|
|
393
|
+
await userEvent.click(canvas.getByLabelText(/^Email/));
|
|
394
|
+
await userEvent.tab();
|
|
395
|
+
await expect(canvas.getByText("'email' cannot be empty")).toBeInTheDocument();
|
|
396
|
+
await expect(
|
|
397
|
+
canvas.queryByText("'coverageType' cannot be empty"),
|
|
398
|
+
).not.toBeInTheDocument();
|
|
399
|
+
|
|
400
|
+
// Deep-path writes flow through the wrapper onChange.
|
|
401
|
+
await userEvent.type(canvas.getByLabelText(/^City/), 'Lyon');
|
|
402
|
+
await expect(canvas.getByLabelText(/^City/)).toHaveValue('Lyon');
|
|
403
|
+
|
|
404
|
+
// A submit attempt unlocks the untouched coverage field's error too.
|
|
405
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Get quote' }));
|
|
406
|
+
await expect(
|
|
407
|
+
canvas.getByText("'coverageType' cannot be empty"),
|
|
408
|
+
).toBeInTheDocument();
|
|
409
|
+
|
|
410
|
+
// Fix both fields; committing a select option counts as its touch.
|
|
411
|
+
await userEvent.type(canvas.getByLabelText(/^Email/), 'will@example.com');
|
|
412
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Coverage' }));
|
|
413
|
+
// The option list is portaled — query the document, not the canvas.
|
|
414
|
+
await userEvent.click(await body.findByRole('option', { name: 'Liability' }));
|
|
415
|
+
await expect(
|
|
416
|
+
canvas.queryByText("'coverageType' cannot be empty"),
|
|
417
|
+
).not.toBeInTheDocument();
|
|
418
|
+
|
|
419
|
+
// Valid submit delivers the narrowed payload.
|
|
420
|
+
await userEvent.click(canvas.getByRole('button', { name: 'Get quote' }));
|
|
421
|
+
await expect(canvas.getByText(/onSubmit received/)).toBeInTheDocument();
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
|
|
262
425
|
type DebuggerDemoValues = {
|
|
263
426
|
email: string | undefined;
|
|
264
427
|
nickname: string | undefined;
|
|
@@ -2,9 +2,11 @@ import { describe, it, expectTypeOf } from 'vitest';
|
|
|
2
2
|
import { useFormState } from './useFormState';
|
|
3
3
|
import { errorAt } from './errorAt';
|
|
4
4
|
import { allOf, matches, min, minLength, notEmpty } from '../validators/validators';
|
|
5
|
-
import type { FormErrors } from './types';
|
|
5
|
+
import type { FormErrors, FormFieldProps } from './types';
|
|
6
6
|
import type { Refine, Validations } from '../validations/types';
|
|
7
7
|
import type { Path, ValueAt } from '../path/types';
|
|
8
|
+
import type { TextInputForFormProps } from '../bindings/TextInputForForm';
|
|
9
|
+
import type { SingleSelectForFormProps } from '../bindings/SingleSelectForForm';
|
|
8
10
|
|
|
9
11
|
// These tests exercise the headline feature end-to-end at the hook boundary:
|
|
10
12
|
// the type `onSubmit` receives must be the *refined* form type. The two
|
|
@@ -425,6 +427,83 @@ describe('useFormState narrowing at realistic scale', () => {
|
|
|
425
427
|
errorAt(errors, ['email', 'domain']);
|
|
426
428
|
});
|
|
427
429
|
|
|
430
|
+
it('getFormFieldPropsAt infers FormFieldProps<ValueAt> at deep paths, inline at the call site', () => {
|
|
431
|
+
const form = useFormState({
|
|
432
|
+
initialValues: {} as InsuranceQuoteForm,
|
|
433
|
+
constraints: { email: notEmpty('email') },
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// Flat and deep paths: value/onChange typed by ValueAt<T, P>.
|
|
437
|
+
expectTypeOf(form.getFormFieldPropsAt(['email'])).toEqualTypeOf<
|
|
438
|
+
FormFieldProps<string | undefined>
|
|
439
|
+
>();
|
|
440
|
+
expectTypeOf(
|
|
441
|
+
form.getFormFieldPropsAt(['homeAddress', 'city']),
|
|
442
|
+
).toEqualTypeOf<FormFieldProps<string | undefined>>();
|
|
443
|
+
expectTypeOf(
|
|
444
|
+
form.getFormFieldPropsAt(['drivers', 0, 'incidents', 1, 'claimAmountUsd']),
|
|
445
|
+
).toEqualTypeOf<FormFieldProps<number | null>>();
|
|
446
|
+
expectTypeOf(
|
|
447
|
+
form.getFormFieldPropsAt(['vehicles', 0, 'garagingAddress', 'postalCode']),
|
|
448
|
+
).toEqualTypeOf<FormFieldProps<string | undefined>>();
|
|
449
|
+
|
|
450
|
+
// Nullable-object semantics (PR #17) flow through the binding: stepping
|
|
451
|
+
// through a dead ancestor adds `| undefined`, stopping AT a nullable
|
|
452
|
+
// field keeps its exact type.
|
|
453
|
+
expectTypeOf(
|
|
454
|
+
form.getFormFieldPropsAt(['coApplicant', 'sharesResidence']),
|
|
455
|
+
).toEqualTypeOf<FormFieldProps<boolean | undefined>>();
|
|
456
|
+
expectTypeOf(
|
|
457
|
+
form.getFormFieldPropsAt(['pastPolicies', 0, 'activeUntil']),
|
|
458
|
+
).toEqualTypeOf<FormFieldProps<string | null | undefined>>();
|
|
459
|
+
expectTypeOf(form.getFormFieldPropsAt(['employer'])).toEqualTypeOf<
|
|
460
|
+
FormFieldProps<string | null>
|
|
461
|
+
>();
|
|
462
|
+
|
|
463
|
+
// Only Path<T> is admitted.
|
|
464
|
+
// @ts-expect-error 'emial' is not a field of the form
|
|
465
|
+
form.getFormFieldPropsAt(['emial']);
|
|
466
|
+
// @ts-expect-error no paths exist below a scalar leaf
|
|
467
|
+
form.getFormFieldPropsAt(['email', 'domain']);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
it('element wrappers accept only shape-compatible bindings', () => {
|
|
471
|
+
const form = useFormState({ initialValues: {} as InsuranceQuoteForm });
|
|
472
|
+
|
|
473
|
+
type TextBinding = TextInputForFormProps['formFieldProps'];
|
|
474
|
+
|
|
475
|
+
// Text-shaped fields bind: V must sit between the wrapper's emit type
|
|
476
|
+
// (string) and display type (string | null | undefined).
|
|
477
|
+
expectTypeOf(
|
|
478
|
+
form.getFormFieldPropsAt(['homeAddress', 'city']),
|
|
479
|
+
).toMatchTypeOf<TextBinding>();
|
|
480
|
+
expectTypeOf(form.getFormFieldPropsAt(['employer'])).toMatchTypeOf<TextBinding>();
|
|
481
|
+
expectTypeOf(
|
|
482
|
+
form.getFormFieldPropsAt(['pastPolicies', 0, 'activeUntil']),
|
|
483
|
+
).toMatchTypeOf<TextBinding>();
|
|
484
|
+
|
|
485
|
+
// Wrong-shaped bindings fail at the formFieldProps prop.
|
|
486
|
+
// @ts-expect-error a number-typed path cannot bind to a text element
|
|
487
|
+
const numberIntoText: TextBinding = form.getFormFieldPropsAt(['deductibleUsd']);
|
|
488
|
+
// @ts-expect-error a boolean-typed path cannot bind to a text element
|
|
489
|
+
const booleanIntoText: TextBinding = form.getFormFieldPropsAt(['agreedToTerms']);
|
|
490
|
+
|
|
491
|
+
// The select wrapper lines its options' literal union up with the field:
|
|
492
|
+
// emitting a wider type than the field holds is rejected.
|
|
493
|
+
type CoverageBinding = SingleSelectForFormProps<
|
|
494
|
+
'liability' | 'comprehensive'
|
|
495
|
+
>['formFieldProps'];
|
|
496
|
+
expectTypeOf<
|
|
497
|
+
FormFieldProps<'liability' | 'comprehensive' | undefined>
|
|
498
|
+
>().toMatchTypeOf<CoverageBinding>();
|
|
499
|
+
// @ts-expect-error a plain-string field would accept values outside the options
|
|
500
|
+
const stringIntoSelect: CoverageBinding = form.getFormFieldPropsAt(['coverageType']);
|
|
501
|
+
|
|
502
|
+
void numberIntoText;
|
|
503
|
+
void booleanIntoText;
|
|
504
|
+
void stringIntoSelect;
|
|
505
|
+
});
|
|
506
|
+
|
|
428
507
|
it('paths through optional sections and nullable lists resolve, at scale', () => {
|
|
429
508
|
// The latent hole this pins: Path admitted these paths all along, but
|
|
430
509
|
// ValueAt resolved them to `never` because `keyof (Obj | undefined)` is
|
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
FormValuesObject,
|
|
6
6
|
UnionPolicyCheck,
|
|
7
7
|
} from './types';
|
|
8
|
+
import { useFieldBinding } from './useFieldBinding';
|
|
8
9
|
import { useFormDebugger } from './useFormDebugger';
|
|
9
10
|
import { useFormSubmit } from './useFormSubmit';
|
|
10
11
|
import type { Refine, Validations } from '../validations/types';
|
|
@@ -30,8 +31,8 @@ type Args<T extends FormValuesObject, V extends Validations<T>> = {
|
|
|
30
31
|
// Plumbing only: each slice of form state lives in its own pure function or
|
|
31
32
|
// focused hook, and this hook just links them up and recomposes their
|
|
32
33
|
// outputs into the `FormHelpers<T>` surface. Values state is the one slice
|
|
33
|
-
// kept inline —
|
|
34
|
-
//
|
|
34
|
+
// kept inline — a bare `useState`; the granular path writes layer on top of
|
|
35
|
+
// its setter (`useFieldBinding` funnels `write()` results through it).
|
|
35
36
|
export const useFormState = <
|
|
36
37
|
T extends FormValuesObject,
|
|
37
38
|
const V extends Validations<T> = Validations<T>,
|
|
@@ -53,8 +54,15 @@ export const useFormState = <
|
|
|
53
54
|
onSubmit,
|
|
54
55
|
});
|
|
55
56
|
|
|
57
|
+
const { touched, getFormFieldPropsAt } = useFieldBinding({
|
|
58
|
+
values,
|
|
59
|
+
onValueChanges,
|
|
60
|
+
errors,
|
|
61
|
+
submitAttempted,
|
|
62
|
+
});
|
|
63
|
+
|
|
56
64
|
const Debugger = useFormDebugger({
|
|
57
|
-
snapshot: { values, errors, isValid, submitAttempted },
|
|
65
|
+
snapshot: { values, errors, isValid, submitAttempted, touched },
|
|
58
66
|
});
|
|
59
67
|
|
|
60
68
|
return {
|
|
@@ -64,6 +72,7 @@ export const useFormState = <
|
|
|
64
72
|
isValid,
|
|
65
73
|
submitAttempted,
|
|
66
74
|
submit,
|
|
75
|
+
getFormFieldPropsAt,
|
|
67
76
|
Debugger,
|
|
68
77
|
};
|
|
69
78
|
};
|
package/src/index.ts
CHANGED
|
@@ -3,26 +3,26 @@ export { Heading, type HeadingProps } from './components/Content/Heading';
|
|
|
3
3
|
export { Link, type LinkProps } from './components/Content/Link';
|
|
4
4
|
export { Stack, type StackProps } from './components/Layout/Stack';
|
|
5
5
|
export { Grid, type GridProps } from './components/Layout/Grid';
|
|
6
|
-
export { Button, type ButtonProps } from './
|
|
6
|
+
export { Button, type ButtonProps } from './forms/elements/Button';
|
|
7
7
|
export {
|
|
8
8
|
IconButton,
|
|
9
9
|
type IconButtonProps,
|
|
10
10
|
type IconButtonVariant,
|
|
11
11
|
type IconButtonSize,
|
|
12
|
-
} from './
|
|
12
|
+
} from './forms/elements/IconButton';
|
|
13
13
|
export {
|
|
14
14
|
Tooltip,
|
|
15
15
|
type TooltipProps,
|
|
16
16
|
type TooltipPlacement,
|
|
17
17
|
} from './components/Overlays/Tooltip';
|
|
18
|
-
export { Input, type InputProps } from './
|
|
19
|
-
export { SearchInput, type SearchInputProps } from './
|
|
20
|
-
export { ColorInput, type ColorInputProps } from './
|
|
21
|
-
export { Textarea, type TextareaProps } from './
|
|
22
|
-
export { SingleSelect, type SingleSelectProps } from './
|
|
23
|
-
export { MultiSelect, type MultiSelectProps } from './
|
|
24
|
-
export type { SelectOption, SelectSize } from './
|
|
25
|
-
export { Field, type FieldProps } from './
|
|
18
|
+
export { Input, type InputProps } from './forms/elements/Input';
|
|
19
|
+
export { SearchInput, type SearchInputProps } from './forms/elements/SearchInput';
|
|
20
|
+
export { ColorInput, type ColorInputProps } from './forms/elements/ColorInput';
|
|
21
|
+
export { Textarea, type TextareaProps } from './forms/elements/Textarea';
|
|
22
|
+
export { SingleSelect, type SingleSelectProps } from './forms/elements/Select';
|
|
23
|
+
export { MultiSelect, type MultiSelectProps } from './forms/elements/Select';
|
|
24
|
+
export type { SelectOption, SelectSize } from './forms/elements/Select';
|
|
25
|
+
export { Field, type FieldProps } from './forms/elements/Field';
|
|
26
26
|
export { MediumModal, LargeModal, ConfirmModal } from './components/Modals';
|
|
27
27
|
export type { MediumModalProps, LargeModalProps, ConfirmModalProps } from './components/Modals';
|
|
28
28
|
export { Divider } from './components/Layout/Divider';
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
2
|
import { useRef, useState } from 'react';
|
|
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 { MediumModal } from '../components/Modals/MediumModal';
|
|
7
7
|
import { Menu } from '../components/Content/Menu';
|
|
8
8
|
import { Popover } from '../components/Overlays/Popover';
|
|
9
|
-
import { SingleSelect } from '../
|
|
9
|
+
import { SingleSelect } from '../forms/elements/Select/SingleSelect';
|
|
10
10
|
import { Stack } from '../components/Layout/Stack';
|
|
11
11
|
import { Text } from '../components/Content/Text';
|
|
12
12
|
import { Tooltip } from '../components/Overlays/Tooltip';
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
2
2
|
import { Lorem, Placeholder, Toggle, Repeat } from './index';
|
|
3
|
-
import { Button } from '../
|
|
3
|
+
import { Button } from '../forms/elements/Button';
|
|
4
4
|
import { Stack } from '../components/Layout/Stack';
|
|
5
5
|
import { Text } from '../components/Content/Text';
|
|
6
6
|
import { MediumModal } from '../components/Modals';
|