@zeno-lib/forms 0.2.2 → 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.
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 };
@@ -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
- return kind === "array" ? [{}] : void 0;
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 = {};
81
+ let filledStrings = [];
56
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,6 @@
1
1
  {
2
2
  "name": "@zeno-lib/forms",
3
- "version": "0.2.2",
3
+ "version": "0.3.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": {
@@ -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>
@@ -139,7 +140,7 @@ 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}
@@ -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,8 +60,9 @@ 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
@@ -74,7 +75,7 @@ function DatePickerField({
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}
@@ -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,8 +43,9 @@ 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
@@ -61,7 +62,7 @@ function SliderField({
61
62
  {(label || hasReadout) && (
62
63
  <FieldContent className="flex-row items-center justify-between">
63
64
  {label && (
64
- <FieldLabel htmlFor={field.name}>
65
+ <FieldLabel htmlFor={id}>
65
66
  {label}
66
67
  {isRequired && <RequiredIndicator />}
67
68
  </FieldLabel>
@@ -79,7 +80,7 @@ function SliderField({
79
80
  [showError, errorId]
80
81
  )}
81
82
  aria-invalid={isInvalid || undefined}
82
- id={field.name}
83
+ id={id}
83
84
  name={field.name}
84
85
  onBlur={field.handleBlur}
85
86
  onValueChange={(next) => {
@@ -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>
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 {
@@ -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)
@@ -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
  //
@@ -86,15 +91,20 @@ function toRequiredPathKey(name: string): string {
86
91
  return name.replace(INDEX_IN_NAME, "[0]")
87
92
  }
88
93
 
89
- function probeContainer(expected: unknown): unknown {
94
+ function probeValue(expected: unknown): unknown {
90
95
  if (typeof expected !== "string") {
91
96
  return
92
97
  }
93
- const kind = expected.toLowerCase()
94
- if (kind === "object") {
95
- return {}
98
+ switch (expected.toLowerCase()) {
99
+ case "object":
100
+ return {}
101
+ case "array":
102
+ return [{}]
103
+ case "string":
104
+ return ""
105
+ default:
106
+ return
96
107
  }
97
- return kind === "array" ? [{}] : undefined
98
108
  }
99
109
 
100
110
  // Write `value` at `keys` inside `root`, only where nothing is set yet.
@@ -135,26 +145,62 @@ function validateSync(
135
145
  return "issues" in result && result.issues ? result.issues : []
136
146
  }
137
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
+
138
183
  function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
139
184
  const required = new Set<string>()
140
185
  const probe: Record<PropertyKey, unknown> = {}
186
+ // Paths the previous pass filled with `""`: they stay only if they still fail.
187
+ let filledStrings: readonly string[] = []
141
188
  for (let depth = 0; depth < MAX_PROBE_DEPTH; depth += 1) {
142
189
  const issues = validateSync(schema, probe)
143
190
  if (!issues) {
144
191
  return required
145
192
  }
146
- let descended = false
147
- for (const issue of issues) {
148
- const keys = issue.path?.map(pathKey) ?? []
149
- if (keys.length === 0) {
150
- continue
151
- }
152
- required.add(joinPath(keys))
153
- const container = probeContainer(issue.expected)
154
- if (container !== undefined && fillAt(probe, keys, container)) {
155
- 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)
156
201
  }
157
202
  }
203
+ filledStrings = filled
158
204
  if (!descended) {
159
205
  break
160
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 {
@@ -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
+ })