@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.
Files changed (70) hide show
  1. package/.github/workflows/publish.yml +66 -0
  2. package/.storybook/main.ts +1 -1
  3. package/.storybook/preview.tsx +5 -1
  4. package/CLAUDE.md +25 -0
  5. package/README.md +79 -0
  6. package/bun.lock +211 -202
  7. package/eslint.config.mjs +85 -84
  8. package/package.json +21 -20
  9. package/roadmap.md +27 -0
  10. package/src/components/Chat/ChatComposer/ChatComposer.stories.tsx +1 -1
  11. package/src/components/Chat/ChatMessage/ChatMessage.stories.tsx +1 -1
  12. package/src/components/Chat/ChatRecipientsHeader/ChatRecipientsHeader.stories.tsx +1 -1
  13. package/src/components/Chat/ChatShell/ChatShell.stories.tsx +1 -1
  14. package/src/components/Chat/PillCombobox/PillCombobox.stories.tsx +1 -1
  15. package/src/components/Content/Badge/Badge.stories.tsx +1 -1
  16. package/src/components/Content/Card/Card.stories.tsx +1 -1
  17. package/src/components/Content/EditableMarkdown/EditableMarkdown.stories.tsx +1 -1
  18. package/src/components/Content/Heading/Heading.stories.tsx +1 -1
  19. package/src/components/Content/Link/Link.stories.tsx +1 -1
  20. package/src/components/Content/List/List.stories.tsx +1 -1
  21. package/src/components/Content/LoadingContainer/LoadingContainer.stories.tsx +1 -1
  22. package/src/components/Content/Markdown/Markdown.stories.tsx +1 -1
  23. package/src/components/Content/Menu/Menu.stories.tsx +1 -1
  24. package/src/components/Content/Text/Text.stories.tsx +1 -1
  25. package/src/components/Forms/Button/Button.stories.tsx +1 -1
  26. package/src/components/Forms/Field/Field.stories.tsx +1 -1
  27. package/src/components/Forms/IconButton/IconButton.stories.tsx +1 -1
  28. package/src/components/Forms/Input/Input.stories.tsx +1 -1
  29. package/src/components/Forms/Select/MultiSelect/MultiSelect.stories.tsx +1 -1
  30. package/src/components/Forms/Select/SingleSelect/SingleSelect.stories.tsx +1 -1
  31. package/src/components/Forms/Textarea/Textarea.stories.tsx +1 -1
  32. package/src/components/Json/Json/Json.stories.tsx +1 -1
  33. package/src/components/Json/JsonTable/JsonTable.stories.tsx +1 -1
  34. package/src/components/Layout/Bar/Bar.stories.tsx +1 -1
  35. package/src/components/Layout/Debug/Debug.stories.tsx +1 -1
  36. package/src/components/Layout/Divider/Divider.stories.tsx +1 -1
  37. package/src/components/Layout/Grid/Grid.stories.tsx +1 -1
  38. package/src/components/Layout/Panels/Panels.stories.tsx +47 -1
  39. package/src/components/Layout/Panels/index.tsx +17 -1
  40. package/src/components/Layout/Panels/types.ts +5 -0
  41. package/src/components/Layout/Stack/Stack.stories.tsx +1 -1
  42. package/src/components/Modals/ConfirmModal/ConfirmModal.stories.tsx +1 -1
  43. package/src/components/Modals/LargeModal/LargeModal.stories.tsx +1 -1
  44. package/src/components/Modals/MediumModal/MediumModal.stories.tsx +1 -1
  45. package/src/components/Modals/MediumModal/MediumModal.test.tsx +11 -11
  46. package/src/components/Navigation/TabBar/TabBar.stories.tsx +1 -1
  47. package/src/components/Navigation/VerticalNav/VerticalNav.stories.tsx +1 -1
  48. package/src/components/Overlays/Popover/Popover.stories.tsx +1 -1
  49. package/src/components/Overlays/Tooltip/Tooltip.stories.tsx +1 -1
  50. package/src/components/Primitives/EmptyValue/EmptyValue.stories.tsx +1 -1
  51. package/src/components/Primitives/LinedStack/LinedStack.stories.tsx +1 -1
  52. package/src/components/Primitives/LongText/LongText.stories.tsx +1 -1
  53. package/src/components/Primitives/Num/Num.stories.tsx +1 -1
  54. package/src/components/Primitives/Percent/Percent.stories.tsx +1 -1
  55. package/src/components/Primitives/RelativeTime/RelativeTime.stories.tsx +1 -1
  56. package/src/components/Tables/BigTable/BigTable.stories.tsx +1 -1
  57. package/src/components/Tables/QuickTable/QuickTable.stories.tsx +1 -1
  58. package/src/forms/CLAUDE.md +144 -0
  59. package/src/forms/path/path.ts +50 -0
  60. package/src/forms/path/types.test-d.ts +175 -0
  61. package/src/forms/path/types.ts +35 -0
  62. package/src/forms/useFormState/types.ts +13 -0
  63. package/src/forms/useFormState/useFormState.ts +14 -0
  64. package/src/forms/validations/types.test-d.ts +26 -0
  65. package/src/forms/validations/types.ts +15 -0
  66. package/src/hooks/useClickOutside/index.ts +57 -0
  67. package/src/hooks/useStableCallback/index.ts +36 -0
  68. package/src/index.ts +2 -0
  69. package/src/storybook/Composition.stories.tsx +1 -1
  70. 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 dialog = utils.container.ownerDocument.querySelector('dialog');
12
+ const doc = utils.container.ownerDocument;
13
+ const dialog = doc.querySelector('[role="dialog"]');
15
14
  if (!dialog) throw new Error('dialog not rendered');
16
- return { ...utils, onClose, dialog };
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 (the dialog itself) is clicked', () => {
28
- const { onClose, dialog } = setup();
29
- fireEvent.click(dialog);
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 the native cancel event (Escape)', () => {
42
- const { onClose, dialog } = setup();
43
- const cancel = new Event('cancel', { cancelable: true });
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
  });
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react';
2
- import type { Meta, StoryObj } from '@storybook/react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
3
  import { TabBar } from './index';
4
4
 
5
5
  const meta: Meta<typeof TabBar> = {
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react';
2
- import type { Meta, StoryObj } from '@storybook/react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
3
  import { VerticalNav } from './index';
4
4
 
5
5
  const meta: Meta<typeof VerticalNav> = {
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { useRef, useState } from 'react';
3
3
  import { Popover } from './index';
4
4
  import { Button } from '../../Forms/Button';
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Tooltip } from './index';
3
3
  import type { TooltipPlacement } from './types';
4
4
  import { Button } from '../../Forms/Button';
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { EmptyValue } from './index';
3
3
 
4
4
  const meta: Meta<typeof EmptyValue> = {
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { LinedStack } from './index';
3
3
  import { Text } from '../../Content/Text';
4
4
 
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { LongText } from './index';
3
3
 
4
4
  const meta: Meta<typeof LongText> = {
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Num } from './index';
3
3
 
4
4
  const meta: Meta<typeof Num> = {
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { Percent } from './index';
3
3
 
4
4
  const meta: Meta<typeof Percent> = {
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import { RelativeTime } from './index';
3
3
 
4
4
  const meta: Meta<typeof RelativeTime> = {
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react';
2
- import type { Meta, StoryObj } from '@storybook/react';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
3
  import {
4
4
  BigTable,
5
5
  badgeColumn,
@@ -1,4 +1,4 @@
1
- import type { Meta, StoryObj } from '@storybook/react';
1
+ import type { Meta, StoryObj } from '@storybook/react-vite';
2
2
  import {
3
3
  QuickTable,
4
4
  QuickTableCell,
@@ -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>;