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