PageSourceSearch

https://railsblocks.com/assets/controllers/tooltip_controller-a8c0…26a8c942400b7d9894d5c0d1f55d2d44ac848dd5.js

js railsblocks.com collected 2026-09-25 20:24:31 UTC 25,853 bytes, 676 lines download raw bytes

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.