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