@structuralists/scaffolding 0.0.1 → 0.1.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/.github/workflows/publish.yml +66 -0
- package/.storybook/main.ts +1 -1
- package/.storybook/preview.tsx +5 -1
- package/CLAUDE.md +25 -0
- package/README.md +79 -0
- package/bun.lock +211 -202
- package/eslint.config.mjs +85 -84
- package/package.json +21 -20
- package/roadmap.md +27 -0
- package/src/components/Chat/ChatComposer/ChatComposer.stories.tsx +1 -1
- package/src/components/Chat/ChatMessage/ChatMessage.stories.tsx +1 -1
- package/src/components/Chat/ChatRecipientsHeader/ChatRecipientsHeader.stories.tsx +1 -1
- package/src/components/Chat/ChatShell/ChatShell.stories.tsx +1 -1
- package/src/components/Chat/PillCombobox/PillCombobox.stories.tsx +1 -1
- package/src/components/Content/Badge/Badge.stories.tsx +1 -1
- package/src/components/Content/Card/Card.stories.tsx +1 -1
- package/src/components/Content/EditableMarkdown/EditableMarkdown.stories.tsx +1 -1
- package/src/components/Content/Heading/Heading.stories.tsx +1 -1
- package/src/components/Content/Link/Link.stories.tsx +1 -1
- package/src/components/Content/List/List.stories.tsx +1 -1
- package/src/components/Content/LoadingContainer/LoadingContainer.stories.tsx +1 -1
- package/src/components/Content/Markdown/Markdown.stories.tsx +1 -1
- package/src/components/Content/Menu/Menu.stories.tsx +1 -1
- package/src/components/Content/Text/Text.stories.tsx +1 -1
- package/src/components/Forms/Button/Button.stories.tsx +1 -1
- package/src/components/Forms/Field/Field.stories.tsx +1 -1
- package/src/components/Forms/IconButton/IconButton.stories.tsx +1 -1
- package/src/components/Forms/Input/Input.stories.tsx +1 -1
- package/src/components/Forms/Select/MultiSelect/MultiSelect.stories.tsx +1 -1
- package/src/components/Forms/Select/SingleSelect/SingleSelect.stories.tsx +1 -1
- package/src/components/Forms/Textarea/Textarea.stories.tsx +1 -1
- package/src/components/Json/Json/Json.stories.tsx +1 -1
- package/src/components/Json/JsonTable/JsonTable.stories.tsx +1 -1
- package/src/components/Layout/Bar/Bar.stories.tsx +1 -1
- package/src/components/Layout/Debug/Debug.stories.tsx +1 -1
- package/src/components/Layout/Divider/Divider.stories.tsx +1 -1
- package/src/components/Layout/Grid/Grid.stories.tsx +1 -1
- package/src/components/Layout/Panels/Panels.stories.tsx +47 -1
- package/src/components/Layout/Panels/index.tsx +17 -1
- package/src/components/Layout/Panels/types.ts +5 -0
- package/src/components/Layout/Stack/Stack.stories.tsx +1 -1
- package/src/components/Modals/ConfirmModal/ConfirmModal.stories.tsx +1 -1
- package/src/components/Modals/LargeModal/LargeModal.stories.tsx +1 -1
- package/src/components/Modals/MediumModal/MediumModal.stories.tsx +1 -1
- package/src/components/Modals/MediumModal/MediumModal.test.tsx +11 -11
- package/src/components/Navigation/TabBar/TabBar.stories.tsx +1 -1
- package/src/components/Navigation/VerticalNav/VerticalNav.stories.tsx +1 -1
- package/src/components/Overlays/Popover/Popover.stories.tsx +1 -1
- package/src/components/Overlays/Tooltip/Tooltip.stories.tsx +1 -1
- package/src/components/Primitives/EmptyValue/EmptyValue.stories.tsx +1 -1
- package/src/components/Primitives/LinedStack/LinedStack.stories.tsx +1 -1
- package/src/components/Primitives/LongText/LongText.stories.tsx +1 -1
- package/src/components/Primitives/Num/Num.stories.tsx +1 -1
- package/src/components/Primitives/Percent/Percent.stories.tsx +1 -1
- package/src/components/Primitives/RelativeTime/RelativeTime.stories.tsx +1 -1
- package/src/components/Tables/BigTable/BigTable.stories.tsx +1 -1
- package/src/components/Tables/QuickTable/QuickTable.stories.tsx +1 -1
- package/src/forms/CLAUDE.md +144 -0
- package/src/forms/path/path.ts +50 -0
- package/src/forms/path/types.test-d.ts +175 -0
- package/src/forms/path/types.ts +35 -0
- package/src/forms/useFormState/types.ts +13 -0
- package/src/forms/useFormState/useFormState.ts +14 -0
- package/src/forms/validations/types.test-d.ts +26 -0
- package/src/forms/validations/types.ts +15 -0
- package/src/hooks/useClickOutside/index.ts +57 -0
- package/src/hooks/useStableCallback/index.ts +36 -0
- package/src/index.ts +2 -0
- package/src/storybook/Composition.stories.tsx +1 -1
- package/src/storybook/_StoryUtils.stories.tsx +1 -1
|
@@ -2,8 +2,6 @@ import { describe, test, expect, mock } from 'bun:test';
|
|
|
2
2
|
import { render, fireEvent, cleanup } from '@testing-library/react';
|
|
3
3
|
import { MediumModal } from './index';
|
|
4
4
|
|
|
5
|
-
// happy-dom implements showMediumModal/close but we call cleanup between tests
|
|
6
|
-
// manually (bun:test has no afterEach-by-default in this setup).
|
|
7
5
|
const setup = (props: Partial<React.ComponentProps<typeof MediumModal>> = {}) => {
|
|
8
6
|
const onClose = mock(() => {});
|
|
9
7
|
const utils = render(
|
|
@@ -11,9 +9,12 @@ const setup = (props: Partial<React.ComponentProps<typeof MediumModal>> = {}) =>
|
|
|
11
9
|
<button type="button">inside</button>
|
|
12
10
|
</MediumModal>,
|
|
13
11
|
);
|
|
14
|
-
const
|
|
12
|
+
const doc = utils.container.ownerDocument;
|
|
13
|
+
const dialog = doc.querySelector('[role="dialog"]');
|
|
15
14
|
if (!dialog) throw new Error('dialog not rendered');
|
|
16
|
-
|
|
15
|
+
const backdrop = doc.querySelector('div[aria-hidden="true"]');
|
|
16
|
+
if (!backdrop) throw new Error('backdrop not rendered');
|
|
17
|
+
return { ...utils, onClose, dialog, backdrop };
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
describe('MediumModal', () => {
|
|
@@ -24,9 +25,9 @@ describe('MediumModal', () => {
|
|
|
24
25
|
cleanup();
|
|
25
26
|
});
|
|
26
27
|
|
|
27
|
-
test('calls onClose when the backdrop
|
|
28
|
-
const { onClose,
|
|
29
|
-
fireEvent.click(
|
|
28
|
+
test('calls onClose when the backdrop is clicked', () => {
|
|
29
|
+
const { onClose, backdrop } = setup();
|
|
30
|
+
fireEvent.click(backdrop);
|
|
30
31
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
31
32
|
cleanup();
|
|
32
33
|
});
|
|
@@ -38,10 +39,9 @@ describe('MediumModal', () => {
|
|
|
38
39
|
cleanup();
|
|
39
40
|
});
|
|
40
41
|
|
|
41
|
-
test('calls onClose on
|
|
42
|
-
const { onClose
|
|
43
|
-
|
|
44
|
-
dialog.dispatchEvent(cancel);
|
|
42
|
+
test('calls onClose on Escape', () => {
|
|
43
|
+
const { onClose } = setup();
|
|
44
|
+
fireEvent.keyDown(document.body, { key: 'Escape' });
|
|
45
45
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
46
46
|
cleanup();
|
|
47
47
|
});
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# src/forms
|
|
2
|
+
|
|
3
|
+
Strongly-typed React form state hook. The headline feature is **validation that
|
|
4
|
+
propagates type refinements to the submit handler** — passing the right
|
|
5
|
+
constraints means the submit handler's `values` argument has a tighter type
|
|
6
|
+
than the initial form values.
|
|
7
|
+
|
|
8
|
+
## Mental model
|
|
9
|
+
|
|
10
|
+
A form has three types in flight:
|
|
11
|
+
|
|
12
|
+
1. **`FormType`** — what fields exist and what they could hold. Often loose
|
|
13
|
+
(`{ a: string | undefined }`).
|
|
14
|
+
2. **`Validations<FormType>`** — a per-field map of validator functions plus,
|
|
15
|
+
at the type level, refinement markers carried by each validator.
|
|
16
|
+
3. **`SubmitType = Refine<FormType, typeof validations>`** — `FormType` with
|
|
17
|
+
each field narrowed by the refinement that field's validator carries.
|
|
18
|
+
|
|
19
|
+
The form hook accepts `initialValues` of `FormType` and `constraints` of
|
|
20
|
+
`Validations<FormType>`, then hands `SubmitType` to `onSubmit`. The user
|
|
21
|
+
wrote a constraint, so the user has earned a tighter type at submit time.
|
|
22
|
+
|
|
23
|
+
## The validator shape
|
|
24
|
+
|
|
25
|
+
A validator is just:
|
|
26
|
+
|
|
27
|
+
```ts
|
|
28
|
+
(val: T) => string | null // null = pass; string = error message
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
No library, no fluent builder, no schema DSL. Composition is via plain
|
|
32
|
+
function composition.
|
|
33
|
+
|
|
34
|
+
## Aggregation: `perField`
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
const constraints = perField({
|
|
38
|
+
a: (val: string | undefined) => !val ? "'a' cannot be empty" : null,
|
|
39
|
+
}) as const satisfies Validations<FormType>;
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
`perField` is the entry point that produces a `Validations<FormType>`-shaped
|
|
43
|
+
value while preserving the precise types of each individual validator.
|
|
44
|
+
|
|
45
|
+
### Why `as const satisfies` (both required)
|
|
46
|
+
|
|
47
|
+
- **`as const`** freezes the object's literal type — including each
|
|
48
|
+
validator's specific function type and any refinement markers attached
|
|
49
|
+
to it. Without `as const`, function types widen, markers are lost,
|
|
50
|
+
`Refine<>` has nothing to walk.
|
|
51
|
+
- **`satisfies Validations<FormType>`** checks shape against `FormType`
|
|
52
|
+
(every key valid, every validator's input compatible) without widening
|
|
53
|
+
the inferred type the way an annotation `: Validations<FormType>` would.
|
|
54
|
+
|
|
55
|
+
The combination = "type-check this shape, but don't lose any precision."
|
|
56
|
+
|
|
57
|
+
## Standard-library validators carry refinements
|
|
58
|
+
|
|
59
|
+
Built-in validators (e.g. `notEmpty`, `oneOf`, `matches`) expose a phantom
|
|
60
|
+
property whose type encodes the refinement they enforce. Sketch:
|
|
61
|
+
|
|
62
|
+
```ts
|
|
63
|
+
type Refinement<Excluded = never> = { readonly __excludes: Excluded };
|
|
64
|
+
|
|
65
|
+
type Validator<Input, Excluded = never> =
|
|
66
|
+
((val: Input) => string | null) & Refinement<Excluded>;
|
|
67
|
+
|
|
68
|
+
// notEmpty: rules out null, undefined, and the empty-string literal
|
|
69
|
+
const notEmpty = (fieldName: string): Validator<unknown, null | undefined | ''> =>
|
|
70
|
+
Object.assign(
|
|
71
|
+
(val: unknown) => (val == null || val === '') ? `'${fieldName}' cannot be empty` : null,
|
|
72
|
+
{} as Refinement<null | undefined | ''>,
|
|
73
|
+
);
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`__excludes` is the *operation* the validator performs at the type level
|
|
77
|
+
(`Exclude<FieldType, __excludes>`), not the resulting type — because the
|
|
78
|
+
field type isn't known when the validator is constructed. The result
|
|
79
|
+
type is computed per-field by `Refine<>` at the use site.
|
|
80
|
+
|
|
81
|
+
The marker is **required** on `Refinement<>` (not optional). This forces
|
|
82
|
+
every validator to declare its refinement explicitly — even validators
|
|
83
|
+
that narrow nothing must say so via `Excl = never`. It also keeps
|
|
84
|
+
`infer Excl` extraction reliable; optional properties make inference
|
|
85
|
+
unreliable in some intersections.
|
|
86
|
+
|
|
87
|
+
## `Refine<FormType, typeof constraints>`
|
|
88
|
+
|
|
89
|
+
Walks the constraints object, reads each field's validator's
|
|
90
|
+
`__excludes` marker, applies `Exclude<FormType[K], __excludes>`. Fields
|
|
91
|
+
without a constraint pass through unchanged. The result is the type
|
|
92
|
+
handed to `onSubmit`.
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
type FormType = { a: string | undefined; b: number };
|
|
96
|
+
type C = typeof constraints; // a: notEmpty
|
|
97
|
+
type SubmitType = Refine<FormType, C>; // { a: string; b: number }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Hook surface
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
useFormState({
|
|
104
|
+
initialValues: FormType,
|
|
105
|
+
constraints: Validations<FormType>, // optional; defaults to no refinement
|
|
106
|
+
onSubmit: (values: Refine<FormType, typeof constraints>) => void,
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Caveats — known and accepted
|
|
111
|
+
|
|
112
|
+
- **`Exclude<string, ''>` is still `string`.** The empty-string refinement
|
|
113
|
+
only narrows fields whose type is a *union* containing the literal `''`.
|
|
114
|
+
For plain `string`, `notEmpty` still validates at runtime, but the
|
|
115
|
+
submit type is unchanged. This is a TypeScript-not-our-problem
|
|
116
|
+
limitation; we don't paper over it.
|
|
117
|
+
- **Phantom markers must survive `as const`.** Any helper that wraps or
|
|
118
|
+
re-exports a validator must preserve its full type — no signature
|
|
119
|
+
re-annotation, no widening. If you write a higher-order validator
|
|
120
|
+
(e.g. `optional(notEmpty('a'))`), keep the marker conjunction explicit
|
|
121
|
+
in the return type.
|
|
122
|
+
- **No runtime carrier.** Markers are pure type-level. Don't `Object.assign`
|
|
123
|
+
real values onto validators except as opaque type tags — anything else
|
|
124
|
+
invites consumers to depend on the runtime shape.
|
|
125
|
+
|
|
126
|
+
## Why we're building it this way from the start
|
|
127
|
+
|
|
128
|
+
The whole carrier-type approach falls apart if it gets retrofitted onto
|
|
129
|
+
existing un-typed validators or onto a hook with looser types. The submit
|
|
130
|
+
type's correctness depends on every layer (validator construction,
|
|
131
|
+
`perField`, `as const`, `satisfies`, the hook signature) preserving
|
|
132
|
+
precision end-to-end. So we wire it up before adding any consumers.
|
|
133
|
+
|
|
134
|
+
## Layout
|
|
135
|
+
|
|
136
|
+
- `useFormState/` — the hook + the value-model types (`FormValueSimple`,
|
|
137
|
+
`FormValuesObject`, `FormValueList`).
|
|
138
|
+
- `path/` — typed cursor (`Path<T>`, `ValueAt<T,P>`, `Cursor<T>`, `read()`).
|
|
139
|
+
Will be used for granular setters and for surfacing per-field
|
|
140
|
+
errors/touched state. Coupled to the form value model intentionally.
|
|
141
|
+
- (planned) `validations/` — `perField`, `Validations<T>`, `Refine<T,V>`,
|
|
142
|
+
`Refinement<>` infra.
|
|
143
|
+
- (planned) `validators/` — the standard-library validators
|
|
144
|
+
(`notEmpty`, `oneOf`, `matches`, …) each carrying its own refinement.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type { Cursor, CursorStep, Path, PathStep, ValueAt } from './types';
|
|
2
|
+
|
|
3
|
+
const makeCursor = <T>(steps: readonly CursorStep[]): Cursor<T> => ({
|
|
4
|
+
at<P extends Path<T>>(p: P): Cursor<ValueAt<T, P>> {
|
|
5
|
+
const keySteps: CursorStep[] = (p as readonly PathStep[]).map((key) => ({
|
|
6
|
+
kind: 'key',
|
|
7
|
+
key,
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
return makeCursor<ValueAt<T, P>>([...steps, ...keySteps]);
|
|
11
|
+
},
|
|
12
|
+
narrow<U extends T>(predicate: (val: T) => val is U): Cursor<U> {
|
|
13
|
+
return makeCursor<U>([
|
|
14
|
+
...steps,
|
|
15
|
+
{ kind: 'narrow', predicate: predicate as (val: unknown) => boolean },
|
|
16
|
+
]);
|
|
17
|
+
},
|
|
18
|
+
build() {
|
|
19
|
+
return [...steps];
|
|
20
|
+
},
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
export const path = <T>(): Cursor<T> => makeCursor<T>([]);
|
|
24
|
+
|
|
25
|
+
// Walk steps against a value. Returns undefined if any step fails — missing
|
|
26
|
+
// key, out-of-bounds index, or a narrow predicate that rejects the current
|
|
27
|
+
// value. Callers using the typed cursor know what shape to expect; this is
|
|
28
|
+
// the runtime escape hatch that surfaces "the path didn't resolve."
|
|
29
|
+
export const read = (root: unknown, steps: readonly CursorStep[]): unknown => {
|
|
30
|
+
let cursor: unknown = root;
|
|
31
|
+
|
|
32
|
+
for (const step of steps) {
|
|
33
|
+
if (cursor == null) return undefined;
|
|
34
|
+
|
|
35
|
+
if (step.kind === 'narrow') {
|
|
36
|
+
if (!step.predicate(cursor)) return undefined;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (typeof step.key === 'number') {
|
|
41
|
+
if (!Array.isArray(cursor)) return undefined;
|
|
42
|
+
cursor = cursor[step.key];
|
|
43
|
+
} else {
|
|
44
|
+
if (typeof cursor !== 'object') return undefined;
|
|
45
|
+
cursor = (cursor as Record<string, unknown>)[step.key];
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return cursor;
|
|
50
|
+
};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from 'vitest';
|
|
2
|
+
import { path } from './path';
|
|
3
|
+
import type { Cursor, Path, ValueAt } from './types';
|
|
4
|
+
|
|
5
|
+
type FlatObj = {
|
|
6
|
+
name: string;
|
|
7
|
+
age: number;
|
|
8
|
+
tags: string[]; // string[] is a FormValueSimple — treated as a leaf
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type NestedObj = {
|
|
12
|
+
user: {
|
|
13
|
+
profile: { name: string; email: string };
|
|
14
|
+
settings: { theme: string };
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type WithList = {
|
|
19
|
+
items: Array<{ title: string; qty: number }>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type Mixed = {
|
|
23
|
+
user: { name: string };
|
|
24
|
+
items: Array<{
|
|
25
|
+
id: number;
|
|
26
|
+
nested: { label: string };
|
|
27
|
+
}>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
describe('Path<T>', () => {
|
|
31
|
+
it('expands flat object keys to single-step paths', () => {
|
|
32
|
+
type P = Path<FlatObj>;
|
|
33
|
+
expectTypeOf<P>().toEqualTypeOf<['name'] | ['age'] | ['tags']>();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('descends into nested objects', () => {
|
|
37
|
+
type P = Path<NestedObj>;
|
|
38
|
+
expectTypeOf<P>().toEqualTypeOf<
|
|
39
|
+
| ['user']
|
|
40
|
+
| ['user', 'profile']
|
|
41
|
+
| ['user', 'profile', 'name']
|
|
42
|
+
| ['user', 'profile', 'email']
|
|
43
|
+
| ['user', 'settings']
|
|
44
|
+
| ['user', 'settings', 'theme']
|
|
45
|
+
>();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('uses `number` for list indices', () => {
|
|
49
|
+
type P = Path<WithList>;
|
|
50
|
+
expectTypeOf<P>().toEqualTypeOf<
|
|
51
|
+
| ['items']
|
|
52
|
+
| ['items', number]
|
|
53
|
+
| ['items', number, 'title']
|
|
54
|
+
| ['items', number, 'qty']
|
|
55
|
+
>();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('combines object and list traversal', () => {
|
|
59
|
+
type P = Path<Mixed>;
|
|
60
|
+
expectTypeOf<P>().toEqualTypeOf<
|
|
61
|
+
| ['user']
|
|
62
|
+
| ['user', 'name']
|
|
63
|
+
| ['items']
|
|
64
|
+
| ['items', number]
|
|
65
|
+
| ['items', number, 'id']
|
|
66
|
+
| ['items', number, 'nested']
|
|
67
|
+
| ['items', number, 'nested', 'label']
|
|
68
|
+
>();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('produces `never` for FormValueSimple leaves', () => {
|
|
72
|
+
expectTypeOf<Path<string>>().toEqualTypeOf<never>();
|
|
73
|
+
expectTypeOf<Path<number>>().toEqualTypeOf<never>();
|
|
74
|
+
expectTypeOf<Path<string[]>>().toEqualTypeOf<never>();
|
|
75
|
+
expectTypeOf<Path<undefined>>().toEqualTypeOf<never>();
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe('ValueAt<T, P>', () => {
|
|
80
|
+
it('returns T when the path is empty', () => {
|
|
81
|
+
expectTypeOf<ValueAt<FlatObj, []>>().toEqualTypeOf<FlatObj>();
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('resolves a single key', () => {
|
|
85
|
+
expectTypeOf<ValueAt<FlatObj, ['name']>>().toEqualTypeOf<string>();
|
|
86
|
+
expectTypeOf<ValueAt<FlatObj, ['age']>>().toEqualTypeOf<number>();
|
|
87
|
+
expectTypeOf<ValueAt<FlatObj, ['tags']>>().toEqualTypeOf<string[]>();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('resolves a nested object path', () => {
|
|
91
|
+
expectTypeOf<ValueAt<NestedObj, ['user']>>().toEqualTypeOf<
|
|
92
|
+
NestedObj['user']
|
|
93
|
+
>();
|
|
94
|
+
expectTypeOf<
|
|
95
|
+
ValueAt<NestedObj, ['user', 'profile', 'email']>
|
|
96
|
+
>().toEqualTypeOf<string>();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('walks into list elements via a numeric step', () => {
|
|
100
|
+
expectTypeOf<ValueAt<WithList, ['items']>>().toEqualTypeOf<
|
|
101
|
+
Array<{ title: string; qty: number }>
|
|
102
|
+
>();
|
|
103
|
+
expectTypeOf<ValueAt<WithList, ['items', number]>>().toEqualTypeOf<{
|
|
104
|
+
title: string;
|
|
105
|
+
qty: number;
|
|
106
|
+
}>();
|
|
107
|
+
expectTypeOf<
|
|
108
|
+
ValueAt<WithList, ['items', number, 'title']>
|
|
109
|
+
>().toEqualTypeOf<string>();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('resolves a deep mixed path', () => {
|
|
113
|
+
expectTypeOf<
|
|
114
|
+
ValueAt<Mixed, ['items', number, 'nested', 'label']>
|
|
115
|
+
>().toEqualTypeOf<string>();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('returns `never` for an unknown key', () => {
|
|
119
|
+
// ValueAt's constraint is just `readonly PathStep[]`, so unknown keys
|
|
120
|
+
// don't error at the call site — they resolve to `never`.
|
|
121
|
+
expectTypeOf<ValueAt<FlatObj, ['nope']>>().toEqualTypeOf<never>();
|
|
122
|
+
expectTypeOf<ValueAt<FlatObj, [string]>>().toEqualTypeOf<never>();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it('returns `never` when indexing a non-list with a number', () => {
|
|
126
|
+
type Bad = ValueAt<FlatObj, [number]>;
|
|
127
|
+
expectTypeOf<Bad>().toEqualTypeOf<never>();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe('Cursor<T>', () => {
|
|
132
|
+
it('infers ValueAt for a single .at() step', () => {
|
|
133
|
+
const c = path<NestedObj>().at(['user']);
|
|
134
|
+
expectTypeOf(c).toEqualTypeOf<Cursor<NestedObj['user']>>();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('chains .at() across nested objects', () => {
|
|
138
|
+
const c = path<NestedObj>().at(['user']).at(['profile']).at(['name']);
|
|
139
|
+
expectTypeOf(c).toEqualTypeOf<Cursor<string>>();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('descends into list elements via a numeric step', () => {
|
|
143
|
+
const c = path<WithList>().at(['items', 0, 'title']);
|
|
144
|
+
expectTypeOf(c).toEqualTypeOf<Cursor<string>>();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('rejects paths that are not part of Path<T>', () => {
|
|
148
|
+
// @ts-expect-error 'nope' is not a valid first step on NestedObj
|
|
149
|
+
path<NestedObj>().at(['nope']);
|
|
150
|
+
|
|
151
|
+
// @ts-expect-error string keys can't index a list
|
|
152
|
+
path<WithList>().at(['items', 'title']);
|
|
153
|
+
|
|
154
|
+
// @ts-expect-error number keys can't index a non-list object
|
|
155
|
+
path<NestedObj>().at(['user', 0]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('narrow() refines the cursor type', () => {
|
|
159
|
+
const c = path<{ value: string | number }>().at(['value']);
|
|
160
|
+
expectTypeOf(c).toEqualTypeOf<Cursor<string | number>>();
|
|
161
|
+
|
|
162
|
+
const narrowed = c.narrow((v): v is string => typeof v === 'string');
|
|
163
|
+
expectTypeOf(narrowed).toEqualTypeOf<Cursor<string>>();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('build() returns the recorded steps', () => {
|
|
167
|
+
const steps = path<NestedObj>().at(['user', 'profile', 'name']).build();
|
|
168
|
+
expectTypeOf(steps).toMatchTypeOf<
|
|
169
|
+
Array<
|
|
170
|
+
| { kind: 'key'; key: string | number }
|
|
171
|
+
| { kind: 'narrow'; predicate: (v: unknown) => boolean }
|
|
172
|
+
>
|
|
173
|
+
>();
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { FormValuesObject, FormValueList } from '../useFormState/types';
|
|
2
|
+
|
|
3
|
+
export type PathStep = string | number;
|
|
4
|
+
|
|
5
|
+
export type Path<T> =
|
|
6
|
+
T extends FormValuesObject
|
|
7
|
+
? {
|
|
8
|
+
[K in Extract<keyof T, PathStep>]: [K] | [K, ...Path<T[K]>];
|
|
9
|
+
}[Extract<keyof T, PathStep>]
|
|
10
|
+
: T extends FormValueList
|
|
11
|
+
? [number] | [number, ...Path<T[number]>]
|
|
12
|
+
: never;
|
|
13
|
+
|
|
14
|
+
export type ValueAt<T, P extends readonly PathStep[]> =
|
|
15
|
+
P extends readonly [infer Head, ...infer Rest]
|
|
16
|
+
? Rest extends readonly PathStep[]
|
|
17
|
+
? Head extends number
|
|
18
|
+
? T extends FormValueList
|
|
19
|
+
? ValueAt<T[number], Rest>
|
|
20
|
+
: never
|
|
21
|
+
: Head extends keyof T
|
|
22
|
+
? ValueAt<T[Head], Rest>
|
|
23
|
+
: never
|
|
24
|
+
: never
|
|
25
|
+
: T;
|
|
26
|
+
|
|
27
|
+
export type CursorStep =
|
|
28
|
+
| { kind: 'key'; key: PathStep }
|
|
29
|
+
| { kind: 'narrow'; predicate: (val: unknown) => boolean };
|
|
30
|
+
|
|
31
|
+
export type Cursor<T> = {
|
|
32
|
+
at<P extends Path<T>>(path: P): Cursor<ValueAt<T, P>>;
|
|
33
|
+
narrow<U extends T>(predicate: (val: T) => val is U): Cursor<U>;
|
|
34
|
+
build(): CursorStep[];
|
|
35
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export type FormValueSimple = string | number | bigint | string[] | Set<string> | undefined | null;
|
|
2
|
+
|
|
3
|
+
export type FormValuesObject = { [k in string]: FormValue };
|
|
4
|
+
|
|
5
|
+
export type FormValueList = FormValuesObject[];
|
|
6
|
+
|
|
7
|
+
export type FormValue = FormValuesObject | FormValueList | FormValueSimple;
|
|
8
|
+
|
|
9
|
+
export type FormHelpers<T extends FormValuesObject> = {
|
|
10
|
+
values: T;
|
|
11
|
+
// todo: likely remove once other setter are available
|
|
12
|
+
onValueChanges: (val: T | ((prev: T) => T)) => void;
|
|
13
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { FormHelpers, FormValuesObject } from './types';
|
|
3
|
+
|
|
4
|
+
type Args<T extends FormValuesObject> = {
|
|
5
|
+
initialValues: T;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const useFormState = <T extends FormValuesObject>(args: Args<T>): FormHelpers<T> => {
|
|
9
|
+
const { initialValues } = args;
|
|
10
|
+
|
|
11
|
+
const [values, onValueChanges] = useState<T>(initialValues);
|
|
12
|
+
|
|
13
|
+
return { values, onValueChanges };
|
|
14
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, it, expectTypeOf } from 'vitest';
|
|
2
|
+
import type { Refinement, Validator } from './types';
|
|
3
|
+
|
|
4
|
+
describe('Validator<Input, Excluded>', () => {
|
|
5
|
+
it('preserves the input type as the call parameter', () => {
|
|
6
|
+
type V = Validator<string | undefined, null | undefined>;
|
|
7
|
+
expectTypeOf<V>().parameter(0).toEqualTypeOf<string | undefined>();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it('always returns string | null', () => {
|
|
11
|
+
type V = Validator<string, never>;
|
|
12
|
+
expectTypeOf<V>().returns.toEqualTypeOf<string | null>();
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('carries the Excluded type, recoverable via `Refinement<infer X>`', () => {
|
|
16
|
+
type V = Validator<string | undefined, null | undefined | ''>;
|
|
17
|
+
type Extracted = V extends Refinement<infer X> ? X : never;
|
|
18
|
+
expectTypeOf<Extracted>().toEqualTypeOf<null | undefined | ''>();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('defaults Excluded to never when omitted', () => {
|
|
22
|
+
type V = Validator<string>;
|
|
23
|
+
type Extracted = V extends Refinement<infer X> ? X : never;
|
|
24
|
+
expectTypeOf<Extracted>().toBeNever();
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Phantom marker carried by validators. The runtime value of `__excludes`
|
|
2
|
+
// is meaningless and never read — only its declared type matters. Required
|
|
3
|
+
// (not optional) so validators can't accidentally drop the marker, and so
|
|
4
|
+
// `Refine<>` can rely on extraction working uniformly. Construction sites
|
|
5
|
+
// supply the marker via a cast: `Object.assign(fn, {} as Refinement<X>)`.
|
|
6
|
+
export type Refinement<Excluded = never> = {
|
|
7
|
+
readonly __excludes: Excluded;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
// A validator is a plain `(val: Input) => string | null` function intersected
|
|
11
|
+
// with a `Refinement<Excluded>` marker. `Excluded` defaults to `never`,
|
|
12
|
+
// meaning "this validator narrows nothing at the type level" (it still
|
|
13
|
+
// validates at runtime; it just doesn't shrink the field's submit type).
|
|
14
|
+
export type Validator<Input, Excluded = never> =
|
|
15
|
+
((val: Input) => string | null) & Refinement<Excluded>;
|