@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.
- package/.github/workflows/publish.yml +11 -0
- package/.storybook/main.ts +1 -1
- package/.storybook/preview.tsx +7 -0
- package/AGENTS.md +104 -0
- package/bun.lock +79 -2
- package/package.json +7 -1
- package/src/forms/CLAUDE.md +41 -18
- package/src/forms/plan.md +270 -0
- package/src/forms/useFormState/types.ts +10 -0
- package/src/forms/useFormState/useFormState.stories.tsx +196 -0
- package/src/forms/useFormState/useFormState.test-d.ts +209 -0
- package/src/forms/useFormState/useFormState.test.tsx +134 -0
- package/src/forms/useFormState/useFormState.ts +41 -5
- package/src/forms/validations/perField.ts +11 -0
- package/src/forms/validations/types.test-d.ts +74 -1
- package/src/forms/validations/types.ts +23 -0
- package/src/forms/validators/validators.test.ts +99 -0
- package/src/forms/validators/validators.ts +95 -0
- package/vitest.config.ts +35 -0
- package/CLAUDE.md +0 -55
|
@@ -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
|
|
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 = <
|
|
9
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
);
|
package/vitest.config.ts
ADDED
|
@@ -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.
|