Files
bsky-social-app/src/components/forms/AutosizedTextarea.tsx
T
2026-04-03 14:36:34 -05:00

171 lines
5.0 KiB
TypeScript

import {useCallback, 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, flatten, useAlf, web} from '#/alf'
import {normalizeTextStyles} from '#/alf/typography'
import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
export function AutosizedTextarea({
ref,
label,
minRows = 1,
maxRows,
onUpdateHeight,
onChangeText: onChangeTextOuter,
onContentSizeChange: onContentSizeChangeOuter,
style,
...rest
}: Omit<TextInputProps, 'multiline'> & {
ref?: React.Ref<TextInput>
label: string
minRows?: number
maxRows?: number
onUpdateHeight?: (height: number) => void
}) {
const textInputRef = useRef<TextInput>(null)
const {theme: t, fonts} = useAlf()
const {processedStyle, minHeight, maxHeight} = useMemo(() => {
const fs = flatten(style)
const ts = normalizeTextStyles([a.leading_snug, fs, t.atoms.text], {
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags: {},
})
const lineHeight = ts.lineHeight || 20
const padding = extractPadding(fs ?? {})
const verticalSpace = padding.paddingTop + padding.paddingBottom
const mh = lineHeight * minRows + verticalSpace
const xh = maxRows ? lineHeight * maxRows + verticalSpace : Infinity
/*
* On web, we set an initial height and auto-resize via DOM measurement.
* On Android, we drive height explicitly via onContentSizeChange to avoid
* sub-pixel oscillation that causes layout jumpiness.
* On iOS, minHeight/maxHeight works fine natively.
*/
const tas = IS_WEB
? {height: lineHeight + verticalSpace}
: IS_ANDROID
? {height: mh}
: {minHeight: mh, maxHeight: xh}
if (IS_IOS) {
delete ts.lineHeight
}
return {
processedStyle: {
...ts,
...tas,
},
minHeight: mh,
maxHeight: xh,
}
}, [t, fonts, style, minRows, maxRows])
/*
* On Android, multiline TextInput oscillates between slightly different
* contentSize values on consecutive layout passes (sub-pixel rounding).
* This causes visible jumpiness when using minHeight/maxHeight. Instead,
* we drive the height explicitly and ceil the value to stabilize it.
*/
const [androidInputHeight, setAndroidInputHeight] = useState(minHeight)
const prevHeight = useRef(0)
const onChangeText = useCallback(
(text: string) => {
if (IS_WEB) {
const el = textInputRef.current as unknown as HTMLTextAreaElement
if (el) {
el.style.height = '0px'
const scrollHeight = el.scrollHeight
const nextHeight = Math.min(
Math.max(scrollHeight, minHeight),
maxHeight,
)
el.style.height = `${nextHeight}px`
el.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'
if (nextHeight !== prevHeight.current) {
prevHeight.current = nextHeight
onUpdateHeight?.(nextHeight)
}
}
} else if (IS_IOS) {
textInputRef.current?.measure((_x, _y, _w, h) => {
if (h !== prevHeight.current) {
prevHeight.current = h
onUpdateHeight?.(h)
}
})
}
onChangeTextOuter?.(text)
},
[onChangeTextOuter, minHeight, maxHeight, onUpdateHeight],
)
const onContentSizeChange = useCallback(
(e: TextInputContentSizeChangeEvent) => {
if (IS_ANDROID) {
const h = Math.ceil(e.nativeEvent.contentSize.height)
const nextHeight = Math.min(Math.max(h, minHeight), maxHeight)
if (nextHeight !== androidInputHeight) {
setAndroidInputHeight(nextHeight)
}
}
onContentSizeChangeOuter?.(e)
},
[onContentSizeChangeOuter, minHeight, maxHeight, androidInputHeight],
)
/*
* On Android, height is driven by onContentSizeChange (see the TextInput
* below), so we update the autocomplete position when that height changes.
*/
useEffect(() => {
if (IS_ANDROID) {
onUpdateHeight?.(androidInputHeight)
}
}, [androidInputHeight, onUpdateHeight])
return (
<TextInput
multiline
placeholderTextColor={t.palette.contrast_500}
accessibilityLabel={label}
accessibilityHint={label}
placeholder={label}
keyboardAppearance={t.scheme}
submitBehavior="newline"
style={[
a.relative,
a.border_0,
IS_ANDROID ? {height: androidInputHeight} : {},
{
textAlignVertical: 'top',
includeFontPadding: false,
},
web({
resize: 'none',
outline: 'none',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overscrollBehavior: 'none',
}),
processedStyle,
]}
{...rest}
ref={mergeRefs([textInputRef, ref])}
onChangeText={onChangeText}
onContentSizeChange={onContentSizeChange}
/>
)
}