@k8ordo/form 0.1.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/LICENSE +21 -0
- package/README.md +127 -0
- package/dist/async-check.d.mts +22 -0
- package/dist/async-check.mjs +49 -0
- package/dist/derive/attributes.d.mts +32 -0
- package/dist/derive/attributes.mjs +130 -0
- package/dist/derive/form-fields.d.mts +15 -0
- package/dist/derive/form-fields.mjs +83 -0
- package/dist/derive/messages.d.mts +12 -0
- package/dist/derive/messages.mjs +80 -0
- package/dist/hidden-value.d.mts +25 -0
- package/dist/hidden-value.mjs +41 -0
- package/dist/index.d.mts +6 -0
- package/dist/index.mjs +4 -0
- package/dist/parse/parse-form.d.mts +26 -0
- package/dist/parse/parse-form.mjs +118 -0
- package/dist/parse/paths.d.mts +20 -0
- package/dist/parse/paths.mjs +52 -0
- package/dist/paths.d.mts +56 -0
- package/dist/paths.mjs +1 -0
- package/dist/rules/define-form.d.mts +21 -0
- package/dist/rules/define-form.mjs +12 -0
- package/dist/rules/rules.d.mts +41 -0
- package/dist/rules/rules.mjs +35 -0
- package/dist/schema/object-schema.d.mts +33 -0
- package/dist/schema/object-schema.mjs +4 -0
- package/dist/schema/walk.d.mts +48 -0
- package/dist/schema/walk.mjs +128 -0
- package/dist/server.d.mts +6 -0
- package/dist/server.mjs +5 -0
- package/dist/types.d.mts +76 -0
- package/dist/types.mjs +1 -0
- package/dist/use-form.d.mts +44 -0
- package/dist/use-form.mjs +309 -0
- package/docs/GUIDE.md +362 -0
- package/docs/llms.txt +32 -0
- package/package.json +76 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Koki Sakano
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# @k8ordo/form
|
|
2
|
+
|
|
3
|
+
Derive HTML constraint attributes, error messages, and server-side validation
|
|
4
|
+
from one zod schema — forms whose two sides cannot disagree, because the client
|
|
5
|
+
side is derived, never written. Works with JavaScript disabled or not yet
|
|
6
|
+
loaded.
|
|
7
|
+
|
|
8
|
+
Like every [k8ordo](https://ordo.k8o.me) package it assumes React 19 and Server
|
|
9
|
+
Components, uses only what has reached Baseline newly available, and ships no
|
|
10
|
+
polyfills or legacy fallbacks.
|
|
11
|
+
|
|
12
|
+
- **Documentation**: https://ordo.k8o.me/form
|
|
13
|
+
- **Design guide**: [docs/GUIDE.md](docs/GUIDE.md) — shipped inside this package
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @k8ordo/form zod
|
|
19
|
+
# or
|
|
20
|
+
pnpm add @k8ordo/form zod
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Peer Dependencies
|
|
24
|
+
|
|
25
|
+
| Package | Version | Needed for |
|
|
26
|
+
| -------------- | -------- | ----------------------------- |
|
|
27
|
+
| `react` | ≥19.2.6 | `useForm` and Server Actions |
|
|
28
|
+
| `react-dom` | ≥19.2.6 | rendering |
|
|
29
|
+
| `zod` | ^4.4.3 | the schema (`zod/mini` works) |
|
|
30
|
+
| `typescript` | ≥7.0.2 | the shipped type declarations |
|
|
31
|
+
| `@types/react` | ≥19.2.18 | the shipped type declarations |
|
|
32
|
+
|
|
33
|
+
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.7 kB gzipped.
|
|
35
|
+
|
|
36
|
+
## Quick Start
|
|
37
|
+
|
|
38
|
+
One schema drives everything — the attributes, the messages, and the
|
|
39
|
+
server-side validation:
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
// schema.ts
|
|
43
|
+
export const talkSchema = z.object({
|
|
44
|
+
title: z.string().min(1, 'タイトルを入力してください').max(120),
|
|
45
|
+
eventUrl: z.url(),
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
// page.tsx — Server Component
|
|
51
|
+
import { formFields } from '@k8ordo/form/server';
|
|
52
|
+
|
|
53
|
+
const talkFields = formFields(talkSchema); // derived once, plain data
|
|
54
|
+
|
|
55
|
+
export default function Page() {
|
|
56
|
+
return <TalkForm action={createTalk} fields={talkFields} />;
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
// talk-form.tsx
|
|
62
|
+
'use client';
|
|
63
|
+
import { useForm } from '@k8ordo/form';
|
|
64
|
+
|
|
65
|
+
export const TalkForm = ({ action, fields }: TalkFormProps) => {
|
|
66
|
+
const [state, formAction] = useActionState(action, {});
|
|
67
|
+
const form = useForm(fields, state);
|
|
68
|
+
const title = form.field('title');
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<form {...form.props} action={formAction}>
|
|
72
|
+
<input {...title.input} />
|
|
73
|
+
{title.error && <p>{title.error}</p>}
|
|
74
|
+
<button type="submit">送信</button>
|
|
75
|
+
</form>
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// actions.ts
|
|
82
|
+
'use server';
|
|
83
|
+
import { parseForm } from '@k8ordo/form/server';
|
|
84
|
+
|
|
85
|
+
export async function createTalk(_prev: FormState, formData: FormData) {
|
|
86
|
+
const parsed = parseForm(talkSchema, formData);
|
|
87
|
+
if (!parsed.success) return parsed.state;
|
|
88
|
+
await insertTalk(parsed.data); // typed
|
|
89
|
+
redirect('/talks');
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The [design guide](docs/GUIDE.md) covers the rest: nested objects and repeated
|
|
94
|
+
rows, checkbox groups, cross-field rules typed against the schema's paths,
|
|
95
|
+
async per-field checks, components that render no input of their own, and what
|
|
96
|
+
the package guarantees (nothing dropped in silence, native validation kept
|
|
97
|
+
without JavaScript, secrets never echoed).
|
|
98
|
+
|
|
99
|
+
## AI Agent Documentation
|
|
100
|
+
|
|
101
|
+
The docs ship **inside the package**, so an agent always reads the exact
|
|
102
|
+
version you installed — there is no snapshot to copy or re-sync on upgrade.
|
|
103
|
+
|
|
104
|
+
Point your agent at them once by pasting this into your project's `CLAUDE.md` /
|
|
105
|
+
`AGENTS.md`:
|
|
106
|
+
|
|
107
|
+
```markdown
|
|
108
|
+
Use `@k8ordo/form` for forms. Before writing or changing a form, read
|
|
109
|
+
`node_modules/@k8ordo/form/docs/GUIDE.md`. One zod schema is the single
|
|
110
|
+
source: derive with `formFields(schema)` on the server, wire with
|
|
111
|
+
`useForm(fields, state)` on the client, validate with
|
|
112
|
+
`parseForm(schema, formData)` in the Server Action. Never duplicate a
|
|
113
|
+
constraint in JSX that the schema already states, and check the `dropped`
|
|
114
|
+
report for validations that do not reach the client.
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
What each surface gives an agent:
|
|
118
|
+
|
|
119
|
+
| Surface | Where |
|
|
120
|
+
| -------------------------- | ---------------------------------------------- |
|
|
121
|
+
| Design guide (entry point) | `node_modules/@k8ordo/form/docs/GUIDE.md` |
|
|
122
|
+
| Docs index for LLMs | `docs/llms.txt` · https://ordo.k8o.me/llms.txt |
|
|
123
|
+
| Markdown twin on the web | https://ordo.k8o.me/form/docs/GUIDE.md |
|
|
124
|
+
|
|
125
|
+
## License
|
|
126
|
+
|
|
127
|
+
MIT License - see [LICENSE](https://github.com/k35o/k8ordo/blob/main/LICENSE) for details.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
//#region src/async-check.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Ask the server about one field while the person is still filling the form.
|
|
4
|
+
*
|
|
5
|
+
* Uniqueness is the case HTML cannot help with at all: only the server knows
|
|
6
|
+
* whether a name is taken. The check runs on blur rather than on every
|
|
7
|
+
* keystroke, and its answer is applied with `setCustomValidity`, so it joins
|
|
8
|
+
* the same message path as every built-in check instead of opening a second
|
|
9
|
+
* one.
|
|
10
|
+
*/
|
|
11
|
+
type AsyncCheck = {
|
|
12
|
+
/** Attach to the input this check guards. */
|
|
13
|
+
props: {
|
|
14
|
+
onBlur: () => void;
|
|
15
|
+
ref: (node: HTMLInputElement | null) => void;
|
|
16
|
+
};
|
|
17
|
+
/** True while an answer is outstanding, for disabling submit. */
|
|
18
|
+
isChecking: boolean;
|
|
19
|
+
};
|
|
20
|
+
declare const useAsyncCheck: (check: (value: string) => Promise<string | undefined>) => AsyncCheck;
|
|
21
|
+
//#endregion
|
|
22
|
+
export { AsyncCheck, useAsyncCheck };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
//#region src/async-check.ts
|
|
4
|
+
const useAsyncCheck = (check) => {
|
|
5
|
+
const control = useRef(null);
|
|
6
|
+
const latest = useRef(0);
|
|
7
|
+
const answered = useRef(null);
|
|
8
|
+
const [isChecking, setIsChecking] = useState(false);
|
|
9
|
+
useEffect(() => () => {
|
|
10
|
+
latest.current += 1;
|
|
11
|
+
}, []);
|
|
12
|
+
return {
|
|
13
|
+
props: {
|
|
14
|
+
onBlur: useCallback(() => {
|
|
15
|
+
const node = control.current;
|
|
16
|
+
if (node === null) return;
|
|
17
|
+
if (node.value === answered.current) return;
|
|
18
|
+
latest.current += 1;
|
|
19
|
+
if (node.value === "") {
|
|
20
|
+
answered.current = null;
|
|
21
|
+
node.setCustomValidity("");
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
const ticket = latest.current;
|
|
25
|
+
const issuedFor = node.value;
|
|
26
|
+
setIsChecking(true);
|
|
27
|
+
(async () => {
|
|
28
|
+
try {
|
|
29
|
+
const message = await check(issuedFor);
|
|
30
|
+
if (ticket !== latest.current) return;
|
|
31
|
+
const { current } = control;
|
|
32
|
+
if (current === null || current.value !== issuedFor) return;
|
|
33
|
+
answered.current = issuedFor;
|
|
34
|
+
current.setCustomValidity(message ?? "");
|
|
35
|
+
current.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
|
|
36
|
+
} catch {} finally {
|
|
37
|
+
if (ticket === latest.current) setIsChecking(false);
|
|
38
|
+
}
|
|
39
|
+
})();
|
|
40
|
+
}, [check]),
|
|
41
|
+
ref: useCallback((node) => {
|
|
42
|
+
control.current = node;
|
|
43
|
+
}, [])
|
|
44
|
+
},
|
|
45
|
+
isChecking
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
//#endregion
|
|
49
|
+
export { useAsyncCheck };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { DroppedCheck, FieldInput } from "../types.mjs";
|
|
2
|
+
//#region src/derive/attributes.d.ts
|
|
3
|
+
/** The subset of JSON Schema that `z.toJSONSchema` emits for a leaf field. */
|
|
4
|
+
type LeafSchema = {
|
|
5
|
+
type?: string;
|
|
6
|
+
format?: string;
|
|
7
|
+
pattern?: string;
|
|
8
|
+
minLength?: number;
|
|
9
|
+
maxLength?: number;
|
|
10
|
+
minimum?: number;
|
|
11
|
+
maximum?: number;
|
|
12
|
+
exclusiveMinimum?: number;
|
|
13
|
+
exclusiveMaximum?: number;
|
|
14
|
+
multipleOf?: number;
|
|
15
|
+
enum?: unknown[];
|
|
16
|
+
anyOf?: unknown[];
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Turn one leaf JSON Schema into input attributes, collecting whatever HTML
|
|
20
|
+
* cannot express. Anything not representable is returned rather than dropped,
|
|
21
|
+
* so the caller can report it instead of leaving the author to discover at
|
|
22
|
+
* 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
|
+
*/
|
|
27
|
+
declare const attributesFor: (name: string, schema: LeafSchema, required: boolean, patternFlags?: string) => {
|
|
28
|
+
input: FieldInput;
|
|
29
|
+
dropped: DroppedCheck[];
|
|
30
|
+
};
|
|
31
|
+
//#endregion
|
|
32
|
+
export { LeafSchema, attributesFor };
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
//#region src/derive/attributes.ts
|
|
2
|
+
/**
|
|
3
|
+
* `.int()` carries JavaScript's safe-integer range into the schema. Emitting it
|
|
4
|
+
* as `max` would put 9007199254740991 in the markup, which tells a reader
|
|
5
|
+
* nothing and is never the bound the author meant.
|
|
6
|
+
*/
|
|
7
|
+
const JS_SAFE_INTEGER_BOUNDS = /* @__PURE__ */ new Set([Number.MAX_SAFE_INTEGER, Number.MIN_SAFE_INTEGER]);
|
|
8
|
+
const FORMAT_TO_INPUT_TYPE = {
|
|
9
|
+
date: "date",
|
|
10
|
+
"date-time": "datetime-local",
|
|
11
|
+
duration: "text",
|
|
12
|
+
email: "email",
|
|
13
|
+
ipv4: "text",
|
|
14
|
+
ipv6: "text",
|
|
15
|
+
time: "time",
|
|
16
|
+
uri: "url",
|
|
17
|
+
uuid: "text"
|
|
18
|
+
};
|
|
19
|
+
/** `pattern` is ignored by the browser on these input types. */
|
|
20
|
+
const TYPES_WITHOUT_PATTERN = /* @__PURE__ */ new Set([
|
|
21
|
+
"checkbox",
|
|
22
|
+
"date",
|
|
23
|
+
"datetime-local",
|
|
24
|
+
"number",
|
|
25
|
+
"time"
|
|
26
|
+
]);
|
|
27
|
+
const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
28
|
+
/**
|
|
29
|
+
* A regex only reaches the markup when the browser will read it the way zod
|
|
30
|
+
* does. Three things silently change its meaning there: the `pattern`
|
|
31
|
+
* attribute matches the whole value while a zod regex matches a substring, the
|
|
32
|
+
* attribute is always case-sensitive, and browsers compile it with the `v`
|
|
33
|
+
* flag — an expression that fails that compile is not an error but a pattern
|
|
34
|
+
* the browser ignores wholesale (zod's own email regex is one).
|
|
35
|
+
*/
|
|
36
|
+
const patternVerdict = (source, flags) => {
|
|
37
|
+
if (flags !== void 0 && flags !== "" && flags !== "u") return `フラグ '${flags}' は HTML の pattern に存在しないため、ブラウザ側だけ判定が変わります`;
|
|
38
|
+
if (!source.startsWith("^") || !source.endsWith("$")) return "HTML の pattern は全体一致、zod の regex は部分一致のため、^…$ で括られていない正規表現は意味が変わります";
|
|
39
|
+
return (() => {
|
|
40
|
+
try {
|
|
41
|
+
return new RegExp(source, "v").flags === "v";
|
|
42
|
+
} catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
})() ? void 0 : "ブラウザは pattern を v フラグで解釈し、コンパイルできない正規表現は黙って無視します";
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Turn one leaf JSON Schema into input attributes, collecting whatever HTML
|
|
49
|
+
* cannot express. Anything not representable is returned rather than dropped,
|
|
50
|
+
* so the caller can report it instead of leaving the author to discover at
|
|
51
|
+
* 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
|
+
*/
|
|
56
|
+
const attributesFor = (name, schema, required, patternFlags) => {
|
|
57
|
+
const input = { name };
|
|
58
|
+
const dropped = [];
|
|
59
|
+
if (required) input.required = true;
|
|
60
|
+
if (schema.anyOf !== void 0) {
|
|
61
|
+
input.type = "text";
|
|
62
|
+
dropped.push({
|
|
63
|
+
field: name,
|
|
64
|
+
reason: "nullable / union の中の制約は、どの枝を検査すべきか決まらないため属性に落ちません"
|
|
65
|
+
});
|
|
66
|
+
return {
|
|
67
|
+
input,
|
|
68
|
+
dropped
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (schema.enum !== void 0) return {
|
|
72
|
+
input,
|
|
73
|
+
dropped
|
|
74
|
+
};
|
|
75
|
+
if (schema.type === "boolean") {
|
|
76
|
+
input.type = "checkbox";
|
|
77
|
+
return {
|
|
78
|
+
input,
|
|
79
|
+
dropped
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
if (schema.type === "integer" || schema.type === "number") {
|
|
83
|
+
input.type = "number";
|
|
84
|
+
input.step = schema.type === "integer" ? schema.multipleOf ?? 1 : schema.multipleOf ?? "any";
|
|
85
|
+
if (isFiniteNumber(schema.minimum) && !JS_SAFE_INTEGER_BOUNDS.has(schema.minimum)) input.min = schema.minimum;
|
|
86
|
+
else if (isFiniteNumber(schema.exclusiveMinimum)) {
|
|
87
|
+
if (schema.type === "integer") input.min = schema.exclusiveMinimum + 1;
|
|
88
|
+
else dropped.push({
|
|
89
|
+
field: name,
|
|
90
|
+
reason: `exclusiveMinimum ${String(schema.exclusiveMinimum)} — HTML の min は境界を含むため表現できません`
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (isFiniteNumber(schema.maximum) && !JS_SAFE_INTEGER_BOUNDS.has(schema.maximum)) input.max = schema.maximum;
|
|
94
|
+
else if (isFiniteNumber(schema.exclusiveMaximum)) {
|
|
95
|
+
if (schema.type === "integer") input.max = schema.exclusiveMaximum - 1;
|
|
96
|
+
else dropped.push({
|
|
97
|
+
field: name,
|
|
98
|
+
reason: `exclusiveMaximum ${String(schema.exclusiveMaximum)} — HTML の max は境界を含むため表現できません`
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
return {
|
|
102
|
+
input,
|
|
103
|
+
dropped
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
input.type = schema.format === void 0 ? "text" : FORMAT_TO_INPUT_TYPE[schema.format] ?? "text";
|
|
107
|
+
if (isFiniteNumber(schema.minLength) && schema.minLength > 0) input.minLength = schema.minLength;
|
|
108
|
+
if (isFiniteNumber(schema.maxLength)) input.maxLength = schema.maxLength;
|
|
109
|
+
if (schema.pattern !== void 0) {
|
|
110
|
+
if (TYPES_WITHOUT_PATTERN.has(input.type)) {
|
|
111
|
+
if (input.type !== "date" && input.type !== "datetime-local") dropped.push({
|
|
112
|
+
field: name,
|
|
113
|
+
reason: `pattern は type="${input.type}" では無視されます`
|
|
114
|
+
});
|
|
115
|
+
} else {
|
|
116
|
+
const verdict = patternVerdict(schema.pattern, patternFlags);
|
|
117
|
+
if (verdict === void 0) input.pattern = schema.pattern;
|
|
118
|
+
else dropped.push({
|
|
119
|
+
field: name,
|
|
120
|
+
reason: verdict
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
input,
|
|
126
|
+
dropped
|
|
127
|
+
};
|
|
128
|
+
};
|
|
129
|
+
//#endregion
|
|
130
|
+
export { attributesFor };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { FormFields } from "../types.mjs";
|
|
2
|
+
import { ArrayPathsOf, FieldPathsOf } from "../paths.mjs";
|
|
3
|
+
import { ObjectSchema } from "../schema/object-schema.mjs";
|
|
4
|
+
import { FormDefinition } from "../rules/define-form.mjs";
|
|
5
|
+
//#region src/derive/form-fields.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Derive input attributes and messages from one zod object schema.
|
|
8
|
+
*
|
|
9
|
+
* Call this on the server — in a Server Component or at module scope. The
|
|
10
|
+
* result is plain data, so it crosses to the client as props and zod never
|
|
11
|
+
* enters the bundle.
|
|
12
|
+
*/
|
|
13
|
+
declare const formFields: <Schema extends ObjectSchema>(input: FormDefinition<Schema> | Schema) => FormFields<FieldPathsOf<Schema>, ArrayPathsOf<Schema>>;
|
|
14
|
+
//#endregion
|
|
15
|
+
export { formFields };
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { asDefinition } from "../rules/define-form.mjs";
|
|
2
|
+
import { asProbe } from "../schema/object-schema.mjs";
|
|
3
|
+
import { schemaMap } from "../schema/walk.mjs";
|
|
4
|
+
import { attributesFor } from "./attributes.mjs";
|
|
5
|
+
import { messagesFor } from "./messages.mjs";
|
|
6
|
+
//#region src/derive/form-fields.ts
|
|
7
|
+
const objectLevelCheckCount = (schema) => schema._zod?.def?.checks?.length ?? 0;
|
|
8
|
+
/**
|
|
9
|
+
* Recover the flags of the RegExp behind a JSON Schema `pattern` string. The
|
|
10
|
+
* JSON string has already lost them, and whether the browser may see the
|
|
11
|
+
* pattern depends on them (an `i` flag has no HTML equivalent).
|
|
12
|
+
*/
|
|
13
|
+
const patternFlags = (schema, source) => {
|
|
14
|
+
const def = schema._zod?.def;
|
|
15
|
+
if (def?.pattern?.source === source) return def.pattern.flags;
|
|
16
|
+
for (const check of def?.checks ?? []) {
|
|
17
|
+
const candidate = check._zod?.def?.pattern;
|
|
18
|
+
if (candidate?.source === source) return candidate.flags;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
/** What a `datetime-local` control actually submits: no seconds, no zone. */
|
|
22
|
+
const DATETIME_LOCAL_PROBE = "2000-01-01T00:00";
|
|
23
|
+
/**
|
|
24
|
+
* Derive input attributes and messages from one zod object schema.
|
|
25
|
+
*
|
|
26
|
+
* Call this on the server — in a Server Component or at module scope. The
|
|
27
|
+
* result is plain data, so it crosses to the client as props and zod never
|
|
28
|
+
* enters the bundle.
|
|
29
|
+
*/
|
|
30
|
+
const formFields = (input) => {
|
|
31
|
+
const { schema, rules } = asDefinition(input);
|
|
32
|
+
const map = schemaMap(schema);
|
|
33
|
+
const fields = {};
|
|
34
|
+
const arrays = {};
|
|
35
|
+
const dropped = [];
|
|
36
|
+
for (const array of map.arrays) arrays[array.path] = {
|
|
37
|
+
path: array.path,
|
|
38
|
+
minItems: array.minItems,
|
|
39
|
+
maxItems: array.maxItems,
|
|
40
|
+
item: {}
|
|
41
|
+
};
|
|
42
|
+
for (const leaf of map.leaves) {
|
|
43
|
+
const attributes = attributesFor(leaf.name, leaf.json, leaf.required, leaf.json.pattern === void 0 ? void 0 : patternFlags(leaf.zod, leaf.json.pattern));
|
|
44
|
+
const secret = leaf.json.input === "password";
|
|
45
|
+
if (secret) attributes.input.type = "password";
|
|
46
|
+
if (attributes.input.type === "datetime-local" && !asProbe(leaf.zod).safeParse(DATETIME_LOCAL_PROBE).success) {
|
|
47
|
+
attributes.input.type = "text";
|
|
48
|
+
attributes.dropped.push({
|
|
49
|
+
field: leaf.name,
|
|
50
|
+
reason: "z.iso.datetime() はタイムゾーンを要求しますが、datetime-local はタイムゾーンを送信できません。type=\"text\" に落とします(local: true なら datetime-local が使えます)"
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
if (leaf.group !== void 0 && (leaf.group.minItems !== void 0 || leaf.group.maxItems !== void 0)) dropped.push({
|
|
54
|
+
field: leaf.path,
|
|
55
|
+
reason: "チェックボックス群の個数制限に対応する HTML 属性はありません。クライアントでも検査するには minChecked を使ってください"
|
|
56
|
+
});
|
|
57
|
+
const derived = {
|
|
58
|
+
input: attributes.input,
|
|
59
|
+
messages: messagesFor(leaf.zod, attributes.input, leaf.required),
|
|
60
|
+
secret
|
|
61
|
+
};
|
|
62
|
+
const array = leaf.arrayPath === null ? void 0 : arrays[leaf.arrayPath];
|
|
63
|
+
if (array === void 0) fields[leaf.path] = derived;
|
|
64
|
+
else array.item[leaf.itemKey ?? ""] = derived;
|
|
65
|
+
for (const entry of attributes.dropped) dropped.push({
|
|
66
|
+
...entry,
|
|
67
|
+
field: leaf.path
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const objectChecks = objectLevelCheckCount(schema);
|
|
71
|
+
if (objectChecks > 0) dropped.push({
|
|
72
|
+
field: "(schema)",
|
|
73
|
+
reason: `${String(objectChecks)}件のオブジェクト階層のチェック(refine など)は HTML の制約属性に落ちないため、クライアントでは検査されません`
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
fields,
|
|
77
|
+
arrays,
|
|
78
|
+
rules,
|
|
79
|
+
dropped
|
|
80
|
+
};
|
|
81
|
+
};
|
|
82
|
+
//#endregion
|
|
83
|
+
export { formFields };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { FieldInput, ValidityFlag } from "../types.mjs";
|
|
2
|
+
import { $ZodType } from "zod/v4/core";
|
|
3
|
+
//#region src/derive/messages.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* Run each probe through the field's own schema and keep the message zod
|
|
6
|
+
* returns. A probe that unexpectedly passes simply yields no message for that
|
|
7
|
+
* flag — the browser's own wording is never used, since it is locale-dependent
|
|
8
|
+
* and cannot be controlled.
|
|
9
|
+
*/
|
|
10
|
+
declare const messagesFor: (schema: $ZodType, input: FieldInput, required: boolean) => Partial<Record<ValidityFlag, string>>;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { messagesFor };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { asProbe } from "../schema/object-schema.mjs";
|
|
2
|
+
//#region src/derive/messages.ts
|
|
3
|
+
const repeat = (length) => "a".repeat(Math.max(length, 0));
|
|
4
|
+
const probesFor = (input, required) => {
|
|
5
|
+
const probes = [];
|
|
6
|
+
if (required) probes.push(input.type === "checkbox" ? {
|
|
7
|
+
flag: "valueMissing",
|
|
8
|
+
value: false,
|
|
9
|
+
codes: ["invalid_value", "invalid_type"]
|
|
10
|
+
} : {
|
|
11
|
+
flag: "valueMissing",
|
|
12
|
+
value: "",
|
|
13
|
+
codes: [
|
|
14
|
+
"too_small",
|
|
15
|
+
"invalid_type",
|
|
16
|
+
"invalid_format"
|
|
17
|
+
]
|
|
18
|
+
});
|
|
19
|
+
if (input.minLength !== void 0) probes.push({
|
|
20
|
+
flag: "tooShort",
|
|
21
|
+
value: repeat(input.minLength - 1),
|
|
22
|
+
codes: ["too_small"]
|
|
23
|
+
});
|
|
24
|
+
if (input.maxLength !== void 0) probes.push({
|
|
25
|
+
flag: "tooLong",
|
|
26
|
+
value: repeat(input.maxLength + 1),
|
|
27
|
+
codes: ["too_big"]
|
|
28
|
+
});
|
|
29
|
+
if (input.type === "email" || input.type === "url" || input.type === "date") probes.push({
|
|
30
|
+
flag: "typeMismatch",
|
|
31
|
+
value: "k8ordo-probe",
|
|
32
|
+
codes: ["invalid_format"]
|
|
33
|
+
});
|
|
34
|
+
else if (input.pattern !== void 0) probes.push({
|
|
35
|
+
flag: "patternMismatch",
|
|
36
|
+
value: "k8ordo-probe",
|
|
37
|
+
codes: ["invalid_format"]
|
|
38
|
+
});
|
|
39
|
+
if (input.type === "number") {
|
|
40
|
+
probes.push({
|
|
41
|
+
flag: "badInput",
|
|
42
|
+
value: "k8ordo-probe",
|
|
43
|
+
codes: ["invalid_type"]
|
|
44
|
+
});
|
|
45
|
+
if (typeof input.min === "number") probes.push({
|
|
46
|
+
flag: "rangeUnderflow",
|
|
47
|
+
value: input.min - 1,
|
|
48
|
+
codes: ["too_small"]
|
|
49
|
+
});
|
|
50
|
+
if (typeof input.max === "number") probes.push({
|
|
51
|
+
flag: "rangeOverflow",
|
|
52
|
+
value: input.max + 1,
|
|
53
|
+
codes: ["too_big"]
|
|
54
|
+
});
|
|
55
|
+
if (typeof input.step === "number") probes.push({
|
|
56
|
+
flag: "stepMismatch",
|
|
57
|
+
value: input.step === 1 ? .5 : Number.isInteger(input.step) ? input.step + 1 : input.step / 2,
|
|
58
|
+
codes: ["not_multiple_of", "invalid_type"]
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return probes;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Run each probe through the field's own schema and keep the message zod
|
|
65
|
+
* returns. A probe that unexpectedly passes simply yields no message for that
|
|
66
|
+
* flag — the browser's own wording is never used, since it is locale-dependent
|
|
67
|
+
* and cannot be controlled.
|
|
68
|
+
*/
|
|
69
|
+
const messagesFor = (schema, input, required) => {
|
|
70
|
+
const messages = {};
|
|
71
|
+
for (const probe of probesFor(input, required)) {
|
|
72
|
+
const result = asProbe(schema).safeParse(probe.value);
|
|
73
|
+
if (result.success) continue;
|
|
74
|
+
const issue = probe.codes.map((code) => result.error.issues.find((candidate) => candidate.code === code)).find((found) => found !== void 0) ?? result.error.issues[0];
|
|
75
|
+
if (issue !== void 0) messages[probe.flag] = issue.message;
|
|
76
|
+
}
|
|
77
|
+
return messages;
|
|
78
|
+
};
|
|
79
|
+
//#endregion
|
|
80
|
+
export { messagesFor };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { FC } from "react";
|
|
2
|
+
//#region src/hidden-value.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* A hidden input that carries a value the visible component never submits.
|
|
5
|
+
*
|
|
6
|
+
* A component that renders no `<input name>` of its own — a rich text editor, a
|
|
7
|
+
* canvas picker, a third-party combobox — is invisible to FormData. Rather than
|
|
8
|
+
* pulling its value into React state and fighting it back out at submit, park
|
|
9
|
+
* the value in a hidden input and let the form collect it like any other.
|
|
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
|
+
* A component rather than a props helper for two reasons the form cannot see
|
|
15
|
+
* from outside: React updates a controlled value without any DOM event, so a
|
|
16
|
+
* 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 first
|
|
18
|
+
* render's value is snapshotted into `data-initial` as the dirtiness baseline.
|
|
19
|
+
*/
|
|
20
|
+
declare const HiddenValue: FC<{
|
|
21
|
+
name: string;
|
|
22
|
+
value: string;
|
|
23
|
+
}>;
|
|
24
|
+
//#endregion
|
|
25
|
+
export { HiddenValue };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useEffect, useRef } from "react";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
//#region src/hidden-value.tsx
|
|
5
|
+
/**
|
|
6
|
+
* A hidden input that carries a value the visible component never submits.
|
|
7
|
+
*
|
|
8
|
+
* A component that renders no `<input name>` of its own — a rich text editor, a
|
|
9
|
+
* canvas picker, a third-party combobox — is invisible to FormData. Rather than
|
|
10
|
+
* pulling its value into React state and fighting it back out at submit, park
|
|
11
|
+
* the value in a hidden input and let the form collect it like any other.
|
|
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
|
+
* A component rather than a props helper for two reasons the form cannot see
|
|
17
|
+
* from outside: React updates a controlled value without any DOM event, so a
|
|
18
|
+
* 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 first
|
|
20
|
+
* render's value is snapshotted into `data-initial` as the dirtiness baseline.
|
|
21
|
+
*/
|
|
22
|
+
const HiddenValue = ({ name, value }) => {
|
|
23
|
+
const node = useRef(null);
|
|
24
|
+
const initial = useRef(value);
|
|
25
|
+
const last = useRef(value);
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (last.current === value) return;
|
|
28
|
+
last.current = value;
|
|
29
|
+
node.current?.dispatchEvent(new Event("input", { bubbles: true }));
|
|
30
|
+
}, [value]);
|
|
31
|
+
return /* @__PURE__ */ jsx("input", {
|
|
32
|
+
"data-initial": initial.current,
|
|
33
|
+
name,
|
|
34
|
+
readOnly: true,
|
|
35
|
+
ref: node,
|
|
36
|
+
type: "hidden",
|
|
37
|
+
value
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
//#endregion
|
|
41
|
+
export { HiddenValue };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { AsyncCheck, useAsyncCheck } from "./async-check.mjs";
|
|
2
|
+
import { Rule } from "./rules/rules.mjs";
|
|
3
|
+
import { DerivedArray, DerivedField, FieldInput, FormFields, FormState, ValidityFlag } from "./types.mjs";
|
|
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 FieldInput, type FieldView, type FormFields, type FormState, HiddenValue, type RowView, type Rule, type UseFormReturn, type ValidityFlag, useAsyncCheck, useForm };
|
package/dist/index.mjs
ADDED