AutosizedTextarea, minor tweaks

This commit is contained in:
Eric Bailey
2026-04-03 14:36:34 -05:00
parent 1c8e4c5f05
commit 8b65e57889
5 changed files with 298 additions and 145 deletions
+37 -1
View File
@@ -1,3 +1,39 @@
import {StyleSheet} from 'react-native'
import {type DimensionValue, StyleSheet} from 'react-native'
export const flatten = StyleSheet.flatten
/**
* Coerce a style value to a number. Padding values are typed as
* `DimensionValue` (numbers, percentages, "auto", etc.) but our ALF atoms
* are always plain numbers. Non-numeric values are treated as 0.
*/
function num(v: unknown): number {
return typeof v === 'number' ? v : 0
}
interface PaddingStyle {
padding?: DimensionValue
paddingHorizontal?: DimensionValue
paddingVertical?: DimensionValue
paddingTop?: DimensionValue
paddingBottom?: DimensionValue
paddingLeft?: DimensionValue
paddingRight?: DimensionValue
}
/**
* Extract resolved padding values from a style object. Returns numbers for
* each side, resolving shorthand properties (padding → paddingVertical →
* paddingTop/paddingBottom, etc.). Values are expected to be numbers — any
* non-numeric `DimensionValue` (e.g. percentages) is treated as 0.
*/
export function extractPadding(style: PaddingStyle | PaddingStyle[]) {
const s = flatten(style as any) ?? {}
const base = num(s.padding)
return {
paddingTop: num(s.paddingTop) || num(s.paddingVertical) || base,
paddingBottom: num(s.paddingBottom) || num(s.paddingVertical) || base,
paddingLeft: num(s.paddingLeft) || num(s.paddingHorizontal) || base,
paddingRight: num(s.paddingRight) || num(s.paddingHorizontal) || base,
}
}
+60 -142
View File
@@ -13,7 +13,7 @@ import {
useState,
} from 'react'
import {
TextInput,
type TextInput,
type TextInputProps,
type TextInputSubmitEditingEvent,
View,
@@ -29,7 +29,6 @@ import {
useTapper,
} from '@bsky.app/tapper'
import {HITSLOP_10} from '#/lib/constants'
import {mergeRefs} from '#/lib/merge-refs'
import {
atoms as a,
@@ -46,13 +45,10 @@ import {
parseAutocompleteItemType,
useAutocomplete,
} from '#/components/Autocomplete'
import {AutosizedTextarea} from '#/components/forms/AutosizedTextarea'
import {useOnKeyboard} from '#/components/hooks/useOnKeyboard'
import {Span, Text} from '#/components/Typography'
import {IS_ANDROID, IS_IOS, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
/*
* ─── Types ────────────────────────────────────────────────────────────────────
*/
import {IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
export type SubmitRequest =
| {
@@ -67,9 +63,9 @@ export type SubmitRequest =
}
/**
* Bail-out API for special cases where a parent component needs to
* imperatively control the Composer (e.g. clearing the input on submit).
* Prefer props/callbacks for normal data flow.
* Imperative API exposed via `internalApiRef` prop for parent components that
* need to control the composer programmatically, e.g. to clear the input or
* insert text at the current cursor position.
*/
export type ComposerInternalApi = {
input?: ReturnType<typeof useTapper>['input']
@@ -106,7 +102,6 @@ export type ComposerProps = Omit<
paddingRight?: number
}
textStyle?: TextStyleProp['style']
initialNumberOfLines?: number
maxNumberOfLines?: number
initialText?: string
onChange?: (text: string) => void
@@ -114,6 +109,10 @@ export type ComposerProps = Omit<
onFacetCommitted?: (facet: TapperFacet) => void
onRequestSubmit?: (request: SubmitRequest) => void
internalApiRef?: React.Ref<ComposerInternalApi>
autocompletePlacement?: Exclude<
Parameters<typeof useSift>[0],
undefined
>['placement']
}
export function Composer({
@@ -123,7 +122,6 @@ export function Composer({
style,
padding,
textStyle: rawTextStyle,
initialNumberOfLines = 1,
maxNumberOfLines,
initialText,
onChange: onChangeOuter,
@@ -131,29 +129,34 @@ export function Composer({
onFacetCommitted: onFacetCommittedOuter,
onRequestSubmit,
internalApiRef,
autocompletePlacement,
...rest
}: ComposerProps) {
const {theme: t, fonts} = useAlf()
const textInputRef = useRef<TextInput>(null)
/*
* Meat and potatoes
*/
const tapper = useTapper({initialText})
const sift = useSift({
offset: a.p_sm.padding,
placement: 'top-start',
placement: autocompletePlacement,
dynamicWidth: IS_WEB,
})
const inputScrollSharedValue = useSharedValue(0)
/*
* Active facet state for controlling the visibility of the Autocomplete.
*/
const [activeFacet, setActiveFacet] = useState<TapperActiveFacet | null>(null)
const callbackRefs = useRef({
onActiveFacetOuter,
onFacetCommittedOuter,
})
callbackRefs.current = {
onActiveFacetOuter,
onFacetCommittedOuter,
}
/*
* Reanimated shared value for syncing scroll on all platforms.
*/
const inputScrollSharedValue = useSharedValue(0)
/*
* Expose imperative internal API
*/
useImperativeHandle(
internalApiRef,
() => ({
@@ -180,6 +183,17 @@ export function Composer({
onChangeOuter?.(tapper.state.text)
}, [tapper.state.text, onChangeOuter])
/*
* Tapper callbacks
*/
const callbackRefs = useRef({
onActiveFacetOuter,
onFacetCommittedOuter,
})
callbackRefs.current = {
onActiveFacetOuter,
onFacetCommittedOuter,
}
useEffect(() => {
const offActiveFacet = tapper.on('activeFacet', facet => {
setActiveFacet(facet)
@@ -198,105 +212,23 @@ export function Composer({
}
}, [tapper.on, tapper.input])
// ─── Text style computation ───────────────────────────────────────────
const {textStyle, textAreaStyle, minHeight, maxHeight} = useMemo(() => {
const ts = normalizeTextStyles(
[a.leading_snug, rawTextStyle, t.atoms.text],
{
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags: {},
},
)
const lineHeight = ts.lineHeight || 20
const verticalSpace =
(padding?.paddingTop || 0) + (padding?.paddingBottom || 0)
const mh = lineHeight * initialNumberOfLines + verticalSpace
const xh = maxNumberOfLines
? lineHeight * maxNumberOfLines + verticalSpace
: 999
/*
* 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 {textStyle: ts, textAreaStyle: tas, minHeight: mh, maxHeight: xh}
}, [t, fonts, padding, rawTextStyle, initialNumberOfLines, maxNumberOfLines])
/*
* 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.
* Styles
*/
const [androidInputHeight, setAndroidInputHeight] = useState(minHeight)
// ─── Height auto-resize + sift positioning ────────────────────────────
const updateAutocompletePosition = useCallback(() => {
sift.updatePosition()
}, [sift])
useOnKeyboard('keyboardDidShow', updateAutocompletePosition)
useOnKeyboard('keyboardDidHide', updateAutocompletePosition)
const prevHeight = useRef(0)
useEffect(() => {
if (IS_WEB) {
const el = textInputRef.current as unknown as HTMLTextAreaElement
if (!el) return
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
updateAutocompletePosition()
}
return
}
if (IS_IOS) {
textInputRef.current?.measure((_x, _y, _w, h) => {
if (h !== prevHeight.current) {
prevHeight.current = h
updateAutocompletePosition()
}
})
}
}, [tapper.state.text, minHeight, maxHeight, updateAutocompletePosition])
/*
* 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) {
updateAutocompletePosition()
}
}, [androidInputHeight, updateAutocompletePosition])
// ─── Scroll sync ──────────────────────────────────────────────────────
const previewScrollStyle = useAnimatedStyle(() => ({
transform: [{translateY: -inputScrollSharedValue.value}],
}))
const textStyle = useMemo(() => {
return normalizeTextStyles([a.leading_snug, rawTextStyle, t.atoms.text], {
fontScale: fonts.scaleMultiplier,
fontFamily: fonts.family,
flags: {},
})
}, [rawTextStyle, fonts])
// ─── Web keyboard handling ────────────────────────────────────────────
/*
* Web keyboard handling
*/
const isComposing = useRef(false)
const onKeyPressWeb = useCallback(
(e: React.KeyboardEvent | any) => {
@@ -323,6 +255,15 @@ export function Composer({
[onRequestSubmit],
)
/*
* Sift popover positioning
*/
const updateAutocompletePosition = useCallback(() => {
sift.updatePosition()
}, [sift])
useOnKeyboard('keyboardDidShow', updateAutocompletePosition)
useOnKeyboard('keyboardDidHide', updateAutocompletePosition)
return (
<>
<View style={[a.relative, style]}>
@@ -359,47 +300,32 @@ export function Composer({
</Text>
</Animated.View>
</View>
<TextInput
dirName="ltr"
multiline={true}
hitSlop={HITSLOP_10}
<AutosizedTextarea
maxRows={12}
placeholder={placeholder}
placeholderTextColor={t.palette.contrast_500}
accessibilityLabel={label}
accessibilityHint={label}
keyboardAppearance={t.scheme}
submitBehavior="newline"
onSubmitEditing={e => {
onRequestSubmit?.({platform: 'native', nativeEvent: e})
}}
style={[
textStyle,
padding,
a.relative,
a.z_20,
a.border_0,
{
color: 'transparent',
background: 'transparent',
textAlignVertical: 'top',
includeFontPadding: false,
},
IS_ANDROID ? {height: androidInputHeight} : textAreaStyle,
web({
resize: 'none',
outline: 'none',
caretColor: textStyle.color ?? 'black',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
overscrollBehavior: 'none',
...textAreaStyle,
}),
]}
{...rest}
{...tapper.inputProps}
{...sift.targetProps}
ref={mergeRefs([
textInputRef,
rest.ref,
tapper.inputProps.ref,
sift.targetProps.ref,
@@ -416,15 +342,6 @@ export function Composer({
inputScrollSharedValue.value = e.nativeEvent.contentOffset.y
}
}}
onContentSizeChange={
IS_ANDROID
? e => {
const h = Math.ceil(e.nativeEvent.contentSize.height)
const clamped = Math.min(Math.max(h, minHeight), maxHeight)
setAndroidInputHeight(clamped)
}
: undefined
}
// @ts-ignore web only
onCompositionStart={() => {
isComposing.current = true
@@ -433,6 +350,7 @@ export function Composer({
onCompositionEnd={() => {
isComposing.current = false
}}
onUpdateHeight={updateAutocompletePosition}
/>
{children}
+170
View File
@@ -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}
/>
)
}
+29 -1
View File
@@ -3,8 +3,9 @@ import {type TextInput, View} from 'react-native'
import {APP_LANGUAGES} from '#/lib/../locale/languages'
import {type CountryCode} from '#/lib/international-telephone-codes'
import {atoms as a} from '#/alf'
import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import {AutosizedTextarea} from '#/components/forms/AutosizedTextarea'
import {DateField, LabelText} from '#/components/forms/DateField'
import * as SegmentedControl from '#/components/forms/SegmentedControl'
import * as TextField from '#/components/forms/TextField'
@@ -16,6 +17,7 @@ import * as Select from '#/components/Select'
import {H1, H3} from '#/components/Typography'
export function Forms() {
const t = useTheme()
const [toggleGroupAValues, setToggleGroupAValues] = useState(['a'])
const [toggleGroupBValues, setToggleGroupBValues] = useState(['a', 'b'])
const [toggleGroupCValues, setToggleGroupCValues] = useState(['a', 'b'])
@@ -36,6 +38,32 @@ export function Forms() {
<View style={[a.gap_4xl, a.align_start]}>
<H1>Forms</H1>
<View style={[a.gap_md, a.align_start, a.w_full]}>
<AutosizedTextarea
label="AutosizedTextarea minRows=1 maxRows=5"
style={[
a.w_full,
a.p_md,
a.rounded_sm,
a.border,
t.atoms.border_contrast_medium,
]}
maxRows={5}
/>
<AutosizedTextarea
label="AutosizedTextarea minRows=3 maxRows=10"
style={[
a.w_full,
a.p_md,
a.rounded_sm,
a.border,
t.atoms.border_contrast_medium,
]}
minRows={3}
maxRows={10}
/>
</View>
<Select.Root value={lang} onValueChange={setLang}>
<Select.Trigger label="Select app language">
<Select.ValueText />
+2 -1
View File
@@ -75,6 +75,8 @@ export default function Storybook() {
</Button>
</View>
<Forms />
<Button
color="primary"
size="small"
@@ -109,7 +111,6 @@ export default function Storybook() {
<Toasts />
<Buttons />
<Forms />
<Typography />
<Spacing />
<Shadows />