PageSourceSearch

https://railsblocks.com/assets/controllers/navbar_controller-e2e93…361a9e916e3a70f4d0a3d8a6da435c38faefd802.js

js railsblocks.com collected 2026-09-25 20:23:42 UTC 39,803 bytes, 1,103 lines download raw bytes

1import { Controller } from "@hotwired/stimulus";
2
3export default class extends Controller {
4  static targets = ["trigger", "content", "viewport", "indicator", "menu", "background"];
5
6  connect() {
7    this.isOpen = false;
8    this.currentContentId = null;
9    this.closeTimeout = null;
10    this.closeAnimationTimeout = null;
11    this.transitionTimeout = null;
12    this.openDelayTimeout = null;
13    this.skipDelayTimeout = null;
14    this.justOpened = false;
15    this.justOpenedTimeout = null;
16    this.isAnimating = false; // Track if an animation is in progress
17    this.isOpenDelayed = true; // Whether to apply delay when opening
18    this.pendingTransition = null; // Queue pending transition while animating
19    this.hasPointerMoveOpened = new Map(); // Track which triggers have opened via pointer movement
20    this.targetDimensions = null; // Track the target dimensions we're animating to
21
22    // Delay durations
23    this.delayDuration = 200; // Initial open delay
24    this.skipDelayDuration = 300; // Time window to skip delay after closing
25    this.closeLockDuration = 300; // Minimum time menu stays open before click-toggle can close
26
27    // Detect if this is a touch device
28    this.isTouchDevice = "ontouchstart" in window || navigator.maxTouchPoints > 0;
29
30    // Setup click outside listener
31    this.handleClickOutside = this.handleClickOutside.bind(this);
32    document.addEventListener("click", this.handleClickOutside);
33
34    // Setup keyboard listeners
35    this.handleKeydown = this.handleKeydown.bind(this);
36    document.addEventListener("keydown", this.handleKeydown);
37
38    this.syncTriggerAriaAttributes();
39  }
40
41  disconnect() {
42    document.removeEventListener("click", this.handleClickOutside);
43    document.removeEventListener("keydown", this.handleKeydown);
44    if (this.closeTimeout) {
45      clearTimeout(this.closeTimeout);
46    }
47    if (this.closeAnimationTimeout) {
48      clearTimeout(this.closeAnimationTimeout);
49    }
50    if (this.transitionTimeout) {
51      clearTimeout(this.transitionTimeout);
52    }
53    if (this.openDelayTimeout) {
54      clearTimeout(this.openDelayTimeout);
55    }
56    if (this.skipDelayTimeout) {
57      clearTimeout(this.skipDelayTimeout);
58    }
59    if (this.justOpenedTimeout) {
60      clearTimeout(this.justOpenedTimeout);
61    }
62  }
63
64  syncTriggerAriaAttributes() {
65    this.triggerTargets.forEach((trigger) => {
66      const contentId = trigger.dataset.contentId;
67      if (!contentId) return;
68
69      trigger.setAttribute("aria-controls", contentId);
70      trigger.setAttribute("aria-haspopup", "menu");
71      trigger.setAttribute("aria-expanded", this.isOpen && trigger.dataset.state === "open" ? "true" : "false");
72    });
73  }
74
75  isMobileViewport() {
76    return window.innerWidth < 640;
77  }
78
79  toggleMenu(event) {
80    const trigger = event.currentTarget;
81    const contentId = trigger.dataset.contentId;
82
83    // Clear any pending open delay - clicks should be instant
84    if (this.openDelayTimeout) {
85      clearTimeout(this.openDelayTimeout);
86      this.openDelayTimeout = null;
87    }
88
89    // Clear any pending close timeout
90    if (this.closeTimeout) {
91      clearTimeout(this.closeTimeout);
92      this.closeTimeout = null;
93    }
94
95    // If clicking the same trigger, close it
96    if (this.isOpen && this.currentContentId === contentId) {
97      // Ignore close toggles immediately after open to prevent hover/click race jank
98      if (this.justOpened) {
99        return;
100      }
101      this.closeMenu();
102      return;
103    }
104
105    // If a different menu is open, close it first
106    if (this.isOpen && this.currentContentId !== contentId) {
107      this.closeMenu(false);
108    }
109
110    // Open the new menu (clicks are always instant, no delay)
111    this.openMenu(trigger, contentId);
112  }
113
114  handlePointerEnter(event) {
115    // Hover behavior is desktop-only. Mobile layout uses the hamburger menu and
116    // nested click triggers, so viewport pointerleave events should not close it.
117    if (this.isMobileViewport()) {
118      return;
119    }
120
121    // Skip hover behavior on touch devices to prevent conflicts with tap/click
122    if (this.isTouchDevice) {
123      return;
124    }
125
126    // Only handle mouse pointers, not touch or pen
127    if (event.pointerType && event.pointerType !== "mouse") {
128      return;
129    }
130
131    const trigger = event.currentTarget;
132    const contentId = trigger.dataset.contentId;
133
134    // Reset the pointer move flag when entering a new trigger
135    this.hasPointerMoveOpened.set(contentId, false);
136
137    // Clear any pending open delay
138    if (this.openDelayTimeout) {
139      clearTimeout(this.openDelayTimeout);
140      this.openDelayTimeout = null;
141    }
142
143    // Clear any pending close timeouts
144    if (this.closeTimeout) {
145      clearTimeout(this.closeTimeout);
146      this.closeTimeout = null;
147    }
148
149    // Clear any pending close animation
150    if (this.closeAnimationTimeout) {
151      clearTimeout(this.closeAnimationTimeout);
152      this.closeAnimationTimeout = null;
153    }
154
155    // If viewport is in closing state, immediately reset it
156    // BUT ONLY if we're re-entering the SAME content that's closing
157    if (this.hasViewportTarget && this.viewportTarget.dataset.state === "closing") {
158      if (contentId === this.currentContentId) {
159        this.viewportTarget.dataset.state = "open";
160        // Also restore isOpen state since we're preventing the close
161        this.isOpen = true;
162        trigger.dataset.state = "open";
163        this.syncTriggerAriaAttributes();
164      }
165    }
166  }
167
168  handlePointerMove(event) {
169    if (this.isMobileViewport()) {
170      return;
171    }
172
173    // Skip hover behavior on touch devices to prevent conflicts with tap/click
174    if (this.isTouchDevice) {
175      return;
176    }
177
178    // Only respond to mouse pointers, not touch or pen
179    if (event.pointerType && event.pointerType !== "mouse") {
180      return;
181    }
182
183    const trigger = event.currentTarget;
184    const contentId = trigger.dataset.contentId;
185
186    // If we've already opened via pointer movement for this trigger, don't do it again
187    if (this.hasPointerMoveOpened.get(contentId)) {
188      return;
189    }
190
191    // Mark that we've opened via pointer movement
192    this.hasPointerMoveOpened.set(contentId, true);
193
194    // If a different menu is open, smoothly transition to the new one (no delay)
195    if (this.isOpen && this.currentContentId !== contentId) {
196      // Don't transition if still animating - queue it for when animation completes
197      if (this.isAnimating) {
198        this.pendingTransition = { trigger, contentId };
199        return;
200      }
201      this.transitionToMenu(trigger, contentId);
202      return;
203    }
204
205    // Opening from closed state - apply delay if needed
206    if (!this.isOpen) {
207      if (this.isOpenDelayed) {
208        // Apply delay before opening
209        this.openDelayTimeout = setTimeout(() => {
210          this.openMenu(trigger, contentId);
211          this.openDelayTimeout = null;
212        }, this.delayDuration);
213      } else {
214        // No delay - open immediately (within skip delay window)
215        this.openMenu(trigger, contentId);
216      }
217    }
218  }
219
220  handlePointerLeave(event) {
221    if (this.isMobileViewport()) {
222      return;
223    }
224
225    // Skip hover behavior on touch devices to prevent conflicts with tap/click
226    if (this.isTouchDevice) {
227      return;
228    }
229
230    // Only handle mouse pointers, not touch or pen
231    if (event.pointerType && event.pointerType !== "mouse") {
232      return;
233    }
234
235    const trigger = event.currentTarget;
236    const contentId = trigger.dataset.contentId;
237
238    // Reset the pointer move flag when leaving the trigger
239    this.hasPointerMoveOpened.set(contentId, false);
240
241    // Clear any pending open delay - user moved away before it opened
242    if (this.openDelayTimeout) {
243      clearTimeout(this.openDelayTimeout);
244      this.openDelayTimeout = null;
245    }
246
247    // Clear any existing close timeout
248    if (this.closeTimeout) {
249      clearTimeout(this.closeTimeout);
250    }
251
252    // Close after a delay
253    const delay = this.justOpened ? 350 : 150;
254
255    this.closeTimeout = setTimeout(() => {
256      this.closeMenu();
257    }, delay);
258  }
259
260  cancelClose(event) {
261    if (this.isMobileViewport()) {
262      return;
263    }
264
265    // Skip hover behavior on touch devices to prevent conflicts with tap/click
266    if (this.isTouchDevice) {
267      return;
268    }
269
270    // Only handle mouse pointers, not touch or pen
271    if (event.pointerType && event.pointerType !== "mouse") {
272      return;
273    }
274
275    // Cancel close when mouse enters the viewport
276    if (this.closeTimeout) {
277      clearTimeout(this.closeTimeout);
278      this.closeTimeout = null;
279    }
280
281    // Cancel any pending close animation
282    if (this.closeAnimationTimeout) {
283      clearTimeout(this.closeAnimationTimeout);
284      this.closeAnimationTimeout = null;
285    }
286
287    // If viewport is in closing state, immediately reset it
288    if (this.hasViewportTarget && this.viewportTarget.dataset.state === "closing") {
289      this.viewportTarget.dataset.state = "open";
290    }
291  }
292
293  openMenu(trigger, contentId) {
294    const content = this.contentTargets.find((c) => c.id === contentId);
295    if (!content) return;
296
297    // Clear any pending close timeouts to prevent interference from mouse events
298    if (this.closeTimeout) {
299      clearTimeout(this.closeTimeout);
300      this.closeTimeout = null;
301    }
302
303    if (this.closeAnimationTimeout) {
304      clearTimeout(this.closeAnimationTimeout);
305      this.closeAnimationTimeout = null;
306    }
307
308    // Clear skip delay timer since we're opening
309    if (this.skipDelayTimeout) {
310      clearTimeout(this.skipDelayTimeout);
311      this.skipDelayTimeout = null;
312    }
313
314    this.isOpen = true;
315    this.isAnimating = true; // Mark that we're animating
316    this.isOpenDelayed = false; // No delay for subsequent opens (within skip delay window)
317    this.currentContentId = contentId;
318
319    // Set flag to prevent mouse events from closing menu immediately after opening
320    this.justOpened = true;
321    if (this.justOpenedTimeout) {
322      clearTimeout(this.justOpenedTimeout);
323    }
324    this.justOpenedTimeout = setTimeout(() => {
325      this.justOpened = false;
326      this.justOpenedTimeout = null;
327    }, this.closeLockDuration);
328
329    // Clean up ALL content first - ensure no leftover styles
330    this.contentTargets.forEach((c) => {
331      if (c.id !== contentId) {
332        c.classList.add("hidden");
333        c.dataset.state = "closed";
334      }
335      // Reset any transition styles from previous animations
336      c.style.position = "";
337      c.style.width = "";
338      c.style.top = "";
339      c.style.left = "";
340      c.style.opacity = "";
341      c.style.transition = "";
342      c.style.filter = "";
343    });
344
345    // Mark trigger as active and close all others
346    this.triggerTargets.forEach((t) => {
347      t.dataset.state = t === trigger ? "open" : "closed";
348    });
349    this.syncTriggerAriaAttributes();
350
351    // CRITICAL: Clear viewport dimensions from any previous menu
352    if (this.hasViewportTarget) {
353      this.viewportTarget.style.width = "";
354      this.viewportTarget.style.height = "";
355      this.viewportTarget.style.transition = "";
356      // On mobile, clear left style to allow CSS centering
357      const isMobile = window.innerWidth < 640;
358      if (isMobile) {
359        this.viewportTarget.style.left = "";
360      }
361    }
362
363    if (this.hasBackgroundTarget) {
364      this.backgroundTarget.style.width = "";
365      this.backgroundTarget.style.height = "";
366      this.backgroundTarget.style.overflow = "";
367      this.backgroundTarget.style.transition = "";
368    }
369
370    // Show content first (before viewport for proper height calculation)
371    content.classList.remove("hidden");
372    content.dataset.state = "open";
373
374    // Store dimensions for potential transitions
375    requestAnimationFrame(() => {
376      if (content && !content.classList.contains("hidden")) {
377        const contentRect = content.getBoundingClientRect();
378        this.targetDimensions = { width: contentRect.width, height: contentRect.height };
379      }
380    });
381
382    // Position viewport and indicator before showing
383    if (this.hasIndicatorTarget && this.hasViewportTarget) {
384      // Find the effective trigger for indicator positioning
385      // If opening from within mobile menu, use the hamburger button instead
386      const effectiveTrigger = this.getEffectiveTrigger(trigger, contentId);
387
388      // Disable transitions for positioning to prevent horizontal slide
389      this.viewportTarget.style.transition = "none";
390
391      // Position immediately
392      this.positionIndicator(effectiveTrigger);
393
394      // Re-enable transition and show viewport with animation
395      requestAnimationFrame(() => {
396        this.viewportTarget.style.transition = "";
397
398        // Show viewport on next frame to ensure transition is active
399        requestAnimationFrame(() => {
400          this.viewportTarget.dataset.state = "open";
401
402          // Clear animation flag after the viewport animation completes (200ms)
403          setTimeout(() => {
404            this.isAnimating = false;
405            this.executePendingTransition();
406          }, 200);
407        });
408      });
409    } else if (this.hasViewportTarget) {
410      // If no indicator, just show viewport
411      this.viewportTarget.dataset.state = "open";
412
413      // Clear animation flag after a short delay
414      setTimeout(() => {
415        this.isAnimating = false;
416        this.executePendingTransition();
417      }, 200);
418    }
419  }
420
421  executePendingTransition() {
422    if (this.pendingTransition) {
423      const { trigger, contentId } = this.pendingTransition;
424      this.pendingTransition = null;
425      // The normal hover-out logic will handle closing if the user has moved away
426      this.transitionToMenu(trigger, contentId);
427    }
428  }
429
430  getEffectiveTrigger(trigger, contentId) {
431    // Check if the trigger is inside the mobile menu content
432    const mobileMenuContent = document.getElementById("mobile-menu-content");
433    if (mobileMenuContent && mobileMenuContent.contains(trigger)) {
434      // Find the hamburger menu button (the trigger for mobile-menu-content)
435      const hamburgerTrigger = this.triggerTargets.find((t) => t.dataset.contentId === "mobile-menu-content");
436      if (hamburgerTrigger) {
437        return hamburgerTrigger;
438      }
439    }
440
441    return trigger;
442  }
443
444  transitionToMenu(trigger, contentId) {
445    // Store the old content reference BEFORE any cleanup
446    const oldContentId = this.currentContentId;
447    const newContent = this.contentTargets.find((c) => c.id === contentId);
448    const oldContent = this.contentTargets.find((c) => c.id === oldContentId);
449    if (!newContent || !this.hasViewportTarget) return;
450
451    // Mark that we're animating
452    this.isAnimating = true;
453
454    // Update current state immediately to prevent race conditions
455    this.currentContentId = contentId;
456
457    // Set flag to prevent mouse events from closing menu during transition
458    this.justOpened = true;
459    if (this.justOpenedTimeout) {
460      clearTimeout(this.justOpenedTimeout);
461    }
462    this.justOpenedTimeout = setTimeout(() => {
463      this.justOpened = false;
464      this.justOpenedTimeout = null;
465    }, 400);
466
467    // Clear any pending transition cleanup to prevent interference
468    if (this.transitionTimeout) {
469      clearTimeout(this.transitionTimeout);
470      this.transitionTimeout = null;
471      // Note: Don't clear isAnimating here - we're starting a new animation immediately
472
473      // Immediately clean up any in-progress transition state
474      // Reset all content that might be mid-transition (except the ones we're transitioning between)
475      this.contentTargets.forEach((content) => {
476        if (content.id !== contentId && content.id !== oldContentId) {
477          content.classList.add("hidden");
478          content.dataset.state = "closed";
479          content.style.position = "";
480          content.style.width = "";
481          content.style.top = "";
482          content.style.left = "";
483          content.style.opacity = "";
484          content.style.transition = "";
485          content.style.filter = "";
486        }
487      });
488
489      // Clean up old content that was mid-transition
490      if (oldContent) {
491        // Hide it temporarily so it doesn't affect viewport measurements
492        oldContent.classList.add("hidden");
493        oldContent.style.position = "";
494        oldContent.style.width = "";
495        oldContent.style.top = "";
496        oldContent.style.left = "";
497        oldContent.style.opacity = "";
498        oldContent.style.transition = "";
499        oldContent.style.filter = "";
500      }
501
502      // DON'T reset viewport/background dimensions - we need them for smooth measurement
503      // Disable transitions completely so CSS transition-all doesn't interfere
504      this.viewportTarget.style.transition = "none";
505
506      if (this.hasBackgroundTarget) {
507        this.backgroundTarget.style.overflow = "";
508        this.backgroundTarget.style.transition = "none";
509      }
510
511      if (this.hasIndicatorTarget) {
512        this.indicatorTarget.style.transition = "none";
513      }
514
515      // Force a reflow to ensure all cleanup styles are applied before starting new transition
516      void this.viewportTarget.offsetHeight;
517    }
518
519    // Update trigger states
520    this.triggerTargets.forEach((t) => {
521      t.dataset.state = t === trigger ? "open" : "closed";
522    });
523    this.syncTriggerAriaAttributes();
524
525    // Use requestAnimationFrame to ensure cleanup is rendered before measuring
526    requestAnimationFrame(() => {
527      // IMPORTANT: Use stored target dimensions from previous transition if available
528      // This ensures we start from the correct dimensions even if we're mid-transition
529      // Use getBoundingClientRect for subpixel precision to avoid stuttering
530      let currentWidth, currentHeight;
531
532      if (this.targetDimensions) {
533        // Use the stored target dimensions from the previous transition
534        // This is more reliable than reading mid-transition values from the DOM
535        currentWidth = this.targetDimensions.width;
536        currentHeight = this.targetDimensions.height;
537      } else {
538        // No stored dimensions - measure from current content
539        // This happens on the first transition or after menu was fully closed
540        const currentContent = oldContent && !oldContent.classList.contains("hidden") ? oldContent : null;
541        if (currentContent) {
542          const currentContentRect = currentContent.getBoundingClientRect();
543          currentWidth = currentContentRect.width;
544          currentHeight = currentContentRect.height;
545        } else {
546          // Fallback to viewport size
547          const viewportRect = this.viewportTarget.getBoundingClientRect();
548          currentWidth = viewportRect.width;
549          currentHeight = viewportRect.height;
550        }
551      }
552
553      // NOW clear viewport size constraints so new content can measure at natural size
554      this.viewportTarget.style.width = "";
555      this.viewportTarget.style.height = "";
556
557      if (this.hasBackgroundTarget) {
558        this.backgroundTarget.style.width = "";
559        this.backgroundTarget.style.height = "";
560      }
561
562      // Ensure new content has no lingering transition styles
563      newContent.style.position = "";
564      newContent.style.width = "";
565      newContent.style.top = "";
566      newContent.style.left = "";
567      newContent.style.filter = "";
568      newContent.style.transition = "";
569
570      newContent.classList.remove("hidden");
571      newContent.style.opacity = "0";
572      newContent.style.position = "absolute";
573      newContent.dataset.state = "open";
574
575      // Force a reflow before measuring
576      void newContent.offsetHeight;
577
578      // Force layout and measure new dimensions at natural size
579      // Use getBoundingClientRect for subpixel precision to avoid stuttering
580      const newContentRect = newContent.getBoundingClientRect();
581      const newWidth = newContentRect.width;
582      const newHeight = newContentRect.height;
583
584      // Store the target dimensions for the next potential transition
585      this.targetDimensions = { width: newWidth, height: newHeight };
586
587      // Reset new content positioning
588      newContent.style.position = "";
589
590      // Hide new content again (will show it with animation)
591      newContent.classList.add("hidden");
592
593      // Calculate new position to determine movement direction
594      const effectiveTrigger = this.getEffectiveTrigger(trigger, contentId);
595      const triggerRect = effectiveTrigger.getBoundingClientRect();
596      const parentRect = this.viewportTarget.parentElement.getBoundingClientRect();
597      const align = effectiveTrigger.dataset.align || "center";
598
599      // Check if we're on mobile
600      const isMobile = window.innerWidth < 640;
601
602      let newLeft;
603      let newRelativeLeft;
604
605      if (isMobile) {
606        // On mobile, CSS handles centering - don't override it
607        // Just use current left position for smooth transition
608        newRelativeLeft = parseFloat(this.viewportTarget.style.left) || 0;
609      } else {
610        // On desktop, position based on trigger alignment
611        switch (align) {
612          case "start":
613            newLeft = triggerRect.left;
614            break;
615          case "end":
616            newLeft = triggerRect.right - newWidth;
617            break;
618          case "center":
619          default:
620            const triggerCenter = triggerRect.left + triggerRect.width / 2;
621            newLeft = triggerCenter - newWidth / 2;
622            break;
623        }
624        newRelativeLeft = newLeft - parentRect.left;
625      }
626
627      // Set explicit dimensions on viewport for smooth transition
628      this.viewportTarget.style.width = `${currentWidth}px`;
629      this.viewportTarget.style.height = `${currentHeight}px`;
630
631      // On mobile, don't animate left since CSS centering handles it
632      if (isMobile) {
633        this.viewportTarget.style.transition =
634          "width 300ms cubic-bezier(0.22, 0.61, 0.36, 1), height 300ms cubic-bezier(0.22, 0.61, 0.36, 1)";
635      } else {
636        this.viewportTarget.style.transition =
637          "left 250ms cubic-bezier(0.22, 0.61, 0.36, 1), width 300ms cubic-bezier(0.22, 0.61, 0.36, 1), height 300ms cubic-bezier(0.22, 0.61, 0.36, 1)";
638      }
639
640      // Also set dimensions on background container if it exists
641      if (this.hasBackgroundTarget) {
642        this.backgroundTarget.style.width = `${currentWidth}px`;
643        this.backgroundTarget.style.height = `${currentHeight}px`;
644        this.backgroundTarget.style.overflow = "hidden";
645        this.backgroundTarget.style.transition =
646          "width 300ms cubic-bezier(0.22, 0.61, 0.36, 1), height 300ms cubic-bezier(0.22, 0.61, 0.36, 1)";
647      }
648
649      // Position the content absolutely within viewport during transition
650      if (oldContent) {
651        oldContent.style.position = "absolute";
652        oldContent.style.width = "100%";
653        oldContent.style.top = "0";
654        oldContent.style.left = "0";
655        oldContent.style.filter = "blur(0px)";
656      }
657
658      // Show new content positioned absolutely
659      newContent.classList.remove("hidden");
660      newContent.style.position = "absolute";
661      newContent.style.width = `${newWidth}px`;
662      newContent.style.top = "0";
663      newContent.style.left = "0";
664      newContent.style.opacity = "0";
665
666      // Position the viewport and indicator for the new content
667      if (this.hasIndicatorTarget) {
668        // Add smooth transition to indicator movement
669        this.indicatorTarget.style.transition = "left 250ms cubic-bezier(0.22, 0.61, 0.36, 1)";
670        this.positionIndicator(effectiveTrigger, newWidth);
671      }
672
673      // Use another requestAnimationFrame to ensure styles are applied before animating
674      requestAnimationFrame(() => {
675        // Transition viewport dimensions
676        this.viewportTarget.style.width = `${newWidth}px`;
677        this.viewportTarget.style.height = `${newHeight}px`;
678
679        // Also transition background dimensions if it exists
680        if (this.hasBackgroundTarget) {
681          this.backgroundTarget.style.width = `${newWidth}px`;
682          this.backgroundTarget.style.height = `${newHeight}px`;
683        }
684
685        // Blur and fade out old content
686        if (oldContent) {
687          oldContent.style.transition =
688            "opacity 250ms cubic-bezier(0.22, 0.61, 0.36, 1), filter 250ms cubic-bezier(0.22, 0.61, 0.36, 1)";
689          oldContent.style.opacity = "0";
690          oldContent.style.filter = "blur(4px)";
691        }
692
693        // Fade in new content
694        newContent.style.transition = "opacity 250ms cubic-bezier(0.22, 0.61, 0.36, 1) 50ms";
695        newContent.style.opacity = "1";
696
697        // After transition, clean up
698        // Small buffer to ensure all transitions complete before cleanup
699        this.transitionTimeout = setTimeout(() => {
700          // Clear animation flag
701          this.isAnimating = false;
702
703          // Execute any pending transition
704          this.executePendingTransition();
705
706          // Disable transitions completely to prevent CSS transition-all from causing stutter
707          this.viewportTarget.style.transition = "none";
708
709          if (this.hasBackgroundTarget) {
710            this.backgroundTarget.style.overflow = "";
711            this.backgroundTarget.style.transition = "none";
712          }
713
714          if (this.hasIndicatorTarget) {
715            this.indicatorTarget.style.transition = "none";
716          }
717
718          // Reset new content positioning
719          newContent.style.position = "";
720          newContent.style.width = "";
721          newContent.style.top = "";
722          newContent.style.left = "";
723          newContent.style.opacity = "";
724          newContent.style.transition = "none";
725
726          // Hide and reset old content
727          if (oldContent) {
728            oldContent.classList.add("hidden");
729            oldContent.dataset.state = "closed";
730            oldContent.style.position = "";
731            oldContent.style.width = "";
732            oldContent.style.top = "";
733            oldContent.style.left = "";
734            oldContent.style.opacity = "";
735            oldContent.style.transition = "none";
736            oldContent.style.filter = "";
737          }
738
739          this.transitionTimeout = null;
740        }, 300);
741      });
742    });
743  }
744
745  closeMenu(animate = true) {
746    if (!this.isOpen) return;
747
748    // Clear any pending transition cleanup
749    if (this.transitionTimeout) {
750      clearTimeout(this.transitionTimeout);
751      this.transitionTimeout = null;
752    }
753
754    const closingContentId = this.currentContentId; // Save before clearing
755    const content = this.contentTargets.find((c) => c.id === closingContentId);
756    this.triggerTargets.forEach((trigger) => {
757      if (trigger.dataset.state === "open" || trigger.dataset.contentId === closingContentId) {
758        trigger.dataset.state = "closed";
759      }
760    });
761
762    // Mark as closed immediately so new hovers know we're closing
763    this.isOpen = false;
764    this.justOpened = false;
765    if (this.justOpenedTimeout) {
766      clearTimeout(this.justOpenedTimeout);
767      this.justOpenedTimeout = null;
768    }
769    this.isAnimating = false; // Clear animation flag when closing
770    this.pendingTransition = null; // Clear any pending transitions
771    this.targetDimensions = null; // Clear stored dimensions
772    this.syncTriggerAriaAttributes();
773
774    // Start skip delay timer - if user reopens within this window, no delay applied
775    if (this.skipDelayTimeout) {
776      clearTimeout(this.skipDelayTimeout);
777    }
778    this.skipDelayTimeout = setTimeout(() => {
779      this.isOpenDelayed = true;
780    }, this.skipDelayDuration);
781
782    // Hide viewport with animation
783    if (this.hasViewportTarget) {
784      if (animate) {
785        // Clear inline transition to allow CSS transition-all to handle the close animation
786        this.viewportTarget.style.transition = "";
787
788        // Also clear content transitions
789        if (content) {
790          content.style.transition = "";
791        }
792
793        // Set closing state for CSS animation
794        this.viewportTarget.dataset.state = "closing";
795
796        // Hide after animation completes
797        this.closeAnimationTimeout = setTimeout(() => {
798          this.viewportTarget.dataset.state = "closed";
799
800          // Hide content after viewport is hidden
801          if (content) {
802            content.classList.add("hidden");
803            content.dataset.state = "closed";
804          }
805
806          // Reset viewport position after animation completes
807          // On mobile, clear the left style to allow CSS centering; on desktop set to 0
808          const isMobile = window.innerWidth < 640;
809          this.viewportTarget.style.left = isMobile ? "" : "0px";
810          if (this.hasIndicatorTarget) {
811            this.indicatorTarget.style.left = "0px";
812          }
813
814          // Clear currentContentId ONLY after animation completes
815          this.currentContentId = null;
816          this.closeAnimationTimeout = null;
817        }, 200);
818      } else {
819        this.viewportTarget.dataset.state = "closed";
820
821        if (content) {
822          content.classList.add("hidden");
823          content.dataset.state = "closed";
824        }
825
826        // Reset viewport position immediately when not animating
827        // On mobile, clear the left style to allow CSS centering; on desktop set to 0
828        const isMobile = window.innerWidth < 640;
829        this.viewportTarget.style.left = isMobile ? "" : "0px";
830        if (this.hasIndicatorTarget) {
831          this.indicatorTarget.style.left = "0px";
832        }
833
834        // Clear currentContentId immediately when not animating
835        this.currentContentId = null;
836      }
837    }
838  }
839
840  handleClickOutside(event) {
841    if (!this.isOpen) return;
842
843    // Check if click is outside the navbar
844    if (!this.element.contains(event.target)) {
845      this.closeMenu();
846    }
847  }
848
849  handleKeydown(event) {
850    // Handle Escape key
851    if (event.key === "Escape" && this.isOpen) {
852      event.preventDefault();
853      const activeTrigger = this.triggerTargets.find((t) => t.dataset.state === "open");
854      this.closeMenu();
855      // Return focus to the active trigger
856      if (activeTrigger) activeTrigger.focus();
857      return;
858    }
859
860    // Only handle arrow keys and Tab when menu is open
861    if (!this.isOpen) return;
862
863    const focusableItems = this.getFocusableItems();
864    if (focusableItems.length === 0) return;
865
866    const currentIndex = focusableItems.indexOf(document.activeElement);
867
868    // Handle Arrow Down or Arrow Right - next item
869    if (event.key === "ArrowDown" || event.key === "ArrowRight") {
870      event.preventDefault();
871      const nextIndex = currentIndex < focusableItems.length - 1 ? currentIndex + 1 : 0;
872      focusableItems[nextIndex].focus();
873    }
874
875    // Handle Arrow Up or Arrow Left - previous item
876    if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
877      event.preventDefault();
878      const prevIndex = currentIndex >
878 0 ? currentIndex - 1 : focusableItems.length - 1;
879      focusableItems[prevIndex].focus();
880    }
881
882    // Handle Tab key - close menu and move to next navbar item
883    if (event.key === "Tab" && !event.shiftKey) {
884      event.preventDefault();
885
886      // Get all visible, focusable elements in the navbar menu (not inside dropdown content)
887      const menuElement = this.hasMenuTarget ? this.menuTarget : this.element.querySelector("ul");
888      if (menuElement) {
889        // Only get direct children of the menu, excluding items inside dropdown content
890        const visibleNavItems = Array.from(
891          menuElement.querySelectorAll(":scope > li > a, :scope > li > button"),
892        ).filter((el) => {
893          // Check if element is visible
894          const rect = el.getBoundingClientRect();
895          return rect.width > 0 && rect.height > 0;
896        });
897
898        // Find the currently active trigger (that opened this menu) among visible items
899        // Important: filter triggers to only visible ones to handle responsive layouts
900        const visibleTriggers = this.triggerTargets.filter((t) => {
901          const rect = t.getBoundingClientRect();
902          return rect.width > 0 && rect.height > 0 && t.dataset.contentId === this.currentContentId;
903        });
904
905        const currentTrigger = visibleTriggers[0]; // Get the first visible trigger for this content
906
907        this.closeMenu(false); // Close without animation for immediate focus shift
908
909        const currentIndex = currentTrigger ? visibleNavItems.indexOf(currentTrigger) : -1;
910
911        if (currentIndex !== -1 && currentIndex + 1 < visibleNavItems.length) {
912          // Focus next visible nav item
913          visibleNavItems[currentIndex + 1].focus();
914        } else {
915          // If no next item in navbar, tab out to content after navbar
916          const allFocusable = Array.from(
917            document.querySelectorAll(
918              'a[href]:not([disabled]), button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
919            ),
920          );
921          const nextElement = allFocusable.find((el) => {
922            return !this.element.contains(el);
923          });
924          if (nextElement) nextElement.focus();
925        }
926      }
927    }
928
929    // Handle Shift+Tab - close menu and move to previous navbar item
930    if (event.key === "Tab" && event.shiftKey) {
931      event.preventDefault();
932
933      // Get all visible, focusable elements in the navbar menu (not inside dropdown content)
934      const menuElement = this.hasMenuTarget ? this.menuTarget : this.element.querySelector("ul");
935      if (menuElement) {
936        // Only get direct children of the menu, excluding items inside dropdown content
937        const visibleNavItems = Array.from(
938          menuElement.querySelectorAll(":scope > li > a, :scope > li > button"),
939        ).filter((el) => {
940          // Check if element is visible
941          const rect = el.getBoundingClientRect();
942          return rect.width > 0 && rect.height > 0;
943        });
944
945        // Find the currently active trigger (that opened this menu) among visible items
946        const visibleTriggers = this.triggerTargets.filter((t) => {
947          const rect = t.getBoundingClientRect();
948          return rect.width > 0 && rect.height > 0 && t.dataset.contentId === this.currentContentId;
949        });
950
951        const currentTrigger = visibleTriggers[0]; // Get the first visible trigger for this content
952
953        this.closeMenu(false); // Close without animation for immediate focus shift
954
955        const currentIndex = currentTrigger ? visibleNavItems.indexOf(currentTrigger) : -1;
956
957        if (currentIndex > 0) {
958          // Focus previous visible nav item
959          visibleNavItems[currentIndex - 1].focus();
960        } else {
961          // If at first item in navbar, tab out to content before navbar
962          const allFocusable = Array.from(
963            document.querySelectorAll(
964              'a[href]:not([disabled]), button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not
964([tabindex="-1"])',
965            ),
966          );
967          // Find the last focusable element before the navbar
968          const navbarStart = this.element.getBoundingClientRect().top;
969          const navbarElements = Array.from(this.element.querySelectorAll("*"));
970          const previousElement = allFocusable.reverse().find((el) => {
971            const rect = el.getBoundingClientRect();
972            return rect.bottom <= navbarStart || (!navbarElements.includes(el) && !this.element.contains(el));
973          });
974          if (previousElement) previousElement.focus();
975        }
976      }
977    }
978  }
979
980  getFocusableItems() {
981    if (!this.isOpen) return [];
982
983    const content = this.contentTargets.find((c) => c.id === this.currentContentId);
984    if (!content) return [];
985
986    return Array.from(content.querySelectorAll("a[href]:not([disabled]), button:not([disabled])"));
987  }
988
989  handleTriggerKeydown(event) {
990    const trigger = event.currentTarget;
991    const contentId = trigger.dataset.contentId;
992
993    // Clear any pending open delay - keyboard should be instant
994    if (this.openDelayTimeout) {
995      clearTimeout(this.openDelayTimeout);
996      this.openDelayTimeout = null;
997    }
998
999    // Open menu and focus first item on Enter, Space, ArrowDown, or ArrowRight
1000    if (event.key === "Enter" || event.key === " " || event.key === "ArrowDown" || event.key === "ArrowRight") {
1001      event.preventDefault();
1002
1003      // Open menu if not open (keyboard is always instant, no delay)
1004      if (!this.isOpen || this.currentContentId !== contentId) {
1005        this.openMenu(trigger, contentId);
1006      }
1007
1008      // Focus first item after menu opens
1009      requestAnimationFrame(() => {
1010        const focusableItems = this.getFocusableItems();
1011        if (focusableItems.length > 0) {
1012          focusableItems[0].focus();
1013        }
1014      });
1015    }
1016
1017    // Open menu and focus last item on ArrowUp or ArrowLeft
1018    if (event.key === "ArrowUp" || event.key === "ArrowLeft") {
1019      event.preventDefault();
1020
1021      // Open menu if not open (keyboard is always instant, no delay)
1022      if (!this.isOpen || this.currentContentId !== contentId) {
1023        this.openMenu(trigger, contentId);
1024      }
1025
1026      // Focus last item after menu opens
1027      requestAnimationFrame(() => {
1028        const focusableItems = this.getFocusableItems();
1029        if (focusableItems.length > 0) {
1030          focusableItems[focusableItems.length - 1].focus();
1031        }
1032      });
1033    }
1034  }
1035
1036  positionIndicator(trigger, providedWidth = null) {
1037    if (!this.hasIndicatorTarget || !this.hasViewportTarget) return;
1038
1039    const triggerRect = trigger.getBoundingClientRect();
1040
1041    // Get viewport width (use provided width during transitions, otherwise read from DOM)
1042    const viewportWidth = providedWidth !== null ? providedWidth : this.viewportTarget.offsetWidth;
1043
1044    // Check if we're on mobile (screen width < 640px, sm breakpoint)
1045    const isMobile = window.innerWidth < 640;
1046
1047    let viewportLeft;
1048    let indicatorLeft;
1049
1050    if (isMobile) {
1051      // On mobile, CSS handles centering with left-1/2 -translate-x-1/2
1052      // Don't override the CSS positioning
1053      // Just hide the indicator and return early
1054      this.indicatorTarget.style.opacity = "0";
1055      return;
1056    } else {
1057      // On desktop, position relative to trigger
1058      // Get alignment from trigger's data attribute (defaults to "center")
1059      const align = trigger.dataset.align || "center";
1060
1061      // Show indicator on desktop
1062      this.indicatorTarget.style.opacity = "1";
1063
1064      // Calculate positions based on alignment
1065      switch (align) {
1066        case "start":
1067          // Align viewport left edge with trigger left edge
1068          viewportLeft = triggerRect.left;
1069          // Position indicator at the trigger center relative to viewport
1070          indicatorLeft = triggerRect.width / 2 - 20;
1071          break;
1072
1073        case "end":
1074          // Align viewport right edge with trigger right edge
1075          viewportLeft = triggerRect.right - viewportWidth;
1076          // Position indicator at the trigger center relative to viewport
1077          const offsetFromRight = triggerRect.width / 2;
1078          indicatorLeft = viewportWidth - offsetFromRight - 20;
1079          break;
1080
1081        case "center":
1082        default:
1083          // Center viewport on trigger
1084          const triggerCenter = triggerRect.left + triggerRect.width / 2;
1085          viewportLeft = triggerCenter - viewportWidth / 2;
1086          // Position indicator at center of viewport
1087          indicatorLeft = viewportWidth / 2 - 20;
1088          break;
1089      }
1090    }
1091
1092    // Calculate position relative to the parent element
1093    const parentRect = this.viewportTarget.parentElement.getBoundingClientRect();
1094    const relativeLeft = viewportLeft - parentRect.left;
1095
1096    // Position the viewport
1097    this.viewportTarget.style.left = `${relativeLeft}px`;
1098
1099    // Position the indicator
1100    // w-10 = 40px (2.5rem), so subtract 20px to center the arrow
1101    this.indicatorTarget.style.left = `${indicatorLeft}px`;
1102  }
1103};

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.