@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.
Files changed (67) hide show
  1. package/.storybook/main.ts +1 -1
  2. package/.storybook/preview.tsx +36 -1
  3. package/CLAUDE.md +25 -0
  4. package/bun.lock +211 -202
  5. package/eslint.config.mjs +85 -84
  6. package/package.json +21 -20
  7. package/roadmap.md +27 -0
  8. package/src/components/Chat/ChatComposer/ChatComposer.stories.tsx +1 -1
  9. package/src/components/Chat/ChatMessage/ChatMessage.stories.tsx +1 -1
  10. package/src/components/Chat/ChatRecipientsHeader/ChatRecipientsHeader.stories.tsx +1 -1
  11. package/src/components/Chat/ChatShell/ChatShell.stories.tsx +1 -1
  12. package/src/components/Chat/PillCombobox/PillCombobox.stories.tsx +1 -1
  13. package/src/components/Content/Badge/Badge.stories.tsx +1 -1
  14. package/src/components/Content/Card/Card.stories.tsx +1 -1
  15. package/src/components/Content/EditableMarkdown/EditableMarkdown.stories.tsx +1 -1
  16. package/src/components/Content/Heading/Heading.stories.tsx +1 -1
  17. package/src/components/Content/Link/Link.stories.tsx +1 -1
  18. package/src/components/Content/List/List.stories.tsx +1 -1
  19. package/src/components/Content/LoadingContainer/LoadingContainer.stories.tsx +1 -1
  20. package/src/components/Content/Markdown/Markdown.stories.tsx +1 -1
  21. package/src/components/Content/Menu/Menu.stories.tsx +1 -1
  22. package/src/components/Content/Text/Text.stories.tsx +1 -1
  23. package/src/components/Forms/Button/Button.stories.tsx +1 -1
  24. package/src/components/Forms/Field/Field.stories.tsx +1 -1
  25. package/src/components/Forms/IconButton/IconButton.stories.tsx +1 -1
  26. package/src/components/Forms/Input/Input.stories.tsx +1 -1
  27. package/src/components/Forms/Select/MultiSelect/MultiSelect.stories.tsx +1 -1
  28. package/src/components/Forms/Select/SingleSelect/SingleSelect.stories.tsx +1 -1
  29. package/src/components/Forms/Textarea/Textarea.stories.tsx +1 -1
  30. package/src/components/Json/Json/Json.stories.tsx +1 -1
  31. package/src/components/Json/JsonTable/JsonTable.stories.tsx +1 -1
  32. package/src/components/Layout/Bar/Bar.stories.tsx +1 -1
  33. package/src/components/Layout/Debug/Debug.stories.tsx +1 -1
  34. package/src/components/Layout/Divider/Divider.stories.tsx +1 -1
  35. package/src/components/Layout/Grid/Grid.stories.tsx +1 -1
  36. package/src/components/Layout/Panels/Panels.stories.tsx +47 -1
  37. package/src/components/Layout/Panels/index.tsx +17 -1
  38. package/src/components/Layout/Panels/types.ts +5 -0
  39. package/src/components/Layout/Stack/Stack.stories.tsx +1 -1
  40. package/src/components/Modals/ConfirmModal/ConfirmModal.stories.tsx +1 -1
  41. package/src/components/Modals/LargeModal/LargeModal.stories.tsx +1 -1
  42. package/src/components/Modals/MediumModal/MediumModal.stories.tsx +1 -1
  43. package/src/components/Navigation/TabBar/TabBar.stories.tsx +1 -1
  44. package/src/components/Navigation/VerticalNav/VerticalNav.stories.tsx +1 -1
  45. package/src/components/Overlays/Popover/Popover.stories.tsx +1 -1
  46. package/src/components/Overlays/Tooltip/Tooltip.stories.tsx +1 -1
  47. package/src/components/Primitives/EmptyValue/EmptyValue.stories.tsx +1 -1
  48. package/src/components/Primitives/LinedStack/LinedStack.stories.tsx +1 -1
  49. package/src/components/Primitives/LongText/LongText.stories.tsx +1 -1
  50. package/src/components/Primitives/Num/Num.stories.tsx +1 -1
  51. package/src/components/Primitives/Percent/Percent.stories.tsx +1 -1
  52. package/src/components/Primitives/RelativeTime/RelativeTime.stories.tsx +1 -1
  53. package/src/components/Tables/BigTable/BigTable.stories.tsx +1 -1
  54. package/src/components/Tables/QuickTable/QuickTable.stories.tsx +1 -1
  55. package/src/forms/CLAUDE.md +144 -0
  56. package/src/forms/path/path.ts +50 -0
  57. package/src/forms/path/types.test-d.ts +175 -0
  58. package/src/forms/path/types.ts +35 -0
  59. package/src/forms/useFormState/types.ts +13 -0
  60. package/src/forms/useFormState/useFormState.ts +14 -0
  61. package/src/forms/validations/types.test-d.ts +26 -0
  62. package/src/forms/validations/types.ts +15 -0
  63. package/src/hooks/useClickOutside/index.ts +57 -0
  64. package/src/hooks/useStableCallback/index.ts +36 -0
  65. package/src/index.ts +2 -0
  66. package/src/storybook/Composition.stories.tsx +1 -1
  67. package/src/storybook/_StoryUtils.stories.tsx +1 -1
@@ -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>;
@@ -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';