1import { Controller } from "@hotwired/stimulus"; 2import { animate, stagger, spring, transform } from "motion"; 3import { computePosition, offset, flip, shift } from "@floating-ui/dom"; 4 5export default class extends Controller { 6 static targets = ["icon", "mobileMenu", "mobileButton", "tooltip", "tooltipContent"]; 7 8 connect() { 9 this.isToggling = false; 10 this.initDesktopHover(); 11 this.initTooltips(); 12 13 // Update active menu styling on Turbo page loads. 14 this.updateActiveMenu(); 15 this.updateActiveMenuBound = this.updateActiveMenu.bind(this); 16 document.addEventListener("turbo:load", this.updateActiveMenuBound); 17 18 // Restore any tooltip if still hovering during a Turbo transition. 19 this.restoreTooltipBound = this.restoreTooltip.bind(this); 20 document.addEventListener("turbo:load", this.restoreTooltipBound); 21 } 22 23 // Desktop hover animations 24 initDesktopHover() { 25 this.mouseX = Infinity; 26 this.animations = new Map(); 27 28 // Debounce the animation frame request 29 let frameRequest; 30 const updateAnimations = () => { 31 frameRequest = requestAnimationFrame(() => this.updateIconAnimations()); 32 }; 33 34 this.element.addEventListener("mousemove", (e) => { 35 this.mouseX = e.pageX; 36 if (frameRequest) { 37 cancelAnimationFrame(frameRequest); 38 } 39 updateAnimations(); 40 }); 41 42 this.element.addEventListener("mouseleave", () => { 43 this.mouseX = Infinity; 44 if (frameRequest) { 45 cancelAnimationFrame(frameRequest); 46 } 47 updateAnimations(); 48 }); 49 } 50 51 updateIconAnimations() { 52 // Add transform utility for smooth size mapping 53 const sizeTransformer = transform( 54 [-150, 0, 150], // Input range (distance from center) 55 [40, 80, 40], // Output range (icon size) 56 ); 57 58 this.iconTargets.forEach((icon) => { 59 const rect = icon.getBoundingClientRect(); 60 const distance = this.mouseX - (rect.x + rect.width / 2); 61 const targetSize = sizeTransformer(distance); 62 const iconElement = icon.querySelector("div:first-child"); 63 if (!iconElement) return; 64 65 // Use spring animation with proper physics parameters 66 animate( 67 iconElement, 68 { width: `${targetSize}px`, height: `${targetSize}px` }, 69 { 70 duration: 2, 71 easing: "easeOut", 72 type: spring, 73 stiffness: 1150, 74 damping: 80, 75 mass: 0.7, 76 restDelta: 0.1, 77 }, 78 ); 79 }); 80 } 81 82 // Mobile toggle functionality 83 async toggleMobile() { 84 if (this.isToggling) return; 85 this.isToggling = true; 86 const is_open = this.mobileMenuTarget.classList.contains("hidden"); 87 if (is_open) { 88 // Opening: remove hidden immediately and enable interactions 89 this.mobileMenuTarget.classList.remove("hidden"); 90 this.mobileMenuTarget.style.pointerEvents = "auto"; // re-enable pointer events 91 } else { 92 // Closing: disable pointer events immediately to prevent hover interactions 93 this.mobileMenuTarget.style.pointerEvents = "none"; 94 } 95 await new Promise((resolve) => requestAnimationFrame(resolve)); 96 const menuPromise = this.animateMobileMenu(is_open); 97 const buttonPromise = this.rotateMobileButton(is_open); 98 await Promise.all([menuPromise, buttonPromise]); 99 if (!is_open) { 100 // Closing: add hidden once animation completes 101 this.mobileMenuTarget.classList.add("hidden"); 102 } 103 this.isToggling = false; 104 } 105 106 animateMobileMenu(is_open) { 107 const menuItems = Array.from(this.mobileMenuTarget.children); 108 const properties = { 109 opacity: [is_open ? 0 : 1, is_open ? 1 : 0], 110 y: [is_open ? 20 : 0, is_open ? 0 : 10], 111 scale: [is_open ? 0.9 : 1, is_open ? 1 : 0.95], 112 }; 113 114 const options = is_open 115 ? { 116 delay: stagger(0.01, { start: 0.1, from: "first" }), 117 duration: 0.3, 118 type: spring, 119 stiffness: 500, 120 damping: 25, 121 mass: 1.5, 122 } 123 : { 124 delay: stagger(0.01, { from: "last" }), 125 duration: 0.1, 126 type: spring, 127 stiffness: 400, 128 damping: 45, 129 mass: 1.5, 130 }; 131 132 const animations = animate(menuItems, properties, options); 133 // If multiple elements are animated, wait for all animations to finish 134 if (Array.isArray(animations)) { 135 return Promise.all(animations.map((anim) => anim.finished)); 136 } 137 return animations.finished; 138 } 139 140 rotateMobileButton(is_open) { 141 const buttonIcon = this.mobileButtonTarget.querySelector("svg"); 142 if (!buttonIcon) return Promise.resolve(); 143 return animate( 144 buttonIcon, 145 { rotate: is_open ? "180deg" : "0deg" }, 146 { 147 duration: 2, 148 easing: "easeOut", 149 type: spring, 150 stiffness: 1150, 151 damping: 80, 152 mass: 5, 153 restDelta: 0.1, 154 }, 155 ).finished; 156 } 157 158 // Initialize tooltips for each icon 159 initTooltips() { 160 this.tooltip = document.createElement("div"); 161 this.tooltip.className = 162 "absolute z-50 px-1.5 py-1 text-xs font-medium text-neutral-900 bg-white border border-neutral-200/70 rounded-md shadow-sm opacity-0 dark:bg-neutral-800 dark:border-neutral-600/50 dark:text-neutral-100"; 163 this.tooltip.style.position = "fixed"; 164 document.body.appendChild(this.tooltip); 165 166 this.cleanupEventListeners = []; 167 this.tooltipLoopId = null; 168 169 const setupTooltipHandlers = (icon) => { 170 const mouseEnterHandler = () => { 171 this.activeIcon = icon; 172 const tooltipText = icon.dataset.tooltip; 173 const hotkey = icon.dataset.tooltipHotkey; 174 if (hotkey && hotkey.trim() !== "") { 175 this.tooltip.innerHTML = `${tooltipText} <span class="tooltip-hotkey uppercase text-[10px] font-semibold ml-0.5 px-1 py-px bg-neutral-200 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 rounded">${hotkey}</span>`;
176 } else { 177 this.tooltip.textContent = tooltipText; 178 } 179 this.positionTooltip(icon); 180 animate(this.tooltip, { opacity: 1 }, { duration: 0.3 }); 181 const updateTooltip = () => { 182 if (this.activeIcon) { 183 this.positionTooltip(this.activeIcon); 184 this.tooltipLoopId = requestAnimationFrame(updateTooltip); 185 } 186 }; 187 this.tooltipLoopId = requestAnimationFrame(updateTooltip); 188 }; 189 190 const mouseLeaveHandler = () => { 191 this.activeIcon = null; 192 animate(this.tooltip, { opacity: 0 }, { duration: 0.1 }); 193 if (this.tooltipLoopId) { 194 cancelAnimationFrame(this.tooltipLoopId); 195 this.tooltipLoopId = null; 196 } 197 }; 198 199 icon.addEventListener("mouseenter", mouseEnterHandler); 200 icon.addEventListener("mouseleave", mouseLeaveHandler); 201 this.cleanupEventListeners.push(() => { 202 icon.removeEventListener("mouseenter", mouseEnterHandler); 203 icon.removeEventListener("mouseleave", mouseLeaveHandler); 204 }); 205 }; 206 207 this.iconTargets.forEach(setupTooltipHandlers); 208 } 209 210 positionTooltip(icon) { 211 // Use data-tooltip-placement from the icon, defaulting to "top" 212 const placement = icon.dataset.tooltipPlacement || "top"; 213 computePosition(icon, this.tooltip, { 214 placement: placement, 215 strategy: "fixed", 216 middleware: [ 217 offset(8), 218 flip(), 219 shift({ 220 padding: 4, 221 crossAxis: true, 222 }), 223 ], 224 }).then(({ x, y }) => { 225 Object.assign(this.tooltip.style, { 226 left: `${x}px`, 227 top: `${y}px`, 228 }); 229 }); 230 } 231 232 disconnect() { 233 if (this.tooltip) this.tooltip.remove(); 234 if (this.tooltipLoopId) { 235 cancelAnimationFrame(this.tooltipLoopId); 236 this.tooltipLoopId = null; 237 } 238 this.cleanupEventListeners.forEach((cleanup) => cleanup()); 239 document.removeEventListener("turbo:load", this.updateActiveMenuBound); 240 document.removeEventListener("turbo:load", this.restoreTooltipBound); 241 } 242 243 /** 244 * Iterates over each menu item and updates its styling based on the current URL. 245 * Both desktop and mobile menu items are updated. 246 */ 247 updateActiveMenu() { 248 const desktopActiveClasses = 249 "text-neutral-50 dark:text-neutral-800 bg-neutral-800 dark:bg-neutral-100 active:bg-neutral-700 dark:active:bg-neutral-200"; 250 const desktopInactiveClasses = 251 "text-neutral-500 dark:text-neutral-300 bg-neutral-200 dark:bg-neutral-800 active:bg-neutral-300 dark:active:bg-neutral-700"; 252 const mobileActiveClasses = 253 "text-neutral-50 dark:text-neutral-800 hover:text-neutral-50 dark:hover:text-neutral-800 bg-neutral-800 dark:bg-neutral-100 active:bg-neutral-700 dark:active:bg-neutral-200"; 254 const mobileInactiveClasses = 255 "text-neutral-500 dark:text-neutral-300 hover:text-neutral-500 dark:hover:text-neutral-300 bg-neutral-50 dark:bg-neutral-800 active:bg-neutral-100 dark:active:bg-neutral-700"; 256 257 // Update desktop icons 258 this.iconTargets.forEach((anchor) => { 259 const iconBox = anchor.querySelector("div"); 260 if (!iconBox) return; 261 const url = anchor.getAttribute("href"); 262 try { 263 // Compare the anchor URL (resolved relative to the current origin) with the current pathname and search params. 264 const anchorUrl = new URL(url, window.location.origin); 265 // If the menu item has no search params, match only by pathname. 266 // If it has search params, match both pathname and search params exactly. 267 const isActive = 268 anchorUrl.search === "" 269 ? anchorUrl.pathname === window.location.pathname 270 : anchorUrl.pathname === window.location.pathname && anchorUrl.search === window.location.search; 271 if (isActive) { 272 iconBox.classList.remove(...desktopInactiveClasses.split(" ")); 273 iconBox.classList.add(...desktopActiveClasses.split(" ")); 274 } else { 275 iconBox.classList.remove(...desktopActiveClasses.split(" ")); 276 iconBox.classList.add(...desktopInactiveClasses.split(" ")); 277 } 278 } catch (e) { 279 // Skip if URL parsing fails. 280 } 281 }); 282 283 // Update mobile menu items (if present) 284 if (this.hasMobileMenuTarget) { 285 const mobileAnchors = this.mobileMenuTarget.querySelectorAll("a");
286 mobileAnchors.forEach((anchor) => { 287 const url = anchor.getAttribute("href"); 288 try { 289 const anchorUrl = new URL(url, window.location.origin); 290 // If the menu item has no search params, match only by pathname. 291 // If it has search params, match both pathname and search params exactly. 292 const isActive = 293 anchorUrl.search === "" 294 ? anchorUrl.pathname === window.location.pathname 295 : anchorUrl.pathname === window.location.pathname && anchorUrl.search === window.location.search; 296 if (isActive) { 297 anchor.classList.remove(...mobileInactiveClasses.split(" ")); 298 anchor.classList.add(...mobileActiveClasses.split(" ")); 299 } else { 300 anchor.classList.remove(...mobileActiveClasses.split(" ")); 301 anchor.classList.add(...mobileInactiveClasses.split(" ")); 302 } 303 } catch (e) {} 304 }); 305 } 306 } 307 308 /** 309 * If an icon is still active (hovered) when Turbo loads a new page, 310 * re-position the tooltip and force its opacity so it remains visible. 311 */ 312 restoreTooltip() { 313 if (this.activeIcon) { 314 const tooltipText = this.activeIcon.dataset.tooltip; 315 const hotkey = this.activeIcon.dataset.tooltipHotkey; 316 if (hotkey && hotkey.trim() !== "") { 317 this.tooltip.innerHTML = `${tooltipText} <span class="tooltip-hotkey uppercase text-[10px] font-semibold ml-0.5 px-1 py-px bg-neutral-200 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-200 rounded">${hotkey}</span>`; 318 } else { 319 this.tooltip.textContent = tooltipText; 320 } 321 this.positionTooltip(this.activeIcon); 322 this.tooltip.style.opacity = 1; 323 if (!this.tooltipLoopId) { 324 const updateTooltipLoop = () => { 325 if (this.activeIcon) { 326 this.positionTooltip(this.activeIcon); 327 this.tooltipLoopId = requestAnimationFrame(updateTooltipLoop); 328 } 329 }; 330 this.tooltipLoopId = requestAnimationFrame(updateTooltipLoop); 331 } 332 } 333 } 334};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.