Bluesky app fork with some witchin' additions 馃挮
at main 482 lines 12 kB view raw
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}