Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/tedi/components/form/textarea/textarea.module.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
160 changes: 140 additions & 20 deletions src/tedi/components/form/textarea/textarea.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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(<TextArea {...defaultProps} characterLimit={charLimit} />);
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(<TextArea {...defaultProps} disabled />);
it('displays character counter as hint when under limit', () => {
render(<TextArea {...defaultProps} characterLimit={15} />);
const textarea = screen.getByPlaceholderText(/enter text/i);
expect(textarea).toBeDisabled();
});

it('renders helper text when provided', () => {
render(<TextArea {...defaultProps} helper={{ type: 'hint', text: 'Helper text', id: 'helper-id' }} />);
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(<TextArea {...defaultProps} invalid helper={{ type: 'error', text: 'Error message' }} />);
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', () => {
Expand All @@ -84,6 +77,138 @@ describe('TextArea component', () => {
expect(counter).not.toBeInTheDocument();
});

it('applies minRows when autoGrow is enabled', () => {
render(<TextArea {...defaultProps} autoGrow minRows={5} />);
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(<TextArea {...defaultProps} autoGrow minRows={3} maxRows={10} />);
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;
}
});
Comment thread
airikej marked this conversation as resolved.

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(<TextArea {...defaultProps} autoGrow minRows={3} maxRows={5} />);

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(<TextArea {...defaultProps} autoGrow maxHeight="200px" />);
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(<TextArea {...defaultProps} height="200px" />);
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(<TextArea {...defaultProps} />);
const textarea = screen.getByRole('textbox') as HTMLTextAreaElement;
expect(textarea.style.height).toBe('7.5rem');
});

it('applies maxHeight when autoGrow=false', () => {
render(<TextArea {...defaultProps} height="150px" maxHeight="300px" />);
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(<TextArea {...defaultProps} disabled />);
const textarea = screen.getByPlaceholderText(/enter text/i);
expect(textarea).toBeDisabled();
});

it('renders helper text when provided', () => {
render(<TextArea {...defaultProps} helper={{ type: 'hint', text: 'Helper text', id: 'helper-id' }} />);
const helper = screen.getByText(/helper text/i);
expect(helper).toBeInTheDocument();
});

it('displays validation error if invalid prop is true', () => {
render(<TextArea {...defaultProps} invalid helper={{ type: 'error', text: 'Error message' }} />);
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';
Expand Down Expand Up @@ -114,11 +239,6 @@ describe('TextArea component', () => {
expect(handleChange).toHaveBeenCalledTimes(1);
expect(handleChange).toHaveBeenCalledWith(newValue);
expect(textarea).toHaveValue(initialValue);

render(<TextArea {...defaultProps} value={newValue} onChange={handleChange} placeholder="Enter text" />);
const allAreas = screen.getAllByRole('textbox');
const newTextarea = allAreas[allAreas.length - 1];
expect(newTextarea).toHaveValue(newValue);
});

it('handles onChangeEvent correctly with controlled value', () => {
Expand Down
102 changes: 100 additions & 2 deletions src/tedi/components/form/textarea/textarea.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ const TemplateColumnWithStates: StoryFn<TemplateStateProps> = (args) => {
</Col>
</Row>
))}
<Row className="padding-14-16">
<Row>
<Col width={2} className="display-flex align-items-center">
<Text modifiers="bold">Success</Text>
</Col>
Expand All @@ -67,7 +67,7 @@ const TemplateColumnWithStates: StoryFn<TemplateStateProps> = (args) => {
/>
</Col>
</Row>
<Row className="padding-14-16">
<Row>
<Col width={2} className="display-flex align-items-center">
<Text modifiers="bold">Error</Text>
</Col>
Expand Down Expand Up @@ -190,3 +190,101 @@ export const DefaultValue: Story = {
defaultValue: 'Text value',
},
};

const TemplateHeights: StoryFn<TextAreaProps> = (args) => {
return (
<VerticalSpacing>
<Row>
<Col>
<Text modifiers="bold">Fixed Height (7.5rem default)</Text>
</Col>
<Col>
<TextArea
{...args}
id="fixed-height-default"
label="Label"
placeholder="This textarea has a fixed height of 7.5rem"
/>
</Col>
</Row>

<Row>
<Col>
<Text modifiers="bold">Custom Fixed Height</Text>
</Col>
<Col>
<TextArea
{...args}
id="custom-height"
label="Label"
height="4rem"
placeholder="This textarea has a fixed height of 4rem"
/>
</Col>
</Row>

<Row>
<Col>
<Text modifiers="bold">Auto Grow (minRows: 3, maxRows: 12)</Text>
</Col>
<Col>
<TextArea
{...args}
id="auto-grow"
label="Label"
autoGrow={true}
minRows={3}
maxRows={12}
placeholder="Type multiple lines to see it grow automatically"
/>
</Col>
</Row>

<Row>
<Col>
<Text modifiers="bold">Auto Grow with Custom Rows</Text>
</Col>
<Col>
<TextArea
{...args}
id="auto-grow-custom"
label="Label"
autoGrow={true}
minRows={5}
maxRows={8}
placeholder="This will grow from 5 to 8 rows maximum"
/>
</Col>
</Row>

<Row>
<Col>
<Text modifiers="bold">Auto Grow with Max Height</Text>
</Col>
<Col>
<TextArea
{...args}
id="auto-grow-max-height"
label="Label"
autoGrow={true}
maxHeight="200px"
minRows={3}
maxRows={12}
placeholder="This will grow but max height is limited to 200px"
/>
</Col>
</Row>
</VerticalSpacing>
);
};

export const HeightExamples: Story = {
render: TemplateHeights,
parameters: {
docs: {
description: {
story: 'Examples showing different height configurations for TextArea',
},
},
},
};
Loading
Loading