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