"use client"; import { AnimatePresence, type HTMLMotionProps, motion, type Transition, useReducedMotion, type Variants, } from "motion/react"; import { type MouseEvent, type ReactNode, useCallback, useRef, useState, } from "react"; import { cn } from "@/lib/utils"; // Matches the Motion Patterns "Layout continuity" recipe so the surface // keeps its identity while its footprint changes. const CONTINUITY_SPRING: Transition = { type: "spring", stiffness: 220, damping: 17, mass: 0.85, }; const CONTINUITY_LABEL: Variants = { hidden: { opacity: 0, filter: "blur(4px)" }, visible: { opacity: 1, filter: "blur(0px)" }, exit: { opacity: 0, filter: "blur(4px)" }, }; type ExpandableStateProps = { expanded?: boolean; defaultExpanded?: boolean; onExpandedChange?: (expanded: boolean) => void; }; export interface ExpandableButtonProps extends Omit, "children">, ExpandableStateProps { icon: ReactNode; label: ReactNode; } export interface ExpandableChipProps extends ExpandableStateProps { label: ReactNode; actionIcon: ReactNode; actionLabel: string; onAction?: () => void; collapseOnAction?: boolean; disabled?: boolean; className?: string; labelClassName?: string; actionClassName?: string; } function useExpandableState({ expanded, defaultExpanded = false, onExpandedChange, }: ExpandableStateProps) { const [internalExpanded, setInternalExpanded] = useState(defaultExpanded); const isControlled = expanded !== undefined; const currentExpanded = expanded ?? internalExpanded; const setExpanded = useCallback( (nextExpanded: boolean) => { if (!isControlled) { setInternalExpanded(nextExpanded); } onExpandedChange?.(nextExpanded); }, [isControlled, onExpandedChange], ); return [currentExpanded, setExpanded] as const; } export function ExpandableButton({ icon, label, expanded, defaultExpanded, onExpandedChange, className, onClick, disabled, type = "button", "aria-label": ariaLabel, ...props }: ExpandableButtonProps) { const reduce = useReducedMotion(); const [isExpanded, setExpanded] = useExpandableState({ expanded, defaultExpanded, onExpandedChange, }); const handleClick = (event: MouseEvent) => { onClick?.(event); if (!event.defaultPrevented) { setExpanded(!isExpanded); } }; const transition = reduce ? { duration: 0 } : CONTINUITY_SPRING; return ( {isExpanded ? ( ) : null} ); } export function ExpandableChip({ label, actionIcon, actionLabel, onAction, collapseOnAction = true, expanded, defaultExpanded, onExpandedChange, disabled, className, labelClassName, actionClassName, }: ExpandableChipProps) { const reduce = useReducedMotion(); const triggerRef = useRef(null); const [isExpanded, setExpanded] = useExpandableState({ expanded, defaultExpanded, onExpandedChange, }); const handleAction = () => { onAction?.(); if (collapseOnAction) { setExpanded(false); triggerRef.current?.focus(); } }; const transition = reduce ? { duration: 0 } : CONTINUITY_SPRING; return ( setExpanded(!isExpanded)} transition={transition} > {label} ); }