import {useEffect, useMemo, useRef, useState} from 'react' import { TextInput, type TextInputContentSizeChangeEvent, type TextInputProps, } from 'react-native' import {mergeRefs} from '#/lib/merge-refs' import {atoms as a, extractPadding, useAlf, web} from '#/alf' import {normalizeTextStyles} from '#/alf/typography' import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env' export type AutosizedTextareaProps = Omit & { ref?: React.Ref label: string minRows?: number maxRows?: number onUpdateHeight?: (height: number) => void /** * In some cases, like on native, we may not pass in an actual `value` prop. * This prop allows Android to know if the field was cleared and thereby * reset its height. This is a small hack required because we need to * explicitly set the `{height: nativeHeight}` on Android. * * If you notice height calcuation issues after clearing the field on * Android, check if this value is being populated and cleared properly in * the parent component. */ rawValue?: string } export function AutosizedTextarea({ ref, label, minRows = 1, maxRows, onUpdateHeight, rawValue, onChangeText: onChangeTextOuter, onContentSizeChange: onContentSizeChangeOuter, style: outerStyle, ...rest }: AutosizedTextareaProps) { const {theme: t, fonts} = useAlf() const internalRef = useRef(null) const {style, minInputHeight, maxInputHeight, verticalContentPadding} = useMemo(() => { const normalizedStyles = normalizeTextStyles( [a.text_md, a.leading_snug, t.atoms.text, outerStyle], { fontScale: fonts.scaleMultiplier, fontFamily: fonts.family, flags: {}, }, ) const lineHeight = normalizedStyles.lineHeight || 20 const {paddingTop, paddingBottom} = extractPadding(normalizedStyles ?? {}) const verticalContentPadding = paddingTop + paddingBottom const minInputHeight = lineHeight * minRows + verticalContentPadding const maxInputHeight = maxRows ? lineHeight * maxRows + verticalContentPadding : Infinity /* * iOS: minHeight/maxHeight works fine natively. * Web + Android: we set an explicit initial height and resize dynamically * (web via DOM measurement, Android via onContentSizeChange state). * * iOS also seems to need 1px headroom to actually expand to the correct * maxHeight */ const heightConstraints = IS_IOS ? {minHeight: minInputHeight, maxHeight: maxInputHeight + 1} : {height: minInputHeight} return { style: { ...normalizedStyles, ...heightConstraints, }, minInputHeight, maxInputHeight, verticalContentPadding, } }, [t, fonts, outerStyle, minRows, maxRows]) /* * Web handling */ const prevWebHeight = useRef(0) const handleResizeWeb = () => { const el = internalRef.current as unknown as HTMLTextAreaElement if (!el) return // collapse to get natural scroll height el.style.height = '0px' const scrollHeight = Math.ceil(el.scrollHeight) const nextHeight = Math.min( Math.max(scrollHeight, minInputHeight), maxInputHeight, ) // immediately update height to prevent flicker el.style.height = `${nextHeight}px` el.style.overflowY = scrollHeight > maxInputHeight ? 'auto' : 'hidden' if (nextHeight !== prevWebHeight.current) { prevWebHeight.current = nextHeight onUpdateHeight?.(nextHeight) } } const onChangeText = (text: string) => { if (IS_WEB) handleResizeWeb() onChangeTextOuter?.(text) } /* * Native handling * * We track the height as state on native, and on Android, we use this to * directly drive the `height`. */ const [nativeHeight, setNativeHeight] = useState(minInputHeight) const onContentSizeChange = (e: TextInputContentSizeChangeEvent) => { const contentSize = Math.ceil(e.nativeEvent.contentSize.height) // ios reports the content size without padding const height = IS_IOS ? contentSize + verticalContentPadding : contentSize const nextHeight = Math.min( Math.max(height, minInputHeight), maxInputHeight, ) if (nextHeight !== nativeHeight) { setNativeHeight(nextHeight) onUpdateHeight?.(nextHeight) } onContentSizeChangeOuter?.(e) } /* * Manual height clearing required for Android because we're forced to set * `{height: nativeHeight}` on Android, and even though `onContentSizeChange` * fires, the height matches the existing height and we aren't able to reset. */ const prevRawValue = useRef(rawValue || '') useEffect(() => { if (!IS_ANDROID) return // everything else is fine if (rawValue === undefined) return // uncontrolled if (prevRawValue.current?.length && rawValue === '') { setNativeHeight(minInputHeight) onUpdateHeight?.(minInputHeight) } prevRawValue.current = rawValue }, [rawValue, minInputHeight]) return ( = maxInputHeight} style={[ a.relative, a.border_0, { textAlignVertical: 'top', includeFontPadding: false, }, web({ resize: 'none', outline: 'none', whiteSpace: 'pre-wrap', wordBreak: 'break-word', }), style, IS_ANDROID ? {height: nativeHeight} : {}, ]} {...rest} ref={mergeRefs([ (node: TextInput | null) => { internalRef.current = node // bop resize on first render if (IS_WEB && node) handleResizeWeb() }, ref, ])} onChangeText={onChangeText} onContentSizeChange={onContentSizeChange} /> ) }