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