import { CheckCircleRegularIcon } from "alouette-icons/phosphor-icons/CheckCircle"; import { WarningDuotoneIcon } from "alouette-icons/phosphor-icons/Warning"; import { type ReactNode, type Ref, useEffect, useState } from "react"; import type { View as RNView } from "react-native"; import type { VariantProps } from "tailwind-variants"; import type { Density, controlHeightsPx } from "../../controlSizes"; import type { Accent, AccentOrNeutral } from "../../core/AlouetteConfig"; import { useDensity } from "../../core/DensityContext"; import { useAccentOrInheritedOrBrand } from "../../core/ThemeContext"; import { tv } from "../../core/tv"; import { ExternalLink } from "../../expo/ExternalLink"; import { type ExternalOpenLinkBehavior, defaultExternalOpenLinkBehavior, } from "../../expo/ExternalLink.shared"; import { AccentScope } from "../containers/AccentScope"; import { mergeTooltipTriggerProps } from "../containers/mergeTooltipTriggerProps"; import { type CircularProgressSize, IndeterminateCircularProgress, } from "../feedback/CircularProgress"; import { indeterminateExitDurationMs } from "../feedback/useSimulatedProgress"; import { Icon, type SVGIconElement } from "../primitives/Icon"; import { InteractiveIcon } from "../primitives/InteractiveIcon"; import { Text } from "../primitives/Text"; import { View } from "../primitives/View"; import { DisabledReasonTooltip, hasDisabledReason, } from "./DisabledReasonTooltip"; import { PressableBox, type PressableBoxProps } from "./PressableBox"; /** `lg` is the prominent call to action of a landing page or a hero. */ export type ButtonSize = keyof (typeof controlHeightsPx)["comfortable"]; interface ButtonSizeMetrics { iconSize: number; terminalIconSize: number; spinnerSize: CircularProgressSize; } // The leading icon matches the label, whose size the density keeps; the // terminal icon and the spinner overlay the whole button, so they follow its // height. const buttonSizeMetrics: Record< Density, Record > = { comfortable: { sm: { iconSize: 16, terminalIconSize: 24, spinnerSize: "xs" }, md: { iconSize: 20, terminalIconSize: 32, spinnerSize: "sm" }, lg: { iconSize: 24, terminalIconSize: 36, spinnerSize: "sm" }, }, compact: { sm: { iconSize: 16, terminalIconSize: 20, spinnerSize: "xs" }, md: { iconSize: 20, terminalIconSize: 28, spinnerSize: "sm" }, lg: { iconSize: 24, terminalIconSize: 32, spinnerSize: "sm" }, }, }; const buttonVariants = tv({ slots: { frame: "flex-row flex-center relative", text: "font-body-bold text-center shrink transition-opacity duration-fade", icon: "", terminalIcon: "text-accent", overlayIconContainer: "absolute inset-0 flex-center", }, variants: { size: { sm: { frame: "rounded-full px-sm gap-xxs min-h-control-sm", text: "text-sm py-xxs", }, md: { frame: "rounded-full px-m gap-xs min-h-control-md", text: "text-base py-control-inset", }, lg: { frame: "rounded-full px-l gap-sm min-h-control-lg", text: "text-lg py-control-inset", }, }, variant: { tonal: {}, filled: {}, outlined: {}, soft: {}, }, disabled: { true: {}, false: {} }, dimmed: { true: { text: "opacity-30", icon: "opacity-30" }, false: {}, }, }, compoundVariants: [ { variant: "tonal", disabled: false, class: { text: "text-on-tonal", icon: "text-on-tonal" }, }, { variant: "filled", disabled: false, class: { text: "text-on-accent", icon: "text-on-accent" }, }, { variant: "outlined", disabled: false, class: { text: "text-sharp", icon: "text-sharp" }, }, { variant: "soft", disabled: false, class: { text: "text-accent underline", icon: "text-accent" }, }, { variant: "tonal", disabled: true, class: { icon: "text-disabled-sharp", text: "text-disabled-sharp" }, }, { variant: "filled", disabled: true, class: { icon: "text-disabled-sharp", text: "text-disabled-sharp" }, }, { variant: "outlined", disabled: true, class: { icon: "text-disabled-muted", text: "text-disabled-muted" }, }, { variant: "soft", disabled: true, class: { icon: "text-disabled-muted", text: "text-disabled-muted" }, }, ], defaultVariants: { size: "md", variant: "tonal" }, }); interface ButtonSizeProps { /** `lg` is for the prominent call to action of a landing page or a hero. */ size?: ButtonSize; } export type ButtonState = "failed" | "loading" | "success"; function resolveTerminalIcon(state: ButtonState | undefined): { terminalIcon: SVGIconElement | undefined; terminalIconAccent: Accent | undefined; } { if (state === "success") { return { terminalIcon: , terminalIconAccent: "success", }; } if (state === "failed") { return { terminalIcon: , terminalIconAccent: "danger", }; } return { terminalIcon: undefined, terminalIconAccent: undefined }; } export interface ButtonProps extends Omit, ButtonSizeProps { variant?: VariantProps["variant"]; icon?: SVGIconElement; /** Replaces `icon` while the button is hovered, focused or pressed. */ activeIcon?: SVGIconElement; /** Defaults to the inherited accent, or `brand` outside an accent scope. */ accent?: AccentOrNeutral; text: ReactNode; state?: ButtonState; /** * Why the button is disabled. A non-empty reason disables it, describes it * with the reason and, on web, shows the reason on hover. */ disabledReason?: string | null; /** * Forwarded to the underlying `PressableBox`, so the button can anchor a * `Popover` or a `Menu`. React 19 carries it in with the other props. */ ref?: Ref; } interface IsButtonDisabledParams { disabled?: boolean | null; disabledReason?: string | null; state?: ButtonState; } function isButtonDisabled({ disabled, disabledReason, state, }: IsButtonDisabledParams): boolean { return ( disabled === true || hasDisabledReason(disabledReason) || state != null ); } function ButtonPressable({ icon, activeIcon, text, disabled, state, accent: accentProp, variant, size = "md", className, forceStyle, ...pressableProps }: Omit): ReactNode { const accent = useAccentOrInheritedOrBrand(accentProp); const density = useDensity(); const isLoading = state === "loading"; // Keep the spinner (and the disabled look) mounted past `state` leaving // "loading" so the ring's complete-then-fade animation can finish, and the // background transitions back in step with it, instead of both being cut // off mid-animation. const [showSpinner, setShowSpinner] = useState(isLoading); useEffect(() => { if (isLoading) { setShowSpinner(true); return undefined; } const timer = setTimeout(() => { setShowSpinner(false); }, indeterminateExitDurationMs); return () => { clearTimeout(timer); }; }, [isLoading]); const { terminalIcon, terminalIconAccent } = resolveTerminalIcon(state); const hasOverlayIcon = showSpinner || terminalIcon !== undefined; const isDisabled = isButtonDisabled({ disabled, state }); const { iconSize, terminalIconSize, spinnerSize } = buttonSizeMetrics[density][size]; const styles = buttonVariants({ size, variant, disabled: isDisabled, dimmed: hasOverlayIcon, }); return ( {hasOverlayIcon ? ( {showSpinner || !terminalIcon ? ( ) : ( )} ) : null} {icon ? ( ) : null} {text} ); } export function Button({ disabledReason, ...buttonProps }: ButtonProps): ReactNode { return ( ( )} /> ); } export interface ExternalLinkButtonProps extends ButtonProps { href: string; /** How the link opens. Defaults to an in-app browser sheet / a new tab. */ openLinkBehavior?: ExternalOpenLinkBehavior; } export function ExternalLinkButton({ href, openLinkBehavior = defaultExternalOpenLinkBehavior, onPress, ...buttonProps }: ExternalLinkButtonProps): ReactNode { return ( ); } export interface InternalLinkButtonProps extends ButtonProps { href: string; } export function InternalLinkButton({ href: _href, ...buttonProps }: InternalLinkButtonProps): ReactNode { return