mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at samuel/fancy-queue 751 lines 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 {gradientColors, gradientHoverColors, gradientLocations} = 409 React.useMemo(() => { 410 const colors: string[] = [] 411 const hoverColors: string[] = [] 412 const locations: number[] = [] 413 const gradient = { 414 primary: tokens.gradients.sky, 415 secondary: tokens.gradients.sky, 416 secondary_inverted: tokens.gradients.sky, 417 negative: tokens.gradients.sky, 418 gradient_primary: tokens.gradients.primary, 419 gradient_sky: tokens.gradients.sky, 420 gradient_midnight: tokens.gradients.midnight, 421 gradient_sunrise: tokens.gradients.sunrise, 422 gradient_sunset: tokens.gradients.sunset, 423 gradient_nordic: tokens.gradients.nordic, 424 gradient_bonfire: tokens.gradients.bonfire, 425 }[color || 'primary'] 426 427 if (variant === 'gradient') { 428 colors.push(...gradient.values.map(([_, color]) => color)) 429 hoverColors.push(...gradient.values.map(_ => gradient.hover_value)) 430 locations.push(...gradient.values.map(([location, _]) => location)) 431 } 432 433 return { 434 gradientColors: colors, 435 gradientHoverColors: hoverColors, 436 gradientLocations: locations, 437 } 438 }, [variant, color]) 439 440 const context = React.useMemo<ButtonContext>( 441 () => ({ 442 ...state, 443 variant, 444 color, 445 size, 446 disabled: disabled || false, 447 }), 448 [state, variant, color, size, disabled], 449 ) 450 451 const flattenedBaseStyles = flatten([baseStyles, style]) 452 453 return ( 454 <PressableComponent 455 role="button" 456 accessibilityHint={undefined} // optional 457 {...rest} 458 // @ts-ignore - this will always be a pressable 459 ref={ref} 460 aria-label={label} 461 aria-pressed={state.pressed} 462 accessibilityLabel={label} 463 disabled={disabled || false} 464 accessibilityState={{ 465 disabled: disabled || false, 466 }} 467 style={[ 468 a.flex_row, 469 a.align_center, 470 a.justify_center, 471 flattenedBaseStyles, 472 ...(state.hovered || state.pressed 473 ? [hoverStyles, flatten(hoverStyleProp)] 474 : []), 475 ]} 476 onPressIn={onPressIn} 477 onPressOut={onPressOut} 478 onHoverIn={onHoverIn} 479 onHoverOut={onHoverOut} 480 onFocus={onFocus} 481 onBlur={onBlur}> 482 {variant === 'gradient' && ( 483 <View 484 style={[ 485 a.absolute, 486 a.inset_0, 487 a.overflow_hidden, 488 {borderRadius: flattenedBaseStyles.borderRadius}, 489 ]}> 490 <LinearGradient 491 colors={ 492 state.hovered || state.pressed 493 ? gradientHoverColors 494 : gradientColors 495 } 496 locations={gradientLocations} 497 start={{x: 0, y: 0}} 498 end={{x: 1, y: 1}} 499 style={[a.absolute, a.inset_0]} 500 /> 501 </View> 502 )} 503 <Context.Provider value={context}> 504 {typeof children === 'function' ? children(context) : children} 505 </Context.Provider> 506 </PressableComponent> 507 ) 508 }, 509) 510Button.displayName = 'Button' 511 512export function useSharedButtonTextStyles() { 513 const t = useTheme() 514 const {color, variant, disabled, size} = useButtonContext() 515 return React.useMemo(() => { 516 const baseStyles: TextStyle[] = [] 517 518 if (color === 'primary') { 519 if (variant === 'solid') { 520 if (!disabled) { 521 baseStyles.push({color: t.palette.white}) 522 } else { 523 baseStyles.push({color: t.palette.white, opacity: 0.5}) 524 } 525 } else if (variant === 'outline') { 526 if (!disabled) { 527 baseStyles.push({ 528 color: t.palette.primary_600, 529 }) 530 } else { 531 baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) 532 } 533 } else if (variant === 'ghost') { 534 if (!disabled) { 535 baseStyles.push({color: t.palette.primary_600}) 536 } else { 537 baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) 538 } 539 } 540 } else if (color === 'secondary') { 541 if (variant === 'solid' || variant === 'gradient') { 542 if (!disabled) { 543 baseStyles.push({ 544 color: t.palette.contrast_700, 545 }) 546 } else { 547 baseStyles.push({ 548 color: t.palette.contrast_400, 549 }) 550 } 551 } else if (variant === 'outline') { 552 if (!disabled) { 553 baseStyles.push({ 554 color: t.palette.contrast_600, 555 }) 556 } else { 557 baseStyles.push({ 558 color: t.palette.contrast_300, 559 }) 560 } 561 } else if (variant === 'ghost') { 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 } 572 } else if (color === 'secondary_inverted') { 573 if (variant === 'solid' || variant === 'gradient') { 574 if (!disabled) { 575 baseStyles.push({ 576 color: t.palette.contrast_100, 577 }) 578 } else { 579 baseStyles.push({ 580 color: t.palette.contrast_400, 581 }) 582 } 583 } else if (variant === 'outline') { 584 if (!disabled) { 585 baseStyles.push({ 586 color: t.palette.contrast_600, 587 }) 588 } else { 589 baseStyles.push({ 590 color: t.palette.contrast_300, 591 }) 592 } 593 } else if (variant === 'ghost') { 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 } 604 } else if (color === 'negative') { 605 if (variant === 'solid' || variant === 'gradient') { 606 if (!disabled) { 607 baseStyles.push({color: t.palette.white}) 608 } else { 609 baseStyles.push({color: t.palette.white, opacity: 0.5}) 610 } 611 } else if (variant === 'outline') { 612 if (!disabled) { 613 baseStyles.push({color: t.palette.negative_400}) 614 } else { 615 baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 616 } 617 } else if (variant === 'ghost') { 618 if (!disabled) { 619 baseStyles.push({color: t.palette.negative_400}) 620 } else { 621 baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 622 } 623 } 624 } else { 625 if (!disabled) { 626 baseStyles.push({color: t.palette.white}) 627 } else { 628 baseStyles.push({color: t.palette.white, opacity: 0.5}) 629 } 630 } 631 632 if (size === 'large') { 633 baseStyles.push(a.text_md, a.leading_tight) 634 } else if (size === 'small') { 635 baseStyles.push(a.text_sm, a.leading_tight) 636 } else if (size === 'tiny') { 637 baseStyles.push(a.text_xs, a.leading_tight) 638 } 639 640 return StyleSheet.flatten(baseStyles) 641 }, [t, variant, color, size, disabled]) 642} 643 644export function ButtonText({children, style, ...rest}: ButtonTextProps) { 645 const textStyles = useSharedButtonTextStyles() 646 647 return ( 648 <Text {...rest} style={[a.font_bold, a.text_center, textStyles, style]}> 649 {children} 650 </Text> 651 ) 652} 653 654export function ButtonIcon({ 655 icon: Comp, 656 position, 657 size, 658}: { 659 icon: React.ComponentType<SVGIconProps> 660 position?: 'left' | 'right' 661 size?: SVGIconProps['size'] 662}) { 663 const {size: buttonSize, disabled} = useButtonContext() 664 const textStyles = useSharedButtonTextStyles() 665 const {iconSize, iconContainerSize} = React.useMemo(() => { 666 /** 667 * Pre-set icon sizes for different button sizes 668 */ 669 const iconSizeShorthand = 670 size ?? 671 (({ 672 large: 'sm', 673 small: 'sm', 674 tiny: 'xs', 675 }[buttonSize || 'small'] || 'sm') as Exclude< 676 SVGIconProps['size'], 677 undefined 678 >) 679 680 /* 681 * Copied here from icons/common.tsx so we can tweak if we need to, but 682 * also so that we can calculate transforms. 683 */ 684 const iconSize = { 685 xs: 12, 686 sm: 16, 687 md: 20, 688 lg: 24, 689 xl: 28, 690 '2xl': 32, 691 }[iconSizeShorthand] 692 693 /* 694 * Goal here is to match rendered text size so that different size icons 695 * don't increase button size 696 */ 697 const iconContainerSize = { 698 large: 18, 699 small: 16, 700 tiny: 13, 701 }[buttonSize || 'small'] 702 703 return { 704 iconSize, 705 iconContainerSize, 706 } 707 }, [buttonSize, size]) 708 709 return ( 710 <View 711 style={[ 712 a.z_20, 713 { 714 width: iconContainerSize, 715 height: iconContainerSize, 716 opacity: disabled ? 0.7 : 1, 717 marginLeft: position === 'left' ? -2 : 0, 718 marginRight: position === 'right' ? -2 : 0, 719 }, 720 ]}> 721 <View 722 style={[ 723 a.absolute, 724 { 725 width: iconSize, 726 height: iconSize, 727 top: '50%', 728 left: '50%', 729 transform: [ 730 { 731 translateX: (iconSize / 2) * -1, 732 }, 733 { 734 translateY: (iconSize / 2) * -1, 735 }, 736 ], 737 }, 738 ]}> 739 <Comp 740 width={iconSize} 741 style={[ 742 { 743 color: textStyles.color, 744 pointerEvents: 'none', 745 }, 746 ]} 747 /> 748 </View> 749 </View> 750 ) 751}