Bluesky app fork with some witchin' additions 馃挮
at main 26 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 type TargetedEvent, 11 type TextProps, 12 type TextStyle, 13 View, 14 type ViewStyle, 15} from 'react-native' 16 17import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 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' | 'rectangular' | '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 * - `default`: Pill shaped. Most buttons should use this shape. 62 * - `round`: Circular. For icon-only buttons. 63 * - `square`: Square. For icon-only buttons. 64 * - `rectangular`: Rectangular. Matches previous style, use when adjacent to form fields. 65 */ 66 shape?: ButtonShape 67} 68 69export type ButtonState = { 70 hovered: boolean 71 focused: boolean 72 pressed: boolean 73 disabled: boolean 74} 75 76export type ButtonContext = VariantProps & ButtonState 77 78type NonTextElements = 79 | React.ReactElement<any> 80 | Iterable<React.ReactElement<any> | null | undefined | boolean> 81 82export type ButtonProps = Pick< 83 PressableProps, 84 | 'disabled' 85 | 'onPress' 86 | 'testID' 87 | 'onLongPress' 88 | 'hitSlop' 89 | 'onHoverIn' 90 | 'onHoverOut' 91 | 'onPressIn' 92 | 'onPressOut' 93 | 'onFocus' 94 | 'onBlur' 95> & 96 AccessibilityProps & 97 VariantProps & { 98 testID?: string 99 /** 100 * For a11y, try to make this descriptive and clear 101 */ 102 label: string 103 style?: StyleProp<ViewStyle> 104 hoverStyle?: StyleProp<ViewStyle> 105 children: NonTextElements | ((context: ButtonContext) => NonTextElements) 106 PressableComponent?: React.ComponentType<PressableProps> 107 } 108 109export type ButtonTextProps = TextProps & 110 VariantProps & {disabled?: boolean; emoji?: boolean} 111 112const Context = React.createContext<VariantProps & ButtonState>({ 113 hovered: false, 114 focused: false, 115 pressed: false, 116 disabled: false, 117}) 118Context.displayName = 'ButtonContext' 119 120export function useButtonContext() { 121 return React.useContext(Context) 122} 123 124export const Button = React.forwardRef<View, ButtonProps>( 125 ( 126 { 127 children, 128 variant, 129 color, 130 size, 131 shape = 'default', 132 label, 133 disabled = false, 134 style, 135 hoverStyle: hoverStyleProp, 136 PressableComponent = Pressable, 137 onPressIn: onPressInOuter, 138 onPressOut: onPressOutOuter, 139 onHoverIn: onHoverInOuter, 140 onHoverOut: onHoverOutOuter, 141 onFocus: onFocusOuter, 142 onBlur: onBlurOuter, 143 ...rest 144 }, 145 ref, 146 ) => { 147 /** 148 * The `variant` prop is deprecated in favor of simply specifying `color`. 149 * If a `color` is set, then we want to use the existing codepaths for 150 * "solid" buttons. This is to maintain backwards compatibility. 151 */ 152 if (!variant && color) { 153 variant = 'solid' 154 } 155 156 const enableSquareButtons = useEnableSquareButtons() 157 158 const t = useTheme() 159 const [state, setState] = React.useState({ 160 pressed: false, 161 hovered: false, 162 focused: false, 163 }) 164 165 const onPressIn = React.useCallback( 166 (e: GestureResponderEvent) => { 167 setState(s => ({ 168 ...s, 169 pressed: true, 170 })) 171 onPressInOuter?.(e) 172 }, 173 [setState, onPressInOuter], 174 ) 175 const onPressOut = React.useCallback( 176 (e: GestureResponderEvent) => { 177 setState(s => ({ 178 ...s, 179 pressed: false, 180 })) 181 onPressOutOuter?.(e) 182 }, 183 [setState, onPressOutOuter], 184 ) 185 const onHoverIn = React.useCallback( 186 (e: MouseEvent) => { 187 setState(s => ({ 188 ...s, 189 hovered: true, 190 })) 191 onHoverInOuter?.(e) 192 }, 193 [setState, onHoverInOuter], 194 ) 195 const onHoverOut = React.useCallback( 196 (e: MouseEvent) => { 197 setState(s => ({ 198 ...s, 199 hovered: false, 200 })) 201 onHoverOutOuter?.(e) 202 }, 203 [setState, onHoverOutOuter], 204 ) 205 const onFocus = React.useCallback( 206 (e: NativeSyntheticEvent<TargetedEvent>) => { 207 setState(s => ({ 208 ...s, 209 focused: true, 210 })) 211 onFocusOuter?.(e) 212 }, 213 [setState, onFocusOuter], 214 ) 215 const onBlur = React.useCallback( 216 (e: NativeSyntheticEvent<TargetedEvent>) => { 217 setState(s => ({ 218 ...s, 219 focused: false, 220 })) 221 onBlurOuter?.(e) 222 }, 223 [setState, onBlurOuter], 224 ) 225 226 const {baseStyles, hoverStyles} = React.useMemo(() => { 227 const baseStyles: ViewStyle[] = [] 228 const hoverStyles: ViewStyle[] = [] 229 230 /* 231 * This is the happy path for new button styles, following the 232 * deprecation of `variant` prop. This redundant `variant` check is here 233 * just to make this handling easier to understand. 234 */ 235 if (variant === 'solid') { 236 if (color === 'primary') { 237 if (!disabled) { 238 baseStyles.push({ 239 backgroundColor: t.palette.primary_500, 240 }) 241 hoverStyles.push({ 242 backgroundColor: t.palette.primary_600, 243 }) 244 } else { 245 baseStyles.push({ 246 backgroundColor: t.palette.primary_200, 247 }) 248 } 249 } else if (color === 'secondary') { 250 if (!disabled) { 251 baseStyles.push(t.atoms.bg_contrast_50) 252 hoverStyles.push(t.atoms.bg_contrast_100) 253 } else { 254 baseStyles.push(t.atoms.bg_contrast_50) 255 } 256 } else if (color === 'secondary_inverted') { 257 if (!disabled) { 258 baseStyles.push({ 259 backgroundColor: t.palette.contrast_900, 260 }) 261 hoverStyles.push({ 262 backgroundColor: t.palette.contrast_975, 263 }) 264 } else { 265 baseStyles.push({ 266 backgroundColor: t.palette.contrast_600, 267 }) 268 } 269 } else if (color === 'negative') { 270 if (!disabled) { 271 baseStyles.push({ 272 backgroundColor: t.palette.negative_500, 273 }) 274 hoverStyles.push({ 275 backgroundColor: t.palette.negative_600, 276 }) 277 } else { 278 baseStyles.push({ 279 backgroundColor: t.palette.negative_700, 280 }) 281 } 282 } else if (color === 'primary_subtle') { 283 if (!disabled) { 284 baseStyles.push({ 285 backgroundColor: t.palette.primary_50, 286 }) 287 hoverStyles.push({ 288 backgroundColor: t.palette.primary_100, 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: t.palette.negative_50, 299 }) 300 hoverStyles.push({ 301 backgroundColor: t.palette.negative_100, 302 }) 303 } else { 304 baseStyles.push({ 305 backgroundColor: t.palette.negative_50, 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_50, 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_50, 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(enableSquareButtons ? a.rounded_sm : a.rounded_full, { 448 paddingVertical: 12, 449 paddingHorizontal: 24, 450 gap: 6, 451 }) 452 } else if (size === 'small') { 453 baseStyles.push(enableSquareButtons ? a.rounded_sm : a.rounded_full, { 454 paddingVertical: 8, 455 paddingHorizontal: 14, 456 gap: 5, 457 }) 458 } else if (size === 'tiny') { 459 baseStyles.push(enableSquareButtons ? a.rounded_sm : a.rounded_full, { 460 paddingVertical: 5, 461 paddingHorizontal: 10, 462 gap: 3, 463 }) 464 } 465 } else if (shape === 'rectangular') { 466 if (size === 'large') { 467 baseStyles.push({ 468 paddingVertical: 12, 469 paddingHorizontal: 25, 470 borderRadius: 10, 471 gap: 3, 472 }) 473 } else if (size === 'small') { 474 baseStyles.push({ 475 paddingVertical: 8, 476 paddingHorizontal: 13, 477 borderRadius: 8, 478 gap: 3, 479 }) 480 } else if (size === 'tiny') { 481 baseStyles.push({ 482 paddingVertical: 5, 483 paddingHorizontal: 9, 484 borderRadius: 6, 485 gap: 2, 486 }) 487 } 488 } else if (shape === 'round' || shape === 'square') { 489 /* 490 * These sizes match the actual rendered size on screen, based on 491 * Chrome's web inspector 492 */ 493 if (size === 'large') { 494 if (shape === 'round') { 495 baseStyles.push({height: 44, width: 44}) 496 } else { 497 baseStyles.push({height: 44, width: 44}) 498 } 499 } else if (size === 'small') { 500 if (shape === 'round') { 501 baseStyles.push({height: 33, width: 33}) 502 } else { 503 baseStyles.push({height: 33, width: 33}) 504 } 505 } else if (size === 'tiny') { 506 if (shape === 'round') { 507 baseStyles.push({height: 25, width: 25}) 508 } else { 509 baseStyles.push({height: 25, width: 25}) 510 } 511 } 512 513 if (shape === 'round') { 514 baseStyles.push(enableSquareButtons ? a.rounded_sm : a.rounded_full) 515 } else if (shape === 'square') { 516 if (size === 'tiny') { 517 baseStyles.push({ 518 borderRadius: 6, 519 }) 520 } else { 521 baseStyles.push(a.rounded_sm) 522 } 523 } 524 } 525 526 return { 527 baseStyles, 528 hoverStyles, 529 } 530 }, [t, variant, color, size, shape, disabled, enableSquareButtons]) 531 532 const context = React.useMemo<ButtonContext>( 533 () => ({ 534 ...state, 535 variant, 536 color, 537 size, 538 shape, 539 disabled: disabled || false, 540 }), 541 [state, variant, color, size, shape, disabled], 542 ) 543 544 return ( 545 <PressableComponent 546 role="button" 547 accessibilityHint={undefined} // optional 548 {...rest} 549 // @ts-ignore - this will always be a pressable 550 ref={ref} 551 aria-label={label} 552 aria-pressed={state.pressed} 553 accessibilityLabel={label} 554 disabled={disabled || false} 555 accessibilityState={{ 556 disabled: disabled || false, 557 }} 558 style={[ 559 a.flex_row, 560 a.align_center, 561 a.justify_center, 562 a.curve_continuous, 563 baseStyles, 564 style, 565 ...(state.hovered || state.pressed 566 ? [hoverStyles, hoverStyleProp] 567 : []), 568 ]} 569 onPressIn={onPressIn} 570 onPressOut={onPressOut} 571 onHoverIn={onHoverIn} 572 onHoverOut={onHoverOut} 573 onFocus={onFocus} 574 onBlur={onBlur}> 575 <Context.Provider value={context}> 576 {typeof children === 'function' ? children(context) : children} 577 </Context.Provider> 578 </PressableComponent> 579 ) 580 }, 581) 582Button.displayName = 'Button' 583 584export function useSharedButtonTextStyles() { 585 const t = useTheme() 586 const {color, variant, disabled, size} = useButtonContext() 587 return React.useMemo(() => { 588 const baseStyles: TextStyle[] = [] 589 590 /* 591 * This is the happy path for new button styles, following the 592 * deprecation of `variant` prop. This redundant `variant` check is here 593 * just to make this handling easier to understand. 594 */ 595 if (variant === 'solid') { 596 if (color === 'primary') { 597 if (!disabled) { 598 baseStyles.push({color: t.palette.white}) 599 } else { 600 baseStyles.push({ 601 color: select(t.name, { 602 light: t.palette.white, 603 dim: t.atoms.text_inverted.color, 604 dark: t.atoms.text_inverted.color, 605 }), 606 }) 607 } 608 } else if (color === 'secondary') { 609 if (!disabled) { 610 baseStyles.push(t.atoms.text_contrast_medium) 611 } else { 612 baseStyles.push({ 613 color: t.palette.contrast_300, 614 }) 615 } 616 } else if (color === 'secondary_inverted') { 617 if (!disabled) { 618 baseStyles.push(t.atoms.text_inverted) 619 } else { 620 baseStyles.push({ 621 color: t.palette.contrast_300, 622 }) 623 } 624 } else if (color === 'negative') { 625 if (!disabled) { 626 baseStyles.push({color: t.palette.white}) 627 } else { 628 baseStyles.push({color: t.palette.negative_300}) 629 } 630 } else if (color === 'primary_subtle') { 631 if (!disabled) { 632 baseStyles.push({ 633 color: t.palette.primary_600, 634 }) 635 } else { 636 baseStyles.push({ 637 color: t.palette.primary_200, 638 }) 639 } 640 } else if (color === 'negative_subtle') { 641 if (!disabled) { 642 baseStyles.push({ 643 color: t.palette.negative_600, 644 }) 645 } else { 646 baseStyles.push({ 647 color: t.palette.negative_200, 648 }) 649 } 650 } 651 } else { 652 /* 653 * BEGIN DEPRECATED STYLES 654 */ 655 if (color === 'primary') { 656 if (variant === 'outline') { 657 if (!disabled) { 658 baseStyles.push({ 659 color: t.palette.primary_600, 660 }) 661 } else { 662 baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) 663 } 664 } else if (variant === 'ghost') { 665 if (!disabled) { 666 baseStyles.push({color: t.palette.primary_600}) 667 } else { 668 baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) 669 } 670 } 671 } else if (color === 'secondary') { 672 if (variant === 'outline') { 673 if (!disabled) { 674 baseStyles.push({ 675 color: t.palette.contrast_600, 676 }) 677 } else { 678 baseStyles.push({ 679 color: t.palette.contrast_300, 680 }) 681 } 682 } else if (variant === 'ghost') { 683 if (!disabled) { 684 baseStyles.push({ 685 color: t.palette.contrast_600, 686 }) 687 } else { 688 baseStyles.push({ 689 color: t.palette.contrast_300, 690 }) 691 } 692 } 693 } else if (color === 'secondary_inverted') { 694 if (variant === 'outline') { 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 } else if (variant === 'ghost') { 705 if (!disabled) { 706 baseStyles.push({ 707 color: t.palette.contrast_600, 708 }) 709 } else { 710 baseStyles.push({ 711 color: t.palette.contrast_300, 712 }) 713 } 714 } 715 } else if (color === 'negative') { 716 if (variant === 'outline') { 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 } else if (variant === 'ghost') { 723 if (!disabled) { 724 baseStyles.push({color: t.palette.negative_400}) 725 } else { 726 baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 727 } 728 } 729 } else if (color === 'negative_subtle') { 730 if (variant === 'outline') { 731 if (!disabled) { 732 baseStyles.push({color: t.palette.negative_400}) 733 } else { 734 baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 735 } 736 } else if (variant === 'ghost') { 737 if (!disabled) { 738 baseStyles.push({color: t.palette.negative_400}) 739 } else { 740 baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 741 } 742 } 743 } 744 /* 745 * END DEPRECATED STYLES 746 */ 747 } 748 749 if (size === 'large') { 750 baseStyles.push(a.text_md, a.leading_snug, a.font_medium) 751 } else if (size === 'small') { 752 baseStyles.push(a.text_sm, a.leading_snug, a.font_medium) 753 } else if (size === 'tiny') { 754 baseStyles.push(a.text_xs, a.leading_snug, a.font_semi_bold) 755 } 756 757 return flatten(baseStyles) 758 }, [t, variant, color, size, disabled]) 759} 760 761export function ButtonText({children, style, ...rest}: ButtonTextProps) { 762 const textStyles = useSharedButtonTextStyles() 763 764 return ( 765 <Text {...rest} style={[a.text_center, textStyles, style]}> 766 {children} 767 </Text> 768 ) 769} 770 771export function ButtonIcon({ 772 icon: Comp, 773 size, 774}: { 775 icon: React.ComponentType<SVGIconProps> 776 /** 777 * @deprecated no longer needed 778 */ 779 position?: 'left' | 'right' 780 size?: SVGIconProps['size'] 781}) { 782 const {size: buttonSize, shape: buttonShape} = useButtonContext() 783 const textStyles = useSharedButtonTextStyles() 784 const {iconSize, iconContainerSize, iconNegativeMargin} = 785 React.useMemo(() => { 786 /** 787 * Pre-set icon sizes for different button sizes 788 */ 789 const iconSizeShorthand = 790 size ?? 791 (({ 792 large: 'md', 793 small: 'sm', 794 tiny: 'xs', 795 }[buttonSize || 'small'] || 'sm') as Exclude< 796 SVGIconProps['size'], 797 undefined 798 >) 799 800 /* 801 * Copied here from icons/common.tsx so we can tweak if we need to, but 802 * also so that we can calculate transforms. 803 */ 804 const iconSize = { 805 xs: 12, 806 sm: 16, 807 md: 18, 808 lg: 24, 809 xl: 28, 810 '2xs': 8, 811 '2xl': 32, 812 '3xl': 40, 813 }[iconSizeShorthand] 814 815 /* 816 * Goal here is to match rendered text size so that different size icons 817 * don't increase button size 818 */ 819 const iconContainerSize = { 820 large: 20, 821 small: 17, 822 tiny: 15, 823 }[buttonSize || 'small'] 824 825 /* 826 * The icon needs to be closer to the edge of the button than the text. Therefore 827 * we make the gap slightly too large, and then pull in the sides using negative margins. 828 */ 829 let iconNegativeMargin = 0 830 831 if (buttonShape === 'default') { 832 iconNegativeMargin = { 833 large: -2, 834 small: -2, 835 tiny: -1, 836 }[buttonSize || 'small'] 837 } 838 839 return { 840 iconSize, 841 iconContainerSize, 842 iconNegativeMargin, 843 } 844 }, [buttonSize, buttonShape, size]) 845 846 return ( 847 <View 848 style={[ 849 a.z_20, 850 { 851 width: size === '2xs' ? 10 : iconContainerSize, 852 height: iconContainerSize, 853 marginLeft: iconNegativeMargin, 854 marginRight: iconNegativeMargin, 855 }, 856 ]}> 857 <View 858 style={[ 859 a.absolute, 860 { 861 width: iconSize, 862 height: iconSize, 863 top: '50%', 864 left: '50%', 865 transform: [ 866 { 867 translateX: (iconSize / 2) * -1, 868 }, 869 { 870 translateY: (iconSize / 2) * -1, 871 }, 872 ], 873 }, 874 ]}> 875 <Comp 876 width={iconSize} 877 style={[ 878 { 879 color: textStyles.color, 880 pointerEvents: 'none', 881 }, 882 ]} 883 /> 884 </View> 885 </View> 886 ) 887} 888 889export type StackedButtonProps = Omit< 890 ButtonProps, 891 keyof VariantProps | 'children' 892> & 893 Pick<VariantProps, 'color'> & { 894 children: React.ReactNode 895 icon: React.ComponentType<SVGIconProps> 896 } 897 898export function StackedButton({children, ...props}: StackedButtonProps) { 899 return ( 900 <Button 901 {...props} 902 size="tiny" 903 style={[ 904 a.flex_col, 905 { 906 height: 72, 907 paddingHorizontal: 16, 908 borderRadius: 20, 909 gap: 4, 910 }, 911 props.style, 912 ]}> 913 <StackedButtonInnerText icon={props.icon}> 914 {children} 915 </StackedButtonInnerText> 916 </Button> 917 ) 918} 919 920function StackedButtonInnerText({ 921 children, 922 icon: Icon, 923}: Pick<StackedButtonProps, 'icon' | 'children'>) { 924 const textStyles = useSharedButtonTextStyles() 925 return ( 926 <> 927 <Icon width={24} fill={textStyles.color} /> 928 <ButtonText>{children}</ButtonText> 929 </> 930 ) 931}