Bluesky app fork with some witchin' additions 馃挮
witchsky.app
bluesky
fork
client
1import {useMemo, useRef, useState} from 'react'
2import {
3 TextInput,
4 type TextInputContentSizeChangeEvent,
5 type TextInputProps,
6} from 'react-native'
7
8import {mergeRefs} from '#/lib/merge-refs'
9import {atoms as a, extractPadding, useAlf, web} from '#/alf'
10import {normalizeTextStyles} from '#/alf/typography'
11import {IS_ANDROID, IS_IOS, IS_WEB} from '#/env'
12
13export type AutosizedTextareaProps = Omit<TextInputProps, 'multiline'> & {
14 ref?: React.Ref<TextInput>
15 label: string
16 minRows?: number
17 maxRows?: number
18 onUpdateHeight?: (height: number) => void
19}
20
21export function AutosizedTextarea({
22 ref,
23 label,
24 minRows = 1,
25 maxRows,
26 onUpdateHeight,
27
28 onChangeText: onChangeTextOuter,
29 onContentSizeChange: onContentSizeChangeOuter,
30 style: outerStyle,
31 ...rest
32}: AutosizedTextareaProps) {
33 const {theme: t, fonts} = useAlf()
34 const internalRef = useRef<TextInput>(null)
35 const {style, minInputHeight, maxInputHeight, verticalContentPadding} =
36 useMemo(() => {
37 const normalizedStyles = normalizeTextStyles(
38 [a.text_md, a.leading_snug, t.atoms.text, outerStyle],
39 {
40 fontScale: fonts.scaleMultiplier,
41 fontFamily: fonts.family,
42 flags: {},
43 },
44 )
45 const lineHeight = normalizedStyles.lineHeight || 20
46 const {paddingTop, paddingBottom} = extractPadding(normalizedStyles ?? {})
47 const verticalContentPadding = paddingTop + paddingBottom
48 const minInputHeight = lineHeight * minRows + verticalContentPadding
49 const maxInputHeight = maxRows
50 ? lineHeight * maxRows + verticalContentPadding
51 : Infinity
52
53 /*
54 * iOS: minHeight/maxHeight works fine natively.
55 * Web + Android: we set an explicit initial height and resize dynamically
56 * (web via DOM measurement, Android via onContentSizeChange state).
57 *
58 * iOS also seems to need 1px headroom to actually expand to the correct
59 * maxHeight
60 */
61 const heightConstraints = IS_IOS
62 ? {minHeight: minInputHeight, maxHeight: maxInputHeight + 1}
63 : {height: minInputHeight}
64
65 return {
66 style: {
67 ...normalizedStyles,
68 ...heightConstraints,
69 },
70 minInputHeight,
71 maxInputHeight,
72 verticalContentPadding,
73 }
74 }, [t, fonts, outerStyle, minRows, maxRows])
75
76 /*
77 * Web handling
78 */
79 const prevWebHeight = useRef(0)
80 const handleResizeWeb = () => {
81 const el = internalRef.current as unknown as HTMLTextAreaElement
82 if (!el) return
83 // collapse to get natural scroll height
84 el.style.height = '0px'
85 const scrollHeight = Math.ceil(el.scrollHeight)
86 const nextHeight = Math.min(
87 Math.max(scrollHeight, minInputHeight),
88 maxInputHeight,
89 )
90 // immediately update height to prevent flicker
91 el.style.height = `${nextHeight}px`
92 el.style.overflowY = scrollHeight > maxInputHeight ? 'auto' : 'hidden'
93 if (nextHeight !== prevWebHeight.current) {
94 prevWebHeight.current = nextHeight
95 onUpdateHeight?.(nextHeight)
96 }
97 }
98 const onChangeText = (text: string) => {
99 if (IS_WEB) handleResizeWeb()
100 onChangeTextOuter?.(text)
101 }
102
103 /*
104 * Native handling
105 *
106 * We track the height as state on native, and on Android, we use this to
107 * directly drive the `height`.
108 */
109 const [nativeHeight, setNativeHeight] = useState(minInputHeight)
110 const onContentSizeChange = (e: TextInputContentSizeChangeEvent) => {
111 const contentSize = Math.ceil(e.nativeEvent.contentSize.height)
112 // ios reports the content size without padding
113 const height = IS_IOS ? contentSize + verticalContentPadding : contentSize
114 const nextHeight = Math.min(
115 Math.max(height, minInputHeight),
116 maxInputHeight,
117 )
118
119 if (nextHeight !== nativeHeight) {
120 setNativeHeight(nextHeight)
121 onUpdateHeight?.(nextHeight)
122 }
123
124 onContentSizeChangeOuter?.(e)
125 }
126
127 return (
128 <TextInput
129 multiline
130 placeholderTextColor={t.palette.contrast_500}
131 accessibilityLabel={label}
132 accessibilityHint={label}
133 placeholder={label}
134 keyboardAppearance={t.scheme}
135 submitBehavior="newline"
136 scrollEnabled={nativeHeight >= maxInputHeight}
137 style={[
138 a.relative,
139 a.border_0,
140 {
141 textAlignVertical: 'top',
142 includeFontPadding: false,
143 },
144 web({
145 resize: 'none',
146 outline: 'none',
147 whiteSpace: 'pre-wrap',
148 wordBreak: 'break-word',
149 }),
150 style,
151 IS_ANDROID ? {height: nativeHeight} : {},
152 ]}
153 {...rest}
154 ref={mergeRefs([
155 (node: TextInput | null) => {
156 internalRef.current = node
157 // bop resize on first render
158 if (IS_WEB && node) handleResizeWeb()
159 },
160 ref,
161 ])}
162 onChangeText={onChangeText}
163 onContentSizeChange={onContentSizeChange}
164 />
165 )
166}