@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 +2 -1
- package/dist/index.mjs +2 -1
- package/dist/lib/schema-required.mjs +33 -12
- package/dist/lib/use-form-dialog.d.mts +3 -2
- package/dist/lib/use-form-dialog.mjs +7 -3
- package/package.json +5 -3
- package/src/fields/checkbox-field.tsx +6 -5
- package/src/fields/checkbox-group-field.tsx +8 -7
- package/src/fields/combobox-field.tsx +6 -5
- package/src/fields/date-picker-field.tsx +6 -5
- package/src/fields/input-field.tsx +6 -5
- package/src/fields/multi-select-field.tsx +6 -5
- package/src/fields/otp-field.tsx +6 -5
- package/src/fields/radio-group-field.tsx +3 -2
- package/src/fields/select-field.tsx +6 -5
- package/src/fields/slider-field.tsx +6 -5
- package/src/fields/switch-field.tsx +6 -5
- package/src/fields/textarea-field.tsx +6 -5
- package/src/index.ts +1 -0
- package/src/lib/schema-required.test.ts +21 -1
- package/src/lib/schema-required.ts +61 -15
- package/src/lib/use-form-dialog.ts +11 -3
- package/src/use-app-fields.test.tsx +29 -2
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
|
-
|
|
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
|
-
|
|
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
|
|
25
|
+
function probeValue(expected) {
|
|
26
26
|
if (typeof expected !== "string") return;
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
60
|
-
for (const
|
|
61
|
-
|
|
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}` (
|
|
69
|
-
*
|
|
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}` (
|
|
81
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
|
39
|
-
const
|
|
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={
|
|
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={
|
|
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
|
|
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
|
|
68
|
-
const
|
|
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
|
|
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={
|
|
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={
|
|
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={
|
|
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
|
|
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
|
|
99
|
-
const
|
|
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={
|
|
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={
|
|
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
|
|
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
|
|
64
|
-
const
|
|
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={
|
|
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={
|
|
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
|
|
44
|
-
const
|
|
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
|
|
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={
|
|
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
|
|
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
|
|
100
|
-
const
|
|
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={
|
|
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={
|
|
147
|
+
id={id}
|
|
147
148
|
name={field.name}
|
|
148
149
|
onBlur={field.handleBlur}
|
|
149
150
|
placeholder={chips.length === 0 ? placeholder : undefined}
|
package/src/fields/otp-field.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
useIsFieldRequired,
|
|
8
8
|
useIsInvalid,
|
|
9
9
|
} from "@zeno-lib/forms/lib/use-is-invalid"
|
|
10
|
-
import type
|
|
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
|
|
61
|
-
const
|
|
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={
|
|
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={
|
|
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
|
|
40
|
-
const
|
|
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
|
|
52
|
-
const
|
|
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={
|
|
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={
|
|
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
|
|
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
|
|
47
|
-
const
|
|
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={
|
|
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={
|
|
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
|
|
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
|
|
39
|
-
const
|
|
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={
|
|
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={
|
|
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
|
|
41
|
-
const
|
|
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
|
|
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={
|
|
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
|
|
94
|
+
function probeValue(expected: unknown): unknown {
|
|
90
95
|
if (typeof expected !== "string") {
|
|
91
96
|
return
|
|
92
97
|
}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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}` (
|
|
160
|
-
*
|
|
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
|
-
|
|
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
|
+
})
|