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.