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