@zeno-lib/forms 0.3.0 → 0.5.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 +9 -3
- package/dist/form-element.mjs +7 -5
- package/dist/index.d.mts +2 -2
- package/dist/index.mjs +3 -3
- package/dist/lib/empty-value.d.mts +12 -0
- package/dist/lib/empty-value.mjs +68 -0
- package/dist/lib/focus-first-invalid.d.mts +23 -0
- package/dist/lib/focus-first-invalid.mjs +37 -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 +49 -21
- package/dist/lib/submit-action.mjs +1 -1
- package/dist/lib/submit-error.d.mts +36 -0
- package/dist/lib/submit-error.mjs +66 -0
- package/dist/lib/use-form-dialog.d.mts +15 -1
- package/dist/lib/use-form-dialog.mjs +20 -2
- 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 +1 -1
- package/src/create-form.tsx +2 -0
- package/src/create-zeno-form.tsx +25 -5
- package/src/fields/checkbox-field.tsx +8 -1
- package/src/fields/combobox-field.tsx +29 -12
- package/src/fields/date-picker-field.tsx +37 -9
- package/src/fields/email-field.tsx +8 -0
- package/src/fields/form-error.tsx +61 -0
- package/src/fields/index.ts +1 -0
- package/src/fields/input-field.tsx +6 -2
- package/src/fields/money-field.tsx +3 -1
- package/src/fields/multi-select-field.tsx +8 -1
- package/src/fields/number-field.tsx +9 -7
- package/src/fields/otp-field.tsx +8 -1
- package/src/fields/percentage-field.tsx +3 -1
- package/src/fields/radio-group-field.tsx +11 -4
- package/src/fields/select-field.tsx +26 -3
- package/src/fields/switch-field.tsx +8 -1
- package/src/fields/textarea-field.tsx +8 -1
- package/src/fields/year-field.tsx +3 -1
- package/src/form-dialog.test.tsx +493 -2
- package/src/form-dialog.tsx +217 -33
- package/src/form-element.tsx +13 -11
- package/src/form-error.test.tsx +255 -0
- package/src/index.ts +1 -0
- package/src/lib/empty-value.test.ts +72 -0
- package/src/lib/empty-value.ts +130 -0
- package/src/lib/focus-first-invalid.test.ts +22 -0
- package/src/lib/focus-first-invalid.ts +52 -0
- package/src/lib/schema-defaults.ts +50 -1
- package/src/lib/schema-required.test.ts +24 -2
- package/src/lib/schema-required.ts +81 -43
- package/src/lib/submit-error.ts +133 -0
- package/src/lib/use-form-dialog.ts +49 -1
- package/src/lib/use-is-invalid.ts +14 -1
- package/src/lib/validation-modes.ts +18 -6
- package/src/use-app-fields.test.tsx +312 -2
- package/src/use-form.focus-invalid.test.tsx +34 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import { useCallback, useSyncExternalStore } from "react"
|
|
4
|
+
|
|
5
|
+
// A submit whose handler threw (a network failure, an unexpected server
|
|
6
|
+
// error), kept per form so `FormError` can show it.
|
|
7
|
+
//
|
|
8
|
+
// Not written to TanStack's `errorMap`: any entry there makes the form
|
|
9
|
+
// invalid, which disables `SubmitButton`, and `handleSubmit` then skips
|
|
10
|
+
// `onSubmit` until an edit clears the entry. The user couldn't retry a failed
|
|
11
|
+
// save as is.
|
|
12
|
+
|
|
13
|
+
type SubmitErrorState = {
|
|
14
|
+
readonly submissionAttempts: number
|
|
15
|
+
readonly values: unknown
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// The slice of a form the helpers read. Keyed by `store`, as the per-form
|
|
19
|
+
// Zeno state is (`validation-modes.ts`): it is the one reference shared by
|
|
20
|
+
// the `useForm()` result and the form API that fields and context receive.
|
|
21
|
+
type SubmitErrorForm = {
|
|
22
|
+
readonly state: SubmitErrorState
|
|
23
|
+
readonly store: {
|
|
24
|
+
subscribe: (listener: () => void) => { unsubscribe: () => void }
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type SubmitError = {
|
|
29
|
+
/** What the submit handler threw. */
|
|
30
|
+
readonly error: unknown
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type Entry = {
|
|
34
|
+
readonly submitError: SubmitError
|
|
35
|
+
readonly unsubscribe: () => void
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const entries = new WeakMap<object, Entry>()
|
|
39
|
+
const listeners = new WeakMap<object, Set<() => void>>()
|
|
40
|
+
|
|
41
|
+
function notify(key: object): void {
|
|
42
|
+
for (const listener of listeners.get(key) ?? []) {
|
|
43
|
+
listener()
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function clear(key: object): void {
|
|
48
|
+
const entry = entries.get(key)
|
|
49
|
+
if (entry) {
|
|
50
|
+
entry.unsubscribe()
|
|
51
|
+
entries.delete(key)
|
|
52
|
+
notify(key)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Next.js `redirect()`, `notFound()`, `forbidden()` and `unauthorized()` throw
|
|
57
|
+
// an error whose `digest` starts with `NEXT_`, and a server action that calls
|
|
58
|
+
// one rejects with it on the client. Next navigates on its own: no failure.
|
|
59
|
+
function isNavigationError(error: unknown): boolean {
|
|
60
|
+
if (typeof error !== "object" || error === null || !("digest" in error)) {
|
|
61
|
+
return false
|
|
62
|
+
}
|
|
63
|
+
return typeof error.digest === "string" && error.digest.startsWith("NEXT_")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Record that `form`'s submit threw `error`. `useForm` calls it for what
|
|
68
|
+
* `onSubmit` throws; call it for a failure your own submit code catches
|
|
69
|
+
* outside `onSubmit`. The error lasts until the values change or the next
|
|
70
|
+
* submit starts (`reset()` does both), so an edit or a retry clears it. A
|
|
71
|
+
* Next.js navigation (`redirect()`, `notFound()`) is ignored.
|
|
72
|
+
*/
|
|
73
|
+
function setSubmitError(form: SubmitErrorForm, error: unknown): void {
|
|
74
|
+
if (isNavigationError(error)) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
const key = form.store
|
|
78
|
+
entries.get(key)?.unsubscribe()
|
|
79
|
+
const { submissionAttempts, values } = form.state
|
|
80
|
+
// Dropped, not just hidden, once stale: a reset restores the attempt count
|
|
81
|
+
// and can restore the same values object, which would bring it back.
|
|
82
|
+
const subscription = form.store.subscribe(() => {
|
|
83
|
+
const { state } = form
|
|
84
|
+
if (
|
|
85
|
+
state.values !== values ||
|
|
86
|
+
state.submissionAttempts !== submissionAttempts
|
|
87
|
+
) {
|
|
88
|
+
clear(key)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
entries.set(key, {
|
|
92
|
+
submitError: { error },
|
|
93
|
+
unsubscribe: subscription.unsubscribe,
|
|
94
|
+
})
|
|
95
|
+
notify(key)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Report a submit rejection that is caught so the user can retry, as
|
|
100
|
+
* `<Form>` and `FormDialog` do: `reportError` logs it and passes it to the
|
|
101
|
+
* page's `error` listeners (where error monitoring hooks in), as if it were
|
|
102
|
+
* uncaught. A Next.js navigation is skipped.
|
|
103
|
+
*/
|
|
104
|
+
function reportSubmitError(error: unknown): void {
|
|
105
|
+
if (!isNavigationError(error)) {
|
|
106
|
+
// Missing in jsdom and older browsers.
|
|
107
|
+
globalThis.reportError?.(error)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The error `form`'s last submit threw, until it went stale. */
|
|
112
|
+
function useSubmitError(form: SubmitErrorForm): SubmitError | undefined {
|
|
113
|
+
const key = form.store
|
|
114
|
+
const subscribe = useCallback(
|
|
115
|
+
(listener: () => void) => {
|
|
116
|
+
const set = listeners.get(key) ?? new Set()
|
|
117
|
+
listeners.set(key, set)
|
|
118
|
+
set.add(listener)
|
|
119
|
+
return () => {
|
|
120
|
+
set.delete(listener)
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
[key]
|
|
124
|
+
)
|
|
125
|
+
return useSyncExternalStore(
|
|
126
|
+
subscribe,
|
|
127
|
+
() => entries.get(key)?.submitError,
|
|
128
|
+
() => undefined
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export type { SubmitError, SubmitErrorForm }
|
|
133
|
+
export { reportSubmitError, setSubmitError, useSubmitError }
|
|
@@ -97,6 +97,11 @@ function useFormDialog<TValues>(
|
|
|
97
97
|
type UseLeaveGuardOptions = {
|
|
98
98
|
/** Whether leaving now would lose changes. */
|
|
99
99
|
hasUnsavedChanges: boolean
|
|
100
|
+
/**
|
|
101
|
+
* Save the changes and resolve whether that worked. Backs `saveAndLeave`,
|
|
102
|
+
* so the confirm UI can offer Save next to Discard.
|
|
103
|
+
*/
|
|
104
|
+
onSave?: () => boolean | Promise<boolean>
|
|
100
105
|
}
|
|
101
106
|
|
|
102
107
|
type LeaveGuard = {
|
|
@@ -111,14 +116,25 @@ type LeaveGuard = {
|
|
|
111
116
|
confirmLeave: () => void
|
|
112
117
|
/** The user chose to stay: drop the held leave. */
|
|
113
118
|
cancelLeave: () => void
|
|
119
|
+
/**
|
|
120
|
+
* The user chose to save: run `onSave`, then the held leave if it saved.
|
|
121
|
+
* Either way the confirm UI closes; after a failed save the user stays.
|
|
122
|
+
* Resolves whether it saved. A rejection from `onSave` keeps the leave held
|
|
123
|
+
* and propagates.
|
|
124
|
+
*/
|
|
125
|
+
saveAndLeave: () => Promise<boolean>
|
|
126
|
+
/** Whether `saveAndLeave` is waiting on `onSave`. */
|
|
127
|
+
isSaving: boolean
|
|
114
128
|
}
|
|
115
129
|
|
|
116
130
|
/** Ask before a leave (close, navigate, switch record) drops unsaved changes. */
|
|
117
131
|
function useLeaveGuard({
|
|
118
132
|
hasUnsavedChanges,
|
|
133
|
+
onSave,
|
|
119
134
|
}: UseLeaveGuardOptions): LeaveGuard {
|
|
120
135
|
const pending = useRef<(() => void) | null>(null)
|
|
121
136
|
const [isConfirming, setIsConfirming] = useState(false)
|
|
137
|
+
const [isSaving, setIsSaving] = useState(false)
|
|
122
138
|
|
|
123
139
|
const requestLeave = useCallback(
|
|
124
140
|
(proceed: () => void) => {
|
|
@@ -144,7 +160,39 @@ function useLeaveGuard({
|
|
|
144
160
|
setIsConfirming(false)
|
|
145
161
|
}, [])
|
|
146
162
|
|
|
147
|
-
|
|
163
|
+
const saveAndLeave = useCallback(async () => {
|
|
164
|
+
const proceed = pending.current
|
|
165
|
+
if (!(proceed && onSave)) {
|
|
166
|
+
return false
|
|
167
|
+
}
|
|
168
|
+
setIsSaving(true)
|
|
169
|
+
let saved = false
|
|
170
|
+
try {
|
|
171
|
+
saved = await onSave()
|
|
172
|
+
} finally {
|
|
173
|
+
setIsSaving(false)
|
|
174
|
+
}
|
|
175
|
+
// Answered otherwise while saving (cancelled, or a newer leave replaced
|
|
176
|
+
// this one): leave that answer alone.
|
|
177
|
+
if (pending.current !== proceed) {
|
|
178
|
+
return saved
|
|
179
|
+
}
|
|
180
|
+
pending.current = null
|
|
181
|
+
setIsConfirming(false)
|
|
182
|
+
if (saved) {
|
|
183
|
+
proceed()
|
|
184
|
+
}
|
|
185
|
+
return saved
|
|
186
|
+
}, [onSave])
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
cancelLeave,
|
|
190
|
+
confirmLeave,
|
|
191
|
+
isConfirming,
|
|
192
|
+
isSaving,
|
|
193
|
+
requestLeave,
|
|
194
|
+
saveAndLeave,
|
|
195
|
+
}
|
|
148
196
|
}
|
|
149
197
|
|
|
150
198
|
const FOCUSABLE =
|
|
@@ -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()
|
|
@@ -164,3 +180,297 @@ describe("field ids", () => {
|
|
|
164
180
|
expect(dialog.getAttribute("aria-describedby")).toBe(hints[1]?.id)
|
|
165
181
|
})
|
|
166
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
|
+
})
|
|
@@ -9,6 +9,7 @@ import type { ReactNode } from "react"
|
|
|
9
9
|
import { afterEach, describe, expect, test, vi } from "vitest"
|
|
10
10
|
import { z } from "zod"
|
|
11
11
|
import { Form, FormProvider, RadioGroupFieldItem, useForm } from "./create-form"
|
|
12
|
+
import { submitAction } from "./lib/submit-action"
|
|
12
13
|
|
|
13
14
|
afterEach(() => {
|
|
14
15
|
cleanup()
|
|
@@ -103,6 +104,39 @@ describe("<Form> — focus the first invalid field on a failed submit", () => {
|
|
|
103
104
|
)
|
|
104
105
|
})
|
|
105
106
|
|
|
107
|
+
test("focuses a field error returned through submitAction", async () => {
|
|
108
|
+
const user = userEvent.setup()
|
|
109
|
+
function Harness() {
|
|
110
|
+
const form = useForm({
|
|
111
|
+
defaultValues: { email: "ada@example.com", name: "Ada" },
|
|
112
|
+
onSubmit: (submit) =>
|
|
113
|
+
submitAction(submit, () =>
|
|
114
|
+
Promise.resolve({
|
|
115
|
+
error: { fieldErrors: { name: ["Taken"] }, formErrors: [] },
|
|
116
|
+
ok: false as const,
|
|
117
|
+
})
|
|
118
|
+
),
|
|
119
|
+
schema,
|
|
120
|
+
})
|
|
121
|
+
const { EmailField, InputField, SubmitButton } = form
|
|
122
|
+
return (
|
|
123
|
+
<FormProvider form={form}>
|
|
124
|
+
<Form aria-label="Profile">
|
|
125
|
+
<EmailField name="email" />
|
|
126
|
+
<InputField label="Name" name="name" />
|
|
127
|
+
<SubmitButton>Save</SubmitButton>
|
|
128
|
+
</Form>
|
|
129
|
+
</FormProvider>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
render(<Harness />)
|
|
133
|
+
await user.click(screen.getByRole("button", { name: "Save" }))
|
|
134
|
+
expect(await screen.findByText("Taken")).toBeTruthy()
|
|
135
|
+
await waitFor(() =>
|
|
136
|
+
expect(document.activeElement).toBe(control("Profile", "name"))
|
|
137
|
+
)
|
|
138
|
+
})
|
|
139
|
+
|
|
106
140
|
test("a radio group or slider root hands focus to its tabbable child", async () => {
|
|
107
141
|
const user = userEvent.setup()
|
|
108
142
|
function Harness({ sliderFirst }: { sliderFirst: boolean }) {
|