cursedbelt 5.0.0 → 5.0.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/dist/react/emoji/EmojiPicker.d.ts.map +1 -1
- package/dist/react/emoji/EmojiPicker.js +49 -8
- package/dist/react/emoji/EmojiPicker.js.map +1 -1
- package/dist/react/lib/form.d.ts +2 -2
- package/dist/react/lib/form.d.ts.map +1 -1
- package/dist/react/lib/form.js +4 -41
- package/dist/react/lib/form.js.map +1 -1
- package/dist/react/lib/zodResolver.d.ts +9 -0
- package/dist/react/lib/zodResolver.d.ts.map +1 -0
- package/dist/react/lib/zodResolver.js +105 -0
- package/dist/react/lib/zodResolver.js.map +1 -0
- package/dist/styles-areas/analytics.css +1 -1
- package/dist/styles-areas/auth.css +1 -1
- package/dist/styles-areas/charts.css +1 -1
- package/dist/styles-areas/chat.css +1 -1
- package/dist/styles-areas/core.css +1 -1
- package/dist/styles-areas/dashboard-grid.css +1 -1
- package/dist/styles-areas/data-table.css +1 -1
- package/dist/styles-areas/deep-link.css +1 -1
- package/dist/styles-areas/emoji.css +1 -1
- package/dist/styles-areas/fields.css +1 -1
- package/dist/styles-areas/file-tree.css +1 -1
- package/dist/styles-areas/keep-awake.css +1 -1
- package/dist/styles-areas/layout-engine.css +1 -1
- package/dist/styles-areas/markdown.css +1 -1
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/media.css +1 -1
- package/dist/styles-areas/nav.css +1 -1
- package/dist/styles-areas/overlays.css +1 -1
- package/dist/styles-areas/palette.css +1 -1
- package/dist/styles-areas/stats.css +1 -1
- package/dist/styles-areas/virtual.css +1 -1
- package/dist/styles-areas/wizard.css +1 -1
- package/dist/styles-areas/workbench.css +1 -1
- package/package.json +16 -5
- package/src/react/emoji/EmojiPicker.tsx +70 -19
- package/src/react/lib/form.ts +5 -48
- package/src/react/lib/zodResolver.spec.ts +82 -0
- package/src/react/lib/zodResolver.ts +136 -0
- package/src/styles-areas/analytics.css +1 -1
- package/src/styles-areas/auth.css +1 -1
- package/src/styles-areas/charts.css +1 -1
- package/src/styles-areas/chat.css +1 -1
- package/src/styles-areas/core.css +1 -1
- package/src/styles-areas/dashboard-grid.css +1 -1
- package/src/styles-areas/data-table.css +1 -1
- package/src/styles-areas/deep-link.css +1 -1
- package/src/styles-areas/emoji.css +1 -1
- package/src/styles-areas/fields.css +1 -1
- package/src/styles-areas/file-tree.css +1 -1
- package/src/styles-areas/keep-awake.css +1 -1
- package/src/styles-areas/layout-engine.css +1 -1
- package/src/styles-areas/markdown.css +1 -1
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/media.css +1 -1
- package/src/styles-areas/nav.css +1 -1
- package/src/styles-areas/overlays.css +1 -1
- package/src/styles-areas/palette.css +1 -1
- package/src/styles-areas/stats.css +1 -1
- package/src/styles-areas/virtual.css +1 -1
- package/src/styles-areas/wizard.css +1 -1
- package/src/styles-areas/workbench.css +1 -1
- package/src/unpeeredStaysOffTheBarrel.spec.ts +99 -0
|
@@ -1,15 +1,68 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
|
|
2
2
|
import { IconButton } from '../components/IconButton.js';
|
|
3
3
|
import { Popover } from '../components/Popover.js';
|
|
4
4
|
import { Skeleton } from '../components/Skeleton.js';
|
|
5
5
|
import { resolveEmojiTheme } from './emojiSearch.js';
|
|
6
6
|
|
|
7
|
-
// The emoji-mart
|
|
8
|
-
//
|
|
9
|
-
// async `data` loader (never statically imported — that would tax every page).
|
|
10
|
-
const Picker = lazy(() => import('@emoji-mart/react'));
|
|
7
|
+
// The emoji-mart engine and its ~300 KB data are BOTH lazy: nothing loads until the panel
|
|
8
|
+
// first mounts, and the data is fetched through emoji-mart's async `data` loader.
|
|
11
9
|
const loadEmojiData = () => import('@emoji-mart/data').then((m) => m.default);
|
|
12
10
|
|
|
11
|
+
/**
|
|
12
|
+
* 🔴 emoji-mart's `Picker` custom element, mounted directly — NOT through `@emoji-mart/react`
|
|
13
|
+
* (cursedbelt 5.0.1). That wrapper is twenty lines (`new Picker({ ...props, ref })` into a div),
|
|
14
|
+
* and depending on it cost every consumer something either way: as an optional peer it made
|
|
15
|
+
* `npm install cursedbelt` fail ERESOLVE (it peers `react <=18`; task 2087), and as `unpeered`
|
|
16
|
+
* (5.0.0) a literal `import('@emoji-mart/react')` broke every clean-install `vite build` that
|
|
17
|
+
* imports `cursedbelt/react`, because Vite stubs a missing import only for a DECLARED optional
|
|
18
|
+
* peer. `emoji-mart` itself IS a declared optional peer, so a consumer that never renders this
|
|
19
|
+
* builds green with it absent, and one that does gets a named error rather than a blank panel.
|
|
20
|
+
*/
|
|
21
|
+
function EmojiMartPanel(props: Record<string, unknown>) {
|
|
22
|
+
const host = useRef<HTMLDivElement>(null);
|
|
23
|
+
const picker = useRef<{ update?: (props: object) => void } | null>(null);
|
|
24
|
+
const latest = useRef(props);
|
|
25
|
+
latest.current = props;
|
|
26
|
+
const [ready, setReady] = useState(false);
|
|
27
|
+
const [failed, setFailed] = useState<Error | null>(null);
|
|
28
|
+
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
let live = true;
|
|
31
|
+
import('emoji-mart').then(
|
|
32
|
+
({ Picker }) => {
|
|
33
|
+
if (!live || !host.current) return;
|
|
34
|
+
picker.current = new Picker({ ...latest.current, ref: host }) as { update?: (props: object) => void };
|
|
35
|
+
setReady(true);
|
|
36
|
+
},
|
|
37
|
+
(cause: unknown) => {
|
|
38
|
+
if (live) {
|
|
39
|
+
setFailed(
|
|
40
|
+
new Error("cursedbelt: EmojiPicker needs the optional peer 'emoji-mart' (and '@emoji-mart/data') — run `bun add emoji-mart @emoji-mart/data` in the app that renders it.", { cause }),
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
return () => {
|
|
46
|
+
live = false;
|
|
47
|
+
picker.current = null;
|
|
48
|
+
host.current?.replaceChildren();
|
|
49
|
+
};
|
|
50
|
+
}, []);
|
|
51
|
+
|
|
52
|
+
// The wrapper's contract: every render pushes the current props into the live element.
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
picker.current?.update?.(props);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (failed) throw failed;
|
|
58
|
+
return (
|
|
59
|
+
<>
|
|
60
|
+
{!ready && <Skeleton className='h-[420px] w-[352px]' />}
|
|
61
|
+
<div ref={host} />
|
|
62
|
+
</>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
13
66
|
interface EmojiMartSelectEvent {
|
|
14
67
|
native: string;
|
|
15
68
|
id: string;
|
|
@@ -37,7 +90,7 @@ export interface EmojiPickerProps {
|
|
|
37
90
|
onOpenChange?: (open: boolean) => void;
|
|
38
91
|
}
|
|
39
92
|
|
|
40
|
-
/** The emoji-mart grid itself, themed + lazy, behind a
|
|
93
|
+
/** The emoji-mart grid itself, themed + lazy, behind a skeleton until the engine has loaded. */
|
|
41
94
|
function EmojiPanel({
|
|
42
95
|
onSelect,
|
|
43
96
|
theme = 'auto',
|
|
@@ -47,19 +100,17 @@ function EmojiPanel({
|
|
|
47
100
|
}: Pick<EmojiPickerProps, 'onSelect' | 'theme' | 'locale' | 'previewPosition' | 'set'>) {
|
|
48
101
|
const resolvedTheme = useMemo(() => resolveEmojiTheme(theme), [theme]);
|
|
49
102
|
return (
|
|
50
|
-
<
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
/>
|
|
62
|
-
</Suspense>
|
|
103
|
+
<EmojiMartPanel
|
|
104
|
+
data={loadEmojiData}
|
|
105
|
+
onEmojiSelect={(e: EmojiMartSelectEvent) => onSelect(e.native)}
|
|
106
|
+
theme={resolvedTheme}
|
|
107
|
+
locale={locale}
|
|
108
|
+
previewPosition={previewPosition}
|
|
109
|
+
set={set}
|
|
110
|
+
navPosition='top'
|
|
111
|
+
skinTonePosition='search'
|
|
112
|
+
dynamicWidth={false}
|
|
113
|
+
/>
|
|
63
114
|
);
|
|
64
115
|
}
|
|
65
116
|
|
package/src/react/lib/form.ts
CHANGED
|
@@ -1,36 +1,6 @@
|
|
|
1
|
-
import type { DefaultValues, FieldValues,
|
|
1
|
+
import type { DefaultValues, FieldValues, UseFormProps } from 'react-hook-form';
|
|
2
2
|
import type { z } from 'zod';
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* 🔴 `@hookform/resolvers` is an OPTIONAL peer, and this dynamic import is what makes it
|
|
6
|
-
* one (task 149). `cursedbelt/react` is the barrel all eight apps import; a static
|
|
7
|
-
* `import { zodResolver } from '@hookform/resolvers/zod'` at the top of this file put the
|
|
8
|
-
* package — and its own dependency tree — into every one of those installs, to serve the
|
|
9
|
-
* one function below. `src/barrelsReachNoOptionalPeer.spec.ts` is the check that keeps it
|
|
10
|
-
* out, and a `await import(...)` inside the function that needs it is the shape that spec
|
|
11
|
-
* blesses.
|
|
12
|
-
*
|
|
13
|
-
* Resolved once per process and memoized: RHF calls the resolver on every validation
|
|
14
|
-
* pass, and `zodResolver(schema)` is cheap but the module load is not.
|
|
15
|
-
*/
|
|
16
|
-
let zodResolverModule: Promise<typeof import('@hookform/resolvers/zod')> | undefined;
|
|
17
|
-
|
|
18
|
-
const loadZodResolver = async (): Promise<typeof import('@hookform/resolvers/zod')> => {
|
|
19
|
-
zodResolverModule ??= import('@hookform/resolvers/zod');
|
|
20
|
-
try {
|
|
21
|
-
return await zodResolverModule;
|
|
22
|
-
} catch (cause) {
|
|
23
|
-
// 🔴 Loud and actionable, not an unhandled rejection three frames into RHF. A missing
|
|
24
|
-
// optional peer surfaces here as "the form never validates", which is the worst
|
|
25
|
-
// possible thing to debug without the package name in the message.
|
|
26
|
-
zodResolverModule = undefined;
|
|
27
|
-
throw new Error(
|
|
28
|
-
"cursedbelt: makeFormConfig() needs the optional peer '@hookform/resolvers' — " +
|
|
29
|
-
'run `bun add @hookform/resolvers` in the app that renders this form.',
|
|
30
|
-
{ cause },
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
};
|
|
3
|
+
import { zodResolver } from './zodResolver.js';
|
|
34
4
|
|
|
35
5
|
/**
|
|
36
6
|
* The canonical react-hook-form config for every cursedbelt form (Wizard,
|
|
@@ -53,27 +23,14 @@ const loadZodResolver = async (): Promise<typeof import('@hookform/resolvers/zod
|
|
|
53
23
|
* const form = useForm<Values>(makeFormConfig(schema, { name: '', tags: [] }));
|
|
54
24
|
* ```
|
|
55
25
|
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
26
|
+
* The resolver is this package's own (`./zodResolver.ts`, 5.0.1) — there is nothing for the
|
|
27
|
+
* app to install, and nothing for a bundler to fail to resolve.
|
|
58
28
|
*/
|
|
59
29
|
export function makeFormConfig<TFieldValues extends FieldValues>(
|
|
60
30
|
schema: z.ZodType,
|
|
61
31
|
defaults: TFieldValues,
|
|
62
32
|
): UseFormProps<TFieldValues> {
|
|
63
|
-
|
|
64
|
-
let resolved: Resolver<TFieldValues> | undefined;
|
|
65
|
-
const resolver: Resolver<TFieldValues> = async (values, context, options) => {
|
|
66
|
-
if (!resolved) {
|
|
67
|
-
const { zodResolver } = await loadZodResolver();
|
|
68
|
-
// zodResolver's generics don't line up with the caller's value type (the schema
|
|
69
|
-
// input/output may differ from TFieldValues); the runtime resolver is correct, so
|
|
70
|
-
// cast through the overload's param/return types rather than over-genericize.
|
|
71
|
-
resolved = zodResolver(
|
|
72
|
-
schema as Parameters<typeof zodResolver>[0],
|
|
73
|
-
) as unknown as Resolver<TFieldValues>;
|
|
74
|
-
}
|
|
75
|
-
return resolved(values, context, options);
|
|
76
|
-
};
|
|
33
|
+
const resolver = zodResolver<TFieldValues>(schema);
|
|
77
34
|
|
|
78
35
|
return {
|
|
79
36
|
resolver,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 🔴 `./zodResolver.ts` must answer EXACTLY what `@hookform/resolvers/zod` answers — it replaced
|
|
3
|
+
* that package in 5.0.1 so that no consumer has to install it (see the module header for why
|
|
4
|
+
* every way of depending on it broke somebody). `@hookform/resolvers` stays a devDependency for
|
|
5
|
+
* one reason: to be the oracle here. Each case runs both resolvers over one schema and one input
|
|
6
|
+
* and compares the whole result, `ref`s and all.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { zodResolver as upstream } from '@hookform/resolvers/zod';
|
|
10
|
+
import type { FieldValues, ResolverOptions } from 'react-hook-form';
|
|
11
|
+
import { z } from 'zod';
|
|
12
|
+
import { zodResolver } from './zodResolver.js';
|
|
13
|
+
|
|
14
|
+
const options = (over: Partial<ResolverOptions<FieldValues>> = {}): ResolverOptions<FieldValues> =>
|
|
15
|
+
({ fields: {}, shouldUseNativeValidation: false, ...over }) as ResolverOptions<FieldValues>;
|
|
16
|
+
|
|
17
|
+
async function both(schema: z.ZodType, values: FieldValues, opts = options()) {
|
|
18
|
+
const ours = await zodResolver(schema)(values, undefined, opts);
|
|
19
|
+
const theirs = await (upstream as unknown as (s: z.ZodType) => typeof zodResolver extends (s: z.ZodType) => infer R ? R : never)(schema)(values, undefined, opts);
|
|
20
|
+
return { ours, theirs };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const account = z.object({
|
|
24
|
+
name: z.string().min(2, 'too short'),
|
|
25
|
+
email: z.email('not an email'),
|
|
26
|
+
age: z.number().int().min(18, 'adults only'),
|
|
27
|
+
address: z.object({ city: z.string().min(1, 'city required'), zip: z.string().regex(/^\d{5}$/, 'five digits') }),
|
|
28
|
+
tags: z.array(z.string().min(1, 'empty tag')).min(1, 'at least one tag'),
|
|
29
|
+
contact: z.union([z.object({ kind: z.literal('phone'), phone: z.string().min(7, 'phone too short') }), z.object({ kind: z.literal('mail'), mail: z.email('bad mail') })]),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('zodResolver matches @hookform/resolvers/zod', () => {
|
|
33
|
+
test('valid input: parsed output as values, no errors', async () => {
|
|
34
|
+
const schema = z.object({ n: z.coerce.number(), s: z.string().trim() });
|
|
35
|
+
const { ours, theirs } = await both(schema, { n: '42', s: ' x ' });
|
|
36
|
+
expect(ours).toEqual(theirs);
|
|
37
|
+
expect(ours.values).toEqual({ n: 42, s: 'x' });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('flat errors', async () => {
|
|
41
|
+
const { ours, theirs } = await both(account, { name: 'a', email: 'nope', age: 12, address: { city: '', zip: '1' }, tags: [], contact: { kind: 'phone', phone: '1' } });
|
|
42
|
+
expect(ours).toEqual(theirs);
|
|
43
|
+
expect(Object.keys(ours.errors).sort()).toEqual(['address', 'age', 'contact', 'email', 'name', 'tags']);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('nested paths and array items nest, not dot-join', async () => {
|
|
47
|
+
const { ours, theirs } = await both(account, { name: 'ok', email: 'a@b.co', age: 30, address: { city: 'x', zip: 'abc' }, tags: ['a', ''], contact: { kind: 'mail', mail: 'x' } });
|
|
48
|
+
expect(ours).toEqual(theirs);
|
|
49
|
+
expect((ours.errors as Record<string, Record<string, unknown>>).address?.zip).toBeDefined();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('criteriaMode "all" collects every failing rule per field', async () => {
|
|
53
|
+
const schema = z.object({ pw: z.string().min(8, 'eight').regex(/\d/, 'a digit').regex(/[A-Z]/, 'a capital') });
|
|
54
|
+
const { ours, theirs } = await both(schema, { pw: 'abc' }, options({ criteriaMode: 'all' }));
|
|
55
|
+
expect(ours).toEqual(theirs);
|
|
56
|
+
expect(Object.keys((ours.errors as Record<string, { types: object }>).pw?.types ?? {}).length).toBeGreaterThan(0);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('a field-array ROOT error lands on `.root` when its items are registered', async () => {
|
|
60
|
+
const schema = z.object({ items: z.array(z.object({ v: z.string() })).min(2, 'two please') });
|
|
61
|
+
const opts = options({ names: ['items.0.v'] });
|
|
62
|
+
const { ours, theirs } = await both(schema, { items: [{ v: 'a' }] }, opts);
|
|
63
|
+
expect(ours).toEqual(theirs);
|
|
64
|
+
expect((ours.errors as Record<string, { root?: unknown }>).items?.root).toBeDefined();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('a field ref is carried onto its error, as react-hook-form focuses it', async () => {
|
|
68
|
+
const ref = { name: 'name' };
|
|
69
|
+
const opts = options({ fields: { name: { ref, name: 'name' } } as unknown as ResolverOptions<FieldValues>['fields'] });
|
|
70
|
+
const { ours, theirs } = await both(account.pick({ name: true }), { name: '' }, opts);
|
|
71
|
+
expect(ours).toEqual(theirs);
|
|
72
|
+
expect((ours.errors as Record<string, { ref?: unknown }>).name?.ref).toBe(ref);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a non-zod throw from the schema is rethrown, by both', async () => {
|
|
76
|
+
const schema = z.string().transform(() => {
|
|
77
|
+
throw new TypeError('boom');
|
|
78
|
+
});
|
|
79
|
+
await expect(zodResolver(schema)('x' as unknown as FieldValues, undefined, options())).rejects.toThrow('boom');
|
|
80
|
+
await expect((upstream as unknown as (s: z.ZodType) => ReturnType<typeof zodResolver>)(schema)('x' as unknown as FieldValues, undefined, options())).rejects.toThrow('boom');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { appendErrors, type FieldError, type FieldErrors, type FieldValues, get, type Resolver, type ResolverOptions, set } from 'react-hook-form';
|
|
2
|
+
import type { z } from 'zod';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 🔴 react-hook-form's zod resolver, owned here instead of imported from `@hookform/resolvers`
|
|
6
|
+
* (cursedbelt 5.0.1). What that package cost, and why neither way of depending on it worked:
|
|
7
|
+
*
|
|
8
|
+
* · As an optional PEER, `npm install cursedbelt` failed ERESOLVE (task 2087): npm checks an
|
|
9
|
+
* optional peer's own peer ranges, and `@hookform/resolvers@5` declares ~25 of them.
|
|
10
|
+
* · As `unpeered` (5.0.0), every consumer's clean-install `vite build` failed:
|
|
11
|
+
* `Rollup failed to resolve import "@hookform/resolvers/zod"`. Vite stubs a missing import
|
|
12
|
+
* only when it is a DECLARED optional peer, and a `import()` with a literal specifier is
|
|
13
|
+
* still resolved at build time. Primary checkouts hid it because their `node_modules` still
|
|
14
|
+
* held the 4.x copy.
|
|
15
|
+
* · A variable specifier (`import(/* @vite-ignore *\/ spec)`) builds, and then fails in the
|
|
16
|
+
* BROWSER for every app that does install the package — a bare specifier has nothing to
|
|
17
|
+
* resolve it at run time.
|
|
18
|
+
*
|
|
19
|
+
* So this is the zod-4 half of `@hookform/resolvers/zod` 5.9.1 and its `toNestErrors` /
|
|
20
|
+
* `validateFieldsNatively` (MIT, © 2019-present Beier (Bill) Luo), reduced to the one schema
|
|
21
|
+
* library this package peers on (`zod ^4.4.0`) and the one call shape {@link makeFormConfig}
|
|
22
|
+
* makes. `react-hook-form` is already a dependency, and supplies `get`, `set` and
|
|
23
|
+
* `appendErrors`. `zodResolver.spec.ts` pins the behaviours that matter: nested paths, the
|
|
24
|
+
* closest union branch, `criteriaMode: 'all'`, a field-array root error, and parsed output.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
type Issue = z.core.$ZodIssue;
|
|
28
|
+
|
|
29
|
+
/** zod 4 marks its errors with a trait, not a prototype chain a bundler could duplicate. */
|
|
30
|
+
function isZodError(error: unknown): error is { issues: Issue[] } {
|
|
31
|
+
return !!(error as { _zod?: { traits?: Set<string> } } | null)?._zod?.traits?.has('$ZodError');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseIssues(input: Issue[], validateAllFieldCriteria: boolean): Record<string, FieldError> {
|
|
35
|
+
const issues = [...input];
|
|
36
|
+
// A null prototype, so a field named `toString` or `constructor` is not already "set".
|
|
37
|
+
const errors: Record<string, FieldError> = Object.create(null);
|
|
38
|
+
while (issues.length > 0) {
|
|
39
|
+
const issue = issues[0] as Issue;
|
|
40
|
+
const { code, message, path } = issue;
|
|
41
|
+
const key = path.join('.');
|
|
42
|
+
if (!errors[key]) {
|
|
43
|
+
if (issue.code === 'invalid_union' && issue.errors.length > 0) {
|
|
44
|
+
// The branch that came CLOSEST to matching speaks for the union, not the first one.
|
|
45
|
+
const closest = issue.errors.reduce((best, branch) => (branch.length < best.length ? branch : best));
|
|
46
|
+
const first = closest[0];
|
|
47
|
+
errors[key] = { message: first?.message ?? message, type: first?.code ?? code };
|
|
48
|
+
} else {
|
|
49
|
+
errors[key] = { message, type: code };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (issue.code === 'invalid_union') {
|
|
53
|
+
for (const branch of issue.errors) for (const nested of branch) issues.push({ ...nested, path: [...issue.path, ...nested.path] } as Issue);
|
|
54
|
+
}
|
|
55
|
+
if (validateAllFieldCriteria) {
|
|
56
|
+
const types = (errors[key] as FieldError).types;
|
|
57
|
+
const messages = types?.[issue.code];
|
|
58
|
+
errors[key] = appendErrors(
|
|
59
|
+
key,
|
|
60
|
+
validateAllFieldCriteria,
|
|
61
|
+
errors as Parameters<typeof appendErrors>[2],
|
|
62
|
+
code,
|
|
63
|
+
messages ? ([] as string[]).concat(messages as string[], issue.message) : issue.message,
|
|
64
|
+
) as FieldError;
|
|
65
|
+
}
|
|
66
|
+
issues.shift();
|
|
67
|
+
}
|
|
68
|
+
return errors;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type Fields = ResolverOptions<FieldValues>['fields'];
|
|
72
|
+
type NativeRef = { reportValidity?: () => void; setCustomValidity?: (m: string) => void };
|
|
73
|
+
|
|
74
|
+
function reportNative(ref: unknown, name: string, errors: FieldErrors): void {
|
|
75
|
+
const el = ref as NativeRef | undefined;
|
|
76
|
+
if (el && typeof el.reportValidity === 'function' && typeof el.setCustomValidity === 'function') {
|
|
77
|
+
const error = get(errors, name) as FieldError | undefined;
|
|
78
|
+
el.setCustomValidity(error?.message || '');
|
|
79
|
+
el.reportValidity();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function validateFieldsNatively(errors: FieldErrors, fields: Fields): void {
|
|
84
|
+
for (const name in fields) {
|
|
85
|
+
const field = fields[name] as { ref?: unknown; refs?: unknown[] } | undefined;
|
|
86
|
+
if (field?.ref && typeof (field.ref as NativeRef).reportValidity === 'function') reportNative(field.ref, name, errors);
|
|
87
|
+
else for (const ref of field?.refs ?? []) reportNative(ref, name, errors);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `a[0].b` and `a.0.b` are one path. */
|
|
92
|
+
const dotted = (name: string): string => name.replace(/\[(\d+)]/g, '.$1').replace(/[[\]]/g, '');
|
|
93
|
+
|
|
94
|
+
/** Is `name` the ROOT of a field array — some registered name is `name.<index>…`? */
|
|
95
|
+
function isFieldArrayRoot(names: readonly string[], name: string): boolean {
|
|
96
|
+
const escaped = dotted(name).replace(/[.*+?^${}()|\\]/g, '\\$&');
|
|
97
|
+
return names.some((n) => new RegExp(`^${escaped}\\.\\d+`).test(dotted(n)));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function toNestErrors<T extends FieldValues>(flat: Record<string, FieldError>, options: ResolverOptions<T>): FieldErrors<T> {
|
|
101
|
+
if (options.shouldUseNativeValidation) validateFieldsNatively(flat as FieldErrors, options.fields);
|
|
102
|
+
const nested: Record<string, unknown> = {};
|
|
103
|
+
for (const path in flat) {
|
|
104
|
+
const field = get(options.fields, path) as { ref?: unknown; refs?: unknown[] } | undefined;
|
|
105
|
+
const error = Object.assign(flat[path] || {}, { ref: field?.refs ? field.refs[0] : field?.ref });
|
|
106
|
+
if (isFieldArrayRoot((options.names as string[] | undefined) ?? Object.keys(flat), path)) {
|
|
107
|
+
const existing = Object.assign({}, get(nested, path));
|
|
108
|
+
set(existing, 'root', error);
|
|
109
|
+
set(nested, path, existing);
|
|
110
|
+
} else {
|
|
111
|
+
set(nested, path, error);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return nested as FieldErrors<T>;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* A react-hook-form {@link Resolver} for a zod 4 schema: `parseAsync`, parsed output as the
|
|
119
|
+
* form's values on success, react-hook-form's nested error shape on a `ZodError`. Anything
|
|
120
|
+
* else a schema throws is not a validation result and is rethrown.
|
|
121
|
+
*/
|
|
122
|
+
export function zodResolver<TFieldValues extends FieldValues>(schema: z.ZodType): Resolver<TFieldValues> {
|
|
123
|
+
return async (values, _context, options) => {
|
|
124
|
+
try {
|
|
125
|
+
const data = await schema.parseAsync(values);
|
|
126
|
+
if (options.shouldUseNativeValidation) validateFieldsNatively({}, options.fields);
|
|
127
|
+
return { errors: {}, values: data as TFieldValues };
|
|
128
|
+
} catch (error) {
|
|
129
|
+
if (!isZodError(error)) throw error;
|
|
130
|
+
return {
|
|
131
|
+
values: {},
|
|
132
|
+
errors: toNestErrors(parseIssues(error.issues, !options.shouldUseNativeValidation && options.criteriaMode === 'all'), options),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 1317 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 302 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 464 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 390 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
15
15
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
16
|
-
* when this file is stale.
|
|
16
|
+
* when this file is stale. 5214 candidate(s) scanned.
|
|
17
17
|
*
|
|
18
18
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
19
19
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 209 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 1355 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 190 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 103 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 619 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 573 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 155 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 245 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 112 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 2157 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 2775 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
package/src/styles-areas/nav.css
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 801 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 119 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 213 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 361 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 160 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 395 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* An app that imports every area gets exactly what `cursedbelt/styles-utilities.css`
|
|
17
17
|
* emits — proved rule by rule in src/stylesAreas.spec.ts, which is also what fails
|
|
18
|
-
* when this file is stale.
|
|
18
|
+
* when this file is stale. 776 candidate(s) scanned.
|
|
19
19
|
*
|
|
20
20
|
* Written by scripts/generateAreaStyles.ts (`bun run styles:areas`), which
|
|
21
21
|
* `bun run build` runs. The map of what is in which area, and why the areas are
|