@zeno-lib/forms 0.3.0 → 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 +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/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/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-zeno-form.tsx +13 -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/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/index.ts +1 -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 +24 -2
- package/src/lib/schema-required.ts +81 -43
- 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
|
@@ -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,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 };
|
|
@@ -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 = {};
|
|
@@ -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
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,6 +5,7 @@ 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
11
|
import { type ComponentProps, type ReactNode, useId } from "react"
|
|
@@ -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
|
|
|
@@ -43,6 +47,8 @@ function CheckboxField({
|
|
|
43
47
|
const showError = isInvalid && !hideErrors
|
|
44
48
|
const schemaRequired = useIsFieldRequired(field)
|
|
45
49
|
const isRequired = required ?? schemaRequired
|
|
50
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
51
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
46
52
|
|
|
47
53
|
return (
|
|
48
54
|
<Field
|
|
@@ -56,6 +62,7 @@ function CheckboxField({
|
|
|
56
62
|
[showError, errorId]
|
|
57
63
|
)}
|
|
58
64
|
aria-invalid={isInvalid || undefined}
|
|
65
|
+
aria-required={requiresValue || undefined}
|
|
59
66
|
checked={field.state.value ?? false}
|
|
60
67
|
id={id}
|
|
61
68
|
name={field.name}
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
import { describedBy } from "@zeno-lib/forms/lib/aria"
|
|
4
4
|
import { useFieldContext } from "@zeno-lib/forms/lib/contexts"
|
|
5
|
+
import { getEmptyValue } from "@zeno-lib/forms/lib/empty-value"
|
|
5
6
|
import {
|
|
6
7
|
useHideFieldErrors,
|
|
7
8
|
useIsFieldRequired,
|
|
9
|
+
useIsFieldRequiredBySchema,
|
|
8
10
|
useIsInvalid,
|
|
9
11
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
12
|
import { type ReactNode, useId } from "react"
|
|
@@ -43,13 +45,17 @@ type ComboboxFieldProps<T = string> = {
|
|
|
43
45
|
/** Override per-row rendering. Default: plain `<ComboboxItem>{label}</ComboboxItem>`. */
|
|
44
46
|
renderItem?: (item: T) => ReactNode
|
|
45
47
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
48
|
+
* Show the clear button when a value is selected. Defaults to `true` on an
|
|
49
|
+
* optional field and `false` on a required one (from the schema or
|
|
50
|
+
* `required`), where clearing could only make the form invalid.
|
|
49
51
|
*/
|
|
50
52
|
showClear?: boolean
|
|
53
|
+
disabled?: boolean
|
|
51
54
|
className?: string
|
|
52
|
-
/**
|
|
55
|
+
/**
|
|
56
|
+
* Mark the field required, or not, over the schema. Drives the `*`,
|
|
57
|
+
* `aria-required` and the `showClear` default.
|
|
58
|
+
*/
|
|
53
59
|
required?: boolean
|
|
54
60
|
/**
|
|
55
61
|
* Controlled input text — pair with `onInputValueChange` for server-side
|
|
@@ -82,6 +88,7 @@ function isItemObject(item: unknown): item is ComboboxItemObject<unknown> {
|
|
|
82
88
|
function ComboboxField<T = string>({
|
|
83
89
|
className,
|
|
84
90
|
description,
|
|
91
|
+
disabled,
|
|
85
92
|
emptyMessage = "No results.",
|
|
86
93
|
filter,
|
|
87
94
|
inputValue,
|
|
@@ -92,7 +99,7 @@ function ComboboxField<T = string>({
|
|
|
92
99
|
placeholder,
|
|
93
100
|
renderItem,
|
|
94
101
|
required,
|
|
95
|
-
showClear
|
|
102
|
+
showClear,
|
|
96
103
|
}: ComboboxFieldProps<T>) {
|
|
97
104
|
const field = useFieldContext()
|
|
98
105
|
const id = useId()
|
|
@@ -103,13 +110,20 @@ function ComboboxField<T = string>({
|
|
|
103
110
|
const showError = isInvalid && !hideErrors
|
|
104
111
|
const schemaRequired = useIsFieldRequired(field)
|
|
105
112
|
const isRequired = required ?? schemaRequired
|
|
113
|
+
const schemaRequiresValue = useIsFieldRequiredBySchema(field)
|
|
114
|
+
const requiresValue = required ?? schemaRequiresValue
|
|
115
|
+
const canClear = showClear ?? !requiresValue
|
|
106
116
|
|
|
107
117
|
const fieldValue = field.state.value
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
118
|
+
// Base UI shows the value it gets as text, so a stored id whose item isn't
|
|
119
|
+
// in `items` (options still loading, or a search that dropped it) would
|
|
120
|
+
// show raw. Only a plain string or number item is its own label.
|
|
121
|
+
const match = items.find(
|
|
122
|
+
(item) => (isItemObject(item) ? item.value : item) === fieldValue
|
|
123
|
+
)
|
|
124
|
+
const plainItems = items.length > 0 && !items.some(isItemObject)
|
|
125
|
+
const value = (match ??
|
|
126
|
+
(plainItems ? (fieldValue ?? null) : null)) as T | null
|
|
113
127
|
|
|
114
128
|
return (
|
|
115
129
|
<Field data-field={field.name} data-invalid={isInvalid}>
|
|
@@ -120,13 +134,14 @@ function ComboboxField<T = string>({
|
|
|
120
134
|
</FieldLabel>
|
|
121
135
|
)}
|
|
122
136
|
<Combobox
|
|
137
|
+
disabled={disabled}
|
|
123
138
|
filter={filter}
|
|
124
139
|
inputValue={inputValue}
|
|
125
140
|
items={items}
|
|
126
141
|
onInputValueChange={onInputValueChange}
|
|
127
142
|
onValueChange={(next) => {
|
|
128
143
|
if (next === null) {
|
|
129
|
-
field.handleChange(undefined)
|
|
144
|
+
field.handleChange(getEmptyValue(field, undefined))
|
|
130
145
|
return
|
|
131
146
|
}
|
|
132
147
|
field.handleChange(isItemObject(next) ? next.value : next)
|
|
@@ -139,12 +154,14 @@ function ComboboxField<T = string>({
|
|
|
139
154
|
[showError, errorId]
|
|
140
155
|
)}
|
|
141
156
|
aria-invalid={isInvalid || undefined}
|
|
157
|
+
aria-required={requiresValue || undefined}
|
|
142
158
|
className={className}
|
|
159
|
+
disabled={disabled}
|
|
143
160
|
id={id}
|
|
144
161
|
name={field.name}
|
|
145
162
|
onBlur={field.handleBlur}
|
|
146
163
|
placeholder={placeholder}
|
|
147
|
-
showClear={
|
|
164
|
+
showClear={canClear && value !== null && value !== "" && !loading}
|
|
148
165
|
>
|
|
149
166
|
{loading && (
|
|
150
167
|
<InputGroupAddon align="inline-end">
|