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