Behive Tech Component Registry

Text Field

v0.4.0Stable

A bare text input — the native `<input>` is this component's own root element. It has no built-in label/hint/error; wrap it in `FormField` for that chrome. Works standalone too — not every input needs the full form suite.

pnpm add @behivetech/atoms.text-field

registry/behivetech/atoms/text-field

text-field.tsx
1import { type InputHTMLAttributes, useId } from "react";
2import { getClassName } from "@bht-component-registry/class-names";
3import styles from "./text-field.module.scss";
4
5export interface TextFieldProps extends Omit<
6 InputHTMLAttributes<HTMLInputElement>,
7 "size"
8> {
9 /** Additional class names to merge with the component root element */
10 className?: string;
11 /** Marks the field as invalid: adds the `--invalid` modifier and sets `aria-invalid="true"` */
12 invalid?: boolean;
13}
14
15/**
16 * Bare text input — the native `<input>` is this component's own root
17 * element, no built-in label/hint/error. Wrap it in `FormField` for that
18 * chrome: `<FormField label="..." htmlFor="x"><TextField id="x" /></FormField>`.
19 */
20export const TextField = ({
21 className,
22 id,
23 invalid = false,
24 "aria-invalid": ariaInvalid,
25 ...rest
26}: TextFieldProps) => {
27 const generatedId = useId();
28 const fieldId = id ?? generatedId;
29
30 const isInvalid = invalid || ariaInvalid === true || ariaInvalid === "true";
31
32 const [rootClass] = getClassName({
33 className,
34 modifiers: { invalid: isInvalid },
35 rootClass: "BHT__TextField",
36 styles,
37 });
38
39 return (
40 <input
41 {...rest}
42 id={fieldId}
43 className={rootClass}
44 aria-invalid={isInvalid ? true : ariaInvalid}
45 />
46 );
47};
text-field.module.scss
1// Material Design 3 outlined text field (the input only — label and supporting
2// text live in FormField).
3//
4// The 2px focus/error edge is drawn as a 1px inset shadow on top of the 1px
5// border rather than by widening the border, so the text never shifts when the
6// field gains focus.
7
8.BHT__TextField {
9 width: 100%;
10 padding: var(--spacing-2, 0.5rem) var(--spacing-3, 0.75rem);
11 font-family: var(--md-sys-typescale-body-large-font, var(--font-body));
12 font-size: var(--md-sys-typescale-body-large-size, 1rem);
13 font-weight: var(--md-sys-typescale-body-large-weight, 400);
14 line-height: var(--md-sys-typescale-body-large-line-height, 1.5rem);
15 letter-spacing: var(--md-sys-typescale-body-large-tracking, 0.0313em);
16 color: var(--md-sys-color-on-surface, var(--color-on-surface));
17 caret-color: var(--md-sys-color-primary, var(--color-primary));
18 // Outlined fields have no container fill; they take the surface they sit on.
19 background: transparent;
20 border: 1px solid var(--md-sys-color-outline, var(--color-border-strong));
21 border-radius: var(--md-sys-shape-corner-extra-small, 4px);
22 transition:
23 border-color var(--md-sys-motion-duration-short-3, 150ms)
24 var(--md-sys-motion-easing-standard, ease),
25 box-shadow var(--md-sys-motion-duration-short-3, 150ms)
26 var(--md-sys-motion-easing-standard, ease);
27 outline: none;
28 min-height: 2.5rem;
29
30 &::placeholder {
31 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));
32 }
33
34 &:hover:not(:disabled, :focus) {
35 border-color: var(--md-sys-color-on-surface, var(--color-on-surface));
36 }
37
38 &:focus {
39 border-color: var(--md-sys-color-primary, var(--color-border-focus));
40 box-shadow: inset 0 0 0 1px
41 var(--md-sys-color-primary, var(--color-border-focus));
42 }
43
44 &:disabled {
45 opacity: 0.45;
46 cursor: not-allowed;
47 }
48
49 &--invalid {
50 &.BHT__TextField {
51 border-color: var(--md-sys-color-error, var(--color-error));
52 caret-color: var(--md-sys-color-error, var(--color-error));
53
54 &:hover:not(:disabled, :focus) {
55 border-color: var(
56 --md-sys-color-on-error-container,
57 var(--color-error)
58 );
59 }
60
61 &:focus {
62 border-color: var(--md-sys-color-error, var(--color-error));
63 box-shadow: inset 0 0 0 1px
64 var(--md-sys-color-error, var(--color-error));
65 }
66 }
67 }
68}
index.ts
1export { TextField } from "./text-field.js";
2export type { TextFieldProps } from "./text-field.js";
text-field.example.tsx
1import { useState } from "react";
2import { FormField } from "@behivetech/forms.form-field";
3import { TextField } from "./text-field.js";
4
5export const BasicTextField = () => {
6 const [value, setValue] = useState("");
7 return (
8 <TextField
9 value={value}
10 onChange={(e) => setValue(e.target.value)}
11 placeholder="Type here…"
12 />
13 );
14};
15
16export const TextFieldWithFormField = () => (
17 <FormField label="Email" htmlFor="email" hint="We'll never share this">
18 <TextField id="email" type="email" placeholder="you@example.com" />
19 </FormField>
20);
21
22export const TextFieldWithError = () => (
23 <FormField
24 label="Email"
25 htmlFor="email-error"
26 error="A valid email is required"
27 >
28 <TextField id="email-error" type="email" invalid />
29 </FormField>
30);
31
32export const DisabledTextField = () => (
33 <TextField disabled value="Can't touch this" />
34);
text-field.spec.tsx
1import { render, screen } from "@testing-library/react";
2import userEvent from "@testing-library/user-event";
3import { TextField } from "./text-field.js";
4
5describe("TextField", () => {
6 it("renders as a textbox", () => {
7 render(<TextField />);
8 expect(screen.getByRole("textbox")).toBeTruthy();
9 });
10
11 it("accepts typed input", async () => {
12 render(<TextField />);
13 const field = screen.getByRole("textbox");
14 await userEvent.type(field, "hello");
15 expect(field).toHaveValue("hello");
16 });
17
18 it("does not accept input when disabled", async () => {
19 render(<TextField disabled />);
20 expect(screen.getByRole("textbox")).toBeDisabled();
21 });
22
23 it("passes aria-invalid through to the root element", () => {
24 render(<TextField aria-invalid="true" />);
25 expect(screen.getByRole("textbox").getAttribute("aria-invalid")).toBe(
26 "true",
27 );
28 });
29
30 it("adds the --invalid modifier and aria-invalid for the invalid prop", () => {
31 render(<TextField invalid />);
32 const el = screen.getByRole("textbox");
33 expect(el.className).toContain("--invalid");
34 expect(el.getAttribute("aria-invalid")).toBe("true");
35 });
36
37 it("adds the --invalid modifier for a direct aria-invalid", () => {
38 render(<TextField aria-invalid="true" />);
39 expect(screen.getByRole("textbox").className).toContain("--invalid");
40 });
41
42 it("has no --invalid modifier by default", () => {
43 render(<TextField />);
44 expect(screen.getByRole("textbox").className).not.toContain("--invalid");
45 });
46
47 it("applies additional className", () => {
48 render(<TextField className="custom" />);
49 expect(screen.getByRole("textbox").className).toContain("custom");
50 });
51
52 it("generates an id when none is provided", () => {
53 render(<TextField />);
54 expect(screen.getByRole("textbox").id).toBeTruthy();
55 });
56});