"use client"; import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from "motion/react"; import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; import { EASE_OUT, EASE_OUT_CSS, SPRING_PRESS, SPRING_SWAP } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type ActionSwapItem = { id: string; label: ReactNode; icon?: ReactNode; ariaLabel?: string; }; export type ActionSwapButtonVariant = "primary" | "secondary" | "outline" | "ghost"; export type ActionSwapButtonSize = "sm" | "md" | "lg" | "icon"; export type ActionSwapAnimation = "blur" | "roll" | "cascade"; /** Animations with a single-element variant set (cascade animates per letter). */ type CoreAnimation = "blur" | "roll"; export interface ActionSwapButtonProps extends Omit< HTMLMotionProps<"button">, "children" | "onChange" > { items: ActionSwapItem[]; value?: string; defaultValue?: string; onValueChange?: (value: string, item: ActionSwapItem) => void; variant?: ActionSwapButtonVariant; size?: ActionSwapButtonSize; animation?: ActionSwapAnimation; iconOnly?: boolean; cycle?: boolean; } export interface ActionSwapTextProps { value: string; children: ReactNode; animation?: ActionSwapAnimation; className?: string; } export interface ActionSwapIconProps { value: string; children: ReactNode; animation?: ActionSwapAnimation; className?: string; } const BLUR_TRANSITION = { duration: 0.2, ease: "easeInOut" } as const; const ROLL_TRANSITION = { duration: 0.24, ease: EASE_OUT } as const; const SWAP_BLUR = "blur(8px)"; const ROLL_BLUR = "blur(6px)"; // Cascade rolls the label one letter at a time, left to right. The leaving // and landing strings overlap as independent layers (no shared cells), so // proportional glyph widths never jitter. Exits cascade at half the enter // stagger so the tail of the old label lingers briefly. const CASCADE_STAGGER = 0.025; const CASCADE_LETTER_VARIANTS: Variants = { initial: { opacity: 0, y: "105%", filter: ROLL_BLUR }, animate: (delay: number = 0) => ({ opacity: 1, y: "0%", filter: "blur(0px)", transition: { ...SPRING_SWAP, delay }, }), exit: (delay: number = 0) => ({ opacity: 0, y: "-105%", filter: ROLL_BLUR, transition: { duration: 0.16, ease: EASE_OUT, delay: delay * 0.5 }, }), }; const TEXT_VARIANTS: Record = { blur: { initial: { opacity: 0, scale: 0.94, filter: SWAP_BLUR }, animate: { opacity: 1, scale: 1, filter: "blur(0px)", transition: BLUR_TRANSITION, }, exit: { opacity: 0, scale: 0.94, filter: SWAP_BLUR, transition: BLUR_TRANSITION, }, }, roll: { initial: { opacity: 0, y: "115%", filter: ROLL_BLUR }, animate: { opacity: 1, y: "0%", filter: "blur(0px)", transition: ROLL_TRANSITION, }, exit: { opacity: 0, y: "-115%", filter: ROLL_BLUR, transition: { duration: 0.18, ease: "easeInOut" }, }, }, }; const ICON_VARIANTS: Record = { blur: { initial: { opacity: 0, scale: 0.25, filter: SWAP_BLUR }, animate: { opacity: 1, scale: 1, filter: "blur(0px)", transition: BLUR_TRANSITION, }, exit: { opacity: 0, scale: 0.25, filter: SWAP_BLUR, transition: BLUR_TRANSITION, }, }, roll: { initial: { opacity: 0, y: 16, filter: ROLL_BLUR }, animate: { opacity: 1, y: 0, filter: "blur(0px)", transition: ROLL_TRANSITION, }, exit: { opacity: 0, y: -16, filter: ROLL_BLUR, transition: { duration: 0.18, ease: "easeInOut" }, }, }, }; const VARIANT_CLASS: Record = { primary: "bg-primary text-primary-foreground hover:bg-primary/90", secondary: "border border-border bg-card text-foreground hover:border-border", outline: "border border-border bg-transparent text-foreground hover:bg-primary/5", ghost: "text-muted-foreground hover:bg-primary/5 hover:text-foreground", }; const SIZE_CLASS: Record = { sm: "h-8 gap-1.5 rounded-full px-3 text-xs", md: "h-10 gap-2 rounded-full px-4 text-sm", lg: "h-12 gap-2.5 rounded-full px-5 text-base", icon: "h-10 w-10 rounded-full", }; export function ActionSwapText({ value, children, animation = "blur", className, }: ActionSwapTextProps) { const reduce = useReducedMotion(); const measureRef = useRef(null); const [width, setWidth] = useState(); useLayoutEffect(() => { const nextWidth = measureRef.current?.offsetWidth; if (!nextWidth) return; setWidth((currentWidth) => (currentWidth === nextWidth ? currentWidth : nextWidth)); }); // Cascade needs a plain string to split into letters; non-string content // and reduced motion fall back to the closest single-element animation. const label = typeof children === "string" ? children : null; const cascade = animation === "cascade" && label !== null && !reduce; const coreAnimation: CoreAnimation = animation === "cascade" ? "roll" : animation; return ( {children} {cascade ? ( <> {/* Letters are decorative fragments; readers get the whole label. */} {label} {label.split("").map((char, i) => ( {char} ))} ) : ( {children} )} ); } export function ActionSwapIcon({ value, children, animation = "blur", className, }: ActionSwapIconProps) { const reduce = useReducedMotion(); // Icons are single elements — cascade maps to its closest motion, roll. const coreAnimation: CoreAnimation = animation === "cascade" ? "roll" : animation; return ( {children} ); } export function ActionSwapButton({ items, value, defaultValue, onValueChange, variant = "secondary", size = "md", animation = "blur", iconOnly = size === "icon", cycle = true, className, disabled, onClick, ...rest }: ActionSwapButtonProps) { const reduce = useReducedMotion(); const [internalValue, setInternalValue] = useState(defaultValue ?? items[0]?.id); const currentValue = value ?? internalValue; const activeIndex = Math.max(0, items.findIndex((item) => item.id === currentValue)); const activeItem = items[activeIndex] ?? items[0]; const hasIcon = items.some((item) => item.icon); const nextItem = cycle && items.length > 0 ? items[(activeIndex + 1) % items.length] : undefined; if (!activeItem) return null; const accessibleLabel = activeItem.ariaLabel ?? (iconOnly && typeof activeItem.label === "string" ? activeItem.label : undefined); return ( { onClick?.(event); if (event.defaultPrevented || disabled || !cycle || !nextItem) return; if (value === undefined) setInternalValue(nextItem.id); onValueChange?.(nextItem.id, nextItem); }} {...rest} > {hasIcon ? ( {activeItem.icon ?? null} ) : null} {!iconOnly ? ( {activeItem.label} ) : null} ); }