Textarea
v0.4.0A bare multi-line text input — the native `<textarea>` 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 textarea needs the full form suite.
registry/behivetech/atoms/textarea · depends on @behivetech/get-class-name
textarea.tsx
1import { type TextareaHTMLAttributes, useId } from "react";2import { getClassName } from "@behivetech/get-class-name";3import styles from "./textarea.module.scss";45export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {6 /** Additional class names to merge with the component root element */7 className?: string;8}910/**11 * Bare textarea — the native `<textarea>` is this component's own root12 * element, no built-in label/hint/error. Wrap it in `FormField` for that13 * chrome: `<FormField label="..." htmlFor="x"><Textarea id="x" /></FormField>`.14 */15export const Textarea = ({16 className,17 id,18 rows = 4,19 ...rest20}: TextareaProps) => {21 const generatedId = useId();22 const textareaId = id ?? generatedId;2324 const [rootClass] = getClassName({25 className,26 rootClass: "BHT__Textarea",27 styles,28 });2930 return (31 <textarea {...rest} id={textareaId} rows={rows} className={rootClass} />32 );33};
textarea.module.scss
1// Material Design 3 outlined text field, multi-line (the input only — label2// and supporting 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__Textarea {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 resize: vertical;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 resize: none;48 }4950 &[aria-invalid="true"] {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(--md-sys-color-on-error-container, var(--color-error));56 }5758 &:focus {59 border-color: var(--md-sys-color-error, var(--color-error));60 box-shadow: inset 0 0 0 1px var(--md-sys-color-error, var(--color-error));61 }62 }63}
index.ts
1export { Textarea } from './textarea.js';2export type { TextareaProps } from './textarea.js';
textarea.composition.tsx
1import { FormField } from "@behivetech/forms.form-field";2import { Textarea } from "./textarea.js";34export const BasicTextarea = () => (5 <Textarea placeholder="Type your message..." />6);78export const TextareaWithFormField = () => (9 <FormField label="Bio" htmlFor="bio" hint="Max 200 characters.">10 <Textarea id="bio" rows={3} />11 </FormField>12);1314export const TextareaWithError = () => (15 <FormField16 label="Description"17 htmlFor="description"18 error="Description is required."19 >20 <Textarea id="description" aria-invalid="true" />21 </FormField>22);2324export const DisabledTextarea = () => (25 <Textarea disabled value="Cannot edit this" onChange={() => {}} />26);
textarea.spec.tsx
1import { render, screen } from "@testing-library/react";2import userEvent from "@testing-library/user-event";3import { Textarea } from "./textarea.js";45describe("Textarea", () => {6 it("renders as a textbox", () => {7 render(<Textarea />);8 expect(screen.getByRole("textbox")).toBeTruthy();9 });1011 it("accepts typed input", async () => {12 render(<Textarea />);13 const field = screen.getByRole("textbox");14 await userEvent.type(field, "hello");15 expect(field).toHaveValue("hello");16 });1718 it("is disabled when disabled prop is true", () => {19 render(<Textarea disabled />);20 expect(screen.getByRole("textbox")).toBeDisabled();21 });2223 it("passes aria-invalid through to the root element", () => {24 render(<Textarea aria-invalid="true" />);25 expect(screen.getByRole("textbox").getAttribute("aria-invalid")).toBe(26 "true",27 );28 });2930 it("defaults to 4 rows", () => {31 render(<Textarea />);32 expect(screen.getByRole("textbox")).toHaveAttribute("rows", "4");33 });3435 it("applies additional className", () => {36 render(<Textarea className="custom" />);37 expect(screen.getByRole("textbox").className).toContain("custom");38 });39});