`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 at v0.1.9, snapshotted 2026-11-10
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};