@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/dist/types.d.mts
ADDED
|
@@ -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 };
|