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:
@@ -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,
|
||||
},
|
||||
}
|
||||
120
packages/design-system/lib/components/TextArea/TextArea.test.tsx
Normal file
120
packages/design-system/lib/components/TextArea/TextArea.test.tsx
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
43
packages/design-system/lib/components/TextArea/TextArea.tsx
Normal file
43
packages/design-system/lib/components/TextArea/TextArea.tsx
Normal 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>
|
||||
>
|
||||
2
packages/design-system/lib/components/TextArea/index.tsx
Normal file
2
packages/design-system/lib/components/TextArea/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export { TextArea } from "./TextArea"
|
||||
export type { TextAreaProps } from "./types"
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
7
packages/design-system/lib/components/TextArea/types.ts
Normal file
7
packages/design-system/lib/components/TextArea/types.ts
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user