AutosizedTextarea, minor tweaks
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user