Merged in feat/3685-new-textarea-component (pull request #3392)

feat(SW-3685): Add new TextArea and FormTextArea components

* Add new TextArea and FormTextArea components

* Update example form with description

* Merge branch 'master' into feat/3685-new-textarea-component

* Formatting new files with new prettier config

* Added custom controls for the text area story


Approved-by: Linus Flood
This commit is contained in:
Rasmus Langvad
2026-01-07 17:04:30 +00:00
parent d0546926a9
commit 4980cc830d
17 changed files with 752 additions and 304 deletions

View File

@@ -0,0 +1,196 @@
import { zodResolver } from "@hookform/resolvers/zod"
import type { Meta, StoryObj } from "@storybook/nextjs-vite"
import { useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { z } from "zod"
import { FormTextArea } from "../Form/FormTextArea"
import type { MaterialIconProps } from "../Icons/MaterialIcon"
interface FormTextAreaStoryProps {
label?: string
placeholder?: string
description?: string
descriptionIcon?: MaterialIconProps["icon"]
disabled?: boolean
readOnly?: boolean
required?: boolean
showLabel?: boolean
showDescription?: boolean
showDescriptionIcon?: boolean
filled?: boolean
defaultValue?: string
errorMessage?: string
name?: string
}
function FormTextAreaComponent({
label,
placeholder,
description,
descriptionIcon = "info",
disabled = false,
readOnly = false,
required = false,
showLabel = true,
showDescription = true,
showDescriptionIcon = true,
filled = false,
defaultValue = "",
errorMessage,
name = "textarea",
}: FormTextAreaStoryProps) {
const schema = z.object({
[name]: errorMessage
? z.string().min(1, errorMessage)
: required
? z.string().min(1, "This field is required")
: z.string().optional(),
})
const methods = useForm<{ [key: string]: string }>({
resolver: zodResolver(schema),
defaultValues: {
[name]: filled ? defaultValue : "",
},
mode: "onChange",
})
useEffect(() => {
if (errorMessage) {
// Set error manually if errorMessage is provided
methods.setError(name, {
type: "manual",
message: errorMessage,
})
// Trigger validation to show the error immediately
methods.trigger(name)
} else {
// Clear errors if errorMessage is removed
methods.clearErrors(name)
}
}, [errorMessage, methods, name])
return (
<FormProvider {...methods}>
<div style={{ maxWidth: "500px", padding: "1rem" }}>
<FormTextArea
name={name}
label={showLabel ? label : undefined}
placeholder={placeholder}
description={showDescription ? description : undefined}
descriptionIcon={showDescriptionIcon ? descriptionIcon : undefined}
disabled={disabled}
readOnly={readOnly}
registerOptions={required ? { required: true } : undefined}
/>
</div>
</FormProvider>
)
}
const meta: Meta<typeof FormTextAreaComponent> = {
title: "Core Components/TextArea",
component: FormTextAreaComponent,
argTypes: {
label: {
control: "text",
description: "The label text displayed above the textarea field",
table: {
type: { summary: "string" },
},
},
placeholder: {
control: "text",
description: "Placeholder text shown when textarea is empty",
table: {
type: { summary: "string" },
defaultValue: { summary: "undefined" },
},
},
description: {
control: "text",
description: "Supporting text displayed below the textarea",
table: {
type: { summary: "string" },
defaultValue: { summary: "undefined" },
},
},
disabled: {
control: "boolean",
description: "Whether the textarea is disabled",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
},
},
readOnly: {
control: "boolean",
description: "Whether the textarea is read-only",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
},
},
required: {
control: "boolean",
description: "Whether the textarea is required",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "false" },
},
},
showLabel: {
control: "boolean",
description: "Whether to show the label",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "true" },
},
},
showDescription: {
control: "boolean",
description: "Whether to show the description/supporting text",
table: {
type: { summary: "boolean" },
defaultValue: { summary: "true" },
},
},
defaultValue: {
control: "text",
description: "Default value when filled is true",
table: {
type: { summary: "string" },
defaultValue: { summary: "" },
},
},
errorMessage: {
control: "text",
description: "Error message to display (triggers error state)",
table: {
type: { summary: "string" },
defaultValue: { summary: "undefined" },
},
},
},
}
export default meta
type Story = StoryObj<typeof FormTextAreaComponent>
export const Default: Story = {
args: {
label: "Label",
placeholder: "Placeholder",
description: "Supporting text",
disabled: false,
readOnly: false,
required: false,
showLabel: true,
showDescription: true,
defaultValue: "",
errorMessage: undefined,
},
}

View File

@@ -0,0 +1,120 @@
import { describe, expect, it, vi, afterEach } from "vitest"
import { render, screen, fireEvent, cleanup } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { TextArea } from "./TextArea"
import { TextField } from "react-aria-components"
afterEach(() => {
cleanup()
})
// Wrap TextArea in TextField for proper React Aria context
const renderTextArea = (props: React.ComponentProps<typeof TextArea>) => {
return render(
<TextField>
<TextArea {...props} />
</TextField>
)
}
// Render TextArea standalone for testing its own behavior
const renderTextAreaStandalone = (
props: React.ComponentProps<typeof TextArea>
) => {
return render(<TextArea {...props} />)
}
describe("TextArea", () => {
describe("props", () => {
it("applies required attribute", () => {
renderTextArea({ label: "Description", required: true })
expect(screen.getByRole("textbox")).toHaveProperty("required", true)
})
it("applies placeholder when provided", () => {
renderTextArea({ label: "Description", placeholder: "Enter description" })
expect(screen.getByRole("textbox").getAttribute("placeholder")).toBe(
"Enter description"
)
})
it("applies empty placeholder by default", () => {
renderTextArea({ label: "Description" })
expect(screen.getByRole("textbox").getAttribute("placeholder")).toBe("")
})
it("applies custom id", () => {
renderTextAreaStandalone({ label: "Description", id: "custom-id" })
expect(screen.getByRole("textbox").getAttribute("id")).toBe("custom-id")
})
})
describe("label", () => {
it("renders label when provided", () => {
renderTextArea({ label: "Description" })
expect(screen.getByText("Description")).toBeTruthy()
})
it("does not render label when not provided", () => {
renderTextArea({})
expect(screen.queryByText("Description")).toBeNull()
})
})
describe("controlled textarea", () => {
it("displays the controlled value", () => {
renderTextArea({
label: "Description",
value: "Some text content",
onChange: vi.fn(),
})
expect(screen.getByRole("textbox")).toHaveProperty(
"value",
"Some text content"
)
})
it("calls onChange when typing", async () => {
const onChange = vi.fn()
renderTextArea({ label: "Description", value: "", onChange })
const textarea = screen.getByRole("textbox")
await userEvent.type(textarea, "a")
expect(onChange).toHaveBeenCalled()
})
it("does not change value without onChange updating it", () => {
const onChange = vi.fn()
renderTextArea({ label: "Description", value: "initial", onChange })
const textarea = screen.getByRole("textbox")
fireEvent.change(textarea, { target: { value: "changed" } })
expect(textarea).toHaveProperty("value", "initial")
})
})
describe("ref forwarding", () => {
it("forwards ref to the textarea element", () => {
const ref = { current: null as HTMLTextAreaElement | null }
render(
<TextField>
<TextArea label="Description" ref={ref} />
</TextField>
)
expect(ref.current).toBeInstanceOf(HTMLTextAreaElement)
})
it("allows focusing via ref", () => {
const ref = { current: null as HTMLTextAreaElement | null }
render(
<TextField>
<TextArea label="Description" ref={ref} />
</TextField>
)
ref.current?.focus()
expect(document.activeElement).toBe(ref.current)
})
})
})

View File

@@ -0,0 +1,43 @@
import { cx } from "class-variance-authority"
import { forwardRef, type ForwardedRef, useId } from "react"
import { TextArea as AriaTextArea } from "react-aria-components"
import { InputLabel } from "../InputLabel"
import { Typography } from "../Typography"
import styles from "./textarea.module.css"
import type { TextAreaProps } from "./types"
const TextAreaComponent = forwardRef(function TextAreaComponent(
{ label, placeholder, id, required, ...props }: TextAreaProps,
ref: ForwardedRef<HTMLTextAreaElement>
) {
const generatedId = useId()
const textareaId = id || generatedId
return (
<>
{label && (
<InputLabel required={required} className={styles.labelAbove}>
{label}
</InputLabel>
)}
<label className={styles.container} htmlFor={textareaId}>
<Typography variant="Body/Paragraph/mdRegular">
<AriaTextArea
{...props}
id={textareaId}
required={required}
placeholder={placeholder ?? ""}
className={cx(styles.textarea, props.className)}
ref={ref}
/>
</Typography>
</label>
</>
)
})
export const TextArea = TextAreaComponent as React.ForwardRefExoticComponent<
TextAreaProps & React.RefAttributes<HTMLTextAreaElement>
>

View File

@@ -0,0 +1,2 @@
export { TextArea } from "./TextArea"
export type { TextAreaProps } from "./types"

View File

@@ -0,0 +1,70 @@
.labelAbove {
color: var(--Text-Default);
font-family: var(--Label-Font-family), var(--Label-Font-fallback);
font-size: var(--Body-Supporting-text-Size);
font-weight: var(--Body-Supporting-text-Font-weight-2);
letter-spacing: var(--Body-Supporting-text-Letter-spacing);
line-height: 1.5;
margin-bottom: var(--Space-x1);
}
.container {
background-color: var(--Surface-Primary-Default);
border: 1px solid var(--Border-Interactive-Default);
border-radius: var(--Corner-radius-md);
display: block;
min-width: 0;
height: 138px;
padding: var(--Space-x2);
box-sizing: border-box;
cursor: text;
&:has(.textarea:focus):not(:has(.textarea:disabled)):not(
:has(.textarea:read-only)
):not(:has(.textarea[data-invalid="true"])):not(
:has(.textarea[aria-invalid="true"])
) {
outline-offset: -2px;
outline: 2px solid var(--Border-Interactive-Focus);
}
&:has(.textarea:disabled),
&:has(.textarea:read-only) {
background-color: var(--Surface-Primary-Disabled);
border: transparent;
cursor: unset;
}
&:has(.textarea[data-invalid="true"], .textarea[aria-invalid="true"]) {
border-color: var(--Border-Interactive-Error);
&:focus-within,
&:has(.textarea:focus) {
outline-offset: -2px;
outline: 2px solid var(--Border-Interactive-Error);
border-color: var(--Border-Interactive-Error);
}
}
}
.textarea {
background: none;
border: none;
color: var(--Text-Default);
height: 100%;
width: 100%;
margin: 0;
overflow: visible;
padding: 0;
resize: none;
&:focus {
outline: none;
}
&:disabled,
&:read-only {
color: var(--Text-Interactive-Disabled);
cursor: unset;
}
}

View File

@@ -0,0 +1,7 @@
import { ComponentProps } from "react"
import { TextArea } from "react-aria-components"
export interface TextAreaProps extends ComponentProps<typeof TextArea> {
/** Optional label displayed above the textarea */
label?: string
}