Behive Tech Registry

Form Field

v0.3.0

Dumb 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`.

pnpm add @behivetech/forms.form-field

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";
4
5export 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}
24
25/**
26 * Dumb wrapper providing label + hint/error + required-marker layout around
27 * any form control (Checkbox, Input, Textarea, Select, Toggle, ...) — the
28 * same job AntD's `Form.Item` does. The control itself owns none of this
29 * 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 ...rest
40}: FormFieldProps) => {
41 const [rootClass, getChildClass] = getClassName({
42 className,
43 rootClass: "BHT__FormField",
44 modifiers: { error: !!error },
45 styles,
46 });
47
48 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 sit
2// outside the control. Both use body-small, as MD3's outlined field does once
3// its label has floated.
4
5.BHT__FormField {
6 display: flex;
7 flex-direction: column;
8 gap: var(--spacing-1, 0.25rem);
9
10 &__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 }
23
24 &__required {
25 color: var(--md-sys-color-error, var(--color-error));
26 }
27
28 &__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 }
36
37 &__hint {
38 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));
39 }
40
41 &__error {
42 color: var(--md-sys-color-error, var(--color-error));
43 }
44
45 // 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 }
55
56 &--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";
2
3export const BasicFormField = () => (
4 <FormField label="Email" htmlFor="email">
5 <input id="email" type="email" />
6 </FormField>
7);
8
9export const RequiredFormField = () => (
10 <FormField label="Email" htmlFor="email-required" required>
11 <input id="email-required" type="email" />
12 </FormField>
13);
14
15export 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);
20
21export const FormFieldWithError = () => (
22 <FormField
23 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";
3
4describe("FormField", () => {
5 it("renders children", () => {
6 render(
7 <FormField>
8 <input />
9 </FormField>,
10 );
11 expect(screen.getByRole("textbox")).toBeTruthy();
12 });
13
14 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 });
22
23 it("renders no label when omitted", () => {
24 const { container } = render(
25 <FormField>
26 <input />
27 </FormField>,
28 );
29 expect(container.querySelector("label")).toBeNull();
30 });
31
32 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 });
40
41 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 });
49
50 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 });
58
59 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});