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