import { forwardRef, useEffect, useState } from 'react'; import { Platform, type TextInput, type TextInputProps, type TextStyle, type ViewProps, } from 'react-native'; import { InputField as InputFieldPrimitive, InputRoot as InputRootPrimitive, dataAttributes, type IInputFieldProps, } from '@cdx-ui/primitives'; import { cn, composeEventHandlers } from '@cdx-ui/utils'; import { type TextareaVariantProps, textareaFieldPlaceholderVariants, textareaFieldVariants, textareaRootVariants, } from './styles'; // Initial/minimum visible rows. Web textareas render compactly, so a smaller default // reads better there; native has no intrinsic multiline height, so it needs more. const DEFAULT_MIN_ROWS = Platform.select({ web: 2, default: 8 }); // Explicit per-size line height (px) set on the field so `rows * lineHeight` maps to an // exact pixel height on native. Values track the field font sizes in `styles.ts`. const LINE_HEIGHT_BY_SIZE = { default: 20, small: 16 } as const; // Module-scoped latch so the dev conflict warning fires at most once per session. let hasWarnedResizeAutoGrowConflict = false; type ContentSizeChangeEvent = Parameters>[0]; export type TextareaResize = 'none' | 'vertical'; export interface TextareaProps extends Omit, IInputFieldProps, TextareaVariantProps { className?: string; placeholderTextColorClassName?: string; /** Initial / minimum visible rows. Defaults to 2 on web and 8 on native. */ numberOfLines?: number; /** * Grow the field height to fit its content. Defaults to `false`. * Web uses `field-sizing: content`; native grows intrinsically (`scrollEnabled` * off), using `onContentSizeChange` only to enable scrolling past `maxRows`. */ autoGrow?: boolean; /** Caps `autoGrow` at this many rows, after which the field scrolls. */ maxRows?: number; /** * Web-only user resize affordance. Defaults to `'none'`. `'vertical'` exposes the * browser resize handle and takes precedence over `autoGrow` (they are mutually * exclusive). No-op on native. */ resize?: TextareaResize; style?: ViewProps['style']; } export const Textarea = forwardRef( ( { variant = 'outline', size = 'default', isDisabled, isInvalid, isReadOnly, isRequired, isFocused, isHovered, isFullWidth, className, placeholderTextColorClassName, style, numberOfLines, autoGrow = false, maxRows, resize = 'none', onContentSizeChange, ...rest }, ref, ) => { const minRows = numberOfLines ?? DEFAULT_MIN_ROWS; const lineHeight = LINE_HEIGHT_BY_SIZE[size ?? 'default']; const minHeight = minRows * lineHeight; const maxHeight = maxRows != null ? maxRows * lineHeight : undefined; const isWeb = Platform.OS === 'web'; // Web user-resize wins over auto-grow; the two are mutually exclusive. const userResizable = isWeb && resize === 'vertical'; const effectiveAutoGrow = autoGrow && !userResizable; useEffect(() => { if (__DEV__ && autoGrow && resize === 'vertical' && !hasWarnedResizeAutoGrowConflict) { hasWarnedResizeAutoGrowConflict = true; console.warn( '[Textarea] `autoGrow` and `resize="vertical"` are mutually exclusive. ' + '`resize` takes precedence and `autoGrow` is ignored.', ); } }, [autoGrow, resize]); // Native auto-grow: track the measured content height so we can enable internal // scrolling once it passes the `maxRows` cap. Growth itself is handled natively // (see `nativeScrollEnabled`), not by pinning a height here. const [measuredHeight, setMeasuredHeight] = useState(undefined); const handleContentSizeChange = (event: ContentSizeChangeEvent) => { if (!isWeb && effectiveAutoGrow) { setMeasuredHeight(event.nativeEvent.contentSize.height); } }; // On native, a multiline TextInput only grows to fit its content when scrolling is // disabled — an explicit height or an enabled scroll view pins it. So for auto-grow we // keep `scrollEnabled` off (letting the view size itself between min/max) and only turn // scrolling back on once the content exceeds the `maxRows` cap. Undefined elsewhere // (web + fixed height) leaves React Native's default (`true`) so overflow scrolls. const nativeScrollEnabled = !isWeb && effectiveAutoGrow ? maxHeight != null && measuredHeight != null && measuredHeight > maxHeight : undefined; // Auto-grow (web + native) and fixed sizing all bound the field with min/max height and // never pin an explicit `height`: web grows via `field-sizing: content`, native grows via // the disabled scroll view above, and the fixed case simply floors at `minHeight`. const heightStyle: TextStyle = !isWeb && !effectiveAutoGrow ? { minHeight } : { minHeight, maxHeight }; // `textAlignVertical` is set in style (not just the prop) so it overrides the Android // primitive's `center` default — style wins over the deprecated prop. const fieldStyle: TextStyle = { lineHeight, textAlignVertical: 'top', ...heightStyle }; // `className` applies to the root (the visible bordered box), matching the flat // CurrencyInput pattern. Field-level utilities below are internal sizing concerns. const fieldClassName = cn( textareaFieldVariants({ size }), effectiveAutoGrow && 'web:[field-sizing:content]', userResizable ? 'web:[resize:vertical]' : 'web:[resize:none]', ); return ( ); }, ); Textarea.displayName = 'Textarea';