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