Behive Tech Component Registry

Form Field Managed

v0.1.8Stable

`FormField` with its react-hook-form wiring already done — the control's value/onChange/onBlur/ref, the label association, and the error message.

pnpm add @behivetech/forms.form-field-managed

registry/behivetech/forms/form-field-managed · depends on @behivetech/forms.form-field, @behivetech/forms.form-provider

form-field-managed.tsx
1import { type ReactNode, useId } from "react";
2import { FormField, type FormFieldProps } from "@behivetech/forms.form-field";
3import {
4 useController,
5 type Control,
6 type ControllerFieldState,
7 type ControllerRenderProps,
8 type FieldPath,
9 type FieldValues,
10 type RegisterOptions,
11} from "@behivetech/forms.form-provider";
12
13/**
14 * What the render prop receives: the wired field plus its `id`.
15 *
16 * `TName` is threaded through to `ControllerRenderProps` rather than left at its
17 * default. Without it `value` widens to the union of every field type in the
18 * form, so a call site spreading onto a `TextField` has to assert
19 * `value as string` — an assertion that keeps compiling if the field later
20 * changes type, which is the mistake the generic exists to catch.
21 */
22export type ManagedField<
23 TFieldValues extends FieldValues = FieldValues,
24 TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
25> = ControllerRenderProps<TFieldValues, TName> & {
26 /** Matches the label's `htmlFor`; spread onto the control or pass explicitly */
27 id: string;
28};
29
30export interface FormFieldManagedProps<
31 TFieldValues extends FieldValues = FieldValues,
32 TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
33> extends Omit<FormFieldProps, "children" | "error" | "htmlFor"> {
34 /** Field path in the form values; infers the field's value type */
35 name: TName;
36 /** Form control; falls back to the surrounding `useFormContext()` when omitted */
37 control?: Control<TFieldValues>;
38 /** Validation rules passed through to react-hook-form */
39 rules?: RegisterOptions<TFieldValues, TName>;
40 /** Renders the control from the wired field */
41 children: (
42 field: ManagedField<TFieldValues, TName>,
43 fieldState: ControllerFieldState,
44 ) => ReactNode;
45}
46
47/**
48 * `FormField` with its react-hook-form wiring already done: the control's
49 * value/onChange/onBlur/ref, the label association, and the error message.
50 *
51 * Uses `useController` rather than `register` because `register` only works on
52 * controls that render a native input. `TextField` and `Textarea` do;
53 * `Checkbox`, `Toggle` and `Select` are Radix-backed, where the ref would land
54 * on the trigger button and the change callback is `onCheckedChange` /
55 * `onValueChange`, so `register` binds nothing and the field silently keeps its
56 * default. `useController` works for all of them.
57 *
58 * The control is supplied through a render prop rather than cloned, because the
59 * prop a control listens on is the one thing that genuinely differs between
60 * them — worth seeing at the call site, and impossible to type if hidden.
61 *
62 * Pass `control` and both type parameters infer, so `field.value` is the type
63 * of that one field and spreads onto a control without a cast:
64 *
65 * ```tsx
66 * <FormFieldManaged control={methods.control} name="email" label="Email">
67 * {(field) => <TextField {...field} />}
68 * </FormFieldManaged>
69 * ```
70 *
71 * Taking `control` from context instead means naming both parameters. Supplying
72 * only the first — `<FormFieldManaged<Values> name="email">` — leaves `TName` at
73 * its default and widens `field.value` to a union of every field in the form,
74 * because TypeScript stops inferring the rest once any type argument is given
75 * explicitly:
76 *
77 * ```tsx
78 * <FormFieldManaged<Values, "email"> name="email" label="Email">
79 * {(field) => <TextField {...field} />}
80 * </FormFieldManaged>
81 * ```
82 */
83export const FormFieldManaged = <
84 TFieldValues extends FieldValues = FieldValues,
85 TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
86>({
87 name,
88 control,
89 rules,
90 children,
91 ...fieldProps
92}: FormFieldManagedProps<TFieldValues, TName>) => {
93 const id = useId();
94 const { field, fieldState } = useController<TFieldValues, TName>({
95 name,
96 control,
97 rules,
98 });
99
100 return (
101 <FormField {...fieldProps} htmlFor={id} error={fieldState.error?.message}>
102 {children({ ...field, id }, fieldState)}
103 </FormField>
104 );
105};
form-field-managed.type-test.tsx
1import { useForm } from "@behivetech/forms.form-provider";
2import { FormFieldManaged } from "./form-field-managed.js";
3
4/**
5 * Compile-time coverage for the render prop's `value` narrowing.
6 *
7 * This lives outside the spec file on purpose: `tsconfig.json` excludes
8 * `**\/*.spec.*`, so nothing in the specs is type-checked and a widening
9 * regression there would surface only in a consumer's build — which is how
10 * `field.value` shipped as a union of every field type in the form. `tsc
11 * --noEmit` covers this file, so the narrowing is enforced by `check-types`.
12 *
13 * Nothing here runs; it only has to compile.
14 */
15
16interface Values {
17 email: string;
18 active: boolean;
19}
20
21/** Fails to compile if `field.value` is wider than `string`. */
22const expectString = (value: string) => value;
23
24// Both type arguments supplied: `TName` narrows `value` to that field's type.
25export const ExplicitBothArgs = () => (
26 <FormFieldManaged<Values, "email"> name="email" label="Email">
27 {(field) => <span>{expectString(field.value)}</span>}
28 </FormFieldManaged>
29);
30
31// `control` supplied: both parameters infer, nothing written by hand.
32export const InferredFromControl = () => {
33 const methods = useForm<Values>({
34 defaultValues: { email: "", active: false },
35 });
36
37 return (
38 <FormFieldManaged control={methods.control} name="email" label="Email">
39 {(field) => <span>{expectString(field.value)}</span>}
40 </FormFieldManaged>
41 );
42};
43
44// The boolean field narrows the same way, so a mismatch is still caught.
45export const NarrowsBooleanField = () => (
46 <FormFieldManaged<Values, "active"> name="active" label="Active">
47 {(field) => <input type="checkbox" checked={field.value} readOnly />}
48 </FormFieldManaged>
49);
50
51/**
52 * Supplying only `TFieldValues` leaves `TName` at its default, so `value` stays
53 * the union — TypeScript stops inferring the remaining parameters as soon as
54 * one is given explicitly. This is a language rule, not something the component
55 * can type its way around, which is why the docstring steers callers to
56 * `control` or to naming both arguments.
57 *
58 * The expected error is the guard: if a future TypeScript infers partial type
59 * arguments, this line stops erroring and the comment above needs revisiting.
60 */
61export const PartialArgsStillWiden = () => (
62 <FormFieldManaged<Values> name="email" label="Email">
63 {/* @ts-expect-error `value` is `string | boolean` when `TName` defaults */}
64 {(field) => <span>{expectString(field.value)}</span>}
65 </FormFieldManaged>
66);
index.ts
1export { FormFieldManaged } from "./form-field-managed.js";
2export type {
3 FormFieldManagedProps,
4 ManagedField,
5} from "./form-field-managed.js";
form-field-managed.example.tsx
1import { Button } from "@behivetech/atoms.button";
2import { Select } from "@behivetech/atoms.select";
3import { TextField } from "@behivetech/atoms.text-field";
4import { Toggle } from "@behivetech/atoms.toggle";
5import { Form, useForm } from "@behivetech/forms.form-provider";
6import { FormFieldManaged } from "./form-field-managed.js";
7
8interface DemoValues {
9 email: string;
10 role: string;
11 active: boolean;
12}
13
14const ROLES = [
15 { label: "Admin", value: "admin" },
16 { label: "Editor", value: "editor" },
17];
18
19/**
20 * One managed form covering both integration shapes: a native control, where
21 * the field spreads straight on, and two Radix controls, where the caller maps
22 * the one prop that differs.
23 *
24 * Each field passes `control` rather than naming type parameters. That is the
25 * shape worth copying: it infers both parameters, so `field.value` is the type
26 * of that one field and needs no cast. Reading `control` from context instead
27 * works too, but then the field type has to be named — `<FormFieldManaged<
28 * DemoValues, "email">` — because supplying one type argument stops TypeScript
29 * inferring the other.
30 */
31export const ManagedForm = () => {
32 const methods = useForm<DemoValues>({
33 defaultValues: { email: "", role: "", active: false },
34 });
35
36 return (
37 <Form methods={methods} onSubmit={(values) => console.log(values)}>
38 <FormFieldManaged
39 control={methods.control}
40 name="email"
41 label="Email"
42 hint="We only use this for sign-in"
43 required
44 rules={{ required: "Email is required" }}
45 >
46 {(field) => <TextField {...field} type="email" />}
47 </FormFieldManaged>
48
49 <FormFieldManaged
50 control={methods.control}
51 name="role"
52 label="Role"
53 rules={{ required: "Pick a role" }}
54 >
55 {(field) => (
56 <Select
57 id={field.id}
58 name={field.name}
59 options={ROLES}
60 value={field.value}
61 onValueChange={field.onChange}
62 placeholder="Pick one"
63 />
64 )}
65 </FormFieldManaged>
66
67 <FormFieldManaged control={methods.control} name="active" label="Active">
68 {(field) => (
69 <Toggle
70 id={field.id}
71 name={field.name}
72 checked={field.value}
73 onCheckedChange={field.onChange}
74 />
75 )}
76 </FormFieldManaged>
77
78 <Button type="submit" variant="primary" size="sm">
79 Save
80 </Button>
81 </Form>
82 );
83};
form-field-managed.spec.tsx
1import { useState } from "react";
2import { act, render, screen, waitFor } from "@testing-library/react";
3import userEvent from "@testing-library/user-event";
4import { Checkbox } from "@behivetech/atoms.checkbox";
5import { Select } from "@behivetech/atoms.select";
6import { TextField } from "@behivetech/atoms.text-field";
7import { Textarea } from "@behivetech/atoms.textarea";
8import { Toggle } from "@behivetech/atoms.toggle";
9import {
10 Form,
11 type UseFormReturn,
12 useForm,
13} from "@behivetech/forms.form-provider";
14import { FormFieldManaged } from "./form-field-managed.js";
15
16interface Values {
17 email: string;
18 role: string;
19 active: boolean;
20}
21
22const ROLES = [
23 { label: "Admin", value: "admin" },
24 { label: "Editor", value: "editor" },
25];
26
27/** Renders whichever fields the test needs, reporting submitted values. */
28const Harness = ({
29 onSubmit,
30 children,
31 defaultValues,
32}: {
33 onSubmit: (values: Values) => void;
34 children: React.ReactNode;
35 defaultValues?: Partial<Values>;
36}) => {
37 const methods = useForm<Values>({
38 defaultValues: { email: "", role: "", active: false, ...defaultValues },
39 });
40
41 return (
42 <Form methods={methods} onSubmit={onSubmit}>
43 {children}
44 <button type="submit">Save</button>
45 </Form>
46 );
47};
48
49// These name both type parameters rather than just `Values`. Supplying only the
50// first leaves `TName` at its default and widens `field.value` to a union of
51// every field in the form; see form-field-managed.type-test.tsx, which is where
52// that is enforced, since tsconfig excludes spec files from check-types.
53describe("FormFieldManaged", () => {
54 it("renders the FormField chrome around the control", () => {
55 render(
56 <Harness onSubmit={vi.fn()}>
57 <FormFieldManaged<Values, "email">
58 name="email"
59 label="Email"
60 hint="Work address"
61 >
62 {(field) => <TextField {...field} />}
63 </FormFieldManaged>
64 </Harness>,
65 );
66
67 expect(screen.getByText("Email")).toBeTruthy();
68 expect(screen.getByText("Work address")).toBeTruthy();
69 });
70
71 it("associates the label with the control", () => {
72 render(
73 <Harness onSubmit={vi.fn()}>
74 <FormFieldManaged<Values, "email"> name="email" label="Email">
75 {(field) => <TextField {...field} />}
76 </FormFieldManaged>
77 </Harness>,
78 );
79
80 // getByLabelText only resolves when htmlFor matches the control's id.
81 expect(screen.getByLabelText("Email")).toBeTruthy();
82 });
83
84 it("round-trips a native control's value into onSubmit", async () => {
85 const user = userEvent.setup();
86 const onSubmit = vi.fn();
87 render(
88 <Harness onSubmit={onSubmit}>
89 <FormFieldManaged<Values, "email"> name="email" label="Email">
90 {(field) => <TextField {...field} />}
91 </FormFieldManaged>
92 </Harness>,
93 );
94
95 await user.type(screen.getByLabelText("Email"), "a@b.com");
96 await user.click(screen.getByRole("button", { name: "Save" }));
97
98 await waitFor(() => {
99 expect(onSubmit).toHaveBeenCalled();
100 });
101 expect(onSubmit.mock.calls[0][0].email).toBe("a@b.com");
102 });
103
104 // The cases below are the reason this package exists: `register` binds
105 // nothing on a Radix-backed control and the value silently stays default.
106 it("round-trips a Radix Select's value into onSubmit", async () => {
107 const user = userEvent.setup();
108 const onSubmit = vi.fn();
109 render(
110 <Harness onSubmit={onSubmit}>
111 <FormFieldManaged<Values, "role"> name="role" label="Role">
112 {(field) => (
113 <Select
114 id={field.id}
115 name={field.name}
116 options={ROLES}
117 value={field.value}
118 onValueChange={field.onChange}
119 placeholder="Pick one"
120 />
121 )}
122 </FormFieldManaged>
123 </Harness>,
124 );
125
126 await user.click(screen.getByLabelText("Role"));
127 await user.click(await screen.findByRole("option", { name: "Editor" }));
128 await user.click(screen.getByRole("button", { name: "Save" }));
129
130 await waitFor(() => {
131 expect(onSubmit).toHaveBeenCalled();
132 });
133 expect(onSubmit.mock.calls[0][0].role).toBe("editor");
134 });
135
136 it("round-trips a Radix Toggle's value into onSubmit", async () => {
137 const user = userEvent.setup();
138 const onSubmit = vi.fn();
139 render(
140 <Harness onSubmit={onSubmit}>
141 <FormFieldManaged<Values, "active"> name="active" label="Active">
142 {(field) => (
143 <Toggle
144 id={field.id}
145 name={field.name}
146 checked={field.value}
147 onCheckedChange={field.onChange}
148 />
149 )}
150 </FormFieldManaged>
151 </Harness>,
152 );
153
154 await user.click(screen.getByLabelText("Active"));
155 await user.click(screen.getByRole("button", { name: "Save" }));
156
157 await waitFor(() => {
158 expect(onSubmit).toHaveBeenCalled();
159 });
160 expect(onSubmit.mock.calls[0][0].active).toBe(true);
161 });
162
163 it("surfaces a validation error without the caller passing one", async () => {
164 const user = userEvent.setup();
165 const onSubmit = vi.fn();
166 render(
167 <Harness onSubmit={onSubmit}>
168 <FormFieldManaged<Values, "email">
169 name="email"
170 label="Email"
171 rules={{ required: "Email is required" }}
172 >
173 {(field) => <TextField {...field} />}
174 </FormFieldManaged>
175 </Harness>,
176 );
177
178 await user.click(screen.getByRole("button", { name: "Save" }));
179
180 const error = await screen.findByRole("alert");
181 expect(error).toHaveTextContent("Email is required");
182 expect(onSubmit).not.toHaveBeenCalled();
183 });
184
185 it("hides the hint once an error is showing", async () => {
186 const user = userEvent.setup();
187 render(
188 <Harness onSubmit={vi.fn()}>
189 <FormFieldManaged<Values, "email">
190 name="email"
191 label="Email"
192 hint="Work address"
193 rules={{ required: "Email is required" }}
194 >
195 {(field) => <TextField {...field} />}
196 </FormFieldManaged>
197 </Harness>,
198 );
199
200 expect(screen.getByText("Work address")).toBeTruthy();
201 await user.click(screen.getByRole("button", { name: "Save" }));
202
203 await screen.findByRole("alert");
204 expect(screen.queryByText("Work address")).toBeNull();
205 });
206
207 it("passes fieldState to the render prop", async () => {
208 const user = userEvent.setup();
209 render(
210 <Harness onSubmit={vi.fn()}>
211 <FormFieldManaged<Values, "email">
212 name="email"
213 label="Email"
214 rules={{ required: "Email is required" }}
215 >
216 {(field, fieldState) => (
217 <>
218 <TextField {...field} />
219 <span data-testid="invalid">{String(fieldState.invalid)}</span>
220 </>
221 )}
222 </FormFieldManaged>
223 </Harness>,
224 );
225
226 expect(screen.getByTestId("invalid")).toHaveTextContent("false");
227 await user.click(screen.getByRole("button", { name: "Save" }));
228
229 await waitFor(() => {
230 expect(screen.getByTestId("invalid")).toHaveTextContent("true");
231 });
232 });
233});
234
235interface RhfValues {
236 agree: boolean;
237 active: boolean;
238 role: string | undefined;
239 name: string;
240 bio: string;
241}
242
243const RHF_DEFAULTS: RhfValues = {
244 agree: false,
245 active: false,
246 role: "",
247 name: "",
248 bio: "",
249};
250
251/** Hands the live `useForm` methods to the test so it can reset, setValue and setFocus. */
252const RhfHarness = ({
253 onSubmit,
254 onMethods,
255 children,
256 defaultValues = RHF_DEFAULTS,
257}: {
258 onSubmit: (values: RhfValues) => void;
259 onMethods: (methods: UseFormReturn<RhfValues>) => void;
260 children: (methods: UseFormReturn<RhfValues>) => React.ReactNode;
261 defaultValues?: RhfValues;
262}) => {
263 const methods = useForm<RhfValues>({ defaultValues });
264 onMethods(methods);
265
266 return (
267 <Form methods={methods} onSubmit={onSubmit}>
268 {children(methods)}
269 <button type="submit">Save</button>
270 </Form>
271 );
272};
273
274const CheckboxField = ({ required }: { required?: boolean }) => (
275 <FormFieldManaged<RhfValues, "agree">
276 name="agree"
277 label="Agree"
278 rules={required ? { required: "Required" } : undefined}
279 >
280 {(field) => (
281 <Checkbox
282 id={field.id}
283 name={field.name}
284 ref={field.ref}
285 checked={field.value}
286 onCheckedChange={field.onChange}
287 onBlur={field.onBlur}
288 />
289 )}
290 </FormFieldManaged>
291);
292
293const ToggleField = ({ required }: { required?: boolean }) => (
294 <FormFieldManaged<RhfValues, "active">
295 name="active"
296 label="Active"
297 rules={required ? { required: "Required" } : undefined}
298 >
299 {(field) => (
300 <Toggle
301 id={field.id}
302 name={field.name}
303 ref={field.ref}
304 checked={field.value}
305 onCheckedChange={field.onChange}
306 onBlur={field.onBlur}
307 />
308 )}
309 </FormFieldManaged>
310);
311
312const SelectField = ({ required }: { required?: boolean }) => (
313 <FormFieldManaged<RhfValues, "role">
314 name="role"
315 label="Role"
316 rules={required ? { required: "Required" } : undefined}
317 >
318 {(field) => (
319 <Select
320 id={field.id}
321 name={field.name}
322 ref={field.ref}
323 options={ROLES}
324 value={field.value}
325 onValueChange={field.onChange}
326 onBlur={field.onBlur}
327 placeholder="Pick one"
328 />
329 )}
330 </FormFieldManaged>
331);
332
333/** Renders the harness and returns a getter for the latest form methods. */
334const setup = (
335 children: (methods: UseFormReturn<RhfValues>) => React.ReactNode,
336 defaultValues?: RhfValues,
337) => {
338 const onSubmit = vi.fn();
339 let latest: UseFormReturn<RhfValues> | undefined;
340 render(
341 <RhfHarness
342 onSubmit={onSubmit}
343 onMethods={(methods) => {
344 latest = methods;
345 }}
346 defaultValues={defaultValues}
347 >
348 {children}
349 </RhfHarness>,
350 );
351 const methods = () => latest as UseFormReturn<RhfValues>;
352
353 return { methods, onSubmit };
354};
355
356const submittedValues = async (onSubmit: ReturnType<typeof vi.fn>) => {
357 await waitFor(() => {
358 expect(onSubmit).toHaveBeenCalled();
359 });
360
361 return onSubmit.mock.calls[0][0] as RhfValues;
362};
363
364describe("FormFieldManaged with react-hook-form", () => {
365 describe("a user change", () => {
366 it("reaches the submitted values and agrees with Radix for Checkbox", async () => {
367 const user = userEvent.setup();
368 const { onSubmit } = setup(() => <CheckboxField />);
369 const checkbox = screen.getByRole("checkbox");
370
371 expect(checkbox.className).not.toContain("--checked");
372 await user.click(checkbox);
373
374 expect(checkbox).toHaveAttribute("aria-checked", "true");
375 expect(checkbox).toHaveAttribute("data-state", "checked");
376 expect(checkbox.className).toContain("--checked");
377 await user.click(screen.getByRole("button", { name: "Save" }));
378 expect((await submittedValues(onSubmit)).agree).toBe(true);
379 });
380
381 it("reaches the submitted values and agrees with Radix for Toggle", async () => {
382 const user = userEvent.setup();
383 const { onSubmit } = setup(() => <ToggleField />);
384 const toggle = screen.getByRole("switch");
385
386 await user.click(toggle);
387
388 expect(toggle).toHaveAttribute("aria-checked", "true");
389 expect(toggle).toHaveAttribute("data-state", "checked");
390 expect(toggle.className).toContain("--checked");
391 await user.click(screen.getByRole("button", { name: "Save" }));
392 expect((await submittedValues(onSubmit)).active).toBe(true);
393 });
394
395 it("reaches the submitted values and agrees with Radix for Select", async () => {
396 const user = userEvent.setup();
397 const { onSubmit } = setup(() => <SelectField />);
398 const trigger = screen.getByRole("combobox");
399
400 expect(trigger).toHaveAttribute("data-placeholder");
401 expect(trigger.className).toContain("--placeholder");
402 await user.click(trigger);
403 await user.click(await screen.findByRole("option", { name: "Editor" }));
404
405 expect(trigger).not.toHaveAttribute("data-placeholder");
406 expect(trigger.className).not.toContain("--placeholder");
407 await user.click(screen.getByRole("button", { name: "Save" }));
408 expect((await submittedValues(onSubmit)).role).toBe("editor");
409 });
410 });
411
412 describe("reset()", () => {
413 it("returns Checkbox and Toggle to false, modifier and Radix together", async () => {
414 const user = userEvent.setup();
415 const { methods } = setup(() => (
416 <>
417 <CheckboxField />
418 <ToggleField />
419 </>
420 ));
421 const checkbox = screen.getByRole("checkbox");
422 const toggle = screen.getByRole("switch");
423
424 await user.click(checkbox);
425 await user.click(toggle);
426 expect(checkbox.className).toContain("--checked");
427 expect(toggle.className).toContain("--checked");
428
429 act(() => methods().reset());
430
431 await waitFor(() => {
432 expect(checkbox).toHaveAttribute("aria-checked", "false");
433 });
434 expect(checkbox).toHaveAttribute("data-state", "unchecked");
435 expect(checkbox.className).not.toContain("--checked");
436 expect(toggle).toHaveAttribute("aria-checked", "false");
437 expect(toggle).toHaveAttribute("data-state", "unchecked");
438 expect(toggle.className).not.toContain("--checked");
439 });
440
441 it.each([
442 ["an empty string", ""],
443 ["undefined", undefined],
444 ])(
445 "returns Select to the placeholder when reset to %s",
446 async (_label, resetTo) => {
447 const user = userEvent.setup();
448 const { methods } = setup(() => <SelectField />);
449 const trigger = screen.getByRole("combobox");
450
451 await user.click(trigger);
452 await user.click(await screen.findByRole("option", { name: "Admin" }));
453 expect(trigger.className).not.toContain("--placeholder");
454
455 act(() => methods().reset({ ...RHF_DEFAULTS, role: resetTo }));
456
457 await waitFor(() => {
458 expect(trigger).toHaveAttribute("data-placeholder");
459 });
460 expect(trigger.className).toContain("--placeholder");
461 },
462 );
463
464 it("returns Select to a non-empty default", async () => {
465 const user = userEvent.setup();
466 const { methods } = setup(() => <SelectField />, {
467 ...RHF_DEFAULTS,
468 role: "admin",
469 });
470 const trigger = screen.getByRole("combobox");
471
472 expect(trigger.className).not.toContain("--placeholder");
473 await user.click(trigger);
474 await user.click(await screen.findByRole("option", { name: "Editor" }));
475 expect(trigger).toHaveTextContent("Editor");
476
477 act(() => methods().reset());
478
479 await waitFor(() => {
480 expect(trigger).toHaveTextContent("Admin");
481 });
482 expect(trigger.className).not.toContain("--placeholder");
483 });
484 });
485
486 describe("setValue()", () => {
487 it("moves the modifiers when the value is set from outside", async () => {
488 const { methods } = setup(() => (
489 <>
490 <CheckboxField />
491 <ToggleField />
492 <SelectField />
493 </>
494 ));
495 const checkbox = screen.getByRole("checkbox");
496 const toggle = screen.getByRole("switch");
497 const trigger = screen.getByRole("combobox");
498
499 act(() => {
500 methods().setValue("agree", true);
501 methods().setValue("active", true);
502 methods().setValue("role", "editor");
503 });
504
505 await waitFor(() => {
506 expect(checkbox.className).toContain("--checked");
507 });
508 expect(checkbox).toHaveAttribute("aria-checked", "true");
509 expect(toggle.className).toContain("--checked");
510 expect(toggle).toHaveAttribute("data-state", "checked");
511 expect(trigger.className).not.toContain("--placeholder");
512 expect(trigger).toHaveTextContent("Editor");
513
514 act(() => {
515 methods().setValue("agree", false);
516 methods().setValue("active", false);
517 methods().setValue("role", "");
518 });
519
520 await waitFor(() => {
521 expect(checkbox.className).not.toContain("--checked");
522 });
523 expect(toggle.className).not.toContain("--checked");
524 expect(trigger.className).toContain("--placeholder");
525 });
526 });
527
528 describe("the ref", () => {
529 it("moves focus to a failing Checkbox on submit", async () => {
530 const user = userEvent.setup();
531 const { onSubmit } = setup(() => <CheckboxField required />);
532
533 await user.click(screen.getByRole("button", { name: "Save" }));
534
535 await waitFor(() => {
536 expect(screen.getByRole("checkbox")).toHaveFocus();
537 });
538 expect(onSubmit).not.toHaveBeenCalled();
539 });
540
541 it("moves focus to a failing Toggle on submit", async () => {
542 const user = userEvent.setup();
543 const { onSubmit } = setup(() => <ToggleField required />);
544
545 await user.click(screen.getByRole("button", { name: "Save" }));
546
547 await waitFor(() => {
548 expect(screen.getByRole("switch")).toHaveFocus();
549 });
550 expect(onSubmit).not.toHaveBeenCalled();
551 });
552
553 it("moves focus to a failing Select trigger on submit", async () => {
554 const user = userEvent.setup();
555 const { onSubmit } = setup(() => <SelectField required />);
556
557 await user.click(screen.getByRole("button", { name: "Save" }));
558
559 await waitFor(() => {
560 expect(screen.getByRole("combobox")).toHaveFocus();
561 });
562 expect(onSubmit).not.toHaveBeenCalled();
563 });
564
565 // RHF defers the focus call with a setTimeout, so each assertion waits.
566 it("focuses each control through setFocus(name)", async () => {
567 const { methods } = setup(() => (
568 <>
569 <CheckboxField />
570 <ToggleField />
571 <SelectField />
572 </>
573 ));
574
575 act(() => methods().setFocus("agree"));
576 await waitFor(() => {
577 expect(screen.getByRole("checkbox")).toHaveFocus();
578 });
579 act(() => methods().setFocus("active"));
580 await waitFor(() => {
581 expect(screen.getByRole("switch")).toHaveFocus();
582 });
583 act(() => methods().setFocus("role"));
584 await waitFor(() => {
585 expect(screen.getByRole("combobox")).toHaveFocus();
586 });
587 });
588 });
589
590 describe("register on TextField and Textarea", () => {
591 const RegisteredFields = ({
592 methods,
593 }: {
594 methods: UseFormReturn<RhfValues>;
595 }) => {
596 const { errors } = methods.formState;
597
598 return (
599 <>
600 <TextField
601 aria-label="Name"
602 aria-invalid={errors.name ? "true" : undefined}
603 {...methods.register("name", { required: "Required" })}
604 />
605 <Textarea
606 aria-label="Bio"
607 aria-invalid={errors.bio ? "true" : undefined}
608 {...methods.register("bio", { required: "Required" })}
609 />
610 </>
611 );
612 };
613
614 it("reaches the submitted values when typed into", async () => {
615 const user = userEvent.setup();
616 const { onSubmit } = setup((methods) => (
617 <RegisteredFields methods={methods} />
618 ));
619
620 await user.type(screen.getByLabelText("Name"), "Ada");
621 await user.type(screen.getByLabelText("Bio"), "Maths");
622 await user.click(screen.getByRole("button", { name: "Save" }));
623
624 const values = await submittedValues(onSubmit);
625 expect(values.name).toBe("Ada");
626 expect(values.bio).toBe("Maths");
627 });
628
629 it("clears the DOM value on reset()", async () => {
630 const user = userEvent.setup();
631 const { methods } = setup((m) => <RegisteredFields methods={m} />);
632 const input = screen.getByLabelText("Name");
633 const area = screen.getByLabelText("Bio");
634
635 await user.type(input, "Ada");
636 await user.type(area, "Maths");
637 act(() => methods().reset());
638
639 await waitFor(() => {
640 expect(input).toHaveValue("");
641 });
642 expect(area).toHaveValue("");
643 });
644
645 it("focuses the first failing field and sets --invalid", async () => {
646 const user = userEvent.setup();
647 const { onSubmit } = setup((methods) => (
648 <RegisteredFields methods={methods} />
649 ));
650 const input = screen.getByLabelText("Name");
651 const area = screen.getByLabelText("Bio");
652
653 expect(input.className).not.toContain("--invalid");
654 await user.click(screen.getByRole("button", { name: "Save" }));
655
656 await waitFor(() => {
657 expect(input).toHaveFocus();
658 });
659 expect(input).toHaveAttribute("aria-invalid", "true");
660 expect(input.className).toContain("--invalid");
661 expect(area).toHaveAttribute("aria-invalid", "true");
662 expect(area.className).toContain("--invalid");
663 expect(onSubmit).not.toHaveBeenCalled();
664
665 await user.type(input, "Ada");
666 await user.type(area, "Maths");
667 await user.click(screen.getByRole("button", { name: "Save" }));
668 await submittedValues(onSubmit);
669 expect(input.className).not.toContain("--invalid");
670 expect(area.className).not.toContain("--invalid");
671 });
672
673 it("focuses the Textarea through setFocus(name)", async () => {
674 const { methods } = setup((m) => <RegisteredFields methods={m} />);
675
676 act(() => methods().setFocus("bio"));
677
678 await waitFor(() => {
679 expect(screen.getByLabelText("Bio")).toHaveFocus();
680 });
681 });
682 });
683});
684
685/** The modifier on a control's root must always say what Radix's attribute says. */
686const expectChecked = (el: HTMLElement, checked: boolean) => {
687 expect(el).toHaveAttribute("aria-checked", String(checked));
688 expect(el.className.includes("--checked")).toBe(checked);
689};
690
691describe("state staying in sync", () => {
692 it("follows a native form reset for uncontrolled Checkbox, Toggle and Select", async () => {
693 const user = userEvent.setup();
694 const { container } = render(
695 <form>
696 <Checkbox aria-label="Agree" name="agree" defaultChecked />
697 <Toggle aria-label="Active" name="active" />
698 <Select
699 aria-label="Role"
700 name="role"
701 options={ROLES}
702 defaultValue="admin"
703 />
704 <button type="reset">Reset</button>
705 </form>,
706 );
707 const checkbox = screen.getByRole("checkbox");
708 const toggle = screen.getByRole("switch");
709 const trigger = screen.getByRole("combobox");
710 const nativeSelect = container.querySelector("select") as HTMLSelectElement;
711
712 await user.click(checkbox);
713 await user.click(toggle);
714 await user.click(trigger);
715 await user.click(await screen.findByRole("option", { name: "Editor" }));
716 expectChecked(checkbox, false);
717 expectChecked(toggle, true);
718 expect(nativeSelect.value).toBe("editor");
719
720 await user.click(screen.getByRole("button", { name: "Reset" }));
721
722 await waitFor(() => {
723 expectChecked(checkbox, true);
724 });
725 expectChecked(toggle, false);
726 expect(
727 container.querySelector<HTMLInputElement>('input[name="agree"]')?.checked,
728 ).toBe(true);
729 expect(
730 container.querySelector<HTMLInputElement>('input[name="active"]')
731 ?.checked,
732 ).toBe(false);
733 await waitFor(() => {
734 expect(nativeSelect.value).toBe("admin");
735 });
736 expect(trigger).toHaveTextContent("Admin");
737 expect(trigger.className).not.toContain("--placeholder");
738 });
739
740 it("goes back to the reloaded defaults after reset(newDefaults) then reset()", async () => {
741 const user = userEvent.setup();
742 const { methods } = setup(() => (
743 <>
744 <CheckboxField />
745 <ToggleField />
746 <SelectField />
747 </>
748 ));
749 const checkbox = screen.getByRole("checkbox");
750 const toggle = screen.getByRole("switch");
751 const trigger = screen.getByRole("combobox");
752 const loaded: RhfValues = {
753 ...RHF_DEFAULTS,
754 agree: true,
755 active: true,
756 role: "editor",
757 };
758
759 act(() => methods().reset(loaded));
760 await waitFor(() => {
761 expectChecked(checkbox, true);
762 });
763 expectChecked(toggle, true);
764 expect(trigger).toHaveTextContent("Editor");
765 expect(trigger.className).not.toContain("--placeholder");
766
767 await user.click(checkbox);
768 await user.click(toggle);
769 await user.click(trigger);
770 await user.click(await screen.findByRole("option", { name: "Admin" }));
771 expectChecked(checkbox, false);
772 expectChecked(toggle, false);
773
774 act(() => methods().reset());
775
776 await waitFor(() => {
777 expectChecked(checkbox, true);
778 });
779 expectChecked(toggle, true);
780 expect(trigger).toHaveTextContent("Editor");
781 expect(trigger.className).not.toContain("--placeholder");
782 });
783
784 describe("controlled to undefined to controlled", () => {
785 const Switcher = ({ checked }: { checked: boolean | undefined }) => (
786 <>
787 <Checkbox aria-label="Agree" checked={checked} />
788 <Toggle aria-label="Active" checked={checked} />
789 </>
790 );
791
792 const Wrapper = () => {
793 const [checked, setChecked] = useState<boolean | undefined>(true);
794
795 return (
796 <>
797 <Switcher checked={checked} />
798 <button type="button" onClick={() => setChecked(true)}>
799 on
800 </button>
801 <button type="button" onClick={() => setChecked(false)}>
802 off
803 </button>
804 <button type="button" onClick={() => setChecked(undefined)}>
805 clear
806 </button>
807 </>
808 );
809 };
810
811 it("keeps the modifier equal to aria-checked at every step", async () => {
812 const user = userEvent.setup();
813 render(<Wrapper />);
814 const checkbox = screen.getByRole("checkbox");
815 const toggle = screen.getByRole("switch");
816 const expectBoth = () => {
817 [checkbox, toggle].forEach((el) => {
818 expect(el.className.includes("--checked")).toBe(
819 el.getAttribute("aria-checked") === "true",
820 );
821 });
822 };
823
824 expectChecked(checkbox, true);
825 expectChecked(toggle, true);
826 await user.click(screen.getByRole("button", { name: "clear" }));
827 expectBoth();
828 await user.click(screen.getByRole("button", { name: "off" }));
829 expectChecked(checkbox, false);
830 expectChecked(toggle, false);
831 await user.click(screen.getByRole("button", { name: "on" }));
832 expectChecked(checkbox, true);
833 expectChecked(toggle, true);
834 await user.click(screen.getByRole("button", { name: "clear" }));
835 expectBoth();
836 await user.click(screen.getByRole("button", { name: "on" }));
837 expectChecked(checkbox, true);
838 expectChecked(toggle, true);
839 });
840 });
841});