@zeno-lib/forms 0.2.1 → 0.3.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.
@@ -99,7 +99,7 @@ type CreateZenoFormConfig<FC extends Record<string, AnyComponent>, FMC extends R
99
99
  * the `createFormHook` primitives plus the schema-aware `useForm` and the typed
100
100
  * `useAppFields` wrappers (prop types are inferred from the injected components).
101
101
  */
102
- declare function createZenoForm<FC extends Record<string, AnyComponent>, FMC extends Record<string, AnyComponent>>(config: CreateZenoFormConfig<FC, FMC>): {
102
+ export declare function createZenoForm<FC extends Record<string, AnyComponent>, FMC extends Record<string, AnyComponent>>(config: CreateZenoFormConfig<FC, FMC>): {
103
103
  useAppFields: <TForm extends AnyAppForm>(form: TForm) => FieldWrappers<FC, FormDataOf<TForm>>;
104
104
  useAppForm: <TFormData, TOnMount extends undefined | FormValidateOrFn<TFormData>, TOnChange extends undefined | FormValidateOrFn<TFormData>, TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnBlur extends undefined | FormValidateOrFn<TFormData>, TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnSubmit extends undefined | FormValidateOrFn<TFormData>, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnDynamic extends undefined | FormValidateOrFn<TFormData>, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData>, TOnServer extends undefined | FormAsyncValidateOrFn<TFormData>, TSubmitMeta>(props: FormOptions<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta>) => import("@tanstack/react-form").AppFieldExtendedReactFormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta, FC, FMC>;
105
105
  useForm: <TFormData, TOnMount extends undefined | FormValidateOrFn<TFormData> = undefined, TOnChange extends undefined | FormValidateOrFn<TFormData> = undefined, TOnChangeAsync extends undefined | FormAsyncValidateOrFn<TFormData> = undefined, TOnBlur extends undefined | FormValidateOrFn<TFormData> = undefined, TOnBlurAsync extends undefined | FormAsyncValidateOrFn<TFormData> = undefined, TOnSubmit extends undefined | FormValidateOrFn<TFormData> = undefined, TOnSubmitAsync extends undefined | FormAsyncValidateOrFn<TFormData> = undefined, TOnDynamic extends undefined | FormValidateOrFn<TFormData> = undefined, TOnDynamicAsync extends undefined | FormAsyncValidateOrFn<TFormData> = undefined, TOnServer extends undefined | FormAsyncValidateOrFn<TFormData> = undefined, TSubmitMeta = never>(options: UseFormOptions<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta>) => import("@tanstack/react-form").FormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta> & import("@tanstack/react-form").ReactFormApi<TFormData, TOnMount, TOnChange, TOnChangeAsync, TOnBlur, TOnBlurAsync, TOnSubmit, TOnSubmitAsync, TOnDynamic, TOnDynamicAsync, TOnServer, TSubmitMeta> & NoInfer<FMC> & {
@@ -114,5 +114,4 @@ declare function createZenoForm<FC extends Record<string, AnyComponent>, FMC ext
114
114
  form: import("@tanstack/react-form").AppFieldExtendedReactFormApi<[unknown] extends [TFormData] ? any : TFormData, [FormValidateOrFn<TFormData> | undefined] extends [TOnMount] ? [TOnMount] extends [TOnMount & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnMount : TOnMount, [FormValidateOrFn<TFormData> | undefined] extends [TOnChange] ? [TOnChange] extends [TOnChange & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnChange : TOnChange, [FormValidateOrFn<TFormData> | undefined] extends [TOnChangeAsync] ? [TOnChangeAsync] extends [TOnChangeAsync & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnChangeAsync : TOnChangeAsync, [FormValidateOrFn<TFormData> | undefined] extends [TOnBlur] ? [TOnBlur] extends [TOnBlur & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnBlur : TOnBlur, [FormValidateOrFn<TFormData> | undefined] extends [TOnBlurAsync] ? [TOnBlurAsync] extends [TOnBlurAsync & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnBlurAsync : TOnBlurAsync, [FormValidateOrFn<TFormData> | undefined] extends [TOnSubmit] ? [TOnSubmit] extends [TOnSubmit & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnSubmit : TOnSubmit, [FormValidateOrFn<TFormData> | undefined] extends [TOnSubmitAsync] ? [TOnSubmitAsync] extends [TOnSubmitAsync & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnSubmitAsync : TOnSubmitAsync, [FormValidateOrFn<TFormData> | undefined] extends [TOnDynamic] ? [TOnDynamic] extends [TOnDynamic & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnDynamic : TOnDynamic, [FormValidateOrFn<TFormData> | undefined] extends [TOnDynamicAsync] ? [TOnDynamicAsync] extends [TOnDynamicAsync & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnDynamicAsync : TOnDynamicAsync, [FormValidateOrFn<TFormData> | undefined] extends [TOnServer] ? [TOnServer] extends [TOnServer & (FormValidateOrFn<TFormData> | undefined)] ? any : TOnServer : TOnServer, [unknown] extends [TSubmitMeta] ? any : TSubmitMeta, [unknown] extends [FC] ? any : FC, [unknown] extends [FMC] ? any : FMC>;
115
115
  }>>;
116
116
  };
117
- //#endregion
118
- export { createZenoForm };
117
+ //#endregion
@@ -6,8 +6,8 @@ type FormProviderProps = {
6
6
  handleSubmit: () => unknown;
7
7
  };
8
8
  };
9
- declare function FormProvider({ children, form }: FormProviderProps): import("react/jsx-runtime").JSX.Element;
9
+ declare function FormProvider({ children, form }: FormProviderProps): import("react").JSX.Element;
10
10
  type FormProps = Omit<ComponentProps<"form">, "onSubmit">;
11
- declare function Form({ children, className, ...props }: FormProps): import("react/jsx-runtime").JSX.Element;
11
+ declare function Form({ children, className, ...props }: FormProps): import("react").JSX.Element;
12
12
  //#endregion
13
13
  export { Form, FormProvider };
package/dist/index.d.mts CHANGED
@@ -8,4 +8,5 @@ import { useFieldContext, useFormContext } from "./lib/contexts.mjs";
8
8
  import { SubmitActionOptions, SubmitActionSchema, applyActionError, submitAction } from "./lib/submit-action.mjs";
9
9
  import { useHideFieldErrors, useIsFieldRequired, 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, useIsInvalid, useSelector };
package/dist/index.mjs CHANGED
@@ -7,4 +7,5 @@ import { Form, FormProvider } from "./form-element.mjs";
7
7
  import { toActionError, toFieldName } from "./lib/action-result.mjs";
8
8
  import { applyActionError, submitAction } from "./lib/submit-action.mjs";
9
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 };
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 };
@@ -1,4 +1,3 @@
1
1
  //#region src/lib/aria.d.ts
2
- declare function describedBy(...entries: [unknown, string][]): string | undefined;
3
- //#endregion
4
- export { describedBy };
2
+ export declare function describedBy(...entries: [unknown, string][]): string | undefined;
3
+ //#endregion
@@ -1,5 +1,5 @@
1
1
  //#region src/lib/contexts.d.ts
2
2
  declare const fieldContext: import("react").Context<import("@tanstack/react-form").AnyFieldApi>, formContext: import("react").Context<import("@tanstack/react-form").AnyFormApi>, useFieldContext: <TData>() => import("@tanstack/react-form").FieldApi<any, string, TData, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any, any>, useFormContext: () => import("@tanstack/react-form").ReactFormExtendedApi<Record<string, never>, any, any, any, any, any, any, any, any, any, any, any>;
3
- declare const FormProvider: import("react").Provider<import("@tanstack/react-form").AnyFormApi>;
3
+ export declare const FormProvider: import("react").Provider<import("@tanstack/react-form").AnyFormApi>;
4
4
  //#endregion
5
- export { FormProvider, fieldContext, formContext, useFieldContext, useFormContext };
5
+ export { fieldContext, formContext, useFieldContext, useFormContext };
@@ -26,7 +26,7 @@ function normalizeMinus(text) {
26
26
  }
27
27
  /** Format a number for display (on blur, on reset, on first render). */
28
28
  function formatNumber(value, { locale, maximumFractionDigits, minimumFractionDigits = 0, useGrouping = true }) {
29
- if (value == null || !Number.isFinite(value)) return "";
29
+ if (value === null || value === void 0 || !Number.isFinite(value)) return "";
30
30
  return normalizeMinus(new Intl.NumberFormat(locale, {
31
31
  maximumFractionDigits,
32
32
  minimumFractionDigits: Math.min(minimumFractionDigits, maximumFractionDigits),
@@ -102,19 +102,19 @@ function parseLocaleNumber(text, locale) {
102
102
  }
103
103
  function countSignificant(text, end, decimal) {
104
104
  let count = 0;
105
- for (let i = 0; i < end && i < text.length; i++) {
105
+ for (let i = 0; i < end && i < text.length; i += 1) {
106
106
  const char = text[i];
107
- if (DIGIT.test(char) || char === decimal || MINUS_SIGNS.test(char)) count++;
107
+ if (DIGIT.test(char) || char === decimal || MINUS_SIGNS.test(char)) count += 1;
108
108
  }
109
109
  return count;
110
110
  }
111
111
  function caretForSignificant(text, significant, decimal) {
112
112
  if (significant <= 0) return 0;
113
113
  let count = 0;
114
- for (let i = 0; i < text.length; i++) {
114
+ for (let i = 0; i < text.length; i += 1) {
115
115
  const char = text[i];
116
116
  if (DIGIT.test(char) || char === decimal || char === "-") {
117
- count++;
117
+ count += 1;
118
118
  if (count === significant) return i + 1;
119
119
  }
120
120
  }
@@ -5,7 +5,7 @@ function getDef(node) {
5
5
  }
6
6
  function extractDefaultForField(node) {
7
7
  let current = node;
8
- for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth++) {
8
+ for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) {
9
9
  const def = getDef(current);
10
10
  if (!def?.type) return { ok: false };
11
11
  switch (def.type) {
@@ -22,11 +22,14 @@ function joinPath(keys) {
22
22
  function toRequiredPathKey(name) {
23
23
  return name.replace(INDEX_IN_NAME, "[0]");
24
24
  }
25
- function probeContainer(expected) {
25
+ function probeValue(expected) {
26
26
  if (typeof expected !== "string") return;
27
- const kind = expected.toLowerCase();
28
- if (kind === "object") return {};
29
- if (kind === "array") return [{}];
27
+ switch (expected.toLowerCase()) {
28
+ case "object": return {};
29
+ case "array": return [{}];
30
+ case "string": return "";
31
+ default: return;
32
+ }
30
33
  }
31
34
  function fillAt(root, keys, value) {
32
35
  let node = root;
@@ -50,20 +53,38 @@ function validateSync(schema, value) {
50
53
  if (result instanceof Promise) return;
51
54
  return "issues" in result && result.issues ? result.issues : [];
52
55
  }
56
+ function recordPass(issues, probe, required) {
57
+ const failing = /* @__PURE__ */ new Set();
58
+ const filledStrings = [];
59
+ let descended = false;
60
+ for (const issue of issues) {
61
+ const keys = issue.path?.map(pathKey) ?? [];
62
+ if (keys.length === 0) continue;
63
+ const path = joinPath(keys);
64
+ failing.add(path);
65
+ required.add(path);
66
+ const value = probeValue(issue.expected);
67
+ if (value !== void 0 && fillAt(probe, keys, value)) {
68
+ descended = true;
69
+ if (value === "") filledStrings.push(path);
70
+ }
71
+ }
72
+ return {
73
+ descended,
74
+ failing,
75
+ filledStrings
76
+ };
77
+ }
53
78
  function getRequiredPaths(schema) {
54
79
  const required = /* @__PURE__ */ new Set();
55
80
  const probe = {};
56
- for (let depth = 0; depth < MAX_PROBE_DEPTH; depth++) {
81
+ let filledStrings = [];
82
+ for (let depth = 0; depth < MAX_PROBE_DEPTH; depth += 1) {
57
83
  const issues = validateSync(schema, probe);
58
84
  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
- }
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;
67
88
  if (!descended) break;
68
89
  }
69
90
  return required;
@@ -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 };
package/package.json CHANGED
@@ -1,6 +1,10 @@
1
1
  {
2
2
  "name": "@zeno-lib/forms",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
+ "repository": {
5
+ "url": "https://github.com/zeno-lib/zeno.git",
6
+ "directory": "packages/forms"
7
+ },
4
8
  "type": "module",
5
9
  "exports": {
6
10
  ".": {
@@ -25,12 +29,22 @@
25
29
  "dist",
26
30
  "src"
27
31
  ],
28
- "publishConfig": {
29
- "access": "public"
32
+ "dependencies": {
33
+ "@tanstack/react-form": "1.33.5"
30
34
  },
31
- "repository": {
32
- "url": "https://github.com/zeno-lib/zeno.git",
33
- "directory": "packages/forms"
35
+ "devDependencies": {
36
+ "@types/node": "26.6.4",
37
+ "@types/react": "19.3.0",
38
+ "@types/react-dom": "19.3.0",
39
+ "@zeno-lib/test": "^0.1.1",
40
+ "@zeno-lib/typescript": "^1.1.0",
41
+ "@zeno-lib/ui": "^0.0.1",
42
+ "lucide-react": "^1.52.0",
43
+ "tsdown": "^0.23.0",
44
+ "typescript": "7.0.2",
45
+ "vite": "8.3.3",
46
+ "vitest": "5.0.3",
47
+ "zod": "^4.6.5"
34
48
  },
35
49
  "peerDependencies": {
36
50
  "@tanstack/react-devtools": ">=0.10",
@@ -46,28 +60,17 @@
46
60
  },
47
61
  "@tanstack/react-form-devtools": {
48
62
  "optional": true
63
+ },
64
+ "zod": {
65
+ "optional": true
49
66
  }
50
67
  },
51
- "dependencies": {
52
- "@tanstack/react-form": "1.33.5",
53
- "@tanstack/react-form-nextjs": "1.33.5"
54
- },
55
- "devDependencies": {
56
- "@types/node": "25.6.0",
57
- "@types/react": "19.2.14",
58
- "@types/react-dom": "19.2.3",
59
- "lucide-react": "^1.23.0",
60
- "tsdown": "^0.22.14",
61
- "typescript": "7.0.2",
62
- "vite": "8.0.16",
63
- "vitest": "5.0.2",
64
- "zod": "^4.3.6",
65
- "@zeno-lib/test": "^0.1.0",
66
- "@zeno-lib/typescript": "^1.1.0",
67
- "@zeno-lib/ui": "^0.0.1"
68
+ "publishConfig": {
69
+ "access": "public"
68
70
  },
69
71
  "scripts": {
70
72
  "build": "tsdown",
73
+ "dev": "tsdown --watch --no-clean",
71
74
  "test": "vitest --run",
72
75
  "test:watch": "vitest watch",
73
76
  "types:check": "tsc --noEmit"
@@ -7,7 +7,7 @@ import {
7
7
  useIsFieldRequired,
8
8
  useIsInvalid,
9
9
  } from "@zeno-lib/forms/lib/use-is-invalid"
10
- import type { ComponentProps, ReactNode } from "react"
10
+ import { type ComponentProps, type ReactNode, useId } from "react"
11
11
  import { Checkbox } from "@/components/ui/checkbox"
12
12
  import {
13
13
  Field,
@@ -35,8 +35,9 @@ function CheckboxField({
35
35
  ...props
36
36
  }: CheckboxFieldProps) {
37
37
  const field = useFieldContext<boolean>()
38
- const errorId = `${field.name}-error`
39
- const descriptionId = `${field.name}-description`
38
+ const id = useId()
39
+ const errorId = `${id}-error`
40
+ const descriptionId = `${id}-description`
40
41
  const isInvalid = useIsInvalid(field)
41
42
  const hideErrors = useHideFieldErrors(field)
42
43
  const showError = isInvalid && !hideErrors
@@ -56,7 +57,7 @@ function CheckboxField({
56
57
  )}
57
58
  aria-invalid={isInvalid || undefined}
58
59
  checked={field.state.value ?? false}
59
- id={field.name}
60
+ id={id}
60
61
  name={field.name}
61
62
  onBlur={field.handleBlur}
62
63
  onCheckedChange={(value) => field.handleChange(value === true)}
@@ -64,7 +65,7 @@ function CheckboxField({
64
65
  />
65
66
  <FieldContent>
66
67
  {label && (
67
- <FieldLabel className="font-normal" htmlFor={field.name}>
68
+ <FieldLabel className="font-normal" htmlFor={id}>
68
69
  {label}
69
70
  {isRequired && <RequiredIndicator />}
70
71
  </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
  )}
@@ -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 {
12
12
  Combobox,
13
13
  ComboboxContent,
@@ -95,8 +95,9 @@ function ComboboxField<T = string>({
95
95
  showClear = true,
96
96
  }: ComboboxFieldProps<T>) {
97
97
  const field = useFieldContext()
98
- const errorId = `${field.name}-error`
99
- const descriptionId = `${field.name}-description`
98
+ const id = useId()
99
+ const errorId = `${id}-error`
100
+ const descriptionId = `${id}-description`
100
101
  const isInvalid = useIsInvalid(field)
101
102
  const hideErrors = useHideFieldErrors(field)
102
103
  const showError = isInvalid && !hideErrors
@@ -113,7 +114,7 @@ function ComboboxField<T = string>({
113
114
  return (
114
115
  <Field data-field={field.name} data-invalid={isInvalid}>
115
116
  {label && (
116
- <FieldLabel htmlFor={field.name}>
117
+ <FieldLabel htmlFor={id}>
117
118
  {label}
118
119
  {isRequired && <RequiredIndicator />}
119
120
  </FieldLabel>
@@ -124,7 +125,7 @@ function ComboboxField<T = string>({
124
125
  items={items}
125
126
  onInputValueChange={onInputValueChange}
126
127
  onValueChange={(next) => {
127
- if (next == null) {
128
+ if (next === null) {
128
129
  field.handleChange(undefined)
129
130
  return
130
131
  }
@@ -139,11 +140,11 @@ function ComboboxField<T = string>({
139
140
  )}
140
141
  aria-invalid={isInvalid || undefined}
141
142
  className={className}
142
- id={field.name}
143
+ id={id}
143
144
  name={field.name}
144
145
  onBlur={field.handleBlur}
145
146
  placeholder={placeholder}
146
- showClear={showClear && value != null && value !== "" && !loading}
147
+ showClear={showClear && value !== null && value !== "" && !loading}
147
148
  >
148
149
  {loading && (
149
150
  <InputGroupAddon align="inline-end">
@@ -7,7 +7,7 @@ import {
7
7
  useIsFieldRequired,
8
8
  useIsInvalid,
9
9
  } from "@zeno-lib/forms/lib/use-is-invalid"
10
- import type { ComponentProps, ReactNode } from "react"
10
+ import { type ComponentProps, type ReactNode, useId } from "react"
11
11
  import { buttonVariants } from "@/components/ui/button"
12
12
  import { Calendar } from "@/components/ui/calendar"
13
13
  import {
@@ -60,21 +60,22 @@ function DatePickerField({
60
60
  triggerClassName,
61
61
  }: DatePickerFieldProps) {
62
62
  const field = useFieldContext<Date | undefined>()
63
- const errorId = `${field.name}-error`
64
- const descriptionId = `${field.name}-description`
63
+ const id = useId()
64
+ const errorId = `${id}-error`
65
+ const descriptionId = `${id}-description`
65
66
  const isInvalid = useIsInvalid(field)
66
67
  const hideErrors = useHideFieldErrors(field)
67
68
  const showError = isInvalid && !hideErrors
68
69
  const schemaRequired = useIsFieldRequired(field)
69
70
  const isRequired = required ?? schemaRequired
70
71
 
71
- const value = field.state.value
72
+ const { value } = field.state
72
73
  const empty = !value
73
74
 
74
75
  return (
75
76
  <Field data-field={field.name} data-invalid={isInvalid}>
76
77
  {label && (
77
- <FieldLabel htmlFor={field.name}>
78
+ <FieldLabel htmlFor={id}>
78
79
  {label}
79
80
  {isRequired && <RequiredIndicator />}
80
81
  </FieldLabel>
@@ -92,7 +93,7 @@ function DatePickerField({
92
93
  triggerClassName
93
94
  )}
94
95
  data-empty={empty || undefined}
95
- id={field.name}
96
+ id={id}
96
97
  // Base UI's Popover handles open state — we still need blur on the
97
98
  // form's reactive store. Passing onBlur here mirrors how `<select>`
98
99
  // commits on close.
@@ -7,7 +7,7 @@ import {
7
7
  useIsFieldRequired,
8
8
  useIsInvalid,
9
9
  } from "@zeno-lib/forms/lib/use-is-invalid"
10
- import { Children, type ComponentProps, type ReactNode } from "react"
10
+ import { Children, type ComponentProps, type ReactNode, useId } from "react"
11
11
  import {
12
12
  Field,
13
13
  FieldDescription,
@@ -40,8 +40,9 @@ function InputField({
40
40
  ...props
41
41
  }: InputFieldProps) {
42
42
  const field = useFieldContext<string>()
43
- const errorId = `${field.name}-error`
44
- const descriptionId = `${field.name}-description`
43
+ const id = useId()
44
+ const errorId = `${id}-error`
45
+ const descriptionId = `${id}-description`
45
46
  const isInvalid = useIsInvalid(field)
46
47
  const hideErrors = useHideFieldErrors(field)
47
48
  const showError = isInvalid && !hideErrors
@@ -55,7 +56,7 @@ function InputField({
55
56
  [showError, errorId]
56
57
  ),
57
58
  "aria-invalid": isInvalid || undefined,
58
- id: field.name,
59
+ id,
59
60
  name: field.name,
60
61
  onBlur: field.handleBlur,
61
62
  onChange: (event: React.ChangeEvent<HTMLInputElement>) =>
@@ -67,7 +68,7 @@ function InputField({
67
68
  return (
68
69
  <Field data-field={field.name} data-invalid={isInvalid}>
69
70
  {label && (
70
- <FieldLabel htmlFor={field.name}>
71
+ <FieldLabel htmlFor={id}>
71
72
  {label}
72
73
  {isRequired && <RequiredIndicator />}
73
74
  </FieldLabel>
@@ -8,7 +8,7 @@ import {
8
8
  useIsInvalid,
9
9
  } from "@zeno-lib/forms/lib/use-is-invalid"
10
10
  import { XIcon } from "lucide-react"
11
- import type { ReactNode } from "react"
11
+ import { type ReactNode, useId } from "react"
12
12
  import { Button } from "@/components/ui/button"
13
13
  import {
14
14
  Combobox,
@@ -96,8 +96,9 @@ function MultiSelectField<T = string>({
96
96
  }: MultiSelectFieldProps<T>) {
97
97
  const field = useFieldContext<MultiSelectValue<T>[] | null | undefined>()
98
98
  const anchor = useComboboxAnchor()
99
- const errorId = `${field.name}-error`
100
- const descriptionId = `${field.name}-description`
99
+ const id = useId()
100
+ const errorId = `${id}-error`
101
+ const descriptionId = `${id}-description`
101
102
  const isInvalid = useIsInvalid(field)
102
103
  const hideErrors = useHideFieldErrors(field)
103
104
  const showError = isInvalid && !hideErrors
@@ -114,7 +115,7 @@ function MultiSelectField<T = string>({
114
115
  return (
115
116
  <Field data-field={field.name} data-invalid={isInvalid}>
116
117
  {label && (
117
- <FieldLabel htmlFor={field.name}>
118
+ <FieldLabel htmlFor={id}>
118
119
  {label}
119
120
  {isRequired && <RequiredIndicator />}
120
121
  </FieldLabel>
@@ -143,7 +144,7 @@ function MultiSelectField<T = string>({
143
144
  [showError, errorId]
144
145
  )}
145
146
  aria-invalid={isInvalid || undefined}
146
- id={field.name}
147
+ id={id}
147
148
  name={field.name}
148
149
  onBlur={field.handleBlur}
149
150
  placeholder={chips.length === 0 ? placeholder : undefined}
@@ -11,7 +11,7 @@ type NumberFieldProps = Omit<
11
11
 
12
12
  function NumberField(props: NumberFieldProps) {
13
13
  const field = useFieldContext<number | undefined>()
14
- const value = field.state.value
14
+ const { value } = field.state
15
15
  const inputValue =
16
16
  typeof value === "number" && !Number.isNaN(value) ? value : ""
17
17
 
@@ -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 {
12
12
  Field,
13
13
  FieldDescription,
@@ -57,8 +57,9 @@ function OtpField({
57
57
  required,
58
58
  }: OtpFieldProps) {
59
59
  const field = useFieldContext<string>()
60
- const errorId = `${field.name}-error`
61
- const descriptionId = `${field.name}-description`
60
+ const id = useId()
61
+ const errorId = `${id}-error`
62
+ const descriptionId = `${id}-description`
62
63
  const isInvalid = useIsInvalid(field)
63
64
  const hideErrors = useHideFieldErrors(field)
64
65
  const showError = isInvalid && !hideErrors
@@ -68,7 +69,7 @@ function OtpField({
68
69
  return (
69
70
  <Field data-field={field.name} data-invalid={isInvalid}>
70
71
  {label && (
71
- <FieldLabel htmlFor={field.name}>
72
+ <FieldLabel htmlFor={id}>
72
73
  {label}
73
74
  {isRequired && <RequiredIndicator />}
74
75
  </FieldLabel>
@@ -83,7 +84,7 @@ function OtpField({
83
84
  className={className}
84
85
  containerClassName={containerClassName}
85
86
  disabled={disabled}
86
- id={field.name}
87
+ id={id}
87
88
  maxLength={maxLength}
88
89
  name={field.name}
89
90
  onBlur={field.handleBlur}
@@ -36,8 +36,9 @@ function RadioGroupField({
36
36
  ...props
37
37
  }: RadioGroupFieldProps) {
38
38
  const field = useFieldContext<string>()
39
- const errorId = `${field.name}-error`
40
- const descriptionId = `${field.name}-description`
39
+ const id = useId()
40
+ const errorId = `${id}-error`
41
+ const descriptionId = `${id}-description`
41
42
  const isInvalid = useIsInvalid(field)
42
43
  const hideErrors = useHideFieldErrors(field)
43
44
  const showError = isInvalid && !hideErrors
@@ -8,7 +8,7 @@ import {
8
8
  useIsInvalid,
9
9
  } from "@zeno-lib/forms/lib/use-is-invalid"
10
10
  import type { ComponentProps, ReactNode } from "react"
11
- import { Children, isValidElement, useMemo } from "react"
11
+ import { Children, isValidElement, useId, useMemo } from "react"
12
12
  import {
13
13
  Field,
14
14
  FieldDescription,
@@ -48,8 +48,9 @@ function SelectField({
48
48
  ...props
49
49
  }: SelectFieldProps) {
50
50
  const field = useFieldContext()
51
- const errorId = `${field.name}-error`
52
- const descriptionId = `${field.name}-description`
51
+ const id = useId()
52
+ const errorId = `${id}-error`
53
+ const descriptionId = `${id}-description`
53
54
  const isInvalid = useIsInvalid(field)
54
55
  const hideErrors = useHideFieldErrors(field)
55
56
  const showError = isInvalid && !hideErrors
@@ -72,7 +73,7 @@ function SelectField({
72
73
  return (
73
74
  <Field data-field={field.name} data-invalid={isInvalid}>
74
75
  {label && (
75
- <FieldLabel htmlFor={field.name}>
76
+ <FieldLabel htmlFor={id}>
76
77
  {label}
77
78
  {isRequired && <RequiredIndicator />}
78
79
  </FieldLabel>
@@ -91,7 +92,7 @@ function SelectField({
91
92
  )}
92
93
  aria-invalid={isInvalid || undefined}
93
94
  className={triggerClassName}
94
- id={field.name}
95
+ id={id}
95
96
  onBlur={field.handleBlur}
96
97
  size={triggerSize}
97
98
  >
@@ -7,7 +7,7 @@ import {
7
7
  useIsFieldRequired,
8
8
  useIsInvalid,
9
9
  } from "@zeno-lib/forms/lib/use-is-invalid"
10
- import type { ComponentProps, ReactNode } from "react"
10
+ import { type ComponentProps, type ReactNode, useId } from "react"
11
11
  import {
12
12
  Field,
13
13
  FieldContent,
@@ -43,28 +43,31 @@ function SliderField({
43
43
  ...props
44
44
  }: SliderFieldProps) {
45
45
  const field = useFieldContext<SliderValue>()
46
- const errorId = `${field.name}-error`
47
- const descriptionId = `${field.name}-description`
46
+ const id = useId()
47
+ const errorId = `${id}-error`
48
+ const descriptionId = `${id}-description`
48
49
  const isInvalid = useIsInvalid(field)
49
50
  const hideErrors = useHideFieldErrors(field)
50
51
  const showError = isInvalid && !hideErrors
51
52
  const schemaRequired = useIsFieldRequired(field)
52
53
  const isRequired = required ?? schemaRequired
53
54
 
54
- const value = field.state.value
55
+ const { value } = field.state
55
56
  const readout = formatValue && value !== undefined ? formatValue(value) : null
57
+ // Not a truthiness check: a formatter may return the number 0, which must still render.
58
+ const hasReadout = readout !== null && readout !== undefined
56
59
 
57
60
  return (
58
61
  <Field data-field={field.name} data-invalid={isInvalid}>
59
- {(label || readout) && (
62
+ {(label || hasReadout) && (
60
63
  <FieldContent className="flex-row items-center justify-between">
61
64
  {label && (
62
- <FieldLabel htmlFor={field.name}>
65
+ <FieldLabel htmlFor={id}>
63
66
  {label}
64
67
  {isRequired && <RequiredIndicator />}
65
68
  </FieldLabel>
66
69
  )}
67
- {readout && (
70
+ {hasReadout && (
68
71
  <span className="text-muted-foreground text-sm tabular-nums">
69
72
  {readout}
70
73
  </span>
@@ -77,13 +80,13 @@ function SliderField({
77
80
  [showError, errorId]
78
81
  )}
79
82
  aria-invalid={isInvalid || undefined}
80
- id={field.name}
83
+ id={id}
81
84
  name={field.name}
82
85
  onBlur={field.handleBlur}
83
86
  onValueChange={(next) => {
84
- const value: SliderValue =
87
+ const nextValue: SliderValue =
85
88
  typeof next === "number" ? next : Array.from(next)
86
- field.handleChange(value)
89
+ field.handleChange(nextValue)
87
90
  }}
88
91
  value={value}
89
92
  {...props}
@@ -7,7 +7,7 @@ import {
7
7
  useIsFieldRequired,
8
8
  useIsInvalid,
9
9
  } from "@zeno-lib/forms/lib/use-is-invalid"
10
- import type { ComponentProps, ReactNode } from "react"
10
+ import { type ComponentProps, type ReactNode, useId } from "react"
11
11
  import {
12
12
  Field,
13
13
  FieldContent,
@@ -35,8 +35,9 @@ function SwitchField({
35
35
  ...props
36
36
  }: SwitchFieldProps) {
37
37
  const field = useFieldContext<boolean>()
38
- const errorId = `${field.name}-error`
39
- const descriptionId = `${field.name}-description`
38
+ const id = useId()
39
+ const errorId = `${id}-error`
40
+ const descriptionId = `${id}-description`
40
41
  const isInvalid = useIsInvalid(field)
41
42
  const hideErrors = useHideFieldErrors(field)
42
43
  const showError = isInvalid && !hideErrors
@@ -51,7 +52,7 @@ function SwitchField({
51
52
  >
52
53
  <FieldContent>
53
54
  {label && (
54
- <FieldLabel htmlFor={field.name}>
55
+ <FieldLabel htmlFor={id}>
55
56
  {label}
56
57
  {isRequired && <RequiredIndicator />}
57
58
  </FieldLabel>
@@ -70,7 +71,7 @@ function SwitchField({
70
71
  )}
71
72
  aria-invalid={isInvalid || undefined}
72
73
  checked={field.state.value ?? false}
73
- id={field.name}
74
+ id={id}
74
75
  name={field.name}
75
76
  onBlur={field.handleBlur}
76
77
  onCheckedChange={(value) => field.handleChange(value)}
@@ -7,7 +7,7 @@ import {
7
7
  useIsFieldRequired,
8
8
  useIsInvalid,
9
9
  } from "@zeno-lib/forms/lib/use-is-invalid"
10
- import { Children, type ComponentProps, type ReactNode } from "react"
10
+ import { Children, type ComponentProps, type ReactNode, useId } from "react"
11
11
  import {
12
12
  Field,
13
13
  FieldDescription,
@@ -37,8 +37,9 @@ function TextAreaField({
37
37
  ...props
38
38
  }: TextAreaFieldProps) {
39
39
  const field = useFieldContext<string>()
40
- const errorId = `${field.name}-error`
41
- const descriptionId = `${field.name}-description`
40
+ const id = useId()
41
+ const errorId = `${id}-error`
42
+ const descriptionId = `${id}-description`
42
43
  const isInvalid = useIsInvalid(field)
43
44
  const hideErrors = useHideFieldErrors(field)
44
45
  const showError = isInvalid && !hideErrors
@@ -52,7 +53,7 @@ function TextAreaField({
52
53
  [showError, errorId]
53
54
  ),
54
55
  "aria-invalid": isInvalid || undefined,
55
- id: field.name,
56
+ id,
56
57
  name: field.name,
57
58
  onBlur: field.handleBlur,
58
59
  onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) =>
@@ -64,7 +65,7 @@ function TextAreaField({
64
65
  return (
65
66
  <Field data-field={field.name} data-invalid={isInvalid}>
66
67
  {label && (
67
- <FieldLabel htmlFor={field.name}>
68
+ <FieldLabel htmlFor={id}>
68
69
  {label}
69
70
  {isRequired && <RequiredIndicator />}
70
71
  </FieldLabel>
@@ -63,8 +63,8 @@ type DialogForm<TValues> = {
63
63
  }
64
64
  }
65
65
  readonly state: DialogFormState
66
- handleSubmit(): unknown
67
- reset(values?: TValues): void
66
+ handleSubmit: () => unknown
67
+ reset: (values?: TValues) => void
68
68
  }
69
69
 
70
70
  type FormDialogProps<TValues> = {
package/src/index.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /* biome-ignore-all lint/performance/noBarrelFile: single npm entry for the headless form API. */
2
2
 
3
+ export { useSelector } from "@tanstack/react-form"
3
4
  export { createZenoForm } from "./create-zeno-form"
4
5
  export { Form, FormProvider } from "./form-element"
5
6
  export {
@@ -84,7 +84,7 @@ function formatNumber(
84
84
  useGrouping = true,
85
85
  }: FormatNumberOptions
86
86
  ): string {
87
- if (value == null || !Number.isFinite(value)) {
87
+ if (value === null || value === undefined || !Number.isFinite(value)) {
88
88
  return ""
89
89
  }
90
90
  return normalizeMinus(
@@ -236,10 +236,10 @@ function parseLocaleNumber(text: string, locale?: string): number | null {
236
236
  // the same logical place after separators were inserted or removed.
237
237
  function countSignificant(text: string, end: number, decimal: string): number {
238
238
  let count = 0
239
- for (let i = 0; i < end && i < text.length; i++) {
239
+ for (let i = 0; i < end && i < text.length; i += 1) {
240
240
  const char = text[i] as string
241
241
  if (DIGIT.test(char) || char === decimal || MINUS_SIGNS.test(char)) {
242
- count++
242
+ count += 1
243
243
  }
244
244
  }
245
245
  return count
@@ -254,10 +254,10 @@ function caretForSignificant(
254
254
  return 0
255
255
  }
256
256
  let count = 0
257
- for (let i = 0; i < text.length; i++) {
257
+ for (let i = 0; i < text.length; i += 1) {
258
258
  const char = text[i] as string
259
259
  if (DIGIT.test(char) || char === decimal || char === "-") {
260
- count++
260
+ count += 1
261
261
  if (count === significant) {
262
262
  return i + 1
263
263
  }
@@ -37,7 +37,7 @@ function getDef(node: SchemaNode | undefined): SchemaDef | undefined {
37
37
 
38
38
  function extractDefaultForField(node: SchemaNode): ExtractResult {
39
39
  let current: SchemaNode | undefined = node
40
- for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth++) {
40
+ for (let depth = 0; depth < MAX_WRAPPER_DEPTH; depth += 1) {
41
41
  const def: SchemaDef | undefined = getDef(current)
42
42
  if (!def?.type) {
43
43
  return { ok: false }
@@ -38,6 +38,26 @@ describe("getRequiredPaths", () => {
38
38
  expect(required.has("role")).toBe(false)
39
39
  })
40
40
 
41
+ test("a string that accepts the empty value it starts at is not required", () => {
42
+ const schema = z.object({
43
+ email: z.email(),
44
+ members: z.array(z.object({ name: z.string().min(1), note: z.string() })),
45
+ name: z.string().trim().min(1),
46
+ notes: z.string(),
47
+ profile: z.object({ bio: z.string(), title: z.string().min(1) }),
48
+ subtitle: z.string().nullable(),
49
+ })
50
+ const required = getRequiredPaths(schema)
51
+ expect(required.has("notes")).toBe(false)
52
+ expect(required.has("subtitle")).toBe(false)
53
+ expect(required.has("profile.bio")).toBe(false)
54
+ expect(required.has("members[0].note")).toBe(false)
55
+ expect(required.has("email")).toBe(true)
56
+ expect(required.has("name")).toBe(true)
57
+ expect(required.has("profile.title")).toBe(true)
58
+ expect(required.has("members[0].name")).toBe(true)
59
+ })
60
+
41
61
  test("nested z.object reports the parent and its required children", () => {
42
62
  const schema = z.object({
43
63
  profile: z.object({
@@ -79,7 +99,7 @@ describe("getRequiredPaths", () => {
79
99
  test("nested arrays descend through every level", () => {
80
100
  const schema = z.object({
81
101
  groups: z.array(
82
- z.object({ members: z.array(z.object({ name: z.string() })) })
102
+ z.object({ members: z.array(z.object({ name: z.string().min(1) })) })
83
103
  ),
84
104
  })
85
105
  const required = getRequiredPaths(schema)
@@ -166,7 +186,7 @@ describe("getRequiredPaths", () => {
166
186
  const schema: StandardSchemaLike = {
167
187
  "~standard": {
168
188
  validate: (value) => {
169
- const profile = (value as { profile?: unknown }).profile
189
+ const { profile } = value as { profile?: unknown }
170
190
  return profile === undefined
171
191
  ? { issues: [{ expected: "Object", path: [{ key: "profile" }] }] }
172
192
  : {
@@ -13,6 +13,11 @@
13
13
  // index `0` and recorded index-agnostically, so `members[0].name` in the set
14
14
  // matches every row's `members[3].name` via `toRequiredPathKey`.
15
15
  //
16
+ // A missing string is probed again as `""`, the value the form starts it at
17
+ // (`extractZodDefaults`). A field that accepts `""` (a bare `z.string()`)
18
+ // can't fail while untouched, so it leaves the set; one that rejects it
19
+ // (`.min(1)`, `z.email()`) stays.
20
+ //
16
21
  // Paths use TanStack Form's field-name syntax: dots for object keys, brackets
17
22
  // for array indices (`members[0].name`, not `members.0.name`).
18
23
  //
@@ -50,6 +55,7 @@ const INDEX_SEGMENT = /^\d+$/
50
55
  const INDEX_IN_NAME = /\[\d+\]|\.(\d+)(?=\.|\[|$)/g
51
56
 
52
57
  function pathKey(part: PathPart): PropertyKey {
58
+ // biome-ignore lint/suspicious/noUnnecessaryConditions: issue paths come from third-party validators at runtime, and a non-conforming `null` segment would make `in` throw.
53
59
  if (typeof part === "object" && part !== null && "key" in part) {
54
60
  return part.key
55
61
  }
@@ -85,18 +91,20 @@ function toRequiredPathKey(name: string): string {
85
91
  return name.replace(INDEX_IN_NAME, "[0]")
86
92
  }
87
93
 
88
- function probeContainer(expected: unknown): unknown {
94
+ function probeValue(expected: unknown): unknown {
89
95
  if (typeof expected !== "string") {
90
96
  return
91
97
  }
92
- const kind = expected.toLowerCase()
93
- if (kind === "object") {
94
- return {}
95
- }
96
- if (kind === "array") {
97
- return [{}]
98
+ switch (expected.toLowerCase()) {
99
+ case "object":
100
+ return {}
101
+ case "array":
102
+ return [{}]
103
+ case "string":
104
+ return ""
105
+ default:
106
+ return
98
107
  }
99
- return
100
108
  }
101
109
 
102
110
  // Write `value` at `keys` inside `root`, only where nothing is set yet.
@@ -137,26 +145,62 @@ function validateSync(
137
145
  return "issues" in result && result.issues ? result.issues : []
138
146
  }
139
147
 
148
+ type ProbePass = {
149
+ readonly descended: boolean
150
+ readonly failing: ReadonlySet<string>
151
+ readonly filledStrings: readonly string[]
152
+ }
153
+
154
+ // Record one pass's failing paths, and fill the probe at each of them for the
155
+ // next pass.
156
+ function recordPass(
157
+ issues: readonly StandardIssue[],
158
+ probe: Record<PropertyKey, unknown>,
159
+ required: Set<string>
160
+ ): ProbePass {
161
+ const failing = new Set<string>()
162
+ const filledStrings: string[] = []
163
+ let descended = false
164
+ for (const issue of issues) {
165
+ const keys = issue.path?.map(pathKey) ?? []
166
+ if (keys.length === 0) {
167
+ continue
168
+ }
169
+ const path = joinPath(keys)
170
+ failing.add(path)
171
+ required.add(path)
172
+ const value = probeValue(issue.expected)
173
+ if (value !== undefined && fillAt(probe, keys, value)) {
174
+ descended = true
175
+ if (value === "") {
176
+ filledStrings.push(path)
177
+ }
178
+ }
179
+ }
180
+ return { descended, failing, filledStrings }
181
+ }
182
+
140
183
  function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
141
184
  const required = new Set<string>()
142
185
  const probe: Record<PropertyKey, unknown> = {}
143
- for (let depth = 0; depth < MAX_PROBE_DEPTH; depth++) {
186
+ // Paths the previous pass filled with `""`: they stay only if they still fail.
187
+ let filledStrings: readonly string[] = []
188
+ for (let depth = 0; depth < MAX_PROBE_DEPTH; depth += 1) {
144
189
  const issues = validateSync(schema, probe)
145
190
  if (!issues) {
146
191
  return required
147
192
  }
148
- let descended = false
149
- for (const issue of issues) {
150
- const keys = issue.path?.map(pathKey) ?? []
151
- if (keys.length === 0) {
152
- continue
153
- }
154
- required.add(joinPath(keys))
155
- const container = probeContainer(issue.expected)
156
- if (container !== undefined && fillAt(probe, keys, container)) {
157
- descended = true
193
+ const {
194
+ descended,
195
+ failing,
196
+ filledStrings: filled,
197
+ } = recordPass(issues, probe, required)
198
+ for (const path of filledStrings) {
199
+ if (!failing.has(path)) {
200
+ required.delete(path)
158
201
  }
159
202
  }
203
+ filledStrings = filled
160
204
  if (!descended) {
161
205
  break
162
206
  }
@@ -156,8 +156,9 @@ function attributeSelector(attribute: string, value: string): string {
156
156
 
157
157
  /**
158
158
  * Find the element to focus for field `name` inside `root`: the control with
159
- * `id={name}` (what the shipped fields render), else the first focusable
160
- * element inside the field root marked `data-field={name}`.
159
+ * `id={name}` (fields that use the name as their id), else, inside the field
160
+ * root marked `data-field={name}`, the control its label points at (the shipped
161
+ * fields' ids come from `useId`), else that root's first focusable element.
161
162
  */
162
163
  function findFieldElement(
163
164
  root: ParentNode | null | undefined,
@@ -171,7 +172,14 @@ function findFieldElement(
171
172
  return byId
172
173
  }
173
174
  const fieldRoot = root.querySelector(attributeSelector("data-field", name))
174
- return fieldRoot?.querySelector<HTMLElement>(FOCUSABLE) ?? null
175
+ if (!fieldRoot) {
176
+ return null
177
+ }
178
+ const labelFor = fieldRoot.querySelector("label[for]")?.getAttribute("for")
179
+ const labelled = labelFor
180
+ ? fieldRoot.querySelector<HTMLElement>(attributeSelector("id", labelFor))
181
+ : null
182
+ return labelled ?? fieldRoot.querySelector<HTMLElement>(FOCUSABLE)
175
183
  }
176
184
 
177
185
  export type {
@@ -32,7 +32,6 @@ function makeRun() {
32
32
  const runValidation = vi.fn(
33
33
  (props: { form: AnyFormApi; validators: ObservedValidator[] }) => {
34
34
  observed.push(props.validators)
35
- return
36
35
  }
37
36
  )
38
37
  return { observed, runValidation }
@@ -0,0 +1,30 @@
1
+ import { cleanup, render, screen } from "@zeno-lib/test/testing-library"
2
+ import { afterEach, describe, expect, test, vi } from "vitest"
3
+ import { Form, FormProvider, useForm } from "./create-form"
4
+
5
+ afterEach(() => {
6
+ cleanup()
7
+ })
8
+
9
+ describe("SliderField", () => {
10
+ function Harness() {
11
+ const form = useForm({ defaultValues: { volume: 0 }, onSubmit: vi.fn() })
12
+ const { SliderField } = form
13
+ return (
14
+ <FormProvider form={form}>
15
+ <Form>
16
+ <SliderField
17
+ formatValue={(value) => value}
18
+ label="Volume"
19
+ name="volume"
20
+ />
21
+ </Form>
22
+ </FormProvider>
23
+ )
24
+ }
25
+
26
+ test("renders a numeric readout of 0", () => {
27
+ render(<Harness />)
28
+ expect(screen.getByText("0").tagName).toBe("SPAN")
29
+ })
30
+ })
@@ -30,7 +30,6 @@ describe("EmailField / PasswordField", () => {
30
30
  }
31
31
  render(<H />)
32
32
  const input = screen.getByLabelText(CONTACT_EMAIL_LABEL) as HTMLInputElement
33
- expect(input.id).toBe("contactEmail")
34
33
  expect(input.name).toBe("contactEmail")
35
34
  await user.type(input, "u@example.com")
36
35
  expect(input.value).toBe("u@example.com")
@@ -54,7 +53,6 @@ describe("EmailField / PasswordField", () => {
54
53
  }
55
54
  render(<H />)
56
55
  const input = screen.getByLabelText(PASSWORD_LABEL) as HTMLInputElement
57
- expect(input.id).toBe("password")
58
56
  expect(input.name).toBe("password")
59
57
  expect(input.type).toBe("password")
60
58
  })
@@ -137,3 +135,32 @@ describe("CheckboxField — boolean state wrapper", () => {
137
135
  expect(checkbox.getAttribute("aria-checked")).toBe("true")
138
136
  })
139
137
  })
138
+
139
+ describe("field ids", () => {
140
+ test("two forms with the same field name label their own controls", () => {
141
+ const schema = z.object({ description: z.string().min(1) })
142
+ function H({ label }: { label: string }) {
143
+ const form = useForm({ onSubmit: vi.fn(), schema })
144
+ const { InputField } = form
145
+ return (
146
+ <FormProvider form={form}>
147
+ <Form>
148
+ <InputField description="Hint" label={label} name="description" />
149
+ </Form>
150
+ </FormProvider>
151
+ )
152
+ }
153
+ render(
154
+ <>
155
+ <H label="Page description" />
156
+ <H label="Dialog description" />
157
+ </>
158
+ )
159
+ const page = screen.getByRole("textbox", { name: "Page description" })
160
+ const dialog = screen.getByRole("textbox", { name: "Dialog description" })
161
+ expect(dialog).not.toBe(page)
162
+ const hints = screen.getAllByText("Hint")
163
+ expect(page.getAttribute("aria-describedby")).toBe(hints[0]?.id)
164
+ expect(dialog.getAttribute("aria-describedby")).toBe(hints[1]?.id)
165
+ })
166
+ })