diff --git a/src/alf/util/flatten.ts b/src/alf/util/flatten.ts index 448716a082..6d49ce6e51 100644 --- a/src/alf/util/flatten.ts +++ b/src/alf/util/flatten.ts @@ -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, + } +} diff --git a/src/components/Composer/index.tsx b/src/components/Composer/index.tsx index 894b95863d..44f234cfda 100644 --- a/src/components/Composer/index.tsx +++ b/src/components/Composer/index.tsx @@ -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['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 + autocompletePlacement?: Exclude< + Parameters[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(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(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 ( <> @@ -359,47 +300,32 @@ export function Composer({ - { 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} diff --git a/src/components/forms/AutosizedTextarea.tsx b/src/components/forms/AutosizedTextarea.tsx new file mode 100644 index 0000000000..96e6ccc1e3 --- /dev/null +++ b/src/components/forms/AutosizedTextarea.tsx @@ -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 & { + ref?: React.Ref + label: string + minRows?: number + maxRows?: number + onUpdateHeight?: (height: number) => void +}) { + const textInputRef = useRef(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 ( + + ) +} diff --git a/src/view/screens/Storybook/Forms.tsx b/src/view/screens/Storybook/Forms.tsx index 5d5f4d0c3e..ce6e7ff58d 100644 --- a/src/view/screens/Storybook/Forms.tsx +++ b/src/view/screens/Storybook/Forms.tsx @@ -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() {

Forms

+ + + + + diff --git a/src/view/screens/Storybook/Storybook.tsx b/src/view/screens/Storybook/Storybook.tsx index 8fb85e8e0a..cd48ae77b8 100644 --- a/src/view/screens/Storybook/Storybook.tsx +++ b/src/view/screens/Storybook/Storybook.tsx @@ -75,6 +75,8 @@ export default function Storybook() {
+ +