Form Field Managed
v0.1.2`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.composition.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 { render, screen, waitFor } from "@testing-library/react";2import userEvent from "@testing-library/user-event";3import { Select } from "@behivetech/atoms.select";4import { TextField } from "@behivetech/atoms.text-field";5import { Toggle } from "@behivetech/atoms.toggle";6import { Form, useForm } from "@behivetech/forms.form-provider";7import { FormFieldManaged } from "./form-field-managed.js";89interface Values {10 email: string;11 role: string;12 active: boolean;13}1415const ROLES = [16 { label: "Admin", value: "admin" },17 { label: "Editor", value: "editor" },18];1920/** Renders whichever fields the test needs, reporting submitted values. */21const Harness = ({22 onSubmit,23 children,24 defaultValues,25}: {26 onSubmit: (values: Values) => void;27 children: React.ReactNode;28 defaultValues?: Partial<Values>;29}) => {30 const methods = useForm<Values>({31 defaultValues: { email: "", role: "", active: false, ...defaultValues },32 });3334 return (35 <Form methods={methods} onSubmit={onSubmit}>36 {children}37 <button type="submit">Save</button>38 </Form>39 );40};4142// These name both type parameters rather than just `Values`. Supplying only the43// first leaves `TName` at its default and widens `field.value` to a union of44// every field in the form; see form-field-managed.type-test.tsx, which is where45// that is enforced, since tsconfig excludes spec files from check-types.46describe("FormFieldManaged", () => {47 it("renders the FormField chrome around the control", () => {48 render(49 <Harness onSubmit={vi.fn()}>50 <FormFieldManaged<Values, "email">51 name="email"52 label="Email"53 hint="Work address"54 >55 {(field) => <TextField {...field} />}56 </FormFieldManaged>57 </Harness>,58 );5960 expect(screen.getByText("Email")).toBeTruthy();61 expect(screen.getByText("Work address")).toBeTruthy();62 });6364 it("associates the label with the control", () => {65 render(66 <Harness onSubmit={vi.fn()}>67 <FormFieldManaged<Values, "email"> name="email" label="Email">68 {(field) => <TextField {...field} />}69 </FormFieldManaged>70 </Harness>,71 );7273 // getByLabelText only resolves when htmlFor matches the control's id.74 expect(screen.getByLabelText("Email")).toBeTruthy();75 });7677 it("round-trips a native control's value into onSubmit", async () => {78 const user = userEvent.setup();79 const onSubmit = vi.fn();80 render(81 <Harness onSubmit={onSubmit}>82 <FormFieldManaged<Values, "email"> name="email" label="Email">83 {(field) => <TextField {...field} />}84 </FormFieldManaged>85 </Harness>,86 );8788 await user.type(screen.getByLabelText("Email"), "a@b.com");89 await user.click(screen.getByRole("button", { name: "Save" }));9091 await waitFor(() => {92 expect(onSubmit).toHaveBeenCalled();93 });94 expect(onSubmit.mock.calls[0][0].email).toBe("a@b.com");95 });9697 // The cases below are the reason this package exists: `register` binds98 // nothing on a Radix-backed control and the value silently stays default.99 it("round-trips a Radix Select's value into onSubmit", async () => {100 const user = userEvent.setup();101 const onSubmit = vi.fn();102 render(103 <Harness onSubmit={onSubmit}>104 <FormFieldManaged<Values, "role"> name="role" label="Role">105 {(field) => (106 <Select107 id={field.id}108 name={field.name}109 options={ROLES}110 value={field.value}111 onValueChange={field.onChange}112 placeholder="Pick one"113 />114 )}115 </FormFieldManaged>116 </Harness>,117 );118119 await user.click(screen.getByLabelText("Role"));120 await user.click(await screen.findByRole("option", { name: "Editor" }));121 await user.click(screen.getByRole("button", { name: "Save" }));122123 await waitFor(() => {124 expect(onSubmit).toHaveBeenCalled();125 });126 expect(onSubmit.mock.calls[0][0].role).toBe("editor");127 });128129 it("round-trips a Radix Toggle's value into onSubmit", async () => {130 const user = userEvent.setup();131 const onSubmit = vi.fn();132 render(133 <Harness onSubmit={onSubmit}>134 <FormFieldManaged<Values, "active"> name="active" label="Active">135 {(field) => (136 <Toggle137 id={field.id}138 name={field.name}139 checked={field.value}140 onCheckedChange={field.onChange}141 />142 )}143 </FormFieldManaged>144 </Harness>,145 );146147 await user.click(screen.getByLabelText("Active"));148 await user.click(screen.getByRole("button", { name: "Save" }));149150 await waitFor(() => {151 expect(onSubmit).toHaveBeenCalled();152 });153 expect(onSubmit.mock.calls[0][0].active).toBe(true);154 });155156 it("surfaces a validation error without the caller passing one", async () => {157 const user = userEvent.setup();158 const onSubmit = vi.fn();159 render(160 <Harness onSubmit={onSubmit}>161 <FormFieldManaged<Values, "email">162 name="email"163 label="Email"164 rules={{ required: "Email is required" }}165 >166 {(field) => <TextField {...field} />}167 </FormFieldManaged>168 </Harness>,169 );170171 await user.click(screen.getByRole("button", { name: "Save" }));172173 const error = await screen.findByRole("alert");174 expect(error).toHaveTextContent("Email is required");175 expect(onSubmit).not.toHaveBeenCalled();176 });177178 it("hides the hint once an error is showing", async () => {179 const user = userEvent.setup();180 render(181 <Harness onSubmit={vi.fn()}>182 <FormFieldManaged<Values, "email">183 name="email"184 label="Email"185 hint="Work address"186 rules={{ required: "Email is required" }}187 >188 {(field) => <TextField {...field} />}189 </FormFieldManaged>190 </Harness>,191 );192193 expect(screen.getByText("Work address")).toBeTruthy();194 await user.click(screen.getByRole("button", { name: "Save" }));195196 await screen.findByRole("alert");197 expect(screen.queryByText("Work address")).toBeNull();198 });199200 it("passes fieldState to the render prop", async () => {201 const user = userEvent.setup();202 render(203 <Harness onSubmit={vi.fn()}>204 <FormFieldManaged<Values, "email">205 name="email"206 label="Email"207 rules={{ required: "Email is required" }}208 >209 {(field, fieldState) => (210 <>211 <TextField {...field} />212 <span data-testid="invalid">{String(fieldState.invalid)}</span>213 </>214 )}215 </FormFieldManaged>216 </Harness>,217 );218219 expect(screen.getByTestId("invalid")).toHaveTextContent("false");220 await user.click(screen.getByRole("button", { name: "Save" }));221222 await waitFor(() => {223 expect(screen.getByTestId("invalid")).toHaveTextContent("true");224 });225 });226});