import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { default as React, useCallback, useMemo, useState, type ComponentType, type PropsWithChildren, type ReactElement, type ReactNode, } from "react"; import { Pressable, Text, View } from "react-native"; import type { PressableProps, PressableStateCallbackType, StyleProp, TextStyle, ViewStyle, } from "react-native"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { buttonIconSize, createControlGeometry, type ButtonControlSize, } from "@/components/ui/control-geometry"; import type { Theme } from "@/styles/theme"; type ButtonVariant = "default" | "secondary" | "outline" | "ghost" | "destructive"; type ButtonSize = ButtonControlSize; type LeftIcon = | ReactElement | ComponentType<{ color: string; size: number }> | ((color: string) => ReactElement) | null; interface ButtonIconProps { loading: boolean; leftIcon?: LeftIcon; iconSize: number; iconColor: string; } function ButtonIcon({ loading, leftIcon, iconSize, iconColor }: ButtonIconProps) { if (loading) { return ( ); } if (!leftIcon) return null; if (typeof leftIcon === "object" && "type" in leftIcon) { return {leftIcon}; } if ( typeof leftIcon === "function" && !leftIcon.prototype?.isReactComponent && leftIcon.length > 0 ) { return {(leftIcon as (color: string) => ReactElement)(iconColor)}; } const Icon = leftIcon as ComponentType<{ color: string; size: number }>; return ( ); } const ThemedButtonIcon = withUnistyles(ButtonIcon); const foregroundIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foreground }); const foregroundMutedIconMapping = (theme: Theme) => ({ iconColor: theme.colors.foregroundMuted, }); const accentForegroundIconMapping = (theme: Theme) => ({ iconColor: theme.colors.accentForeground, }); const destructiveForegroundIconMapping = (theme: Theme) => ({ iconColor: theme.colors.destructiveForeground, }); const styles = StyleSheet.create((theme) => { const geometry = createControlGeometry(theme); return { base: { flexDirection: "row", alignItems: "center", justifyContent: "center", gap: theme.spacing[2], borderRadius: theme.borderRadius.lg, borderWidth: 1, borderColor: "transparent", }, md: { ...geometry.buttonMd, }, xs: { ...geometry.buttonXs, }, sm: { ...geometry.buttonSm, }, lg: { ...geometry.buttonLg, }, default: { backgroundColor: theme.colors.accent, borderColor: theme.colors.accent, }, secondary: { backgroundColor: theme.colors.surface3, borderColor: theme.colors.surface3, }, outline: { backgroundColor: "transparent", borderColor: theme.colors.borderAccent, }, ghost: { backgroundColor: "transparent", borderColor: "transparent", }, destructive: { backgroundColor: theme.colors.destructive, borderColor: theme.colors.destructive, }, pressed: { opacity: 0.85, }, disabled: { opacity: theme.opacity[50], }, text: { color: theme.colors.foreground, ...geometry.buttonText, fontWeight: theme.fontWeight.normal, }, textXs: { ...geometry.buttonTextXs, }, textDefault: { color: theme.colors.accentForeground, }, textDestructive: { color: theme.colors.destructiveForeground, }, textGhost: { color: theme.colors.foregroundMuted, }, textGhostHovered: { color: theme.colors.foreground, }, }; }); export function Button({ children, variant = "secondary", size = "md", leftIcon, trailing, style, textStyle, disabled, loading = false, accessibilityRole, accessibilityState: accessibilityStateProp, ...props }: PropsWithChildren< Omit & { ref?: React.Ref; variant?: ButtonVariant; size?: ButtonSize; leftIcon?: LeftIcon; trailing?: ReactNode; style?: StyleProp; textStyle?: StyleProp; loading?: boolean; } >) { const [hovered, setHovered] = useState(false); const isDisabled = disabled || loading; let variantStyle: ViewStyle; if (variant === "default") { variantStyle = styles.default; } else if (variant === "secondary") { variantStyle = styles.secondary; } else if (variant === "outline") { variantStyle = styles.outline; } else if (variant === "ghost") { variantStyle = styles.ghost; } else { variantStyle = styles.destructive; } let sizeStyle: ViewStyle; if (size === "xs") { sizeStyle = styles.xs; } else if (size === "sm") { sizeStyle = styles.sm; } else if (size === "lg") { sizeStyle = styles.lg; } else { sizeStyle = styles.md; } const isGhostHovered = hovered && variant === "ghost"; const handleHoverIn = useCallback(() => setHovered(true), []); const handleHoverOut = useCallback(() => setHovered(false), []); const pressableStyle = useCallback( ({ pressed }: PressableStateCallbackType): StyleProp => [ styles.base, sizeStyle, variantStyle, pressed ? styles.pressed : null, isDisabled ? styles.disabled : null, style, ], [sizeStyle, variantStyle, isDisabled, style], ); const resolvedTextStyle = useMemo( () => [ styles.text, size === "xs" ? styles.textXs : null, variant === "default" ? styles.textDefault : null, variant === "destructive" ? styles.textDestructive : null, variant === "ghost" ? styles.textGhost : null, textStyle, isGhostHovered ? styles.textGhostHovered : null, ], [size, variant, textStyle, isGhostHovered], ); const accessibilityState = useMemo( () => ({ ...accessibilityStateProp, disabled: isDisabled, busy: loading }), [accessibilityStateProp, isDisabled, loading], ); function resolveIconMapping() { if (variant === "default") { return accentForegroundIconMapping; } if (variant === "destructive") { return destructiveForegroundIconMapping; } if (variant === "ghost") { return isGhostHovered ? foregroundIconMapping : foregroundMutedIconMapping; } return foregroundIconMapping; } return ( {children != null ? {children} : null} {trailing} ); }