@structuralists/scaffolding 0.4.2 → 0.5.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.
@@ -0,0 +1,134 @@
1
+ import { describe, test, expect, mock } from 'bun:test';
2
+ import { act, renderHook } from '@testing-library/react';
3
+ import { useFormState } from './useFormState';
4
+ import { allOf, matches, notEmpty } from '../validators/validators';
5
+
6
+ type SignupForm = {
7
+ email: string | undefined;
8
+ nickname: string | undefined;
9
+ };
10
+
11
+ const initialValues: SignupForm = { email: undefined, nickname: undefined };
12
+
13
+ describe('useFormState', () => {
14
+ test('exposes initial values', () => {
15
+ const { result } = renderHook(() => useFormState({ initialValues }));
16
+ expect(result.current.values).toEqual(initialValues);
17
+ });
18
+
19
+ test('onValueChanges accepts a replacement object', () => {
20
+ const { result } = renderHook(() => useFormState({ initialValues }));
21
+ act(() => {
22
+ result.current.onValueChanges({ email: 'a@b.co', nickname: 'will' });
23
+ });
24
+ expect(result.current.values.email).toBe('a@b.co');
25
+ });
26
+
27
+ test('onValueChanges accepts an updater function', () => {
28
+ const { result } = renderHook(() => useFormState({ initialValues }));
29
+ act(() => {
30
+ result.current.onValueChanges((prev) => ({ ...prev, nickname: 'will' }));
31
+ });
32
+ expect(result.current.values).toEqual({ email: undefined, nickname: 'will' });
33
+ });
34
+
35
+ test('errors are derived live from the current values', () => {
36
+ const { result } = renderHook(() =>
37
+ useFormState({
38
+ initialValues,
39
+ constraints: { email: notEmpty('email') },
40
+ }),
41
+ );
42
+ expect(result.current.errors.email).toBe("'email' cannot be empty");
43
+ expect(result.current.isValid).toBe(false);
44
+
45
+ act(() => {
46
+ result.current.onValueChanges((prev) => ({ ...prev, email: 'a@b.co' }));
47
+ });
48
+ expect(result.current.errors.email).toBeUndefined();
49
+ expect(result.current.isValid).toBe(true);
50
+ });
51
+
52
+ test('fields without constraints never produce errors', () => {
53
+ const { result } = renderHook(() =>
54
+ useFormState({
55
+ initialValues,
56
+ constraints: { email: notEmpty('email') },
57
+ }),
58
+ );
59
+ expect(result.current.errors.nickname).toBeUndefined();
60
+ });
61
+
62
+ test('a form without constraints is always valid', () => {
63
+ const { result } = renderHook(() => useFormState({ initialValues }));
64
+ expect(result.current.isValid).toBe(true);
65
+ expect(result.current.errors).toEqual({});
66
+ });
67
+
68
+ test('submit on an invalid form marks the attempt and skips onSubmit', () => {
69
+ const onSubmit = mock(() => {});
70
+ const { result } = renderHook(() =>
71
+ useFormState({
72
+ initialValues,
73
+ constraints: { email: notEmpty('email') },
74
+ onSubmit,
75
+ }),
76
+ );
77
+ expect(result.current.submitAttempted).toBe(false);
78
+
79
+ act(() => {
80
+ result.current.submit();
81
+ });
82
+ expect(result.current.submitAttempted).toBe(true);
83
+ expect(onSubmit).not.toHaveBeenCalled();
84
+ });
85
+
86
+ test('submit on a valid form calls onSubmit with the current values', () => {
87
+ const onSubmit = mock(() => {});
88
+ const { result } = renderHook(() =>
89
+ useFormState({
90
+ initialValues,
91
+ constraints: {
92
+ email: allOf(notEmpty('email'), matches('email', /@/, 'a valid email')),
93
+ },
94
+ onSubmit,
95
+ }),
96
+ );
97
+
98
+ act(() => {
99
+ result.current.onValueChanges({ email: 'a@b.co', nickname: undefined });
100
+ });
101
+ act(() => {
102
+ result.current.submit();
103
+ });
104
+ expect(onSubmit).toHaveBeenCalledTimes(1);
105
+ expect(onSubmit).toHaveBeenCalledWith({
106
+ email: 'a@b.co',
107
+ nickname: undefined,
108
+ });
109
+ });
110
+
111
+ test('a failed submit followed by a fix allows the next submit through', () => {
112
+ const onSubmit = mock(() => {});
113
+ const { result } = renderHook(() =>
114
+ useFormState({
115
+ initialValues,
116
+ constraints: { email: notEmpty('email') },
117
+ onSubmit,
118
+ }),
119
+ );
120
+
121
+ act(() => {
122
+ result.current.submit();
123
+ });
124
+ expect(onSubmit).not.toHaveBeenCalled();
125
+
126
+ act(() => {
127
+ result.current.onValueChanges((prev) => ({ ...prev, email: 'a@b.co' }));
128
+ });
129
+ act(() => {
130
+ result.current.submit();
131
+ });
132
+ expect(onSubmit).toHaveBeenCalledTimes(1);
133
+ });
134
+ });
@@ -1,14 +1,50 @@
1
1
  import { useState } from 'react';
2
- import type { FormHelpers, FormValuesObject } from './types';
2
+ import type { FormErrors, FormHelpers, FormValuesObject } from './types';
3
+ import type { Refine, Validations } from '../validations/types';
3
4
 
4
- type Args<T extends FormValuesObject> = {
5
+ // `const V` freezes the inferred type of an inline `constraints` object —
6
+ // each validator's precise type and Refinement marker survive without any
7
+ // `as const` at the call site. Constraint objects built outside the call
8
+ // still need `as const satisfies Validations<T>` (see src/forms/CLAUDE.md).
9
+ type Args<T extends FormValuesObject, V extends Validations<T>> = {
5
10
  initialValues: T;
11
+ constraints?: V;
12
+ onSubmit?: (values: Refine<T, V>) => void;
6
13
  };
7
14
 
8
- export const useFormState = <T extends FormValuesObject>(args: Args<T>): FormHelpers<T> => {
9
- const { initialValues } = args;
15
+ export const useFormState = <
16
+ T extends FormValuesObject,
17
+ const V extends Validations<T> = Validations<T>,
18
+ >(
19
+ args: Args<T, V>,
20
+ ): FormHelpers<T> => {
21
+ const { initialValues, constraints, onSubmit } = args;
10
22
 
11
23
  const [values, onValueChanges] = useState<T>(initialValues);
24
+ const [submitAttempted, setSubmitAttempted] = useState(false);
12
25
 
13
- return { values, onValueChanges };
26
+ const errors: FormErrors<T> = {};
27
+ if (constraints) {
28
+ for (const key of Object.keys(constraints) as (keyof T & string)[]) {
29
+ // Field type and validator input are correlated per key, but TS can't
30
+ // track that through the union of keys — widen the input to unknown.
31
+ const validator = constraints[key] as
32
+ | ((val: unknown) => string | null)
33
+ | undefined;
34
+ const error = validator?.(values[key]);
35
+ if (error != null) errors[key] = error;
36
+ }
37
+ }
38
+
39
+ const isValid = Object.keys(errors).length === 0;
40
+
41
+ const submit = () => {
42
+ setSubmitAttempted(true);
43
+ if (!isValid) return;
44
+ // Every constrained field's validator just passed at runtime — exactly
45
+ // the guarantee Refine<T, V> encodes.
46
+ onSubmit?.(values as Refine<T, V>);
47
+ };
48
+
49
+ return { values, onValueChanges, errors, isValid, submitAttempted, submit };
14
50
  };
@@ -0,0 +1,11 @@
1
+ // Aggregation entry point for building a `Validations<T>`-shaped constraints
2
+ // object. Runtime identity; the value is in the type: the `const` type
3
+ // parameter freezes each validator's precise type (including `Refinement`
4
+ // markers) instead of letting function types widen. Callers still apply
5
+ // `as const satisfies Validations<FormType>` to shape-check against their
6
+ // form type without losing that precision — see src/forms/CLAUDE.md.
7
+ export const perField = <
8
+ const V extends Record<string, (val: never) => string | null>,
9
+ >(
10
+ validations: V,
11
+ ): V => validations;
@@ -1,5 +1,7 @@
1
1
  import { describe, it, expectTypeOf } from 'vitest';
2
- import type { Refinement, Validator } from './types';
2
+ import { perField } from './perField';
3
+ import { allOf, matches, minLength, notEmpty } from '../validators/validators';
4
+ import type { Refine, Refinement, Validations, Validator } from './types';
3
5
 
4
6
  describe('Validator<Input, Excluded>', () => {
5
7
  it('preserves the input type as the call parameter', () => {
@@ -24,3 +26,74 @@ describe('Validator<Input, Excluded>', () => {
24
26
  expectTypeOf<Extracted>().toBeNever();
25
27
  });
26
28
  });
29
+
30
+ type FormType = {
31
+ a: string | undefined;
32
+ b: number;
33
+ c: string | null;
34
+ };
35
+
36
+ describe('Refine<T, V>', () => {
37
+ it('narrows fields whose validator carries a refinement', () => {
38
+ const constraints = {
39
+ a: notEmpty('a'),
40
+ } as const satisfies Validations<FormType>;
41
+
42
+ type Result = Refine<FormType, typeof constraints>;
43
+ expectTypeOf<Result>().toEqualTypeOf<{
44
+ a: string;
45
+ b: number;
46
+ c: string | null;
47
+ }>();
48
+ });
49
+
50
+ it('preserves precision through perField without as const', () => {
51
+ const constraints = perField({
52
+ a: notEmpty('a'),
53
+ c: notEmpty('c'),
54
+ }) satisfies Validations<FormType>;
55
+
56
+ type Result = Refine<FormType, typeof constraints>;
57
+ expectTypeOf<Result>().toEqualTypeOf<{
58
+ a: string;
59
+ b: number;
60
+ c: string;
61
+ }>();
62
+ });
63
+
64
+ it('bare validator functions narrow nothing', () => {
65
+ const constraints = {
66
+ a: (val: string | undefined) => (val ? null : 'required'),
67
+ } as const satisfies Validations<FormType>;
68
+
69
+ type Result = Refine<FormType, typeof constraints>;
70
+ expectTypeOf<Result>().toEqualTypeOf<FormType>();
71
+ });
72
+
73
+ it('a constraints object widened by annotation refines nothing', () => {
74
+ // Annotating with `: Validations<FormType>` (instead of `satisfies`)
75
+ // widens every validator to a bare function — markers are lost. This is
76
+ // the failure mode the `as const satisfies` / const-generic patterns exist
77
+ // to prevent.
78
+ const constraints: Validations<FormType> = { a: notEmpty('a') };
79
+
80
+ type Result = Refine<FormType, typeof constraints>;
81
+ expectTypeOf<Result>().toEqualTypeOf<FormType>();
82
+ });
83
+
84
+ it('allOf carries the union of its parts’ refinements', () => {
85
+ const constraints = {
86
+ a: allOf(notEmpty('a'), minLength('a', 3), matches('a', /^\S+$/, 'no spaces')),
87
+ } as const satisfies Validations<FormType>;
88
+
89
+ type Result = Refine<FormType, typeof constraints>;
90
+ expectTypeOf<Result['a']>().toEqualTypeOf<string>();
91
+ });
92
+
93
+ it('an empty constraints object leaves the form type unchanged', () => {
94
+ const constraints = {} as const satisfies Validations<FormType>;
95
+
96
+ type Result = Refine<FormType, typeof constraints>;
97
+ expectTypeOf<Result>().toEqualTypeOf<FormType>();
98
+ });
99
+ });
@@ -1,3 +1,5 @@
1
+ import type { FormValuesObject } from '../useFormState/types';
2
+
1
3
  // Phantom marker carried by validators. The runtime value of `__excludes`
2
4
  // is meaningless and never read — only its declared type matters. Required
3
5
  // (not optional) so validators can't accidentally drop the marker, and so
@@ -13,3 +15,24 @@ export type Refinement<Excluded = never> = {
13
15
  // validates at runtime; it just doesn't shrink the field's submit type).
14
16
  export type Validator<Input, Excluded = never> =
15
17
  ((val: Input) => string | null) & Refinement<Excluded>;
18
+
19
+ // The constraint for a per-field validation map. Deliberately does NOT demand
20
+ // the `Refinement` marker: a bare `(val) => string | null` is a legal
21
+ // constraint that narrows nothing. Markers on standard-library validators
22
+ // ride along in the *inferred* type of a concrete constraints object and are
23
+ // recovered structurally by `Refine<>`.
24
+ export type Validations<T extends FormValuesObject> = {
25
+ readonly [K in keyof T]?: (val: T[K]) => string | null;
26
+ };
27
+
28
+ // Applies each field's validator marker to the form type: the submit-time
29
+ // type. Fields whose validator carries `Refinement<X>` become
30
+ // `Exclude<T[K], X>`; unconstrained fields and bare (marker-less) validators
31
+ // pass through unchanged. Shallow by design — one mapped type, no recursion.
32
+ export type Refine<T extends FormValuesObject, V extends Validations<T>> = {
33
+ [K in keyof T]: K extends keyof V
34
+ ? V[K] extends Refinement<infer Excluded>
35
+ ? Exclude<T[K], Excluded>
36
+ : T[K]
37
+ : T[K];
38
+ };
@@ -0,0 +1,99 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { allOf, matches, min, minLength, notEmpty } from './validators';
3
+
4
+ describe('notEmpty', () => {
5
+ const validator = notEmpty('email');
6
+
7
+ test('rejects null, undefined, and the empty string', () => {
8
+ expect(validator(null)).toBe("'email' cannot be empty");
9
+ expect(validator(undefined)).toBe("'email' cannot be empty");
10
+ expect(validator('')).toBe("'email' cannot be empty");
11
+ });
12
+
13
+ test('passes any other value', () => {
14
+ expect(validator('a@b.co')).toBeNull();
15
+ expect(validator(0)).toBeNull();
16
+ expect(validator(' ')).toBeNull();
17
+ });
18
+ });
19
+
20
+ describe('minLength', () => {
21
+ const validator = minLength('nickname', 3);
22
+
23
+ test('rejects strings shorter than the minimum', () => {
24
+ expect(validator('ab')).toBe("'nickname' must be at least 3 characters");
25
+ });
26
+
27
+ test('passes strings at or above the minimum', () => {
28
+ expect(validator('abc')).toBeNull();
29
+ expect(validator('abcd')).toBeNull();
30
+ });
31
+
32
+ test('passes nullish and empty values — requiredness is notEmpty’s job', () => {
33
+ expect(validator(null)).toBeNull();
34
+ expect(validator(undefined)).toBeNull();
35
+ expect(validator('')).toBeNull();
36
+ });
37
+ });
38
+
39
+ describe('matches', () => {
40
+ const validator = matches('email', /@/, 'a valid email');
41
+
42
+ test('rejects values that fail the pattern', () => {
43
+ expect(validator('not-an-email')).toBe("'email' must be a valid email");
44
+ });
45
+
46
+ test('passes values matching the pattern', () => {
47
+ expect(validator('a@b.co')).toBeNull();
48
+ });
49
+
50
+ test('passes nullish and empty values', () => {
51
+ expect(validator(null)).toBeNull();
52
+ expect(validator(undefined)).toBeNull();
53
+ expect(validator('')).toBeNull();
54
+ });
55
+ });
56
+
57
+ describe('min', () => {
58
+ const validator = min('age', 18);
59
+
60
+ test('rejects numbers below the minimum', () => {
61
+ expect(validator(17)).toBe("'age' must be at least 18");
62
+ });
63
+
64
+ test('passes numbers at or above the minimum', () => {
65
+ expect(validator(18)).toBeNull();
66
+ expect(validator(99)).toBeNull();
67
+ });
68
+
69
+ test('passes nullish values', () => {
70
+ expect(validator(null)).toBeNull();
71
+ expect(validator(undefined)).toBeNull();
72
+ });
73
+ });
74
+
75
+ describe('allOf', () => {
76
+ const validator = allOf(
77
+ notEmpty('email'),
78
+ minLength('email', 6),
79
+ matches('email', /@/, 'a valid email'),
80
+ );
81
+
82
+ test('returns the first failing part’s error', () => {
83
+ expect(validator(undefined)).toBe("'email' cannot be empty");
84
+ expect(validator('a@b')).toBe("'email' must be at least 6 characters");
85
+ expect(validator('abcdefg')).toBe("'email' must be a valid email");
86
+ });
87
+
88
+ test('passes when every part passes', () => {
89
+ expect(validator('a@b.com')).toBeNull();
90
+ });
91
+
92
+ test('accepts bare validator functions alongside marked ones', () => {
93
+ const noAdmin = (val: string | null | undefined) =>
94
+ val === 'admin' ? 'reserved' : null;
95
+ const composed = allOf(notEmpty('name'), noAdmin);
96
+ expect(composed('admin')).toBe('reserved');
97
+ expect(composed('will')).toBeNull();
98
+ });
99
+ });
@@ -0,0 +1,95 @@
1
+ import type { Refinement, Validator } from '../validations/types';
2
+
3
+ // Standard-library validators. Each one declares its refinement explicitly —
4
+ // `Validator<Input, Excluded>` — even when it narrows nothing (`never`).
5
+ // The marker is attached with `Object.assign(fn, {} as Refinement<X>)`: a
6
+ // pure type-level tag, never read at runtime.
7
+
8
+ // Rules out null, undefined, and the empty string. The only refining
9
+ // validator in the baseline set: submit-time type becomes
10
+ // `Exclude<FieldType, null | undefined | ''>`.
11
+ export const notEmpty = (
12
+ fieldName: string,
13
+ ): Validator<unknown, null | undefined | ''> =>
14
+ Object.assign(
15
+ (val: unknown) =>
16
+ val == null || val === '' ? `'${fieldName}' cannot be empty` : null,
17
+ {} as Refinement<null | undefined | ''>,
18
+ );
19
+
20
+ // Minimum string length. Nullish and empty values pass — requiredness is
21
+ // notEmpty's job; compose via allOf. Narrows nothing: TypeScript has no type
22
+ // for "string of at least n characters".
23
+ export const minLength = (
24
+ fieldName: string,
25
+ min: number,
26
+ ): Validator<string | null | undefined> =>
27
+ Object.assign(
28
+ (val: string | null | undefined) =>
29
+ val != null && val !== '' && val.length < min
30
+ ? `'${fieldName}' must be at least ${min} characters`
31
+ : null,
32
+ {} as Refinement,
33
+ );
34
+
35
+ // Regex test. Nullish and empty values pass (compose with notEmpty for
36
+ // requiredness). Narrows nothing.
37
+ export const matches = (
38
+ fieldName: string,
39
+ pattern: RegExp,
40
+ description: string,
41
+ ): Validator<string | null | undefined> =>
42
+ Object.assign(
43
+ (val: string | null | undefined) =>
44
+ val != null && val !== '' && !pattern.test(val)
45
+ ? `'${fieldName}' must be ${description}`
46
+ : null,
47
+ {} as Refinement,
48
+ );
49
+
50
+ // Minimum numeric value. Nullish passes. Narrows nothing.
51
+ export const min = (
52
+ fieldName: string,
53
+ minimum: number,
54
+ ): Validator<number | null | undefined> =>
55
+ Object.assign(
56
+ (val: number | null | undefined) =>
57
+ val != null && val < minimum
58
+ ? `'${fieldName}' must be at least ${minimum}`
59
+ : null,
60
+ {} as Refinement,
61
+ );
62
+
63
+ type ExcludedOf<V> = V extends Refinement<infer Excluded> ? Excluded : never;
64
+
65
+ // The composed input is the *intersection* of the parts' inputs: a value the
66
+ // composite accepts must be acceptable to every part. Inferring `I` from the
67
+ // union of function types puts it in contravariant position, which is what
68
+ // makes TS produce the intersection. Computing it this way — rather than as
69
+ // a standalone `Input` type parameter inferred from the arguments — matters:
70
+ // a separate parameter resolves from whichever argument TS visits first
71
+ // (e.g. `unknown` from notEmpty) and then rejects narrower siblings when the
72
+ // call has no outer contextual type, as inside an inline constraints object.
73
+ type InputOf<Validators extends readonly ((val: never) => string | null)[]> =
74
+ Validators[number] extends (val: infer I) => string | null ? I : never;
75
+
76
+ // Composes validators on one field: first error wins. The refinement is the
77
+ // union of the parts' refinements — running all of them earns all of their
78
+ // narrowings. `const Validators` keeps each member's precise type so
79
+ // `ExcludedOf` can extract markers; bare functions contribute `never`.
80
+ export const allOf = <
81
+ const Validators extends readonly ((val: never) => string | null)[],
82
+ >(
83
+ ...validators: Validators
84
+ ): Validator<InputOf<Validators>, ExcludedOf<Validators[number]>> =>
85
+ Object.assign(
86
+ (val: InputOf<Validators>) => {
87
+ for (const validator of validators) {
88
+ // Safe: each part's declared input is a supertype of the intersection.
89
+ const error = (validator as (v: unknown) => string | null)(val);
90
+ if (error != null) return error;
91
+ }
92
+ return null;
93
+ },
94
+ {} as Refinement<ExcludedOf<Validators[number]>>,
95
+ );
@@ -0,0 +1,35 @@
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+
4
+ import { defineConfig } from 'vitest/config';
5
+
6
+ import { storybookTest } from '@storybook/addon-vitest/vitest-plugin';
7
+
8
+ import { playwright } from '@vitest/browser-playwright';
9
+
10
+ const dirname =
11
+ typeof __dirname !== 'undefined' ? __dirname : path.dirname(fileURLToPath(import.meta.url));
12
+
13
+ // Runs every story as a Vitest test in headless Chromium: each story must
14
+ // mount without throwing, its play function (if any) must pass, and the
15
+ // a11y addon reports axe-core violations (see `a11y` in .storybook/preview.tsx).
16
+ // More info: https://storybook.js.org/docs/writing-tests/integrations/vitest-addon
17
+ export default defineConfig({
18
+ test: {
19
+ projects: [
20
+ {
21
+ extends: true,
22
+ plugins: [storybookTest({ configDir: path.join(dirname, '.storybook') })],
23
+ test: {
24
+ name: 'storybook',
25
+ browser: {
26
+ enabled: true,
27
+ headless: true,
28
+ provider: playwright({}),
29
+ instances: [{ browser: 'chromium' }],
30
+ },
31
+ },
32
+ },
33
+ ],
34
+ },
35
+ });
package/CLAUDE.md DELETED
@@ -1,55 +0,0 @@
1
- # @structuralists/scaffolding
2
-
3
- Generic React component library. Storybook for dev.
4
-
5
- Designed to be used to scaffold up an app
6
-
7
- ## Conventions
8
-
9
- ### Component prop destructuring
10
-
11
- React components must take a single argument named `props` and destructure
12
- on the first line of the function body — not in the parameter list.
13
-
14
- ```tsx
15
- // ✅ correct
16
- export const Foo = (props: FooProps) => {
17
- const { a, b, c } = props;
18
-
19
- return <div>{a}</div>;
20
- };
21
-
22
- // ❌ wrong — destructures in the parameter list
23
- export const Foo = ({ a, b, c }: FooProps) => {
24
- return <div>{a}</div>;
25
- };
26
- ```
27
-
28
- Why: the destructuring line at the top of the body acts as a quick legend
29
- of what the component reads from its props, scannable without parsing the
30
- function signature. Keeps the call shape uniform across the package.
31
-
32
- ### Custom hook arguments
33
-
34
- Project-defined hooks must take a single argument named `args` (an object)
35
- and destructure on the first line of the function body — same shape as the
36
- component-prop rule above.
37
-
38
- ```ts
39
- // ✅ correct
40
- export const useFoo = (args: UseFooArgs) => {
41
- const { a, b, c } = args;
42
- // ...
43
- };
44
-
45
- // ❌ wrong — positional args
46
- export const useFoo = (a: string, b: number, c?: boolean) => {
47
- // ...
48
- };
49
- ```
50
-
51
- Why: named args read clearly at the call site (`useFoo({ a, b })`), survive
52
- reordering, and let new optional fields be added without breaking callers.
53
- Built-in React hooks (`useState`, `useEffect`, etc.) keep their stock
54
- positional signatures — this rule applies only to hooks defined in this
55
- package.