Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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