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