Skip to content

Commit 7bd7eb8

Browse files
feat(TextArea): add auto-grow functionality with minRows/maxRows (#22)
* feat(TextArea): add auto-grow functionality with minRows/maxRows Add auto-grow behavior to TextArea component that automatically adjusts height based on content. Users can set minRows for starting height and maxRows to limit growth. When maxRows is exceeded, the textarea becomes scrollable. - Add minRows and maxRows props to enable auto-grow behavior - Implement height adjustment logic using useLayoutEffect and refs - Default resize to 'none' when auto-grow is enabled - Add mergeRefs utility to handle forwarded and internal refs - Update component to use forwardRef for better ref handling - Add comprehensive auto-grow stories and examples - Update documentation with auto-grow usage and best practices * fix(TextArea): neutralize CSS min-height to prevent scrollHeight inflation Reset textarea min-height to 0 before measuring scrollHeight during auto-grow calculation, then restore it afterward. This prevents CSS min-height from inflating the measured scrollHeight, which caused incorrect sizing when minRows equals maxRows. Add story example for minRows=1, maxRows=1 edge case.
1 parent 400f2e8 commit 7bd7eb8

File tree

4 files changed

+306
-14
lines changed

4 files changed

+306
-14
lines changed

packages/baukasten/src/components/TextArea/TextArea.stories.tsx

Lines changed: 92 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,18 +54,26 @@ const meta = {
5454
resize: {
5555
control: 'select',
5656
options: ['none', 'vertical', 'horizontal', 'both'],
57-
description: 'Resize behavior for the textarea',
57+
description: 'Resize behavior for the textarea. When minRows/maxRows is set, defaults to "none"',
5858
table: {
5959
defaultValue: { summary: 'vertical' },
6060
},
6161
},
6262
rows: {
6363
control: 'number',
64-
description: 'Number of visible text rows',
64+
description: 'Number of visible text rows (static height, ignored when minRows/maxRows is set)',
6565
table: {
6666
defaultValue: { summary: '4' },
6767
},
6868
},
69+
minRows: {
70+
control: 'number',
71+
description: 'Minimum number of rows for auto-grow behavior. Setting this enables auto-grow.',
72+
},
73+
maxRows: {
74+
control: 'number',
75+
description: 'Maximum number of rows for auto-grow behavior. Setting this enables auto-grow.',
76+
},
6977
},
7078
} satisfies Meta<typeof TextArea>;
7179

@@ -192,6 +200,76 @@ export const RowOptions: Story = {
192200
},
193201
};
194202

203+
/**
204+
* Auto-grow textareas that expand as content is added.
205+
*/
206+
export const AutoGrow: Story = {
207+
render: () => (
208+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--bk-spacing-3)', width: '400px' }}>
209+
<div>
210+
<h4 style={{ marginBottom: 'var(--bk-spacing-2)', fontSize: 'var(--bk-font-size-sm)', fontWeight: 'var(--bk-font-weight-medium)' }}>
211+
minRows={2}, maxRows={6}
212+
</h4>
213+
<TextArea
214+
minRows={2}
215+
maxRows={6}
216+
placeholder="Type to see auto-grow. Starts at 2 rows, grows up to 6 rows, then scrolls."
217+
fullWidth
218+
/>
219+
</div>
220+
<div>
221+
<h4 style={{ marginBottom: 'var(--bk-spacing-2)', fontSize: 'var(--bk-font-size-sm)', fontWeight: 'var(--bk-font-weight-medium)' }}>
222+
minRows={1}, maxRows={4} (Compact)
223+
</h4>
224+
<TextArea
225+
minRows={1}
226+
maxRows={4}
227+
placeholder="Single line that grows to 4 rows max"
228+
fullWidth
229+
/>
230+
</div>
231+
<div>
232+
<h4 style={{ marginBottom: 'var(--bk-spacing-2)', fontSize: 'var(--bk-font-size-sm)', fontWeight: 'var(--bk-font-weight-medium)' }}>
233+
minRows={3}, no maxRows (Unlimited)
234+
</h4>
235+
<TextArea
236+
minRows={3}
237+
placeholder="Starts at 3 rows, grows indefinitely with content"
238+
fullWidth
239+
/>
240+
</div>
241+
<div>
242+
<h4 style={{ marginBottom: 'var(--bk-spacing-2)', fontSize: 'var(--bk-font-size-sm)', fontWeight: 'var(--bk-font-weight-medium)' }}>
243+
maxRows={5} only (starts at default 4 rows)
244+
</h4>
245+
<TextArea
246+
maxRows={5}
247+
placeholder="Uses default 4 rows as min, max at 5 rows"
248+
fullWidth
249+
/>
250+
</div>
251+
<div>
252+
<h4 style={{ marginBottom: 'var(--bk-spacing-2)', fontSize: 'var(--bk-font-size-sm)', fontWeight: 'var(--bk-font-weight-medium)' }}>
253+
minRows={1}, maxRows={1}
254+
</h4>
255+
<TextArea
256+
minRows={1}
257+
maxRows={1}
258+
placeholder="This one is a little awkward!"
259+
fullWidth
260+
/>
261+
</div>
262+
</div>
263+
),
264+
parameters: {
265+
docs: {
266+
description: {
267+
story: 'Auto-grow textareas automatically expand as content is added. Use `minRows` to set the starting height and `maxRows` to limit growth. When maxRows is exceeded, the textarea becomes scrollable. Setting either `minRows` or `maxRows` enables auto-grow behavior.',
268+
},
269+
},
270+
},
271+
};
272+
195273
/**
196274
* TextArea states: default, error, and disabled.
197275
*/
@@ -402,6 +480,18 @@ export const Showcase: Story = {
402480
</div>
403481
</div>
404482

483+
{/* Auto-Grow */}
484+
<div>
485+
<h3 style={{ marginBottom: 'var(--bk-spacing-3)', fontSize: 'var(--bk-font-size-base)', fontWeight: 'var(--bk-font-weight-semibold)' }}>
486+
Auto-Grow
487+
</h3>
488+
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--bk-spacing-2)' }}>
489+
<TextArea minRows={2} maxRows={6} placeholder="Grows from 2 to 6 rows" fullWidth />
490+
<TextArea minRows={1} maxRows={4} placeholder="Compact: 1 to 4 rows" fullWidth />
491+
<TextArea minRows={3} placeholder="Unlimited growth from 3 rows" fullWidth />
492+
</div>
493+
</div>
494+
405495
{/* Combinations */}
406496
<div>
407497
<h3 style={{ marginBottom: 'var(--bk-spacing-3)', fontSize: 'var(--bk-font-size-base)', fontWeight: 'var(--bk-font-weight-semibold)' }}>

packages/baukasten/src/components/TextArea/TextArea.tsx

Lines changed: 123 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React from 'react';
1+
import React, { useRef, useCallback, useLayoutEffect, forwardRef } from 'react';
22
import clsx from 'clsx';
33
import { type Size } from '../../styles';
44
import { textAreaWrapper, styledTextArea, errorText } from './TextArea.css';
@@ -31,15 +31,59 @@ export interface TextAreaProps extends Omit<React.TextareaHTMLAttributes<HTMLTex
3131

3232
/**
3333
* Resize behavior for the textarea
34+
* When minRows or maxRows is set, resize defaults to 'none' for auto-grow behavior
3435
* @default 'vertical'
3536
*/
3637
resize?: TextAreaResize;
3738

3839
/**
39-
* Number of visible text rows
40+
* Number of visible text rows (static height)
41+
* When minRows/maxRows are not set, this controls the fixed height
42+
* When minRows/maxRows are set, this is ignored in favor of auto-grow
4043
* @default 4
4144
*/
4245
rows?: number;
46+
47+
/**
48+
* Minimum number of rows when auto-grow is enabled
49+
* Setting this enables auto-grow behavior
50+
*/
51+
minRows?: number;
52+
53+
/**
54+
* Maximum number of rows when auto-grow is enabled
55+
* Setting this enables auto-grow behavior
56+
*/
57+
maxRows?: number;
58+
}
59+
60+
/**
61+
* Merge multiple refs into a single callback ref
62+
*/
63+
function mergeRefs<T>(...refs: (React.Ref<T> | undefined)[]): React.RefCallback<T> {
64+
return (value) => {
65+
refs.forEach((ref) => {
66+
if (typeof ref === 'function') {
67+
ref(value);
68+
} else if (ref != null) {
69+
(ref as React.MutableRefObject<T | null>).current = value;
70+
}
71+
});
72+
};
73+
}
74+
75+
/**
76+
* Get computed styles for calculating row height
77+
*/
78+
function getRowHeight(textarea: HTMLTextAreaElement): { lineHeight: number; paddingTop: number; paddingBottom: number; borderTop: number; borderBottom: number } {
79+
const computedStyle = window.getComputedStyle(textarea);
80+
return {
81+
lineHeight: parseFloat(computedStyle.lineHeight) || parseFloat(computedStyle.fontSize) * 1.2,
82+
paddingTop: parseFloat(computedStyle.paddingTop) || 0,
83+
paddingBottom: parseFloat(computedStyle.paddingBottom) || 0,
84+
borderTop: parseFloat(computedStyle.borderTopWidth) || 0,
85+
borderBottom: parseFloat(computedStyle.borderBottomWidth) || 0,
86+
};
4387
}
4488

4589
/**
@@ -77,6 +121,10 @@ export interface TextAreaProps extends Omit<React.TextareaHTMLAttributes<HTMLTex
77121
* <TextArea resize="horizontal" placeholder="Horizontal resize" />
78122
* <TextArea resize="both" placeholder="Resize both directions" />
79123
*
124+
* // Auto-grow with minRows/maxRows
125+
* <TextArea minRows={2} maxRows={6} placeholder="Auto-growing textarea" />
126+
* <TextArea minRows={3} placeholder="Grows from 3 rows, no max limit" />
127+
*
80128
* // With FormGroup
81129
* <FormGroup>
82130
* <FieldLabel htmlFor="description">Description</FieldLabel>
@@ -85,26 +133,93 @@ export interface TextAreaProps extends Omit<React.TextareaHTMLAttributes<HTMLTex
85133
* </FormGroup>
86134
* ```
87135
*/
88-
export const TextArea: React.FC<TextAreaProps> = ({
136+
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(({
89137
size = 'md',
90138
error,
91139
fullWidth = false,
92-
resize = 'vertical',
140+
resize,
93141
rows = 4,
142+
minRows,
143+
maxRows,
94144
className,
145+
onChange,
146+
value,
147+
defaultValue,
95148
...props
96-
}) => {
149+
}, forwardedRef) => {
150+
const internalRef = useRef<HTMLTextAreaElement>(null);
151+
152+
// Determine if auto-grow is enabled
153+
const isAutoGrow = minRows !== undefined || maxRows !== undefined;
154+
155+
// When auto-grow is enabled, default resize to 'none' unless explicitly set
156+
const effectiveResize = resize ?? (isAutoGrow ? 'none' : 'vertical');
157+
158+
// Calculate the effective minRows and maxRows
159+
const effectiveMinRows = minRows ?? rows;
160+
const effectiveMaxRows = maxRows;
161+
162+
// Auto-grow resize function
163+
const adjustHeight = useCallback(() => {
164+
const textarea = internalRef.current;
165+
if (!textarea || !isAutoGrow) return;
166+
167+
const { lineHeight, paddingTop, paddingBottom, borderTop, borderBottom } = getRowHeight(textarea);
168+
const verticalPadding = paddingTop + paddingBottom + borderTop + borderBottom;
169+
170+
// Calculate min and max heights
171+
const minHeight = lineHeight * effectiveMinRows + verticalPadding;
172+
const maxHeight = effectiveMaxRows ? lineHeight * effectiveMaxRows + verticalPadding : Infinity;
173+
174+
// Neutralize any CSS min-height so it doesn't inflate scrollHeight
175+
textarea.style.minHeight = '0';
176+
177+
// Reset height to auto to get the correct scrollHeight
178+
textarea.style.height = 'auto';
179+
180+
// Calculate the new height based on content
181+
const scrollHeight = textarea.scrollHeight;
182+
const newHeight = Math.min(Math.max(scrollHeight, minHeight), maxHeight);
183+
184+
textarea.style.height = `${newHeight}px`;
185+
textarea.style.minHeight = `${minHeight}px`;
186+
187+
// Set overflow based on whether content exceeds maxHeight
188+
if (effectiveMaxRows && scrollHeight > maxHeight) {
189+
textarea.style.overflowY = 'auto';
190+
} else {
191+
textarea.style.overflowY = 'hidden';
192+
}
193+
}, [isAutoGrow, effectiveMinRows, effectiveMaxRows]);
194+
195+
// Adjust height on mount and when value changes
196+
useLayoutEffect(() => {
197+
adjustHeight();
198+
}, [adjustHeight, value, defaultValue]);
199+
200+
// Handle onChange to adjust height
201+
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
202+
onChange?.(e);
203+
adjustHeight();
204+
}, [onChange, adjustHeight]);
205+
97206
// Only show error text if error is a string (not just boolean)
98207
const errorMessage = typeof error === 'string' ? error : undefined;
99208

100209
return (
101210
<div className={textAreaWrapper({ fullWidth })}>
102211
<textarea
103-
className={clsx(styledTextArea({ size, resize, hasError: !!error }), className)}
104-
rows={rows}
212+
ref={mergeRefs(internalRef, forwardedRef)}
213+
className={clsx(styledTextArea({ size, resize: effectiveResize, hasError: !!error }), className)}
214+
rows={isAutoGrow ? effectiveMinRows : rows}
215+
onChange={handleChange}
216+
value={value}
217+
defaultValue={defaultValue}
105218
{...props}
106219
/>
107220
{errorMessage && <span className={errorText}>{errorMessage}</span>}
108221
</div>
109222
);
110-
};
223+
});
224+
225+
TextArea.displayName = 'TextArea';

0 commit comments

Comments
 (0)