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