1import { Controller } from "@hotwired/stimulus"; 2import { computePosition, offset, flip, shift, arrow, autoUpdate } from "@floating-ui/dom"; 3 4// Global tooltip state manager for intelligent behavior 5class TooltipGlobalState { 6 constructor() { 7 this.visibleCount = 0; 8 this.isFastMode = false; 9 this.resetTimeout = null; 10 this.fastModeResetDelay = 100; // 0.1 seconds 11 this.visibleTooltips = new Set(); // Track currently visible tooltip controllers 12 this.closingTooltips = new Set(); // Track tooltips currently in closing animation 13 } 14 15 // Called when a tooltip becomes visible 16 onTooltipShow(tooltipController) { 17 this.visibleTooltips.add(tooltipController); 18 this.closingTooltips.delete(tooltipController); // Remove from closing if it was there 19 this.visibleCount = this.visibleTooltips.size; 20 if (this.visibleCount > 0 && !this.isFastMode) { 21 this.isFastMode = true; 22 } 23 this.clearResetTimeout(); 24 } 25 26 // Called when a tooltip starts hiding 27 onTooltipStartHide(tooltipController) { 28 this.visibleTooltips.delete(tooltipController); 29 this.visibleCount = this.visibleTooltips.size; 30 } 31 32 // Called when a tooltip starts its closing animation 33 onTooltipClosing(tooltipController) { 34 this.closingTooltips.add(tooltipController); 35 } 36 37 // Called when a tooltip has fully closed 38 onTooltipClosed(tooltipController) { 39 this.closingTooltips.delete(tooltipController); 40 41 // If no tooltips are visible or closing, start countdown to exit fast mode 42 if (this.visibleCount === 0 && this.closingTooltips.size === 0) { 43 this.startResetTimeout(); 44 } 45 } 46 47 // Hide all currently visible tooltips and interrupt closing animations 48 hideAllTooltipsInstantly(exceptController) { 49 // Instantly hide all visible tooltips 50 const visibleToHide = [...this.visibleTooltips].filter((controller) => controller !== exceptController); 51 visibleToHide.forEach((controller) => { 52 controller._hideTooltip(true); // true = instant hide 53 }); 54 55 // Instantly finish all closing animations 56 const closingToHide = [...this.closingTooltips].filter((controller) => controller !== exceptController); 57 closingToHide.forEach((controller) => { 58 controller._finishClosingAnimation(); 59 }); 60 } 61 62 // Check if we're in fast mode 63 isInFastMode() { 64 return this.isFastMode; 65 } 66 67 // Start timeout to reset fast mode 68 startResetTimeout() { 69 this.clearResetTimeout(); 70 this.resetTimeout = setTimeout(() => { 71 this.isFastMode = false; 72 }, this.fastModeResetDelay); 73 } 74 75 // Clear the reset timeout 76 clearResetTimeout() { 77 if (this.resetTimeout) { 78 clearTimeout(this.resetTimeout); 79 this.resetTimeout = null; 80 } 81 } 82} 83 84// Global instance 85const tooltipGlobalState = new TooltipGlobalState(); 86 87const tooltipKbdClasses = 88 "shrink-0 flex items-center gap-0.5 rounded-md border px-1 font-mono text-[10px] border-white/25 bg-neutral-700 text-neutral-200 shadow-[0px_1px_0px_0px_rgba(255,255,255,0.08)]"; 89 90const commandKeySvg = ` 91 <svg xmlns="http://www.w3.org/2000/svg" class="size-2.5" width="18" height="18" viewBox="0 0 18 18" aria-label="Command"> 92 <g fill="currentColor"> 93 <path d="M13.25,10.5h-1.25v-3h1.25c1.517,0,2.75-1.233,2.75-2.75s-1.233-2.75-2.75-2.75-2.75,1.233-2.75,2.75v1.25h-3v-1.25c0-1.517-1.233-2.75-2.75-2.75s-2.75,1.233-2.75,2.75,1.233,2.75,2.75,2.75h1.25v3h-1.25c-1.517,0-2.75,1.233-2.75,2.75s1.233,2.75,2.75,2.75,2.75-1.233,2.75-2.75v-1.25h3v1.25c0,1.517,1.233,2.75,2.75,2.75s2.75-1.233,2.75-2.75-1.233-2.75-2.75-2.75Zm-1.25-5.75c0-.689,.561-1.25,1.25-1.25s1.25,.561,1.25,1.25-.561,1.25-1.25,1.25h-1.25v-1.25Zm-8.5,0c0-.689,.561-1.25,1.25-1.25s1.25,.561,1.25,1.25v1.25h-1.25c-.689,0-1.25-.561-1.25-1.25Zm2.5,8.5c0,.689-.561,1.25-1.25,1.25s-1.25-.561-1.25-1.25,.561-1.25,1.25-1.25h1.25v1.25Zm1.5-5.75h3v3h-3v-3Zm5.75,7c-.689,0-1.25-.561-1.25-1.25v-1.25h1.25c.689,0,1.25,.561,1.25,1.25s-.561,1.25-1.25,1.25Z"></path> 94 </g> 95 </svg> 96`; 97 98export default class extends Controller { 99 // placement and offset can still be configured via data-tooltip-placement-value etc. if desired, 100 // but will use defaults if the original HTML (using data-tooltip-*) doesn't provide them. 101 static values = { 102 placement: { type: String, default: "top" }, // Placement(s) of the tooltip, e.g., "top", "top-start", "top-end", "bottom", "bottom-start", "bottom-end", "left", "left-start", "left-end", "right", "right-start", "right-end" 103 offset: { type: Number, default: 8 }, // Offset of the tooltip 104 maxWidth: { type: Number, default: 200 }, // Default max width for tooltips 105 delay: { type: Number, default: 0 }, // Delay before showing the tooltip (in ms) 106 size: { type: String, default: "regular" }, // Size of the tooltip, e.g., "small", "regular", "large" 107 animation: { type: String, default: "fade" }, // e.g., "fade", "origin", "fade origin", "none" 108 trigger: { type: String, default: "auto" }, // space-separated: mouseenter, focus, click, or "auto" (auto-detects based on device) 109 kbd: { type: String, default: "" }, // Optional keyboard shortcut displayed at the end of the tooltip 110 macKbd: { type: String, default: "" }, // Optional Mac-specific keyboard shortcut 111 nonMacKbd: { type: String, default: "" }, // Optional Windows/Linux keyboard shortcut 112 // tooltipContent and tooltipArrow are read directly from element attributes in connect() 113 }; 114 115 _hasAnimationType(type) { 116 return this.animationValue.split(" ").includes(type); 117 } 118 119 connect() { 120 this.tooltipContent = this.element.getAttribute("data-tooltip-content") || ""; 121 this.showArrow = this.element.getAttribute("data-tooltip-arrow") !== "fal
121se"; 122 this.showTimeoutId = null; 123 this.hideTimeoutId = null; 124 this.isVisible = false; 125 126 if (!this.tooltipContent) { 127 console.warn("Tooltip initialized without data-tooltip-content", this.element); 128 return; 129 } 130 131 this.tooltipElement = document.createElement("div"); 132 this.tooltipElement.className = 133 "tooltip-content pointer-events-none wrap-break-word shadow-sm border rounded-lg border-white/10 fixed bg-[#333333] text-white py-1 px-2 z-[1000]"; 134 135 const sizeClasses = { 136 small: "text-xs", 137 regular: "text-sm", 138 large: "text-base", 139 }; 140 const sizeClass = sizeClasses[this.sizeValue] || sizeClasses.regular; 141 this.tooltipElement.classList.add(sizeClass); 142 143 this.tooltipElement.classList.add("opacity-0"); 144 this.tooltipElement.style.visibility = "hidden"; 145 146 const hasFade = this._hasAnimationType("fade"); 147 const hasOrigin = this._hasAnimationType("origin"); 148 149 if (hasFade && hasOrigin) { 150 this.tooltipElement.style.transition = "opacity 150ms ease-out, transform 150ms ease-out"; 151 this.tooltipElement.style.transform = "scale(0.95)"; 152 } else if (hasOrigin) { 153 this.tooltipElement.style.transition = "transform 150ms ease-out"; 154 this.tooltipElement.style.transform = "scale(0.95)"; 155 } else if (hasFade) { 156 this.tooltipElement.style.transition = "opacity 150ms ease-out"; 157 } 158 159 this._renderTooltipContent(); 160 this.tooltipElement.style.maxWidth = `${this.maxWidthValue}px`; 161 162 if (this.showArrow) { 163 // Create arrow container with padding to prevent clipping at viewport edges 164 this.arrowContainer = document.createElement("div"); 165 this.arrowContainer.className = "absolute z-[1000]"; 166 167 // Create the arrow element within the container 168 this.arrowElement = document.createElement("div"); 169 this.arrowElement.className = "tooltip-arrow-element bg-[#333333] w-2 h-2 border-white/10"; 170 this.arrowElement.style.transform = "rotate(45deg)"; 171 172 this.arrowContainer.appendChild(this.arrowElement); 173 this.tooltipElement.appendChild(this.arrowContainer); 174 } 175 176 // Append target logic is handled in _showTooltip to ensure it's correct at showtime 177 // const appendTarget = this.element.closest("dialog[open]") || document.body; 178 // appendTarget.appendChild(this.tooltipElement); 179 180 this.showTooltipBound = this._showTooltip.bind(this); 181 this.hideTooltipBound = this._hideTooltip.bind(this); 182 this.clickHideTooltipBound = this._handleClick.bind(this); 183 this.clickToggleTooltipBound = this._handleClickToggle.bind(this); 184 this.clickOutsideBound = this._handleClickOutside.bind(this); 185 186 // Auto-detect trigger based on device capability 187 let triggerValue = this.triggerValue; 188 if (triggerValue === "auto") { 189 // Use click on touch devices, mouseenter+focus on others 190 const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0; 191 triggerValue = isTouchDevice ? "click" : "mouseenter focus"; 192 } 193 194 const triggers = triggerValue.split(" "); 195 this.hasMouseEnterTrigger = triggers.includes("mouseenter"); 196 this.hasClickTrigger = triggers.includes("click"); 197 198 triggers.forEach((event_type) => { 199 if (event_type === "mouseenter") { 200 this.element.addEventListener("mouseenter", this.showTooltipBound); 201 this.element.addEventListener("mouseleave", this.hideTooltipBound); 202 } 203 if (event_type === "focus") { 204 this.element.addEventListener("focusin", this.showTooltipBound); 205 this.element.addEventListener("focusout", this.hideTooltipBound); 206 } 207 if (event_type === "click") { 208 this.element.addEventListener("click", this.clickToggleTooltipBound); 209 } 210 }); 211 212 // Add click event to close tooltip but allow event to bubble up (only for hover triggers) 213 if (this.hasMouseEnterTrigger && !this.hasClickTrigger) { 214 this.element.addEventListener("click", this.clickHideTooltipBound); 215 } 216 217 this.cleanupAutoUpdate = null; 218 this.intersectionObserver = null; 219 } 220 221 _renderTooltipContent() { 222 if (!this.tooltipElement) return; 223
224 const kbdValue = this._resolvedKbdValue(); 225 226 if (!kbdValue) { 227 this.tooltipElement.innerHTML = this.tooltipContent; 228 } else { 229 const contentElement = document.createElement("span"); 230 contentElement.className = "min-w-0"; 231 contentElement.innerHTML = this.tooltipContent; 232 233 const kbdElement = document.createElement("span"); 234 kbdElement.className = tooltipKbdClasses; 235 this._renderKbdValue(kbdElement, kbdValue); 236 237 const contentWrapper = document.createElement("span"); 238 contentWrapper.className = "flex items-center gap-1.5"; 239 contentWrapper.append(contentElement, kbdElement); 240 241 this.tooltipElement.replaceChildren(contentWrapper); 242 } 243 244 // Updating dynamic tooltip content replaces its children, including the 245 // positioning arrow. Restore the existing arrow after every re-render. 246 if (this.arrowContainer) this.tooltipElement.appendChild(this.arrowContainer); 247 } 248 249 _resolvedKbdValue() { 250 if (this._isMac()) { 251 return (this.macKbdValue || this.kbdValue).trim(); 252 } 253 254 return (this.nonMacKbdValue || this.kbdValue).trim(); 255 } 256 257 _renderKbdValue(kbdElement, kbdValue) { 258 const parts = kbdValue.split(/(\s+|\+)/).filter((part) => part.trim()); 259 const hasCommandKey = parts.some((part) => this._isCommandKey(part)); 260 261 if (!hasCommandKey) { 262 kbdElement.textContent = kbdValue; 263 return; 264 } 265 266 kbdElement.classList.add("inline-flex", "items-center", "gap-0.5"); 267 268 parts.forEach((part) => { 269 if (this._isCommandKey(part)) { 270 kbdElement.insertAdjacentHTML("beforeend", commandKeySvg); 271 } else { 272 kbdElement.append(document.createTextNode(part)); 273 } 274 }); 275 } 276 277 _isCommandKey(value) { 278 return ["cmd", "command", "â"].includes(value.trim().toLowerCase()); 279 } 280 281 _isMac() { 282 if (navigator.userAgentData && navigator.userAgentData.platform) { 283 return navigator.userAgentData.platform === "macOS"; 284 } 285 286 return /Mac|iPhone|iPad|iPod/.test(navigator.userAgent); 287 } 288 289 disconnect() { 290 clearTimeout(this.showTimeoutId); 291 clearTimeout(this.hideTimeoutId); 292 293 // Remove from global state if visible or closing 294 if (this.isVisible) { 295 tooltipGlobalState.onTooltipStartHide(this); 296 this.isVisible = false; 297 } 298 tooltipGlobalState.onTooltipClosed(this); 299 300 // Auto-detect trigger (same logic as connect) 301 let triggerValue = this.triggerValue; 302 if (triggerValue === "auto") { 303 const isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0; 304 triggerValue = isTouchDevice ? "click" : "mouseenter focus"; 305 } 306 307 triggerValue.split(" ").forEach((event_type) => { 308 if (event_type === "mouseenter") { 309 this.element.removeEventListener("mouseenter", this.showTooltipBound); 310 this.element.removeEventListener("mouseleave", this.hideTooltipBound); 311 } 312 if (event_type === "focus") { 313 this.element.removeEventListener("focusin", this.showTooltipBound); 314 this.element.removeEventListener("focusout", this.hideTooltipBound); 315 } 316 if (event_type === "click") { 317 this.element.removeEventListener("click", this.clickToggleTooltipBound); 318 } 319 }); 320 321 // Remove click event listener only if it was added for hover-enabled tooltips 322 if (this.hasMouseEnterTrigger && !this.hasClickTrigger) { 323 this.element.removeEventListener("click", this.clickHideTooltipBound); 324 } 325 326 this._cleanupObservers(); 327 328 if (this.tooltipElement && this.tooltipElement.parentElement) { 329 this.tooltipElement.remove(); 330 } 331 332 // Permanent elements reconnect after Turbo renders. Do not let the next 333 // connect() restore this connection's detached arrow before creating its own. 334 this.tooltipElement = null; 335 this.arrowContainer = null; 336 this.arrowElement = null; 337 } 338 339 async _updatePositionAndArrow() { 340 if (!this.element || !this.tooltipElement) return; 341 342 // Parse placement value to support multiple placements 343 const placements = this.placementValue.split(/[\s,]+/).filter(Boolean); 344 const primaryPlacement = placements[0] || "top"; 345 const fallbackPlacements = placements.slice(1); 346 347 const middleware = [ 348 offset(this.offsetValue), 349 flip({ 350 fallbackPlacements: fallbackPlacements.length > 0 ? fallbackPlacements : undefined, 351 }), 352 shift({ padding: 5 }), 353 ]; 354 if (this.showArrow && this.arrowContainer) { 355 middleware.push(arrow({ element: this.arrowContainer, padding: 2 })); 356 } 357 358 const { x, y, placement, middlewareData } = await computePosition(this.element, this.tooltipElement, { 359 strategy: "fixed", 360 placement: primaryPlacement, 361 middleware: middleware, 362 }); 363 364 Object.assign(this.tooltipElement.style, { 365 left: `${x}px`, 366 top: `${y}px`, 367 }); 368
369 if (this._hasAnimationType("origin")) { 370 const basePlacement = placement.split("-")[0]; 371 this.tooltipElement.classList.remove("origin-top", "origin-bottom", "origin-left", "origin-right"); 372 if (basePlacement === "top") { 373 this.tooltipElement.classList.add("origin-bottom"); 374 } else if (basePlacement === "bottom") { 375 this.tooltipElement.classList.add("origin-top"); 376 } else if (basePlacement === "left") { 377 this.tooltipElement.classList.add("origin-right"); 378 } else if (basePlacement === "right") { 379 this.tooltipElement.classList.add("origin-left"); 380 } 381 } 382 383 if (this.showArrow && this.arrowContainer && this.arrowElement && middlewareData.arrow) { 384 const { x: arrowX, y: arrowY } = middlewareData.arrow; 385 const currentPlacement = placement; // Use the resolved placement from computePosition 386 const basePlacement = currentPlacement.split("-")[0]; 387 const staticSide = { 388 top: "bottom", 389 right: "left", 390 bottom: "top", 391 left: "right", 392 }[basePlacement]; 393 394 // Apply appropriate padding based on placement direction 395 this.arrowContainer.classList.remove("px-1", "py-1"); 396 if (basePlacement === "top" || basePlacement === "bottom") { 397 this.arrowContainer.classList.add("px-1"); // Horizontal padding for top/bottom 398 } else { 399 this.arrowContainer.classList.add("py-1"); // Vertical padding for left/right 400 } 401 402 // Position the arrow container 403 Object.assign(this.arrowContainer.style, { 404 left: arrowX != null ? `${arrowX}px` : "", 405 top: arrowY != null ? `${arrowY}px` : "", 406 right: "", 407 bottom: "", 408 [staticSide]: "-0.275rem", // Adjusted to -0.275rem as often seen with 0.5rem arrows 409 }); 410 411 // Style the arrow element within the container 412 // Reset existing border classes before adding new ones 413 this.arrowElement.classList.remove("border-t", "border-r", "border-b", "border-l"); 414 415 // Apply new borders based on placement 416 if (staticSide === "bottom") { 417 // Arrow points up 418 this.arrowElement.classList.add("border-b", "border-r"); 419 } else if (staticSide === "top") { 420 // Arrow points down 421 this.arrowElement.classList.add("border-t", "border-l"); 422 } else if (staticSide === "left") { 423 // Arrow points right 424 this.arrowElement.classList.add("border-b", "border-l"); 425 } else if (staticSide === "right") { 426 // Arrow points left 427 this.arrowElement.classList.add("border-t", "border-r"); 428 } 429 } 430 } 431 432 async _showTooltip() { 433 if (!this.tooltipElement) return; 434 435 clearTimeout(this.hideTimeoutId); // Cancel any pending hide finalization 436 clearTimeout(this.showTimeoutId); // Cancel any pending show 437 438 // Always hide all other visible tooltips and interrupt closing animations IMMEDIATELY 439 // This must happen synchronously before scheduling the show to prevent multiple tooltips 440 // from appearing simultaneously when hovering quickly 441 tooltipGlobalState.hideAllTooltipsInstantly(this); 442 443 // Determine if we should use fast mode (no delay, no animations) 444 const isFastMode = tooltipGlobalState.isInFastMode(); 445 const effectiveDelay = isFastMode ? 0 : this.delayValue; 446 447 this.showTimeoutId = setTimeout(async () => { 448 // Ensure tooltip is appended to the correct target (body or open dialog) 449 // This is done here to handle cases where the element might move into/out of a dialog 450 const currentAppendTarget = this.element.closest("dialog[open]") || document.body; 451 if (this.tooltipElement.parentElement !== currentAppendTarget) { 452 currentAppendTarget.appendChild(this.tooltipElement); 453 } 454 455 // Tooltip is already opacity-0 and visibility-hidden from connect() 456 // 1. Calculate and apply position 457 await this._updatePositionAndArrow(); 458 459 // 2. Make it visible 460 this.tooltipElement.style.visibility = "visible"; 461 462 // 3. Apply opacity and scale based on animation type 463 const applyVisibleState = () => { 464 this.tooltipElement.classList.remove("opacity-0"); 465 this.tooltipElement.classList.add("opacity-100"); 466
467 if (this._hasAnimationType("origin")) { 468 this.tooltipElement.style.transform = "scale(1)"; 469 } 470 }; 471 472 if (isFastMode) { 473 // Fast mode: apply changes instantly without transitions 474 this.tooltipElement.setAttribute("data-instant", ""); 475 this._withoutTransition(applyVisibleState); 476 // Remove data-instant after the instant show is complete 477 // This prevents it from interfering with hover state changes 478 requestAnimationFrame(() => { 479 if (this.tooltipElement) { 480 this.tooltipElement.removeAttribute("data-instant"); 481 } 482 }); 483 } else { 484 // Normal mode: use requestAnimationFrame for smooth animations 485 this.tooltipElement.removeAttribute("data-instant"); 486 requestAnimationFrame(applyVisibleState); 487 } 488 489 // 4. Setup autoUpdate for continuous positioning 490 if (this.cleanupAutoUpdate) { 491 this.cleanupAutoUpdate(); 492 } 493 this.cleanupAutoUpdate = autoUpdate( 494 this.element, 495 this.tooltipElement, 496 async () => { 497 // Re-check append target in case DOM changes during interaction 498 const appendTargetRecurring = this.element.closest("dialog[open]") || document.body; 499 if (this.tooltipElement.parentElement !== appendTargetRecurring) { 500 appendTargetRecurring.appendChild(this.tooltipElement); 501 } 502 await this._updatePositionAndArrow(); 503 }, 504 { animationFrame: true }, // Use animationFrame for smoother updates 505 ); 506 507 // 5. Setup intersection observer to hide tooltip when trigger element goes out of view 508 if (this.intersectionObserver) { 509 this.intersectionObserver.disconnect(); 510 } 511 this.intersectionObserver = new IntersectionObserver( 512 (entries) => { 513 entries.forEach((entry) => { 514 if (!entry.isIntersecting) { 515 this._hideTooltip(); 516 } 517 }); 518 }, 519 { threshold: 0 }, // Hide as soon as any part goes out of view 520 ); 521 this.intersectionObserver.observe(this.element); 522 523 // 6. Register with global state that this tooltip is now visible 524 if (!this.isVisible) { 525 this.isVisible = true; 526 tooltipGlobalState.onTooltipShow(this); 527 } 528 529 // 7. Add click-outside listener for click triggers 530 if (this.hasClickTrigger) { 531 // Use setTimeout to avoid immediately triggering the click-outside handler 532 setTimeout(() => { 533 document.addEventListener("click", this.clickOutsideBound); 534 }, 0); 535 } 536 }, effectiveDelay); 537 } 538 539 _handleClick() { 540 // Hide the tooltip but allow the event to bubble up 541 this._hideTooltip(); 542 // Don't call event.preventDefault() or event.stopPropagation() 543 // so the event can bubble up to parent elements (like kanban cards) 544 } 545 546 _handleClickToggle(event) { 547 // Toggle tooltip visibility on click 548 if (this.isVisible) { 549 this._hideTooltip(); 550 } else { 551 this._showTooltip(); 552 } 553 554 // Prevent the click from bubbling only if the element is interactive (button, a, etc.) 555 // Otherwise allow it to bubble for non-interactive elements 556 const isInteractive = this.element.matches("button, a, [role='button'], input, select, textarea"); 557 if (!isInteractive) { 558 event.stopPropagation(); 559 } 560 } 561 562 _handleClickOutside(event) { 563 // Hide tooltip when clicking outside of trigger element 564 if (!this.element.contains(event.target)) { 565 this._hideTooltip(); 566 } 567 } 568 569 // Helper: Apply instant changes without transitions 570 _withoutTransition(callback) { 571 if (!this.tooltipElement) return; 572 573 // Ensure data-instant is set (caller should set it, but we ensure it's there) 574 this.tooltipElement.setAttribute("data-instant", ""); 575 this.tooltipElement.offsetHeight; // Force reflow 576 577 callback(); 578 579 // Don't remove data-instant here - let the show/hide logic manage it 580 // This keeps the attribute on for the entire duration of instant mode 581 } 582 583 // Helper: Apply hidden state (opacity, scale, visibility) 584 _applyHiddenState() { 585 if (!this.tooltipElement) return; 586 587 this.tooltipElement.classList.remove("opacity-100"); 588 this.tooltipElement.classList.add("opacity-0");
589 if (this._hasAnimationType("origin")) { 590 this.tooltipElement.style.transform = "scale(0.95)"; 591 } 592 this.tooltipElement.style.visibility = "hidden"; 593 } 594 595 // Helper: Cleanup observers and auto-update 596 _cleanupObservers() { 597 if (this.cleanupAutoUpdate) { 598 this.cleanupAutoUpdate(); 599 this.cleanupAutoUpdate = null; 600 } 601 if (this.intersectionObserver) { 602 this.intersectionObserver.disconnect(); 603 this.intersectionObserver = null; 604 } 605 // Remove click-outside listener 606 if (this.hasClickTrigger) { 607 document.removeEventListener("click", this.clickOutsideBound); 608 } 609 } 610 611 _hideTooltip(isInstantHide = false) { 612 clearTimeout(this.showTimeoutId); // Cancel any pending show operation 613 clearTimeout(this.hideTimeoutId); // Cancel any pending hide finalization 614 615 if (!this.tooltipElement) return; 616 617 // Register with global state that this tooltip is starting to hide 618 if (this.isVisible) { 619 this.isVisible = false; 620 tooltipGlobalState.onTooltipStartHide(this); 621 } 622 623 this._cleanupObservers(); 624 625 if (isInstantHide) { 626 // Instant hide: apply hidden state without transitions 627 this.tooltipElement.setAttribute("data-instant", ""); 628 this._withoutTransition(() => { 629 this._applyHiddenState(); 630 }); 631 tooltipGlobalState.onTooltipClosed(this); 632 return; 633 } 634 635 // Remove data-instant for normal animated hide 636 this.tooltipElement.removeAttribute("data-instant"); 637 638 // Normal hide with animations 639 tooltipGlobalState.onTooltipClosing(this); 640 641 const needsAnimation = this._hasAnimationType("fade") || this._hasAnimationType("origin"); 642 643 if (needsAnimation || this.animationValue === "none") { 644 // Apply opacity/scale changes for animation 645 this.tooltipElement.classList.remove("opacity-100"); 646 this.tooltipElement.classList.add("opacity-0"); 647 if (this._hasAnimationType("origin")) { 648 this.tooltipElement.style.transform = "scale(0.95)"; 649 } 650 } 651 652 // Calculate animation duration (match the 150ms transition) 653 const animationDelay = needsAnimation ? 150 : 0; 654 655 this.hideTimeoutId = setTimeout(() => { 656 if (this.tooltipElement) { 657 this.tooltipElement.style.visibility = "hidden"; 658 } 659 tooltipGlobalState.onTooltipClosed(this); 660 }, animationDelay); 661 } 662 663 // Instantly finish a closing animation (called when another tooltip is being shown) 664 _finishClosingAnimation() { 665 clearTimeout(this.hideTimeoutId); 666 667 if (!this.tooltipElement) return; 668 669 this.tooltipElement.setAttribute("data-instant", ""); 670 this._withoutTransition(() => { 671 this._applyHiddenState(); 672 }); 673 674 tooltipGlobalState.onTooltipClosed(this); 675 } 676};
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.