@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.
Files changed (45) hide show
  1. package/dist/create-zeno-form.mjs +5 -3
  2. package/dist/index.d.mts +3 -2
  3. package/dist/index.mjs +4 -3
  4. package/dist/lib/empty-value.d.mts +12 -0
  5. package/dist/lib/empty-value.mjs +68 -0
  6. package/dist/lib/schema-defaults.d.mts +9 -1
  7. package/dist/lib/schema-defaults.mjs +33 -1
  8. package/dist/lib/schema-required.d.mts +2 -1
  9. package/dist/lib/schema-required.mjs +67 -18
  10. package/dist/lib/submit-action.mjs +1 -1
  11. package/dist/lib/use-form-dialog.d.mts +3 -2
  12. package/dist/lib/use-form-dialog.mjs +7 -3
  13. package/dist/lib/use-is-invalid.d.mts +2 -1
  14. package/dist/lib/use-is-invalid.mjs +5 -2
  15. package/dist/lib/validation-modes.d.mts +5 -1
  16. package/dist/lib/validation-modes.mjs +9 -4
  17. package/package.json +5 -3
  18. package/src/create-zeno-form.tsx +13 -5
  19. package/src/fields/checkbox-field.tsx +14 -6
  20. package/src/fields/checkbox-group-field.tsx +8 -7
  21. package/src/fields/combobox-field.tsx +35 -17
  22. package/src/fields/date-picker-field.tsx +42 -13
  23. package/src/fields/email-field.tsx +8 -0
  24. package/src/fields/input-field.tsx +12 -7
  25. package/src/fields/money-field.tsx +3 -1
  26. package/src/fields/multi-select-field.tsx +14 -6
  27. package/src/fields/number-field.tsx +9 -7
  28. package/src/fields/otp-field.tsx +14 -6
  29. package/src/fields/percentage-field.tsx +3 -1
  30. package/src/fields/radio-group-field.tsx +14 -6
  31. package/src/fields/select-field.tsx +31 -7
  32. package/src/fields/slider-field.tsx +6 -5
  33. package/src/fields/switch-field.tsx +14 -6
  34. package/src/fields/textarea-field.tsx +14 -6
  35. package/src/fields/year-field.tsx +3 -1
  36. package/src/index.ts +2 -0
  37. package/src/lib/empty-value.test.ts +72 -0
  38. package/src/lib/empty-value.ts +130 -0
  39. package/src/lib/schema-defaults.ts +50 -1
  40. package/src/lib/schema-required.test.ts +45 -3
  41. package/src/lib/schema-required.ts +113 -29
  42. package/src/lib/use-form-dialog.ts +11 -3
  43. package/src/lib/use-is-invalid.ts +14 -1
  44. package/src/lib/validation-modes.ts +18 -6
  45. 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 && requiredIndicator ? getRequiredPaths(schema) : /* @__PURE__ */ new Set(), [schema, requiredIndicator]);
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
- 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 };
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
- export { Form, FormProvider, ValidationError, applyActionError, applyValidationError, blurThenChangeLogic, createZenoForm, submitAction, toActionError, toFieldName, useFieldContext, useFormContext, useHideFieldErrors, useIsFieldRequired, useIsInvalid };
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 = 8;
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 probeContainer(expected) {
26
- if (typeof expected !== "string") return;
27
- const kind = expected.toLowerCase();
28
- if (kind === "object") return {};
29
- return kind === "array" ? [{}] : void 0;
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
- let descended = false;
60
- for (const issue of issues) {
61
- const keys = issue.path?.map(pathKey) ?? [];
62
- if (keys.length === 0) continue;
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}` (what the shipped fields render), else the first focusable
69
- * element inside the field root marked `data-field={name}`.
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}` (what the shipped fields render), else the first focusable
81
- * element inside the field root marked `data-field={name}`.
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
- return root.querySelector(attributeSelector("data-field", name))?.querySelector(FOCUSABLE) ?? null;
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
- const state = getFormZenoState(form);
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.2.2",
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": {
@@ -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 { getRequiredPaths } from "./lib/schema-required"
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 && requiredIndicator
460
+ schema
457
461
  ? getRequiredPaths(schema as Parameters<typeof getRequiredPaths>[0])
458
462
  : new Set<string>(),
459
- [schema, requiredIndicator]
463
+ [schema]
460
464
  )
461
465
 
462
466
  const schemaDefaults = useMemo(
463
467
  () =>
464
468
  schema
465
- ? extractZodDefaults(
466
- schema as Parameters<typeof extractZodDefaults>[0]
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 { ComponentProps, ReactNode } from "react"
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
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
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 errorId = `${field.name}-error`
39
- const descriptionId = `${field.name}-description`
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={field.name}
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={field.name}>
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 { ReactNode } from "react"
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 errorId = `${field.name}-error`
68
- const descriptionId = `${field.name}-description`
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 id = `${field.name}-${index}`
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={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={id}>
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={id}>
145
+ <FieldLabel className="w-auto font-normal" htmlFor={optionId}>
145
146
  {option.label}
146
147
  </FieldLabel>
147
148
  )}