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, }, }) }