@zeno-lib/forms 0.2.2 → 0.4.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 +5 -3
- package/dist/index.d.mts +3 -2
- package/dist/index.mjs +4 -3
- package/dist/lib/empty-value.d.mts +12 -0
- package/dist/lib/empty-value.mjs +68 -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 +67 -18
- package/dist/lib/submit-action.mjs +1 -1
- package/dist/lib/use-form-dialog.d.mts +3 -2
- package/dist/lib/use-form-dialog.mjs +7 -3
- 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 +5 -3
- package/src/create-zeno-form.tsx +13 -5
- package/src/fields/checkbox-field.tsx +14 -6
- package/src/fields/checkbox-group-field.tsx +8 -7
- package/src/fields/combobox-field.tsx +35 -17
- package/src/fields/date-picker-field.tsx +42 -13
- package/src/fields/email-field.tsx +8 -0
- package/src/fields/input-field.tsx +12 -7
- package/src/fields/money-field.tsx +3 -1
- package/src/fields/multi-select-field.tsx +14 -6
- package/src/fields/number-field.tsx +9 -7
- package/src/fields/otp-field.tsx +14 -6
- package/src/fields/percentage-field.tsx +3 -1
- package/src/fields/radio-group-field.tsx +14 -6
- package/src/fields/select-field.tsx +31 -7
- package/src/fields/slider-field.tsx +6 -5
- package/src/fields/switch-field.tsx +14 -6
- package/src/fields/textarea-field.tsx +14 -6
- package/src/fields/year-field.tsx +3 -1
- package/src/index.ts +2 -0
- package/src/lib/empty-value.test.ts +72 -0
- package/src/lib/empty-value.ts +130 -0
- package/src/lib/schema-defaults.ts +50 -1
- package/src/lib/schema-required.test.ts +45 -3
- package/src/lib/schema-required.ts +113 -29
- package/src/lib/use-form-dialog.ts +11 -3
- package/src/lib/use-is-invalid.ts +14 -1
- package/src/lib/validation-modes.ts +18 -6
- package/src/use-app-fields.test.tsx +341 -4
|
@@ -3,11 +3,12 @@ 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";
|
|
6
8
|
import { useRebasedDefaultValues } from "./lib/use-rebased-default-values.mjs";
|
|
7
9
|
import { useUnsavedChangesWarning } from "./lib/use-unsaved-changes-warning.mjs";
|
|
8
10
|
import { ValidationError } from "./lib/validation-error.mjs";
|
|
9
11
|
import { blurThenChangeLogic } from "./lib/validation-logic.mjs";
|
|
10
|
-
import { DEFAULT_VALIDATION_MODE, setFormZenoState } from "./lib/validation-modes.mjs";
|
|
11
12
|
import { createFormHook } from "@tanstack/react-form";
|
|
12
13
|
import { useMemo } from "react";
|
|
13
14
|
import { jsx } from "react/jsx-runtime";
|
|
@@ -86,8 +87,8 @@ function createZenoForm(config) {
|
|
|
86
87
|
throw error;
|
|
87
88
|
}
|
|
88
89
|
}) : void 0;
|
|
89
|
-
const requiredFields = useMemo(() => schema
|
|
90
|
-
const schemaDefaults = useMemo(() => schema ? extractZodDefaults(schema) : void 0, [schema]);
|
|
90
|
+
const requiredFields = useMemo(() => schema ? getRequiredPaths(schema) : /* @__PURE__ */ new Set(), [schema]);
|
|
91
|
+
const schemaDefaults = useMemo(() => schema ? withEmptyStartValues(schema, extractZodDefaults(schema)) : void 0, [schema]);
|
|
91
92
|
const mergedDefaultValues = useMemo(() => schemaDefaults ? deepMergeDefaults(schemaDefaults, userDefaultValues) : userDefaultValues, [schemaDefaults, userDefaultValues]);
|
|
92
93
|
const rebased = useRebasedDefaultValues(mergedDefaultValues);
|
|
93
94
|
const listeners = withMountListener(rest.listeners, rebased.onMount);
|
|
@@ -103,6 +104,7 @@ function createZenoForm(config) {
|
|
|
103
104
|
hideFieldErrors,
|
|
104
105
|
requiredFields,
|
|
105
106
|
requiredIndicator,
|
|
107
|
+
schema,
|
|
106
108
|
...schemaMode === void 0 ? {} : { validation: schemaMode }
|
|
107
109
|
});
|
|
108
110
|
useUnsavedChangesWarning(form, unsavedChangesWarning);
|
package/dist/index.d.mts
CHANGED
|
@@ -6,6 +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, useIsFieldRequiredBySchema, useIsInvalid, useSelector };
|
package/dist/index.mjs
CHANGED
|
@@ -1,10 +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";
|
|
10
|
-
|
|
9
|
+
import { useHideFieldErrors, useIsFieldRequired, useIsFieldRequiredBySchema, useIsInvalid } from "./lib/use-is-invalid.mjs";
|
|
10
|
+
import { useSelector } from "@tanstack/react-form";
|
|
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 };
|
|
@@ -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) {
|
|
@@ -22,13 +22,15 @@ function joinPath(keys) {
|
|
|
22
22
|
function toRequiredPathKey(name) {
|
|
23
23
|
return name.replace(INDEX_IN_NAME, "[0]");
|
|
24
24
|
}
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
25
|
+
function probeValue(expected) {
|
|
26
|
+
switch (typeof expected === "string" ? expected.toLowerCase() : void 0) {
|
|
27
|
+
case "object": return {};
|
|
28
|
+
case "array": return [];
|
|
29
|
+
case "string": return "";
|
|
30
|
+
default: return null;
|
|
31
|
+
}
|
|
30
32
|
}
|
|
31
|
-
function fillAt(root, keys, value) {
|
|
33
|
+
function fillAt(root, keys, value, replace = false) {
|
|
32
34
|
let node = root;
|
|
33
35
|
for (const key of keys.slice(0, -1)) {
|
|
34
36
|
const next = node[key];
|
|
@@ -36,7 +38,7 @@ function fillAt(root, keys, value) {
|
|
|
36
38
|
node = next;
|
|
37
39
|
}
|
|
38
40
|
const last = keys.at(-1);
|
|
39
|
-
if (node[last] !== void 0) return false;
|
|
41
|
+
if (!replace && node[last] !== void 0) return false;
|
|
40
42
|
node[last] = value;
|
|
41
43
|
return true;
|
|
42
44
|
}
|
|
@@ -50,23 +52,70 @@ function validateSync(schema, value) {
|
|
|
50
52
|
if (result instanceof Promise) return;
|
|
51
53
|
return "issues" in result && result.issues ? result.issues : [];
|
|
52
54
|
}
|
|
55
|
+
function recordPass(issues, probe, required, cleared) {
|
|
56
|
+
const failing = /* @__PURE__ */ new Set();
|
|
57
|
+
const filled = [];
|
|
58
|
+
for (const issue of issues) {
|
|
59
|
+
const keys = issue.path?.map(pathKey) ?? [];
|
|
60
|
+
if (keys.length === 0) continue;
|
|
61
|
+
const path = joinPath(keys);
|
|
62
|
+
failing.add(path);
|
|
63
|
+
if (!cleared.has(path)) required.add(path);
|
|
64
|
+
const value = probeValue(issue.expected);
|
|
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
|
+
});
|
|
97
|
+
descended = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
descended,
|
|
102
|
+
next
|
|
103
|
+
};
|
|
104
|
+
}
|
|
53
105
|
function getRequiredPaths(schema) {
|
|
54
106
|
const required = /* @__PURE__ */ new Set();
|
|
107
|
+
const cleared = /* @__PURE__ */ new Set();
|
|
55
108
|
const probe = {};
|
|
109
|
+
let emptied = [];
|
|
56
110
|
for (let depth = 0; depth < MAX_PROBE_DEPTH; depth += 1) {
|
|
57
111
|
const issues = validateSync(schema, probe);
|
|
58
112
|
if (!issues) return required;
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
required.add(joinPath(keys));
|
|
64
|
-
const container = probeContainer(issue.expected);
|
|
65
|
-
if (container !== void 0 && fillAt(probe, keys, container)) descended = true;
|
|
66
|
-
}
|
|
67
|
-
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;
|
|
68
117
|
}
|
|
69
118
|
return required;
|
|
70
119
|
}
|
|
71
120
|
//#endregion
|
|
72
|
-
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 = {};
|
|
@@ -65,8 +65,9 @@ type LeaveGuard = {
|
|
|
65
65
|
declare function useLeaveGuard({ hasUnsavedChanges }: UseLeaveGuardOptions): LeaveGuard;
|
|
66
66
|
/**
|
|
67
67
|
* Find the element to focus for field `name` inside `root`: the control with
|
|
68
|
-
* `id={name}` (
|
|
69
|
-
*
|
|
68
|
+
* `id={name}` (fields that use the name as their id), else, inside the field
|
|
69
|
+
* root marked `data-field={name}`, the control its label points at (the shipped
|
|
70
|
+
* fields' ids come from `useId`), else that root's first focusable element.
|
|
70
71
|
*/
|
|
71
72
|
declare function findFieldElement(root: ParentNode | null | undefined, name: string): HTMLElement | null;
|
|
72
73
|
//#endregion
|
|
@@ -77,14 +77,18 @@ function attributeSelector(attribute, value) {
|
|
|
77
77
|
}
|
|
78
78
|
/**
|
|
79
79
|
* Find the element to focus for field `name` inside `root`: the control with
|
|
80
|
-
* `id={name}` (
|
|
81
|
-
*
|
|
80
|
+
* `id={name}` (fields that use the name as their id), else, inside the field
|
|
81
|
+
* root marked `data-field={name}`, the control its label points at (the shipped
|
|
82
|
+
* fields' ids come from `useId`), else that root's first focusable element.
|
|
82
83
|
*/
|
|
83
84
|
function findFieldElement(root, name) {
|
|
84
85
|
if (!root) return null;
|
|
85
86
|
const byId = root.querySelector(attributeSelector("id", name));
|
|
86
87
|
if (byId) return byId;
|
|
87
|
-
|
|
88
|
+
const fieldRoot = root.querySelector(attributeSelector("data-field", name));
|
|
89
|
+
if (!fieldRoot) return null;
|
|
90
|
+
const labelFor = fieldRoot.querySelector("label[for]")?.getAttribute("for");
|
|
91
|
+
return (labelFor ? fieldRoot.querySelector(attributeSelector("id", labelFor)) : null) ?? fieldRoot.querySelector(FOCUSABLE);
|
|
88
92
|
}
|
|
89
93
|
//#endregion
|
|
90
94
|
export { findFieldElement, useFormDialog, useLeaveGuard };
|
|
@@ -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 };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { StandardSchemaLike } from "./schema-required.mjs";
|
|
1
2
|
import { AnyFormApi } from "@tanstack/react-form";
|
|
2
3
|
//#region src/lib/validation-modes.d.ts
|
|
3
4
|
type ValidationMode = "change" | "blur" | "submit" | "blur-then-change";
|
|
@@ -7,10 +8,13 @@ type ZenoFormState = {
|
|
|
7
8
|
hideFieldErrors: boolean;
|
|
8
9
|
requiredIndicator: boolean;
|
|
9
10
|
requiredFields: ReadonlySet<string>;
|
|
11
|
+
schema: StandardSchemaLike | undefined;
|
|
10
12
|
};
|
|
11
13
|
declare function setFormZenoState(form: AnyFormApi, state: Partial<ZenoFormState>): void;
|
|
12
14
|
declare function getFormValidationMode(form: AnyFormApi): ValidationMode;
|
|
13
15
|
declare function getFormHideFieldErrors(form: AnyFormApi): boolean;
|
|
16
|
+
declare function getFormSchema(form: AnyFormApi): StandardSchemaLike | undefined;
|
|
17
|
+
declare function isFieldRequiredBySchema(form: AnyFormApi, name: string): boolean;
|
|
14
18
|
declare function isFieldRequired(form: AnyFormApi, name: string): boolean;
|
|
15
19
|
//#endregion
|
|
16
|
-
export { DEFAULT_VALIDATION_MODE, type ValidationMode, getFormHideFieldErrors, getFormValidationMode, isFieldRequired, setFormZenoState };
|
|
20
|
+
export { DEFAULT_VALIDATION_MODE, type ValidationMode, getFormHideFieldErrors, getFormSchema, getFormValidationMode, isFieldRequired, isFieldRequiredBySchema, setFormZenoState };
|
|
@@ -6,6 +6,7 @@ const DEFAULT_FORM_STATE = {
|
|
|
6
6
|
hideFieldErrors: false,
|
|
7
7
|
requiredFields: /* @__PURE__ */ new Set(),
|
|
8
8
|
requiredIndicator: true,
|
|
9
|
+
schema: void 0,
|
|
9
10
|
validation: DEFAULT_VALIDATION_MODE
|
|
10
11
|
};
|
|
11
12
|
const formZenoState = /* @__PURE__ */ new WeakMap();
|
|
@@ -29,10 +30,14 @@ function getFormValidationMode(form) {
|
|
|
29
30
|
function getFormHideFieldErrors(form) {
|
|
30
31
|
return getFormZenoState(form).hideFieldErrors;
|
|
31
32
|
}
|
|
33
|
+
function getFormSchema(form) {
|
|
34
|
+
return getFormZenoState(form).schema;
|
|
35
|
+
}
|
|
36
|
+
function isFieldRequiredBySchema(form, name) {
|
|
37
|
+
return getFormZenoState(form).requiredFields.has(toRequiredPathKey(name));
|
|
38
|
+
}
|
|
32
39
|
function isFieldRequired(form, name) {
|
|
33
|
-
|
|
34
|
-
if (!state.requiredIndicator) return false;
|
|
35
|
-
return state.requiredFields.has(toRequiredPathKey(name));
|
|
40
|
+
return getFormZenoState(form).requiredIndicator && isFieldRequiredBySchema(form, name);
|
|
36
41
|
}
|
|
37
42
|
//#endregion
|
|
38
|
-
export { DEFAULT_VALIDATION_MODE, getFormHideFieldErrors, getFormValidationMode, isFieldRequired, setFormZenoState };
|
|
43
|
+
export { DEFAULT_VALIDATION_MODE, getFormHideFieldErrors, getFormSchema, getFormValidationMode, isFieldRequired, isFieldRequiredBySchema, setFormZenoState };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zeno-lib/forms",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"url": "https://github.com/zeno-lib/zeno.git",
|
|
6
6
|
"directory": "packages/forms"
|
|
@@ -30,8 +30,7 @@
|
|
|
30
30
|
"src"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@tanstack/react-form": "1.33.5"
|
|
34
|
-
"@tanstack/react-form-nextjs": "1.33.5"
|
|
33
|
+
"@tanstack/react-form": "1.33.5"
|
|
35
34
|
},
|
|
36
35
|
"devDependencies": {
|
|
37
36
|
"@types/node": "26.6.4",
|
|
@@ -61,6 +60,9 @@
|
|
|
61
60
|
},
|
|
62
61
|
"@tanstack/react-form-devtools": {
|
|
63
62
|
"optional": true
|
|
63
|
+
},
|
|
64
|
+
"zod": {
|
|
65
|
+
"optional": true
|
|
64
66
|
}
|
|
65
67
|
},
|
|
66
68
|
"publishConfig": {
|
package/src/create-zeno-form.tsx
CHANGED
|
@@ -12,8 +12,12 @@ import { createFormHook } from "@tanstack/react-form"
|
|
|
12
12
|
import { type ComponentProps, type ReactNode, useMemo } from "react"
|
|
13
13
|
import { applyValidationError } from "./lib/apply-validation-error"
|
|
14
14
|
import { fieldContext, formContext } from "./lib/contexts"
|
|
15
|
+
import { withEmptyStartValues } from "./lib/empty-value"
|
|
15
16
|
import { deepMergeDefaults, extractZodDefaults } from "./lib/schema-defaults"
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
getRequiredPaths,
|
|
19
|
+
type StandardSchemaLike,
|
|
20
|
+
} from "./lib/schema-required"
|
|
17
21
|
import { useRebasedDefaultValues } from "./lib/use-rebased-default-values"
|
|
18
22
|
import { useUnsavedChangesWarning } from "./lib/use-unsaved-changes-warning"
|
|
19
23
|
import { ValidationError } from "./lib/validation-error"
|
|
@@ -453,17 +457,20 @@ export function createZenoForm<
|
|
|
453
457
|
|
|
454
458
|
const requiredFields = useMemo(
|
|
455
459
|
() =>
|
|
456
|
-
schema
|
|
460
|
+
schema
|
|
457
461
|
? getRequiredPaths(schema as Parameters<typeof getRequiredPaths>[0])
|
|
458
462
|
: new Set<string>(),
|
|
459
|
-
[schema
|
|
463
|
+
[schema]
|
|
460
464
|
)
|
|
461
465
|
|
|
462
466
|
const schemaDefaults = useMemo(
|
|
463
467
|
() =>
|
|
464
468
|
schema
|
|
465
|
-
?
|
|
466
|
-
schema as
|
|
469
|
+
? withEmptyStartValues(
|
|
470
|
+
schema as StandardSchemaLike,
|
|
471
|
+
extractZodDefaults(
|
|
472
|
+
schema as Parameters<typeof extractZodDefaults>[0]
|
|
473
|
+
)
|
|
467
474
|
)
|
|
468
475
|
: undefined,
|
|
469
476
|
[schema]
|
|
@@ -515,6 +522,7 @@ export function createZenoForm<
|
|
|
515
522
|
hideFieldErrors,
|
|
516
523
|
requiredFields,
|
|
517
524
|
requiredIndicator,
|
|
525
|
+
schema: schema as StandardSchemaLike | undefined,
|
|
518
526
|
...(schemaMode === undefined ? {} : { validation: schemaMode }),
|
|
519
527
|
})
|
|
520
528
|
|
|
@@ -5,9 +5,10 @@ import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
|
5
5
|
import {
|
|
6
6
|
useHideFieldErrors,
|
|
7
7
|
useIsFieldRequired,
|
|
8
|
+
useIsFieldRequiredBySchema,
|
|
8
9
|
useIsInvalid,
|
|
9
10
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import type
|
|
11
|
+
import { type ComponentProps, type ReactNode, useId } from "react"
|
|
11
12
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
12
13
|
import {
|
|
13
14
|
Field,
|
|
@@ -24,7 +25,10 @@ type CheckboxFieldProps = Omit<
|
|
|
24
25
|
> & {
|
|
25
26
|
description?: ReactNode
|
|
26
27
|
label?: ReactNode
|
|
27
|
-
/**
|
|
28
|
+
/**
|
|
29
|
+
* Mark the field required, or not, over the schema. Drives the `*` and
|
|
30
|
+
* `aria-required`.
|
|
31
|
+
*/
|
|
28
32
|
required?: boolean
|
|
29
33
|
}
|
|
30
34
|
|
|
@@ -35,13 +39,16 @@ function CheckboxField({
|
|
|
35
39
|
...props
|
|
36
40
|
}: CheckboxFieldProps) {
|
|
37
41
|
const field = useFieldContext<boolean>()
|
|
38
|
-
const
|
|
39
|
-
const
|
|
42
|
+
const id = useId()
|
|
43
|
+
const errorId = `${id}-error`
|
|
44
|
+
const descriptionId = `${id}-description`
|
|
40
45
|
const isInvalid = useIsInvalid(field)
|
|
41
46
|
const hideErrors = useHideFieldErrors(field)
|
|
42
47
|
const showError = isInvalid && !hideErrors
|
|
43
48
|
const schemaRequired = useIsFieldRequired(field)
|
|
44
49
|
const isRequired = required ?? schemaRequired
|
|
50
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
51
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
45
52
|
|
|
46
53
|
return (
|
|
47
54
|
<Field
|
|
@@ -55,8 +62,9 @@ function CheckboxField({
|
|
|
55
62
|
[showError, errorId]
|
|
56
63
|
)}
|
|
57
64
|
aria-invalid={isInvalid || undefined}
|
|
65
|
+
aria-required={requiresValue || undefined}
|
|
58
66
|
checked={field.state.value ?? false}
|
|
59
|
-
id={
|
|
67
|
+
id={id}
|
|
60
68
|
name={field.name}
|
|
61
69
|
onBlur={field.handleBlur}
|
|
62
70
|
onCheckedChange={(value) => field.handleChange(value === true)}
|
|
@@ -64,7 +72,7 @@ function CheckboxField({
|
|
|
64
72
|
/>
|
|
65
73
|
<FieldContent>
|
|
66
74
|
{label && (
|
|
67
|
-
<FieldLabel className="font-normal" htmlFor={
|
|
75
|
+
<FieldLabel className="font-normal" htmlFor={id}>
|
|
68
76
|
{label}
|
|
69
77
|
{isRequired && <RequiredIndicator />}
|
|
70
78
|
</FieldLabel>
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
useIsFieldRequired,
|
|
8
8
|
useIsInvalid,
|
|
9
9
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import type
|
|
10
|
+
import { type ReactNode, useId } from "react"
|
|
11
11
|
import { Checkbox } from "@/components/ui/checkbox"
|
|
12
12
|
import {
|
|
13
13
|
Field,
|
|
@@ -64,8 +64,9 @@ function CheckboxGroupField<V = string>({
|
|
|
64
64
|
required,
|
|
65
65
|
}: CheckboxGroupFieldProps<V>) {
|
|
66
66
|
const field = useFieldContext<V[] | null | undefined>()
|
|
67
|
-
const
|
|
68
|
-
const
|
|
67
|
+
const id = useId()
|
|
68
|
+
const errorId = `${id}-error`
|
|
69
|
+
const descriptionId = `${id}-description`
|
|
69
70
|
const isInvalid = useIsInvalid(field)
|
|
70
71
|
const hideErrors = useHideFieldErrors(field)
|
|
71
72
|
const showError = isInvalid && !hideErrors
|
|
@@ -116,7 +117,7 @@ function CheckboxGroupField<V = string>({
|
|
|
116
117
|
data-slot="checkbox-group"
|
|
117
118
|
>
|
|
118
119
|
{options.map((option, index) => {
|
|
119
|
-
const
|
|
120
|
+
const optionId = `${id}-${index}`
|
|
120
121
|
return (
|
|
121
122
|
<Field
|
|
122
123
|
data-invalid={isInvalid}
|
|
@@ -127,7 +128,7 @@ function CheckboxGroupField<V = string>({
|
|
|
127
128
|
aria-invalid={isInvalid || undefined}
|
|
128
129
|
checked={checked.includes(option.value)}
|
|
129
130
|
disabled={disabled || option.disabled}
|
|
130
|
-
id={
|
|
131
|
+
id={optionId}
|
|
131
132
|
name={field.name}
|
|
132
133
|
onBlur={field.handleBlur}
|
|
133
134
|
onCheckedChange={(on) => toggle(option.value, on === true)}
|
|
@@ -135,13 +136,13 @@ function CheckboxGroupField<V = string>({
|
|
|
135
136
|
/>
|
|
136
137
|
{option.description ? (
|
|
137
138
|
<FieldContent>
|
|
138
|
-
<FieldLabel className="font-normal" htmlFor={
|
|
139
|
+
<FieldLabel className="font-normal" htmlFor={optionId}>
|
|
139
140
|
{option.label}
|
|
140
141
|
</FieldLabel>
|
|
141
142
|
<FieldDescription>{option.description}</FieldDescription>
|
|
142
143
|
</FieldContent>
|
|
143
144
|
) : (
|
|
144
|
-
<FieldLabel className="w-auto font-normal" htmlFor={
|
|
145
|
+
<FieldLabel className="w-auto font-normal" htmlFor={optionId}>
|
|
145
146
|
{option.label}
|
|
146
147
|
</FieldLabel>
|
|
147
148
|
)}
|