An ATproto social media client -- with an independent Appview.
at main 24 kB view raw
1import React from 'react' 2import { 3 type AccessibilityProps, 4 type GestureResponderEvent, 5 type MouseEvent, 6 type NativeSyntheticEvent, 7 Pressable, 8 type PressableProps, 9 type StyleProp, 10 StyleSheet, 11 type TargetedEvent, 12 type TextProps, 13 type TextStyle, 14 View, 15 type ViewStyle, 16} from 'react-native' 17 18import {atoms as a, flatten, select, useTheme} from '#/alf' 19import {type Props as SVGIconProps} from '#/components/icons/common' 20import {Text} from '#/components/Typography' 21 22/** 23 * The `Button` component, and some extensions of it like `Link` are intended 24 * to be generic and therefore apply no styles by default. These `VariantProps` 25 * are what control the `Button`'s presentation, and are intended only use cases where the buttons appear as, well, buttons. 26 * 27 * If `Button` or an extension of it are used for other compound components, use this property to avoid misuse of these variant props further down the line. 28 * 29 * @example 30 * type MyComponentProps = Omit<ButtonProps, UninheritableButtonProps> & {...} 31 */ 32export type UninheritableButtonProps = 'variant' | 'color' | 'size' | 'shape' 33 34export type ButtonVariant = 'solid' | 'outline' | 'ghost' 35export type ButtonColor = 36 | 'primary' 37 | 'secondary' 38 | 'secondary_inverted' 39 | 'negative' 40 | 'primary_subtle' 41 | 'negative_subtle' 42export type ButtonSize = 'tiny' | 'small' | 'large' 43export type ButtonShape = 'round' | 'square' | 'default' 44export type VariantProps = { 45 /** 46 * The style variation of the button 47 * @deprecated Use `color` instead. 48 */ 49 variant?: ButtonVariant 50 /** 51 * The color of the button 52 */ 53 color?: ButtonColor 54 /** 55 * The size of the button 56 */ 57 size?: ButtonSize 58 /** 59 * The shape of the button 60 */ 61 shape?: ButtonShape 62} 63 64export type ButtonState = { 65 hovered: boolean 66 focused: boolean 67 pressed: boolean 68 disabled: boolean 69} 70 71export type ButtonContext = VariantProps & ButtonState 72 73type NonTextElements = 74 | React.ReactElement<any> 75 | Iterable<React.ReactElement<any> | null | undefined | boolean> 76 77export type ButtonProps = Pick< 78 PressableProps, 79 | 'disabled' 80 | 'onPress' 81 | 'testID' 82 | 'onLongPress' 83 | 'hitSlop' 84 | 'onHoverIn' 85 | 'onHoverOut' 86 | 'onPressIn' 87 | 'onPressOut' 88 | 'onFocus' 89 | 'onBlur' 90> & 91 AccessibilityProps & 92 VariantProps & { 93 testID?: string 94 /** 95 * For a11y, try to make this descriptive and clear 96 */ 97 label: string 98 style?: StyleProp<ViewStyle> 99 hoverStyle?: StyleProp<ViewStyle> 100 children: NonTextElements | ((context: ButtonContext) => NonTextElements) 101 PressableComponent?: React.ComponentType<PressableProps> 102 } 103 104export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} 105 106const Context = React.createContext<VariantProps & ButtonState>({ 107 hovered: false, 108 focused: false, 109 pressed: false, 110 disabled: false, 111}) 112Context.displayName = 'ButtonContext' 113 114export function useButtonContext() { 115 return React.useContext(Context) 116} 117 118export const Button = React.forwardRef<View, ButtonProps>( 119 ( 120 { 121 children, 122 variant, 123 color, 124 size, 125 shape = 'default', 126 label, 127 disabled = false, 128 style, 129 hoverStyle: hoverStyleProp, 130 PressableComponent = Pressable, 131 onPressIn: onPressInOuter, 132 onPressOut: onPressOutOuter, 133 onHoverIn: onHoverInOuter, 134 onHoverOut: onHoverOutOuter, 135 onFocus: onFocusOuter, 136 onBlur: onBlurOuter, 137 ...rest 138 }, 139 ref, 140 ) => { 141 /** 142 * The `variant` prop is deprecated in favor of simply specifying `color`. 143 * If a `color` is set, then we want to use the existing codepaths for 144 * "solid" buttons. This is to maintain backwards compatibility. 145 */ 146 if (!variant && color) { 147 variant = 'solid' 148 } 149 150 const t = useTheme() 151 const [state, setState] = React.useState({ 152 pressed: false, 153 hovered: false, 154 focused: false, 155 }) 156 157 const onPressIn = React.useCallback( 158 (e: GestureResponderEvent) => { 159 setState(s => ({ 160 ...s, 161 pressed: true, 162 })) 163 onPressInOuter?.(e) 164 }, 165 [setState, onPressInOuter], 166 ) 167 const onPressOut = React.useCallback( 168 (e: GestureResponderEvent) => { 169 setState(s => ({ 170 ...s, 171 pressed: false, 172 })) 173 onPressOutOuter?.(e) 174 }, 175 [setState, onPressOutOuter], 176 ) 177 const onHoverIn = React.useCallback( 178 (e: MouseEvent) => { 179 setState(s => ({ 180 ...s, 181 hovered: true, 182 })) 183 onHoverInOuter?.(e) 184 }, 185 [setState, onHoverInOuter], 186 ) 187 const onHoverOut = React.useCallback( 188 (e: MouseEvent) => { 189 setState(s => ({ 190 ...s, 191 hovered: false, 192 })) 193 onHoverOutOuter?.(e) 194 }, 195 [setState, onHoverOutOuter], 196 ) 197 const onFocus = React.useCallback( 198 (e: NativeSyntheticEvent<TargetedEvent>) => { 199 setState(s => ({ 200 ...s, 201 focused: true, 202 })) 203 onFocusOuter?.(e) 204 }, 205 [setState, onFocusOuter], 206 ) 207 const onBlur = React.useCallback( 208 (e: NativeSyntheticEvent<TargetedEvent>) => { 209 setState(s => ({ 210 ...s, 211 focused: false, 212 })) 213 onBlurOuter?.(e) 214 }, 215 [setState, onBlurOuter], 216 ) 217 218 const {baseStyles, hoverStyles} = React.useMemo(() => { 219 const baseStyles: ViewStyle[] = [] 220 const hoverStyles: ViewStyle[] = [] 221 222 /* 223 * This is the happy path for new button styles, following the 224 * deprecation of `variant` prop. This redundant `variant` check is here 225 * just to make this handling easier to understand. 226 */ 227 if (variant === 'solid') { 228 if (color === 'primary') { 229 if (!disabled) { 230 baseStyles.push({ 231 backgroundColor: t.palette.primary_500, 232 }) 233 hoverStyles.push({ 234 backgroundColor: t.palette.primary_600, 235 }) 236 } else { 237 baseStyles.push({ 238 backgroundColor: t.palette.primary_200, 239 }) 240 } 241 } else if (color === 'secondary') { 242 if (!disabled) { 243 baseStyles.push(t.atoms.bg_contrast_25) 244 hoverStyles.push(t.atoms.bg_contrast_100) 245 } else { 246 baseStyles.push(t.atoms.bg_contrast_50) 247 } 248 } else if (color === 'secondary_inverted') { 249 if (!disabled) { 250 baseStyles.push({ 251 backgroundColor: t.palette.contrast_900, 252 }) 253 hoverStyles.push({ 254 backgroundColor: t.palette.contrast_975, 255 }) 256 } else { 257 baseStyles.push({ 258 backgroundColor: t.palette.contrast_600, 259 }) 260 } 261 } else if (color === 'negative') { 262 if (!disabled) { 263 baseStyles.push({ 264 backgroundColor: t.palette.negative_500, 265 }) 266 hoverStyles.push({ 267 backgroundColor: t.palette.negative_600, 268 }) 269 } else { 270 baseStyles.push({ 271 backgroundColor: t.palette.negative_700, 272 }) 273 } 274 } else if (color === 'primary_subtle') { 275 if (!disabled) { 276 baseStyles.push({ 277 backgroundColor: t.palette.primary_50, 278 }) 279 hoverStyles.push({ 280 backgroundColor: t.palette.primary_100, 281 }) 282 } else { 283 baseStyles.push({ 284 backgroundColor: select(t.name, { 285 light: t.palette.primary_25, 286 dim: t.palette.primary_50, 287 dark: t.palette.primary_50, 288 }), 289 }) 290 } 291 } else if (color === 'negative_subtle') { 292 if (!disabled) { 293 baseStyles.push({ 294 backgroundColor: t.palette.negative_50, 295 }) 296 hoverStyles.push({ 297 backgroundColor: t.palette.negative_100, 298 }) 299 } else { 300 baseStyles.push({ 301 backgroundColor: select(t.name, { 302 light: t.palette.negative_25, 303 dim: t.palette.negative_50, 304 dark: t.palette.negative_50, 305 }), 306 }) 307 } 308 } 309 } else { 310 /* 311 * BEGIN DEPRECATED STYLES 312 */ 313 if (color === 'primary') { 314 if (variant === 'outline') { 315 baseStyles.push(a.border, t.atoms.bg, { 316 borderWidth: 1, 317 }) 318 319 if (!disabled) { 320 baseStyles.push(a.border, { 321 borderColor: t.palette.primary_500, 322 }) 323 hoverStyles.push(a.border, { 324 backgroundColor: t.palette.primary_50, 325 }) 326 } else { 327 baseStyles.push(a.border, { 328 borderColor: t.palette.primary_200, 329 }) 330 } 331 } else if (variant === 'ghost') { 332 if (!disabled) { 333 baseStyles.push(t.atoms.bg) 334 hoverStyles.push({ 335 backgroundColor: t.palette.primary_100, 336 }) 337 } 338 } 339 } else if (color === 'secondary') { 340 if (variant === 'outline') { 341 baseStyles.push(a.border, t.atoms.bg, { 342 borderWidth: 1, 343 }) 344 345 if (!disabled) { 346 baseStyles.push(a.border, { 347 borderColor: t.palette.contrast_300, 348 }) 349 hoverStyles.push(t.atoms.bg_contrast_50) 350 } else { 351 baseStyles.push(a.border, { 352 borderColor: t.palette.contrast_200, 353 }) 354 } 355 } else if (variant === 'ghost') { 356 if (!disabled) { 357 baseStyles.push(t.atoms.bg) 358 hoverStyles.push({ 359 backgroundColor: t.palette.contrast_25, 360 }) 361 } 362 } 363 } else if (color === 'secondary_inverted') { 364 if (variant === 'outline') { 365 baseStyles.push(a.border, t.atoms.bg, { 366 borderWidth: 1, 367 }) 368 369 if (!disabled) { 370 baseStyles.push(a.border, { 371 borderColor: t.palette.contrast_300, 372 }) 373 hoverStyles.push(t.atoms.bg_contrast_50) 374 } else { 375 baseStyles.push(a.border, { 376 borderColor: t.palette.contrast_200, 377 }) 378 } 379 } else if (variant === 'ghost') { 380 if (!disabled) { 381 baseStyles.push(t.atoms.bg) 382 hoverStyles.push({ 383 backgroundColor: t.palette.contrast_25, 384 }) 385 } 386 } 387 } else if (color === 'negative') { 388 if (variant === 'outline') { 389 baseStyles.push(a.border, t.atoms.bg, { 390 borderWidth: 1, 391 }) 392 393 if (!disabled) { 394 baseStyles.push(a.border, { 395 borderColor: t.palette.negative_500, 396 }) 397 hoverStyles.push(a.border, { 398 backgroundColor: t.palette.negative_50, 399 }) 400 } else { 401 baseStyles.push(a.border, { 402 borderColor: t.palette.negative_200, 403 }) 404 } 405 } else if (variant === 'ghost') { 406 if (!disabled) { 407 baseStyles.push(t.atoms.bg) 408 hoverStyles.push({ 409 backgroundColor: t.palette.negative_100, 410 }) 411 } 412 } 413 } else if (color === 'negative_subtle') { 414 if (variant === 'outline') { 415 baseStyles.push(a.border, t.atoms.bg, { 416 borderWidth: 1, 417 }) 418 419 if (!disabled) { 420 baseStyles.push(a.border, { 421 borderColor: t.palette.negative_500, 422 }) 423 hoverStyles.push(a.border, { 424 backgroundColor: t.palette.negative_50, 425 }) 426 } else { 427 baseStyles.push(a.border, { 428 borderColor: t.palette.negative_200, 429 }) 430 } 431 } else if (variant === 'ghost') { 432 if (!disabled) { 433 baseStyles.push(t.atoms.bg) 434 hoverStyles.push({ 435 backgroundColor: t.palette.negative_100, 436 }) 437 } 438 } 439 } 440 /* 441 * END DEPRECATED STYLES 442 */ 443 } 444 445 if (shape === 'default') { 446 if (size === 'large') { 447 baseStyles.push({ 448 paddingVertical: 12, 449 paddingHorizontal: 25, 450 borderRadius: 10, 451 gap: 3, 452 }) 453 } else if (size === 'small') { 454 baseStyles.push({ 455 paddingVertical: 8, 456 paddingHorizontal: 13, 457 borderRadius: 8, 458 gap: 3, 459 }) 460 } else if (size === 'tiny') { 461 baseStyles.push({ 462 paddingVertical: 5, 463 paddingHorizontal: 9, 464 borderRadius: 6, 465 gap: 2, 466 }) 467 } 468 } else if (shape === 'round' || shape === 'square') { 469 /* 470 * These sizes match the actual rendered size on screen, based on 471 * Chrome's web inspector 472 */ 473 if (size === 'large') { 474 if (shape === 'round') { 475 baseStyles.push({height: 44, width: 44}) 476 } else { 477 baseStyles.push({height: 44, width: 44}) 478 } 479 } else if (size === 'small') { 480 if (shape === 'round') { 481 baseStyles.push({height: 33, width: 33}) 482 } else { 483 baseStyles.push({height: 33, width: 33}) 484 } 485 } else if (size === 'tiny') { 486 if (shape === 'round') { 487 baseStyles.push({height: 25, width: 25}) 488 } else { 489 baseStyles.push({height: 25, width: 25}) 490 } 491 } 492 493 if (shape === 'round') { 494 baseStyles.push(a.rounded_full) 495 } else if (shape === 'square') { 496 if (size === 'tiny') { 497 baseStyles.push({ 498 borderRadius: 6, 499 }) 500 } else { 501 baseStyles.push(a.rounded_sm) 502 } 503 } 504 } 505 506 return { 507 baseStyles, 508 hoverStyles, 509 } 510 }, [t, variant, color, size, shape, disabled]) 511 512 const context = React.useMemo<ButtonContext>( 513 () => ({ 514 ...state, 515 variant, 516 color, 517 size, 518 disabled: disabled || false, 519 }), 520 [state, variant, color, size, disabled], 521 ) 522 523 const flattenedBaseStyles = flatten([baseStyles, style]) 524 525 return ( 526 <PressableComponent 527 role="button" 528 accessibilityHint={undefined} // optional 529 {...rest} 530 // @ts-ignore - this will always be a pressable 531 ref={ref} 532 aria-label={label} 533 aria-pressed={state.pressed} 534 accessibilityLabel={label} 535 disabled={disabled || false} 536 accessibilityState={{ 537 disabled: disabled || false, 538 }} 539 style={[ 540 a.flex_row, 541 a.align_center, 542 a.justify_center, 543 a.curve_continuous, 544 flattenedBaseStyles, 545 ...(state.hovered || state.pressed 546 ? [hoverStyles, flatten(hoverStyleProp)] 547 : []), 548 ]} 549 onPressIn={onPressIn} 550 onPressOut={onPressOut} 551 onHoverIn={onHoverIn} 552 onHoverOut={onHoverOut} 553 onFocus={onFocus} 554 onBlur={onBlur}> 555 <Context.Provider value={context}> 556 {typeof children === 'function' ? children(context) : children} 557 </Context.Provider> 558 </PressableComponent> 559 ) 560 }, 561) 562Button.displayName = 'Button' 563 564export function useSharedButtonTextStyles() { 565 const t = useTheme() 566 const {color, variant, disabled, size} = useButtonContext() 567 return React.useMemo(() => { 568 const baseStyles: TextStyle[] = [] 569 570 /* 571 * This is the happy path for new button styles, following the 572 * deprecation of `variant` prop. This redundant `variant` check is here 573 * just to make this handling easier to understand. 574 */ 575 if (variant === 'solid') { 576 if (color === 'primary') { 577 if (!disabled) { 578 baseStyles.push(t.atoms.text_inverted) 579 } else { 580 baseStyles.push({ 581 color: select(t.name, { 582 light: t.palette.white, 583 dim: t.atoms.text_inverted.color, 584 dark: t.atoms.text_inverted.color, 585 }), 586 }) 587 } 588 } else if (color === 'secondary') { 589 if (!disabled) { 590 baseStyles.push(t.atoms.text_contrast_medium) 591 } else { 592 baseStyles.push({ 593 color: t.palette.contrast_300, 594 }) 595 } 596 } else if (color === 'secondary_inverted') { 597 if (!disabled) { 598 baseStyles.push(t.atoms.text_inverted) 599 } else { 600 baseStyles.push({ 601 color: t.palette.contrast_300, 602 }) 603 } 604 } else if (color === 'negative') { 605 if (!disabled) { 606 baseStyles.push({color: t.palette.white}) 607 } else { 608 baseStyles.push({color: t.palette.negative_300}) 609 } 610 } else if (color === 'primary_subtle') { 611 if (!disabled) { 612 baseStyles.push({ 613 color: t.palette.primary_600, 614 }) 615 } else { 616 baseStyles.push({ 617 color: t.palette.primary_200, 618 }) 619 } 620 } else if (color === 'negative_subtle') { 621 if (!disabled) { 622 baseStyles.push({ 623 color: t.palette.negative_600, 624 }) 625 } else { 626 baseStyles.push({ 627 color: t.palette.negative_200, 628 }) 629 } 630 } 631 } else { 632 /* 633 * BEGIN DEPRECATED STYLES 634 */ 635 if (color === 'primary') { 636 if (variant === 'outline') { 637 if (!disabled) { 638 baseStyles.push({ 639 color: t.palette.primary_600, 640 }) 641 } else { 642 baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) 643 } 644 } else if (variant === 'ghost') { 645 if (!disabled) { 646 baseStyles.push({color: t.palette.primary_600}) 647 } else { 648 baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) 649 } 650 } 651 } else if (color === 'secondary') { 652 if (variant === 'outline') { 653 if (!disabled) { 654 baseStyles.push({ 655 color: t.palette.contrast_600, 656 }) 657 } else { 658 baseStyles.push({ 659 color: t.palette.contrast_300, 660 }) 661 } 662 } else if (variant === 'ghost') { 663 if (!disabled) { 664 baseStyles.push({ 665 color: t.palette.contrast_600, 666 }) 667 } else { 668 baseStyles.push({ 669 color: t.palette.contrast_300, 670 }) 671 } 672 } 673 } else if (color === 'secondary_inverted') { 674 if (variant === 'outline') { 675 if (!disabled) { 676 baseStyles.push({ 677 color: t.palette.contrast_600, 678 }) 679 } else { 680 baseStyles.push({ 681 color: t.palette.contrast_300, 682 }) 683 } 684 } else if (variant === 'ghost') { 685 if (!disabled) { 686 baseStyles.push({ 687 color: t.palette.contrast_600, 688 }) 689 } else { 690 baseStyles.push({ 691 color: t.palette.contrast_300, 692 }) 693 } 694 } 695 } else if (color === 'negative') { 696 if (variant === 'outline') { 697 if (!disabled) { 698 baseStyles.push({color: t.palette.negative_400}) 699 } else { 700 baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 701 } 702 } else if (variant === 'ghost') { 703 if (!disabled) { 704 baseStyles.push({color: t.palette.negative_400}) 705 } else { 706 baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 707 } 708 } 709 } else if (color === 'negative_subtle') { 710 if (variant === 'outline') { 711 if (!disabled) { 712 baseStyles.push({color: t.palette.negative_400}) 713 } else { 714 baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 715 } 716 } else if (variant === 'ghost') { 717 if (!disabled) { 718 baseStyles.push({color: t.palette.negative_400}) 719 } else { 720 baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 721 } 722 } 723 } 724 /* 725 * END DEPRECATED STYLES 726 */ 727 } 728 729 if (size === 'large') { 730 baseStyles.push(a.text_md, a.leading_snug, a.font_medium) 731 } else if (size === 'small') { 732 baseStyles.push(a.text_sm, a.leading_snug, a.font_medium) 733 } else if (size === 'tiny') { 734 baseStyles.push(a.text_xs, a.leading_snug, a.font_bold) 735 } 736 737 return StyleSheet.flatten(baseStyles) 738 }, [t, variant, color, size, disabled]) 739} 740 741export function ButtonText({children, style, ...rest}: ButtonTextProps) { 742 const textStyles = useSharedButtonTextStyles() 743 744 return ( 745 <Text {...rest} style={[a.text_center, textStyles, style]}> 746 {children} 747 </Text> 748 ) 749} 750 751export function ButtonIcon({ 752 icon: Comp, 753 size, 754}: { 755 icon: React.ComponentType<SVGIconProps> 756 /** 757 * @deprecated no longer needed 758 */ 759 position?: 'left' | 'right' 760 size?: SVGIconProps['size'] 761}) { 762 const {size: buttonSize} = useButtonContext() 763 const textStyles = useSharedButtonTextStyles() 764 const {iconSize, iconContainerSize} = React.useMemo(() => { 765 /** 766 * Pre-set icon sizes for different button sizes 767 */ 768 const iconSizeShorthand = 769 size ?? 770 (({ 771 large: 'md', 772 small: 'sm', 773 tiny: 'xs', 774 }[buttonSize || 'small'] || 'sm') as Exclude< 775 SVGIconProps['size'], 776 undefined 777 >) 778 779 /* 780 * Copied here from icons/common.tsx so we can tweak if we need to, but 781 * also so that we can calculate transforms. 782 */ 783 const iconSize = { 784 xs: 12, 785 sm: 16, 786 md: 18, 787 lg: 24, 788 xl: 28, 789 '2xl': 32, 790 }[iconSizeShorthand] 791 792 /* 793 * Goal here is to match rendered text size so that different size icons 794 * don't increase button size 795 */ 796 const iconContainerSize = { 797 large: 20, 798 small: 17, 799 tiny: 15, 800 }[buttonSize || 'small'] 801 802 return { 803 iconSize, 804 iconContainerSize, 805 } 806 }, [buttonSize, size]) 807 808 return ( 809 <View 810 style={[ 811 a.z_20, 812 { 813 width: iconContainerSize, 814 height: iconContainerSize, 815 }, 816 ]}> 817 <View 818 style={[ 819 a.absolute, 820 { 821 width: iconSize, 822 height: iconSize, 823 top: '50%', 824 left: '50%', 825 transform: [ 826 { 827 translateX: (iconSize / 2) * -1, 828 }, 829 { 830 translateY: (iconSize / 2) * -1, 831 }, 832 ], 833 }, 834 ]}> 835 <Comp 836 width={iconSize} 837 style={[ 838 { 839 color: textStyles.color, 840 pointerEvents: 'none', 841 }, 842 ]} 843 /> 844 </View> 845 </View> 846 ) 847} 848 849export type StackedButtonProps = Omit< 850 ButtonProps, 851 keyof VariantProps | 'children' 852> & 853 Pick<VariantProps, 'color'> & { 854 children: React.ReactNode 855 icon: React.ComponentType<SVGIconProps> 856 } 857 858export function StackedButton({children, ...props}: StackedButtonProps) { 859 return ( 860 <Button 861 {...props} 862 size="tiny" 863 style={[ 864 a.flex_col, 865 { 866 height: 72, 867 paddingHorizontal: 16, 868 borderRadius: 20, 869 gap: 4, 870 }, 871 props.style, 872 ]}> 873 <StackedButtonInnerText icon={props.icon}> 874 {children} 875 </StackedButtonInnerText> 876 </Button> 877 ) 878} 879 880function StackedButtonInnerText({ 881 children, 882 icon: Icon, 883}: Pick<StackedButtonProps, 'icon' | 'children'>) { 884 const textStyles = useSharedButtonTextStyles() 885 return ( 886 <> 887 <Icon width={24} fill={textStyles.color} /> 888 <ButtonText>{children}</ButtonText> 889 </> 890 ) 891}