"use client"; import { MoreHorizontal, X } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion, type Transition, type Variants, } from "motion/react"; import { useCallback, useId, useLayoutEffect, useRef, useState, type ReactNode, } from "react"; import { EASE_OUT } from "@/lib/ease"; import { useHoverCapable } from "@/lib/hooks/use-hover-capable"; import { cn } from "@/lib/utils"; export type OverflowActionsSize = "sm" | "md"; export type OverflowActionItem = { id: string; label: ReactNode; icon?: ReactNode; onClick?: () => void; disabled?: boolean; ariaLabel?: string; }; export type OverflowActionsClassNames = { root?: string; track?: string; action?: string; primaryAction?: string; overflowAction?: string; toggle?: string; icon?: string; label?: string; }; export interface OverflowActionsProps { primaryActions: OverflowActionItem[]; overflowActions: OverflowActionItem[]; expanded?: boolean; defaultExpanded?: boolean; onExpandedChange?: (expanded: boolean) => void; onAction?: (item: OverflowActionItem) => void; collapseOnAction?: boolean; size?: OverflowActionsSize; openLabel?: string; closeLabel?: string; className?: string; classNames?: OverflowActionsClassNames; } // This needs a softer layout spring than the app defaults so the overflow group // stays visually attached to the toggle while entering and leaving. const SHELL_TRANSITION: Transition = { type: "spring", stiffness: 220, damping: 17, mass: 0.85, }; const ICON_VARIANTS: Variants = { hidden: { opacity: 0, filter: "blur(3px)" }, visible: { opacity: 1, filter: "blur(0px)", transition: { duration: 0.18, ease: EASE_OUT }, }, exit: { opacity: 0, filter: "blur(3px)", transition: { duration: 0.18, ease: EASE_OUT }, }, }; const OVERFLOW_ACTION_VARIANTS: Variants = { hidden: { opacity: 0, filter: "blur(4px)" }, visible: { opacity: 1, filter: "blur(0px)" }, exit: { opacity: 0, filter: "blur(4px)" }, }; const TRACK_SIZE_CLASS: Record = { sm: "gap-1 p-1 text-xs", md: "gap-1.5 p-1.5 text-sm", }; const GROUP_GAP_CLASS: Record = { sm: "gap-1", md: "gap-1.5", }; const ACTION_SIZE_CLASS: Record = { sm: "h-8 min-w-8 gap-1.5 px-3", md: "h-9 min-w-9 gap-2 px-3.5", }; const TOGGLE_SIZE_CLASS: Record = { sm: "h-8 w-8", md: "h-9 w-9", }; const ICON_SIZE_CLASS: Record = { sm: "h-3.5 w-3.5", md: "h-4 w-4", }; function useControllableExpanded({ expanded, defaultExpanded, onExpandedChange, }: { expanded?: boolean; defaultExpanded?: boolean; onExpandedChange?: (expanded: boolean) => void; }) { const [internalExpanded, setInternalExpanded] = useState( defaultExpanded ?? false, ); const isControlled = expanded !== undefined; const value = expanded ?? internalExpanded; const setValue = useCallback( (next: boolean) => { if (!isControlled) setInternalExpanded(next); onExpandedChange?.(next); }, [isControlled, onExpandedChange], ); return [value, setValue] as const; } export function OverflowActions({ primaryActions, overflowActions, expanded, defaultExpanded = false, onExpandedChange, onAction, collapseOnAction = false, size = "md", openLabel = "Show extra actions", closeLabel = "Hide extra actions", className, classNames, }: OverflowActionsProps) { const reduce = useReducedMotion(); const canHover = useHoverCapable(); const overflowId = useId(); const overflowWrapperRef = useRef(null); const overflowWrapperLeftRef = useRef(0); const [isExpanded, setIsExpanded] = useControllableExpanded({ expanded, defaultExpanded, onExpandedChange, }); const transition = reduce ? { duration: 0 } : SHELL_TRANSITION; useLayoutEffect(() => { const overflowNode = overflowWrapperRef.current; if (!overflowNode) return; if (!isExpanded) { overflowNode.style.left = `${ overflowWrapperLeftRef.current - overflowNode.getBoundingClientRect().left }px`; return; } overflowNode.style.left = ""; overflowWrapperLeftRef.current = overflowNode.getBoundingClientRect().left; }, [isExpanded]); const handleAction = (item: OverflowActionItem) => { item.onClick?.(); onAction?.(item); if (collapseOnAction) setIsExpanded(false); }; return ( {primaryActions.map((item) => ( ))} {isExpanded ? ( {overflowActions.map((item) => ( ))} ) : null} setIsExpanded(!isExpanded)} whileTap={reduce ? undefined : { scale: 0.96 }} whileHover={reduce || !canHover ? undefined : { scale: 1.03 }} transition={transition} className={cn( "relative inline-grid shrink-0 place-items-center rounded-full bg-primary text-primary-foreground outline-none disabled:pointer-events-none disabled:opacity-50", "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background", TOGGLE_SIZE_CLASS[size], classNames?.toggle, )} > {isExpanded ? ( ) : ( )} ); } function ActionButton({ item, size, reduce, canHover, overflow, visible = true, variants, onAction, layoutTransition, className, iconClassName, labelClassName, }: { item: OverflowActionItem; size: OverflowActionsSize; reduce: boolean | null; canHover: boolean; overflow?: boolean; visible?: boolean; variants?: Variants; onAction: (item: OverflowActionItem) => void; layoutTransition: Transition; className?: string; iconClassName?: string; labelClassName?: string; }) { const label = typeof item.label === "string" ? item.label : undefined; return ( ); }