import React from 'react' import { type AccessibilityProps, type GestureResponderEvent, type MouseEvent, type NativeSyntheticEvent, Pressable, type PressableProps, type StyleProp, StyleSheet, type TargetedEvent, type TextProps, type TextStyle, View, type ViewStyle, } from 'react-native' import {LinearGradient} from 'expo-linear-gradient' import {atoms as a, flatten, select, tokens, useTheme} from '#/alf' import {type Props as SVGIconProps} from '#/components/icons/common' import {Text} from '#/components/Typography' /** * The `Button` component, and some extensions of it like `Link` are intended * to be generic and therefore apply no styles by default. These `VariantProps` * are what control the `Button`'s presentation, and are intended only use cases where the buttons appear as, well, buttons. * * If `Button` or an extension of it are used for other compound components, use this property to avoid misuse of these variant props further down the line. * * @example * type MyComponentProps = Omit & {...} */ export type UninheritableButtonProps = 'variant' | 'color' | 'size' | 'shape' export type ButtonVariant = 'solid' | 'outline' | 'ghost' | 'gradient' export type ButtonColor = | 'primary' | 'secondary' | 'secondary_inverted' | 'negative' | 'negative_secondary' | 'gradient_primary' | 'gradient_sky' | 'gradient_midnight' | 'gradient_sunrise' | 'gradient_sunset' | 'gradient_nordic' | 'gradient_bonfire' export type ButtonSize = 'tiny' | 'small' | 'large' export type ButtonShape = 'round' | 'square' | 'default' export type VariantProps = { /** * The style variation of the button */ variant?: ButtonVariant /** * The color of the button */ color?: ButtonColor /** * The size of the button */ size?: ButtonSize /** * The shape of the button */ shape?: ButtonShape } export type ButtonState = { hovered: boolean focused: boolean pressed: boolean disabled: boolean } export type ButtonContext = VariantProps & ButtonState type NonTextElements = | React.ReactElement | Iterable export type ButtonProps = Pick< PressableProps, | 'disabled' | 'onPress' | 'testID' | 'onLongPress' | 'hitSlop' | 'onHoverIn' | 'onHoverOut' | 'onPressIn' | 'onPressOut' | 'onFocus' | 'onBlur' > & AccessibilityProps & VariantProps & { testID?: string /** * For a11y, try to make this descriptive and clear */ label: string style?: StyleProp hoverStyle?: StyleProp children: NonTextElements | ((context: ButtonContext) => NonTextElements) PressableComponent?: React.ComponentType } export type ButtonTextProps = TextProps & VariantProps & {disabled?: boolean} const Context = React.createContext({ hovered: false, focused: false, pressed: false, disabled: false, }) export function useButtonContext() { return React.useContext(Context) } export const Button = React.forwardRef( ( { children, variant, color, size, shape = 'default', label, disabled = false, style, hoverStyle: hoverStyleProp, PressableComponent = Pressable, onPressIn: onPressInOuter, onPressOut: onPressOutOuter, onHoverIn: onHoverInOuter, onHoverOut: onHoverOutOuter, onFocus: onFocusOuter, onBlur: onBlurOuter, ...rest }, ref, ) => { const t = useTheme() const [state, setState] = React.useState({ pressed: false, hovered: false, focused: false, }) const onPressIn = React.useCallback( (e: GestureResponderEvent) => { setState(s => ({ ...s, pressed: true, })) onPressInOuter?.(e) }, [setState, onPressInOuter], ) const onPressOut = React.useCallback( (e: GestureResponderEvent) => { setState(s => ({ ...s, pressed: false, })) onPressOutOuter?.(e) }, [setState, onPressOutOuter], ) const onHoverIn = React.useCallback( (e: MouseEvent) => { setState(s => ({ ...s, hovered: true, })) onHoverInOuter?.(e) }, [setState, onHoverInOuter], ) const onHoverOut = React.useCallback( (e: MouseEvent) => { setState(s => ({ ...s, hovered: false, })) onHoverOutOuter?.(e) }, [setState, onHoverOutOuter], ) const onFocus = React.useCallback( (e: NativeSyntheticEvent) => { setState(s => ({ ...s, focused: true, })) onFocusOuter?.(e) }, [setState, onFocusOuter], ) const onBlur = React.useCallback( (e: NativeSyntheticEvent) => { setState(s => ({ ...s, focused: false, })) onBlurOuter?.(e) }, [setState, onBlurOuter], ) const {baseStyles, hoverStyles} = React.useMemo(() => { const baseStyles: ViewStyle[] = [] const hoverStyles: ViewStyle[] = [] if (color === 'primary') { if (variant === 'solid') { if (!disabled) { baseStyles.push({ backgroundColor: t.palette.primary_500, }) hoverStyles.push({ backgroundColor: t.palette.primary_600, }) } else { baseStyles.push({ backgroundColor: select(t.name, { light: t.palette.primary_700, dim: t.palette.primary_300, dark: t.palette.primary_300, }), }) } } else if (variant === 'outline') { baseStyles.push(a.border, t.atoms.bg, { borderWidth: 1, }) if (!disabled) { baseStyles.push(a.border, { borderColor: t.palette.primary_500, }) hoverStyles.push(a.border, { backgroundColor: t.palette.primary_50, }) } else { baseStyles.push(a.border, { borderColor: t.palette.primary_200, }) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push(t.atoms.bg) hoverStyles.push({ backgroundColor: t.palette.primary_100, }) } } } else if (color === 'secondary') { if (variant === 'solid') { if (!disabled) { baseStyles.push(t.atoms.bg_contrast_25) hoverStyles.push(t.atoms.bg_contrast_50) } else { baseStyles.push(t.atoms.bg_contrast_100) } } else if (variant === 'outline') { baseStyles.push(a.border, t.atoms.bg, { borderWidth: 1, }) if (!disabled) { baseStyles.push(a.border, { borderColor: t.palette.contrast_300, }) hoverStyles.push(t.atoms.bg_contrast_50) } else { baseStyles.push(a.border, { borderColor: t.palette.contrast_200, }) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push(t.atoms.bg) hoverStyles.push({ backgroundColor: t.palette.contrast_25, }) } } } else if (color === 'secondary_inverted') { if (variant === 'solid') { if (!disabled) { baseStyles.push({ backgroundColor: t.palette.contrast_900, }) hoverStyles.push({ backgroundColor: t.palette.contrast_950, }) } else { baseStyles.push({ backgroundColor: t.palette.contrast_600, }) } } else if (variant === 'outline') { baseStyles.push(a.border, t.atoms.bg, { borderWidth: 1, }) if (!disabled) { baseStyles.push(a.border, { borderColor: t.palette.contrast_300, }) hoverStyles.push(t.atoms.bg_contrast_50) } else { baseStyles.push(a.border, { borderColor: t.palette.contrast_200, }) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push(t.atoms.bg) hoverStyles.push({ backgroundColor: t.palette.contrast_25, }) } } } else if (color === 'negative') { if (variant === 'solid') { if (!disabled) { baseStyles.push({ backgroundColor: t.palette.negative_500, }) hoverStyles.push({ backgroundColor: t.palette.negative_600, }) } else { baseStyles.push({ backgroundColor: select(t.name, { light: t.palette.negative_700, dim: t.palette.negative_300, dark: t.palette.negative_300, }), }) } } else if (variant === 'outline') { baseStyles.push(a.border, t.atoms.bg, { borderWidth: 1, }) if (!disabled) { baseStyles.push(a.border, { borderColor: t.palette.negative_500, }) hoverStyles.push(a.border, { backgroundColor: t.palette.negative_50, }) } else { baseStyles.push(a.border, { borderColor: t.palette.negative_200, }) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push(t.atoms.bg) hoverStyles.push({ backgroundColor: t.palette.negative_100, }) } } } else if (color === 'negative_secondary') { if (variant === 'solid') { if (!disabled) { baseStyles.push({ backgroundColor: select(t.name, { light: t.palette.negative_50, dim: t.palette.negative_100, dark: t.palette.negative_100, }), }) hoverStyles.push({ backgroundColor: select(t.name, { light: t.palette.negative_100, dim: t.palette.negative_200, dark: t.palette.negative_200, }), }) } else { baseStyles.push({ backgroundColor: select(t.name, { light: t.palette.negative_100, dim: t.palette.negative_50, dark: t.palette.negative_50, }), }) } } else if (variant === 'outline') { baseStyles.push(a.border, t.atoms.bg, { borderWidth: 1, }) if (!disabled) { baseStyles.push(a.border, { borderColor: t.palette.negative_500, }) hoverStyles.push(a.border, { backgroundColor: t.palette.negative_50, }) } else { baseStyles.push(a.border, { borderColor: t.palette.negative_200, }) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push(t.atoms.bg) hoverStyles.push({ backgroundColor: t.palette.negative_100, }) } } } if (shape === 'default') { if (size === 'large') { baseStyles.push({ paddingVertical: 13, paddingHorizontal: 20, borderRadius: 8, gap: 8, }) } else if (size === 'small') { baseStyles.push({ paddingVertical: 9, paddingHorizontal: 12, borderRadius: 6, gap: 6, }) } else if (size === 'tiny') { baseStyles.push({ paddingVertical: 4, paddingHorizontal: 8, borderRadius: 4, gap: 4, }) } } else if (shape === 'round' || shape === 'square') { if (size === 'large') { if (shape === 'round') { baseStyles.push({height: 46, width: 46}) } else { baseStyles.push({height: 44, width: 44}) } } else if (size === 'small') { if (shape === 'round') { baseStyles.push({height: 34, width: 34}) } else { baseStyles.push({height: 34, width: 34}) } } else if (size === 'tiny') { if (shape === 'round') { baseStyles.push({height: 22, width: 22}) } else { baseStyles.push({height: 21, width: 21}) } } if (shape === 'round') { baseStyles.push(a.rounded_full) } else if (shape === 'square') { if (size === 'tiny') { baseStyles.push(a.rounded_xs) } else { baseStyles.push(a.rounded_sm) } } } return { baseStyles, hoverStyles, } }, [t, variant, color, size, shape, disabled]) const gradientValues = React.useMemo(() => { const gradient = { primary: tokens.gradients.sky, secondary: tokens.gradients.sky, secondary_inverted: tokens.gradients.sky, negative: tokens.gradients.sky, negative_secondary: tokens.gradients.sky, gradient_primary: tokens.gradients.primary, gradient_sky: tokens.gradients.sky, gradient_midnight: tokens.gradients.midnight, gradient_sunrise: tokens.gradients.sunrise, gradient_sunset: tokens.gradients.sunset, gradient_nordic: tokens.gradients.nordic, gradient_bonfire: tokens.gradients.bonfire, }[color || 'primary'] if (variant === 'gradient') { if (gradient.values.length < 2) { throw new Error( 'Gradient buttons must have at least two colors in the gradient', ) } return { colors: gradient.values.map(([_, color]) => color) as [ string, string, ...string[], ], hoverColors: gradient.values.map(_ => gradient.hover_value) as [ string, string, ...string[], ], locations: gradient.values.map(([location, _]) => location) as [ number, number, ...number[], ], } } }, [variant, color]) const context = React.useMemo( () => ({ ...state, variant, color, size, disabled: disabled || false, }), [state, variant, color, size, disabled], ) const flattenedBaseStyles = flatten([baseStyles, style]) return ( {variant === 'gradient' && gradientValues && ( )} {typeof children === 'function' ? children(context) : children} ) }, ) Button.displayName = 'Button' export function useSharedButtonTextStyles() { const t = useTheme() const {color, variant, disabled, size} = useButtonContext() return React.useMemo(() => { const baseStyles: TextStyle[] = [] if (color === 'primary') { if (variant === 'solid') { if (!disabled) { baseStyles.push({color: t.palette.white}) } else { baseStyles.push({color: t.palette.white, opacity: 0.5}) } } else if (variant === 'outline') { if (!disabled) { baseStyles.push({ color: t.palette.primary_600, }) } else { baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push({color: t.palette.primary_600}) } else { baseStyles.push({color: t.palette.primary_600, opacity: 0.5}) } } } else if (color === 'secondary') { if (variant === 'solid' || variant === 'gradient') { if (!disabled) { baseStyles.push({ color: t.palette.contrast_700, }) } else { baseStyles.push({ color: t.palette.contrast_400, }) } } else if (variant === 'outline') { if (!disabled) { baseStyles.push({ color: t.palette.contrast_600, }) } else { baseStyles.push({ color: t.palette.contrast_300, }) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push({ color: t.palette.contrast_600, }) } else { baseStyles.push({ color: t.palette.contrast_300, }) } } } else if (color === 'secondary_inverted') { if (variant === 'solid' || variant === 'gradient') { if (!disabled) { baseStyles.push({ color: t.palette.contrast_50, }) } else { baseStyles.push({ color: t.palette.contrast_400, }) } } else if (variant === 'outline') { if (!disabled) { baseStyles.push({ color: t.palette.contrast_600, }) } else { baseStyles.push({ color: t.palette.contrast_300, }) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push({ color: t.palette.contrast_600, }) } else { baseStyles.push({ color: t.palette.contrast_300, }) } } } else if (color === 'negative') { if (variant === 'solid' || variant === 'gradient') { if (!disabled) { baseStyles.push({color: t.palette.white}) } else { baseStyles.push({color: t.palette.white, opacity: 0.5}) } } else if (variant === 'outline') { if (!disabled) { baseStyles.push({color: t.palette.negative_400}) } else { baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push({color: t.palette.negative_400}) } else { baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) } } } else if (color === 'negative_secondary') { if (variant === 'solid' || variant === 'gradient') { if (!disabled) { baseStyles.push({ color: select(t.name, { light: t.palette.negative_500, dim: t.palette.negative_950, dark: t.palette.negative_900, }), }) } else { baseStyles.push({ color: select(t.name, { light: t.palette.negative_500, dim: t.palette.negative_700, dark: t.palette.negative_700, }), opacity: 0.5, }) } } else if (variant === 'outline') { if (!disabled) { baseStyles.push({color: t.palette.negative_400}) } else { baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) } } else if (variant === 'ghost') { if (!disabled) { baseStyles.push({color: t.palette.negative_400}) } else { baseStyles.push({color: t.palette.negative_400, opacity: 0.5}) } } } else { if (!disabled) { baseStyles.push({color: t.palette.white}) } else { baseStyles.push({color: t.palette.white, opacity: 0.5}) } } if (size === 'large') { baseStyles.push(a.text_md, a.leading_tight) } else if (size === 'small') { baseStyles.push(a.text_sm, a.leading_tight) } else if (size === 'tiny') { baseStyles.push(a.text_xs, a.leading_tight) } return StyleSheet.flatten(baseStyles) }, [t, variant, color, size, disabled]) } export function ButtonText({children, style, ...rest}: ButtonTextProps) { const textStyles = useSharedButtonTextStyles() return ( {children} ) } export function ButtonIcon({ icon: Comp, position, size, }: { icon: React.ComponentType position?: 'left' | 'right' size?: SVGIconProps['size'] }) { const {size: buttonSize, disabled} = useButtonContext() const textStyles = useSharedButtonTextStyles() const {iconSize, iconContainerSize} = React.useMemo(() => { /** * Pre-set icon sizes for different button sizes */ const iconSizeShorthand = size ?? (({ large: 'sm', small: 'sm', tiny: 'xs', }[buttonSize || 'small'] || 'sm') as Exclude< SVGIconProps['size'], undefined >) /* * Copied here from icons/common.tsx so we can tweak if we need to, but * also so that we can calculate transforms. */ const iconSize = { xs: 12, sm: 16, md: 20, lg: 24, xl: 28, '2xl': 32, }[iconSizeShorthand] /* * Goal here is to match rendered text size so that different size icons * don't increase button size */ const iconContainerSize = { large: 18, small: 16, tiny: 13, }[buttonSize || 'small'] return { iconSize, iconContainerSize, } }, [buttonSize, size]) return ( ) }