diff --git a/.changeset/fuzzy-ears-occur.md b/.changeset/fuzzy-ears-occur.md
new file mode 100644
index 00000000000..396b4ad300c
--- /dev/null
+++ b/.changeset/fuzzy-ears-occur.md
@@ -0,0 +1,5 @@
+---
+"@utrecht/component-library-react": minor
+---
+
+Add `FormFieldTextarea` component to the React component library.
diff --git a/packages/component-library-react/src/FormFieldTextarea.test.tsx b/packages/component-library-react/src/FormFieldTextarea.test.tsx
new file mode 100644
index 00000000000..d13f8284a3e
--- /dev/null
+++ b/packages/component-library-react/src/FormFieldTextarea.test.tsx
@@ -0,0 +1,826 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { createRef } from 'react';
+import { FormFieldTextarea } from './FormFieldTextarea';
+import '@testing-library/jest-dom';
+
+describe('Form field with a textarea', () => {
+ const defaultProps = {
+ name: 'subject',
+ label: 'Subject',
+ };
+
+ it('renders an HTML div element', () => {
+ const { container } = render();
+
+ const field = container.querySelector('div');
+
+ expect(field).toBeInTheDocument();
+ });
+
+ it('renders a design system BEM class name: utrecht-form-field', () => {
+ const { container } = render();
+
+ const field = container.querySelector('div');
+
+ expect(field).toHaveClass('utrecht-form-field');
+ });
+
+ it('displays as CSS block element (or equivalent)', () => {
+ const { container } = render();
+
+ const field = container.querySelector('div');
+
+ expect(field).toBeVisible();
+ expect(field).not.toHaveStyle({ display: 'inline' });
+ expect(field).not.toHaveStyle({ display: 'inline-block' });
+ });
+
+ it('renders rich text content', () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ const richText = container.querySelector('hr');
+
+ expect(richText).toBeInTheDocument();
+ });
+
+ it('can be hidden', () => {
+ const { container } = render();
+
+ const field = container.querySelector('div');
+
+ expect(field).not.toBeVisible();
+ });
+
+ it('can have a custom class name', () => {
+ const { container } = render();
+
+ const field = container.querySelector('div');
+
+ expect(field).toHaveClass('invalid');
+ });
+
+ it('can have a additional class name', () => {
+ const { container } = render();
+
+ const field = container.querySelector(':only-child');
+
+ expect(field).toHaveClass('large');
+ expect(field).toHaveClass('utrecht-form-field');
+ });
+
+ describe('label', () => {
+ it('renders a design system BEM class name: utrecht-form-field__label', () => {
+ const { container } = render();
+
+ const field = container.querySelector('.utrecht-form-field__label');
+
+ expect(field).toBeInTheDocument();
+ });
+
+ it('renders rich text content', () => {
+ const { container } = render(
+
+ I can speak the lingua franca
+ >
+ }
+ />,
+ );
+
+ const richText = container.querySelector('i');
+
+ expect(richText).toBeInTheDocument();
+ });
+
+ it('is associated with the textbox', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox', { name: 'Subject' });
+
+ expect(textbox).toBeInTheDocument();
+ });
+
+ // We cannot test this currently, because jsdom doesn't appear to have
+ // implemented focusing the associated input after clicking
+ }
+ />,
+ );
+
+ const richText = container.querySelector('strong');
+
+ expect(richText).toBeInTheDocument();
+ });
+
+ it('is associated with the textbox', () => {
+ const description = 'Lingua franca is a common language between groups of people.';
+
+ render();
+
+ const textbox = screen.getByRole('textbox', { description });
+
+ expect(textbox).toBeInTheDocument();
+ });
+ });
+
+ describe('error message', () => {
+ it('is not rendered by default', () => {
+ const { container } = render();
+
+ const field = container.querySelector('.utrecht-form-field__error-message');
+
+ expect(field).not.toBeInTheDocument();
+ });
+
+ it('renders a design system BEM class name: utrecht-form-field__error-message', () => {
+ const { container } = render(
+ ,
+ );
+
+ const field = container.querySelector('.utrecht-form-field__error-message');
+
+ expect(field).toBeInTheDocument();
+ });
+
+ it('renders rich text content', () => {
+ const { container } = render(
+
+ You required to agree.
+
+ }
+ />,
+ );
+
+ const richText = container.querySelector('strong');
+
+ expect(richText).toBeInTheDocument();
+ });
+
+ it('is associated with the textbox', () => {
+ const errorMessage = 'Check this required field to continue.';
+
+ render();
+
+ const textbox = screen.getByRole('textbox', { description: errorMessage });
+
+ expect(textbox).toBeInTheDocument();
+ });
+ });
+
+ describe('input', () => {
+ it('renders a textbox role element', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(textbox).toBeInTheDocument();
+ });
+
+ it('renders a design system BEM class name: utrecht-form-field__input', () => {
+ const { container } = render();
+
+ const field = container.querySelector('.utrecht-form-field__input');
+
+ expect(field).toBeInTheDocument();
+ });
+
+ it('renders an HTML textarea element', () => {
+ const { container } = render();
+
+ const textbox = container.querySelector('textarea');
+
+ expect(textbox).toBeInTheDocument();
+ });
+ });
+
+ describe('status', () => {
+ it('is not rendered by default', () => {
+ const { container } = render();
+
+ const field = container.querySelector('.utrecht-form-field__status');
+
+ expect(field).not.toBeInTheDocument();
+ });
+
+ it('renders a design system BEM class name: utrecht-form-field__status', () => {
+ const { container } = render();
+
+ const field = container.querySelector('.utrecht-form-field__status');
+
+ expect(field).toBeInTheDocument();
+ });
+
+ it('renders rich text content', () => {
+ const { container } = render(
+
+ Saving failed. Please try again at a later time.
+
+ }
+ />,
+ );
+
+ const richText = container.querySelector('strong');
+
+ expect(richText).toBeInTheDocument();
+ });
+
+ it('is associated with the textbox', () => {
+ const status = '40 characters remaining';
+
+ render();
+
+ const textbox = screen.getByRole('textbox', { description: status });
+
+ expect(textbox).toBeInTheDocument();
+ });
+ });
+
+ describe('change event', () => {
+ it('can trigger a change event', async () => {
+ const handleChange = jest.fn();
+
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(handleChange).not.toHaveBeenCalled();
+
+ textbox?.focus();
+ await userEvent.keyboard('Hello, world!');
+
+ expect(handleChange).toHaveBeenCalled();
+ });
+
+ it('does not trigger a change event when disabled', async () => {
+ const handleChange = jest.fn();
+
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ textbox?.focus();
+ await userEvent.keyboard('Hello, world!');
+
+ expect(handleChange).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('focus event', () => {
+ it('can trigger a focus event', async () => {
+ const handleFocus = jest.fn();
+
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(handleFocus).not.toHaveBeenCalled();
+
+ textbox?.focus();
+
+ expect(handleFocus).toHaveBeenCalled();
+ });
+
+ it('can trigger a focus event when read-only', async () => {
+ const handleFocus = jest.fn();
+
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ textbox?.focus();
+
+ expect(handleFocus).toHaveBeenCalled();
+ });
+
+ it('does not trigger a focus event when disabled', async () => {
+ const handleFocus = jest.fn();
+
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ textbox?.focus();
+
+ expect(handleFocus).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('blur event', () => {
+ it('can trigger a blur event', async () => {
+ const handleBlur = jest.fn();
+
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(handleBlur).not.toHaveBeenCalled();
+
+ textbox?.focus();
+ textbox?.blur();
+
+ expect(handleBlur).toHaveBeenCalled();
+ });
+
+ it('can trigger a blur event when read-only', async () => {
+ const handleBlur = jest.fn();
+
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ textbox?.focus();
+ textbox?.blur();
+
+ expect(handleBlur).toHaveBeenCalled();
+ });
+
+ it('does not trigger a blur event when disabled', async () => {
+ const handleBlur = jest.fn();
+
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ textbox?.focus();
+ textbox?.blur();
+
+ expect(handleBlur).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('input event', () => {
+ it('can trigger a input event', async () => {
+ const handleInput = jest.fn();
+
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(handleInput).not.toHaveBeenCalled();
+
+ textbox?.focus();
+ await userEvent.keyboard('Hello, world!');
+
+ expect(handleInput).toHaveBeenCalled();
+ });
+
+ it('does not trigger a input event when disabled', async () => {
+ const handleInput = jest.fn();
+
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ textbox?.focus();
+ await userEvent.keyboard('Hello, world!');
+
+ expect(handleInput).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('invalid state', () => {
+ it('renders a design system BEM modifier class name: utrecht-form-field--invalid', () => {
+ const { container } = render();
+
+ const formField = container.querySelector('.utrecht-form-field');
+
+ expect(formField).toHaveClass('utrecht-form-field--invalid');
+ });
+
+ it('renders a design system BEM modifier class name: utrecht-textarea--invalid', () => {
+ const { container } = render();
+
+ const textbox = container.querySelector('.utrecht-textarea');
+
+ expect(textbox).toHaveClass('utrecht-textarea--invalid');
+ });
+
+ it('is not invalid by default', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(textbox).not.toBeInvalid();
+ });
+
+ it('omits non-essential invalid attributes when not invalid', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(textbox).not.toHaveAttribute('aria-invalid');
+ });
+
+ it('can have an invalid state', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(textbox).toBeInvalid();
+ });
+
+ it.skip('can have a invalid state in CSS based on required', () => {
+ // This doesn't work, because the rendering uses `aria-required`
+ const { container } = render();
+
+ const textbox = container.querySelector(':invalid');
+
+ expect(textbox).toBeInTheDocument();
+ });
+ });
+
+ describe('disabled state', () => {
+ it('renders a design system BEM modifier class name: utrecht-textarea--disabled', () => {
+ const { container } = render();
+
+ const textbox = container.querySelector('.utrecht-textarea');
+
+ expect(textbox).toHaveClass('utrecht-textarea--disabled');
+ });
+
+ it('is not disabled by default', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(textbox).not.toBeDisabled();
+ });
+
+ it('omits non-essential disabled attributes when not disabled', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(textbox).not.toHaveAttribute('aria-disabled');
+
+ expect(textbox).not.toHaveAttribute('disabled');
+ });
+
+ it('can have a disabled state', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(textbox).toBeDisabled();
+ });
+
+ it('can have a disabled state in CSS', () => {
+ const { container } = render();
+
+ const textbox = container.querySelector(':disabled');
+
+ expect(textbox).toBeInTheDocument();
+ });
+ });
+
+ describe('required state', () => {
+ it('renders a design system BEM modifier class name: utrecht-textarea--required', () => {
+ const { container } = render();
+
+ const textbox = container.querySelector('.utrecht-textarea');
+
+ expect(textbox).toHaveClass('utrecht-textarea--required');
+ });
+
+ it('is not required by default', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(textbox).not.toBeRequired();
+ });
+
+ it('omits non-essential required attributes when not required', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(textbox).not.toHaveAttribute('aria-required');
+
+ expect(textbox).not.toHaveAttribute('required');
+ });
+
+ it('can have a required state', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(textbox).toBeRequired();
+ });
+ });
+
+ describe('read-only state', () => {
+ it('renders a design system BEM modifier class name: utrecht-textarea--readonly', () => {
+ const { container } = render();
+
+ const textbox = container.querySelector('.utrecht-textarea');
+
+ expect(textbox).toHaveClass('utrecht-textarea--readonly');
+ });
+
+ it('is not read-only in CSS by default', () => {
+ const { container } = render();
+
+ const textbox = container.querySelector(':read-only');
+
+ expect(textbox).not.toBeInTheDocument();
+ });
+
+ // Test doesn't work, Testing Library doesn't support testing read only state
+ it.skip('is not read-only in the accessibility tree by default', () => {});
+
+ it('omits non-essential disabled attributes when not read-only', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(textbox).not.toHaveAttribute('aria-readonly');
+ expect(textbox).not.toHaveAttribute('readonly');
+ });
+
+ // Test doesn't work, Testing Library doesn't support testing read only state
+ it.skip('can have a read-only state in the accessibility tree', () => {});
+
+ it('can have a read-only state in CSS', () => {
+ const { container } = render();
+
+ const textbox = container.querySelector(':read-only');
+
+ expect(textbox).toBeInTheDocument();
+ });
+ });
+
+ describe('minlength', () => {
+ it('omits non-essential required attributes when not required', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ // avoid `minlength="0"`
+ expect(textbox).not.toHaveAttribute('minlength');
+ });
+
+ it('can have a minlength', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(textbox).toHaveAttribute('minlength');
+ });
+
+ // Test doesn't work, perhaps Testing Library doesn't support `minLength` validation
+ it.skip('can have a invalid state in CSS based on minlength', async () => {
+ const { container } = render();
+
+ const textbox = screen.getByRole('textbox');
+
+ // minlength is validated only for inputs with the "dirty flag", so only after changes:
+ // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#attr-fe-minlength
+ textbox?.focus();
+ await userEvent.keyboard('Hello, world!');
+ textbox?.blur();
+
+ const invalidTextbox = container.querySelector(':invalid');
+
+ expect(invalidTextbox).toBeInTheDocument();
+ });
+ });
+
+ describe('maxlength', () => {
+ it('omits non-essential required attributes when not required', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(textbox).not.toHaveAttribute('maxlength');
+ });
+
+ it('can have a maxlength', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(textbox).toHaveAttribute('maxlength');
+ });
+
+ // Test doesn't work, perhaps Testing Library doesn't support `maxLength` validation
+ it.skip('can have a invalid state in CSS based on maxlength', async () => {
+ const { container } = render();
+
+ const textbox = screen.getByRole('textbox');
+
+ // maxlength is validated only for inputs with the "dirty flag", so only after changes:
+ // https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#attr-fe-maxlength
+ textbox?.focus();
+ await userEvent.keyboard('Hello, world!');
+ textbox?.blur();
+
+ const invalidTextbox = container.querySelector(':invalid');
+
+ expect(invalidTextbox).toBeInTheDocument();
+ });
+ });
+
+ describe('placeholder', () => {
+ it('omits non-essential required attributes when not required', () => {
+ render();
+
+ const textbox = screen.getByRole('textbox');
+
+ expect(textbox).not.toHaveAttribute('placeholder');
+ });
+
+ it('does not have a placeholder by default', () => {
+ const { container } = render();
+
+ const textbox = container.querySelector(':placeholder-shown');
+
+ expect(textbox).not.toBeInTheDocument();
+ });
+
+ it('can have a placeholder', () => {
+ const { container } = render();
+
+ const textbox = container.querySelector(':placeholder-shown');
+
+ expect(textbox).toBeInTheDocument();
+ });
+ });
+
+ describe('input text directionality', () => {
+ it('renders bidirectional by default using `dir="auto"', () => {
+ const { container } = render();
+
+ const textbox = container.querySelector('textarea:only-child');
+
+ expect(textbox).toHaveAttribute('dir', 'auto');
+ });
+
+ it('can render left-to-right', () => {
+ const { container } = render();
+
+ const textbox = container.querySelector('textarea:only-child');
+
+ expect(textbox).toHaveAttribute('dir', 'ltr');
+ });
+
+ it('can render right-to-left', () => {
+ const { container } = render();
+
+ const textbox = container.querySelector('textarea:only-child');
+
+ expect(textbox).toHaveAttribute('dir', 'rtl');
+ });
+ });
+
+ describe('autocomplete', () => {
+ it('is not rendered by default', () => {
+ const { container } = render();
+
+ const textbox = container.querySelector('textarea');
+
+ expect(textbox).not.toHaveAttribute('autocomplete');
+ });
+
+ it('renders the autocomplete attribute on the textarea element', () => {
+ const { container } = render();
+
+ const textbox = container.querySelector('textarea');
+
+ expect(textbox).toHaveAttribute('autocomplete', 'off');
+ });
+ });
+
+ describe('name', () => {
+ it('associates the textarea with a form', () => {
+ const name = 'message';
+ const { container } = render(
+ ,
+ );
+
+ const form = container.querySelector('form');
+ const textbox = container.querySelector('textarea');
+
+ // TypeScript definitions don't yet support the named index
+ expect(form?.elements[name as any]).toBe(textbox);
+ });
+ });
+
+ describe('rows', () => {
+ it('can render the rows attribute on the textarea', () => {
+ const { container } = render(
+ ,
+ );
+
+ const textarea = container.querySelector('textarea');
+ expect(textarea).toHaveAttribute('rows', '20');
+ });
+ });
+
+ describe('cols', () => {
+ it('can render the cols attribute on the textarea', () => {
+ const { container } = render(
+ ,
+ );
+
+ const textarea = container.querySelector('textarea');
+ expect(textarea).toHaveAttribute('cols', '20');
+ });
+ });
+
+ describe.skip('inputId', () => {});
+ describe.skip('defaultValue', () => {});
+
+ it('supports ForwardRef in React', () => {
+ const ref = createRef();
+
+ const { container } = render();
+
+ const div = container.querySelector('div');
+
+ expect(ref.current).toBe(div);
+ });
+
+ it('supports ForwardRef for the form control in React', () => {
+ const textareaRef = createRef();
+
+ const { container } = render();
+
+ const div = container.querySelector('textarea');
+
+ expect(textareaRef.current).toBe(div);
+ });
+});
diff --git a/packages/component-library-react/src/FormFieldTextarea.tsx b/packages/component-library-react/src/FormFieldTextarea.tsx
new file mode 100644
index 00000000000..6cf50c73131
--- /dev/null
+++ b/packages/component-library-react/src/FormFieldTextarea.tsx
@@ -0,0 +1,137 @@
+import clsx from 'clsx';
+import { ForwardedRef, forwardRef, ReactNode, Ref, useId } from 'react';
+import type { FormFieldProps } from './FormField';
+import { FormField } from './FormField';
+import { FormFieldDescription } from './FormFieldDescription';
+import { FormFieldErrorMessage } from './FormFieldErrorMessage';
+import { FormLabel } from './FormLabel';
+import { Textarea } from './Textarea';
+import type { TextareaProps } from './Textarea';
+
+export interface FormFieldTextareaProps
+ extends Omit,
+ Pick<
+ TextareaProps,
+ | 'autoComplete'
+ | 'cols'
+ | 'defaultValue'
+ | 'disabled'
+ | 'invalid'
+ | 'maxLength'
+ | 'minLength'
+ | 'name'
+ | 'onBlur'
+ | 'onChange'
+ | 'onFocus'
+ | 'onInput'
+ | 'placeholder'
+ | 'readOnly'
+ | 'required'
+ | 'rows'
+ | 'value'
+ > {
+ description?: ReactNode;
+ errorMessage?: ReactNode;
+ inputDir?: TextareaProps['dir'];
+ inputRequired?: TextareaProps['required'];
+ label: ReactNode;
+ status?: ReactNode;
+ inputRef?: Ref;
+}
+
+export const FormFieldTextarea = forwardRef(
+ (
+ {
+ autoComplete,
+ children,
+ cols,
+ defaultValue,
+ description,
+ disabled,
+ errorMessage,
+ inputDir,
+ inputRequired,
+ invalid,
+ label,
+ maxLength,
+ minLength,
+ name,
+ onBlur,
+ onChange,
+ onFocus,
+ onInput,
+ placeholder,
+ readOnly,
+ required,
+ rows,
+ status,
+ inputRef,
+ value,
+ ...props
+ }: FormFieldTextareaProps,
+ ref: ForwardedRef,
+ ) => {
+ const inputId = useId();
+ const descriptionId = useId();
+ const statusId = useId();
+ const errorMessageId = useId();
+
+ return (
+
+
+ {label}
+
+ {description && (
+
+ {description}
+
+ )}
+ {invalid && errorMessage && (
+
+ {errorMessage}
+
+ )}
+
+
+
+ {status && (
+
+ {status}
+
+ )}
+ {children}
+
+ );
+ },
+);
+
+FormFieldTextarea.displayName = 'FormFieldTextarea';
diff --git a/packages/component-library-react/src/css-module/FormFieldTextarea.tsx b/packages/component-library-react/src/css-module/FormFieldTextarea.tsx
new file mode 100644
index 00000000000..083e503b454
--- /dev/null
+++ b/packages/component-library-react/src/css-module/FormFieldTextarea.tsx
@@ -0,0 +1,12 @@
+/**
+ * @license EUPL-1.2
+ * Copyright (c) 2021 Robbert Broersma
+ */
+
+// Inject CSS from components used in `FormFieldTextarea`
+import './Textarea';
+import './FormField';
+import './FormFieldDescription';
+import './FormFieldErrorMessage';
+
+export * from '../FormFieldTextarea';
diff --git a/packages/component-library-react/src/css-module/index.ts b/packages/component-library-react/src/css-module/index.ts
index 19702c95265..434c053ad94 100644
--- a/packages/component-library-react/src/css-module/index.ts
+++ b/packages/component-library-react/src/css-module/index.ts
@@ -70,6 +70,8 @@ export type { FormFieldDescriptionProps } from '../FormFieldDescription';
export { FormFieldDescription } from './FormFieldDescription';
export type { FormFieldErrorMessageProps } from '../FormFieldErrorMessage';
export { FormFieldErrorMessage } from './FormFieldErrorMessage';
+export type { FormFieldTextareaProps } from '../FormFieldTextarea';
+export { FormFieldTextarea } from './FormFieldTextarea';
export type { FormFieldTextboxProps } from '../FormFieldTextbox';
export { FormFieldTextbox } from './FormFieldTextbox';
export type { FormLabelProps } from '../FormLabel';
diff --git a/packages/component-library-react/src/index.ts b/packages/component-library-react/src/index.ts
index 291c2e98fd3..63ec15dd97f 100644
--- a/packages/component-library-react/src/index.ts
+++ b/packages/component-library-react/src/index.ts
@@ -77,6 +77,8 @@ export type { FormFieldDescriptionProps } from './FormFieldDescription';
export { FormFieldDescription } from './FormFieldDescription';
export type { FormFieldErrorMessageProps } from './FormFieldErrorMessage';
export { FormFieldErrorMessage } from './FormFieldErrorMessage';
+export type { FormFieldTextareaProps } from './FormFieldTextarea';
+export { FormFieldTextarea } from './FormFieldTextarea';
export type { FormFieldTextboxProps } from './FormFieldTextbox';
export { FormFieldTextbox } from './FormFieldTextbox';
export type { FormLabelProps } from './FormLabel';
diff --git a/packages/storybook-react/src/stories/FormFieldTextarea.stories.tsx b/packages/storybook-react/src/stories/FormFieldTextarea.stories.tsx
new file mode 100644
index 00000000000..31218423494
--- /dev/null
+++ b/packages/storybook-react/src/stories/FormFieldTextarea.stories.tsx
@@ -0,0 +1,447 @@
+import { Meta, StoryObj } from '@storybook/react';
+import { FormFieldTextarea } from '@utrecht/component-library-react/dist/css-module';
+import React from 'react';
+import FormFieldMeta from './FormField.stories';
+
+const storyArgTypes = {
+ ...FormFieldMeta.argTypes,
+ required: {
+ description: 'Required',
+ control: 'boolean',
+ table: {
+ category: 'API',
+ defaultValue: { summary: false },
+ },
+ },
+ inputRequired: {
+ description: 'Required (HTML validation)',
+ control: 'boolean',
+ table: {
+ category: 'API',
+ defaultValue: { summary: false },
+ },
+ },
+ disabled: {
+ description: 'Disabled',
+ control: 'boolean',
+ table: {
+ category: 'API',
+ defaultValue: { summary: false },
+ },
+ },
+ readOnly: {
+ description: 'Read-only',
+ control: 'boolean',
+ table: {
+ category: 'API',
+ defaultValue: { summary: false },
+ },
+ },
+ invalid: {
+ description: 'Invalid',
+ control: 'boolean',
+ table: {
+ category: 'API',
+ defaultValue: { summary: false },
+ },
+ },
+ name: {
+ description: 'Name',
+ control: 'text',
+ table: {
+ category: 'API',
+ defaultValue: { summary: '' },
+ },
+ },
+ defaultValue: {
+ description: 'Value',
+ control: 'text',
+ table: {
+ category: 'API',
+ defaultValue: { summary: '' },
+ },
+ },
+ value: {
+ description: 'Value',
+ control: 'text',
+ table: {
+ category: 'API',
+ defaultValue: { summary: '' },
+ },
+ },
+ placeholder: {
+ description: 'Placeholder',
+ control: 'text',
+ table: {
+ category: 'API',
+ defaultValue: { summary: '' },
+ },
+ },
+ label: {
+ name: 'label',
+ type: { name: 'text', required: true },
+ table: {
+ defaultValue: { summary: '' },
+ category: 'API',
+ },
+ },
+ errorMessage: {
+ name: 'errorMessage',
+ description: 'Description for invalid input',
+ type: { name: 'text', required: false },
+ table: {
+ defaultValue: { summary: '' },
+ category: 'API',
+ },
+ },
+ description: {
+ description: 'Description',
+ type: { name: 'text', required: false },
+ table: {
+ category: 'API',
+ defaultValue: { summary: '' },
+ },
+ },
+ autoComplete: {
+ description: 'Autocomplete',
+ control: 'select',
+ options: [
+ '',
+ 'additional-name',
+ 'address-level1',
+ 'address-level2',
+ 'address-level3',
+ 'address-level4',
+ 'address-line1',
+ 'address-line2',
+ 'address-line3',
+ 'bday',
+ 'bday-day',
+ 'bday-month',
+ 'bday-year',
+ 'cc-additional-name',
+ 'cc-csc',
+ 'cc-exp',
+ 'cc-exp-month',
+ 'cc-exp-year',
+ 'cc-family-name',
+ 'cc-given-name',
+ 'cc-name',
+ 'cc-number',
+ 'cc-type',
+ 'country',
+ 'country-name',
+ 'current-password',
+ 'email',
+ 'family-name',
+ 'fax',
+ 'given-name',
+ 'home',
+ 'honorific-prefix',
+ 'honorific-suffix',
+ 'impp',
+ 'language',
+ 'mobile',
+ 'name',
+ 'new-password',
+ 'nickname',
+ 'one-time-code',
+ 'organization',
+ 'organization-title',
+ 'pager',
+ 'photo',
+ 'postal-code',
+ 'sex',
+ 'street-address',
+ 'tel',
+ 'tel-area-code',
+ 'tel-country-code',
+ 'tel-extension',
+ 'tel-local',
+ 'tel-local-prefix',
+ 'tel-local-suffix',
+ 'tel-national',
+ 'transaction-amount',
+ 'transaction-currency',
+ 'url',
+ 'username',
+ 'work',
+ ],
+ table: {
+ category: 'API',
+ defaultValue: { summary: '' },
+ },
+ },
+ minLength: {
+ description: 'Set the minimum length of the input text',
+ control: 'number',
+ table: {
+ category: 'API',
+ defaultValue: { summary: undefined },
+ },
+ },
+ maxLength: {
+ description: 'Set the maximum length of the input text',
+ control: 'number',
+ table: {
+ category: 'API',
+ defaultValue: { summary: undefined },
+ },
+ },
+ min: {
+ control: 'number',
+ table: {
+ category: 'API',
+ defaultValue: { summary: undefined },
+ },
+ },
+ max: {
+ control: 'number',
+ table: {
+ category: 'API',
+ defaultValue: { summary: undefined },
+ },
+ },
+ step: {
+ control: 'number',
+ table: {
+ category: 'API',
+ defaultValue: { summary: undefined },
+ },
+ },
+ cols: {
+ control: 'number',
+ table: {
+ category: 'API',
+ defaultValue: { summary: undefined },
+ },
+ },
+ rows: {
+ control: 'number',
+ table: {
+ category: 'API',
+ defaultValue: { summary: undefined },
+ },
+ },
+ type: {
+ description: 'Type',
+ control: 'select',
+ options: {
+ '': null,
+ email: 'email',
+ number: 'number',
+ password: 'password',
+ search: 'search',
+ tel: 'tel',
+ text: 'text',
+ url: 'url',
+ },
+ table: {
+ category: 'API',
+ defaultValue: { summary: '' },
+ },
+ },
+ dir: {
+ description: 'Text direction',
+ control: { type: 'select' },
+ options: {
+ '': undefined,
+ auto: 'auto',
+ ltr: 'ltr',
+ rtl: 'rtl',
+ },
+ table: {
+ category: 'DOM',
+ defaultValue: { summary: '' },
+ },
+ },
+ inputDir: {
+ description: 'Text direction',
+ control: { type: 'select' },
+ options: {
+ '': undefined,
+ auto: 'auto',
+ ltr: 'ltr',
+ rtl: 'rtl',
+ },
+ table: {
+ category: 'API',
+ defaultValue: { summary: '' },
+ },
+ },
+};
+
+const meta = {
+ title: 'React Component/Form Field with Textarea',
+ id: 'react-form-field-textarea',
+ component: FormFieldTextarea,
+ argTypes: storyArgTypes,
+ args: {
+ description: '',
+ disabled: false,
+ invalid: false,
+ errorMessage: '',
+ label: '',
+ name: '',
+ defaultValue: '',
+ value: '',
+ required: false,
+ inputRequired: false,
+ type: undefined,
+ autoComplete: '',
+ readOnly: false,
+ dir: undefined,
+ inputDir: undefined,
+ placeholder: '',
+ rows: undefined,
+ cols: undefined,
+ },
+ render: (args) => {
+ const {
+ description,
+ disabled,
+ id,
+ invalid,
+ errorMessage,
+ status,
+ inputRequired,
+ label,
+ name,
+ required,
+ defaultValue,
+ type,
+ autoComplete,
+ minLength,
+ maxLength,
+ readOnly,
+ dir,
+ inputDir,
+ placeholder,
+ value,
+ size,
+ } = args;
+ return (
+
+ );
+ },
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Default: Story = {
+ args: {
+ name: 'subject',
+ label: 'Onderwerp',
+ },
+};
+
+export const Description: Story = {
+ args: {
+ name: 'subject',
+ label: 'Onderwerp',
+ description: 'Kort maar krachtig.',
+ },
+};
+
+export const ErrorMessage: Story = {
+ args: {
+ name: 'subject',
+ label: 'Onderwerp',
+ errorMessage: 'Vul een onderwerp in.',
+ invalid: true,
+ },
+};
+
+export const Status: Story = {
+ args: {
+ name: 'subject',
+ label: 'Onderwerp',
+ defaultValue: 'Hello, World!',
+ status: '13 van de 50 tekens gebruikt.',
+ },
+};
+
+export const Password: Story = {
+ args: {
+ name: 'subject',
+ label: 'Wachtwoord',
+ type: 'password',
+ autoComplete: 'current-password',
+ },
+};
+
+export const Disabled: Story = {
+ args: {
+ name: 'subject',
+ label: 'Onderwerp',
+ disabled: true,
+ defaultValue: 'Hello, world!',
+ },
+};
+
+export const ReadOnly: Story = {
+ args: {
+ name: 'subject',
+ label: 'Onderwerp',
+ readOnly: true,
+ defaultValue: 'Hello, world!',
+ },
+};
+
+export const Required: Story = {
+ args: {
+ name: 'subject',
+ label: 'Onderwerp',
+ required: true,
+ },
+};
+
+export const InputRequired: Story = {
+ args: {
+ name: 'subject',
+ label: 'Onderwerp',
+ inputRequired: true,
+ },
+};
+
+export const Placeholder: Story = {
+ args: {
+ name: 'subject',
+ label: 'Onderwerp',
+ placeholder: 'Type some text...',
+ },
+};
+
+export const LeftToRightInput: Story = {
+ args: {
+ dir: 'rtl',
+ label: 'رقم الجوال',
+ type: 'tel',
+ autoComplete: 'mobile tel-national',
+ inputDir: 'ltr',
+ },
+ name: 'Left-to-right input',
+};