@structuralists/scaffolding 0.0.2 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.storybook/main.ts +1 -1
- package/.storybook/preview.tsx +36 -1
- package/CLAUDE.md +25 -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/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
|
@@ -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>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect, useRef, type RefObject } from 'react';
|
|
2
|
+
import { useStableCallback } from '../useStableCallback';
|
|
3
|
+
|
|
4
|
+
type UseClickOutsideArgs<Element extends HTMLElement> = {
|
|
5
|
+
/** Optional external ref. When omitted, the hook owns an internal ref —
|
|
6
|
+
* attach the returned `ref` to the element you want to guard. */
|
|
7
|
+
ref?: RefObject<Element | null>;
|
|
8
|
+
/** Callback fired on a `pointerdown` outside the guarded element. When
|
|
9
|
+
* `undefined`, the listener is detached (lets callers gate via a prop
|
|
10
|
+
* without conditionally calling the hook). */
|
|
11
|
+
onOutside: (() => void) | undefined;
|
|
12
|
+
/** Detach the listener when false. Defaults to true. */
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type UseClickOutsideReturn<Element extends HTMLElement> = {
|
|
17
|
+
/** Mirror of the passed `ref`, or an internal ref when none was passed.
|
|
18
|
+
* Attach this to the element to be guarded. */
|
|
19
|
+
ref: RefObject<Element | null>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** Calls `onOutside` when a `pointerdown` lands outside the guarded
|
|
23
|
+
* element. `pointerdown` is used so dismissal precedes focus shifts and
|
|
24
|
+
* text-selection starts on the underlying target.
|
|
25
|
+
*
|
|
26
|
+
* The callback is internally stabilized, so callers can pass an inline
|
|
27
|
+
* function without retriggering the listener attach/detach effect.
|
|
28
|
+
*
|
|
29
|
+
* Note: clicks inside portal'd descendants of the guarded element
|
|
30
|
+
* (popovers, tooltips, menus) count as outside since they are not
|
|
31
|
+
* DOM-contained. Compose with additional refs or a custom predicate
|
|
32
|
+
* if that is not desired. */
|
|
33
|
+
export const useClickOutside = <Element extends HTMLElement = HTMLElement>(
|
|
34
|
+
args: UseClickOutsideArgs<Element>,
|
|
35
|
+
): UseClickOutsideReturn<Element> => {
|
|
36
|
+
const { ref: passedRef, onOutside, enabled = true } = args;
|
|
37
|
+
|
|
38
|
+
const innerRef = useRef<Element | null>(null);
|
|
39
|
+
const ref = passedRef ?? innerRef;
|
|
40
|
+
|
|
41
|
+
const stableOnOutside = useStableCallback({ callback: onOutside });
|
|
42
|
+
const isActive = enabled && onOutside != null;
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (!isActive) return;
|
|
46
|
+
const handler = (event: PointerEvent) => {
|
|
47
|
+
const el = ref.current;
|
|
48
|
+
if (el == null) return;
|
|
49
|
+
if (event.target instanceof Node && el.contains(event.target)) return;
|
|
50
|
+
stableOnOutside();
|
|
51
|
+
};
|
|
52
|
+
document.addEventListener('pointerdown', handler);
|
|
53
|
+
return () => document.removeEventListener('pointerdown', handler);
|
|
54
|
+
}, [ref, isActive, stableOnOutside]);
|
|
55
|
+
|
|
56
|
+
return { ref };
|
|
57
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
type UseStableCallbackArgs<Args extends readonly unknown[], Return> = {
|
|
4
|
+
callback: ((...args: Args) => Return) | undefined;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
/** Returns a function reference that is stable for the component's lifetime
|
|
8
|
+
* but always invokes the latest `callback`. Use this to keep effect dep
|
|
9
|
+
* arrays from re-firing when a parent passes an inline callback prop.
|
|
10
|
+
*
|
|
11
|
+
* The wrapper is held in `useState` (not `useCallback`) because React may
|
|
12
|
+
* discard a `useCallback` memo at any time, which would silently break
|
|
13
|
+
* identity-based dependency tracking; `useState`'s initial value is
|
|
14
|
+
* guaranteed stable for the mount's lifetime.
|
|
15
|
+
*
|
|
16
|
+
* The latest callback is committed via `useEffect`, so the wrapper still
|
|
17
|
+
* reflects the previous value during a given render — call it from event
|
|
18
|
+
* handlers and effects, not inline during render. */
|
|
19
|
+
export const useStableCallback = <Args extends readonly unknown[], Return>(
|
|
20
|
+
args: UseStableCallbackArgs<Args, Return>,
|
|
21
|
+
): ((...args: Args) => Return | undefined) => {
|
|
22
|
+
const { callback } = args;
|
|
23
|
+
const callbackRef = useRef(callback);
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
callbackRef.current = callback;
|
|
27
|
+
}, [callback]);
|
|
28
|
+
|
|
29
|
+
const [stableFn] = useState(
|
|
30
|
+
() =>
|
|
31
|
+
(...callArgs: Args): Return | undefined =>
|
|
32
|
+
callbackRef.current?.(...callArgs),
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
return stableFn;
|
|
36
|
+
};
|
package/src/index.ts
CHANGED
|
@@ -85,3 +85,5 @@ export { RelativeTime, type RelativeTimeProps } from './components/Primitives/Re
|
|
|
85
85
|
export { LinedStack, type LinedStackProps } from './components/Primitives/LinedStack';
|
|
86
86
|
export { LongText, type LongTextProps } from './components/Primitives/LongText';
|
|
87
87
|
export { type BackgroundToken } from './tokens';
|
|
88
|
+
export { useStableCallback } from './hooks/useStableCallback';
|
|
89
|
+
export { useClickOutside } from './hooks/useClickOutside';
|