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