From 4629664ddaed0827591349bd157c5fb64da0cb22 Mon Sep 17 00:00:00 2001 From: Jaied Al Sabid <87969327+jaieds@users.noreply.github.com> Date: Mon, 13 Apr 2026 12:38:02 +0600 Subject: [PATCH 01/56] feat(textarea): add autoResize, minHeight, and maxHeight props Adds auto-height adjustment to the TextArea component. When `autoResize` is enabled, the textarea grows with its content using scrollHeight and stops at the optional `maxHeight` cap (defaulting to 160px), at which point it becomes scrollable. `minHeight` and `maxHeight` are applied as CSS constraints regardless of `autoResize`. Also extends the props type to include native textarea HTML attributes. --- src/components/textarea/textarea.stories.tsx | 33 ++++++++++++ src/components/textarea/textarea.tsx | 55 ++++++++++++++++++-- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/components/textarea/textarea.stories.tsx b/src/components/textarea/textarea.stories.tsx index 8e55b822..69d6540c 100644 --- a/src/components/textarea/textarea.stories.tsx +++ b/src/components/textarea/textarea.stories.tsx @@ -12,6 +12,15 @@ const meta: Meta = { size: { control: 'select', }, + autoResize: { + control: 'boolean', + }, + minHeight: { + control: 'text', + }, + maxHeight: { + control: 'text', + }, }, }; @@ -60,3 +69,27 @@ Large.args = { error: false, defaultValue: 'Large TextArea', }; + +// Auto-resize TextArea Example +export const AutoResize = Template.bind( {} ); +AutoResize.args = { + size: 'md', + disabled: false, + error: false, + autoResize: true, + minHeight: 80, + maxHeight: 200, + defaultValue: 'This textarea grows as you type. Try adding more lines of text to see the auto-resize in action.', +}; + +// Min/Max Height (no auto-resize) Example +export const WithMinMaxHeight = Template.bind( {} ); +WithMinMaxHeight.args = { + size: 'md', + disabled: false, + error: false, + autoResize: false, + minHeight: 120, + maxHeight: 240, + defaultValue: 'Fixed height range — drag the corner grip to resize between minHeight and maxHeight.', +}; diff --git a/src/components/textarea/textarea.tsx b/src/components/textarea/textarea.tsx index 769e43f6..1f50b9fe 100644 --- a/src/components/textarea/textarea.tsx +++ b/src/components/textarea/textarea.tsx @@ -1,6 +1,17 @@ -import { useState, useCallback, useMemo, forwardRef } from 'react'; +import { + useState, + useCallback, + useMemo, + useRef, + useLayoutEffect, + forwardRef, +} from 'react'; import { nanoid } from 'nanoid'; import { cn } from '@/utilities/functions'; +import { mergeRefs } from '@/components/toaster/utils'; + +const toCssSize = ( v: number | string | undefined ) => + typeof v === 'number' ? `${ v }px` : v; export interface TextAreaProps { /** ID of the textarea element. */ @@ -21,6 +32,12 @@ export interface TextAreaProps { error?: boolean; /** Callback triggered when the field is invalid. */ onError?: () => void; + /** When true, the textarea height auto-adjusts to fit its content. */ + autoResize?: boolean; + /** Minimum height of the textarea. Accepts a number (px) or any CSS length string (e.g. '10rem'). Applied regardless of autoResize. */ + minHeight?: number | string; + /** Maximum height of the textarea. When auto-resize reaches this value, the textarea becomes scrollable. Accepts a number (px) or any CSS length string. Applied regardless of autoResize. Defaults to 160px. */ + maxHeight?: number | string; } export const TextAreaComponent = ( @@ -34,10 +51,19 @@ export const TextAreaComponent = ( onChange = () => {}, error = false, onError = () => {}, + autoResize = false, + minHeight, + maxHeight = 160, + style: callerStyle, ...props - }: TextAreaProps, + }: TextAreaProps & + Omit< + React.TextareaHTMLAttributes, + 'size' | 'onChange' + >, ref: React.ForwardedRef ) => { + const internalRef = useRef( null ); const inputId = useMemo( () => id || `input-textarea-${ nanoid() }`, [ id ] ); const isControlled = useMemo( () => typeof value !== 'undefined', [ value ] ); const [ inputValue, setInputValue ] = useState( defaultValue ); @@ -47,6 +73,18 @@ export const TextAreaComponent = ( [ isControlled, value, inputValue ] ); + useLayoutEffect( () => { + if ( ! autoResize ) { + return; + } + const el = internalRef.current; + if ( ! el ) { + return; + } + el.style.height = 'auto'; + el.style.height = `${ el.scrollHeight }px`; + }, [ autoResize, getValue(), minHeight, maxHeight ] ); + const handleChange = ( event: React.ChangeEvent ) => { if ( disabled ) { return; @@ -83,9 +121,19 @@ export const TextAreaComponent = ( ? 'border-border-disabled bg-field-background-disabled cursor-not-allowed text-text-disabled' : ''; + const computedStyle: React.CSSProperties = { + ...( callerStyle ?? {} ), + minHeight: toCssSize( minHeight ), + maxHeight: toCssSize( maxHeight ), + ...( autoResize && { + resize: 'none', + overflow: maxHeight != null ? 'auto' : 'hidden', + } ), + }; + return (