import React from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import type { PressableProps, ViewStyle } from 'react-native'
import LinearGradient from 'react-native-linear-gradient'
import { useTheme, useFontScale, useCreateAndroidRippleColor } from '../../hooks'
import { platformFontWeightBold, platformPressedOpacityStyle } from '../../utils'
import { tokens } from '../../vendor/tapestry/tokens'
import { Icon, IconString } from './icon'
import { Spinner } from './spinner'
import { getColorKey, useButtonColorOptionMap, useGradientColorMap } from './utils/button_colors'
import type { ButtonAppearanceUnion } from './utils/button_colors'
// =================================
// ====== Constants ================
// =================================
const SIZES = {
sm: 'sm',
md: 'md',
lg: 'lg',
} as const
type SizeUnion = (typeof SIZES)[keyof typeof SIZES]
type SizeStyle = Record<
SizeUnion,
{
fontSize: number
paddingHorizontal: number
height: number
borderRadius: number
gap: number
}
>
type VariantUnion = 'fill' | 'outline'
type VariantColors = Record<
VariantUnion,
{
backgroundColor: string
color: string
}
>
// =================================
// ====== Component ================
// =================================
export interface ButtonProps extends PressableProps {
/**
* Specifies whether fonts should be scaled down automatically to fit given style constraints.
*/
adjustsFontSizeToFit?: boolean
/**
* Specifies whether fonts should scale to respect the device's text size accessibility settings. The default is true.
*/
allowFontScaling?: boolean
/**
* Updates the button's colors
*/
appearance?: ButtonAppearanceUnion
/**
* Styles the outer LinearGradient that gives the button its background and outline color
*/
colorWrapperStyles?: ViewStyle
/**
* Styles the inner View that wraps the button's content
*/
contentWrapperStyles?: ViewStyle
/**
* Generates an icon to the left of the button text
*/
iconNameLeft?: IconString
/**
* Generates an icon to the right of the button text
*/
iconNameRight?: IconString
/**
* Disables the button and replaces its content with a spinner
*/
loading?: boolean
/**
* Specifies the maximum size a font can reach when allowFontScaling is enabled.
*/
maxFontSizeMultiplier?: number
/**
* Specifies smallest possible scale a font can reach when adjustsFontSizeToFit is enabled. (values 0.01-1.0).
*/
minimumFontScale?: number
/**
* Changes the overall size of the button and its contents
*/
size?: SizeUnion
/**
* Renders as text within the button
*/
title: string
/**
* Fill buttons are visually more propmonitent then outline buttons
*/
variant?: VariantUnion
}
export function Button({
adjustsFontSizeToFit = false,
allowFontScaling = true,
appearance = 'interaction',
contentWrapperStyles,
colorWrapperStyles,
disabled = false,
iconNameLeft,
iconNameRight,
loading,
maxFontSizeMultiplier,
minimumFontScale,
size = 'md',
title,
variant = 'fill',
style,
...props
}: ButtonProps) {
const styles = useStyles({ appearance, disabled, loading, maxFontSizeMultiplier, size, variant })
const gradientOptionsMap = useGradientColorMap()
const colorKey = getColorKey({ disabled, loading, appearance })
const overrideStyles = StyleSheet.flatten(style)
const textStyles = [styles.text, disabled && styles.textDisabled, loading && styles.iconLoading]
const iconStyles = [styles.icon, disabled && styles.iconDisabled, loading && styles.textLoading]
const androidRippleColor = useCreateAndroidRippleColor({ color: gradientOptionsMap[colorKey][0] })
return (
({
...styles.pressable,
...(pressed && platformPressedOpacityStyle),
...overrideStyles,
})}
accessibilityRole="button"
disabled={disabled || loading}
accessibilityState={{ busy: loading }}
android_ripple={{ color: androidRippleColor, borderless: false, foreground: true }}
{...props}
>
{loading && (
)}
{iconNameLeft && (
)}
{title}
{iconNameRight && (
)}
)
}
// =================================
// ====== Styles ===================
// =================================
const useStyles = ({
appearance = 'interaction',
disabled = false,
loading = false,
maxFontSizeMultiplier,
size = 'md',
variant = 'fill',
}: Partial) => {
const { colors, button } = useTheme()
const fontScale = useFontScale({ maxFontSizeMultiplier })
const colorOptionMap = useButtonColorOptionMap()
const outlineHeightOffset = size === 'sm' ? 2 : 4
const outlineMarginOffset = size === 'sm' ? 1 : 2
const sizeStyleMap: SizeStyle = {
[SIZES.sm]: {
fontSize: 12,
paddingHorizontal: 12 * fontScale,
borderRadius: button.borderRadius * fontScale,
height: 24 * fontScale - outlineHeightOffset,
gap: 4 * fontScale,
},
[SIZES.md]: {
fontSize: 14,
paddingHorizontal: 16 * fontScale,
borderRadius: button.borderRadius * fontScale,
height: 32 * fontScale - outlineHeightOffset,
gap: 6 * fontScale,
},
[SIZES.lg]: {
fontSize: 16,
paddingHorizontal: 24 * fontScale,
borderRadius: button.borderRadius * fontScale,
height: 40 * fontScale - outlineHeightOffset,
gap: 8 * fontScale,
},
}
const variantOutlineColor =
disabled || loading ? colors.fillColorNeutral090 : colors.fillColorNeutral100Inverted
const variantStyleMap: VariantColors = {
fill: {
backgroundColor: 'transparent',
color: tokens.colorNeutral100White,
},
outline: {
backgroundColor: variantOutlineColor,
color: colorOptionMap[appearance],
},
}
return StyleSheet.create({
pressable: {
borderRadius: sizeStyleMap[size].borderRadius,
overflow: 'hidden',
},
colorWrapper: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
borderRadius: sizeStyleMap[size].borderRadius,
},
innerWrapper: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
margin: outlineMarginOffset,
gap: sizeStyleMap[size].gap,
borderRadius: sizeStyleMap[size].borderRadius - outlineMarginOffset,
height: sizeStyleMap[size].height,
backgroundColor: variantStyleMap[variant].backgroundColor,
paddingHorizontal: sizeStyleMap[size].paddingHorizontal,
},
text: {
textAlign: 'center',
textAlignVertical: 'center',
includeFontPadding: false,
fontWeight: platformFontWeightBold,
fontSize: sizeStyleMap[size].fontSize,
color: variantStyleMap[variant].color,
},
textDisabled: {
color: colors.textColorDefaultDisabled,
},
textLoading: {
opacity: 0,
},
icon: {
fontSize: sizeStyleMap[size].fontSize,
color: variantStyleMap[variant].color,
},
iconDisabled: {
color: colors.iconColorDefaultDisabled,
},
iconLoading: {
opacity: 0,
},
spinnerSize: {
fontSize: sizeStyleMap[size].fontSize,
},
spinner: {
width: '100%',
height: '100%',
position: 'absolute',
justifyContent: 'center',
alignItems: 'center',
zIndex: 2,
},
})
}