mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import {createContext, useContext, useMemo, useRef} from 'react'
2import {
3 type AccessibilityProps,
4 StyleSheet,
5 TextInput,
6 type TextInputProps,
7 type TextStyle,
8 View,
9 type ViewStyle,
10} from 'react-native'
11
12import {HITSLOP_20} from '#/lib/constants'
13import {mergeRefs} from '#/lib/merge-refs'
14import {
15 android,
16 applyFonts,
17 atoms as a,
18 ios,
19 platform,
20 type TextStyleProp,
21 tokens,
22 useAlf,
23 useTheme,
24 web,
25} from '#/alf'
26import {useInteractionState} from '#/components/hooks/useInteractionState'
27import {type Props as SVGIconProps} from '#/components/icons/common'
28import {Text} from '#/components/Typography'
29
30const Context = createContext<{
31 inputRef: React.RefObject<TextInput> | null
32 isInvalid: boolean
33 hovered: boolean
34 onHoverIn: () => void
35 onHoverOut: () => void
36 focused: boolean
37 onFocus: () => void
38 onBlur: () => void
39}>({
40 inputRef: null,
41 isInvalid: false,
42 hovered: false,
43 onHoverIn: () => {},
44 onHoverOut: () => {},
45 focused: false,
46 onFocus: () => {},
47 onBlur: () => {},
48})
49Context.displayName = 'TextFieldContext'
50
51export type RootProps = React.PropsWithChildren<{isInvalid?: boolean}>
52
53export function Root({children, isInvalid = false}: RootProps) {
54 const inputRef = useRef<TextInput>(null)
55 const {
56 state: hovered,
57 onIn: onHoverIn,
58 onOut: onHoverOut,
59 } = useInteractionState()
60 const {state: focused, onIn: onFocus, onOut: onBlur} = useInteractionState()
61
62 const context = useMemo(
63 () => ({
64 inputRef,
65 hovered,
66 onHoverIn,
67 onHoverOut,
68 focused,
69 onFocus,
70 onBlur,
71 isInvalid,
72 }),
73 [
74 inputRef,
75 hovered,
76 onHoverIn,
77 onHoverOut,
78 focused,
79 onFocus,
80 onBlur,
81 isInvalid,
82 ],
83 )
84
85 return (
86 <Context.Provider value={context}>
87 <View
88 style={[a.flex_row, a.align_center, a.relative, a.w_full, a.px_md]}
89 {...web({
90 onClick: () => inputRef.current?.focus(),
91 onMouseOver: onHoverIn,
92 onMouseOut: onHoverOut,
93 })}>
94 {children}
95 </View>
96 </Context.Provider>
97 )
98}
99
100export function useSharedInputStyles() {
101 const t = useTheme()
102 return useMemo(() => {
103 const hover: ViewStyle[] = [
104 {
105 borderColor: t.palette.contrast_100,
106 },
107 ]
108 const focus: ViewStyle[] = [
109 {
110 backgroundColor: t.palette.contrast_50,
111 borderColor: t.palette.primary_500,
112 },
113 ]
114 const error: ViewStyle[] = [
115 {
116 backgroundColor: t.palette.negative_25,
117 borderColor: t.palette.negative_300,
118 },
119 ]
120 const errorHover: ViewStyle[] = [
121 {
122 backgroundColor: t.palette.negative_25,
123 borderColor: t.palette.negative_500,
124 },
125 ]
126
127 return {
128 chromeHover: StyleSheet.flatten(hover),
129 chromeFocus: StyleSheet.flatten(focus),
130 chromeError: StyleSheet.flatten(error),
131 chromeErrorHover: StyleSheet.flatten(errorHover),
132 }
133 }, [t])
134}
135
136export type InputProps = Omit<TextInputProps, 'value' | 'onChangeText'> & {
137 label: string
138 /**
139 * @deprecated Controlled inputs are *strongly* discouraged. Use `defaultValue` instead where possible.
140 *
141 * See https://github.com/facebook/react-native-website/pull/4247
142 */
143 value?: string
144 onChangeText?: (value: string) => void
145 isInvalid?: boolean
146 inputRef?: React.RefObject<TextInput> | React.ForwardedRef<TextInput>
147}
148
149export function createInput(Component: typeof TextInput) {
150 return function Input({
151 label,
152 placeholder,
153 value,
154 onChangeText,
155 onFocus,
156 onBlur,
157 isInvalid,
158 inputRef,
159 style,
160 ...rest
161 }: InputProps) {
162 const t = useTheme()
163 const {fonts} = useAlf()
164 const ctx = useContext(Context)
165 const withinRoot = Boolean(ctx.inputRef)
166
167 const {chromeHover, chromeFocus, chromeError, chromeErrorHover} =
168 useSharedInputStyles()
169
170 if (!withinRoot) {
171 return (
172 <Root isInvalid={isInvalid}>
173 <Input
174 label={label}
175 placeholder={placeholder}
176 value={value}
177 onChangeText={onChangeText}
178 isInvalid={isInvalid}
179 {...rest}
180 />
181 </Root>
182 )
183 }
184
185 const refs = mergeRefs([ctx.inputRef, inputRef!].filter(Boolean))
186
187 const flattened = StyleSheet.flatten([
188 a.relative,
189 a.z_20,
190 a.flex_1,
191 a.text_md,
192 t.atoms.text,
193 a.px_xs,
194 {
195 // paddingVertical doesn't work w/multiline - esb
196 lineHeight: a.text_md.fontSize * 1.1875,
197 textAlignVertical: rest.multiline ? 'top' : undefined,
198 minHeight: rest.multiline ? 80 : undefined,
199 minWidth: 0,
200 },
201 ios({paddingTop: 12, paddingBottom: 13}),
202 // Needs to be sm on Paper, md on Fabric for some godforsaken reason -sfn
203 android(a.py_sm),
204 // fix for autofill styles covering border
205 web({
206 paddingTop: 10,
207 paddingBottom: 11,
208 marginTop: 2,
209 marginBottom: 2,
210 }),
211 style,
212 ])
213
214 applyFonts(flattened, fonts.family)
215
216 // should always be defined on `typography`
217 // @ts-ignore
218 if (flattened.fontSize) {
219 // @ts-ignore
220 flattened.fontSize = Math.round(
221 // @ts-ignore
222 flattened.fontSize * fonts.scaleMultiplier,
223 )
224 }
225
226 return (
227 <>
228 <Component
229 accessibilityHint={undefined}
230 hitSlop={HITSLOP_20}
231 {...rest}
232 accessibilityLabel={label}
233 ref={refs}
234 value={value}
235 onChangeText={onChangeText}
236 onFocus={e => {
237 ctx.onFocus()
238 onFocus?.(e)
239 }}
240 onBlur={e => {
241 ctx.onBlur()
242 onBlur?.(e)
243 }}
244 placeholder={placeholder || label}
245 placeholderTextColor={t.palette.contrast_500}
246 keyboardAppearance={t.name === 'light' ? 'light' : 'dark'}
247 style={flattened}
248 />
249
250 <View
251 style={[
252 a.z_10,
253 a.absolute,
254 a.inset_0,
255 a.rounded_sm,
256 t.atoms.bg_contrast_25,
257 {borderColor: 'transparent', borderWidth: 2},
258 ctx.hovered ? chromeHover : {},
259 ctx.focused ? chromeFocus : {},
260 ctx.isInvalid || isInvalid ? chromeError : {},
261 (ctx.isInvalid || isInvalid) && (ctx.hovered || ctx.focused)
262 ? chromeErrorHover
263 : {},
264 ]}
265 />
266 </>
267 )
268 }
269}
270
271export const Input = createInput(TextInput)
272
273export function LabelText({
274 nativeID,
275 children,
276}: React.PropsWithChildren<{nativeID?: string}>) {
277 const t = useTheme()
278 return (
279 <Text
280 nativeID={nativeID}
281 style={[a.text_sm, a.font_bold, t.atoms.text_contrast_medium, a.mb_sm]}>
282 {children}
283 </Text>
284 )
285}
286
287export function Icon({icon: Comp}: {icon: React.ComponentType<SVGIconProps>}) {
288 const t = useTheme()
289 const ctx = useContext(Context)
290 const {hover, focus, errorHover, errorFocus} = useMemo(() => {
291 const hover: TextStyle[] = [
292 {
293 color: t.palette.contrast_800,
294 },
295 ]
296 const focus: TextStyle[] = [
297 {
298 color: t.palette.primary_500,
299 },
300 ]
301 const errorHover: TextStyle[] = [
302 {
303 color: t.palette.negative_500,
304 },
305 ]
306 const errorFocus: TextStyle[] = [
307 {
308 color: t.palette.negative_500,
309 },
310 ]
311
312 return {
313 hover,
314 focus,
315 errorHover,
316 errorFocus,
317 }
318 }, [t])
319
320 return (
321 <View style={[a.z_20, a.pr_xs]}>
322 <Comp
323 size="md"
324 style={[
325 {color: t.palette.contrast_500, pointerEvents: 'none', flexShrink: 0},
326 ctx.hovered ? hover : {},
327 ctx.focused ? focus : {},
328 ctx.isInvalid && ctx.hovered ? errorHover : {},
329 ctx.isInvalid && ctx.focused ? errorFocus : {},
330 ]}
331 />
332 </View>
333 )
334}
335
336export function SuffixText({
337 children,
338 label,
339 accessibilityHint,
340 style,
341}: React.PropsWithChildren<
342 TextStyleProp & {
343 label: string
344 accessibilityHint?: AccessibilityProps['accessibilityHint']
345 }
346>) {
347 const t = useTheme()
348 const ctx = useContext(Context)
349 return (
350 <Text
351 accessibilityLabel={label}
352 accessibilityHint={accessibilityHint}
353 numberOfLines={1}
354 style={[
355 a.z_20,
356 a.pr_sm,
357 a.text_md,
358 t.atoms.text_contrast_medium,
359 a.pointer_events_none,
360 web([{marginTop: -2}, a.leading_snug]),
361 (ctx.hovered || ctx.focused) && {color: t.palette.contrast_800},
362 style,
363 ]}>
364 {children}
365 </Text>
366 )
367}
368
369export function GhostText({
370 children,
371 value,
372}: {
373 children: string
374 value: string
375}) {
376 const t = useTheme()
377 // eslint-disable-next-line bsky-internal/avoid-unwrapped-text
378 return (
379 <View
380 style={[
381 a.pointer_events_none,
382 a.absolute,
383 a.z_10,
384 {
385 paddingLeft: platform({
386 native:
387 // input padding
388 tokens.space.md +
389 // icon
390 tokens.space.xl +
391 // icon padding
392 tokens.space.xs +
393 // text input padding
394 tokens.space.xs,
395 web:
396 // icon
397 tokens.space.xl +
398 // icon padding
399 tokens.space.xs +
400 // text input padding
401 tokens.space.xs,
402 }),
403 },
404 web(a.pr_md),
405 a.overflow_hidden,
406 a.max_w_full,
407 ]}
408 aria-hidden={true}
409 accessibilityElementsHidden
410 importantForAccessibility="no-hide-descendants">
411 <Text
412 style={[
413 {color: 'transparent'},
414 a.text_md,
415 {lineHeight: a.text_md.fontSize * 1.1875},
416 a.w_full,
417 ]}
418 numberOfLines={1}>
419 {children}
420 <Text
421 style={[
422 t.atoms.text_contrast_low,
423 a.text_md,
424 {lineHeight: a.text_md.fontSize * 1.1875},
425 ]}>
426 {value}
427 </Text>
428 </Text>
429 </View>
430 )
431}