"use client"; import { AnimatePresence, motion, useReducedMotion, type HTMLMotionProps, type Variants } from "motion/react"; import { useLayoutEffect, useRef, useState, type ReactNode } from "react"; 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"; 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: [0.22, 1, 0.36, 1] } as const; const SWAP_BLUR = "blur(8px)"; const ROLL_BLUR = "blur(6px)"; 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)); }); return ( {children} {children} ); } export function ActionSwapIcon({ value, children, animation = "blur", className, }: ActionSwapIconProps) { const reduce = useReducedMotion(); 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} ); }