// React ref cleanup keeps native animation lifetimes tied to the mounted icon. const bindIconAnimation = (icon: null | SVGSVGElement) => { if (!icon) return; const target = icon.closest("a, button") ?? icon.parentElement; if (!target) return; let isHovered = false; let isFocused = false; let isRunning = false; let isDisposed = false; const release = () => { if (!isRunning && !isHovered && !isFocused) { delete icon.dataset.iconAnimating; } }; const start = () => { if (Object.hasOwn(icon.dataset, "iconAnimating")) return; icon.dataset.iconAnimating = ""; isRunning = true; // Includes staggered/delayed children and finite iterations, without timers. const animations = icon.getAnimations({ subtree: true }); void Promise.allSettled( animations.map(animation => animation.finished), ).then(() => { if (isDisposed) return; isRunning = false; release(); }); }; const enter = () => { if (!matchMedia("(hover: hover)").matches) return; isHovered = true; start(); }; const leave = () => { isHovered = false; release(); }; const focus = () => { isFocused = target.matches(":focus-visible") || !!target.querySelector(":focus-visible"); if (isFocused) start(); }; const blur = (event: FocusEvent) => { if ( event.relatedTarget instanceof Node && target.contains(event.relatedTarget) ) return; isFocused = false; release(); }; target.addEventListener("mouseenter", enter); target.addEventListener("mouseleave", leave); target.addEventListener("focusin", focus); target.addEventListener("focusout", blur); return () => { isDisposed = true; target.removeEventListener("mouseenter", enter); target.removeEventListener("mouseleave", leave); target.removeEventListener("focusin", focus); target.removeEventListener("focusout", blur); delete icon.dataset.iconAnimating; }; }; export { bindIconAnimation };