mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at thread-bug 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 75 | Iterable<React.ReactElement | 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: select(t.name, { 278 light: t.palette.primary_50, 279 dim: t.palette.primary_100, 280 dark: t.palette.primary_100, 281 }), 282 }) 283 hoverStyles.push({ 284 backgroundColor: select(t.name, { 285 light: t.palette.primary_100, 286 dim: t.palette.primary_200, 287 dark: t.palette.primary_200, 288 }), 289 }) 290 } else { 291 baseStyles.push({ 292 backgroundColor: select(t.name, { 293 light: t.palette.primary_25, 294 dim: t.palette.primary_50, 295 dark: t.palette.primary_50, 296 }), 297 }) 298 } 299 } else if (color === 'negative_subtle') { 300 if (!disabled) { 301 baseStyles.push({ 302 backgroundColor: select(t.name, { 303 light: t.palette.negative_50, 304 dim: t.palette.negative_100, 305 dark: t.palette.negative_100, 306 }), 307 }) 308 hoverStyles.push({ 309 backgroundColor: select(t.name, { 310 light: t.palette.negative_100, 311 dim: t.palette.negative_200, 312 dark: t.palette.negative_200, 313 }), 314 }) 315 } else { 316 baseStyles.push({ 317 backgroundColor: select(t.name, { 318 light: t.palette.negative_25, 319 dim: t.palette.negative_50, 320 dark: t.palette.negative_50, 321 }), 322 }) 323 } 324 } 325 } else { 326 /* 327 * BEGIN DEPRECATED STYLES 328 */ 329 if (color === 'primary') { 330 if (variant === 'outline') { 331 baseStyles.push(a.border, t.atoms.bg, { 332 borderWidth: 1, 333 }) 334 335 if (!disabled) { 336 baseStyles.push(a.border, { 337 borderColor: t.palette.primary_500, 338 }) 339 hoverStyles.push(a.border, { 340 backgroundColor: t.palette.primary_50, 341 }) 342 } else { 343 baseStyles.push(a.border, { 344 borderColor: t.palette.primary_200, 345 }) 346 } 347 } else if (variant === 'ghost') { 348 if (!disabled) { 349 baseStyles.push(t.atoms.bg) 350 hoverStyles.push({ 351 backgroundColor: t.palette.primary_100, 352 }) 353 } 354 } 355 } else if (color === 'secondary') { 356 if (variant === 'outline') { 357 baseStyles.push(a.border, t.atoms.bg, { 358 borderWidth: 1, 359 }) 360 361 if (!disabled) { 362 baseStyles.push(a.border, { 363 borderColor: t.palette.contrast_300, 364 }) 365 hoverStyles.push(t.atoms.bg_contrast_50) 366 } else { 367 baseStyles.push(a.border, { 368 borderColor: t.palette.contrast_200, 369 }) 370 } 371 } else if (variant === 'ghost') { 372 if (!disabled) { 373 baseStyles.push(t.atoms.bg) 374 hoverStyles.push({ 375 backgroundColor: t.palette.contrast_25, 376 }) 377 } 378 } 379 } else if (color === 'secondary_inverted') { 380 if (variant === 'outline') { 381 baseStyles.push(a.border, t.atoms.bg, { 382 borderWidth: 1, 383 }) 384 385 if (!disabled) { 386 baseStyles.push(a.border, { 387 borderColor: t.palette.contrast_300, 388 }) 389 hoverStyles.push(t.atoms.bg_contrast_50) 390 } else { 391 baseStyles.push(a.border, { 392 borderColor: t.palette.contrast_200, 393 }) 394 } 395 } else if (variant === 'ghost') { 396 if (!disabled) { 397 baseStyles.push(t.atoms.bg) 398 hoverStyles.push({ 399 backgroundColor: t.palette.contrast_25, 400 }) 401 } 402 } 403 } else if (color === 'negative') { 404 if (variant === 'outline') { 405 baseStyles.push(a.border, t.atoms.bg, { 406 borderWidth: 1, 407 }) 408 409 if (!disabled) { 410 baseStyles.push(a.border, { 411 borderColor: t.palette.negative_500, 412 }) 413 hoverStyles.push(a.border, { 414 backgroundColor: t.palette.negative_50, 415 }) 416 } else { 417 baseStyles.push(a.border, { 418 borderColor: t.palette.negative_200, 419 }) 420 } 421 } else if (variant === 'ghost') { 422 if (!disabled) { 423 baseStyles.push(t.atoms.bg) 424 hoverStyles.push({ 425 backgroundColor: t.palette.negative_100, 426 }) 427 } 428 } 429 } else if (color === 'negative_subtle') { 430 if (variant === 'outline') { 431 baseStyles.push(a.border, t.atoms.bg, { 432 borderWidth: 1, 433 }) 434 435 if (!disabled) { 436 baseStyles.push(a.border, { 437 borderColor: t.palette.negative_500, 438 }) 439 hoverStyles.push(a.border, { 440 backgroundColor: t.palette.negative_50, 441 }) 442 } else { 443 baseStyles.push(a.border, { 444 borderColor: t.palette.negative_200, 445 }) 446 } 447 } else if (variant === 'ghost') { 448 if (!disabled) { 449 baseStyles.push(t.atoms.bg) 450 hoverStyles.push({ 451 backgroundColor: t.palette.negative_100, 452 }) 453 } 454 } 455 } 456 /* 457 * END DEPRECATED STYLES 458 */ 459 } 460 461 if (shape === 'default') { 462 if (size === 'large') { 463 baseStyles.push({ 464 paddingVertical: 12, 465 paddingHorizontal: 25, 466 borderRadius: 10, 467 gap: 3, 468 }) 469 } else if (size === 'small') { 470 baseStyles.push({ 471 paddingVertical: 8, 472 paddingHorizontal: 13, 473 borderRadius: 8, 474 gap: 3, 475 }) 476 } else if (size === 'tiny') { 477 baseStyles.push({ 478 paddingVertical: 5, 479 paddingHorizontal: 9, 480 borderRadius: 6, 481 gap: 2, 482 }) 483 } 484 } else if (shape === 'round' || shape === 'square') { 485 /* 486 * These sizes match the actual rendered size on screen, based on 487 * Chrome's web inspector 488 */ 489 if (size === 'large') { 490 if (shape === 'round') { 491 baseStyles.push({height: 44, width: 44}) 492 } else { 493 baseStyles.push({height: 44, width: 44}) 494 } 495 } else if (size === 'small') { 496 if (shape === 'round') { 497 baseStyles.push({height: 33, width: 33}) 498 } else { 499 baseStyles.push({height: 33, width: 33}) 500 } 501 } else if (size === 'tiny') { 502 if (shape === 'round') { 503 baseStyles.push({height: 25, width: 25}) 504 } else { 505 baseStyles.push({height: 25, width: 25}) 506 } 507 } 508 509 if (shape === 'round') { 510 baseStyles.push(a.rounded_full) 511 } else if (shape === 'square') { 512 if (size === 'tiny') { 513 baseStyles.push({ 514 borderRadius: 6, 515 }) 516 } else { 517 baseStyles.push(a.rounded_sm) 518 } 519 } 520 } 521 522 return { 523 baseStyles, 524 hoverStyles, 525 } 526 }, [t, variant, color, size, shape, disabled]) 527 528 const context = React.useMemo<ButtonContext>( 529 () => ({ 530 ...state, 531 variant, 532 color, 533 size, 534 disabled: disabled || false, 535 }), 536 [state, variant, color, size, disabled], 537 ) 538 539 const flattenedBaseStyles = flatten([baseStyles, style]) 540 541 return ( 542 <PressableComponent 543 role="button" 544 accessibilityHint={undefined} // optional 545 {...rest} 546 // @ts-ignore - this will always be a pressable 547 ref={ref} 548 aria-label={label} 549 aria-pressed={state.pressed} 550 accessibilityLabel={label} 551 disabled={disabled || false} 552 accessibilityState={{ 553 disabled: disabled || false, 554 }} 555 style={[ 556 a.flex_row, 557 a.align_center, 558 a.justify_center, 559 a.curve_continuous, 560 flattenedBaseStyles, 561 ...(state.hovered || state.pressed 562 ? [hoverStyles, flatten(hoverStyleProp)] 563 : []), 564 ]} 565 onPressIn={onPressIn} 566 onPressOut={onPressOut} 567 onHoverIn={onHoverIn} 568 onHoverOut={onHoverOut} 569 onFocus={onFocus} 570 onBlur={onBlur}> 571 <Context.Provider value={context}> 572 {typeof children === 'function' ? children(context) : children} 573 </Context.Provider> 574 </PressableComponent> 575 ) 576 }, 577) 578Button.displayName = 'Button' 579 580export function useSharedButtonTextStyles() { 581 const t = useTheme() 582 const {color, variant, disabled, size} = useButtonContext() 583 return React.useMemo(() => { 584 const baseStyles: TextStyle[] = [] 585 586 /* 587 * This is the happy path for new button styles, following the 588 * deprecation of `variant` prop. This redundant `variant` check is here 589 * just to make this handling easier to understand. 590 */ 591 if (variant === 'solid') { 592 if (color === 'primary') { 593 if (!disabled) { 594 baseStyles.push({color: t.palette.white}) 595 } else { 596 baseStyles.push({ 597 color: select(t.name, { 598 light: t.palette.white, 599 dim: t.atoms.text_inverted.color, 600 dark: t.atoms.text_inverted.color, 601 }), 602 }) 603 } 604 } else if (color === 'secondary') { 605 if (!disabled) { 606 baseStyles.push(t.atoms.text_contrast_medium) 607 } else { 608 baseStyles.push({ 609 color: t.palette.contrast_300, 610 }) 611 } 612 } else if (color === 'secondary_inverted') { 613 if (!disabled) { 614 baseStyles.push(t.atoms.text_inverted) 615 } else { 616 baseStyles.push({ 617 color: t.palette.contrast_300, 618 }) 619 } 620 } else if (color === 'negative') { 621 if (!disabled) { 622 baseStyles.push({color: t.palette.white}) 623 } else { 624 baseStyles.push({color: t.palette.negative_300}) 625 } 626 } else if (color === 'primary_subtle') { 627 if (!disabled) { 628 baseStyles.push({ 629 color: select(t.name, { 630 light: t.palette.primary_600, 631 dim: t.palette.primary_800, 632 dark: t.palette.primary_800, 633 }), 634 }) 635 } else { 636 baseStyles.push({ 637 color: select(t.name, { 638 light: t.palette.primary_200, 639 dim: t.palette.primary_200, 640 dark: t.palette.primary_200, 641 }), 642 }) 643 } 644 } else if (color === 'negative_subtle') { 645 if (!disabled) { 646 baseStyles.push({ 647 color: select(t.name, { 648 light: t.palette.negative_600, 649 dim: t.palette.negative_800, 650 dark: t.palette.negative_800, 651 }), 652 }) 653 } else { 654 baseStyles.push({ 655 color: select(t.name, { 656 light: t.palette.negative_200, 657 dim: t.palette.negative_200, 658 dark: t.palette.negative_200, 659 }), 660 }) 661 } 662 } 663 } else { 664 /* 665 * BEGIN DEPRECATED STYLES 666 */ 667 if (color === 'primary') { 668 if (variant === 'outline') { 669 if (!disabled) { 670 baseStyles.push({ 671 color: t.palette.primary_600, 672 }) 673 } else { 674 baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) 675 } 676 } else if (variant === 'ghost') { 677 if (!disabled) { 678 baseStyles.push({color: t.palette.primary_600}) 679 } else { 680 baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) 681 } 682 } 683 } else if (color === 'secondary') { 684 if (variant === 'outline') { 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 } else if (variant === 'ghost') { 695 if (!disabled) { 696 baseStyles.push({ 697 color: t.palette.contrast_600, 698 }) 699 } else { 700 baseStyles.push({ 701 color: t.palette.contrast_300, 702 }) 703 } 704 } 705 } else if (color === 'secondary_inverted') { 706 if (variant === 'outline') { 707 if (!disabled) { 708 baseStyles.push({ 709 color: t.palette.contrast_600, 710 }) 711 } else { 712 baseStyles.push({ 713 color: t.palette.contrast_300, 714 }) 715 } 716 } else if (variant === 'ghost') { 717 if (!disabled) { 718 baseStyles.push({ 719 color: t.palette.contrast_600, 720 }) 721 } else { 722 baseStyles.push({ 723 color: t.palette.contrast_300, 724 }) 725 } 726 } 727 } else if (color === 'negative') { 728 if (variant === 'outline') { 729 if (!disabled) { 730 baseStyles.push({color: t.palette.negative_400}) 731 } else { 732 baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 733 } 734 } else if (variant === 'ghost') { 735 if (!disabled) { 736 baseStyles.push({color: t.palette.negative_400}) 737 } else { 738 baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 739 } 740 } 741 } else if (color === 'negative_subtle') { 742 if (variant === 'outline') { 743 if (!disabled) { 744 baseStyles.push({color: t.palette.negative_400}) 745 } else { 746 baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 747 } 748 } else if (variant === 'ghost') { 749 if (!disabled) { 750 baseStyles.push({color: t.palette.negative_400}) 751 } else { 752 baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 753 } 754 } 755 } 756 /* 757 * END DEPRECATED STYLES 758 */ 759 } 760 761 if (size === 'large') { 762 baseStyles.push(a.text_md, a.leading_snug, a.font_medium) 763 } else if (size === 'small') { 764 baseStyles.push(a.text_sm, a.leading_snug, a.font_medium) 765 } else if (size === 'tiny') { 766 baseStyles.push(a.text_xs, a.leading_snug, a.font_medium) 767 } 768 769 return StyleSheet.flatten(baseStyles) 770 }, [t, variant, color, size, disabled]) 771} 772 773export function ButtonText({children, style, ...rest}: ButtonTextProps) { 774 const textStyles = useSharedButtonTextStyles() 775 776 return ( 777 <Text {...rest} style={[a.text_center, textStyles, style]}> 778 {children} 779 </Text> 780 ) 781} 782 783export function ButtonIcon({ 784 icon: Comp, 785 size, 786}: { 787 icon: React.ComponentType<SVGIconProps> 788 /** 789 * @deprecated no longer needed 790 */ 791 position?: 'left' | 'right' 792 size?: SVGIconProps['size'] 793}) { 794 const {size: buttonSize} = useButtonContext() 795 const textStyles = useSharedButtonTextStyles() 796 const {iconSize, iconContainerSize} = React.useMemo(() => { 797 /** 798 * Pre-set icon sizes for different button sizes 799 */ 800 const iconSizeShorthand = 801 size ?? 802 (({ 803 large: 'md', 804 small: 'sm', 805 tiny: 'xs', 806 }[buttonSize || 'small'] || 'sm') as Exclude< 807 SVGIconProps['size'], 808 undefined 809 >) 810 811 /* 812 * Copied here from icons/common.tsx so we can tweak if we need to, but 813 * also so that we can calculate transforms. 814 */ 815 const iconSize = { 816 xs: 12, 817 sm: 16, 818 md: 18, 819 lg: 24, 820 xl: 28, 821 '2xl': 32, 822 }[iconSizeShorthand] 823 824 /* 825 * Goal here is to match rendered text size so that different size icons 826 * don't increase button size 827 */ 828 const iconContainerSize = { 829 large: 20, 830 small: 17, 831 tiny: 15, 832 }[buttonSize || 'small'] 833 834 return { 835 iconSize, 836 iconContainerSize, 837 } 838 }, [buttonSize, size]) 839 840 return ( 841 <View 842 style={[ 843 a.z_20, 844 { 845 width: iconContainerSize, 846 height: iconContainerSize, 847 }, 848 ]}> 849 <View 850 style={[ 851 a.absolute, 852 { 853 width: iconSize, 854 height: iconSize, 855 top: '50%', 856 left: '50%', 857 transform: [ 858 { 859 translateX: (iconSize / 2) * -1, 860 }, 861 { 862 translateY: (iconSize / 2) * -1, 863 }, 864 ], 865 }, 866 ]}> 867 <Comp 868 width={iconSize} 869 style={[ 870 { 871 color: textStyles.color, 872 pointerEvents: 'none', 873 }, 874 ]} 875 /> 876 </View> 877 </View> 878 ) 879}