@zeno-lib/forms 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/create-zeno-form.mjs +5 -3
- package/dist/index.d.mts +3 -2
- package/dist/index.mjs +4 -3
- package/dist/lib/empty-value.d.mts +12 -0
- package/dist/lib/empty-value.mjs +68 -0
- package/dist/lib/schema-defaults.d.mts +9 -1
- package/dist/lib/schema-defaults.mjs +33 -1
- package/dist/lib/schema-required.d.mts +2 -1
- package/dist/lib/schema-required.mjs +67 -18
- package/dist/lib/submit-action.mjs +1 -1
- package/dist/lib/use-form-dialog.d.mts +3 -2
- package/dist/lib/use-form-dialog.mjs +7 -3
- package/dist/lib/use-is-invalid.d.mts +2 -1
- package/dist/lib/use-is-invalid.mjs +5 -2
- package/dist/lib/validation-modes.d.mts +5 -1
- package/dist/lib/validation-modes.mjs +9 -4
- package/package.json +5 -3
- package/src/create-zeno-form.tsx +13 -5
- package/src/fields/checkbox-field.tsx +14 -6
- package/src/fields/checkbox-group-field.tsx +8 -7
- package/src/fields/combobox-field.tsx +35 -17
- package/src/fields/date-picker-field.tsx +42 -13
- package/src/fields/email-field.tsx +8 -0
- package/src/fields/input-field.tsx +12 -7
- package/src/fields/money-field.tsx +3 -1
- package/src/fields/multi-select-field.tsx +14 -6
- package/src/fields/number-field.tsx +9 -7
- package/src/fields/otp-field.tsx +14 -6
- package/src/fields/percentage-field.tsx +3 -1
- package/src/fields/radio-group-field.tsx +14 -6
- package/src/fields/select-field.tsx +31 -7
- package/src/fields/slider-field.tsx +6 -5
- package/src/fields/switch-field.tsx +14 -6
- package/src/fields/textarea-field.tsx +14 -6
- package/src/fields/year-field.tsx +3 -1
- package/src/index.ts +2 -0
- package/src/lib/empty-value.test.ts +72 -0
- package/src/lib/empty-value.ts +130 -0
- package/src/lib/schema-defaults.ts +50 -1
- package/src/lib/schema-required.test.ts +45 -3
- package/src/lib/schema-required.ts +113 -29
- package/src/lib/use-form-dialog.ts +11 -3
- package/src/lib/use-is-invalid.ts +14 -1
- package/src/lib/validation-modes.ts +18 -6
- package/src/use-app-fields.test.tsx +341 -4
|
@@ -9,9 +9,17 @@
|
|
|
9
9
|
// A missing parent hides its children (the schema stops at the missing key), so
|
|
10
10
|
// the probe descends: when an issue says it expected an object or an array
|
|
11
11
|
// (Zod and Valibot both put that on `issue.expected`), the next pass fills that
|
|
12
|
-
// path
|
|
13
|
-
//
|
|
14
|
-
//
|
|
12
|
+
// path and validates again. An object gets `{}`. An array gets one missing row
|
|
13
|
+
// (`[undefined]`), so the following pass probes the row's own schema. Rows are
|
|
14
|
+
// probed through index `0` and recorded index-agnostically, so
|
|
15
|
+
// `members[0].name` in the set matches every row's `members[3].name` via
|
|
16
|
+
// `toRequiredPathKey`.
|
|
17
|
+
//
|
|
18
|
+
// A missing field is then probed with an empty value: `""` for a string, then
|
|
19
|
+
// `null`; `[]` for an array; `null` for anything else. A field that accepts one
|
|
20
|
+
// (a bare `z.string()` or `z.array()`, anything `.nullable()`) leaves the set,
|
|
21
|
+
// matching `lib/empty-value.ts`; one that rejects them all (`.min(1)`,
|
|
22
|
+
// `z.email()`, a bare `z.number()`) stays.
|
|
15
23
|
//
|
|
16
24
|
// Paths use TanStack Form's field-name syntax: dots for object keys, brackets
|
|
17
25
|
// for array indices (`members[0].name`, not `members.0.name`).
|
|
@@ -42,7 +50,8 @@ type StandardSchemaLike = {
|
|
|
42
50
|
}
|
|
43
51
|
}
|
|
44
52
|
|
|
45
|
-
|
|
53
|
+
// Each level can take two passes: the empty value, then the descent.
|
|
54
|
+
const MAX_PROBE_DEPTH = 16
|
|
46
55
|
|
|
47
56
|
const INDEX_SEGMENT = /^\d+$/
|
|
48
57
|
|
|
@@ -86,22 +95,26 @@ function toRequiredPathKey(name: string): string {
|
|
|
86
95
|
return name.replace(INDEX_IN_NAME, "[0]")
|
|
87
96
|
}
|
|
88
97
|
|
|
89
|
-
function
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
98
|
+
function probeValue(expected: unknown): unknown {
|
|
99
|
+
switch (typeof expected === "string" ? expected.toLowerCase() : undefined) {
|
|
100
|
+
case "object":
|
|
101
|
+
return {}
|
|
102
|
+
case "array":
|
|
103
|
+
return []
|
|
104
|
+
case "string":
|
|
105
|
+
return ""
|
|
106
|
+
default:
|
|
107
|
+
return null
|
|
96
108
|
}
|
|
97
|
-
return kind === "array" ? [{}] : undefined
|
|
98
109
|
}
|
|
99
110
|
|
|
100
|
-
// Write `value` at `keys` inside `root`, only where nothing is set yet
|
|
111
|
+
// Write `value` at `keys` inside `root`, only where nothing is set yet unless
|
|
112
|
+
// `replace` is set.
|
|
101
113
|
function fillAt(
|
|
102
114
|
root: Record<PropertyKey, unknown>,
|
|
103
115
|
keys: readonly PropertyKey[],
|
|
104
|
-
value: unknown
|
|
116
|
+
value: unknown,
|
|
117
|
+
replace = false
|
|
105
118
|
): boolean {
|
|
106
119
|
let node: Record<PropertyKey, unknown> = root
|
|
107
120
|
for (const key of keys.slice(0, -1)) {
|
|
@@ -112,7 +125,7 @@ function fillAt(
|
|
|
112
125
|
node = next as Record<PropertyKey, unknown>
|
|
113
126
|
}
|
|
114
127
|
const last = keys.at(-1) as PropertyKey
|
|
115
|
-
if (node[last] !== undefined) {
|
|
128
|
+
if (!replace && node[last] !== undefined) {
|
|
116
129
|
return false
|
|
117
130
|
}
|
|
118
131
|
node[last] = value
|
|
@@ -135,27 +148,98 @@ function validateSync(
|
|
|
135
148
|
return "issues" in result && result.issues ? result.issues : []
|
|
136
149
|
}
|
|
137
150
|
|
|
151
|
+
type ProbeFill = {
|
|
152
|
+
readonly keys: readonly PropertyKey[]
|
|
153
|
+
readonly path: string
|
|
154
|
+
readonly value: unknown
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
type ProbePass = {
|
|
158
|
+
readonly failing: ReadonlySet<string>
|
|
159
|
+
readonly filled: readonly ProbeFill[]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Record one pass's failing paths, and fill the probe at each of them for the
|
|
163
|
+
// next pass. A path whose empty value already passed stays out of the set.
|
|
164
|
+
function recordPass(
|
|
165
|
+
issues: readonly StandardIssue[],
|
|
166
|
+
probe: Record<PropertyKey, unknown>,
|
|
167
|
+
required: Set<string>,
|
|
168
|
+
cleared: ReadonlySet<string>
|
|
169
|
+
): ProbePass {
|
|
170
|
+
const failing = new Set<string>()
|
|
171
|
+
const filled: ProbeFill[] = []
|
|
172
|
+
for (const issue of issues) {
|
|
173
|
+
const keys = issue.path?.map(pathKey) ?? []
|
|
174
|
+
if (keys.length === 0) {
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
const path = joinPath(keys)
|
|
178
|
+
failing.add(path)
|
|
179
|
+
if (!cleared.has(path)) {
|
|
180
|
+
required.add(path)
|
|
181
|
+
}
|
|
182
|
+
const value = probeValue(issue.expected)
|
|
183
|
+
if (value !== undefined && fillAt(probe, keys, value)) {
|
|
184
|
+
filled.push({ keys, path, value })
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return { failing, filled }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isEmptyProbe(value: unknown): boolean {
|
|
191
|
+
return value === "" || value === null || Array.isArray(value)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Settle what the previous pass filled with an empty value: a path that now
|
|
195
|
+
// passes leaves the required set. A string that rejects `""` gets `null` next,
|
|
196
|
+
// and an array checked empty gets one missing row, so the row is probed.
|
|
197
|
+
function settleEmptied(
|
|
198
|
+
emptied: readonly ProbeFill[],
|
|
199
|
+
failing: ReadonlySet<string>,
|
|
200
|
+
probe: Record<PropertyKey, unknown>,
|
|
201
|
+
required: Set<string>,
|
|
202
|
+
cleared: Set<string>
|
|
203
|
+
): { readonly next: ProbeFill[]; readonly descended: boolean } {
|
|
204
|
+
const next: ProbeFill[] = []
|
|
205
|
+
let descended = false
|
|
206
|
+
for (const fill of emptied) {
|
|
207
|
+
const passed = !failing.has(fill.path)
|
|
208
|
+
if (passed) {
|
|
209
|
+
required.delete(fill.path)
|
|
210
|
+
cleared.add(fill.path)
|
|
211
|
+
}
|
|
212
|
+
if (Array.isArray(fill.value)) {
|
|
213
|
+
fill.value.push(undefined)
|
|
214
|
+
descended = true
|
|
215
|
+
} else if (!passed && fill.value === "") {
|
|
216
|
+
fillAt(probe, fill.keys, null, true)
|
|
217
|
+
next.push({ ...fill, value: null })
|
|
218
|
+
descended = true
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return { descended, next }
|
|
222
|
+
}
|
|
223
|
+
|
|
138
224
|
function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
|
|
139
225
|
const required = new Set<string>()
|
|
226
|
+
const cleared = new Set<string>()
|
|
140
227
|
const probe: Record<PropertyKey, unknown> = {}
|
|
228
|
+
// What the previous pass filled with an empty value: each stays required
|
|
229
|
+
// only if it still fails.
|
|
230
|
+
let emptied: readonly ProbeFill[] = []
|
|
141
231
|
for (let depth = 0; depth < MAX_PROBE_DEPTH; depth += 1) {
|
|
142
232
|
const issues = validateSync(schema, probe)
|
|
143
233
|
if (!issues) {
|
|
144
234
|
return required
|
|
145
235
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const container = probeContainer(issue.expected)
|
|
154
|
-
if (container !== undefined && fillAt(probe, keys, container)) {
|
|
155
|
-
descended = true
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
if (!descended) {
|
|
236
|
+
const { failing, filled } = recordPass(issues, probe, required, cleared)
|
|
237
|
+
const settled = settleEmptied(emptied, failing, probe, required, cleared)
|
|
238
|
+
emptied = [
|
|
239
|
+
...filled.filter(({ value }) => isEmptyProbe(value)),
|
|
240
|
+
...settled.next,
|
|
241
|
+
]
|
|
242
|
+
if (filled.length === 0 && !settled.descended) {
|
|
159
243
|
break
|
|
160
244
|
}
|
|
161
245
|
}
|
|
@@ -163,4 +247,4 @@ function getRequiredPaths(schema: StandardSchemaLike): Set<string> {
|
|
|
163
247
|
}
|
|
164
248
|
|
|
165
249
|
export type { StandardSchemaLike }
|
|
166
|
-
export { getRequiredPaths, toRequiredPathKey }
|
|
250
|
+
export { getRequiredPaths, toRequiredPathKey, validateSync }
|
|
@@ -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 {
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
getFormHideFieldErrors,
|
|
7
7
|
getFormValidationMode,
|
|
8
8
|
isFieldRequired,
|
|
9
|
+
isFieldRequiredBySchema,
|
|
9
10
|
type ValidationMode,
|
|
10
11
|
} from "./validation-modes"
|
|
11
12
|
|
|
@@ -106,4 +107,16 @@ function useIsFieldRequired(field: AnyFieldApi): boolean {
|
|
|
106
107
|
return isFieldRequired(field.form, field.name)
|
|
107
108
|
}
|
|
108
109
|
|
|
109
|
-
|
|
110
|
+
// Returns whether the form's schema requires this field, even with
|
|
111
|
+
// `requiredIndicator: false`. Drives what a required field does (no clear
|
|
112
|
+
// button, `aria-required`), where `useIsFieldRequired` drives the `*`.
|
|
113
|
+
function useIsFieldRequiredBySchema(field: AnyFieldApi): boolean {
|
|
114
|
+
return isFieldRequiredBySchema(field.form, field.name)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export {
|
|
118
|
+
useHideFieldErrors,
|
|
119
|
+
useIsFieldRequired,
|
|
120
|
+
useIsFieldRequiredBySchema,
|
|
121
|
+
useIsInvalid,
|
|
122
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { AnyFormApi } from "@tanstack/react-form"
|
|
4
4
|
|
|
5
|
-
import { toRequiredPathKey } from "./schema-required"
|
|
5
|
+
import { type StandardSchemaLike, toRequiredPathKey } from "./schema-required"
|
|
6
6
|
|
|
7
7
|
type ValidationMode = "change" | "blur" | "submit" | "blur-then-change"
|
|
8
8
|
|
|
@@ -13,6 +13,7 @@ type ZenoFormState = {
|
|
|
13
13
|
hideFieldErrors: boolean
|
|
14
14
|
requiredIndicator: boolean
|
|
15
15
|
requiredFields: ReadonlySet<string>
|
|
16
|
+
schema: StandardSchemaLike | undefined
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
const EMPTY_REQUIRED_FIELDS: ReadonlySet<string> = new Set<string>()
|
|
@@ -21,6 +22,7 @@ const DEFAULT_FORM_STATE: ZenoFormState = {
|
|
|
21
22
|
hideFieldErrors: false,
|
|
22
23
|
requiredFields: EMPTY_REQUIRED_FIELDS,
|
|
23
24
|
requiredIndicator: true,
|
|
25
|
+
schema: undefined,
|
|
24
26
|
validation: DEFAULT_VALIDATION_MODE,
|
|
25
27
|
}
|
|
26
28
|
|
|
@@ -57,19 +59,29 @@ function getFormHideFieldErrors(form: AnyFormApi): boolean {
|
|
|
57
59
|
return getFormZenoState(form).hideFieldErrors
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
// Whether the schema requires the field, whatever `requiredIndicator` says.
|
|
63
|
+
function getFormSchema(form: AnyFormApi): StandardSchemaLike | undefined {
|
|
64
|
+
return getFormZenoState(form).schema
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isFieldRequiredBySchema(form: AnyFormApi, name: string): boolean {
|
|
68
|
+
return getFormZenoState(form).requiredFields.has(toRequiredPathKey(name))
|
|
69
|
+
}
|
|
70
|
+
|
|
60
71
|
function isFieldRequired(form: AnyFormApi, name: string): boolean {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
return state.requiredFields.has(toRequiredPathKey(name))
|
|
72
|
+
return (
|
|
73
|
+
getFormZenoState(form).requiredIndicator &&
|
|
74
|
+
isFieldRequiredBySchema(form, name)
|
|
75
|
+
)
|
|
66
76
|
}
|
|
67
77
|
|
|
68
78
|
export {
|
|
69
79
|
DEFAULT_VALIDATION_MODE,
|
|
70
80
|
getFormHideFieldErrors,
|
|
81
|
+
getFormSchema,
|
|
71
82
|
getFormValidationMode,
|
|
72
83
|
isFieldRequired,
|
|
84
|
+
isFieldRequiredBySchema,
|
|
73
85
|
setFormZenoState,
|
|
74
86
|
type ValidationMode,
|
|
75
87
|
}
|
|
@@ -1,13 +1,29 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
cleanup,
|
|
3
|
+
fireEvent,
|
|
4
|
+
render,
|
|
5
|
+
screen,
|
|
6
|
+
waitFor,
|
|
7
|
+
within,
|
|
8
|
+
} from "@zeno-lib/test/testing-library"
|
|
2
9
|
import userEvent from "@zeno-lib/test/user-event"
|
|
3
10
|
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
4
11
|
import { z } from "zod"
|
|
5
|
-
import {
|
|
12
|
+
import { SelectItem } from "@/components/ui/select"
|
|
13
|
+
import { Form, FormProvider, RadioGroupFieldItem, useForm } from "./create-form"
|
|
6
14
|
|
|
7
15
|
const CONTACT_EMAIL_LABEL = /Contact email/
|
|
8
16
|
const PASSWORD_LABEL = /Password/
|
|
9
17
|
const NAME_LABEL = /Name/
|
|
10
18
|
const ACCEPT_NAME = /Accept/
|
|
19
|
+
const TITLE_LABEL = /Title/
|
|
20
|
+
const NOTE_LABEL = /Note/
|
|
21
|
+
const DAY_LABEL = /Day/
|
|
22
|
+
const PICKED_DAY = /October 9/
|
|
23
|
+
const ROLE_LABEL = /Role/
|
|
24
|
+
const OWNER_LABEL = /Owner/
|
|
25
|
+
const PICK_A_ROLE = "Pick a role"
|
|
26
|
+
const PICK_A_DAY = "Pick a day"
|
|
11
27
|
|
|
12
28
|
afterEach(() => {
|
|
13
29
|
cleanup()
|
|
@@ -30,7 +46,6 @@ describe("EmailField / PasswordField", () => {
|
|
|
30
46
|
}
|
|
31
47
|
render(<H />)
|
|
32
48
|
const input = screen.getByLabelText(CONTACT_EMAIL_LABEL) as HTMLInputElement
|
|
33
|
-
expect(input.id).toBe("contactEmail")
|
|
34
49
|
expect(input.name).toBe("contactEmail")
|
|
35
50
|
await user.type(input, "u@example.com")
|
|
36
51
|
expect(input.value).toBe("u@example.com")
|
|
@@ -54,7 +69,6 @@ describe("EmailField / PasswordField", () => {
|
|
|
54
69
|
}
|
|
55
70
|
render(<H />)
|
|
56
71
|
const input = screen.getByLabelText(PASSWORD_LABEL) as HTMLInputElement
|
|
57
|
-
expect(input.id).toBe("password")
|
|
58
72
|
expect(input.name).toBe("password")
|
|
59
73
|
expect(input.type).toBe("password")
|
|
60
74
|
})
|
|
@@ -137,3 +151,326 @@ describe("CheckboxField — boolean state wrapper", () => {
|
|
|
137
151
|
expect(checkbox.getAttribute("aria-checked")).toBe("true")
|
|
138
152
|
})
|
|
139
153
|
})
|
|
154
|
+
|
|
155
|
+
describe("field ids", () => {
|
|
156
|
+
test("two forms with the same field name label their own controls", () => {
|
|
157
|
+
const schema = z.object({ description: z.string().min(1) })
|
|
158
|
+
function H({ label }: { label: string }) {
|
|
159
|
+
const form = useForm({ onSubmit: vi.fn(), schema })
|
|
160
|
+
const { InputField } = form
|
|
161
|
+
return (
|
|
162
|
+
<FormProvider form={form}>
|
|
163
|
+
<Form>
|
|
164
|
+
<InputField description="Hint" label={label} name="description" />
|
|
165
|
+
</Form>
|
|
166
|
+
</FormProvider>
|
|
167
|
+
)
|
|
168
|
+
}
|
|
169
|
+
render(
|
|
170
|
+
<>
|
|
171
|
+
<H label="Page description" />
|
|
172
|
+
<H label="Dialog description" />
|
|
173
|
+
</>
|
|
174
|
+
)
|
|
175
|
+
const page = screen.getByRole("textbox", { name: "Page description" })
|
|
176
|
+
const dialog = screen.getByRole("textbox", { name: "Dialog description" })
|
|
177
|
+
expect(dialog).not.toBe(page)
|
|
178
|
+
const hints = screen.getAllByText("Hint")
|
|
179
|
+
expect(page.getAttribute("aria-describedby")).toBe(hints[0]?.id)
|
|
180
|
+
expect(dialog.getAttribute("aria-describedby")).toBe(hints[1]?.id)
|
|
181
|
+
})
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
describe("ComboboxField", () => {
|
|
185
|
+
test("offers to clear an optional value but not a required one", () => {
|
|
186
|
+
const schema = z.object({
|
|
187
|
+
forced: z.string().optional(),
|
|
188
|
+
kind: z.string().min(1),
|
|
189
|
+
note: z.string().optional(),
|
|
190
|
+
overridden: z.string().min(1),
|
|
191
|
+
})
|
|
192
|
+
const ITEMS = ["a", "b"]
|
|
193
|
+
function H() {
|
|
194
|
+
const form = useForm({
|
|
195
|
+
defaultValues: { forced: "a", kind: "a", note: "a", overridden: "a" },
|
|
196
|
+
onSubmit: vi.fn(),
|
|
197
|
+
schema,
|
|
198
|
+
})
|
|
199
|
+
const { ComboboxField } = form
|
|
200
|
+
return (
|
|
201
|
+
<FormProvider form={form}>
|
|
202
|
+
<Form>
|
|
203
|
+
<ComboboxField items={ITEMS} name="note" />
|
|
204
|
+
<ComboboxField items={ITEMS} name="kind" />
|
|
205
|
+
<ComboboxField items={ITEMS} name="forced" required />
|
|
206
|
+
<ComboboxField items={ITEMS} name="overridden" showClear />
|
|
207
|
+
</Form>
|
|
208
|
+
</FormProvider>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
const { container } = render(<H />)
|
|
212
|
+
const hasClear = (name: string) => {
|
|
213
|
+
const root = container.querySelector<HTMLElement>(
|
|
214
|
+
`[data-field="${name}"]`
|
|
215
|
+
)
|
|
216
|
+
return root
|
|
217
|
+
? within(root).queryByRole("button", { name: "Clear" }) !== null
|
|
218
|
+
: false
|
|
219
|
+
}
|
|
220
|
+
expect(hasClear("note")).toBe(true)
|
|
221
|
+
expect(hasClear("kind")).toBe(false)
|
|
222
|
+
expect(hasClear("forced")).toBe(false)
|
|
223
|
+
expect(hasClear("overridden")).toBe(true)
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe("required fields", () => {
|
|
228
|
+
test("a required control says so to assistive tech, with or without the `*`", () => {
|
|
229
|
+
const schema = z.object({
|
|
230
|
+
kind: z.string().min(1),
|
|
231
|
+
note: z.string(),
|
|
232
|
+
title: z.string().min(1),
|
|
233
|
+
})
|
|
234
|
+
function H({ requiredIndicator }: { requiredIndicator: boolean }) {
|
|
235
|
+
const form = useForm({
|
|
236
|
+
defaultValues: { kind: "a" },
|
|
237
|
+
onSubmit: vi.fn(),
|
|
238
|
+
requiredIndicator,
|
|
239
|
+
schema,
|
|
240
|
+
})
|
|
241
|
+
const { ComboboxField, InputField } = form
|
|
242
|
+
return (
|
|
243
|
+
<FormProvider form={form}>
|
|
244
|
+
<Form>
|
|
245
|
+
<InputField label="Title" name="title" />
|
|
246
|
+
<InputField label="Note" name="note" />
|
|
247
|
+
<ComboboxField items={["a", "b"]} label="Kind" name="kind" />
|
|
248
|
+
</Form>
|
|
249
|
+
</FormProvider>
|
|
250
|
+
)
|
|
251
|
+
}
|
|
252
|
+
for (const requiredIndicator of [true, false]) {
|
|
253
|
+
const { container, unmount } = render(
|
|
254
|
+
<H requiredIndicator={requiredIndicator} />
|
|
255
|
+
)
|
|
256
|
+
const title = screen.getByRole("textbox", { name: TITLE_LABEL })
|
|
257
|
+
const note = screen.getByRole("textbox", { name: NOTE_LABEL })
|
|
258
|
+
expect(title.getAttribute("aria-required")).toBe("true")
|
|
259
|
+
expect(note.hasAttribute("aria-required")).toBe(false)
|
|
260
|
+
expect(
|
|
261
|
+
container.querySelectorAll('[data-slot="required-indicator"]')
|
|
262
|
+
).toHaveLength(requiredIndicator ? 2 : 0)
|
|
263
|
+
expect(
|
|
264
|
+
container.querySelector(
|
|
265
|
+
'[data-field="kind"] [data-slot="combobox-clear"]'
|
|
266
|
+
)
|
|
267
|
+
).toBeNull()
|
|
268
|
+
unmount()
|
|
269
|
+
}
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe("DatePickerField", () => {
|
|
274
|
+
const DAY = new Date(2026, 9, 9)
|
|
275
|
+
|
|
276
|
+
async function pickSelectedDayAgain(required: boolean) {
|
|
277
|
+
const captured: { day?: Date } = {}
|
|
278
|
+
function H() {
|
|
279
|
+
const form = useForm({
|
|
280
|
+
defaultValues: { day: DAY as Date | undefined },
|
|
281
|
+
onSubmit: vi.fn(),
|
|
282
|
+
})
|
|
283
|
+
const { DatePickerField, Subscribe } = form
|
|
284
|
+
return (
|
|
285
|
+
<FormProvider form={form}>
|
|
286
|
+
<Form>
|
|
287
|
+
<DatePickerField label="Day" name="day" required={required} />
|
|
288
|
+
<Subscribe selector={(state) => state.values.day}>
|
|
289
|
+
{(day) => {
|
|
290
|
+
captured.day = day
|
|
291
|
+
return null
|
|
292
|
+
}}
|
|
293
|
+
</Subscribe>
|
|
294
|
+
</Form>
|
|
295
|
+
</FormProvider>
|
|
296
|
+
)
|
|
297
|
+
}
|
|
298
|
+
const user = userEvent.setup()
|
|
299
|
+
render(<H />)
|
|
300
|
+
fireEvent.click(screen.getByRole("button", { name: DAY_LABEL }))
|
|
301
|
+
await user.click(await screen.findByRole("button", { name: PICKED_DAY }))
|
|
302
|
+
return captured.day
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
test("picking a required date's day again keeps it", async () => {
|
|
306
|
+
expect(await pickSelectedDayAgain(true)).toEqual(DAY)
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
test("picking an optional date's day again clears it", async () => {
|
|
310
|
+
expect(await pickSelectedDayAgain(false)).toBeUndefined()
|
|
311
|
+
})
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
describe("ComboboxField with items still loading", () => {
|
|
315
|
+
test("shows a stored id's label once its item arrives, never the raw id", () => {
|
|
316
|
+
function H({ items }: { items: { label: string; value: string }[] }) {
|
|
317
|
+
const form = useForm({
|
|
318
|
+
defaultValues: { ownerId: "user-7" },
|
|
319
|
+
onSubmit: vi.fn(),
|
|
320
|
+
})
|
|
321
|
+
const { ComboboxField } = form
|
|
322
|
+
return (
|
|
323
|
+
<FormProvider form={form}>
|
|
324
|
+
<Form>
|
|
325
|
+
<ComboboxField items={items} label="Owner" name="ownerId" />
|
|
326
|
+
</Form>
|
|
327
|
+
</FormProvider>
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
const { rerender } = render(<H items={[]} />)
|
|
331
|
+
const input = screen.getByRole("combobox", { name: OWNER_LABEL })
|
|
332
|
+
expect(input).toHaveProperty("value", "")
|
|
333
|
+
rerender(<H items={[{ label: "Ada Lovelace", value: "user-7" }]} />)
|
|
334
|
+
expect(input).toHaveProperty("value", "Ada Lovelace")
|
|
335
|
+
})
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
describe("popup fields", () => {
|
|
339
|
+
test("a required select shows its error once the popup closes, not while it is open", async () => {
|
|
340
|
+
const user = userEvent.setup()
|
|
341
|
+
function H() {
|
|
342
|
+
const form = useForm({
|
|
343
|
+
onSubmit: vi.fn(),
|
|
344
|
+
schema: z.object({ role: z.string().min(1, PICK_A_ROLE) }),
|
|
345
|
+
})
|
|
346
|
+
const { SelectField } = form
|
|
347
|
+
return (
|
|
348
|
+
<FormProvider form={form}>
|
|
349
|
+
<Form>
|
|
350
|
+
<SelectField label="Role" name="role">
|
|
351
|
+
<SelectItem value="admin">Admin</SelectItem>
|
|
352
|
+
</SelectField>
|
|
353
|
+
</Form>
|
|
354
|
+
</FormProvider>
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
render(<H />)
|
|
358
|
+
await user.click(screen.getByRole("combobox", { name: ROLE_LABEL }))
|
|
359
|
+
await screen.findByRole("option", { name: "Admin" })
|
|
360
|
+
expect(screen.queryByText(PICK_A_ROLE)).toBeNull()
|
|
361
|
+
await user.keyboard("{Escape}")
|
|
362
|
+
expect(await screen.findByText(PICK_A_ROLE)).toBeTruthy()
|
|
363
|
+
})
|
|
364
|
+
|
|
365
|
+
test("a required date shows its error once the popover closes, not while it is open", async () => {
|
|
366
|
+
const user = userEvent.setup()
|
|
367
|
+
function H() {
|
|
368
|
+
const form = useForm({
|
|
369
|
+
onSubmit: vi.fn(),
|
|
370
|
+
schema: z.object({ day: z.date({ error: PICK_A_DAY }) }),
|
|
371
|
+
})
|
|
372
|
+
const { DatePickerField } = form
|
|
373
|
+
return (
|
|
374
|
+
<FormProvider form={form}>
|
|
375
|
+
<Form>
|
|
376
|
+
<DatePickerField label="Day" name="day" />
|
|
377
|
+
</Form>
|
|
378
|
+
</FormProvider>
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
render(<H />)
|
|
382
|
+
const trigger = screen.getByRole("button", { name: DAY_LABEL })
|
|
383
|
+
trigger.focus()
|
|
384
|
+
fireEvent.click(trigger)
|
|
385
|
+
await screen.findByRole("grid")
|
|
386
|
+
// The trigger's blur is what used to mark the field touched.
|
|
387
|
+
await waitFor(() => expect(document.activeElement).not.toBe(trigger))
|
|
388
|
+
expect(screen.queryByText(PICK_A_DAY)).toBeNull()
|
|
389
|
+
await user.keyboard("{Escape}")
|
|
390
|
+
expect(await screen.findByText(PICK_A_DAY)).toBeTruthy()
|
|
391
|
+
})
|
|
392
|
+
})
|
|
393
|
+
|
|
394
|
+
describe("RadioGroupField", () => {
|
|
395
|
+
test("stores each item's value as is", async () => {
|
|
396
|
+
const user = userEvent.setup()
|
|
397
|
+
const captured: { consent?: unknown } = {}
|
|
398
|
+
function H() {
|
|
399
|
+
const form = useForm({
|
|
400
|
+
defaultValues: { consent: undefined as boolean | undefined },
|
|
401
|
+
onSubmit: vi.fn(),
|
|
402
|
+
})
|
|
403
|
+
const { RadioGroupField, Subscribe } = form
|
|
404
|
+
return (
|
|
405
|
+
<FormProvider form={form}>
|
|
406
|
+
<Form>
|
|
407
|
+
<RadioGroupField label="Consent" name="consent">
|
|
408
|
+
<RadioGroupFieldItem value={true}>Yes</RadioGroupFieldItem>
|
|
409
|
+
<RadioGroupFieldItem value={false}>No</RadioGroupFieldItem>
|
|
410
|
+
</RadioGroupField>
|
|
411
|
+
<Subscribe selector={(state) => state.values.consent}>
|
|
412
|
+
{(consent) => {
|
|
413
|
+
captured.consent = consent
|
|
414
|
+
return null
|
|
415
|
+
}}
|
|
416
|
+
</Subscribe>
|
|
417
|
+
</Form>
|
|
418
|
+
</FormProvider>
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
render(<H />)
|
|
422
|
+
await user.click(screen.getByRole("radio", { name: "No" }))
|
|
423
|
+
expect(captured.consent).toBe(false)
|
|
424
|
+
await user.click(screen.getByRole("radio", { name: "Yes" }))
|
|
425
|
+
expect(captured.consent).toBe(true)
|
|
426
|
+
})
|
|
427
|
+
})
|
|
428
|
+
|
|
429
|
+
describe("clearing a field", () => {
|
|
430
|
+
test("empties it to a value its schema accepts", async () => {
|
|
431
|
+
const user = userEvent.setup()
|
|
432
|
+
const OWNER_ID = "00000000-0000-4000-8000-000000000001"
|
|
433
|
+
const schema = z.object({ kind: z.string(), ownerId: z.uuid().nullable() })
|
|
434
|
+
const captured: { values?: unknown } = {}
|
|
435
|
+
function H() {
|
|
436
|
+
const form = useForm({
|
|
437
|
+
defaultValues: { kind: "a", ownerId: OWNER_ID },
|
|
438
|
+
onSubmit: vi.fn(),
|
|
439
|
+
schema,
|
|
440
|
+
})
|
|
441
|
+
const { ComboboxField, Subscribe } = form
|
|
442
|
+
return (
|
|
443
|
+
<FormProvider form={form}>
|
|
444
|
+
<Form>
|
|
445
|
+
<ComboboxField items={["a", "b"]} label="Kind" name="kind" />
|
|
446
|
+
<ComboboxField
|
|
447
|
+
items={[{ label: "Ada Lovelace", value: OWNER_ID }]}
|
|
448
|
+
label="Owner"
|
|
449
|
+
name="ownerId"
|
|
450
|
+
/>
|
|
451
|
+
<Subscribe selector={(state) => state.values}>
|
|
452
|
+
{(values) => {
|
|
453
|
+
captured.values = values
|
|
454
|
+
return null
|
|
455
|
+
}}
|
|
456
|
+
</Subscribe>
|
|
457
|
+
</Form>
|
|
458
|
+
</FormProvider>
|
|
459
|
+
)
|
|
460
|
+
}
|
|
461
|
+
const { container } = render(<H />)
|
|
462
|
+
const clearButton = (name: string) => {
|
|
463
|
+
const root = container.querySelector<HTMLElement>(
|
|
464
|
+
`[data-field="${name}"]`
|
|
465
|
+
)
|
|
466
|
+
if (!root) {
|
|
467
|
+
throw new Error(`no ${name} field`)
|
|
468
|
+
}
|
|
469
|
+
return within(root).getByRole("button", { name: "Clear" })
|
|
470
|
+
}
|
|
471
|
+
await user.click(clearButton("kind"))
|
|
472
|
+
await user.click(clearButton("ownerId"))
|
|
473
|
+
expect(captured.values).toEqual({ kind: "", ownerId: null })
|
|
474
|
+
expect(schema.safeParse(captured.values).success).toBe(true)
|
|
475
|
+
})
|
|
476
|
+
})
|