@k8ordo/form 0.1.1 → 1.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/README.md +31 -23
- package/dist/async-check.d.mts +3 -4
- package/dist/derive/attributes.d.mts +43 -8
- package/dist/derive/attributes.mjs +74 -19
- package/dist/derive/form-fields.d.mts +6 -5
- package/dist/derive/form-fields.mjs +55 -23
- package/dist/derive/messages.d.mts +2 -3
- package/dist/derive/messages.mjs +8 -11
- package/dist/hidden-value.d.mts +4 -8
- package/dist/hidden-value.mjs +8 -9
- package/dist/index.d.mts +4 -4
- package/dist/parse/parse-form.d.mts +3 -4
- package/dist/parse/parse-form.mjs +25 -4
- package/dist/parse/paths.d.mts +4 -4
- package/dist/paths.d.mts +34 -4
- package/dist/rules/define-form.d.mts +4 -5
- package/dist/rules/rules.d.mts +26 -10
- package/dist/rules/rules.mjs +14 -7
- package/dist/schema/object-schema.d.mts +7 -6
- package/dist/schema/walk.d.mts +16 -8
- package/dist/schema/walk.mjs +77 -10
- package/dist/server.d.mts +3 -3
- package/dist/types.d.mts +38 -17
- package/dist/use-form.d.mts +32 -12
- package/dist/use-form.mjs +136 -22
- package/docs/GUIDE.md +469 -71
- package/docs/llms.txt +61 -15
- package/package.json +18 -17
package/dist/use-form.d.mts
CHANGED
|
@@ -1,33 +1,53 @@
|
|
|
1
|
-
import { FieldInput, FormFields, FormState } from "./types.mjs";
|
|
2
|
-
import { FocusEvent, Ref, SyntheticEvent } from "react";
|
|
1
|
+
import { FieldInput, FormFields, FormState, StringCheckboxInput } from "./types.mjs";
|
|
2
|
+
import { FocusEvent, Ref, SubmitEvent, SyntheticEvent } from "react";
|
|
3
3
|
//#region src/use-form.d.ts
|
|
4
|
-
type FieldView = {
|
|
5
|
-
input:
|
|
4
|
+
export type FieldView<Input extends FieldInput = FieldInput> = {
|
|
5
|
+
input: Input;
|
|
6
6
|
error: string | undefined;
|
|
7
7
|
invalid: boolean;
|
|
8
8
|
required: boolean;
|
|
9
9
|
};
|
|
10
|
-
type RowView = {
|
|
10
|
+
export type RowView = {
|
|
11
11
|
key: string;
|
|
12
12
|
index: number;
|
|
13
13
|
field: (itemKey?: string) => FieldView;
|
|
14
14
|
remove: () => void;
|
|
15
15
|
};
|
|
16
|
-
type ArrayView = {
|
|
16
|
+
export type ArrayView = {
|
|
17
17
|
rows: RowView[];
|
|
18
18
|
add: () => void;
|
|
19
19
|
canAdd: boolean;
|
|
20
20
|
canRemove: boolean;
|
|
21
21
|
error: string | undefined;
|
|
22
|
+
/** Spread onto the element that shows `error`, so focus can land on it. */
|
|
23
|
+
errorProps: {
|
|
24
|
+
id: string;
|
|
25
|
+
tabIndex: -1;
|
|
26
|
+
};
|
|
22
27
|
};
|
|
23
|
-
type
|
|
28
|
+
export type FormErrorView = {
|
|
29
|
+
/** `state.formError`: the failure no field owns. */
|
|
30
|
+
message: string | undefined;
|
|
31
|
+
/** Spread onto the element that shows `message`, so focus can land on it. */
|
|
32
|
+
props: {
|
|
33
|
+
id: string;
|
|
34
|
+
tabIndex: -1;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
export type UseFormReturn<FieldPath extends string = string, ArrayPath extends string = string, StringCheckboxPath extends string = never> = {
|
|
24
38
|
props: {
|
|
25
39
|
onBlur: (event: FocusEvent<HTMLFormElement>) => void;
|
|
26
40
|
onInput: (event: SyntheticEvent<HTMLFormElement>) => void;
|
|
41
|
+
onReset: () => void;
|
|
42
|
+
onSubmit: (event: SubmitEvent<HTMLFormElement>) => void;
|
|
27
43
|
ref: Ref<HTMLFormElement>;
|
|
28
44
|
};
|
|
29
|
-
field:
|
|
45
|
+
field: {
|
|
46
|
+
(path: StringCheckboxPath): FieldView<StringCheckboxInput>;
|
|
47
|
+
(path: FieldPath): FieldView;
|
|
48
|
+
};
|
|
30
49
|
array: (path: ArrayPath) => ArrayView;
|
|
50
|
+
formError: FormErrorView;
|
|
31
51
|
/** True once any field differs from the value it was rendered with. */
|
|
32
52
|
isDirty: boolean;
|
|
33
53
|
};
|
|
@@ -37,8 +57,8 @@ type UseFormReturn<FieldPath extends string = string, ArrayPath extends string =
|
|
|
37
57
|
* Values are never copied into React state — the DOM holds them. What lives
|
|
38
58
|
* here is only what the DOM cannot express: which message to show for a field
|
|
39
59
|
* the browser has judged invalid, which server errors are still current, the
|
|
40
|
-
* identity of each repeated row,
|
|
60
|
+
* identity of each repeated row, one dirty flag, and the row counts adding or
|
|
61
|
+
* removing a row is measured against.
|
|
41
62
|
*/
|
|
42
|
-
declare const useForm: <FieldPath extends string, ArrayPath extends string>(fields: FormFields<FieldPath, ArrayPath>, state
|
|
43
|
-
//#endregion
|
|
44
|
-
export { ArrayView, FieldView, RowView, UseFormReturn, useForm };
|
|
63
|
+
export declare const useForm: <FieldPath extends string, ArrayPath extends string, StringCheckboxPath extends string = never>(fields: FormFields<FieldPath, ArrayPath, StringCheckboxPath>, state?: FormState) => UseFormReturn<FieldPath, ArrayPath, StringCheckboxPath>;
|
|
64
|
+
//#endregion
|
package/dist/use-form.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { breachOf } from "./rules/rules.mjs";
|
|
3
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
//#region src/use-form.ts
|
|
5
5
|
const INDEX = "{index}";
|
|
6
6
|
const FLAGS = [
|
|
@@ -40,15 +40,19 @@ const messageFor = (control, field) => {
|
|
|
40
40
|
if (validity.customError) return control.validationMessage;
|
|
41
41
|
for (const flag of FLAGS) if (validity[flag]) return field.messages[flag];
|
|
42
42
|
};
|
|
43
|
-
const
|
|
43
|
+
const rowCountsFor = (fields, state) => {
|
|
44
|
+
const counts = {};
|
|
45
|
+
for (const [path, array] of Object.entries(fields.arrays)) counts[path] = state.rows?.[path] ?? array.minItems ?? 0;
|
|
46
|
+
return counts;
|
|
47
|
+
};
|
|
48
|
+
const rowKeysFor = (counts, current = {}) => {
|
|
44
49
|
const rows = {};
|
|
45
|
-
for (const [path,
|
|
46
|
-
const
|
|
47
|
-
rows[path] = Array.from({ length: count }, (_, index) => `${path}-${String(index)}`);
|
|
50
|
+
for (const [path, count] of Object.entries(counts)) {
|
|
51
|
+
const kept = current[path];
|
|
52
|
+
rows[path] = kept?.length === count ? kept : Array.from({ length: count }, (_, index) => `${path}-${String(index)}`);
|
|
48
53
|
}
|
|
49
54
|
return rows;
|
|
50
55
|
};
|
|
51
|
-
const rowCountsOf = (rows) => Object.fromEntries(Object.entries(rows).map(([path, keys]) => [path, keys.length]));
|
|
52
56
|
/**
|
|
53
57
|
* Rename one concrete indexed name after the row at `removed` is gone: entries
|
|
54
58
|
* on that row are dropped, entries on later rows slide down one index. Without
|
|
@@ -87,9 +91,10 @@ const shiftSet = (set, path, removed) => {
|
|
|
87
91
|
* Values are never copied into React state — the DOM holds them. What lives
|
|
88
92
|
* here is only what the DOM cannot express: which message to show for a field
|
|
89
93
|
* the browser has judged invalid, which server errors are still current, the
|
|
90
|
-
* identity of each repeated row,
|
|
94
|
+
* identity of each repeated row, one dirty flag, and the row counts adding or
|
|
95
|
+
* removing a row is measured against.
|
|
91
96
|
*/
|
|
92
|
-
const useForm = (fields, state) => {
|
|
97
|
+
const useForm = (fields, state = {}) => {
|
|
93
98
|
const lookup = fields;
|
|
94
99
|
const formRef = useRef(null);
|
|
95
100
|
const nextKey = useRef(0);
|
|
@@ -97,8 +102,10 @@ const useForm = (fields, state) => {
|
|
|
97
102
|
const [clientErrors, setClientErrors] = useState({});
|
|
98
103
|
const [edited, setEdited] = useState(/* @__PURE__ */ new Set());
|
|
99
104
|
const [domDirty, setDomDirty] = useState(false);
|
|
100
|
-
const [
|
|
101
|
-
const
|
|
105
|
+
const [baselineRows, setBaselineRows] = useState(() => rowCountsFor(lookup, state));
|
|
106
|
+
const [rowKeys, setRowKeys] = useState(() => rowKeysFor(baselineRows));
|
|
107
|
+
const formErrorId = useId();
|
|
108
|
+
const arrayErrorIdBase = useId();
|
|
102
109
|
const stateKey = JSON.stringify(state);
|
|
103
110
|
const lastKey = useRef(stateKey);
|
|
104
111
|
useEffect(() => {
|
|
@@ -107,16 +114,21 @@ const useForm = (fields, state) => {
|
|
|
107
114
|
setClientErrors({});
|
|
108
115
|
setEdited(/* @__PURE__ */ new Set());
|
|
109
116
|
setDomDirty(false);
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
setRowKeys(
|
|
113
|
-
const
|
|
114
|
-
|
|
117
|
+
const counts = rowCountsFor(lookup, state);
|
|
118
|
+
setBaselineRows(counts);
|
|
119
|
+
setRowKeys((previous) => rowKeysFor(counts, previous));
|
|
120
|
+
const messageIds = [...state.formError === void 0 ? [] : [formErrorId], ...Object.keys(lookup.arrays).filter((path) => state.errors?.[path] !== void 0).map((path) => arrayErrorId(arrayErrorIdBase, path))];
|
|
121
|
+
firstFailure(formRef.current, state.errors ?? {}, messageIds)?.focus();
|
|
115
122
|
}, [
|
|
116
123
|
stateKey,
|
|
117
124
|
state,
|
|
118
|
-
lookup
|
|
125
|
+
lookup,
|
|
126
|
+
formErrorId,
|
|
127
|
+
arrayErrorIdBase
|
|
119
128
|
]);
|
|
129
|
+
useLayoutEffect(() => {
|
|
130
|
+
writeEchoToSelects(formRef.current, lookup, state.values);
|
|
131
|
+
}, [lookup, state.values]);
|
|
120
132
|
const evaluate = useCallback((target, onlyIfShown) => {
|
|
121
133
|
if (!isControl(target)) return;
|
|
122
134
|
const form = formRef.current;
|
|
@@ -155,6 +167,35 @@ const useForm = (fields, state) => {
|
|
|
155
167
|
}
|
|
156
168
|
evaluate(target, true);
|
|
157
169
|
}, [evaluate]);
|
|
170
|
+
const onSubmit = useCallback((event) => {
|
|
171
|
+
const { submitter } = event;
|
|
172
|
+
if ((submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement) && submitter.formNoValidate) return;
|
|
173
|
+
const form = event.currentTarget;
|
|
174
|
+
applyRules(form, lookup.rules, ownedRuleMessages.current);
|
|
175
|
+
const failed = firstFailed(form, (control) => control.willValidate && !control.validity.valid);
|
|
176
|
+
if (failed === void 0) return;
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
const messages = messagesIn(form, lookup);
|
|
179
|
+
setClientErrors(messages);
|
|
180
|
+
failed.focus();
|
|
181
|
+
if (!Object.hasOwn(messages, failed.name)) failed.reportValidity();
|
|
182
|
+
}, [lookup]);
|
|
183
|
+
const onReset = useCallback(() => {
|
|
184
|
+
setClientErrors({});
|
|
185
|
+
setEdited(/* @__PURE__ */ new Set());
|
|
186
|
+
const counts = rowCountsFor(lookup, state);
|
|
187
|
+
setBaselineRows(counts);
|
|
188
|
+
setRowKeys((previous) => rowKeysFor(counts, previous));
|
|
189
|
+
const form = formRef.current;
|
|
190
|
+
for (const [name, message] of ownedRuleMessages.current) {
|
|
191
|
+
const control = controlNamed(form, name);
|
|
192
|
+
if (control?.validationMessage === message) control.setCustomValidity("");
|
|
193
|
+
}
|
|
194
|
+
ownedRuleMessages.current.clear();
|
|
195
|
+
setTimeout(() => {
|
|
196
|
+
setDomDirty(isFormDirty(formRef.current));
|
|
197
|
+
}, 0);
|
|
198
|
+
}, [lookup, state]);
|
|
158
199
|
const viewOf = useCallback((field, name) => {
|
|
159
200
|
const serverError = edited.has(name) ? void 0 : state.errors?.[name];
|
|
160
201
|
const error = clientErrors[name] ?? serverError;
|
|
@@ -219,13 +260,18 @@ const useForm = (fields, state) => {
|
|
|
219
260
|
},
|
|
220
261
|
canAdd: derived.maxItems === void 0 || keys.length < derived.maxItems,
|
|
221
262
|
canRemove: keys.length > (derived.minItems ?? 0),
|
|
222
|
-
error: state.errors?.[path]
|
|
263
|
+
error: state.errors?.[path],
|
|
264
|
+
errorProps: {
|
|
265
|
+
id: arrayErrorId(arrayErrorIdBase, path),
|
|
266
|
+
tabIndex: -1
|
|
267
|
+
}
|
|
223
268
|
};
|
|
224
269
|
}, [
|
|
225
270
|
lookup,
|
|
226
271
|
rowKeys,
|
|
227
272
|
state,
|
|
228
|
-
viewOf
|
|
273
|
+
viewOf,
|
|
274
|
+
arrayErrorIdBase
|
|
229
275
|
]);
|
|
230
276
|
const ref = useCallback((node) => {
|
|
231
277
|
formRef.current = node;
|
|
@@ -234,20 +280,68 @@ const useForm = (fields, state) => {
|
|
|
234
280
|
const props = useMemo(() => ({
|
|
235
281
|
onBlur,
|
|
236
282
|
onInput,
|
|
283
|
+
onReset,
|
|
284
|
+
onSubmit,
|
|
237
285
|
ref
|
|
238
286
|
}), [
|
|
239
287
|
onBlur,
|
|
240
288
|
onInput,
|
|
289
|
+
onReset,
|
|
290
|
+
onSubmit,
|
|
241
291
|
ref
|
|
242
292
|
]);
|
|
243
|
-
const
|
|
293
|
+
const formError = useMemo(() => ({
|
|
294
|
+
message: state.formError,
|
|
295
|
+
props: {
|
|
296
|
+
id: formErrorId,
|
|
297
|
+
tabIndex: -1
|
|
298
|
+
}
|
|
299
|
+
}), [state.formError, formErrorId]);
|
|
300
|
+
const structuralDirty = Object.entries(rowKeys).some(([path, keys]) => keys.length !== (baselineRows[path] ?? 0));
|
|
244
301
|
return {
|
|
245
302
|
props,
|
|
246
303
|
field,
|
|
247
304
|
array,
|
|
305
|
+
formError,
|
|
248
306
|
isDirty: domDirty || structuralDirty
|
|
249
307
|
};
|
|
250
308
|
};
|
|
309
|
+
/**
|
|
310
|
+
* An object key may hold whitespace, which an id must not — it would also
|
|
311
|
+
* split an `aria-describedby` list — so the path is encoded, not appended.
|
|
312
|
+
*/
|
|
313
|
+
const arrayErrorId = (base, path) => `${base}-${encodeURIComponent(path)}`;
|
|
314
|
+
/**
|
|
315
|
+
* The first failure in document order: a failed control, or the element
|
|
316
|
+
* showing a failure no control owns — the form-level message or an array's
|
|
317
|
+
* own. `state.errors` cannot say which comes first — its keys follow the
|
|
318
|
+
* order zod reported the issues in, which is the schema's, not the page's.
|
|
319
|
+
*/
|
|
320
|
+
const firstFailure = (form, errors, messageIds) => {
|
|
321
|
+
if (form === null) return;
|
|
322
|
+
const candidates = messageIds.map((id) => form.ownerDocument.querySelector(`#${CSS.escape(id)}`)).filter((message) => message !== null);
|
|
323
|
+
const field = firstFailed(form, (control) => errors[control.name] !== void 0);
|
|
324
|
+
if (field !== void 0) candidates.push(field);
|
|
325
|
+
return candidates.toSorted((a, b) => a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1)[0];
|
|
326
|
+
};
|
|
327
|
+
/** The first control in document order that `failed` picks out. */
|
|
328
|
+
const firstFailed = (form, failed) => [...form.elements].find((element) => isControl(element) && failed(element));
|
|
329
|
+
/**
|
|
330
|
+
* The message every field of the form shows right now, read from the DOM —
|
|
331
|
+
* the untouched fields included, which is what a submit has to report.
|
|
332
|
+
*/
|
|
333
|
+
const messagesIn = (form, fields) => {
|
|
334
|
+
const messages = {};
|
|
335
|
+
for (const element of form.elements) {
|
|
336
|
+
if (!isControl(element) || Object.hasOwn(messages, element.name)) continue;
|
|
337
|
+
const field = fieldFor(fields, element.name);
|
|
338
|
+
const control = controlNamed(form, element.name);
|
|
339
|
+
if (field === void 0 || control === void 0) continue;
|
|
340
|
+
const message = messageFor(control, field);
|
|
341
|
+
if (message !== void 0) messages[element.name] = message;
|
|
342
|
+
}
|
|
343
|
+
return messages;
|
|
344
|
+
};
|
|
251
345
|
/** Look up the derived field for a submitted name, row index included. */
|
|
252
346
|
const fieldFor = (fields, name) => {
|
|
253
347
|
const direct = fields.fields[name];
|
|
@@ -290,6 +384,17 @@ const applyRules = (form, rules, owned) => {
|
|
|
290
384
|
}
|
|
291
385
|
}
|
|
292
386
|
};
|
|
387
|
+
const writeEchoToSelects = (form, fields, values) => {
|
|
388
|
+
if (form === null || values === void 0) return;
|
|
389
|
+
for (const element of form.elements) {
|
|
390
|
+
if (!(element instanceof HTMLSelectElement) || fieldFor(fields, element.name) === void 0) continue;
|
|
391
|
+
if (element.matches(":disabled")) continue;
|
|
392
|
+
const echoed = values[element.name];
|
|
393
|
+
if (echoed === void 0) continue;
|
|
394
|
+
const chosen = new Set([echoed].flat());
|
|
395
|
+
for (const option of element.options) option.defaultSelected = chosen.has(option.value);
|
|
396
|
+
}
|
|
397
|
+
};
|
|
293
398
|
const isFormDirty = (form) => {
|
|
294
399
|
if (form === null) return false;
|
|
295
400
|
for (const element of form.elements) if (element instanceof HTMLInputElement) {
|
|
@@ -300,10 +405,19 @@ const isFormDirty = (form) => {
|
|
|
300
405
|
if (element.checked !== element.defaultChecked) return true;
|
|
301
406
|
} else if (element.value !== element.defaultValue) return true;
|
|
302
407
|
} 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
|
-
}
|
|
408
|
+
else if (element instanceof HTMLSelectElement && isSelectDirty(element)) return true;
|
|
306
409
|
return false;
|
|
307
410
|
};
|
|
411
|
+
/**
|
|
412
|
+
* A drop-down with no option marked as the default still shows one — its
|
|
413
|
+
* first option — and a reset goes back to that one too, so the baseline is
|
|
414
|
+
* the option a reset would select, not each option's `defaultSelected`.
|
|
415
|
+
*/
|
|
416
|
+
const isSelectDirty = (select) => {
|
|
417
|
+
const options = [...select.options];
|
|
418
|
+
if (select.multiple) return options.some((option) => option.selected !== option.defaultSelected);
|
|
419
|
+
const baseline = options.filter((option) => option.defaultSelected).at(-1) ?? (select.size > 1 ? void 0 : options.find((option) => !option.disabled));
|
|
420
|
+
return select.selectedIndex !== (baseline?.index ?? -1);
|
|
421
|
+
};
|
|
308
422
|
//#endregion
|
|
309
423
|
export { useForm };
|