Form Field
v0.3.0Dumb wrapper that provides label + hint/error + required-marker layout around any form control (`Checkbox`, `Input`, `Textarea`, `Select`, `Toggle`, ...) — the same job AntD's `Form.Item` does. The control itself owns none of this chrome; it just needs an `id` matching `htmlFor`.
registry/behivetech/forms/form-field · depends on @behivetech/get-class-name
form-field.tsx
1import { type HTMLAttributes, type ReactNode } from "react";2import { getClassName } from "@behivetech/get-class-name";3import styles from "./form-field.module.scss";45export interface FormFieldProps extends Omit<6 HTMLAttributes<HTMLDivElement>,7 "children"8> {9 /** Additional class names to merge with the component root element */10 className?: string;11 /** Field label */12 label?: ReactNode;13 /** Associates the label with the control — pass the same id you give the control */14 htmlFor?: string;15 /** Supporting hint text; hidden while `error` is present */16 hint?: ReactNode;17 /** Error message; also visually marks the field as invalid */18 error?: ReactNode;19 /** Shows a required marker next to the label */20 required?: boolean;21 /** The form control this field wraps */22 children: ReactNode;23}2425/**26 * Dumb wrapper providing label + hint/error + required-marker layout around27 * any form control (Checkbox, Input, Textarea, Select, Toggle, ...) — the28 * same job AntD's `Form.Item` does. The control itself owns none of this29 * chrome; it just needs an `id` matching `htmlFor`.30 */31export const FormField = ({32 className,33 label,34 htmlFor,35 hint,36 error,37 required,38 children,39 ...rest40}: FormFieldProps) => {41 const [rootClass, getChildClass] = getClassName({42 className,43 rootClass: "BHT__FormField",44 modifiers: { error: !!error },45 styles,46 });4748 return (49 <div {...rest} className={rootClass}>50 {label !== undefined && (51 <label className={getChildClass("label")} htmlFor={htmlFor}>52 {label}53 {required && (54 <span className={getChildClass("required")} aria-hidden="true">55 *56 </span>57 )}58 </label>59 )}60 {children}61 {hint !== undefined && error === undefined && (62 <span className={getChildClass("hint")}>{hint}</span>63 )}64 {error !== undefined && (65 <span className={getChildClass("error")} role="alert">66 {error}67 </span>68 )}69 </div>70 );71};
form-field.module.scss
1// Material Design 3 text-field chrome: the label and supporting text that sit2// outside the control. Both use body-small, as MD3's outlined field does once3// its label has floated.45.BHT__FormField {6 display: flex;7 flex-direction: column;8 gap: var(--spacing-1, 0.25rem);910 &__label {11 font-family: var(--md-sys-typescale-body-small-font, var(--font-body));12 font-size: var(--md-sys-typescale-body-small-size, 0.75rem);13 font-weight: var(--md-sys-typescale-body-small-weight, 400);14 line-height: var(--md-sys-typescale-body-small-line-height, 1rem);15 letter-spacing: var(--md-sys-typescale-body-small-tracking, 0.0333em);16 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));17 display: flex;18 align-items: center;19 gap: var(--spacing-1, 0.25rem);20 transition: color var(--md-sys-motion-duration-short-3, 150ms)21 var(--md-sys-motion-easing-standard, ease);22 }2324 &__required {25 color: var(--md-sys-color-error, var(--color-error));26 }2728 &__hint,29 &__error {30 font-family: var(--md-sys-typescale-body-small-font, var(--font-body));31 font-size: var(--md-sys-typescale-body-small-size, 0.75rem);32 font-weight: var(--md-sys-typescale-body-small-weight, 400);33 line-height: var(--md-sys-typescale-body-small-line-height, 1rem);34 letter-spacing: var(--md-sys-typescale-body-small-tracking, 0.0333em);35 }3637 &__hint {38 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));39 }4041 &__error {42 color: var(--md-sys-color-error, var(--color-error));43 }4445 // MD3 tints the label with the focus color while the control is active.46 // Excluding the error modifier keeps an invalid field's label red on focus,47 // as MD3 does; without it this 0-3-0 rule would outrank the 0-2-0 error one.48 &:focus-within:not(.BHT__FormField--error) {49 .BHT__FormField {50 &__label {51 color: var(--md-sys-color-primary, var(--color-primary));52 }53 }54 }5556 &--error {57 .BHT__FormField {58 &__label {59 color: var(--md-sys-color-error, var(--color-error));60 }61 }62 }63}
index.ts
1export { FormField } from "./form-field.js";2export type { FormFieldProps } from "./form-field.js";
form-field.composition.tsx
1import { FormField } from "./form-field.js";23export const BasicFormField = () => (4 <FormField label="Email" htmlFor="email">5 <input id="email" type="email" />6 </FormField>7);89export const RequiredFormField = () => (10 <FormField label="Email" htmlFor="email-required" required>11 <input id="email-required" type="email" />12 </FormField>13);1415export const FormFieldWithHint = () => (16 <FormField label="Email" htmlFor="email-hint" hint="We'll never share this">17 <input id="email-hint" type="email" />18 </FormField>19);2021export const FormFieldWithError = () => (22 <FormField23 label="Email"24 htmlFor="email-error"25 error="A valid email is required"26 >27 <input id="email-error" type="email" />28 </FormField>29);
form-field.spec.tsx
1import { render, screen } from "@testing-library/react";2import { FormField } from "./form-field.js";34describe("FormField", () => {5 it("renders children", () => {6 render(7 <FormField>8 <input />9 </FormField>,10 );11 expect(screen.getByRole("textbox")).toBeTruthy();12 });1314 it("applies additional className", () => {15 const { container } = render(16 <FormField className="custom">17 <input />18 </FormField>,19 );20 expect(container.firstElementChild?.className).toContain("custom");21 });2223 it("renders no label when omitted", () => {24 const { container } = render(25 <FormField>26 <input />27 </FormField>,28 );29 expect(container.querySelector("label")).toBeNull();30 });3132 it("renders the label linked to the control via htmlFor", () => {33 render(34 <FormField label="Email" htmlFor="email-input">35 <input id="email-input" />36 </FormField>,37 );38 expect(screen.getByLabelText("Email")).toBeTruthy();39 });4041 it("shows a required marker next to the label", () => {42 render(43 <FormField label="Email" required>44 <input />45 </FormField>,46 );47 expect(screen.getByText("*")).toBeTruthy();48 });4950 it("shows the hint when there is no error", () => {51 render(52 <FormField hint="We'll never share this">53 <input />54 </FormField>,55 );56 expect(screen.getByText("We'll never share this")).toBeTruthy();57 });5859 it("shows the error instead of the hint when both are present", () => {60 render(61 <FormField hint="We'll never share this" error="Required">62 <input />63 </FormField>,64 );65 expect(screen.getByText("Required")).toBeTruthy();66 expect(screen.queryByText("We'll never share this")).toBeNull();67 });68});