@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.
@@ -0,0 +1,76 @@
1
+ import { Rule } from "./rules/rules.mjs";
2
+ //#region src/types.d.ts
3
+ /** ValidityState flags a derived field can report a message for. */
4
+ type ValidityFlag = 'badInput' | 'patternMismatch' | 'rangeOverflow' | 'rangeUnderflow' | 'stepMismatch' | 'tooLong' | 'tooShort' | 'typeMismatch' | 'valueMissing';
5
+ /** Attributes spread onto the input element itself. */
6
+ type FieldInput = {
7
+ name: string;
8
+ type?: string;
9
+ required?: boolean;
10
+ minLength?: number;
11
+ maxLength?: number;
12
+ pattern?: string;
13
+ min?: number | string;
14
+ max?: number | string;
15
+ step?: number | 'any';
16
+ defaultValue?: string;
17
+ defaultChecked?: boolean;
18
+ };
19
+ /**
20
+ * One field as derived from the schema. Serializable on purpose: it crosses the
21
+ * RSC boundary as props, which is what keeps zod out of the client bundle.
22
+ */
23
+ type DerivedField = {
24
+ input: FieldInput;
25
+ /** Message per ValidityState flag, taken from zod itself. */
26
+ messages: Partial<Record<ValidityFlag, string>>;
27
+ /** True when the input must not be echoed back after a failed submit. */
28
+ secret: boolean;
29
+ };
30
+ /**
31
+ * A repeatable group. The item is described once; the row index is filled into
32
+ * the name when a row renders, because rows do not exist until then.
33
+ */
34
+ type DerivedArray = {
35
+ path: string;
36
+ minItems?: number;
37
+ maxItems?: number;
38
+ /** Keyed by the path within one item; '' when the array holds scalars. */
39
+ item: Record<string, DerivedField>;
40
+ };
41
+ /** What a constraint-carrying check lost on its way to HTML. */
42
+ type DroppedCheck = {
43
+ field: string;
44
+ reason: string;
45
+ };
46
+ type FormFields<FieldPath extends string = string, ArrayPath extends string = string> = {
47
+ fields: Record<FieldPath, DerivedField>;
48
+ arrays: Record<ArrayPath, DerivedArray>;
49
+ /** Checks HTML has no attribute for, evaluated identically on both sides. */
50
+ rules: Rule[];
51
+ /** Checks that HTML cannot express. Reported once, never silently discarded. */
52
+ dropped: DroppedCheck[];
53
+ };
54
+ /**
55
+ * What a Server Action hands back to the form. Keys are dotted paths, so an
56
+ * error on a row reads as `items[1].name`.
57
+ */
58
+ type FormState = {
59
+ errors?: Record<string, string>;
60
+ /**
61
+ * Submitted values for re-render. Secret fields are never included; a name
62
+ * several controls share — a checkbox group — echoes as an array.
63
+ */
64
+ values?: Record<string, string | string[]>;
65
+ /** How many rows each array had, so a no-JS retry rebuilds them. */
66
+ rows?: Record<string, number>;
67
+ /** An error about the submission as a whole rather than one field. */
68
+ formError?: string;
69
+ /**
70
+ * Identity of one parse. Two identical failed submissions must still read
71
+ * as two on the client — content alone cannot tell them apart.
72
+ */
73
+ token?: string;
74
+ };
75
+ //#endregion
76
+ export { DerivedArray, DerivedField, DroppedCheck, FieldInput, FormFields, FormState, ValidityFlag };
package/dist/types.mjs ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ import { FieldInput, FormFields, FormState } from "./types.mjs";
2
+ import { FocusEvent, Ref, SyntheticEvent } from "react";
3
+ //#region src/use-form.d.ts
4
+ type FieldView = {
5
+ input: FieldInput;
6
+ error: string | undefined;
7
+ invalid: boolean;
8
+ required: boolean;
9
+ };
10
+ type RowView = {
11
+ key: string;
12
+ index: number;
13
+ field: (itemKey?: string) => FieldView;
14
+ remove: () => void;
15
+ };
16
+ type ArrayView = {
17
+ rows: RowView[];
18
+ add: () => void;
19
+ canAdd: boolean;
20
+ canRemove: boolean;
21
+ error: string | undefined;
22
+ };
23
+ type UseFormReturn<FieldPath extends string = string, ArrayPath extends string = string> = {
24
+ props: {
25
+ onBlur: (event: FocusEvent<HTMLFormElement>) => void;
26
+ onInput: (event: SyntheticEvent<HTMLFormElement>) => void;
27
+ ref: Ref<HTMLFormElement>;
28
+ };
29
+ field: (path: FieldPath) => FieldView;
30
+ array: (path: ArrayPath) => ArrayView;
31
+ /** True once any field differs from the value it was rendered with. */
32
+ isDirty: boolean;
33
+ };
34
+ /**
35
+ * Wire derived fields to a form.
36
+ *
37
+ * Values are never copied into React state — the DOM holds them. What lives
38
+ * here is only what the DOM cannot express: which message to show for a field
39
+ * the browser has judged invalid, which server errors are still current, the
40
+ * identity of each repeated row, and one dirty flag.
41
+ */
42
+ declare const useForm: <FieldPath extends string, ArrayPath extends string>(fields: FormFields<FieldPath, ArrayPath>, state: FormState) => UseFormReturn<FieldPath, ArrayPath>;
43
+ //#endregion
44
+ export { ArrayView, FieldView, RowView, UseFormReturn, useForm };
@@ -0,0 +1,309 @@
1
+ "use client";
2
+ import { breachOf } from "./rules/rules.mjs";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
+ //#region src/use-form.ts
5
+ const INDEX = "{index}";
6
+ const FLAGS = [
7
+ "valueMissing",
8
+ "typeMismatch",
9
+ "patternMismatch",
10
+ "tooShort",
11
+ "tooLong",
12
+ "rangeUnderflow",
13
+ "rangeOverflow",
14
+ "stepMismatch",
15
+ "badInput"
16
+ ];
17
+ const isControl = (target) => target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target instanceof HTMLTextAreaElement;
18
+ /**
19
+ * A name shared by several controls — a checkbox group — comes back as a
20
+ * RadioNodeList. Validity belongs to an element, so the group reports through
21
+ * its first member.
22
+ */
23
+ const controlNamed = (form, name) => {
24
+ if (form === null) return;
25
+ const found = form.elements.namedItem(name);
26
+ if (isControl(found)) return found;
27
+ if (found instanceof RadioNodeList) {
28
+ const first = found.item(0);
29
+ return isControl(first) ? first : void 0;
30
+ }
31
+ };
32
+ /**
33
+ * Pick the message for whichever check the browser says failed. `customError`
34
+ * is honoured first: it is set deliberately by the caller, so it outranks any
35
+ * built-in flag.
36
+ */
37
+ const messageFor = (control, field) => {
38
+ const { validity } = control;
39
+ if (validity.valid) return;
40
+ if (validity.customError) return control.validationMessage;
41
+ for (const flag of FLAGS) if (validity[flag]) return field.messages[flag];
42
+ };
43
+ const initialRows = (fields, state) => {
44
+ const rows = {};
45
+ for (const [path, array] of Object.entries(fields.arrays)) {
46
+ const count = state.rows?.[path] ?? array.minItems ?? 0;
47
+ rows[path] = Array.from({ length: count }, (_, index) => `${path}-${String(index)}`);
48
+ }
49
+ return rows;
50
+ };
51
+ const rowCountsOf = (rows) => Object.fromEntries(Object.entries(rows).map(([path, keys]) => [path, keys.length]));
52
+ /**
53
+ * Rename one concrete indexed name after the row at `removed` is gone: entries
54
+ * on that row are dropped, entries on later rows slide down one index. Without
55
+ * this, a message raised on `items[1].name` would reattach to whichever row
56
+ * renders as index 1 next.
57
+ */
58
+ const shiftName = (name, path, removed) => {
59
+ const prefix = `${path}[`;
60
+ if (!name.startsWith(prefix)) return name;
61
+ const close = name.indexOf("]", prefix.length);
62
+ const index = Number(name.slice(prefix.length, close));
63
+ if (!Number.isInteger(index)) return name;
64
+ if (index === removed) return null;
65
+ if (index > removed) return `${prefix}${String(index - 1)}${name.slice(close)}`;
66
+ return name;
67
+ };
68
+ const shiftRecord = (record, path, removed) => {
69
+ const next = {};
70
+ for (const [name, value] of Object.entries(record)) {
71
+ const renamed = shiftName(name, path, removed);
72
+ if (renamed !== null) next[renamed] = value;
73
+ }
74
+ return next;
75
+ };
76
+ const shiftSet = (set, path, removed) => {
77
+ const next = /* @__PURE__ */ new Set();
78
+ for (const name of set) {
79
+ const renamed = shiftName(name, path, removed);
80
+ if (renamed !== null) next.add(renamed);
81
+ }
82
+ return next;
83
+ };
84
+ /**
85
+ * Wire derived fields to a form.
86
+ *
87
+ * Values are never copied into React state — the DOM holds them. What lives
88
+ * here is only what the DOM cannot express: which message to show for a field
89
+ * the browser has judged invalid, which server errors are still current, the
90
+ * identity of each repeated row, and one dirty flag.
91
+ */
92
+ const useForm = (fields, state) => {
93
+ const lookup = fields;
94
+ const formRef = useRef(null);
95
+ const nextKey = useRef(0);
96
+ const ownedRuleMessages = useRef(/* @__PURE__ */ new Map());
97
+ const [clientErrors, setClientErrors] = useState({});
98
+ const [edited, setEdited] = useState(/* @__PURE__ */ new Set());
99
+ const [domDirty, setDomDirty] = useState(false);
100
+ const [rowKeys, setRowKeys] = useState(() => initialRows(lookup, state));
101
+ const baselineRows = useRef(rowCountsOf(rowKeys));
102
+ const stateKey = JSON.stringify(state);
103
+ const lastKey = useRef(stateKey);
104
+ useEffect(() => {
105
+ if (lastKey.current === stateKey) return;
106
+ lastKey.current = stateKey;
107
+ setClientErrors({});
108
+ setEdited(/* @__PURE__ */ new Set());
109
+ setDomDirty(false);
110
+ const rows = initialRows(lookup, state);
111
+ baselineRows.current = rowCountsOf(rows);
112
+ setRowKeys(rows);
113
+ const firstErrored = Object.keys(state.errors ?? {})[0];
114
+ if (firstErrored !== void 0) controlNamed(formRef.current, firstErrored)?.focus();
115
+ }, [
116
+ stateKey,
117
+ state,
118
+ lookup
119
+ ]);
120
+ const evaluate = useCallback((target, onlyIfShown) => {
121
+ if (!isControl(target)) return;
122
+ const form = formRef.current;
123
+ applyRules(form, lookup.rules, ownedRuleMessages.current);
124
+ const names = /* @__PURE__ */ new Set([target.name]);
125
+ for (const rule of lookup.rules) names.add(rule.field);
126
+ setClientErrors((previous) => {
127
+ let next = previous;
128
+ for (const name of names) {
129
+ const field = fieldFor(lookup, name);
130
+ if (field === void 0) continue;
131
+ const control = controlNamed(form, name) ?? (name === target.name ? target : void 0);
132
+ if (control === void 0) continue;
133
+ if ((name === target.name ? onlyIfShown : true) && next[name] === void 0) continue;
134
+ const message = messageFor(control, field);
135
+ if (next[name] === message) continue;
136
+ if (message === void 0) {
137
+ const { [name]: _cleared, ...rest } = next;
138
+ next = rest;
139
+ } else next = {
140
+ ...next,
141
+ [name]: message
142
+ };
143
+ }
144
+ return next;
145
+ });
146
+ }, [lookup]);
147
+ const onBlur = useCallback((event) => {
148
+ evaluate(event.target, false);
149
+ }, [evaluate]);
150
+ const onInput = useCallback((event) => {
151
+ const { target } = event;
152
+ if (isControl(target)) {
153
+ setEdited((previous) => previous.has(target.name) ? previous : new Set(previous).add(target.name));
154
+ setDomDirty(isFormDirty(formRef.current));
155
+ }
156
+ evaluate(target, true);
157
+ }, [evaluate]);
158
+ const viewOf = useCallback((field, name) => {
159
+ const serverError = edited.has(name) ? void 0 : state.errors?.[name];
160
+ const error = clientErrors[name] ?? serverError;
161
+ const value = state.values?.[name];
162
+ const input = {
163
+ ...field.input,
164
+ name
165
+ };
166
+ if (field.input.type === "checkbox") {
167
+ if (state.values !== void 0) input.defaultChecked = value !== void 0;
168
+ } else if (typeof value === "string") input.defaultValue = value;
169
+ return {
170
+ input,
171
+ error,
172
+ invalid: error !== void 0,
173
+ required: field.input.required ?? false
174
+ };
175
+ }, [
176
+ clientErrors,
177
+ edited,
178
+ state
179
+ ]);
180
+ const field = useCallback((path) => {
181
+ const derived = lookup.fields[path];
182
+ if (derived === void 0) throw new Error(`[@k8ordo/form] スキーマに '${path}' がありません。繰り返しの中の欄なら array('...') を使ってください。`);
183
+ return viewOf(derived, derived.input.name);
184
+ }, [lookup, viewOf]);
185
+ const array = useCallback((path) => {
186
+ const derived = lookup.arrays[path];
187
+ if (derived === void 0) throw new Error(`[@k8ordo/form] スキーマに配列 '${path}' がありません。`);
188
+ const keys = rowKeys[path] ?? [];
189
+ const remove = (key) => {
190
+ const index = keys.indexOf(key);
191
+ if (index === -1) return;
192
+ setRowKeys((previous) => ({
193
+ ...previous,
194
+ [path]: (previous[path] ?? []).filter((candidate) => candidate !== key)
195
+ }));
196
+ setClientErrors((previous) => shiftRecord(previous, path, index));
197
+ setEdited((previous) => shiftSet(previous, path, index));
198
+ };
199
+ return {
200
+ rows: keys.map((key, index) => ({
201
+ key,
202
+ index,
203
+ field: (itemKey = "") => {
204
+ const item = derived.item[itemKey];
205
+ if (item === void 0) throw new Error(`[@k8ordo/form] 配列 '${path}' に '${itemKey}' がありません。`);
206
+ return viewOf(item, item.input.name.replace(INDEX, String(index)));
207
+ },
208
+ remove: () => {
209
+ remove(key);
210
+ }
211
+ })),
212
+ add: () => {
213
+ nextKey.current += 1;
214
+ const key = `${path}-new-${String(nextKey.current)}`;
215
+ setRowKeys((previous) => ({
216
+ ...previous,
217
+ [path]: [...previous[path] ?? [], key]
218
+ }));
219
+ },
220
+ canAdd: derived.maxItems === void 0 || keys.length < derived.maxItems,
221
+ canRemove: keys.length > (derived.minItems ?? 0),
222
+ error: state.errors?.[path]
223
+ };
224
+ }, [
225
+ lookup,
226
+ rowKeys,
227
+ state,
228
+ viewOf
229
+ ]);
230
+ const ref = useCallback((node) => {
231
+ formRef.current = node;
232
+ if (node !== null) node.noValidate = true;
233
+ }, []);
234
+ const props = useMemo(() => ({
235
+ onBlur,
236
+ onInput,
237
+ ref
238
+ }), [
239
+ onBlur,
240
+ onInput,
241
+ ref
242
+ ]);
243
+ const structuralDirty = Object.entries(rowKeys).some(([path, keys]) => keys.length !== (baselineRows.current[path] ?? 0));
244
+ return {
245
+ props,
246
+ field,
247
+ array,
248
+ isDirty: domDirty || structuralDirty
249
+ };
250
+ };
251
+ /** Look up the derived field for a submitted name, row index included. */
252
+ const fieldFor = (fields, name) => {
253
+ const direct = fields.fields[name];
254
+ if (direct !== void 0) return direct;
255
+ const template = name.replaceAll(/\[\d+\]/gu, `[${INDEX}]`);
256
+ for (const array of Object.values(fields.arrays)) for (const item of Object.values(array.item)) if (item.input.name === template) return item;
257
+ };
258
+ /**
259
+ * Apply the cross-field rules to the live form. `setCustomValidity` is what
260
+ * makes the result indistinguishable from a built-in check: `:user-invalid`
261
+ * lights up, `validity.customError` is set, and the message flows through the
262
+ * same path as every other one.
263
+ *
264
+ * `owned` records what this function wrote, so a clean pass only clears its
265
+ * own message — never an async check's answer sharing the same slot.
266
+ */
267
+ const applyRules = (form, rules, owned) => {
268
+ if (form === null) return;
269
+ const data = new FormData(form);
270
+ const values = (name) => data.getAll(name).filter((value) => typeof value === "string");
271
+ const breaches = /* @__PURE__ */ new Map();
272
+ const involved = /* @__PURE__ */ new Set();
273
+ for (const rule of rules) {
274
+ involved.add(rule.field);
275
+ if (!breaches.has(rule.field)) {
276
+ const breach = breachOf(rule, values);
277
+ if (breach !== void 0) breaches.set(rule.field, breach);
278
+ }
279
+ }
280
+ for (const name of involved) {
281
+ const control = controlNamed(form, name);
282
+ if (control === void 0) continue;
283
+ const breach = breaches.get(name);
284
+ if (breach !== void 0) {
285
+ control.setCustomValidity(breach);
286
+ owned.set(name, breach);
287
+ } else if (owned.has(name)) {
288
+ if (control.validationMessage === owned.get(name)) control.setCustomValidity("");
289
+ owned.delete(name);
290
+ }
291
+ }
292
+ };
293
+ const isFormDirty = (form) => {
294
+ if (form === null) return false;
295
+ for (const element of form.elements) if (element instanceof HTMLInputElement) {
296
+ const { initial } = element.dataset;
297
+ if (initial !== void 0) {
298
+ if (element.value !== initial) return true;
299
+ } else if (element.type === "checkbox" || element.type === "radio") {
300
+ if (element.checked !== element.defaultChecked) return true;
301
+ } else if (element.value !== element.defaultValue) return true;
302
+ } else if (element instanceof HTMLTextAreaElement && element.value !== element.defaultValue) return true;
303
+ else if (element instanceof HTMLSelectElement) {
304
+ for (const option of element.options) if (option.selected !== option.defaultSelected) return true;
305
+ }
306
+ return false;
307
+ };
308
+ //#endregion
309
+ export { useForm };