AutosizedTextarea, minor tweaks
This commit is contained in:
+37
-1
@@ -1,3 +1,39 @@
|
|||||||
import {StyleSheet} from 'react-native'
|
import {type DimensionValue, StyleSheet} from 'react-native'
|
||||||
|
|
||||||
export const flatten = StyleSheet.flatten
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
useState,
|
useState,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import {
|
import {
|
||||||
TextInput,
|
type TextInput,
|
||||||
type TextInputProps,
|
type TextInputProps,
|
||||||
type TextInputSubmitEditingEvent,
|
type TextInputSubmitEditingEvent,
|
||||||
View,
|
View,
|
||||||
@@ -29,7 +29,6 @@ import {
|
|||||||
useTapper,
|
useTapper,
|
||||||
} from '@bsky.app/tapper'
|
} from '@bsky.app/tapper'
|
||||||
|
|
||||||
import {HITSLOP_10} from '#/lib/constants'
|
|
||||||
import {mergeRefs} from '#/lib/merge-refs'
|
import {mergeRefs} from '#/lib/merge-refs'
|
||||||
import {
|
import {
|
||||||
atoms as a,
|
atoms as a,
|
||||||
@@ -46,13 +45,10 @@ import {
|
|||||||
parseAutocompleteItemType,
|
parseAutocompleteItemType,
|
||||||
useAutocomplete,
|
useAutocomplete,
|
||||||
} from '#/components/Autocomplete'
|
} from '#/components/Autocomplete'
|
||||||
|
import {AutosizedTextarea} from '#/components/forms/AutosizedTextarea'
|
||||||
import {useOnKeyboard} from '#/components/hooks/useOnKeyboard'
|
import {useOnKeyboard} from '#/components/hooks/useOnKeyboard'
|
||||||
import {Span, Text} from '#/components/Typography'
|
import {Span, Text} from '#/components/Typography'
|
||||||
import {IS_ANDROID, IS_IOS, IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
|
import {IS_WEB, IS_WEB_TOUCH_DEVICE} from '#/env'
|
||||||
|
|
||||||
/*
|
|
||||||
* ─── Types ────────────────────────────────────────────────────────────────────
|
|
||||||
*/
|
|
||||||
|
|
||||||
export type SubmitRequest =
|
export type SubmitRequest =
|
||||||
| {
|
| {
|
||||||
@@ -67,9 +63,9 @@ export type SubmitRequest =
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bail-out API for special cases where a parent component needs to
|
* Imperative API exposed via `internalApiRef` prop for parent components that
|
||||||
* imperatively control the Composer (e.g. clearing the input on submit).
|
* need to control the composer programmatically, e.g. to clear the input or
|
||||||
* Prefer props/callbacks for normal data flow.
|
* insert text at the current cursor position.
|
||||||
*/
|
*/
|
||||||
export type ComposerInternalApi = {
|
export type ComposerInternalApi = {
|
||||||
input?: ReturnType<typeof useTapper>['input']
|
input?: ReturnType<typeof useTapper>['input']
|
||||||
@@ -106,7 +102,6 @@ export type ComposerProps = Omit<
|
|||||||
paddingRight?: number
|
paddingRight?: number
|
||||||
}
|
}
|
||||||
textStyle?: TextStyleProp['style']
|
textStyle?: TextStyleProp['style']
|
||||||
initialNumberOfLines?: number
|
|
||||||
maxNumberOfLines?: number
|
maxNumberOfLines?: number
|
||||||
initialText?: string
|
initialText?: string
|
||||||
onChange?: (text: string) => void
|
onChange?: (text: string) => void
|
||||||
@@ -114,6 +109,10 @@ export type ComposerProps = Omit<
|
|||||||
onFacetCommitted?: (facet: TapperFacet) => void
|
onFacetCommitted?: (facet: TapperFacet) => void
|
||||||
onRequestSubmit?: (request: SubmitRequest) => void
|
onRequestSubmit?: (request: SubmitRequest) => void
|
||||||
internalApiRef?: React.Ref<ComposerInternalApi>
|
internalApiRef?: React.Ref<ComposerInternalApi>
|
||||||
|
autocompletePlacement?: Exclude<
|
||||||
|
Parameters<typeof useSift>[0],
|
||||||
|
undefined
|
||||||
|
>['placement']
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Composer({
|
export function Composer({
|
||||||
@@ -123,7 +122,6 @@ export function Composer({
|
|||||||
style,
|
style,
|
||||||
padding,
|
padding,
|
||||||
textStyle: rawTextStyle,
|
textStyle: rawTextStyle,
|
||||||
initialNumberOfLines = 1,
|
|
||||||
maxNumberOfLines,
|
maxNumberOfLines,
|
||||||
initialText,
|
initialText,
|
||||||
onChange: onChangeOuter,
|
onChange: onChangeOuter,
|
||||||
@@ -131,29 +129,34 @@ export function Composer({
|
|||||||
onFacetCommitted: onFacetCommittedOuter,
|
onFacetCommitted: onFacetCommittedOuter,
|
||||||
onRequestSubmit,
|
onRequestSubmit,
|
||||||
internalApiRef,
|
internalApiRef,
|
||||||
|
autocompletePlacement,
|
||||||
...rest
|
...rest
|
||||||
}: ComposerProps) {
|
}: ComposerProps) {
|
||||||
const {theme: t, fonts} = useAlf()
|
const {theme: t, fonts} = useAlf()
|
||||||
const textInputRef = useRef<TextInput>(null)
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Meat and potatoes
|
||||||
|
*/
|
||||||
const tapper = useTapper({initialText})
|
const tapper = useTapper({initialText})
|
||||||
const sift = useSift({
|
const sift = useSift({
|
||||||
offset: a.p_sm.padding,
|
offset: a.p_sm.padding,
|
||||||
placement: 'top-start',
|
placement: autocompletePlacement,
|
||||||
dynamicWidth: IS_WEB,
|
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 [activeFacet, setActiveFacet] = useState<TapperActiveFacet | null>(null)
|
||||||
|
|
||||||
const callbackRefs = useRef({
|
/*
|
||||||
onActiveFacetOuter,
|
* Reanimated shared value for syncing scroll on all platforms.
|
||||||
onFacetCommittedOuter,
|
*/
|
||||||
})
|
const inputScrollSharedValue = useSharedValue(0)
|
||||||
callbackRefs.current = {
|
|
||||||
onActiveFacetOuter,
|
|
||||||
onFacetCommittedOuter,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Expose imperative internal API
|
||||||
|
*/
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
internalApiRef,
|
internalApiRef,
|
||||||
() => ({
|
() => ({
|
||||||
@@ -180,6 +183,17 @@ export function Composer({
|
|||||||
onChangeOuter?.(tapper.state.text)
|
onChangeOuter?.(tapper.state.text)
|
||||||
}, [tapper.state.text, onChangeOuter])
|
}, [tapper.state.text, onChangeOuter])
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Tapper callbacks
|
||||||
|
*/
|
||||||
|
const callbackRefs = useRef({
|
||||||
|
onActiveFacetOuter,
|
||||||
|
onFacetCommittedOuter,
|
||||||
|
})
|
||||||
|
callbackRefs.current = {
|
||||||
|
onActiveFacetOuter,
|
||||||
|
onFacetCommittedOuter,
|
||||||
|
}
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const offActiveFacet = tapper.on('activeFacet', facet => {
|
const offActiveFacet = tapper.on('activeFacet', facet => {
|
||||||
setActiveFacet(facet)
|
setActiveFacet(facet)
|
||||||
@@ -198,105 +212,23 @@ export function Composer({
|
|||||||
}
|
}
|
||||||
}, [tapper.on, tapper.input])
|
}, [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
|
* Styles
|
||||||
* 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)
|
|
||||||
|
|
||||||
// ─── 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(() => ({
|
const previewScrollStyle = useAnimatedStyle(() => ({
|
||||||
transform: [{translateY: -inputScrollSharedValue.value}],
|
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 isComposing = useRef(false)
|
||||||
const onKeyPressWeb = useCallback(
|
const onKeyPressWeb = useCallback(
|
||||||
(e: React.KeyboardEvent | any) => {
|
(e: React.KeyboardEvent | any) => {
|
||||||
@@ -323,6 +255,15 @@ export function Composer({
|
|||||||
[onRequestSubmit],
|
[onRequestSubmit],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Sift popover positioning
|
||||||
|
*/
|
||||||
|
const updateAutocompletePosition = useCallback(() => {
|
||||||
|
sift.updatePosition()
|
||||||
|
}, [sift])
|
||||||
|
useOnKeyboard('keyboardDidShow', updateAutocompletePosition)
|
||||||
|
useOnKeyboard('keyboardDidHide', updateAutocompletePosition)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<View style={[a.relative, style]}>
|
<View style={[a.relative, style]}>
|
||||||
@@ -359,47 +300,32 @@ export function Composer({
|
|||||||
</Text>
|
</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
<TextInput
|
<AutosizedTextarea
|
||||||
dirName="ltr"
|
maxRows={12}
|
||||||
multiline={true}
|
|
||||||
hitSlop={HITSLOP_10}
|
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
placeholderTextColor={t.palette.contrast_500}
|
placeholderTextColor={t.palette.contrast_500}
|
||||||
accessibilityLabel={label}
|
accessibilityLabel={label}
|
||||||
accessibilityHint={label}
|
accessibilityHint={label}
|
||||||
keyboardAppearance={t.scheme}
|
|
||||||
submitBehavior="newline"
|
|
||||||
onSubmitEditing={e => {
|
onSubmitEditing={e => {
|
||||||
onRequestSubmit?.({platform: 'native', nativeEvent: e})
|
onRequestSubmit?.({platform: 'native', nativeEvent: e})
|
||||||
}}
|
}}
|
||||||
style={[
|
style={[
|
||||||
textStyle,
|
textStyle,
|
||||||
padding,
|
padding,
|
||||||
a.relative,
|
|
||||||
a.z_20,
|
a.z_20,
|
||||||
a.border_0,
|
|
||||||
{
|
{
|
||||||
color: 'transparent',
|
color: 'transparent',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
textAlignVertical: 'top',
|
|
||||||
includeFontPadding: false,
|
|
||||||
},
|
},
|
||||||
IS_ANDROID ? {height: androidInputHeight} : textAreaStyle,
|
|
||||||
web({
|
web({
|
||||||
resize: 'none',
|
|
||||||
outline: 'none',
|
|
||||||
caretColor: textStyle.color ?? 'black',
|
caretColor: textStyle.color ?? 'black',
|
||||||
whiteSpace: 'pre-wrap',
|
|
||||||
wordBreak: 'break-word',
|
|
||||||
overscrollBehavior: 'none',
|
overscrollBehavior: 'none',
|
||||||
...textAreaStyle,
|
|
||||||
}),
|
}),
|
||||||
]}
|
]}
|
||||||
{...rest}
|
{...rest}
|
||||||
{...tapper.inputProps}
|
{...tapper.inputProps}
|
||||||
{...sift.targetProps}
|
{...sift.targetProps}
|
||||||
ref={mergeRefs([
|
ref={mergeRefs([
|
||||||
textInputRef,
|
|
||||||
rest.ref,
|
rest.ref,
|
||||||
tapper.inputProps.ref,
|
tapper.inputProps.ref,
|
||||||
sift.targetProps.ref,
|
sift.targetProps.ref,
|
||||||
@@ -416,15 +342,6 @@ export function Composer({
|
|||||||
inputScrollSharedValue.value = e.nativeEvent.contentOffset.y
|
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
|
// @ts-ignore web only
|
||||||
onCompositionStart={() => {
|
onCompositionStart={() => {
|
||||||
isComposing.current = true
|
isComposing.current = true
|
||||||
@@ -433,6 +350,7 @@ export function Composer({
|
|||||||
onCompositionEnd={() => {
|
onCompositionEnd={() => {
|
||||||
isComposing.current = false
|
isComposing.current = false
|
||||||
}}
|
}}
|
||||||
|
onUpdateHeight={updateAutocompletePosition}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ import {type TextInput, View} from 'react-native'
|
|||||||
|
|
||||||
import {APP_LANGUAGES} from '#/lib/../locale/languages'
|
import {APP_LANGUAGES} from '#/lib/../locale/languages'
|
||||||
import {type CountryCode} from '#/lib/international-telephone-codes'
|
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 {Button, ButtonText} from '#/components/Button'
|
||||||
|
import {AutosizedTextarea} from '#/components/forms/AutosizedTextarea'
|
||||||
import {DateField, LabelText} from '#/components/forms/DateField'
|
import {DateField, LabelText} from '#/components/forms/DateField'
|
||||||
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
import * as SegmentedControl from '#/components/forms/SegmentedControl'
|
||||||
import * as TextField from '#/components/forms/TextField'
|
import * as TextField from '#/components/forms/TextField'
|
||||||
@@ -16,6 +17,7 @@ import * as Select from '#/components/Select'
|
|||||||
import {H1, H3} from '#/components/Typography'
|
import {H1, H3} from '#/components/Typography'
|
||||||
|
|
||||||
export function Forms() {
|
export function Forms() {
|
||||||
|
const t = useTheme()
|
||||||
const [toggleGroupAValues, setToggleGroupAValues] = useState(['a'])
|
const [toggleGroupAValues, setToggleGroupAValues] = useState(['a'])
|
||||||
const [toggleGroupBValues, setToggleGroupBValues] = useState(['a', 'b'])
|
const [toggleGroupBValues, setToggleGroupBValues] = useState(['a', 'b'])
|
||||||
const [toggleGroupCValues, setToggleGroupCValues] = useState(['a', 'b'])
|
const [toggleGroupCValues, setToggleGroupCValues] = useState(['a', 'b'])
|
||||||
@@ -36,6 +38,32 @@ export function Forms() {
|
|||||||
<View style={[a.gap_4xl, a.align_start]}>
|
<View style={[a.gap_4xl, a.align_start]}>
|
||||||
<H1>Forms</H1>
|
<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.Root value={lang} onValueChange={setLang}>
|
||||||
<Select.Trigger label="Select app language">
|
<Select.Trigger label="Select app language">
|
||||||
<Select.ValueText />
|
<Select.ValueText />
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export default function Storybook() {
|
|||||||
</Button>
|
</Button>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Forms />
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -109,7 +111,6 @@ export default function Storybook() {
|
|||||||
|
|
||||||
<Toasts />
|
<Toasts />
|
||||||
<Buttons />
|
<Buttons />
|
||||||
<Forms />
|
|
||||||
<Typography />
|
<Typography />
|
||||||
<Spacing />
|
<Spacing />
|
||||||
<Shadows />
|
<Shadows />
|
||||||
|
|||||||
Reference in New Issue
Block a user