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