import React from "react"; import { ActivityIndicator, Pressable, type PressableProps, Text as RNText } from "react-native"; import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; import { useColorScheme } from "nativewind"; import { cn } from "../utils/cn"; import { withClickTracking } from "../services/telemetry"; export interface ButtonProps extends Omit { variant?: "default" | "ghost" | "outline" | "destructive"; size?: "sm" | "default" | "lg"; className?: string; textClassName?: string; children?: React.ReactNode; isLoading?: boolean; disabled?: boolean; /** Override the loading spinner colour (otherwise derived from the variant). */ spinnerColor?: string; /** * Stable, author-assigned id reported when this control is activated, e.g. * `"login.submit"`. Omit it and nothing is tracked. * * Deliberately not derived from the button's label: copy changes over time, * differs per locale, and can interpolate user data — all of which would * either break a dashboard or leak into one. */ telemetryId?: string; } const sizeClass = { sm: "h-10 px-4", default: "h-12 px-4", lg: "h-14 px-6" } as const; const textSizeClass = { sm: "text-sm", default: "text-base", lg: "text-lg" } as const; // Colours are driven by the active theme's CSS variables (`--primary`, // `--destructive`, …) so each consuming app's palette flows through without // the component hardcoding anything. // // Each also carries an explicit `dark:` variant matching that same CSS // variable's `.dark` value one-for-one (see styles/globals.css) — belt and // suspenders for native, where a bare CSS-var utility with no `dark:` variant // has been observed stuck on its light value regardless of actual system // theme, while genuine `dark:`-prefixed utilities react correctly (matches // the codebase's already-working pattern in components/text.tsx). Values // omitted below (destructive-foreground) are identical light/dark in // globals.css, so no variant is needed. const bgClass = { default: "bg-primary dark:bg-white", destructive: "bg-destructive dark:bg-red-900", outline: "border border-border dark:border-neutral-800 bg-transparent", ghost: "bg-transparent", } as const; const fgClass = { default: "text-primary-foreground dark:text-black", destructive: "text-destructive-foreground", outline: "text-foreground dark:text-white", ghost: "text-foreground dark:text-white", } as const; export function Button({ variant = "default", size = "default", className, textClassName, children, isLoading = false, disabled = false, spinnerColor, telemetryId, onPress, onPressIn, onPressOut, ...props }: ButtonProps) { const { colorScheme } = useColorScheme(); const isDark = colorScheme === "dark"; const isDisabled = disabled || isLoading; // Wrapped rather than reported from a press listener so a disabled or // loading button records nothing: Pressable does not fire onPress in either // state, and counting presses the user never actually completed would // overstate every funnel it appears in. const handlePress = withClickTracking(telemetryId, onPress); // Reanimated feedback shared by every button: hover (web) lifts slightly, // press dips + dims — two distinct, layered responses. const pressed = useSharedValue(0); const hovered = useSharedValue(0); const pressStyle = useAnimatedStyle(() => ({ transform: [{ scale: 1 + hovered.value * 0.025 - pressed.value * 0.06 }], opacity: 1 - pressed.value * 0.08, })); const spinner = spinnerColor ?? (variant === "default" ? isDark ? "#000000" : "#FFFFFF" : variant === "destructive" ? "#FFFFFF" : isDark ? "#FFFFFF" : "#000000"); return ( { pressed.value = withTiming(1, { duration: 90 }); onPressIn?.(e); }} onPressOut={(e) => { pressed.value = withTiming(0, { duration: 150 }); onPressOut?.(e); }} onHoverIn={() => (hovered.value = withTiming(1, { duration: 120 }))} onHoverOut={() => (hovered.value = withTiming(0, { duration: 160 }))} {...props} focusable={!isDisabled} accessibilityState={{ disabled: isDisabled, busy: isLoading }} > {isLoading ? ( ) : typeof children === "string" ? ( {children} ) : ( children )} ); } export function IconButton({ size = "default", children, className, telemetryId, onPress, ...props }: ButtonProps) { // IconButton accepts ButtonProps, so telemetryId type-checks on it whether or // not it is honoured. Wiring it here means a tagged icon button reports, // rather than silently being the one control that never appears. const handlePress = withClickTracking(telemetryId, onPress); const dim = { sm: 40, default: 48, lg: 56 }[size]; const isDisabled = props.disabled || props.isLoading; const pressed = useSharedValue(0); const hovered = useSharedValue(0); const pressStyle = useAnimatedStyle(() => ({ transform: [{ scale: 1 + hovered.value * 0.06 - pressed.value * 0.08 }], })); return ( (pressed.value = withTiming(1, { duration: 90 }))} onPressOut={() => (pressed.value = withTiming(0, { duration: 150 }))} onHoverIn={() => (hovered.value = withTiming(1, { duration: 120 }))} onHoverOut={() => (hovered.value = withTiming(0, { duration: 160 }))} {...props} focusable={!isDisabled} accessibilityState={{ disabled: isDisabled, busy: props.isLoading }} > {children} ); }