@zeno-lib/forms 0.3.0 → 0.5.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/dist/create-zeno-form.mjs +9 -3
- package/dist/form-element.mjs +7 -5
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +3 -3
- package/dist/lib/empty-value.d.mts +12 -0
- package/dist/lib/empty-value.mjs +68 -0
- package/dist/lib/focus-first-invalid.d.mts +23 -0
- package/dist/lib/focus-first-invalid.mjs +37 -0
- package/dist/lib/schema-defaults.d.mts +9 -1
- package/dist/lib/schema-defaults.mjs +33 -1
- package/dist/lib/schema-required.d.mts +2 -1
- package/dist/lib/schema-required.mjs +49 -21
- package/dist/lib/submit-action.mjs +1 -1
- package/dist/lib/submit-error.d.mts +36 -0
- package/dist/lib/submit-error.mjs +66 -0
- package/dist/lib/use-form-dialog.d.mts +15 -1
- package/dist/lib/use-form-dialog.mjs +20 -2
- package/dist/lib/use-is-invalid.d.mts +2 -1
- package/dist/lib/use-is-invalid.mjs +5 -2
- package/dist/lib/validation-modes.d.mts +5 -1
- package/dist/lib/validation-modes.mjs +9 -4
- package/package.json +1 -1
- package/src/create-form.tsx +2 -0
- package/src/create-zeno-form.tsx +25 -5
- package/src/fields/checkbox-field.tsx +8 -1
- package/src/fields/combobox-field.tsx +29 -12
- package/src/fields/date-picker-field.tsx +37 -9
- package/src/fields/email-field.tsx +8 -0
- package/src/fields/form-error.tsx +61 -0
- package/src/fields/index.ts +1 -0
- package/src/fields/input-field.tsx +6 -2
- package/src/fields/money-field.tsx +3 -1
- package/src/fields/multi-select-field.tsx +8 -1
- package/src/fields/number-field.tsx +9 -7
- package/src/fields/otp-field.tsx +8 -1
- package/src/fields/percentage-field.tsx +3 -1
- package/src/fields/radio-group-field.tsx +11 -4
- package/src/fields/select-field.tsx +26 -3
- package/src/fields/switch-field.tsx +8 -1
- package/src/fields/textarea-field.tsx +8 -1
- package/src/fields/year-field.tsx +3 -1
- package/src/form-dialog.test.tsx +493 -2
- package/src/form-dialog.tsx +217 -33
- package/src/form-element.tsx +13 -11
- package/src/form-error.test.tsx +255 -0
- package/src/index.ts +1 -0
- package/src/lib/empty-value.test.ts +72 -0
- package/src/lib/empty-value.ts +130 -0
- package/src/lib/focus-first-invalid.test.ts +22 -0
- package/src/lib/focus-first-invalid.ts +52 -0
- package/src/lib/schema-defaults.ts +50 -1
- package/src/lib/schema-required.test.ts +24 -2
- package/src/lib/schema-required.ts +81 -43
- package/src/lib/submit-error.ts +133 -0
- package/src/lib/use-form-dialog.ts +49 -1
- package/src/lib/use-is-invalid.ts +14 -1
- package/src/lib/validation-modes.ts +18 -6
- package/src/use-app-fields.test.tsx +312 -2
- package/src/use-form.focus-invalid.test.tsx +34 -0
|
@@ -3,11 +3,13 @@ import { applyValidationError } from "./lib/apply-validation-error.mjs";
|
|
|
3
3
|
import { fieldContext, formContext } from "./lib/contexts.mjs";
|
|
4
4
|
import { deepMergeDefaults, extractZodDefaults } from "./lib/schema-defaults.mjs";
|
|
5
5
|
import { getRequiredPaths } from "./lib/schema-required.mjs";
|
|
6
|
+
import { DEFAULT_VALIDATION_MODE, setFormZenoState } from "./lib/validation-modes.mjs";
|
|
7
|
+
import { withEmptyStartValues } from "./lib/empty-value.mjs";
|
|
8
|
+
import { setSubmitError } from "./lib/submit-error.mjs";
|
|
6
9
|
import { useRebasedDefaultValues } from "./lib/use-rebased-default-values.mjs";
|
|
7
10
|
import { useUnsavedChangesWarning } from "./lib/use-unsaved-changes-warning.mjs";
|
|
8
11
|
import { ValidationError } from "./lib/validation-error.mjs";
|
|
9
12
|
import { blurThenChangeLogic } from "./lib/validation-logic.mjs";
|
|
10
|
-
import { DEFAULT_VALIDATION_MODE, setFormZenoState } from "./lib/validation-modes.mjs";
|
|
11
13
|
import { createFormHook } from "@tanstack/react-form";
|
|
12
14
|
import { useMemo } from "react";
|
|
13
15
|
import { jsx } from "react/jsx-runtime";
|
|
@@ -76,6 +78,7 @@ function createZenoForm(config) {
|
|
|
76
78
|
resolvedValidationLogic = mode === "blur-then-change" ? blurThenChangeLogic : void 0;
|
|
77
79
|
}
|
|
78
80
|
const wrappedOnSubmit = userOnSubmit ? (async (props) => {
|
|
81
|
+
const { submissionAttempts } = props.formApi.state;
|
|
79
82
|
try {
|
|
80
83
|
await userOnSubmit(props);
|
|
81
84
|
} catch (error) {
|
|
@@ -83,11 +86,13 @@ function createZenoForm(config) {
|
|
|
83
86
|
applyValidationError(props.formApi, error);
|
|
84
87
|
return;
|
|
85
88
|
}
|
|
89
|
+
const { state } = props.formApi;
|
|
90
|
+
if (state.values === props.value && state.submissionAttempts === submissionAttempts) setSubmitError(props.formApi, error);
|
|
86
91
|
throw error;
|
|
87
92
|
}
|
|
88
93
|
}) : void 0;
|
|
89
|
-
const requiredFields = useMemo(() => schema
|
|
90
|
-
const schemaDefaults = useMemo(() => schema ? extractZodDefaults(schema) : void 0, [schema]);
|
|
94
|
+
const requiredFields = useMemo(() => schema ? getRequiredPaths(schema) : /* @__PURE__ */ new Set(), [schema]);
|
|
95
|
+
const schemaDefaults = useMemo(() => schema ? withEmptyStartValues(schema, extractZodDefaults(schema)) : void 0, [schema]);
|
|
91
96
|
const mergedDefaultValues = useMemo(() => schemaDefaults ? deepMergeDefaults(schemaDefaults, userDefaultValues) : userDefaultValues, [schemaDefaults, userDefaultValues]);
|
|
92
97
|
const rebased = useRebasedDefaultValues(mergedDefaultValues);
|
|
93
98
|
const listeners = withMountListener(rest.listeners, rebased.onMount);
|
|
@@ -103,6 +108,7 @@ function createZenoForm(config) {
|
|
|
103
108
|
hideFieldErrors,
|
|
104
109
|
requiredFields,
|
|
105
110
|
requiredIndicator,
|
|
111
|
+
schema,
|
|
106
112
|
...schemaMode === void 0 ? {} : { validation: schemaMode }
|
|
107
113
|
});
|
|
108
114
|
useUnsavedChangesWarning(form, unsavedChangesWarning);
|
package/dist/form-element.mjs
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { FormProvider as FormProvider$1, useFormContext } from "./lib/contexts.mjs";
|
|
3
|
+
import { reportSubmitError } from "./lib/submit-error.mjs";
|
|
4
|
+
import { focusFirstInvalid, restoreFocus } from "./lib/focus-first-invalid.mjs";
|
|
3
5
|
import { jsx } from "react/jsx-runtime";
|
|
4
6
|
//#region src/form-element.tsx
|
|
5
7
|
function FormProvider({ children, form }) {
|
|
@@ -17,11 +19,11 @@ function Form({ children, className, ...props }) {
|
|
|
17
19
|
event.preventDefault();
|
|
18
20
|
event.stopPropagation();
|
|
19
21
|
const node = event.currentTarget;
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
const focused = node.ownerDocument.activeElement;
|
|
23
|
+
await Promise.resolve(form.handleSubmit()).catch(reportSubmitError);
|
|
24
|
+
const { isSubmitSuccessful, isValid } = form.state;
|
|
25
|
+
if (!isValid && focusFirstInvalid(node)) return;
|
|
26
|
+
if (!(isValid && isSubmitSuccessful)) restoreFocus(focused);
|
|
25
27
|
},
|
|
26
28
|
...props,
|
|
27
29
|
children
|
package/dist/index.d.mts
CHANGED
|
@@ -6,7 +6,7 @@ import { FieldMessage, ValidationError } from "./lib/validation-error.mjs";
|
|
|
6
6
|
import { applyValidationError } from "./lib/apply-validation-error.mjs";
|
|
7
7
|
import { useFieldContext, useFormContext } from "./lib/contexts.mjs";
|
|
8
8
|
import { SubmitActionOptions, SubmitActionSchema, applyActionError, submitAction } from "./lib/submit-action.mjs";
|
|
9
|
-
import { useHideFieldErrors, useIsFieldRequired, useIsInvalid } from "./lib/use-is-invalid.mjs";
|
|
9
|
+
import { useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid } from "./lib/use-is-invalid.mjs";
|
|
10
10
|
import { blurThenChangeLogic } from "./lib/validation-logic.mjs";
|
|
11
11
|
import { useSelector } from "@tanstack/react-form";
|
|
12
|
-
export { type ActionError, type ActionIssue, type ActionResult, type FieldMessage, Form, FormProvider, type SubmitActionOptions, type SubmitActionSchema, ValidationError, type ValidationMode, applyActionError, applyValidationError, blurThenChangeLogic, createZenoForm, submitAction, toActionError, toFieldName, useFieldContext, useFormContext, useHideFieldErrors, useIsFieldRequired, useIsInvalid, useSelector };
|
|
12
|
+
export { type ActionError, type ActionIssue, type ActionResult, type FieldMessage, Form, FormProvider, type SubmitActionOptions, type SubmitActionSchema, ValidationError, type ValidationMode, applyActionError, applyValidationError, blurThenChangeLogic, createZenoForm, submitAction, toActionError, toFieldName, useFieldContext, useFormContext, useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid, useSelector };
|
package/dist/index.mjs
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { applyValidationError } from "./lib/apply-validation-error.mjs";
|
|
2
2
|
import { useFieldContext, useFormContext } from "./lib/contexts.mjs";
|
|
3
|
+
import { toActionError, toFieldName } from "./lib/action-result.mjs";
|
|
3
4
|
import { ValidationError } from "./lib/validation-error.mjs";
|
|
4
5
|
import { blurThenChangeLogic } from "./lib/validation-logic.mjs";
|
|
5
6
|
import { createZenoForm } from "./create-zeno-form.mjs";
|
|
6
7
|
import { Form, FormProvider } from "./form-element.mjs";
|
|
7
|
-
import { toActionError, toFieldName } from "./lib/action-result.mjs";
|
|
8
8
|
import { applyActionError, submitAction } from "./lib/submit-action.mjs";
|
|
9
|
-
import { useHideFieldErrors, useIsFieldRequired, useIsInvalid } from "./lib/use-is-invalid.mjs";
|
|
9
|
+
import { useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid } from "./lib/use-is-invalid.mjs";
|
|
10
10
|
import { useSelector } from "@tanstack/react-form";
|
|
11
|
-
export { Form, FormProvider, ValidationError, applyActionError, applyValidationError, blurThenChangeLogic, createZenoForm, submitAction, toActionError, toFieldName, useFieldContext, useFormContext, useHideFieldErrors, useIsFieldRequired, useIsInvalid, useSelector };
|
|
11
|
+
export { Form, FormProvider, ValidationError, applyActionError, applyValidationError, blurThenChangeLogic, createZenoForm, submitAction, toActionError, toFieldName, useFieldContext, useFormContext, useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid, useSelector };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { StandardSchemaLike } from "./schema-required.mjs";
|
|
2
|
+
import { AnyFieldApi } from "@tanstack/react-form";
|
|
3
|
+
//#region src/lib/empty-value.d.ts
|
|
4
|
+
declare function emptyValueFor(schema: StandardSchemaLike | undefined, values: unknown, name: string, fallback: unknown): unknown;
|
|
5
|
+
declare function withEmptyStartValues(schema: StandardSchemaLike, defaults: Record<string, unknown>): Record<string, unknown>;
|
|
6
|
+
/**
|
|
7
|
+
* The value `field` takes when the user clears it, by the rule above. Typed as
|
|
8
|
+
* the field's value, since the same schema decides both.
|
|
9
|
+
*/
|
|
10
|
+
declare function getEmptyValue<T>(field: AnyFieldApi, fallback: T): T;
|
|
11
|
+
//#endregion
|
|
12
|
+
export { emptyValueFor, getEmptyValue, withEmptyStartValues };
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { toFieldName } from "./action-result.mjs";
|
|
2
|
+
import { extractZodDefaultAt } from "./schema-defaults.mjs";
|
|
3
|
+
import { validateSync } from "./schema-required.mjs";
|
|
4
|
+
import { getFormSchema } from "./validation-modes.mjs";
|
|
5
|
+
//#region src/lib/empty-value.ts
|
|
6
|
+
const NAME_SEGMENT = /[^.[\]]+/g;
|
|
7
|
+
const INDEX = /^\d+$/;
|
|
8
|
+
function toPathKeys(name) {
|
|
9
|
+
return (name.match(NAME_SEGMENT) ?? []).map((part) => INDEX.test(part) ? Number(part) : part);
|
|
10
|
+
}
|
|
11
|
+
function valueAt(root, keys) {
|
|
12
|
+
let node = root;
|
|
13
|
+
for (const key of keys) {
|
|
14
|
+
if (typeof node !== "object" || node === null) return;
|
|
15
|
+
node = node[key];
|
|
16
|
+
}
|
|
17
|
+
return node;
|
|
18
|
+
}
|
|
19
|
+
function withValueAt(root, keys, value) {
|
|
20
|
+
const [key, ...rest] = keys;
|
|
21
|
+
if (key === void 0) return value;
|
|
22
|
+
const node = root ?? (typeof key === "number" ? [] : {});
|
|
23
|
+
const copy = Array.isArray(node) ? [...node] : { ...node };
|
|
24
|
+
copy[key] = withValueAt(node[key], rest, value);
|
|
25
|
+
return copy;
|
|
26
|
+
}
|
|
27
|
+
function emptyValueFor(schema, values, name, fallback) {
|
|
28
|
+
if (!schema) return fallback;
|
|
29
|
+
const keys = toPathKeys(name);
|
|
30
|
+
const fieldName = toFieldName(keys);
|
|
31
|
+
const typeEmpty = extractZodDefaultAt(schema, keys);
|
|
32
|
+
const candidates = typeEmpty.ok ? [
|
|
33
|
+
null,
|
|
34
|
+
void 0,
|
|
35
|
+
typeEmpty.value
|
|
36
|
+
] : [null, void 0];
|
|
37
|
+
for (const candidate of candidates) {
|
|
38
|
+
const issues = validateSync(schema, withValueAt(values, keys, candidate));
|
|
39
|
+
if (!issues) return fallback;
|
|
40
|
+
if (!issues.some((issue) => toFieldName(issue.path) === fieldName)) return candidate;
|
|
41
|
+
}
|
|
42
|
+
return fallback;
|
|
43
|
+
}
|
|
44
|
+
function isTypeEmpty(value) {
|
|
45
|
+
return value === "" || Array.isArray(value) && value.length === 0;
|
|
46
|
+
}
|
|
47
|
+
function withEmptyStartValues(schema, defaults) {
|
|
48
|
+
const issues = validateSync(schema, defaults);
|
|
49
|
+
let result = defaults;
|
|
50
|
+
for (const issue of issues ?? []) {
|
|
51
|
+
const name = toFieldName(issue.path);
|
|
52
|
+
const keys = toPathKeys(name);
|
|
53
|
+
const start = valueAt(defaults, keys);
|
|
54
|
+
if (keys.length === 0 || !isTypeEmpty(start)) continue;
|
|
55
|
+
const empty = emptyValueFor(schema, result, name, start);
|
|
56
|
+
if (empty !== start) result = withValueAt(result, keys, empty);
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The value `field` takes when the user clears it, by the rule above. Typed as
|
|
62
|
+
* the field's value, since the same schema decides both.
|
|
63
|
+
*/
|
|
64
|
+
function getEmptyValue(field, fallback) {
|
|
65
|
+
return emptyValueFor(getFormSchema(field.form), field.form.state.values, field.name, fallback);
|
|
66
|
+
}
|
|
67
|
+
//#endregion
|
|
68
|
+
export { emptyValueFor, getEmptyValue, withEmptyStartValues };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
//#region src/lib/focus-first-invalid.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Move focus to the first invalid control inside `root` (a `<form>`): the
|
|
4
|
+
* first element with `aria-invalid="true"`, or, for a group root that can't
|
|
5
|
+
* take focus itself (radio group, slider), its first tabbable descendant. An
|
|
6
|
+
* invalid control that won't take focus (disabled, hidden) is skipped for the
|
|
7
|
+
* next one. Call it after `await form.handleSubmit()` leaves the form invalid.
|
|
8
|
+
* Searching only `root` keeps focus inside the submitted form, so a dialog's
|
|
9
|
+
* form never sends it to the page behind. Returns the focused element, or
|
|
10
|
+
* `null`.
|
|
11
|
+
*/
|
|
12
|
+
declare function focusFirstInvalid(root: ParentNode | null | undefined): HTMLElement | null;
|
|
13
|
+
/**
|
|
14
|
+
* Give focus back to `element`, what had it when the submit started, if the
|
|
15
|
+
* submit dropped it to the body. A submit button that disables itself while
|
|
16
|
+
* submitting does that in browsers, so a failed submit with no invalid field
|
|
17
|
+
* to focus would otherwise leave the user nowhere. It doesn't scroll, so a
|
|
18
|
+
* user who scrolled away during a slow submit stays put. Returns whether it
|
|
19
|
+
* moved focus.
|
|
20
|
+
*/
|
|
21
|
+
declare function restoreFocus(element: Element | null): boolean;
|
|
22
|
+
//#endregion
|
|
23
|
+
export { focusFirstInvalid, restoreFocus };
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
//#region src/lib/focus-first-invalid.ts
|
|
2
|
+
/**
|
|
3
|
+
* Move focus to the first invalid control inside `root` (a `<form>`): the
|
|
4
|
+
* first element with `aria-invalid="true"`, or, for a group root that can't
|
|
5
|
+
* take focus itself (radio group, slider), its first tabbable descendant. An
|
|
6
|
+
* invalid control that won't take focus (disabled, hidden) is skipped for the
|
|
7
|
+
* next one. Call it after `await form.handleSubmit()` leaves the form invalid.
|
|
8
|
+
* Searching only `root` keeps focus inside the submitted form, so a dialog's
|
|
9
|
+
* form never sends it to the page behind. Returns the focused element, or
|
|
10
|
+
* `null`.
|
|
11
|
+
*/
|
|
12
|
+
function focusFirstInvalid(root) {
|
|
13
|
+
const invalid = root?.querySelectorAll("[aria-invalid=\"true\"]");
|
|
14
|
+
for (const element of invalid ?? []) for (const target of [element, ...element.querySelectorAll("*")]) {
|
|
15
|
+
if (target.tabIndex < 0) continue;
|
|
16
|
+
target.focus();
|
|
17
|
+
if (target.ownerDocument.activeElement === target) return target;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Give focus back to `element`, what had it when the submit started, if the
|
|
23
|
+
* submit dropped it to the body. A submit button that disables itself while
|
|
24
|
+
* submitting does that in browsers, so a failed submit with no invalid field
|
|
25
|
+
* to focus would otherwise leave the user nowhere. It doesn't scroll, so a
|
|
26
|
+
* user who scrolled away during a slow submit stays put. Returns whether it
|
|
27
|
+
* moved focus.
|
|
28
|
+
*/
|
|
29
|
+
function restoreFocus(element) {
|
|
30
|
+
if (!(element instanceof HTMLElement && element.isConnected)) return false;
|
|
31
|
+
const active = element.ownerDocument.activeElement;
|
|
32
|
+
if (active !== null && active !== element.ownerDocument.body) return false;
|
|
33
|
+
element.focus({ preventScroll: true });
|
|
34
|
+
return element.ownerDocument.activeElement === element;
|
|
35
|
+
}
|
|
36
|
+
//#endregion
|
|
37
|
+
export { focusFirstInvalid, restoreFocus };
|
|
@@ -9,9 +9,17 @@ type SchemaDef = {
|
|
|
9
9
|
readonly innerType?: SchemaNode;
|
|
10
10
|
readonly shape?: Record<string, SchemaNode>;
|
|
11
11
|
readonly in?: SchemaNode;
|
|
12
|
+
readonly element?: SchemaNode;
|
|
12
13
|
readonly defaultValue?: unknown;
|
|
13
14
|
};
|
|
15
|
+
type ExtractResult = {
|
|
16
|
+
ok: true;
|
|
17
|
+
value: unknown;
|
|
18
|
+
} | {
|
|
19
|
+
ok: false;
|
|
20
|
+
};
|
|
14
21
|
declare function extractZodDefaults(schema: SchemaNode): Record<string, unknown>;
|
|
22
|
+
declare function extractZodDefaultAt(schema: SchemaNode, keys: readonly PropertyKey[]): ExtractResult;
|
|
15
23
|
declare function deepMergeDefaults(schemaDefaults: Record<string, unknown>, userDefaults: Record<string, unknown> | undefined): Record<string, unknown>;
|
|
16
24
|
//#endregion
|
|
17
|
-
export { deepMergeDefaults, extractZodDefaults };
|
|
25
|
+
export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults };
|
|
@@ -57,6 +57,38 @@ function extractZodDefaults(schema) {
|
|
|
57
57
|
return {};
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
|
+
function childAt(node, key) {
|
|
61
|
+
let current = node;
|
|
62
|
+
for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) {
|
|
63
|
+
const def = getDef(current);
|
|
64
|
+
switch (def?.type) {
|
|
65
|
+
case "object": return typeof key === "string" && def.shape?.[key] || null;
|
|
66
|
+
case "array": return typeof key === "number" && def.element || null;
|
|
67
|
+
case "default":
|
|
68
|
+
case "nonoptional":
|
|
69
|
+
case "nullable":
|
|
70
|
+
case "optional":
|
|
71
|
+
case "prefault":
|
|
72
|
+
case "readonly":
|
|
73
|
+
current = def.innerType;
|
|
74
|
+
continue;
|
|
75
|
+
case "pipe":
|
|
76
|
+
current = def.in;
|
|
77
|
+
continue;
|
|
78
|
+
default: return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
function extractZodDefaultAt(schema, keys) {
|
|
84
|
+
try {
|
|
85
|
+
let node = schema;
|
|
86
|
+
for (const key of keys) node = node && childAt(node, key);
|
|
87
|
+
return node && keys.length > 0 ? extractDefaultForField(node) : { ok: false };
|
|
88
|
+
} catch {
|
|
89
|
+
return { ok: false };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
60
92
|
function isPlainObject(value) {
|
|
61
93
|
return typeof value === "object" && value !== null && Object.getPrototypeOf(value) === Object.prototype;
|
|
62
94
|
}
|
|
@@ -72,4 +104,4 @@ function deepMergeDefaults(schemaDefaults, userDefaults) {
|
|
|
72
104
|
return result;
|
|
73
105
|
}
|
|
74
106
|
//#endregion
|
|
75
|
-
export { deepMergeDefaults, extractZodDefaults };
|
|
107
|
+
export { deepMergeDefaults, extractZodDefaultAt, extractZodDefaults };
|
|
@@ -20,6 +20,7 @@ type StandardSchemaLike = {
|
|
|
20
20
|
* `getRequiredPaths` records (`members[0].name`).
|
|
21
21
|
*/
|
|
22
22
|
declare function toRequiredPathKey(name: string): string;
|
|
23
|
+
declare function validateSync(schema: StandardSchemaLike, value: unknown): readonly StandardIssue[] | undefined;
|
|
23
24
|
declare function getRequiredPaths(schema: StandardSchemaLike): Set<string>;
|
|
24
25
|
//#endregion
|
|
25
|
-
export { type StandardSchemaLike, getRequiredPaths, toRequiredPathKey };
|
|
26
|
+
export { type StandardSchemaLike, getRequiredPaths, toRequiredPathKey, validateSync };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//#region src/lib/schema-required.ts
|
|
2
|
-
const MAX_PROBE_DEPTH =
|
|
2
|
+
const MAX_PROBE_DEPTH = 16;
|
|
3
3
|
const INDEX_SEGMENT = /^\d+$/;
|
|
4
4
|
const INDEX_IN_NAME = /\[\d+\]|\.(\d+)(?=\.|\[|$)/g;
|
|
5
5
|
function pathKey(part) {
|
|
@@ -23,15 +23,14 @@ function toRequiredPathKey(name) {
|
|
|
23
23
|
return name.replace(INDEX_IN_NAME, "[0]");
|
|
24
24
|
}
|
|
25
25
|
function probeValue(expected) {
|
|
26
|
-
|
|
27
|
-
switch (expected.toLowerCase()) {
|
|
26
|
+
switch (typeof expected === "string" ? expected.toLowerCase() : void 0) {
|
|
28
27
|
case "object": return {};
|
|
29
|
-
case "array": return [
|
|
28
|
+
case "array": return [];
|
|
30
29
|
case "string": return "";
|
|
31
|
-
default: return;
|
|
30
|
+
default: return null;
|
|
32
31
|
}
|
|
33
32
|
}
|
|
34
|
-
function fillAt(root, keys, value) {
|
|
33
|
+
function fillAt(root, keys, value, replace = false) {
|
|
35
34
|
let node = root;
|
|
36
35
|
for (const key of keys.slice(0, -1)) {
|
|
37
36
|
const next = node[key];
|
|
@@ -39,7 +38,7 @@ function fillAt(root, keys, value) {
|
|
|
39
38
|
node = next;
|
|
40
39
|
}
|
|
41
40
|
const last = keys.at(-1);
|
|
42
|
-
if (node[last] !== void 0) return false;
|
|
41
|
+
if (!replace && node[last] !== void 0) return false;
|
|
43
42
|
node[last] = value;
|
|
44
43
|
return true;
|
|
45
44
|
}
|
|
@@ -53,41 +52,70 @@ function validateSync(schema, value) {
|
|
|
53
52
|
if (result instanceof Promise) return;
|
|
54
53
|
return "issues" in result && result.issues ? result.issues : [];
|
|
55
54
|
}
|
|
56
|
-
function recordPass(issues, probe, required) {
|
|
55
|
+
function recordPass(issues, probe, required, cleared) {
|
|
57
56
|
const failing = /* @__PURE__ */ new Set();
|
|
58
|
-
const
|
|
59
|
-
let descended = false;
|
|
57
|
+
const filled = [];
|
|
60
58
|
for (const issue of issues) {
|
|
61
59
|
const keys = issue.path?.map(pathKey) ?? [];
|
|
62
60
|
if (keys.length === 0) continue;
|
|
63
61
|
const path = joinPath(keys);
|
|
64
62
|
failing.add(path);
|
|
65
|
-
required.add(path);
|
|
63
|
+
if (!cleared.has(path)) required.add(path);
|
|
66
64
|
const value = probeValue(issue.expected);
|
|
67
|
-
if (value !== void 0 && fillAt(probe, keys, value)) {
|
|
65
|
+
if (value !== void 0 && fillAt(probe, keys, value)) filled.push({
|
|
66
|
+
keys,
|
|
67
|
+
path,
|
|
68
|
+
value
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
failing,
|
|
73
|
+
filled
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function isEmptyProbe(value) {
|
|
77
|
+
return value === "" || value === null || Array.isArray(value);
|
|
78
|
+
}
|
|
79
|
+
function settleEmptied(emptied, failing, probe, required, cleared) {
|
|
80
|
+
const next = [];
|
|
81
|
+
let descended = false;
|
|
82
|
+
for (const fill of emptied) {
|
|
83
|
+
const passed = !failing.has(fill.path);
|
|
84
|
+
if (passed) {
|
|
85
|
+
required.delete(fill.path);
|
|
86
|
+
cleared.add(fill.path);
|
|
87
|
+
}
|
|
88
|
+
if (Array.isArray(fill.value)) {
|
|
89
|
+
fill.value.push(void 0);
|
|
90
|
+
descended = true;
|
|
91
|
+
} else if (!passed && fill.value === "") {
|
|
92
|
+
fillAt(probe, fill.keys, null, true);
|
|
93
|
+
next.push({
|
|
94
|
+
...fill,
|
|
95
|
+
value: null
|
|
96
|
+
});
|
|
68
97
|
descended = true;
|
|
69
|
-
if (value === "") filledStrings.push(path);
|
|
70
98
|
}
|
|
71
99
|
}
|
|
72
100
|
return {
|
|
73
101
|
descended,
|
|
74
|
-
|
|
75
|
-
filledStrings
|
|
102
|
+
next
|
|
76
103
|
};
|
|
77
104
|
}
|
|
78
105
|
function getRequiredPaths(schema) {
|
|
79
106
|
const required = /* @__PURE__ */ new Set();
|
|
107
|
+
const cleared = /* @__PURE__ */ new Set();
|
|
80
108
|
const probe = {};
|
|
81
|
-
let
|
|
109
|
+
let emptied = [];
|
|
82
110
|
for (let depth = 0; depth < MAX_PROBE_DEPTH; depth += 1) {
|
|
83
111
|
const issues = validateSync(schema, probe);
|
|
84
112
|
if (!issues) return required;
|
|
85
|
-
const {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (!descended) break;
|
|
113
|
+
const { failing, filled } = recordPass(issues, probe, required, cleared);
|
|
114
|
+
const settled = settleEmptied(emptied, failing, probe, required, cleared);
|
|
115
|
+
emptied = [...filled.filter(({ value }) => isEmptyProbe(value)), ...settled.next];
|
|
116
|
+
if (filled.length === 0 && !settled.descended) break;
|
|
89
117
|
}
|
|
90
118
|
return required;
|
|
91
119
|
}
|
|
92
120
|
//#endregion
|
|
93
|
-
export { getRequiredPaths, toRequiredPathKey };
|
|
121
|
+
export { getRequiredPaths, toRequiredPathKey, validateSync };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { applyValidationError } from "./apply-validation-error.mjs";
|
|
2
|
-
import { ValidationError } from "./validation-error.mjs";
|
|
3
2
|
import { toActionError } from "./action-result.mjs";
|
|
3
|
+
import { ValidationError } from "./validation-error.mjs";
|
|
4
4
|
//#region src/lib/submit-action.ts
|
|
5
5
|
function applyActionError(formApi, error) {
|
|
6
6
|
const fields = {};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//#region src/lib/submit-error.d.ts
|
|
2
|
+
type SubmitErrorState = {
|
|
3
|
+
readonly submissionAttempts: number;
|
|
4
|
+
readonly values: unknown;
|
|
5
|
+
};
|
|
6
|
+
type SubmitErrorForm = {
|
|
7
|
+
readonly state: SubmitErrorState;
|
|
8
|
+
readonly store: {
|
|
9
|
+
subscribe: (listener: () => void) => {
|
|
10
|
+
unsubscribe: () => void;
|
|
11
|
+
};
|
|
12
|
+
};
|
|
13
|
+
};
|
|
14
|
+
type SubmitError = {
|
|
15
|
+
/** What the submit handler threw. */
|
|
16
|
+
readonly error: unknown;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Record that `form`'s submit threw `error`. `useForm` calls it for what
|
|
20
|
+
* `onSubmit` throws; call it for a failure your own submit code catches
|
|
21
|
+
* outside `onSubmit`. The error lasts until the values change or the next
|
|
22
|
+
* submit starts (`reset()` does both), so an edit or a retry clears it. A
|
|
23
|
+
* Next.js navigation (`redirect()`, `notFound()`) is ignored.
|
|
24
|
+
*/
|
|
25
|
+
declare function setSubmitError(form: SubmitErrorForm, error: unknown): void;
|
|
26
|
+
/**
|
|
27
|
+
* Report a submit rejection that is caught so the user can retry, as
|
|
28
|
+
* `<Form>` and `FormDialog` do: `reportError` logs it and passes it to the
|
|
29
|
+
* page's `error` listeners (where error monitoring hooks in), as if it were
|
|
30
|
+
* uncaught. A Next.js navigation is skipped.
|
|
31
|
+
*/
|
|
32
|
+
declare function reportSubmitError(error: unknown): void;
|
|
33
|
+
/** The error `form`'s last submit threw, until it went stale. */
|
|
34
|
+
declare function useSubmitError(form: SubmitErrorForm): SubmitError | undefined;
|
|
35
|
+
//#endregion
|
|
36
|
+
export { type SubmitError, type SubmitErrorForm, reportSubmitError, setSubmitError, useSubmitError };
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
3
|
+
//#region src/lib/submit-error.ts
|
|
4
|
+
const entries = /* @__PURE__ */ new WeakMap();
|
|
5
|
+
const listeners = /* @__PURE__ */ new WeakMap();
|
|
6
|
+
function notify(key) {
|
|
7
|
+
for (const listener of listeners.get(key) ?? []) listener();
|
|
8
|
+
}
|
|
9
|
+
function clear(key) {
|
|
10
|
+
const entry = entries.get(key);
|
|
11
|
+
if (entry) {
|
|
12
|
+
entry.unsubscribe();
|
|
13
|
+
entries.delete(key);
|
|
14
|
+
notify(key);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function isNavigationError(error) {
|
|
18
|
+
if (typeof error !== "object" || error === null || !("digest" in error)) return false;
|
|
19
|
+
return typeof error.digest === "string" && error.digest.startsWith("NEXT_");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Record that `form`'s submit threw `error`. `useForm` calls it for what
|
|
23
|
+
* `onSubmit` throws; call it for a failure your own submit code catches
|
|
24
|
+
* outside `onSubmit`. The error lasts until the values change or the next
|
|
25
|
+
* submit starts (`reset()` does both), so an edit or a retry clears it. A
|
|
26
|
+
* Next.js navigation (`redirect()`, `notFound()`) is ignored.
|
|
27
|
+
*/
|
|
28
|
+
function setSubmitError(form, error) {
|
|
29
|
+
if (isNavigationError(error)) return;
|
|
30
|
+
const key = form.store;
|
|
31
|
+
entries.get(key)?.unsubscribe();
|
|
32
|
+
const { submissionAttempts, values } = form.state;
|
|
33
|
+
const subscription = form.store.subscribe(() => {
|
|
34
|
+
const { state } = form;
|
|
35
|
+
if (state.values !== values || state.submissionAttempts !== submissionAttempts) clear(key);
|
|
36
|
+
});
|
|
37
|
+
entries.set(key, {
|
|
38
|
+
submitError: { error },
|
|
39
|
+
unsubscribe: subscription.unsubscribe
|
|
40
|
+
});
|
|
41
|
+
notify(key);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Report a submit rejection that is caught so the user can retry, as
|
|
45
|
+
* `<Form>` and `FormDialog` do: `reportError` logs it and passes it to the
|
|
46
|
+
* page's `error` listeners (where error monitoring hooks in), as if it were
|
|
47
|
+
* uncaught. A Next.js navigation is skipped.
|
|
48
|
+
*/
|
|
49
|
+
function reportSubmitError(error) {
|
|
50
|
+
if (!isNavigationError(error)) globalThis.reportError?.(error);
|
|
51
|
+
}
|
|
52
|
+
/** The error `form`'s last submit threw, until it went stale. */
|
|
53
|
+
function useSubmitError(form) {
|
|
54
|
+
const key = form.store;
|
|
55
|
+
const subscribe = useCallback((listener) => {
|
|
56
|
+
const set = listeners.get(key) ?? /* @__PURE__ */ new Set();
|
|
57
|
+
listeners.set(key, set);
|
|
58
|
+
set.add(listener);
|
|
59
|
+
return () => {
|
|
60
|
+
set.delete(listener);
|
|
61
|
+
};
|
|
62
|
+
}, [key]);
|
|
63
|
+
return useSyncExternalStore(subscribe, () => entries.get(key)?.submitError, () => void 0);
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
export { reportSubmitError, setSubmitError, useSubmitError };
|
|
@@ -47,6 +47,11 @@ declare function useFormDialog<TValues>(options?: UseFormDialogOptions<TValues>)
|
|
|
47
47
|
type UseLeaveGuardOptions = {
|
|
48
48
|
/** Whether leaving now would lose changes. */
|
|
49
49
|
hasUnsavedChanges: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Save the changes and resolve whether that worked. Backs `saveAndLeave`,
|
|
52
|
+
* so the confirm UI can offer Save next to Discard.
|
|
53
|
+
*/
|
|
54
|
+
onSave?: () => boolean | Promise<boolean>;
|
|
50
55
|
};
|
|
51
56
|
type LeaveGuard = {
|
|
52
57
|
/**
|
|
@@ -60,9 +65,18 @@ type LeaveGuard = {
|
|
|
60
65
|
confirmLeave: () => void;
|
|
61
66
|
/** The user chose to stay: drop the held leave. */
|
|
62
67
|
cancelLeave: () => void;
|
|
68
|
+
/**
|
|
69
|
+
* The user chose to save: run `onSave`, then the held leave if it saved.
|
|
70
|
+
* Either way the confirm UI closes; after a failed save the user stays.
|
|
71
|
+
* Resolves whether it saved. A rejection from `onSave` keeps the leave held
|
|
72
|
+
* and propagates.
|
|
73
|
+
*/
|
|
74
|
+
saveAndLeave: () => Promise<boolean>;
|
|
75
|
+
/** Whether `saveAndLeave` is waiting on `onSave`. */
|
|
76
|
+
isSaving: boolean;
|
|
63
77
|
};
|
|
64
78
|
/** Ask before a leave (close, navigate, switch record) drops unsaved changes. */
|
|
65
|
-
declare function useLeaveGuard({ hasUnsavedChanges }: UseLeaveGuardOptions): LeaveGuard;
|
|
79
|
+
declare function useLeaveGuard({ hasUnsavedChanges, onSave }: UseLeaveGuardOptions): LeaveGuard;
|
|
66
80
|
/**
|
|
67
81
|
* Find the element to focus for field `name` inside `root`: the control with
|
|
68
82
|
* `id={name}` (fields that use the name as their id), else, inside the field
|
|
@@ -44,9 +44,10 @@ function useFormDialog(options = {}) {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
/** Ask before a leave (close, navigate, switch record) drops unsaved changes. */
|
|
47
|
-
function useLeaveGuard({ hasUnsavedChanges }) {
|
|
47
|
+
function useLeaveGuard({ hasUnsavedChanges, onSave }) {
|
|
48
48
|
const pending = useRef(null);
|
|
49
49
|
const [isConfirming, setIsConfirming] = useState(false);
|
|
50
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
50
51
|
const requestLeave = useCallback((proceed) => {
|
|
51
52
|
if (!hasUnsavedChanges) {
|
|
52
53
|
proceed();
|
|
@@ -68,7 +69,24 @@ function useLeaveGuard({ hasUnsavedChanges }) {
|
|
|
68
69
|
}, []),
|
|
69
70
|
confirmLeave,
|
|
70
71
|
isConfirming,
|
|
71
|
-
|
|
72
|
+
isSaving,
|
|
73
|
+
requestLeave,
|
|
74
|
+
saveAndLeave: useCallback(async () => {
|
|
75
|
+
const proceed = pending.current;
|
|
76
|
+
if (!(proceed && onSave)) return false;
|
|
77
|
+
setIsSaving(true);
|
|
78
|
+
let saved = false;
|
|
79
|
+
try {
|
|
80
|
+
saved = await onSave();
|
|
81
|
+
} finally {
|
|
82
|
+
setIsSaving(false);
|
|
83
|
+
}
|
|
84
|
+
if (pending.current !== proceed) return saved;
|
|
85
|
+
pending.current = null;
|
|
86
|
+
setIsConfirming(false);
|
|
87
|
+
if (saved) proceed();
|
|
88
|
+
return saved;
|
|
89
|
+
}, [onSave])
|
|
72
90
|
};
|
|
73
91
|
}
|
|
74
92
|
const FOCUSABLE = "input:not([type=\"hidden\"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), button:not([disabled]), [tabindex]:not([tabindex=\"-1\"])";
|
|
@@ -3,5 +3,6 @@ import { AnyFieldApi } from "@tanstack/react-form";
|
|
|
3
3
|
declare function useIsInvalid(field: AnyFieldApi): boolean;
|
|
4
4
|
declare function useHideFieldErrors(field: AnyFieldApi): boolean;
|
|
5
5
|
declare function useIsFieldRequired(field: AnyFieldApi): boolean;
|
|
6
|
+
declare function useIsFieldRequiredBySchema(field: AnyFieldApi): boolean;
|
|
6
7
|
//#endregion
|
|
7
|
-
export { useHideFieldErrors, useIsFieldRequired, useIsInvalid };
|
|
8
|
+
export { useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { getFormHideFieldErrors, getFormValidationMode, isFieldRequired } from "./validation-modes.mjs";
|
|
2
|
+
import { getFormHideFieldErrors, getFormValidationMode, isFieldRequired, isFieldRequiredBySchema } from "./validation-modes.mjs";
|
|
3
3
|
import { useSelector } from "@tanstack/react-form";
|
|
4
4
|
//#region src/lib/use-is-invalid.ts
|
|
5
5
|
function hasFieldLevelError(errorMap, errorSourceMap, cause) {
|
|
@@ -27,5 +27,8 @@ function useHideFieldErrors(field) {
|
|
|
27
27
|
function useIsFieldRequired(field) {
|
|
28
28
|
return isFieldRequired(field.form, field.name);
|
|
29
29
|
}
|
|
30
|
+
function useIsFieldRequiredBySchema(field) {
|
|
31
|
+
return isFieldRequiredBySchema(field.form, field.name);
|
|
32
|
+
}
|
|
30
33
|
//#endregion
|
|
31
|
-
export { useHideFieldErrors, useIsFieldRequired, useIsInvalid };
|
|
34
|
+
export { useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid };
|