Behive Tech Registry

Textarea

v0.4.0

A 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.

pnpm add @behivetech/atoms.textarea

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";
4
5export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
6 /** Additional class names to merge with the component root element */
7 className?: string;
8}
9
10/**
11 * Bare textarea — the native `<textarea>` is this component's own root
12 * element, no built-in label/hint/error. Wrap it in `FormField` for that
13 * chrome: `<FormField label="..." htmlFor="x"><Textarea id="x" /></FormField>`.
14 */
15export const Textarea = ({
16 className,
17 id,
18 rows = 4,
19 ...rest
20}: TextareaProps) => {
21 const generatedId = useId();
22 const textareaId = id ?? generatedId;
23
24 const [rootClass] = getClassName({
25 className,
26 rootClass: "BHT__Textarea",
27 styles,
28 });
29
30 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 — label
2// and supporting 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__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;
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 resize: none;
48 }
49
50 &[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));
53
54 &:hover:not(:disabled, :focus) {
55 border-color: var(--md-sys-color-on-error-container, var(--color-error));
56 }
57
58 &: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";
3
4export const BasicTextarea = () => (
5 <Textarea placeholder="Type your message..." />
6);
7
8export const TextareaWithFormField = () => (
9 <FormField label="Bio" htmlFor="bio" hint="Max 200 characters.">
10 <Textarea id="bio" rows={3} />
11 </FormField>
12);
13
14export const TextareaWithError = () => (
15 <FormField
16 label="Description"
17 htmlFor="description"
18 error="Description is required."
19 >
20 <Textarea id="description" aria-invalid="true" />
21 </FormField>
22);
23
24export 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";
4
5describe("Textarea", () => {
6 it("renders as a textbox", () => {
7 render(<Textarea />);
8 expect(screen.getByRole("textbox")).toBeTruthy();
9 });
10
11 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 });
17
18 it("is disabled when disabled prop is true", () => {
19 render(<Textarea disabled />);
20 expect(screen.getByRole("textbox")).toBeDisabled();
21 });
22
23 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 });
29
30 it("defaults to 4 rows", () => {
31 render(<Textarea />);
32 expect(screen.getByRole("textbox")).toHaveAttribute("rows", "4");
33 });
34
35 it("applies additional className", () => {
36 render(<Textarea className="custom" />);
37 expect(screen.getByRole("textbox").className).toContain("custom");
38 });
39});