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