Behive Tech Registry

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.

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.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";
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 { 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";
8
9interface Values {
10 email: string;
11 role: string;
12 active: boolean;
13}
14
15const ROLES = [
16 { label: "Admin", value: "admin" },
17 { label: "Editor", value: "editor" },
18];
19
20/** 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 });
33
34 return (
35 <Form methods={methods} onSubmit={onSubmit}>
36 {children}
37 <button type="submit">Save</button>
38 </Form>
39 );
40};
41
42// These name both type parameters rather than just `Values`. Supplying only the
43// first leaves `TName` at its default and widens `field.value` to a union of
44// every field in the form; see form-field-managed.type-test.tsx, which is where
45// 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 );
59
60 expect(screen.getByText("Email")).toBeTruthy();
61 expect(screen.getByText("Work address")).toBeTruthy();
62 });
63
64 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 );
72
73 // getByLabelText only resolves when htmlFor matches the control's id.
74 expect(screen.getByLabelText("Email")).toBeTruthy();
75 });
76
77 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 );
87
88 await user.type(screen.getByLabelText("Email"), "a@b.com");
89 await user.click(screen.getByRole("button", { name: "Save" }));
90
91 await waitFor(() => {
92 expect(onSubmit).toHaveBeenCalled();
93 });
94 expect(onSubmit.mock.calls[0][0].email).toBe("a@b.com");
95 });
96
97 // The cases below are the reason this package exists: `register` binds
98 // 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 <Select
107 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 );
118
119 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" }));
122
123 await waitFor(() => {
124 expect(onSubmit).toHaveBeenCalled();
125 });
126 expect(onSubmit.mock.calls[0][0].role).toBe("editor");
127 });
128
129 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 <Toggle
137 id={field.id}
138 name={field.name}
139 checked={field.value}
140 onCheckedChange={field.onChange}
141 />
142 )}
143 </FormFieldManaged>
144 </Harness>,
145 );
146
147 await user.click(screen.getByLabelText("Active"));
148 await user.click(screen.getByRole("button", { name: "Save" }));
149
150 await waitFor(() => {
151 expect(onSubmit).toHaveBeenCalled();
152 });
153 expect(onSubmit.mock.calls[0][0].active).toBe(true);
154 });
155
156 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 );
170
171 await user.click(screen.getByRole("button", { name: "Save" }));
172
173 const error = await screen.findByRole("alert");
174 expect(error).toHaveTextContent("Email is required");
175 expect(onSubmit).not.toHaveBeenCalled();
176 });
177
178 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 );
192
193 expect(screen.getByText("Work address")).toBeTruthy();
194 await user.click(screen.getByRole("button", { name: "Save" }));
195
196 await screen.findByRole("alert");
197 expect(screen.queryByText("Work address")).toBeNull();
198 });
199
200 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 );
218
219 expect(screen.getByTestId("invalid")).toHaveTextContent("false");
220 await user.click(screen.getByRole("button", { name: "Save" }));
221
222 await waitFor(() => {
223 expect(screen.getByTestId("invalid")).toHaveTextContent("true");
224 });
225 });
226});