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.
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";1213/**14 * What the render prop receives: the wired field plus its `id`.15 *16 * `TName` is threaded through to `ControllerRenderProps` rather than left at its17 * default. Without it `value` widens to the union of every field type in the18 * form, so a call site spreading onto a `TextField` has to assert19 * `value as string` — an assertion that keeps compiling if the field later20 * 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};2930export 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}4647/**48 * `FormField` with its react-hook-form wiring already done: the control's49 * value/onChange/onBlur/ref, the label association, and the error message.50 *51 * Uses `useController` rather than `register` because `register` only works on52 * controls that render a native input. `TextField` and `Textarea` do;53 * `Checkbox`, `Toggle` and `Select` are Radix-backed, where the ref would land54 * on the trigger button and the change callback is `onCheckedChange` /55 * `onValueChange`, so `register` binds nothing and the field silently keeps its56 * default. `useController` works for all of them.57 *58 * The control is supplied through a render prop rather than cloned, because the59 * prop a control listens on is the one thing that genuinely differs between60 * 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 type63 * of that one field and spreads onto a control without a cast:64 *65 * ```tsx66 * <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. Supplying72 * only the first — `<FormFieldManaged<Values> name="email">` — leaves `TName` at73 * 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 given75 * explicitly:76 *77 * ```tsx78 * <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 ...fieldProps92}: FormFieldManagedProps<TFieldValues, TName>) => {93 const id = useId();94 const { field, fieldState } = useController<TFieldValues, TName>({95 name,96 control,97 rules,98 });99100 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";34/**5 * Compile-time coverage for the render prop's `value` narrowing.6 *7 * This lives outside the spec file on purpose: `tsconfig.json` excludes8 * `**\/*.spec.*`, so nothing in the specs is type-checked and a widening9 * regression there would surface only in a consumer's build — which is how10 * `field.value` shipped as a union of every field type in the form. `tsc11 * --noEmit` covers this file, so the narrowing is enforced by `check-types`.12 *13 * Nothing here runs; it only has to compile.14 */1516interface Values {17 email: string;18 active: boolean;19}2021/** Fails to compile if `field.value` is wider than `string`. */22const expectString = (value: string) => value;2324// 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);3031// `control` supplied: both parameters infer, nothing written by hand.32export const InferredFromControl = () => {33 const methods = useForm<Values>({34 defaultValues: { email: "", active: false },35 });3637 return (38 <FormFieldManaged control={methods.control} name="email" label="Email">39 {(field) => <span>{expectString(field.value)}</span>}40 </FormFieldManaged>41 );42};4344// 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);5051/**52 * Supplying only `TFieldValues` leaves `TName` at its default, so `value` stays53 * the union — TypeScript stops inferring the remaining parameters as soon as54 * one is given explicitly. This is a language rule, not something the component55 * can type its way around, which is why the docstring steers callers to56 * `control` or to naming both arguments.57 *58 * The expected error is the guard: if a future TypeScript infers partial type59 * 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";78interface DemoValues {9 email: string;10 role: string;11 active: boolean;12}1314const ROLES = [15 { label: "Admin", value: "admin" },16 { label: "Editor", value: "editor" },17];1819/**20 * One managed form covering both integration shapes: a native control, where21 * the field spreads straight on, and two Radix controls, where the caller maps22 * the one prop that differs.23 *24 * Each field passes `control` rather than naming type parameters. That is the25 * shape worth copying: it infers both parameters, so `field.value` is the type26 * of that one field and needs no cast. Reading `control` from context instead27 * works too, but then the field type has to be named — `<FormFieldManaged<28 * DemoValues, "email">` — because supplying one type argument stops TypeScript29 * inferring the other.30 */31export const ManagedForm = () => {32 const methods = useForm<DemoValues>({33 defaultValues: { email: "", role: "", active: false },34 });3536 return (37 <Form methods={methods} onSubmit={(values) => console.log(values)}>38 <FormFieldManaged39 control={methods.control}40 name="email"41 label="Email"42 hint="We only use this for sign-in"43 required44 rules={{ required: "Email is required" }}45 >46 {(field) => <TextField {...field} type="email" />}47 </FormFieldManaged>4849 <FormFieldManaged50 control={methods.control}51 name="role"52 label="Role"53 rules={{ required: "Pick a role" }}54 >55 {(field) => (56 <Select57 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>6667 <FormFieldManaged control={methods.control} name="active" label="Active">68 {(field) => (69 <Toggle70 id={field.id}71 name={field.name}72 checked={field.value}73 onCheckedChange={field.onChange}74 />75 )}76 </FormFieldManaged>7778 <Button type="submit" variant="primary" size="sm">79 Save80 </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";1516interface Values {17 email: string;18 role: string;19 active: boolean;20}2122const ROLES = [23 { label: "Admin", value: "admin" },24 { label: "Editor", value: "editor" },25];2627/** 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 });4041 return (42 <Form methods={methods} onSubmit={onSubmit}>43 {children}44 <button type="submit">Save</button>45 </Form>46 );47};4849// These name both type parameters rather than just `Values`. Supplying only the50// first leaves `TName` at its default and widens `field.value` to a union of51// every field in the form; see form-field-managed.type-test.tsx, which is where52// 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 );6667 expect(screen.getByText("Email")).toBeTruthy();68 expect(screen.getByText("Work address")).toBeTruthy();69 });7071 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 );7980 // getByLabelText only resolves when htmlFor matches the control's id.81 expect(screen.getByLabelText("Email")).toBeTruthy();82 });8384 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 );9495 await user.type(screen.getByLabelText("Email"), "a@b.com");96 await user.click(screen.getByRole("button", { name: "Save" }));9798 await waitFor(() => {99 expect(onSubmit).toHaveBeenCalled();100 });101 expect(onSubmit.mock.calls[0][0].email).toBe("a@b.com");102 });103104 // The cases below are the reason this package exists: `register` binds105 // 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 <Select114 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 );125126 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" }));129130 await waitFor(() => {131 expect(onSubmit).toHaveBeenCalled();132 });133 expect(onSubmit.mock.calls[0][0].role).toBe("editor");134 });135136 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 <Toggle144 id={field.id}145 name={field.name}146 checked={field.value}147 onCheckedChange={field.onChange}148 />149 )}150 </FormFieldManaged>151 </Harness>,152 );153154 await user.click(screen.getByLabelText("Active"));155 await user.click(screen.getByRole("button", { name: "Save" }));156157 await waitFor(() => {158 expect(onSubmit).toHaveBeenCalled();159 });160 expect(onSubmit.mock.calls[0][0].active).toBe(true);161 });162163 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 );177178 await user.click(screen.getByRole("button", { name: "Save" }));179180 const error = await screen.findByRole("alert");181 expect(error).toHaveTextContent("Email is required");182 expect(onSubmit).not.toHaveBeenCalled();183 });184185 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 );199200 expect(screen.getByText("Work address")).toBeTruthy();201 await user.click(screen.getByRole("button", { name: "Save" }));202203 await screen.findByRole("alert");204 expect(screen.queryByText("Work address")).toBeNull();205 });206207 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 );225226 expect(screen.getByTestId("invalid")).toHaveTextContent("false");227 await user.click(screen.getByRole("button", { name: "Save" }));228229 await waitFor(() => {230 expect(screen.getByTestId("invalid")).toHaveTextContent("true");231 });232 });233});234235interface RhfValues {236 agree: boolean;237 active: boolean;238 role: string | undefined;239 name: string;240 bio: string;241}242243const RHF_DEFAULTS: RhfValues = {244 agree: false,245 active: false,246 role: "",247 name: "",248 bio: "",249};250251/** 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);265266 return (267 <Form methods={methods} onSubmit={onSubmit}>268 {children(methods)}269 <button type="submit">Save</button>270 </Form>271 );272};273274const CheckboxField = ({ required }: { required?: boolean }) => (275 <FormFieldManaged<RhfValues, "agree">276 name="agree"277 label="Agree"278 rules={required ? { required: "Required" } : undefined}279 >280 {(field) => (281 <Checkbox282 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);292293const ToggleField = ({ required }: { required?: boolean }) => (294 <FormFieldManaged<RhfValues, "active">295 name="active"296 label="Active"297 rules={required ? { required: "Required" } : undefined}298 >299 {(field) => (300 <Toggle301 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);311312const SelectField = ({ required }: { required?: boolean }) => (313 <FormFieldManaged<RhfValues, "role">314 name="role"315 label="Role"316 rules={required ? { required: "Required" } : undefined}317 >318 {(field) => (319 <Select320 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);332333/** 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 <RhfHarness342 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>;352353 return { methods, onSubmit };354};355356const submittedValues = async (onSubmit: ReturnType<typeof vi.fn>) => {357 await waitFor(() => {358 expect(onSubmit).toHaveBeenCalled();359 });360361 return onSubmit.mock.calls[0][0] as RhfValues;362};363364describe("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");370371 expect(checkbox.className).not.toContain("--checked");372 await user.click(checkbox);373374 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 });380381 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");385386 await user.click(toggle);387388 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 });394395 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");399400 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" }));404405 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 });411412 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");423424 await user.click(checkbox);425 await user.click(toggle);426 expect(checkbox.className).toContain("--checked");427 expect(toggle.className).toContain("--checked");428429 act(() => methods().reset());430431 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 });440441 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");450451 await user.click(trigger);452 await user.click(await screen.findByRole("option", { name: "Admin" }));453 expect(trigger.className).not.toContain("--placeholder");454455 act(() => methods().reset({ ...RHF_DEFAULTS, role: resetTo }));456457 await waitFor(() => {458 expect(trigger).toHaveAttribute("data-placeholder");459 });460 expect(trigger.className).toContain("--placeholder");461 },462 );463464 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");471472 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");476477 act(() => methods().reset());478479 await waitFor(() => {480 expect(trigger).toHaveTextContent("Admin");481 });482 expect(trigger.className).not.toContain("--placeholder");483 });484 });485486 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");498499 act(() => {500 methods().setValue("agree", true);501 methods().setValue("active", true);502 methods().setValue("role", "editor");503 });504505 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");513514 act(() => {515 methods().setValue("agree", false);516 methods().setValue("active", false);517 methods().setValue("role", "");518 });519520 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 });527528 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 />);532533 await user.click(screen.getByRole("button", { name: "Save" }));534535 await waitFor(() => {536 expect(screen.getByRole("checkbox")).toHaveFocus();537 });538 expect(onSubmit).not.toHaveBeenCalled();539 });540541 it("moves focus to a failing Toggle on submit", async () => {542 const user = userEvent.setup();543 const { onSubmit } = setup(() => <ToggleField required />);544545 await user.click(screen.getByRole("button", { name: "Save" }));546547 await waitFor(() => {548 expect(screen.getByRole("switch")).toHaveFocus();549 });550 expect(onSubmit).not.toHaveBeenCalled();551 });552553 it("moves focus to a failing Select trigger on submit", async () => {554 const user = userEvent.setup();555 const { onSubmit } = setup(() => <SelectField required />);556557 await user.click(screen.getByRole("button", { name: "Save" }));558559 await waitFor(() => {560 expect(screen.getByRole("combobox")).toHaveFocus();561 });562 expect(onSubmit).not.toHaveBeenCalled();563 });564565 // 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 ));574575 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 });589590 describe("register on TextField and Textarea", () => {591 const RegisteredFields = ({592 methods,593 }: {594 methods: UseFormReturn<RhfValues>;595 }) => {596 const { errors } = methods.formState;597598 return (599 <>600 <TextField601 aria-label="Name"602 aria-invalid={errors.name ? "true" : undefined}603 {...methods.register("name", { required: "Required" })}604 />605 <Textarea606 aria-label="Bio"607 aria-invalid={errors.bio ? "true" : undefined}608 {...methods.register("bio", { required: "Required" })}609 />610 </>611 );612 };613614 it("reaches the submitted values when typed into", async () => {615 const user = userEvent.setup();616 const { onSubmit } = setup((methods) => (617 <RegisteredFields methods={methods} />618 ));619620 await user.type(screen.getByLabelText("Name"), "Ada");621 await user.type(screen.getByLabelText("Bio"), "Maths");622 await user.click(screen.getByRole("button", { name: "Save" }));623624 const values = await submittedValues(onSubmit);625 expect(values.name).toBe("Ada");626 expect(values.bio).toBe("Maths");627 });628629 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");634635 await user.type(input, "Ada");636 await user.type(area, "Maths");637 act(() => methods().reset());638639 await waitFor(() => {640 expect(input).toHaveValue("");641 });642 expect(area).toHaveValue("");643 });644645 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");652653 expect(input.className).not.toContain("--invalid");654 await user.click(screen.getByRole("button", { name: "Save" }));655656 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();664665 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 });672673 it("focuses the Textarea through setFocus(name)", async () => {674 const { methods } = setup((m) => <RegisteredFields methods={m} />);675676 act(() => methods().setFocus("bio"));677678 await waitFor(() => {679 expect(screen.getByLabelText("Bio")).toHaveFocus();680 });681 });682 });683});684685/** 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};690691describe("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 <Select699 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;711712 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");719720 await user.click(screen.getByRole("button", { name: "Reset" }));721722 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 });739740 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 };758759 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");766767 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);773774 act(() => methods().reset());775776 await waitFor(() => {777 expectChecked(checkbox, true);778 });779 expectChecked(toggle, true);780 expect(trigger).toHaveTextContent("Editor");781 expect(trigger.className).not.toContain("--placeholder");782 });783784 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 );791792 const Wrapper = () => {793 const [checked, setChecked] = useState<boolean | undefined>(true);794795 return (796 <>797 <Switcher checked={checked} />798 <button type="button" onClick={() => setChecked(true)}>799 on800 </button>801 <button type="button" onClick={() => setChecked(false)}>802 off803 </button>804 <button type="button" onClick={() => setChecked(undefined)}>805 clear806 </button>807 </>808 );809 };810811 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 };823824 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});