@k8ordo/form 0.1.1 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -23
- package/dist/async-check.d.mts +3 -4
- package/dist/derive/attributes.d.mts +43 -8
- package/dist/derive/attributes.mjs +74 -19
- package/dist/derive/form-fields.d.mts +6 -5
- package/dist/derive/form-fields.mjs +55 -23
- package/dist/derive/messages.d.mts +2 -3
- package/dist/derive/messages.mjs +8 -11
- package/dist/hidden-value.d.mts +4 -8
- package/dist/hidden-value.mjs +8 -9
- package/dist/index.d.mts +4 -4
- package/dist/parse/parse-form.d.mts +3 -4
- package/dist/parse/parse-form.mjs +25 -4
- package/dist/parse/paths.d.mts +4 -5
- package/dist/paths.d.mts +34 -5
- package/dist/rules/define-form.d.mts +4 -5
- package/dist/rules/rules.d.mts +26 -11
- package/dist/rules/rules.mjs +14 -7
- package/dist/schema/object-schema.d.mts +7 -6
- package/dist/schema/walk.d.mts +16 -8
- package/dist/schema/walk.mjs +77 -10
- package/dist/server.d.mts +3 -3
- package/dist/types.d.mts +38 -17
- package/dist/use-form.d.mts +32 -12
- package/dist/use-form.mjs +136 -22
- package/docs/GUIDE.md +469 -71
- package/docs/llms.txt +61 -15
- package/package.json +17 -16
package/README.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# @k8ordo/form
|
|
2
2
|
|
|
3
3
|
Derive HTML constraint attributes, error messages, and server-side validation
|
|
4
|
-
from one zod schema —
|
|
5
|
-
|
|
4
|
+
from one zod schema — the client side is derived, never written, so there is no
|
|
5
|
+
second copy of a constraint to drift. Works with JavaScript disabled or not yet
|
|
6
6
|
loaded.
|
|
7
7
|
|
|
8
8
|
Like every [k8ordo](https://ordo.k8o.me) package it assumes React 19 and Server
|
|
@@ -22,16 +22,20 @@ pnpm add @k8ordo/form zod
|
|
|
22
22
|
|
|
23
23
|
## Peer Dependencies
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
|
28
|
-
|
|
|
29
|
-
| `
|
|
30
|
-
| `
|
|
31
|
-
|
|
|
25
|
+
<!-- peers -->
|
|
26
|
+
|
|
27
|
+
| Package | Version | Required | Needed for |
|
|
28
|
+
| -------------- | ------- | -------- | ----------------------------- |
|
|
29
|
+
| `react` | ≥19.3.0 | yes | `useForm` and Server Actions |
|
|
30
|
+
| `react-dom` | ≥19.3.0 | yes | rendering |
|
|
31
|
+
| `zod` | ^4.4.3 | yes | the schema (`zod/mini` works) |
|
|
32
|
+
| `typescript` | ≥7.0.0 | optional | the shipped type declarations |
|
|
33
|
+
| `@types/react` | ≥19.3.0 | optional | the shipped type declarations |
|
|
34
|
+
|
|
35
|
+
<!-- /peers -->
|
|
32
36
|
|
|
33
37
|
zod never reaches the browser: the schema is read on the server and crosses the
|
|
34
|
-
RSC boundary as plain data. The client entry is ~2.
|
|
38
|
+
RSC boundary as plain data. The client entry is ~2.8 kB gzipped.
|
|
35
39
|
|
|
36
40
|
## Quick Start
|
|
37
41
|
|
|
@@ -39,7 +43,7 @@ One schema drives everything — the attributes, the messages, and the
|
|
|
39
43
|
server-side validation:
|
|
40
44
|
|
|
41
45
|
```ts
|
|
42
|
-
// schema.ts
|
|
46
|
+
// src/lib/schema.ts
|
|
43
47
|
export const talkSchema = z.object({
|
|
44
48
|
title: z.string().min(1, 'タイトルを入力してください').max(120),
|
|
45
49
|
eventUrl: z.url(),
|
|
@@ -47,7 +51,7 @@ export const talkSchema = z.object({
|
|
|
47
51
|
```
|
|
48
52
|
|
|
49
53
|
```tsx
|
|
50
|
-
// page.tsx — Server Component
|
|
54
|
+
// src/routes/talks/new/page.tsx — Server Component
|
|
51
55
|
import { formFields } from '@k8ordo/form/server';
|
|
52
56
|
|
|
53
57
|
const talkFields = formFields(talkSchema); // derived once, plain data
|
|
@@ -58,7 +62,7 @@ export default function Page() {
|
|
|
58
62
|
```
|
|
59
63
|
|
|
60
64
|
```tsx
|
|
61
|
-
// talk-form.tsx
|
|
65
|
+
// src/components/talk-form.tsx
|
|
62
66
|
'use client';
|
|
63
67
|
import { useForm } from '@k8ordo/form';
|
|
64
68
|
|
|
@@ -78,29 +82,33 @@ export const TalkForm = ({ action, fields }: TalkFormProps) => {
|
|
|
78
82
|
```
|
|
79
83
|
|
|
80
84
|
```ts
|
|
81
|
-
// actions.ts
|
|
85
|
+
// src/lib/actions.ts
|
|
82
86
|
'use server';
|
|
83
87
|
import { parseForm } from '@k8ordo/form/server';
|
|
88
|
+
import { href } from '@k8ordo/framework';
|
|
89
|
+
import { redirect } from '@k8ordo/framework/server';
|
|
84
90
|
|
|
85
91
|
export async function createTalk(_prev: FormState, formData: FormData) {
|
|
86
92
|
const parsed = parseForm(talkSchema, formData);
|
|
87
93
|
if (!parsed.success) return parsed.state;
|
|
88
94
|
await insertTalk(parsed.data); // typed
|
|
89
|
-
redirect('/talks');
|
|
95
|
+
redirect(href('/talks'));
|
|
90
96
|
}
|
|
91
97
|
```
|
|
92
98
|
|
|
93
99
|
The [design guide](docs/GUIDE.md) covers the rest: nested objects and repeated
|
|
94
100
|
rows, checkbox groups, cross-field rules typed against the schema's paths,
|
|
95
101
|
async per-field checks, components that render no input of their own, and what
|
|
96
|
-
the package guarantees (
|
|
97
|
-
without JavaScript, secrets never echoed).
|
|
98
|
-
|
|
99
|
-
A check HTML cannot express — a `refine
|
|
100
|
-
would reinterpret
|
|
101
|
-
|
|
102
|
-
`
|
|
103
|
-
|
|
102
|
+
the package guarantees (checks the client skips are reported, native
|
|
103
|
+
validation kept without JavaScript, secrets never echoed).
|
|
104
|
+
|
|
105
|
+
A check HTML cannot express — a `refine` on the schema as a whole, a regex the
|
|
106
|
+
`pattern` attribute would reinterpret, several regexes on one string, a
|
|
107
|
+
`.mime()` that `accept` only suggests — is returned in `dropped` (typed as
|
|
108
|
+
`DroppedCheck`, exported from both entries) and, outside production, logged
|
|
109
|
+
once per schema with `console.warn`, so it is seen without anyone remembering
|
|
110
|
+
to read it. It still runs on the server. Not reported yet: a `refine` on a
|
|
111
|
+
single field, a nested object, or a row.
|
|
104
112
|
|
|
105
113
|
## AI Agent Documentation
|
|
106
114
|
|
package/dist/async-check.d.mts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* the same message path as every built-in check instead of opening a second
|
|
9
9
|
* one.
|
|
10
10
|
*/
|
|
11
|
-
type AsyncCheck = {
|
|
11
|
+
export type AsyncCheck = {
|
|
12
12
|
/** Attach to the input this check guards. */
|
|
13
13
|
props: {
|
|
14
14
|
onBlur: () => void;
|
|
@@ -17,6 +17,5 @@ type AsyncCheck = {
|
|
|
17
17
|
/** True while an answer is outstanding, for disabling submit. */
|
|
18
18
|
isChecking: boolean;
|
|
19
19
|
};
|
|
20
|
-
declare const useAsyncCheck: (check: (value: string) => Promise<string | undefined>) => AsyncCheck;
|
|
21
|
-
//#endregion
|
|
22
|
-
export { AsyncCheck, useAsyncCheck };
|
|
20
|
+
export declare const useAsyncCheck: (check: (value: string) => Promise<string | undefined>) => AsyncCheck;
|
|
21
|
+
//#endregion
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { DroppedCheck, FieldInput } from "../types.mjs";
|
|
2
2
|
//#region src/derive/attributes.d.ts
|
|
3
3
|
/** The subset of JSON Schema that `z.toJSONSchema` emits for a leaf field. */
|
|
4
|
-
type LeafSchema = {
|
|
4
|
+
export type LeafSchema = {
|
|
5
5
|
type?: string;
|
|
6
6
|
format?: string;
|
|
7
7
|
pattern?: string;
|
|
@@ -13,20 +13,55 @@ type LeafSchema = {
|
|
|
13
13
|
exclusiveMaximum?: number;
|
|
14
14
|
multipleOf?: number;
|
|
15
15
|
enum?: unknown[];
|
|
16
|
-
|
|
16
|
+
contentMediaType?: string;
|
|
17
|
+
anyOf?: LeafSchema[];
|
|
18
|
+
/** Where zod puts a string's patterns once there is more than one. */
|
|
19
|
+
allOf?: LeafSchema[];
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Which control a leaf becomes, at the resolution the submission cares about.
|
|
23
|
+
* `attributesFor` needs the finer distinctions (`email` vs `url`); the walk and
|
|
24
|
+
* the parse need only this. The walk settles it once per leaf, and both read it
|
|
25
|
+
* from there, which is what keeps the three from disagreeing about what an
|
|
26
|
+
* untouched control submits.
|
|
27
|
+
*
|
|
28
|
+
* チェックボックスは、スキーマが何を読むかで 2 つに分かれる。`checkbox` は
|
|
29
|
+
* チェックの有無を真偽値で(`z.boolean()`)、`string-checkbox` は送られた
|
|
30
|
+
* 文字列か何も無いかを(`z.stringbool()`)読む。JSON Schema ではどちらも
|
|
31
|
+
* boolean なので、分けられるのは walk だけ。
|
|
32
|
+
*/
|
|
33
|
+
export type ControlKind = 'checkbox' | 'choice' | 'file' | 'number' | 'string-checkbox' | 'text';
|
|
34
|
+
export declare const controlKindOf: (schema: LeafSchema) => ControlKind;
|
|
35
|
+
/**
|
|
36
|
+
* What the control hands the schema when nobody fills it in. A text field
|
|
37
|
+
* always submits `''` and an unchecked checkbox parses to `false`, but a
|
|
38
|
+
* number, a file and a choice have no such value: an empty numeric field is not
|
|
39
|
+
* 0, an unfilled file input is not a zero-byte file, and a select left on its
|
|
40
|
+
* placeholder has chosen nothing — just like a radio group with no selection,
|
|
41
|
+
* which submits no entry at all (as does an unchecked `string-checkbox`).
|
|
42
|
+
* Reading them as "nothing was entered" is what keeps `required` honest —
|
|
43
|
+
* `z.coerce.number()` turns `''` into 0, so probing with `''` would say an
|
|
44
|
+
* empty field is acceptable and then let a blank submission through as a
|
|
45
|
+
* number the person never typed.
|
|
46
|
+
*/
|
|
47
|
+
export declare const emptySubmissionOf: (kind: ControlKind) => unknown;
|
|
48
|
+
/** Whether a JSON Schema `format` names what a control submits, not a refinement of it. */
|
|
49
|
+
export declare const namesAControl: (format: string) => boolean;
|
|
50
|
+
/** What the JSON Schema has already lost, recovered from the zod side when it can be. */
|
|
51
|
+
export type PatternHints = {
|
|
52
|
+
/** Flags of the RegExp behind a lone `pattern`. */
|
|
53
|
+
flags?: string;
|
|
54
|
+
/** Source of the regex the format itself carries, as opposed to one stacked on it. */
|
|
55
|
+
ofFormat?: string;
|
|
17
56
|
};
|
|
18
57
|
/**
|
|
19
58
|
* Turn one leaf JSON Schema into input attributes, collecting whatever HTML
|
|
20
59
|
* cannot express. Anything not representable is returned rather than dropped,
|
|
21
60
|
* so the caller can report it instead of leaving the author to discover at
|
|
22
61
|
* runtime that a check never ran on the client.
|
|
23
|
-
*
|
|
24
|
-
* `patternFlags` are the flags of the source RegExp when the caller could
|
|
25
|
-
* recover it — the JSON Schema string has already lost them.
|
|
26
62
|
*/
|
|
27
|
-
declare const attributesFor: (name: string, schema: LeafSchema, required: boolean,
|
|
63
|
+
export declare const attributesFor: (name: string, schema: LeafSchema, required: boolean, hints?: PatternHints) => {
|
|
28
64
|
input: FieldInput;
|
|
29
65
|
dropped: DroppedCheck[];
|
|
30
66
|
};
|
|
31
|
-
//#endregion
|
|
32
|
-
export { LeafSchema, attributesFor };
|
|
67
|
+
//#endregion
|
|
@@ -1,4 +1,27 @@
|
|
|
1
1
|
//#region src/derive/attributes.ts
|
|
2
|
+
const controlKindOf = (schema) => {
|
|
3
|
+
if (schema.format === "binary") return "file";
|
|
4
|
+
if (schema.enum !== void 0) return "choice";
|
|
5
|
+
if (schema.type === "boolean") return "checkbox";
|
|
6
|
+
if (schema.type === "integer" || schema.type === "number") return "number";
|
|
7
|
+
return "text";
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* What the control hands the schema when nobody fills it in. A text field
|
|
11
|
+
* always submits `''` and an unchecked checkbox parses to `false`, but a
|
|
12
|
+
* number, a file and a choice have no such value: an empty numeric field is not
|
|
13
|
+
* 0, an unfilled file input is not a zero-byte file, and a select left on its
|
|
14
|
+
* placeholder has chosen nothing — just like a radio group with no selection,
|
|
15
|
+
* which submits no entry at all (as does an unchecked `string-checkbox`).
|
|
16
|
+
* Reading them as "nothing was entered" is what keeps `required` honest —
|
|
17
|
+
* `z.coerce.number()` turns `''` into 0, so probing with `''` would say an
|
|
18
|
+
* empty field is acceptable and then let a blank submission through as a
|
|
19
|
+
* number the person never typed.
|
|
20
|
+
*/
|
|
21
|
+
const emptySubmissionOf = (kind) => {
|
|
22
|
+
if (kind === "checkbox") return false;
|
|
23
|
+
return kind === "text" ? "" : void 0;
|
|
24
|
+
};
|
|
2
25
|
/**
|
|
3
26
|
* `.int()` carries JavaScript's safe-integer range into the schema. Emitting it
|
|
4
27
|
* as `max` would put 9007199254740991 in the markup, which tells a reader
|
|
@@ -44,19 +67,37 @@ const patternVerdict = (source, flags) => {
|
|
|
44
67
|
}
|
|
45
68
|
})() ? void 0 : "ブラウザは pattern を v フラグで解釈し、コンパイルできない正規表現は黙って無視します";
|
|
46
69
|
};
|
|
70
|
+
/** Whether a JSON Schema `format` names what a control submits, not a refinement of it. */
|
|
71
|
+
const namesAControl = (format) => Object.hasOwn(FORMAT_TO_INPUT_TYPE, format);
|
|
47
72
|
/**
|
|
48
73
|
* Turn one leaf JSON Schema into input attributes, collecting whatever HTML
|
|
49
74
|
* cannot express. Anything not representable is returned rather than dropped,
|
|
50
75
|
* so the caller can report it instead of leaving the author to discover at
|
|
51
76
|
* runtime that a check never ran on the client.
|
|
52
|
-
*
|
|
53
|
-
* `patternFlags` are the flags of the source RegExp when the caller could
|
|
54
|
-
* recover it — the JSON Schema string has already lost them.
|
|
55
77
|
*/
|
|
56
|
-
const attributesFor = (name, schema, required,
|
|
78
|
+
const attributesFor = (name, schema, required, hints = {}) => {
|
|
57
79
|
const input = { name };
|
|
58
80
|
const dropped = [];
|
|
59
81
|
if (required) input.required = true;
|
|
82
|
+
if (controlKindOf(schema) === "file") {
|
|
83
|
+
input.type = "file";
|
|
84
|
+
const accept = [schema.contentMediaType, ...(schema.anyOf ?? []).map((branch) => branch.contentMediaType)].filter((type) => type !== void 0);
|
|
85
|
+
if (accept.length > 0) {
|
|
86
|
+
input.accept = accept.join(",");
|
|
87
|
+
dropped.push({
|
|
88
|
+
field: name,
|
|
89
|
+
reason: "accept はファイル選択ダイアログの候補を絞るだけで、ブラウザは選ばれたファイルの MIME タイプを検査しません"
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
if (schema.minLength !== void 0 || schema.maxLength !== void 0) dropped.push({
|
|
93
|
+
field: name,
|
|
94
|
+
reason: "ファイルサイズの制限に対応する HTML 属性はありません(minlength / maxlength は type=\"file\" では無視されます)"
|
|
95
|
+
});
|
|
96
|
+
return {
|
|
97
|
+
input,
|
|
98
|
+
dropped
|
|
99
|
+
};
|
|
100
|
+
}
|
|
60
101
|
if (schema.anyOf !== void 0) {
|
|
61
102
|
input.type = "text";
|
|
62
103
|
dropped.push({
|
|
@@ -103,23 +144,37 @@ const attributesFor = (name, schema, required, patternFlags) => {
|
|
|
103
144
|
dropped
|
|
104
145
|
};
|
|
105
146
|
}
|
|
147
|
+
if (schema.type !== "string") {
|
|
148
|
+
input.type = "text";
|
|
149
|
+
dropped.push({
|
|
150
|
+
field: name,
|
|
151
|
+
reason: "スキーマから制約を読み取れないため(transform / pipe / custom など)、type=\"text\" 以外は何も導出できません。ブラウザ側では何も検査されません"
|
|
152
|
+
});
|
|
153
|
+
return {
|
|
154
|
+
input,
|
|
155
|
+
dropped
|
|
156
|
+
};
|
|
157
|
+
}
|
|
106
158
|
input.type = schema.format === void 0 ? "text" : FORMAT_TO_INPUT_TYPE[schema.format] ?? "text";
|
|
107
159
|
if (isFiniteNumber(schema.minLength) && schema.minLength > 0) input.minLength = schema.minLength;
|
|
108
160
|
if (isFiniteNumber(schema.maxLength)) input.maxLength = schema.maxLength;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
161
|
+
const patterns = schema.pattern === void 0 ? (schema.allOf ?? []).flatMap((branch) => branch.pattern === void 0 ? [] : [branch.pattern]) : [schema.pattern];
|
|
162
|
+
if (TYPES_WITHOUT_PATTERN.has(input.type)) {
|
|
163
|
+
if (patterns.filter((pattern) => !((input.type === "date" || input.type === "datetime-local") && pattern === hints.ofFormat)).length > 0) dropped.push({
|
|
164
|
+
field: name,
|
|
165
|
+
reason: `pattern は type="${input.type}" では無視されます`
|
|
166
|
+
});
|
|
167
|
+
} else if (patterns.length > 1) dropped.push({
|
|
168
|
+
field: name,
|
|
169
|
+
reason: `HTML の pattern は 1 つしか持てないため、重ねた ${String(patterns.length)} 個の正規表現はブラウザでは検査されません`
|
|
170
|
+
});
|
|
171
|
+
else if (patterns[0] !== void 0) {
|
|
172
|
+
const verdict = patternVerdict(patterns[0], hints.flags);
|
|
173
|
+
if (verdict === void 0) input.pattern = patterns[0];
|
|
174
|
+
else dropped.push({
|
|
175
|
+
field: name,
|
|
176
|
+
reason: verdict
|
|
177
|
+
});
|
|
123
178
|
}
|
|
124
179
|
return {
|
|
125
180
|
input,
|
|
@@ -127,4 +182,4 @@ const attributesFor = (name, schema, required, patternFlags) => {
|
|
|
127
182
|
};
|
|
128
183
|
};
|
|
129
184
|
//#endregion
|
|
130
|
-
export { attributesFor };
|
|
185
|
+
export { attributesFor, controlKindOf, emptySubmissionOf, namesAControl };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { FormFields } from "../types.mjs";
|
|
2
|
-
import { ArrayPathsOf, FieldPathsOf } from "../paths.mjs";
|
|
2
|
+
import { ArrayPathsOf, FieldPathsOf, StringCheckboxPathsOf } from "../paths.mjs";
|
|
3
3
|
import { ObjectSchema } from "../schema/object-schema.mjs";
|
|
4
4
|
import { FormDefinition } from "../rules/define-form.mjs";
|
|
5
5
|
//#region src/derive/form-fields.d.ts
|
|
@@ -8,8 +8,9 @@ import { FormDefinition } from "../rules/define-form.mjs";
|
|
|
8
8
|
*
|
|
9
9
|
* Call this on the server — in a Server Component or at module scope. The
|
|
10
10
|
* result is plain data, so it crosses to the client as props and zod never
|
|
11
|
-
* enters the bundle.
|
|
11
|
+
* enters the bundle. Messages are read when it runs — zod's, and a rule's
|
|
12
|
+
* function message — so when one follows the request (its locale, say), call
|
|
13
|
+
* it during the render, not at module scope.
|
|
12
14
|
*/
|
|
13
|
-
declare const formFields: <Schema extends ObjectSchema>(input: FormDefinition<Schema> | Schema) => FormFields<FieldPathsOf<Schema>, ArrayPathsOf<Schema>>;
|
|
14
|
-
//#endregion
|
|
15
|
-
export { formFields };
|
|
15
|
+
export declare const formFields: <Schema extends ObjectSchema>(input: FormDefinition<Schema> | Schema) => FormFields<FieldPathsOf<Schema>, ArrayPathsOf<Schema>, StringCheckboxPathsOf<Schema>>;
|
|
16
|
+
//#endregion
|
|
@@ -1,26 +1,40 @@
|
|
|
1
|
+
import { deriveRule } from "../rules/rules.mjs";
|
|
1
2
|
import { asDefinition } from "../rules/define-form.mjs";
|
|
2
3
|
import { asProbe } from "../schema/object-schema.mjs";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
4
|
+
import { attributesFor, emptySubmissionOf, namesAControl } from "./attributes.mjs";
|
|
5
|
+
import { schemaMap, unwrap } from "../schema/walk.mjs";
|
|
5
6
|
import { messagesFor } from "./messages.mjs";
|
|
6
7
|
//#region src/derive/form-fields.ts
|
|
7
8
|
const objectLevelCheckCount = (schema) => schema._zod?.def?.checks?.length ?? 0;
|
|
8
9
|
/**
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* The string formats a leaf declares — its own (`z.email()`) first, then the
|
|
11
|
+
* ones its checks add (`.regex()`, `.lowercase()`) — each with the RegExp
|
|
12
|
+
* behind it. The JSON Schema cannot stand in: its `pattern` strings have lost
|
|
13
|
+
* their flags (an `i` has no HTML equivalent), and a later check overwrites
|
|
14
|
+
* the `format` keyword (`z.url().lowercase()` says `lowercase`) or erases it
|
|
15
|
+
* (`.regex()`).
|
|
12
16
|
*/
|
|
13
|
-
const
|
|
14
|
-
const def = schema._zod?.def;
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
const stringFormatsOf = (schema) => {
|
|
18
|
+
const def = unwrap(schema)._zod?.def;
|
|
19
|
+
return [def, ...(def?.checks ?? []).map((check) => check._zod?.def)].filter((entry) => entry?.format !== void 0);
|
|
20
|
+
};
|
|
21
|
+
/** zod's own name for a format is not always the JSON Schema one. */
|
|
22
|
+
const ZOD_FORMAT_TO_JSON_FORMAT = {
|
|
23
|
+
datetime: "date-time",
|
|
24
|
+
guid: "uuid",
|
|
25
|
+
url: "uri"
|
|
20
26
|
};
|
|
27
|
+
const jsonFormatOf = (format) => ZOD_FORMAT_TO_JSON_FORMAT[format] ?? format;
|
|
21
28
|
/** What a `datetime-local` control actually submits: no seconds, no zone. */
|
|
22
29
|
const DATETIME_LOCAL_PROBE = "2000-01-01T00:00";
|
|
23
30
|
/**
|
|
31
|
+
* Whether the date-time format itself turns away what a `datetime-local`
|
|
32
|
+
* control submits. Only the format's own issue counts: a regex stacked on it
|
|
33
|
+
* that fails the probe is a check the control ignores, not a shape the control
|
|
34
|
+
* cannot produce.
|
|
35
|
+
*/
|
|
36
|
+
const refusesDatetimeLocal = (schema) => asProbe(schema).safeParse(DATETIME_LOCAL_PROBE).error?.issues.some((issue) => issue.code === "invalid_format" && issue.format === "datetime") ?? false;
|
|
37
|
+
/**
|
|
24
38
|
* Schemas whose `dropped` list has already been reported. `formFields` is not
|
|
25
39
|
* memoized, so a schema derived in every request would otherwise warn on
|
|
26
40
|
* every request; keyed by the schema object, an HMR re-evaluation still gets
|
|
@@ -49,7 +63,9 @@ const warnDropped = (schema, dropped) => {
|
|
|
49
63
|
*
|
|
50
64
|
* Call this on the server — in a Server Component or at module scope. The
|
|
51
65
|
* result is plain data, so it crosses to the client as props and zod never
|
|
52
|
-
* enters the bundle.
|
|
66
|
+
* enters the bundle. Messages are read when it runs — zod's, and a rule's
|
|
67
|
+
* function message — so when one follows the request (its locale, say), call
|
|
68
|
+
* it during the render, not at module scope.
|
|
53
69
|
*/
|
|
54
70
|
const formFields = (input) => {
|
|
55
71
|
const { schema, rules } = asDefinition(input);
|
|
@@ -64,23 +80,39 @@ const formFields = (input) => {
|
|
|
64
80
|
item: {}
|
|
65
81
|
};
|
|
66
82
|
for (const leaf of map.leaves) {
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
83
|
+
const formats = stringFormatsOf(leaf.zod);
|
|
84
|
+
const own = leaf.json.type === "string" ? formats.find((entry) => namesAControl(jsonFormatOf(entry.format))) : void 0;
|
|
85
|
+
let format = own === void 0 ? leaf.json.format : jsonFormatOf(own.format);
|
|
86
|
+
const formatDropped = [];
|
|
87
|
+
if (format === "date-time" && refusesDatetimeLocal(leaf.zod)) {
|
|
88
|
+
format = void 0;
|
|
89
|
+
formatDropped.push({
|
|
73
90
|
field: leaf.name,
|
|
74
|
-
reason:
|
|
91
|
+
reason: `この日時書式は datetime-local が送信する形(${DATETIME_LOCAL_PROBE} のように秒もタイムゾーンも付かない)を受け付けないため、type="text" に落とします(z.iso.datetime({ local: true }) なら datetime-local が使えます)`
|
|
75
92
|
});
|
|
76
93
|
}
|
|
94
|
+
const { pattern } = leaf.json;
|
|
95
|
+
const attributes = attributesFor(leaf.name, {
|
|
96
|
+
...leaf.json,
|
|
97
|
+
format
|
|
98
|
+
}, leaf.required, {
|
|
99
|
+
flags: pattern === void 0 ? void 0 : formats.find((entry) => entry.pattern?.source === pattern)?.pattern?.flags,
|
|
100
|
+
ofFormat: format === void 0 ? void 0 : own?.pattern?.source
|
|
101
|
+
});
|
|
102
|
+
attributes.dropped.unshift(...formatDropped);
|
|
103
|
+
const secret = leaf.json.input === "password";
|
|
104
|
+
if (secret) attributes.input.type = "password";
|
|
105
|
+
const fieldInput = leaf.checkedValue === void 0 ? attributes.input : {
|
|
106
|
+
...attributes.input,
|
|
107
|
+
value: leaf.checkedValue
|
|
108
|
+
};
|
|
77
109
|
if (leaf.group !== void 0 && (leaf.group.minItems !== void 0 || leaf.group.maxItems !== void 0)) dropped.push({
|
|
78
110
|
field: leaf.path,
|
|
79
111
|
reason: "チェックボックス群の個数制限に対応する HTML 属性はありません。クライアントでも検査するには minChecked を使ってください"
|
|
80
112
|
});
|
|
81
113
|
const derived = {
|
|
82
|
-
input:
|
|
83
|
-
messages: messagesFor(leaf.zod,
|
|
114
|
+
input: fieldInput,
|
|
115
|
+
messages: messagesFor(leaf.zod, fieldInput, leaf.required, emptySubmissionOf(leaf.kind)),
|
|
84
116
|
secret
|
|
85
117
|
};
|
|
86
118
|
const array = leaf.arrayPath === null ? void 0 : arrays[leaf.arrayPath];
|
|
@@ -100,7 +132,7 @@ const formFields = (input) => {
|
|
|
100
132
|
return {
|
|
101
133
|
fields,
|
|
102
134
|
arrays,
|
|
103
|
-
rules,
|
|
135
|
+
rules: rules.map((rule) => deriveRule(rule)),
|
|
104
136
|
dropped
|
|
105
137
|
};
|
|
106
138
|
};
|
|
@@ -7,6 +7,5 @@ import { $ZodType } from "zod/v4/core";
|
|
|
7
7
|
* flag — the browser's own wording is never used, since it is locale-dependent
|
|
8
8
|
* and cannot be controlled.
|
|
9
9
|
*/
|
|
10
|
-
declare const messagesFor: (schema: $ZodType, input: FieldInput, required: boolean) => Partial<Record<ValidityFlag, string>>;
|
|
11
|
-
//#endregion
|
|
12
|
-
export { messagesFor };
|
|
10
|
+
export declare const messagesFor: (schema: $ZodType, input: FieldInput, required: boolean, emptySubmission: unknown) => Partial<Record<ValidityFlag, string>>;
|
|
11
|
+
//#endregion
|
package/dist/derive/messages.mjs
CHANGED
|
@@ -1,19 +1,16 @@
|
|
|
1
1
|
import { asProbe } from "../schema/object-schema.mjs";
|
|
2
2
|
//#region src/derive/messages.ts
|
|
3
3
|
const repeat = (length) => "a".repeat(Math.max(length, 0));
|
|
4
|
-
const probesFor = (input, required) => {
|
|
4
|
+
const probesFor = (input, required, emptySubmission) => {
|
|
5
5
|
const probes = [];
|
|
6
|
-
if (required) probes.push(
|
|
6
|
+
if (required) probes.push({
|
|
7
7
|
flag: "valueMissing",
|
|
8
|
-
value:
|
|
9
|
-
codes: ["invalid_value", "invalid_type"]
|
|
10
|
-
} : {
|
|
11
|
-
flag: "valueMissing",
|
|
12
|
-
value: "",
|
|
8
|
+
value: emptySubmission,
|
|
13
9
|
codes: [
|
|
14
10
|
"too_small",
|
|
15
|
-
"
|
|
16
|
-
"invalid_format"
|
|
11
|
+
"invalid_value",
|
|
12
|
+
"invalid_format",
|
|
13
|
+
"invalid_type"
|
|
17
14
|
]
|
|
18
15
|
});
|
|
19
16
|
if (input.minLength !== void 0) probes.push({
|
|
@@ -66,9 +63,9 @@ const probesFor = (input, required) => {
|
|
|
66
63
|
* flag — the browser's own wording is never used, since it is locale-dependent
|
|
67
64
|
* and cannot be controlled.
|
|
68
65
|
*/
|
|
69
|
-
const messagesFor = (schema, input, required) => {
|
|
66
|
+
const messagesFor = (schema, input, required, emptySubmission) => {
|
|
70
67
|
const messages = {};
|
|
71
|
-
for (const probe of probesFor(input, required)) {
|
|
68
|
+
for (const probe of probesFor(input, required, emptySubmission)) {
|
|
72
69
|
const result = asProbe(schema).safeParse(probe.value);
|
|
73
70
|
if (result.success) continue;
|
|
74
71
|
const issue = probe.codes.map((code) => result.error.issues.find((candidate) => candidate.code === code)).find((found) => found !== void 0) ?? result.error.issues[0];
|
package/dist/hidden-value.d.mts
CHANGED
|
@@ -8,18 +8,14 @@ import { FC } from "react";
|
|
|
8
8
|
* pulling its value into React state and fighting it back out at submit, park
|
|
9
9
|
* the value in a hidden input and let the form collect it like any other.
|
|
10
10
|
*
|
|
11
|
-
* This is the whole of what `Controller` exists to do in react-hook-form, and
|
|
12
|
-
* it is the one place that binding silently breaks there.
|
|
13
|
-
*
|
|
14
11
|
* A component rather than a props helper for two reasons the form cannot see
|
|
15
12
|
* from outside: React updates a controlled value without any DOM event, so a
|
|
16
13
|
* change here has to be announced with one for rules and `isDirty` to notice;
|
|
17
|
-
* and a controlled input's defaultValue tracks its value, so the
|
|
18
|
-
*
|
|
14
|
+
* and a controlled input's defaultValue tracks its value, so the value it
|
|
15
|
+
* mounted with is burned into `data-initial` as the dirtiness baseline.
|
|
19
16
|
*/
|
|
20
|
-
declare const HiddenValue: FC<{
|
|
17
|
+
export declare const HiddenValue: FC<{
|
|
21
18
|
name: string;
|
|
22
19
|
value: string;
|
|
23
20
|
}>;
|
|
24
|
-
//#endregion
|
|
25
|
-
export { HiddenValue };
|
|
21
|
+
//#endregion
|
package/dist/hidden-value.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { useEffect, useRef } from "react";
|
|
2
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
3
3
|
import { jsx } from "react/jsx-runtime";
|
|
4
4
|
//#region src/hidden-value.tsx
|
|
5
5
|
/**
|
|
@@ -10,29 +10,28 @@ import { jsx } from "react/jsx-runtime";
|
|
|
10
10
|
* pulling its value into React state and fighting it back out at submit, park
|
|
11
11
|
* the value in a hidden input and let the form collect it like any other.
|
|
12
12
|
*
|
|
13
|
-
* This is the whole of what `Controller` exists to do in react-hook-form, and
|
|
14
|
-
* it is the one place that binding silently breaks there.
|
|
15
|
-
*
|
|
16
13
|
* A component rather than a props helper for two reasons the form cannot see
|
|
17
14
|
* from outside: React updates a controlled value without any DOM event, so a
|
|
18
15
|
* change here has to be announced with one for rules and `isDirty` to notice;
|
|
19
|
-
* and a controlled input's defaultValue tracks its value, so the
|
|
20
|
-
*
|
|
16
|
+
* and a controlled input's defaultValue tracks its value, so the value it
|
|
17
|
+
* mounted with is burned into `data-initial` as the dirtiness baseline.
|
|
21
18
|
*/
|
|
22
19
|
const HiddenValue = ({ name, value }) => {
|
|
23
20
|
const node = useRef(null);
|
|
24
|
-
const initial = useRef(value);
|
|
25
21
|
const last = useRef(value);
|
|
22
|
+
const attach = useCallback((element) => {
|
|
23
|
+
node.current = element;
|
|
24
|
+
if (element !== null) element.dataset.initial = element.value;
|
|
25
|
+
}, []);
|
|
26
26
|
useEffect(() => {
|
|
27
27
|
if (last.current === value) return;
|
|
28
28
|
last.current = value;
|
|
29
29
|
node.current?.dispatchEvent(new Event("input", { bubbles: true }));
|
|
30
30
|
}, [value]);
|
|
31
31
|
return /* @__PURE__ */ jsx("input", {
|
|
32
|
-
"data-initial": initial.current,
|
|
33
32
|
name,
|
|
34
33
|
readOnly: true,
|
|
35
|
-
ref:
|
|
34
|
+
ref: attach,
|
|
36
35
|
type: "hidden",
|
|
37
36
|
value
|
|
38
37
|
});
|
package/dist/index.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { AsyncCheck, useAsyncCheck } from "./async-check.mjs";
|
|
2
|
-
import { Rule } from "./rules/rules.mjs";
|
|
3
|
-
import { DerivedArray, DerivedField, DroppedCheck, FieldInput, FormFields, FormState, ValidityFlag } from "./types.mjs";
|
|
2
|
+
import { DerivedRule, Rule, RuleMessage } from "./rules/rules.mjs";
|
|
3
|
+
import { DerivedArray, DerivedField, DroppedCheck, FieldInput, FormFields, FormState, StringCheckboxInput, ValidityFlag } from "./types.mjs";
|
|
4
4
|
import { HiddenValue } from "./hidden-value.mjs";
|
|
5
|
-
import { ArrayView, FieldView, RowView, UseFormReturn, useForm } from "./use-form.mjs";
|
|
6
|
-
export { type ArrayView, type AsyncCheck, type DerivedArray, type DerivedField, type DroppedCheck, type FieldInput, type FieldView, type FormFields, type FormState, HiddenValue, type RowView, type Rule, type UseFormReturn, type ValidityFlag, useAsyncCheck, useForm };
|
|
5
|
+
import { ArrayView, FieldView, FormErrorView, RowView, UseFormReturn, useForm } from "./use-form.mjs";
|
|
6
|
+
export { type ArrayView, type AsyncCheck, type DerivedArray, type DerivedField, type DerivedRule, type DroppedCheck, type FieldInput, type FieldView, type FormErrorView, type FormFields, type FormState, HiddenValue, type RowView, type Rule, type RuleMessage, type StringCheckboxInput, type UseFormReturn, type ValidityFlag, useAsyncCheck, useForm };
|
|
@@ -3,7 +3,7 @@ import { ObjectSchema } from "../schema/object-schema.mjs";
|
|
|
3
3
|
import { FormDefinition } from "../rules/define-form.mjs";
|
|
4
4
|
import { output } from "zod/v4/core";
|
|
5
5
|
//#region src/parse/parse-form.d.ts
|
|
6
|
-
type ParseResult<Output> = {
|
|
6
|
+
export type ParseResult<Output> = {
|
|
7
7
|
success: true;
|
|
8
8
|
data: Output;
|
|
9
9
|
state: FormState;
|
|
@@ -21,6 +21,5 @@ type ParseResult<Output> = {
|
|
|
21
21
|
* schema's job via `z.coerce`, so the schema keeps describing what it
|
|
22
22
|
* actually validates.
|
|
23
23
|
*/
|
|
24
|
-
declare const parseForm: <Shape extends ObjectSchema>(input: FormDefinition<Shape> | Shape, formData: FormData) => ParseResult<output<Shape>>;
|
|
25
|
-
//#endregion
|
|
26
|
-
export { ParseResult, parseForm };
|
|
24
|
+
export declare const parseForm: <Shape extends ObjectSchema>(input: FormDefinition<Shape> | Shape, formData: FormData) => ParseResult<output<Shape>>;
|
|
25
|
+
//#endregion
|