"use client";import * as React from "react";import { EASE_OUT, INTERACTION_FOCUS, useInteractionReducedMotion,} from "./ui-interaction-motion";export type ExpandingItemTone = "default" | "primary" | "danger";export interface ExpandingButtonItem { id: string; label: string; icon: React.ReactNode; tone?: ExpandingItemTone; confirmLabel?: string; onClick?: () => void; disabled?: boolean;}export interface ExpandingButtonGroupProps extends React.HTMLAttributes<HTMLDivElement> { items: ExpandingButtonItem[]; /** Controlled active/expanded item id. If not provided, hover/focus controls expansion. */ expandedId?: string | null; /** Initial item to be expanded. */ defaultExpandedId?: string | null; /** Visual density. Defaults to md. */ size?: "sm" | "md"; /** Aria label for the toolbar group. */ label?: string;}const EASE = "cubic-bezier(0.16, 1, 0.3, 1)";function InlineCheck({ size }: { size: number }) { return ( <svg width={size} height={size} viewBox="0 0 16 16" fill="none" className="shrink-0"> <path d="M3.5 8.5 6.5 11.5 12.5 4.5" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" /> </svg> );}const toneStyles = { default: { idle: "text-muted-foreground hover:text-foreground hover:bg-muted/70", expanded: "bg-muted text-foreground font-semibold", activePress: "scale-[0.96]", }, primary: { idle: "text-muted-foreground hover:text-foreground hover:bg-muted/70", expanded: "bg-primary text-primary-foreground font-semibold shadow-[0_1px_2px_rgba(48,93,222,0.18)]", activePress: "scale-[0.96]", }, danger: { idle: "text-muted-foreground hover:text-red-600 hover:bg-red-500/10 dark:hover:text-red-400", expanded: "bg-red-500/10 text-red-600 dark:text-red-400 font-semibold shadow-[inset_0_0_0_1px_color-mix(in_oklab,var(--color-red-500)_25%,transparent)]", activePress: "scale-[0.96]", },};export function ExpandingButtonGroup({ items, expandedId: controlledExpandedId, defaultExpandedId = null, size = "md", label = "Action toolbar", className = "", style, ...props}: ExpandingButtonGroupProps) { const reduced = useInteractionReducedMotion(); const [internalExpandedId, setInternalExpandedId] = React.useState<string | null>( defaultExpandedId ); const [confirmedId, setConfirmedId] = React.useState<string | null>(null); const [pressedId, setPressedId] = React.useState<string | null>(null); const groupRef = React.useRef<HTMLDivElement | null>(null); const confirmTimer = React.useRef<number>(0); const activeId = controlledExpandedId !== undefined ? controlledExpandedId : internalExpandedId; // Clean timer on unmount React.useEffect(() => () => window.clearTimeout(confirmTimer.current), []); const handlePointerEnter = (id: string, disabled?: boolean) => { if (disabled) return; setInternalExpandedId(id); }; const handlePointerLeave = () => { if (controlledExpandedId === undefined) { setInternalExpandedId(null); } }; const handleFocus = (id: string, disabled?: boolean) => { if (disabled) return; setInternalExpandedId(id); }; const handleBlur = (e: React.FocusEvent) => { if (groupRef.current?.contains(e.relatedTarget as Node)) return; if (controlledExpandedId === undefined) { setInternalExpandedId(null); } }; const handleClick = (item: ExpandingButtonItem) => { if (item.disabled) return; if (item.confirmLabel) { setConfirmedId(item.id); window.clearTimeout(confirmTimer.current); confirmTimer.current = window.setTimeout(() => { setConfirmedId(null); }, 1600); } item.onClick?.(); }; // Keyboard navigation across items const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => { const buttons = Array.from( groupRef.current?.querySelectorAll<HTMLButtonElement>("button[data-expanding-item]") ?? [] ); const currentIndex = buttons.findIndex((btn) => btn === document.activeElement); if (currentIndex < 0) return; let nextIndex = -1; if (e.key === "ArrowRight") { nextIndex = currentIndex < buttons.length - 1 ? currentIndex + 1 : 0; } else if (e.key === "ArrowLeft") { nextIndex = currentIndex > 0 ? currentIndex - 1 : buttons.length - 1; } else if (e.key === "Home") { nextIndex = 0; } else if (e.key === "End") { nextIndex = buttons.length - 1; } if (nextIndex >= 0) { e.preventDefault(); const targetBtn = buttons[nextIndex]; targetBtn?.focus(); const nextItem = items[nextIndex]; if (nextItem && !nextItem.disabled) { setInternalExpandedId(nextItem.id); } } }; const heightClass = size === "sm" ? "h-7" : "h-8.5"; const iconBoxSize = size === "sm" ? "size-7" : "size-8.5"; const iconSize = size === "sm" ? 13 : 14; return ( <div ref={groupRef} role="toolbar" aria-label={label} onKeyDown={handleKeyDown} onPointerLeave={handlePointerLeave} onBlur={handleBlur} style={style} className={`relative inline-flex items-center rounded-full p-1 gap-1 select-none isolate bg-card text-foreground shadow-[inset_0_0_0_1px_var(--border)] max-w-full overflow-x-auto ${className}`} {...props} > {items.map((item) => { const isExpanded = activeId === item.id; const isConfirmed = confirmedId === item.id; const isPressed = pressedId === item.id; const tone = item.tone ?? "default"; const toneStyle = toneStyles[tone]; const displayLabel = isConfirmed ? item.confirmLabel ?? "Done!" : item.label; return ( <button key={item.id} type="button" data-expanding-item="" disabled={item.disabled} aria-label={item.label} onPointerEnter={() => handlePointerEnter(item.id, item.disabled)} onFocus={() => handleFocus(item.id, item.disabled)} onClick={() => handleClick(item)} onPointerDown={() => !item.disabled && setPressedId(item.id)} onPointerUp={() => setPressedId(null)} style={{ transform: isPressed ? "scale(0.96)" : undefined, transition: reduced ? "none" : `transform 140ms ${EASE}, background-color 160ms ${EASE}, color 160ms ${EASE}`, }} className={`relative flex items-center justify-center rounded-full cursor-pointer transition-colors duration-160 select-none disabled:cursor-not-allowed disabled:opacity-40 ${heightClass} ${ isExpanded ? "px-2.5" : `${iconBoxSize} px-0` } ${isExpanded ? toneStyle.expanded : toneStyle.idle} ${INTERACTION_FOCUS}`} > {/* Leading Icon Slot */} <span className={`relative inline-flex shrink-0 items-center justify-center transition-transform duration-160 ${ isConfirmed ? "text-emerald-500 scale-105" : "" }`} style={{ width: iconSize, height: iconSize, }} > {isConfirmed ? <InlineCheck size={iconSize} /> : item.icon} </span> {/* Expanding Text Label with CSS Grid Smooth Layout Transition */} <span className="grid overflow-hidden whitespace-nowrap text-[12.5px] font-medium transition-all" style={{ gridTemplateColumns: isExpanded ? "1fr" : "0fr", opacity: isExpanded ? 1 : 0, transition: reduced ? "none" : `grid-template-columns 240ms ${EASE}, opacity 180ms ${EASE}`, }} > <span className="min-w-0 overflow-hidden pl-1.5 pr-0.5 font-medium truncate"> {displayLabel} </span> </span> </button> ); })} </div> );}export default ExpandingButtonGroup;