mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React from 'react' 2import { 3 AccessibilityProps, 4 GestureResponderEvent, 5 MouseEvent, 6 Pressable, 7 PressableProps, 8 StyleProp, 9 StyleSheet, 10 Text, 11 TextProps, 12 TextStyle, 13 View, 14 ViewStyle, 15} from 'react-native' 16import {LinearGradient} from 'expo-linear-gradient' 17 18import {android, atoms as a, flatten, select, tokens, useTheme} from '#/alf' 19import {Props as SVGIconProps} from '#/components/icons/common' 20import {normalizeTextStyles} from '#/components/Typography' 21 22export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient' 23export type ButtonColor = 24 | 'primary' 25 | 'secondary' 26 | 'secondary_inverted' 27 | 'negative' 28 | 'gradient_sky' 29 | 'gradient_midnight' 30 | 'gradient_sunrise' 31 | 'gradient_sunset' 32 | 'gradient_nordic' 33 | 'gradient_bonfire' 34export type ButtonSize = 'tiny' | 'xsmall' | 'small' | 'medium' | '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 } 91 92export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} 93 94const Context = React.createContext<VariantProps & ButtonState>({ 95 hovered: false, 96 focused: false, 97 pressed: false, 98 disabled: false, 99}) 100 101export function useButtonContext() { 102 return React.useContext(Context) 103} 104 105export const Button = React.forwardRef<View, ButtonProps>( 106 ( 107 { 108 children, 109 variant, 110 color, 111 size, 112 shape = 'default', 113 label, 114 disabled = false, 115 style, 116 hoverStyle: hoverStyleProp, 117 ...rest 118 }, 119 ref, 120 ) => { 121 const t = useTheme() 122 const [state, setState] = React.useState({ 123 pressed: false, 124 hovered: false, 125 focused: false, 126 }) 127 128 const onPressInOuter = rest.onPressIn 129 const onPressIn = React.useCallback( 130 (e: GestureResponderEvent) => { 131 setState(s => ({ 132 ...s, 133 pressed: true, 134 })) 135 onPressInOuter?.(e) 136 }, 137 [setState, onPressInOuter], 138 ) 139 const onPressOutOuter = rest.onPressOut 140 const onPressOut = React.useCallback( 141 (e: GestureResponderEvent) => { 142 setState(s => ({ 143 ...s, 144 pressed: false, 145 })) 146 onPressOutOuter?.(e) 147 }, 148 [setState, onPressOutOuter], 149 ) 150 const onHoverInOuter = rest.onHoverIn 151 const onHoverIn = React.useCallback( 152 (e: MouseEvent) => { 153 setState(s => ({ 154 ...s, 155 hovered: true, 156 })) 157 onHoverInOuter?.(e) 158 }, 159 [setState, onHoverInOuter], 160 ) 161 const onHoverOutOuter = rest.onHoverOut 162 const onHoverOut = React.useCallback( 163 (e: MouseEvent) => { 164 setState(s => ({ 165 ...s, 166 hovered: false, 167 })) 168 onHoverOutOuter?.(e) 169 }, 170 [setState, onHoverOutOuter], 171 ) 172 const onFocus = React.useCallback(() => { 173 setState(s => ({ 174 ...s, 175 focused: true, 176 })) 177 }, [setState]) 178 const onBlur = React.useCallback(() => { 179 setState(s => ({ 180 ...s, 181 focused: false, 182 })) 183 }, [setState]) 184 185 const {baseStyles, hoverStyles} = React.useMemo(() => { 186 const baseStyles: ViewStyle[] = [] 187 const hoverStyles: ViewStyle[] = [] 188 189 if (color === 'primary') { 190 if (variant === 'solid') { 191 if (!disabled) { 192 baseStyles.push({ 193 backgroundColor: t.palette.primary_500, 194 }) 195 hoverStyles.push({ 196 backgroundColor: t.palette.primary_600, 197 }) 198 } else { 199 baseStyles.push({ 200 backgroundColor: select(t.name, { 201 light: t.palette.primary_700, 202 dim: t.palette.primary_300, 203 dark: t.palette.primary_300, 204 }), 205 }) 206 } 207 } else if (variant === 'outline') { 208 baseStyles.push(a.border, t.atoms.bg, { 209 borderWidth: 1, 210 }) 211 212 if (!disabled) { 213 baseStyles.push(a.border, { 214 borderColor: t.palette.primary_500, 215 }) 216 hoverStyles.push(a.border, { 217 backgroundColor: t.palette.primary_50, 218 }) 219 } else { 220 baseStyles.push(a.border, { 221 borderColor: t.palette.primary_200, 222 }) 223 } 224 } else if (variant === 'ghost') { 225 if (!disabled) { 226 baseStyles.push(t.atoms.bg) 227 hoverStyles.push({ 228 backgroundColor: t.palette.primary_100, 229 }) 230 } 231 } 232 } else if (color === 'secondary') { 233 if (variant === 'solid') { 234 if (!disabled) { 235 baseStyles.push(t.atoms.bg_contrast_25) 236 hoverStyles.push(t.atoms.bg_contrast_50) 237 } else { 238 baseStyles.push(t.atoms.bg_contrast_100) 239 } 240 } else if (variant === 'outline') { 241 baseStyles.push(a.border, t.atoms.bg, { 242 borderWidth: 1, 243 }) 244 245 if (!disabled) { 246 baseStyles.push(a.border, { 247 borderColor: t.palette.contrast_300, 248 }) 249 hoverStyles.push(t.atoms.bg_contrast_50) 250 } else { 251 baseStyles.push(a.border, { 252 borderColor: t.palette.contrast_200, 253 }) 254 } 255 } else if (variant === 'ghost') { 256 if (!disabled) { 257 baseStyles.push(t.atoms.bg) 258 hoverStyles.push({ 259 backgroundColor: t.palette.contrast_25, 260 }) 261 } 262 } 263 } else if (color === 'secondary_inverted') { 264 if (variant === 'solid') { 265 if (!disabled) { 266 baseStyles.push({ 267 backgroundColor: t.palette.contrast_900, 268 }) 269 hoverStyles.push({ 270 backgroundColor: t.palette.contrast_950, 271 }) 272 } else { 273 baseStyles.push({ 274 backgroundColor: t.palette.contrast_600, 275 }) 276 } 277 } else if (variant === 'outline') { 278 baseStyles.push(a.border, t.atoms.bg, { 279 borderWidth: 1, 280 }) 281 282 if (!disabled) { 283 baseStyles.push(a.border, { 284 borderColor: t.palette.contrast_300, 285 }) 286 hoverStyles.push(t.atoms.bg_contrast_50) 287 } else { 288 baseStyles.push(a.border, { 289 borderColor: t.palette.contrast_200, 290 }) 291 } 292 } else if (variant === 'ghost') { 293 if (!disabled) { 294 baseStyles.push(t.atoms.bg) 295 hoverStyles.push({ 296 backgroundColor: t.palette.contrast_25, 297 }) 298 } 299 } 300 } else if (color === 'negative') { 301 if (variant === 'solid') { 302 if (!disabled) { 303 baseStyles.push({ 304 backgroundColor: t.palette.negative_500, 305 }) 306 hoverStyles.push({ 307 backgroundColor: t.palette.negative_600, 308 }) 309 } else { 310 baseStyles.push({ 311 backgroundColor: select(t.name, { 312 light: t.palette.negative_700, 313 dim: t.palette.negative_300, 314 dark: t.palette.negative_300, 315 }), 316 }) 317 } 318 } else if (variant === 'outline') { 319 baseStyles.push(a.border, t.atoms.bg, { 320 borderWidth: 1, 321 }) 322 323 if (!disabled) { 324 baseStyles.push(a.border, { 325 borderColor: t.palette.negative_500, 326 }) 327 hoverStyles.push(a.border, { 328 backgroundColor: t.palette.negative_50, 329 }) 330 } else { 331 baseStyles.push(a.border, { 332 borderColor: t.palette.negative_200, 333 }) 334 } 335 } else if (variant === 'ghost') { 336 if (!disabled) { 337 baseStyles.push(t.atoms.bg) 338 hoverStyles.push({ 339 backgroundColor: t.palette.negative_100, 340 }) 341 } 342 } 343 } 344 345 if (shape === 'default') { 346 if (size === 'large') { 347 baseStyles.push( 348 {paddingVertical: 15}, 349 a.px_2xl, 350 a.rounded_sm, 351 a.gap_md, 352 ) 353 } else if (size === 'medium') { 354 baseStyles.push( 355 {paddingVertical: 12}, 356 a.px_2xl, 357 a.rounded_sm, 358 a.gap_md, 359 ) 360 } else if (size === 'small') { 361 baseStyles.push({paddingVertical: 9}, a.px_lg, a.rounded_sm, a.gap_sm) 362 } else if (size === 'xsmall') { 363 baseStyles.push({paddingVertical: 6}, a.px_sm, a.rounded_sm, a.gap_sm) 364 } else if (size === 'tiny') { 365 baseStyles.push({paddingVertical: 4}, a.px_sm, a.rounded_xs, a.gap_xs) 366 } 367 } else if (shape === 'round' || shape === 'square') { 368 if (size === 'large') { 369 if (shape === 'round') { 370 baseStyles.push({height: 54, width: 54}) 371 } else { 372 baseStyles.push({height: 50, width: 50}) 373 } 374 } else if (size === 'small') { 375 baseStyles.push({height: 34, width: 34}) 376 } else if (size === 'xsmall') { 377 baseStyles.push({height: 28, width: 28}) 378 } else if (size === 'tiny') { 379 baseStyles.push({height: 20, width: 20}) 380 } 381 382 if (shape === 'round') { 383 baseStyles.push(a.rounded_full) 384 } else if (shape === 'square') { 385 if (size === 'tiny') { 386 baseStyles.push(a.rounded_xs) 387 } else { 388 baseStyles.push(a.rounded_sm) 389 } 390 } 391 } 392 393 return { 394 baseStyles, 395 hoverStyles, 396 } 397 }, [t, variant, color, size, shape, disabled]) 398 399 const {gradientColors, gradientHoverColors, gradientLocations} = 400 React.useMemo(() => { 401 const colors: string[] = [] 402 const hoverColors: string[] = [] 403 const locations: number[] = [] 404 const gradient = { 405 primary: tokens.gradients.sky, 406 secondary: tokens.gradients.sky, 407 secondary_inverted: tokens.gradients.sky, 408 negative: tokens.gradients.sky, 409 gradient_sky: tokens.gradients.sky, 410 gradient_midnight: tokens.gradients.midnight, 411 gradient_sunrise: tokens.gradients.sunrise, 412 gradient_sunset: tokens.gradients.sunset, 413 gradient_nordic: tokens.gradients.nordic, 414 gradient_bonfire: tokens.gradients.bonfire, 415 }[color || 'primary'] 416 417 if (variant === 'gradient') { 418 colors.push(...gradient.values.map(([_, color]) => color)) 419 hoverColors.push(...gradient.values.map(_ => gradient.hover_value)) 420 locations.push(...gradient.values.map(([location, _]) => location)) 421 } 422 423 return { 424 gradientColors: colors, 425 gradientHoverColors: hoverColors, 426 gradientLocations: locations, 427 } 428 }, [variant, color]) 429 430 const context = React.useMemo<ButtonContext>( 431 () => ({ 432 ...state, 433 variant, 434 color, 435 size, 436 disabled: disabled || false, 437 }), 438 [state, variant, color, size, disabled], 439 ) 440 441 const flattenedBaseStyles = flatten(baseStyles) 442 443 return ( 444 <Pressable 445 role="button" 446 accessibilityHint={undefined} // optional 447 {...rest} 448 ref={ref} 449 aria-label={label} 450 aria-pressed={state.pressed} 451 accessibilityLabel={label} 452 disabled={disabled || false} 453 accessibilityState={{ 454 disabled: disabled || false, 455 }} 456 style={[ 457 a.flex_row, 458 a.align_center, 459 a.justify_center, 460 flattenedBaseStyles, 461 flatten(style), 462 ...(state.hovered || state.pressed 463 ? [hoverStyles, flatten(hoverStyleProp)] 464 : []), 465 ]} 466 onPressIn={onPressIn} 467 onPressOut={onPressOut} 468 onHoverIn={onHoverIn} 469 onHoverOut={onHoverOut} 470 onFocus={onFocus} 471 onBlur={onBlur}> 472 {variant === 'gradient' && ( 473 <View 474 style={[ 475 a.absolute, 476 a.inset_0, 477 a.overflow_hidden, 478 {borderRadius: flattenedBaseStyles.borderRadius}, 479 ]}> 480 <LinearGradient 481 colors={ 482 state.hovered || state.pressed 483 ? gradientHoverColors 484 : gradientColors 485 } 486 locations={gradientLocations} 487 start={{x: 0, y: 0}} 488 end={{x: 1, y: 1}} 489 style={[a.absolute, a.inset_0]} 490 /> 491 </View> 492 )} 493 <Context.Provider value={context}> 494 {typeof children === 'function' ? children(context) : children} 495 </Context.Provider> 496 </Pressable> 497 ) 498 }, 499) 500Button.displayName = 'Button' 501 502export function useSharedButtonTextStyles() { 503 const t = useTheme() 504 const {color, variant, disabled, size} = useButtonContext() 505 return React.useMemo(() => { 506 const baseStyles: TextStyle[] = [] 507 508 if (color === 'primary') { 509 if (variant === 'solid') { 510 if (!disabled) { 511 baseStyles.push({color: t.palette.white}) 512 } else { 513 baseStyles.push({color: t.palette.white, opacity: 0.5}) 514 } 515 } else if (variant === 'outline') { 516 if (!disabled) { 517 baseStyles.push({ 518 color: t.palette.primary_600, 519 }) 520 } else { 521 baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) 522 } 523 } else if (variant === 'ghost') { 524 if (!disabled) { 525 baseStyles.push({color: t.palette.primary_600}) 526 } else { 527 baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) 528 } 529 } 530 } else if (color === 'secondary') { 531 if (variant === 'solid' || variant === 'gradient') { 532 if (!disabled) { 533 baseStyles.push({ 534 color: t.palette.contrast_700, 535 }) 536 } else { 537 baseStyles.push({ 538 color: t.palette.contrast_400, 539 }) 540 } 541 } else if (variant === 'outline') { 542 if (!disabled) { 543 baseStyles.push({ 544 color: t.palette.contrast_600, 545 }) 546 } else { 547 baseStyles.push({ 548 color: t.palette.contrast_300, 549 }) 550 } 551 } else if (variant === 'ghost') { 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 } 562 } else if (color === 'secondary_inverted') { 563 if (variant === 'solid' || variant === 'gradient') { 564 if (!disabled) { 565 baseStyles.push({ 566 color: t.palette.contrast_100, 567 }) 568 } else { 569 baseStyles.push({ 570 color: t.palette.contrast_400, 571 }) 572 } 573 } else if (variant === 'outline') { 574 if (!disabled) { 575 baseStyles.push({ 576 color: t.palette.contrast_600, 577 }) 578 } else { 579 baseStyles.push({ 580 color: t.palette.contrast_300, 581 }) 582 } 583 } else if (variant === 'ghost') { 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 } 594 } else if (color === 'negative') { 595 if (variant === 'solid' || variant === 'gradient') { 596 if (!disabled) { 597 baseStyles.push({color: t.palette.white}) 598 } else { 599 baseStyles.push({color: t.palette.white, opacity: 0.5}) 600 } 601 } else if (variant === 'outline') { 602 if (!disabled) { 603 baseStyles.push({color: t.palette.negative_400}) 604 } else { 605 baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 606 } 607 } else if (variant === 'ghost') { 608 if (!disabled) { 609 baseStyles.push({color: t.palette.negative_400}) 610 } else { 611 baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) 612 } 613 } 614 } else { 615 if (!disabled) { 616 baseStyles.push({color: t.palette.white}) 617 } else { 618 baseStyles.push({color: t.palette.white, opacity: 0.5}) 619 } 620 } 621 622 if (size === 'large') { 623 baseStyles.push(a.text_md, android({paddingBottom: 1})) 624 } else if (size === 'tiny') { 625 baseStyles.push(a.text_xs, android({paddingBottom: 1})) 626 } else { 627 baseStyles.push(a.text_sm, android({paddingBottom: 1})) 628 } 629 630 return StyleSheet.flatten(baseStyles) 631 }, [t, variant, color, size, disabled]) 632} 633 634export function ButtonText({children, style, ...rest}: ButtonTextProps) { 635 const textStyles = useSharedButtonTextStyles() 636 637 return ( 638 <Text 639 {...rest} 640 style={normalizeTextStyles([ 641 a.font_bold, 642 a.text_center, 643 textStyles, 644 style, 645 ])}> 646 {children} 647 </Text> 648 ) 649} 650 651export function ButtonIcon({ 652 icon: Comp, 653 position, 654 size: iconSize, 655}: { 656 icon: React.ComponentType<SVGIconProps> 657 position?: 'left' | 'right' 658 size?: SVGIconProps['size'] 659}) { 660 const {size, disabled} = useButtonContext() 661 const textStyles = useSharedButtonTextStyles() 662 663 return ( 664 <View 665 style={[ 666 a.z_20, 667 { 668 opacity: disabled ? 0.7 : 1, 669 marginLeft: position === 'left' ? -2 : 0, 670 marginRight: position === 'right' ? -2 : 0, 671 }, 672 ]}> 673 <Comp 674 size={ 675 iconSize ?? (size === 'large' ? 'md' : size === 'tiny' ? 'xs' : 'sm') 676 } 677 style={[{color: textStyles.color, pointerEvents: 'none'}]} 678 /> 679 </View> 680 ) 681}