diff --git a/src/tedi/components/form/textarea/textarea.module.scss b/src/tedi/components/form/textarea/textarea.module.scss
index 4392c9a29..399cc1ff1 100644
--- a/src/tedi/components/form/textarea/textarea.module.scss
+++ b/src/tedi/components/form/textarea/textarea.module.scss
@@ -1,14 +1,19 @@
.tedi-textarea {
&__input {
+ box-sizing: border-box;
display: block;
- height: auto;
- min-height: 7.5rem;
- font-family: inherit;
- font-weight: 400;
+ width: 100%;
+ line-height: 1.5;
resize: vertical;
+
+ &--auto-grow {
+ overflow-y: auto;
+ resize: none;
+ }
}
&__character-count {
flex-grow: 1;
+ text-align: right;
}
}
diff --git a/src/tedi/components/form/textarea/textarea.spec.tsx b/src/tedi/components/form/textarea/textarea.spec.tsx
index 4c5e26bd4..1a5bf35f5 100644
--- a/src/tedi/components/form/textarea/textarea.spec.tsx
+++ b/src/tedi/components/form/textarea/textarea.spec.tsx
@@ -2,6 +2,7 @@ import { act, fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useBreakpointProps } from '../../../helpers';
+import { UnknownType } from '../../../types/commonTypes';
import TextArea, { TextAreaProps } from './textarea';
import '@testing-library/jest-dom';
@@ -40,32 +41,24 @@ describe('TextArea component', () => {
expect(textarea).toHaveClass('tedi-textarea__input');
});
- it('displays error when char count is over character limit', () => {
+ it('displays error when char count exceeds characterLimit', () => {
const charLimit = 10;
const newText = 'This text is too long';
render();
const textarea = screen.getByPlaceholderText(/enter text/i);
fireEvent.change(textarea, { target: { value: newText } });
+
const error = screen.getByText(`${newText.length}/${charLimit}`);
expect(error).toHaveClass('tedi-feedback-text--error');
});
- it('disables the textarea when disabled prop is true', () => {
- render();
+ it('displays character counter as hint when under limit', () => {
+ render();
const textarea = screen.getByPlaceholderText(/enter text/i);
- expect(textarea).toBeDisabled();
- });
-
- it('renders helper text when provided', () => {
- render();
- const helper = screen.getByText(/helper text/i);
- expect(helper).toBeInTheDocument();
- });
+ fireEvent.change(textarea, { target: { value: 'Short text' } });
- it('displays validation error if invalid prop is true', () => {
- render();
- const error = screen.getByText(/error message/i);
- expect(error).toHaveClass('tedi-feedback-text--error');
+ const counter = screen.getByText(/10\/15/i);
+ expect(counter).toHaveClass('tedi-feedback-text--hint');
});
it('displays a character counter when characterLimit is set', () => {
@@ -84,6 +77,138 @@ describe('TextArea component', () => {
expect(counter).not.toBeInTheDocument();
});
+ it('applies minRows when autoGrow is enabled', () => {
+ render();
+ const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
+ expect(textarea).toHaveAttribute('rows', '5');
+ });
+
+ it('grows height when typing with autoGrow=true', async () => {
+ const user = userEvent.setup();
+
+ const originalGetComputedStyle = window.getComputedStyle;
+ window.getComputedStyle = jest.fn().mockImplementation((el) => ({
+ ...originalGetComputedStyle(el),
+ lineHeight: '20px',
+ paddingTop: '8px',
+ paddingBottom: '8px',
+ }));
+
+ const originalScrollHeightDescriptor = Object.getOwnPropertyDescriptor(
+ HTMLTextAreaElement.prototype,
+ 'scrollHeight'
+ );
+ Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollHeight', {
+ configurable: true,
+ get() {
+ return 100 + (this.value.split('\n').length - 1) * 40;
+ },
+ });
+
+ render();
+ const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
+ expect(textarea).toHaveAttribute('rows', '3');
+
+ await act(async () => {
+ await user.type(textarea, 'Line 1\nLine 2\nLine 3\nLine 4\nLine 5');
+ });
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ });
+
+ expect(parseFloat(textarea.style.height || '0')).toBeGreaterThan(120);
+ window.getComputedStyle = originalGetComputedStyle;
+
+ if (originalScrollHeightDescriptor) {
+ Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollHeight', originalScrollHeightDescriptor);
+ } else {
+ delete (HTMLTextAreaElement.prototype as UnknownType).scrollHeight;
+ }
+ });
+
+ it('respects maxRows and enables scroll when exceeded with autoGrow', async () => {
+ const user = userEvent.setup();
+
+ const originalGetComputedStyle = window.getComputedStyle;
+ window.getComputedStyle = jest.fn().mockImplementation((el) => ({
+ ...originalGetComputedStyle(el),
+ lineHeight: '20px',
+ paddingTop: '8px',
+ paddingBottom: '8px',
+ }));
+
+ Object.defineProperty(HTMLTextAreaElement.prototype, 'scrollHeight', {
+ configurable: true,
+ get() {
+ return 400;
+ },
+ });
+
+ render();
+
+ const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
+
+ await act(async () => {
+ await user.type(textarea, 'Line1\nLine2\nLine3\nLine4\nLine5\nLine6\nLine7\nLine8');
+ });
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ });
+
+ expect(textarea.style.overflowY).toBe('auto');
+ const height = parseFloat(textarea.style.height || '0');
+ expect(height).toBeGreaterThan(100);
+ expect(height).toBeLessThan(200);
+
+ window.getComputedStyle = originalGetComputedStyle;
+ });
+
+ it('applies maxHeight when autoGrow=true', () => {
+ render();
+ const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
+ expect(textarea.style.maxHeight).toBe('200px');
+ });
+
+ // === Fixed height (non-autoGrow) Tests ===
+ it('applies fixed height when autoGrow=false (default)', () => {
+ render();
+ const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
+ expect(textarea.style.height).toBe('200px');
+ });
+
+ it('uses default height 7.5rem when not specified and autoGrow=false', () => {
+ render();
+ const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
+ expect(textarea.style.height).toBe('7.5rem');
+ });
+
+ it('applies maxHeight when autoGrow=false', () => {
+ render();
+ const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
+ expect(textarea.style.height).toBe('150px');
+ expect(textarea.style.maxHeight).toBe('300px');
+ });
+
+ it('disables the textarea when disabled prop is true', () => {
+ render();
+ const textarea = screen.getByPlaceholderText(/enter text/i);
+ expect(textarea).toBeDisabled();
+ });
+
+ it('renders helper text when provided', () => {
+ render();
+ const helper = screen.getByText(/helper text/i);
+ expect(helper).toBeInTheDocument();
+ });
+
+ it('displays validation error if invalid prop is true', () => {
+ render();
+ const error = screen.getByText(/error message/i);
+ expect(error).toHaveClass('tedi-feedback-text--error');
+ });
+
it('applies defaultValue correctly when component is uncontrolled', async () => {
const user = userEvent.setup();
const defaultValue = 'Initial text';
@@ -114,11 +239,6 @@ describe('TextArea component', () => {
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith(newValue);
expect(textarea).toHaveValue(initialValue);
-
- render();
- const allAreas = screen.getAllByRole('textbox');
- const newTextarea = allAreas[allAreas.length - 1];
- expect(newTextarea).toHaveValue(newValue);
});
it('handles onChangeEvent correctly with controlled value', () => {
diff --git a/src/tedi/components/form/textarea/textarea.stories.tsx b/src/tedi/components/form/textarea/textarea.stories.tsx
index 007a83431..018d13609 100644
--- a/src/tedi/components/form/textarea/textarea.stories.tsx
+++ b/src/tedi/components/form/textarea/textarea.stories.tsx
@@ -52,7 +52,7 @@ const TemplateColumnWithStates: StoryFn = (args) => {
))}
-
+
Success
@@ -67,7 +67,7 @@ const TemplateColumnWithStates: StoryFn = (args) => {
/>
-
+
Error
@@ -190,3 +190,101 @@ export const DefaultValue: Story = {
defaultValue: 'Text value',
},
};
+
+const TemplateHeights: StoryFn = (args) => {
+ return (
+
+
+
+ Fixed Height (7.5rem default)
+
+
+
+
+
+
+
+
+ Custom Fixed Height
+
+
+
+
+
+
+
+
+ Auto Grow (minRows: 3, maxRows: 12)
+
+
+
+
+
+
+
+
+ Auto Grow with Custom Rows
+
+
+
+
+
+
+
+
+ Auto Grow with Max Height
+
+
+
+
+
+
+ );
+};
+
+export const HeightExamples: Story = {
+ render: TemplateHeights,
+ parameters: {
+ docs: {
+ description: {
+ story: 'Examples showing different height configurations for TextArea',
+ },
+ },
+ },
+};
diff --git a/src/tedi/components/form/textarea/textarea.tsx b/src/tedi/components/form/textarea/textarea.tsx
index 89461b78e..53ef598d4 100644
--- a/src/tedi/components/form/textarea/textarea.tsx
+++ b/src/tedi/components/form/textarea/textarea.tsx
@@ -1,5 +1,5 @@
import cn from 'classnames';
-import React, { forwardRef } from 'react';
+import React, { forwardRef, useEffect, useRef } from 'react';
import { FeedbackTextProps } from '../feedback-text/feedback-text';
import { TextField, TextFieldForwardRef, TextFieldProps } from '../textfield/textfield';
@@ -10,6 +10,35 @@ export interface TextAreaProps extends TextFieldProps {
* Maximum number of characters allowed in the textarea.
*/
characterLimit?: number;
+ /**
+ * Enable automatic height adjustment based on content.
+ * @default false
+ */
+ autoGrow?: boolean;
+ /**
+ * Minimum number of rows (only used when autoGrow = true).
+ * @default 3
+ */
+ minRows?: number;
+ /**
+ * Maximum number of rows before scrolling (only used when autoGrow = true).
+ * @default 12
+ */
+ maxRows?: number;
+ /**
+ * Fixed height for the textarea (e.g. '200px', '12rem', 240).
+ * Ignored when autoGrow = true.
+ *
+ * @default 7.5rem
+ */
+ height?: string | number;
+ /**
+ * Maximum height of the textarea.
+ *
+ * - When `autoGrow` is enabled, this limits how tall the textarea can grow.
+ * - When `autoGrow` is disabled, this limits the fixed height.
+ */
+ maxHeight?: string | number;
}
export const TextArea = forwardRef((props, ref): JSX.Element => {
@@ -21,22 +50,123 @@ export const TextArea = forwardRef((props, r
onChangeEvent,
value: externalValue,
defaultValue,
+ autoGrow = false,
+ minRows = 3,
+ maxRows = 12,
+ height = '7.5rem',
+ maxHeight,
...rest
} = props;
+
const [innerValue, setInnerValue] = React.useState(defaultValue ?? '');
+ const textareaRef = useRef(null);
+ const [textareaHeight, setTextareaHeight] = React.useState('auto');
const handleInputChange = React.useCallback(
(inputValue: string) => {
if (!externalValue && !(onChange || onChangeEvent)) {
setInnerValue(inputValue);
}
-
onChange?.(inputValue);
},
[externalValue, onChange, onChangeEvent]
);
const value = React.useMemo(() => externalValue ?? innerValue, [externalValue, innerValue]);
+
+ const calculateHeight = React.useCallback(() => {
+ if (!autoGrow || !textareaRef.current) return;
+
+ const textarea = textareaRef.current;
+
+ const originalOverflow = textarea.style.overflow;
+
+ textarea.style.overflow = 'hidden';
+
+ const computedStyle = window.getComputedStyle(textarea);
+
+ let lineHeight = parseFloat(computedStyle.lineHeight);
+
+ if (isNaN(lineHeight)) {
+ // Fallback: estimate from font-size (common ratio ~1.5)
+ const fontSize = parseFloat(computedStyle.fontSize) || 16;
+ lineHeight = fontSize * 1.5;
+ }
+
+ const paddingTop = parseFloat(computedStyle.paddingTop);
+ const paddingBottom = parseFloat(computedStyle.paddingBottom);
+
+ const scrollHeight = textarea.scrollHeight;
+ const contentHeight = scrollHeight - paddingTop - paddingBottom;
+
+ let rowCount = Math.ceil(contentHeight / lineHeight);
+
+ rowCount = Math.min(Math.max(rowCount, minRows), maxRows);
+
+ const nextHeight = `${rowCount * lineHeight + paddingTop + paddingBottom}px`;
+ textarea.style.height = nextHeight;
+ setTextareaHeight(nextHeight);
+
+ textarea.style.overflow = originalOverflow;
+ textarea.style.overflowY = textarea.scrollHeight > textarea.clientHeight ? 'auto' : 'hidden';
+ }, [autoGrow, minRows, maxRows]);
+
+ useEffect(() => {
+ if (autoGrow) {
+ requestAnimationFrame(() => {
+ calculateHeight();
+ });
+ }
+ }, [value, autoGrow, calculateHeight]);
+
+ useEffect(() => {
+ if (autoGrow && textareaRef.current) {
+ calculateHeight();
+ }
+ }, [autoGrow, calculateHeight]);
+
+ const handleRef = React.useCallback(
+ (node: TextFieldForwardRef | null) => {
+ if (ref) {
+ if (typeof ref === 'function') {
+ ref(node);
+ } else {
+ ref.current = node;
+ }
+ }
+
+ if (node?.input && node.input instanceof HTMLTextAreaElement) {
+ textareaRef.current = node.input;
+
+ if (autoGrow) {
+ setTimeout(calculateHeight, 0);
+ }
+ }
+ },
+ [ref, autoGrow, calculateHeight]
+ );
+
+ const customInputProps = React.useMemo(() => {
+ if (autoGrow) {
+ return {
+ rows: minRows,
+ style: {
+ ...(maxHeight ? { maxHeight } : {}),
+ overflow: 'hidden',
+ height: textareaHeight,
+ },
+ };
+ } else {
+ return {
+ style: {
+ height: height,
+ ...(maxHeight ? { maxHeight } : {}),
+ overflow: 'auto',
+ },
+ };
+ }
+ }, [autoGrow, minRows, maxHeight, height, textareaHeight]);
+
const charCount = value.length;
const charCountHelper = characterLimit ? `${charCount}/${characterLimit}` : '';
const combinedHelpers = [
@@ -56,15 +186,18 @@ export const TextArea = forwardRef((props, r
return (
);
});