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