@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 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 };
@@ -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
@@ -0,0 +1,4 @@
1
+ import { useAsyncCheck } from "./async-check.mjs";
2
+ import { HiddenValue } from "./hidden-value.mjs";
3
+ import { useForm } from "./use-form.mjs";
4
+ export { HiddenValue, useAsyncCheck, useForm };