@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.
@@ -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: FieldInput;
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 UseFormReturn<FieldPath extends string = string, ArrayPath extends string = string> = {
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: (path: FieldPath) => FieldView;
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, and one dirty flag.
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: FormState) => UseFormReturn<FieldPath, ArrayPath>;
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 initialRows = (fields, state) => {
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, 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)}`);
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, and one dirty flag.
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 [rowKeys, setRowKeys] = useState(() => initialRows(lookup, state));
101
- const baselineRows = useRef(rowCountsOf(rowKeys));
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 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();
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 structuralDirty = Object.entries(rowKeys).some(([path, keys]) => keys.length !== (baselineRows.current[path] ?? 0));
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 };