Text Field
v0.4.0StableA 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.
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";45export 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}1415/**16 * Bare text input — the native `<input>` is this component's own root17 * element, no built-in label/hint/error. Wrap it in `FormField` for that18 * 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 ...rest26}: TextFieldProps) => {27 const generatedId = useId();28 const fieldId = id ?? generatedId;2930 const isInvalid = invalid || ariaInvalid === true || ariaInvalid === "true";3132 const [rootClass] = getClassName({33 className,34 modifiers: { invalid: isInvalid },35 rootClass: "BHT__TextField",36 styles,37 });3839 return (40 <input41 {...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 supporting2// text live in FormField).3//4// The 2px focus/error edge is drawn as a 1px inset shadow on top of the 1px5// border rather than by widening the border, so the text never shifts when the6// field gains focus.78.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;2930 &::placeholder {31 color: var(--md-sys-color-on-surface-variant, var(--color-text-muted));32 }3334 &:hover:not(:disabled, :focus) {35 border-color: var(--md-sys-color-on-surface, var(--color-on-surface));36 }3738 &:focus {39 border-color: var(--md-sys-color-primary, var(--color-border-focus));40 box-shadow: inset 0 0 0 1px41 var(--md-sys-color-primary, var(--color-border-focus));42 }4344 &:disabled {45 opacity: 0.45;46 cursor: not-allowed;47 }4849 &--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));5354 &:hover:not(:disabled, :focus) {55 border-color: var(56 --md-sys-color-on-error-container,57 var(--color-error)58 );59 }6061 &:focus {62 border-color: var(--md-sys-color-error, var(--color-error));63 box-shadow: inset 0 0 0 1px64 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";45export const BasicTextField = () => {6 const [value, setValue] = useState("");7 return (8 <TextField9 value={value}10 onChange={(e) => setValue(e.target.value)}11 placeholder="Type here…"12 />13 );14};1516export 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);2122export const TextFieldWithError = () => (23 <FormField24 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);3132export 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";45describe("TextField", () => {6 it("renders as a textbox", () => {7 render(<TextField />);8 expect(screen.getByRole("textbox")).toBeTruthy();9 });1011 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 });1718 it("does not accept input when disabled", async () => {19 render(<TextField disabled />);20 expect(screen.getByRole("textbox")).toBeDisabled();21 });2223 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 });2930 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 });3637 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 });4142 it("has no --invalid modifier by default", () => {43 render(<TextField />);44 expect(screen.getByRole("textbox").className).not.toContain("--invalid");45 });4647 it("applies additional className", () => {48 render(<TextField className="custom" />);49 expect(screen.getByRole("textbox").className).toContain("custom");50 });5152 it("generates an id when none is provided", () => {53 render(<TextField />);54 expect(screen.getByRole("textbox").id).toBeTruthy();55 });56});