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