@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.
Files changed (40) hide show
  1. package/dist/create-zeno-form.mjs +5 -3
  2. package/dist/index.d.mts +2 -2
  3. package/dist/index.mjs +3 -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 +49 -21
  10. package/dist/lib/submit-action.mjs +1 -1
  11. package/dist/lib/use-is-invalid.d.mts +2 -1
  12. package/dist/lib/use-is-invalid.mjs +5 -2
  13. package/dist/lib/validation-modes.d.mts +5 -1
  14. package/dist/lib/validation-modes.mjs +9 -4
  15. package/package.json +1 -1
  16. package/src/create-zeno-form.tsx +13 -5
  17. package/src/fields/checkbox-field.tsx +8 -1
  18. package/src/fields/combobox-field.tsx +29 -12
  19. package/src/fields/date-picker-field.tsx +37 -9
  20. package/src/fields/email-field.tsx +8 -0
  21. package/src/fields/input-field.tsx +6 -2
  22. package/src/fields/money-field.tsx +3 -1
  23. package/src/fields/multi-select-field.tsx +8 -1
  24. package/src/fields/number-field.tsx +9 -7
  25. package/src/fields/otp-field.tsx +8 -1
  26. package/src/fields/percentage-field.tsx +3 -1
  27. package/src/fields/radio-group-field.tsx +11 -4
  28. package/src/fields/select-field.tsx +26 -3
  29. package/src/fields/switch-field.tsx +8 -1
  30. package/src/fields/textarea-field.tsx +8 -1
  31. package/src/fields/year-field.tsx +3 -1
  32. package/src/index.ts +1 -0
  33. package/src/lib/empty-value.test.ts +72 -0
  34. package/src/lib/empty-value.ts +130 -0
  35. package/src/lib/schema-defaults.ts +50 -1
  36. package/src/lib/schema-required.test.ts +24 -2
  37. package/src/lib/schema-required.ts +81 -43
  38. package/src/lib/use-is-invalid.ts +14 -1
  39. package/src/lib/validation-modes.ts +18 -6
  40. 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 && 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,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 = 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) {
@@ -23,15 +23,14 @@ function toRequiredPathKey(name) {
23
23
  return name.replace(INDEX_IN_NAME, "[0]");
24
24
  }
25
25
  function probeValue(expected) {
26
- if (typeof expected !== "string") return;
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 filledStrings = [];
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
- failing,
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 filledStrings = [];
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 { descended, failing, filledStrings: filled } = recordPass(issues, probe, required);
86
- for (const path of filledStrings) if (!failing.has(path)) required.delete(path);
87
- filledStrings = filled;
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
- 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.3.0",
3
+ "version": "0.4.0",
4
4
  "repository": {
5
5
  "url": "https://github.com/zeno-lib/zeno.git",
6
6
  "directory": "packages/forms"
@@ -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,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
- /** 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
 
@@ -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
- * If `true`, show the clear button when a value is selected. Defaults to
47
- * `true` — pass `false` for required-style comboboxes where clearing is
48
- * disallowed.
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
- /** Force the required `*` indicator on or off. Defaults to schema-derived. */
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 = true,
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
- const hasObjectItems = items.some(isItemObject)
109
- const value = hasObjectItems
110
- ? ((items.find((item) => isItemObject(item) && item.value === fieldValue) ??
111
- null) as T | null)
112
- : ((fieldValue ?? null) as T | null)
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={showClear && value !== null && value !== "" && !loading}
164
+ showClear={canClear && value !== null && value !== "" && !loading}
148
165
  >
149
166
  {loading && (
150
167
  <InputGroupAddon align="inline-end">