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