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