1 2(function ($) { 3 // IconKit compatibility layer: 4 // - supports newer DZIconKit.safeGlobal() / getStyleClass() 5 var IconKit = (window.DZIconKit && window.DZIconKit.safeGlobal) ? window.DZIconKit.safeGlobal() : { htmlFor: () => '', elFor: () => document.createTextNode(''), getStyleClass: () => 'style-symbol' }; 6 7 const themeSettings = window.dzSettings || {}; 8 const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0; 9 const settingIsTrue = (value) => value === true || value === '1' || value === 1; 10 const serverSettings = themeSettings.serverDetection || {}; 11 const serverDetection = { 12 method: serverSettings.method || themeSettings.detectionMethod || 'unknown', 13 isMobileDevice: settingIsTrue(serverSettings.isMobileDevice ?? themeSettings.isMobileDevice), 14 isMobile: settingIsTrue(serverSettings.isMobile ?? themeSettings.isMobile), 15 isTablet: settingIsTrue(serverSettings.isTablet ?? themeSettings.isTablet), 16 scope: serverSettings.scope || 'cache-generating-request', 17 }; 18 19 let $activeItem = null; 20 let hideTimeout; 21 // Strings 22 const subMenuPlaceholderId = '#submenu-display'; 23 const headerContainerClass = '.header-strip'; 24 const headerMenuButtonClass = '.menu-button'; 25 const navClass = '.primary-menu'; 26 const menuClass = '.menu'; 27 const menuItemClass = '.menu-item'; 28 const menuLinkClass = '.menu-link'; 29 const menuLinkHasSubmenuClass = '.menu-link.has-submenu'; 30 const menuItemHasChildrenClass = '.menu-item-has-children'; 31 const menuSubmenuClass = '.submenu'; 32 const menuSubmenuSpacerClass = '.submenu-spacer'; 33 const menuSubmenuToggleClass = '.submenu-toggle'; 34 // CSS classes 35 const menuOpenCSSClass = 'menu-open'; 36 //const menuButtonCSSfaClassClosed = 'fa-bars'; 37 //const menuButtonCSSfaClassOpen = 'fa-times'; 38 const menuIconClosed = 'burger'; 39 const menuIconOpen = 'xmark'; 40 // Elements 41 const $headerContainer = $(headerContainerClass); 42 const $menu = $(navClass); 43 const $menuItemHasChildren = $(menuItemHasChildrenClass); 44 const $menuButton = $(headerMenuButtonClass); 45 const $submenuToggle = $(menuSubmenuToggleClass); 46 // Placeholder for cloned submenus 47 const $subMenuPlaceholder = $(subMenuPlaceholderId); 48 49 const dzDebugState = { 50 interactionMode: 'unknown', 51 modeReason: '', 52 wrapResult: 'n/a', // true/false/null/'n/a' 53 menuToggleVisible: 'n/a', 54 menuMeasurable: 'n/a', 55 lastSwitchAt: '', 56 serverDetection, 57 client: { 58 viewportWidth: window.innerWidth, 59 isDesktopViewport: false, 60 isTouchDevice, 61 hasHover: false, 62 hasFinePointer: false, 63 }, 64 }; 65 function setIcon($el, iconKey) { 66 if (!iconKey) { 67 $el.empty(); 68 return; 69 } 70 $el.html(IconKit.htmlFor(iconKey)); 71 } 72 73 function isDesktopViewportNow() { 74 return window.matchMedia('(min-width: 768px)').matches; 75 } 76 function updateClientDebugState() { 77 dzDebugState.client = { 78 viewportWidth: window.innerWidth, 79 isDesktopViewport: isDesktopViewportNow(), 80 isTouchDevice, 81 hasHover: window.matchMedia('(hover: hover)').matches, 82 hasFinePointer: window.matchMedia('(pointer: fine)').matches, 83 }; 84 85 return dzDebugState.client; 86 } 87 function normalizePath(path) { 88 let p = (path || '/').toString(); 89 try { 90 p = decodeURIComponent(p); 91 } catch (e) { 92 // Keep raw path if decoding fails. 93 } 94 p = p.toLowerCase(); 95 p = p.replace(/\/index\.php$/, '/'); 96 p = p.replace(/\/+/g, '/'); 97 p = '/' + p.replace(/^\/+/, ''); 98 if (!p.endsWith('/')) p += '/'; 99 return p; 100 } 101 function pathFromHref(href) { 102 try { 103 const url = new URL(href, window.location.origin); 104 // Skip links pointing to a different domain. 105 if (url.hostname !== window.location.hostname) return null; 106 return normalizePath(url.pathname || '/'); 107 } catch (e) { 108 return null; 109 } 110 } 111 function syncCurrentMenuState() { 112 const $links = $menu.find(menuLinkClass + '[href]'); 113 if (!$links.length) return; 114 115 const currentPath = normalizePath(window.location.pathname || '/'); 116 const stateClasses = 'current-menu-item current_page_item current-menu-ancestor current_page_ancestor active'; 117 118 // Reset any stale classes from fragment-cached markup. 119 $menu.find(menuItemClass).removeClass(stateClasses); 120 $links.removeAttr('aria-current'); 121 122 let $best = null; 123 let bestLen = -1; 124 125 // Pick the most specific exact path match. 126 $links.each(function () { 127 const $link = $(this);
128 const itemPath = pathFromHref($link.attr('href')); 129 if (!itemPath) return; // skip external/invalid links 130 131 if (itemPath === currentPath && itemPath.length > bestLen) { 132 $best = $link; 133 bestLen = itemPath.length; 134 } 135 }); 136 137 // If no exact match, try to detect parent-child CPT relationship. 138 if (!$best || !$best.length) { 139 const pathSegments = currentPath.split('/').filter(Boolean); 140 141 // If current page has a parent segment (e.g., /case-studies/mathnasium/ => ["case-studies", "mathnasium"]) 142 if (pathSegments.length >= 2) { 143 const parentSlug = pathSegments[0]; 144 145 // Look for menu links that contain the parent slug in their href. 146 $links.each(function () { 147 const $link = $(this); 148 const itemPath = pathFromHref($link.attr('href')); 149 if (!itemPath) return; // skip external/invalid links 150 151 // Match if link contains the parent slug (e.g., /case-studies-selection/ contains "case-studies") 152 if (itemPath.includes('/' + parentSlug) && itemPath.length > bestLen) { 153 $best = $link; 154 bestLen = itemPath.length; 155 } 156 }); 157 } 158 } 159 160 if (!$best || !$best.length) return; 161 162 const $currentItem = $best.closest(menuItemClass); 163 $currentItem.addClass('current-menu-item current_page_item active'); 164 $best.attr('aria-current', 'page'); 165 166 // Mark parents as ancestors for nested menus. 167 $currentItem.parents(menuItemHasChildrenClass).addClass('current-menu-ancestor current_page_ancestor active'); 168 } 169 function isTouchDesktopNow() { 170 return isTouchDevice && isDesktopViewportNow(); 171 } 172 function bindKeyboardFocusClose() { 173 // Close when focus leaves the currently active item/submenu 174 $menu.on('focusout', function () { 175 // Wait for the browser to move focus, then check where it landed 176 setTimeout(() => { 177 const el = document.activeElement; 178 179 // If focus is still inside the menu or inside the placeholder, keep it open 180 const inMenu = $menu.length && $menu.get(0).contains(el); 181 const inPlaceholder = $subMenuPlaceholder.length && $subMenuPlaceholder.get(0).contains(el); 182 183 if (!inMenu && !inPlaceholder) { 184 closeSubmenu(); 185 } 186 }, 0); 187 }); 188 189 // Optional: Esc closes and returns focus to the current trigger link/button 190 $menu.on('keydown', function (e) { 191 if (e.key !== 'Escape') return; 192 e.preventDefault(); 193 194 const $returnTo = $activeItem ? $activeItem.find(menuLinkClass).first() : null; 195 closeSubmenu(); 196 if ($returnTo && $returnTo.length) $returnTo.focus(); 197 }); 198 } 199 200 // Run a callback after layout is more likely to be stable 201 function afterLayout(fn) { 202 requestAnimationFrame(() => requestAnimationFrame(fn)); 203 } 204 /** 205 * MENU MODE DECISION TREE (keep in sync with dzDebugState.modeReason) 206 * 207 * Goals: 208 * - Be reversible on resize/rotation (no âstuck in mobileâ). 209 * - Never let CSS-driven burger visibility create a JS deadlock. 210 * - Prefer capability + layout signals over UA guessing. 211 * - Keep CSS responsible for layout; JS responsible for interaction. 212 * 213 * Priority order: 214 * 1) forceMode (debug/override) 215 * 2) Burger-visible AND viewport < 768 -> mobile (burger-visible(viewport)) 216 * - IMPORTANT: burger visibility alone is NOT a signal when viewport >= 768, 217 * because CSS may show burger in mode-mobile/hybrid. Gate by viewport. 218 * 3) Wrap detection (if enabled AND menu measurable): 219 * wrap=true -> mobile (wrap-detected) 220 * wrap=false -> continue 221 * wrap=null -> continue (unknown; do not flip modes) 222 * 4) Live viewport/capability fallback: 223 * viewport < 768 -> mobile 224 * touch desktop -> hybrid 225 * else -> desktop 226 * 227 * Binding rules (handleInteractionModeChange): 228 * - If burger is visible, always ensure mobile click handlers are bound. 229 * - Rebind not only on mode change, but also on burger visibility change. 230 */ 231 function getInteractionMode() { 232 const client = updateClientDebugState(); 233 234 if (themeSettings.forceMode) { 235 dzDebugState.modeReason = 'forceMode'; 236 return themeSettings.forceMode; 237 } 238 // Check if the menu button is visible 239 const isMenuToggleVisible = $menuButton.is(':visible'); 240 dzDebugState.menuToggleVisible = isMenuToggleVisible; 241 242 // Only allow burger-visible to force mobile 243 // IF we are actually in a small viewport 244 if (isMenuToggleVisible && !isDesktopViewportNow()) { 245 dzDebugState.modeReason = 'burger-visible (viewport)'; 246 dzDebugState.wrapResult = 'skipped'; 247 return 'mobile'; 248 } 249 if (isMenuToggleVisible && isDesktopViewportNow()) { 250 dzDebugState.modeReason = 'burger-visible (ignored)'; 251 } 252 253 if (themeSettings.useMenuWrapDetection === true) { 254 255 dzDebugState.menuMeasurable = isMeasurable($menu.get(0)); 256 const wrapResult = menuItemsAreWrappingSafe($menu); 257 dzDebugState.wrapResult = (wrapResult === null) ? 'not-measurable' : wrapResult; 258 259 260 if (wrapResult === true) { 261 dzDebugState.modeReason = 'wrap-detected'; 262 return 'mobile'; 263 } 264 } else { 265 dzDebugState.wrapResult = 'disabled'; 266 } 267 268 if (!client.isDesktopViewport) { 269 dzDebugState.modeReason = 'mobile-viewport'; 270 return 'mobile'; 271 } 272 if (isTouchDesktopNow()) { 273 dzDebugState.modeReason = 'touch-desktop'; 274 return 'hybrid'; 275 } 276 dzDebugState.modeReason = 'desktop-viewport'; 277 278 return 'desktop'; 279 } 280 281 let interactionMode = 'unknown'; 282 let lastInteractionMode = null; 283 let lastBurgerVisible = null; 284 285 const debouncedModeCheck = debounce(() => { 286 afterLayout(() => { 287 handleInteractionModeChange(); 288 updateDebugBadge(); 289 }); 290 }, 200); 291 292 window.addEventListener('resize', debouncedModeCheck); 293 window.addEventListener('orientationchange', debouncedModeCheck); 294 295 if (document.fonts && document.fonts.ready) { 296 document.fonts.ready.then(() => { 297 afterLayout(() => { 298 handleInteractionModeChange(); 299 updateDebugBadge(); 300 }); 301 }).catch(() => { });
302 } 303 304 function debounce(fn, delay) { 305 let timer = null; 306 return function (...args) { 307 clearTimeout(timer); 308 timer = setTimeout(() => fn.apply(this, args), delay); 309 }; 310 } 311 function shouldUseHover() { 312 if (isBurgerVisible()) return false; 313 const prefersPointer = window.matchMedia('(hover: hover) and (pointer: fine)').matches; 314 return (interactionMode === 'desktop') || (interactionMode === 'hybrid' && prefersPointer); 315 } 316 function shouldUseClickToggle() { 317 if (isBurgerVisible()) return true; 318 return interactionMode === 'mobile' || (interactionMode === 'hybrid' && !isDesktopViewportNow()); 319 } 320 function isBurgerVisible() { 321 return $menuButton.is(':visible'); 322 } 323 function isMeasurable(el) { 324 if (!el) return false; 325 if (!el.isConnected) return false; 326 if (el.offsetParent === null) return false; // display:none or not in layout tree 327 328 const r = el.getBoundingClientRect(); 329 if (!r || r.width <= 1 || r.height <= 1) return false; 330 331 return true; 332 } 333 function menuItemsAreWrappingSafe($container) { 334 const containerEl = $container && $container.length ? $container.get(0) : null; 335 if (!isMeasurable(containerEl)) return null; 336 337 const $items = $(menuClass + ' > ' + menuItemClass); 338 if ($items.length < 2) return false; 339 340 const firstEl = $items.get(0); 341 if (!isMeasurable(firstEl)) return null; 342 343 // Use getBoundingClientRect (more direct than jQuery offset) 344 const top = firstEl.getBoundingClientRect().top; 345 346 for (const el of $items.get()) { 347 if (!isMeasurable(el)) return null; // if any item isn't measurable, bail 348 if (el.getBoundingClientRect().top > top + 0.5) return true; 349 } 350 351 return false; 352 } 353 354 /** 355 * Get the height of the header container and set it as a CSS variable 356 * @returns {number} - The height of the header container 357 */ 358 function getHeaderOffset() { 359 const $header = $headerContainer; 360 const offset = $header.length ? $header.outerHeight() : 110; 361 document.documentElement.style.setProperty('--header-height', offset + 'px'); 362 return offset; 363 } 364 // ---- Desktop Submenu functions ---- 365 function openSubmenuold($item) { 366 const $submenu = $item.children(menuSubmenuClass); 367 if (!$submenu.length) return; 368 369 const offsetY = getHeaderOffset(); 370 const useClone = shouldUseHover(); 371 372 $subMenuPlaceholder.css({ 373 top: offsetY + 'px', 374 opacity: 1, 375 transform: 'translateY(0)', 376 pointerEvents: 'auto' 377 }).attr('aria-hidden', 'false') 378 .empty(); 379 380 if (useClone) { 381 // Remove previous cloned submenu only 382 $subMenuPlaceholder.children(menuSubmenuClass).remove(); 383 384 // Clone and append fresh 385 const $cloned = $submenu.clone(true, true); 386 $cloned.attr('data-clone', 'true'); 387 $subMenuPlaceholder.append($cloned); 388 } else { 389 // Remove any clone before restoring real submenu 390 $subMenuPlaceholder.children(menuSubmenuClass + '[data-clone="true"]').remove(); 391 392 $subMenuPlaceholder.append($submenu); 393 } 394 395 $item.find(menuLinkClass + ', ' + menuSubmenuToggleClass).attr('aria-expanded', 'true'); 396 $activeItem = $item; 397 } 398 function openSubmenu($item) { 399 const $submenu = $item.children(menuSubmenuClass); 400 if (!$submenu.length) return; 401 402 // --- KEYBOARD PATH: inline only, no #submenu-display --- 403 if (isKeyboardNav()) { 404 // Ensure the placeholder is not participating at all 405 $subMenuPlaceholder.css({ 406 opacity: 0, 407 transform: 'translateY(-10px)', 408 pointerEvents: 'none' 409 }).attr('aria-hidden', 'true') 410 .empty(); 411 412 // Open inline submenu (CSS should handle positioning/visibility) 413 $submenu.addClass('open').attr('aria-hidden', 'false'); 414 415 $item.find(menuLinkClass + ', ' + menuSubmenuToggleClass).attr('aria-expanded', 'true'); 416 $activeItem = $item; 417 return; 418 } 419 420 // --- MOUSE/HOVER PATH: your existing placeholder behaviour --- 421 const offsetY = getHeaderOffset(); 422 const useClone = shouldUseHover(); 423 424 $subMenuPlaceholder.css({ 425 top: offsetY + 'px', 426 opacity: 1, 427 transform: 'translateY(0)', 428 pointerEvents: 'auto' 429 }).attr('aria-hidden', 'false') 430 .empty(); 431 432 if (useClone) { 433 $subMenuPlaceholder.children(menuSubmenuClass).remove(); 434 435 const $cloned = $submenu.clone(true, true); 436 $cloned.attr('data-clone', 'true'); 437 $subMenuPlaceholder.append($cloned); 438 } else { 439 $subMenuPlaceholder.children(menuSubmenuClass + '[data-clone="true"]').remove(); 440 $subMenuPlaceholder.append($submenu); 441 } 442 443 $item.find(menuLinkClass + ', ' + menuSubmenuToggleClass).attr('aria-expanded', 'true'); 444 $activeItem = $item; 445 } 446 447 function closeSubmenold() { 448 if (!$activeItem) return; 449 450 const $submenu = $subMenuPlaceholder.children(menuSubmenuClass); // match either 451 452 if ($submenu.length && !shouldUseHover()) { 453 // Move back if it was the main menu 454 $menu.append($submenu); 455 } 456 457 $subMenuPlaceholder.css({ 458 opacity: 0, 459 transform: 'translateY(-10px)', 460 pointerEvents: 'none' 461 }) 462 .attr('aria-hidden', 'true') 463 .empty(); 464 465 $activeItem.find(menuLinkClass + ', ' + menuSubmenuToggleClass).attr('aria-expanded', 'false'); 466 $activeItem = null; 467 } 468 function closeSubmenu() { 469 if (!$activeItem) return; 470 471 // Close inline submenu if keyboard opened it 472 if (isKeyboardNav()) { 473 const $inline = $activeItem.children(menuSubmenuClass); 474 $inline.removeClass('open').attr('aria-hidden', 'true'); 475 476 $activeItem.find(menuLinkClass + ', ' + menuSubmenuToggleClass).attr('aria-expanded', 'false'); 477 $activeItem = null; 478 return; 479 } 480 481 // Existing behaviour for placeholder path 482 const $submenu = $subMenuPlaceholder.children(menuSubmenuClass); 483 484 if ($submenu.length && !shouldUseHover()) { 485 $menu.append($submenu); 486 } 487 488 $subMenuPlaceholder.css({ 489 opacity: 0, 490 transform: 'translateY(-10px)', 491 pointerEvents: 'none' 492 }) 493 .attr('aria-hidden', 'true') 494 .empty(); 495 496 $activeItem.find(menuLinkClass + ', ' + menuSubmenuToggleClass).attr('aria-expanded', 'false'); 497 $activeItem = null; 498 } 499 500 // ---- END Desktop Submenu functions ---- 501 502 // ---- Mobile submenu functions ---- 503 /** 504 * Clone the original menu for mobile 505 * This function creates a clone of the original menu and appends it to a placeholder for mobile view. 506 * @param {*} $originalMenu 507 */ 508 function openMenuForMobile($originalMenu) { 509 const $clone = $originalMenu.clone(true, true); 510 const offsetY = getHeaderOffset(); 511 // Avoid ID conflicts 512 513 const originalId = $originalMenu.attr('id') || 'primary'; 514 const cloneId = originalId + '-mobile'; 515 516 $menuButton.attr('aria-controls', cloneId); 517 518 $clone.attr({ 519 'id': cloneId, 520 'data-mobile-clone': 'true', 521 'aria-hidden': 'false' 522 }); 523 524 // Append to placeholder 525 $subMenuPlaceholder.empty() 526 .css({ 527 top: offsetY + 'px', 528 opacity: 1, 529 transform: 'translateY(0)', 530 pointerEvents: 'auto' 531 }) 532 .attr('aria-hidden', 'false') 533 .append($clone); 534 535 $clone.css({ 536 display: 'flex', 537 visibility: 'visible' 538 }); 539 } 540 /** 541 * Close the mobile menu 542 * This function removes the cloned menu from the placeholder and restores the original menu. 543 * @param {*} $originalMenu 544 */ 545 function closeMenuForMobile() { 546 $menuButton.removeAttr('aria-controls'); 547 548 $subMenuPlaceholder.css({ 549 top: '0px', 550 opacity: 0, 551 transform: 'translateY(-10px)', 552 pointerEvents: 'none' 553 }).attr('aria-hidden', 'true').empty(); 554 555 } 556 function hideDesktopMainMenu($originalMenu) { 557 // Hide the original 558 $originalMenu 559 .attr('data-controller', 'hideDMM') 560 .attr('aria-hidden', 'true') 561 .css({ 562 display: '',// was none
563 visibility: '' // was hidden 564 }) 565 .addClass('dz-nav-hidden'); 566 } 567 function restoreDesktopMainMenu($originalMenu) { 568 $originalMenu 569 .attr('data-controller', 'restoreDMM') 570 .attr('aria-hidden', 'false') 571 .css({ 572 display: '', //was flex 573 visibility: '' // was visible 574 }) 575 .removeClass('dz-nav-hidden'); 576 } 577 /** 578 * Mobile inline submenu toggle 579 * @param {*} $item 580 * @returns 581 */ 582 function openInlineSubmenu($item) { 583 const $submenu = $item.children(menuSubmenuClass); 584 const $spacer = $item.children(menuSubmenuSpacerClass); 585 if (!$submenu.length || !$spacer.length) return; 586 587 closeAllInlineSubmenus(); 588 589 $submenu 590 .attr('aria-hidden', 'false') 591 .attr('aria-expanded', 'true') 592 .stop(true, true) 593 .slideDown({ 594 duration: 200, 595 step: function (_, fx) { 596 // Sync spacer height during animation 597 const currentHeight = $submenu[0]?.getBoundingClientRect?.().height || 0; 598 $spacer.height(currentHeight); 599 }, 600 complete: function () { 601 const finalHeight = $submenu[0]?.getBoundingClientRect?.().height || 0; 602 $spacer.height(finalHeight); 603 } 604 }); 605 606 $item 607 .attr('aria-expanded', 'true') 608 .find(menuLinkHasSubmenuClass + ', ' + menuSubmenuToggleClass) 609 .attr('aria-expanded', 'true'); 610 $activeItem = $item; 611 } 612 function closeInlineSubmenu($item) { 613 const $submenu = $item.children(menuSubmenuClass); 614 const $spacer = $item.children(menuSubmenuSpacerClass); 615 if (!$submenu.length || !$spacer.length) return; 616 617 $submenu 618 .attr('aria-hidden', 'true') 619 .attr('aria-expanded', 'false') 620 .stop(true, true) 621 .slideUp({ 622 duration: 200, 623 step: function (_, fx) { 624 const currentHeight = $submenu[0]?.getBoundingClientRect?.().height || 0; 625 $spacer.height(currentHeight); 626 }, 627 complete: function () { 628 $spacer.height(0); 629 } 630 }); 631 632 $item 633 .attr('aria-expanded', 'false') 634 .find(menuLinkHasSubmenuClass + ', ' + menuSubmenuToggleClass) 635 .attr('aria-expanded', 'false'); 636 637 if ($activeItem && $activeItem.is($item)) { 638 $activeItem = null; 639 } 640 } 641 function closeAllInlineSubmenus() { 642 $(menuClass + ' ' + menuSubmenuClass).each(function () { 643 $(this) 644 .attr('aria-hidden', 'true') 645 .attr('aria-expanded', 'false') 646 .stop(true, true) 647 .slideUp(200); 648 649 }); 650 $(menuLinkHasSubmenuClass).attr('aria-expanded', 'false'); 651 $submenuToggle.attr('aria-expanded', 'false'); 652 $activeItem = null; 653 } 654 // ---- END Mobile submenu functions ---- 655 656 // ---- Platform Event Binding ---- 657 /** 658 * Bind events based on interaction mode 659 * This function binds the appropriate events for desktop/hybrid interaction modes that support hover. 660 */ 661 function bindDesktopEvents() { 662 restoreDesktopMainMenu($menu); 663 $menuItemHasChildren.on('mouseenter', function () { 664 if ($('body').hasClass('kb-nav')) exitKeyboardMode(); 665 if (!shouldUseHover()) return; 666 clearTimeout(hideTimeout); 667 openSubmenu($(this)); 668 }); 669 670 $menuItemHasChildren.on('focusin', function () { 671 if (!shouldUseHover()) return; 672 if (!lastInputWasKeyboard) return; // key line: ignore click/right-click focus 673 clearTimeout(hideTimeout); 674 openSubmenu($(this)); 675 }); 676 677 $menuItemHasChildren.on('mouseleave', function () { 678 if (!shouldUseHover()) return; 679 hideTimeout = setTimeout(closeSubmenu, 300); 680 }); 681 682 $subMenuPlaceholder.on('mouseenter', function () { 683 if (!shouldUseHover()) return; 684 clearTimeout(hideTimeout); 685 }).on('mouseleave', function () { 686 if (!shouldUseHover()) return; 687 hideTimeout = setTimeout(closeSubmenu, 300); 688 }); 689 $submenuToggle.attr('tabindex', '-1'); 690 } 691 function resetMobileToggleMenu() { 692 const $button = $menuButton; 693 $button.attr('aria-expanded', 'false'); 694 const $iconWrap = $button.find('.status-icon').first(); 695 setIcon($iconWrap, menuIconClosed); 696 } 697 /** 698 * Bind events based on interaction mode 699 * This function binds the appropriate events for mobile interaction modes that support touch. 700 */ 701 function bindMobileEvents() { 702 if (themeSettings.debug) console.log('[bindMobileEvents] bound'); 703 hideDesktopMainMenu($menu); 704 $menuButton.on('click', function () { 705 const $button = $(this); 706 const $iconWrap = $button.find('.menu-toggle-icon'); 707 708 const expanded = $button.attr('aria-expanded') === 'true'; 709 $button.attr('aria-expanded', String(!expanded)); 710 711 const isOpen = $button.attr('aria-expanded') === 'true'; 712 setIcon($iconWrap, isOpen ? menuIconOpen : menuIconClosed); 713 714 let $originalMenu = $menu.first(); 715 716 if (!expanded) { 717 $('body').addClass(menuOpenCSSClass); 718 719 openMenuForMobile($originalMenu); 720 } else { 721 $('body').removeClass(menuOpenCSSClass); 722 723 closeMenuForMobile(); 724 } 725 726 }); 727 let tapTimeout = null; 728 // Add Click handler for mobile submenus, single tap to open, double-tap to follow link 729 // This is a workaround for mobile devices where hover doesn't work well 730 $(menuItemHasChildrenClass + ' > ' + menuLinkClass).on('click', function (e) { 731 const $link = $(this); 732 const $item = $link.closest(menuItemHasChildrenClass); 733 const $submenu = $item.children(menuSubmenuClass); 734 735 if ($submenu.length === 0) return;
735 // skip if no submenu 736 737 if (tapTimeout) { 738 // Double-tap: follow the link 739 clearTimeout(tapTimeout); 740 tapTimeout = null; 741 return true; // let browser handle navigation 742 } 743 744 e.preventDefault(); // prevent immediate nav 745 746 tapTimeout = setTimeout(() => { 747 tapTimeout = null; 748 749 const isOpen = $submenu.attr('aria-hidden') === 'false'; 750 if (isOpen) { 751 closeInlineSubmenu($item); 752 } else { 753 openInlineSubmenu($item); 754 } 755 }, 300); // 300ms = typical max interval for double tap 756 }); 757 // Open/Close submenu on outside click 758 $submenuToggle.on('click', function (e) { 759 e.preventDefault(); 760 const $item = $(this).closest(menuItemHasChildrenClass); 761 const isOpen = $(this).attr('aria-expanded') === 'true'; 762 763 if (isOpen) { 764 closeInlineSubmenu($item); 765 } else { 766 openInlineSubmenu($item); 767 } 768 }); 769 $submenuToggle.removeAttr('tabindex'); 770 } 771 772 let dzLastModeLogAt = 0; 773 774 function dzLogModeChange(payload) { 775 const now = Date.now(); 776 if (now - dzLastModeLogAt < 5000) return; // 5s throttle 777 dzLastModeLogAt = now; 778 779 // Sentry (if present) 780 if (window.Sentry && typeof window.Sentry.captureMessage === 'function') { 781 window.Sentry.captureMessage('Menu mode change', { 782 level: 'info', 783 tags: { feature: 'menu', mode: payload.mode, reason: payload.reason }, 784 extra: payload, 785 }); 786 } 787 788 // New Relic Browser (if present) 789 // Depending on the agent version, one of these may exist: 790 if (window.newrelic) { 791 if (typeof window.newrelic.addPageAction === 'function') { 792 window.newrelic.addPageAction('menu_mode_change', payload); 793 } else if (typeof window.newrelic.noticeError === 'function') { 794 // Use noticeError only for real errors; otherwise prefer addPageAction 795 // window.newrelic.noticeError(new Error('menu_mode_change'), payload); 796 } 797 } 798 } 799 function updateDebugBadge() { 800 if (!themeSettings.debug) return; 801 802 let badge = document.getElementById('dz-mode-debug'); 803 if (!badge) { 804 badge = document.createElement('div'); 805 badge.id = 'dz-mode-debug'; 806 badge.style.cssText = 'position:fixed;top:0;left:0;background:#eee;color:#000;padding:6px 8px;font:12px/1.2 monospace;z-index:99999;border-radius:0 0 4px 0;border:1px solid #ccc;'; 807 document.body.appendChild(badge); 808 } 809 810 badge.innerHTML = ` 811 <strong>Mode:</strong> ${dzDebugState.interactionMode}<br> 812 <span style="opacity:0.8;"><strong>Reason</strong>: ${dzDebugState.modeReason}</span><br> 813 <span style="opacity:0.8;"><strong>Wrap</strong>: ${dzDebugState.wrapResult} | BurgerVisible: ${dzDebugState.menuToggleVisible} | Measurable: ${dzDebugState.menuMeasurable}</span><br> 814 <span style="opacity:0.8;"><strong>Client</strong>: width=${dzDebugState.client.viewportWidth} | desktop=${dzDebugState.client.isDesktopViewport} | touch=${dzDebugState.client.isTouchDevice} | hover=${dzDebugState.client.hasHover} | finePointer=${dzDebugState.client.hasFinePointer}</span><br> 815 <span style="opacity:0.8;"><strong>Server (${dzDebugState.serverDetection.scope})</strong>: method=${dzDebugState.serverDetection.method} | device=${dzDebugState.serverDetection.isMobileDevice} | mobile=${dzDebugState.serverDetection.isMobile} | tablet=${dzDebugState.serverDetection.isTablet}</span><br> 816 <span style="opacity:0.8;"><strong>shouldUseHover</strong>: ${shouldUseHover()} | shouldUseClickToggle: ${shouldUseClickToggle()}</span><br> 817 <span style="opacity:0.8;"><strong>Last switch</strong>: ${dzDebugState.lastSwitchAt || 'n/a'}</span><br>
818 <span style="opacity:0.8;"><strong>burgerMode</strong>: ${isBurgerVisible()}</span><br> 819 <span style="opacity:0.8;"><strong>lastInputWasKeyboard</strong>: ${lastInputWasKeyboard}</span><br> 820 `; 821 822 } 823 function isKeyboardNav() { 824 return lastInputWasKeyboard; //$('body').hasClass('kb-nav'); 825 } 826 function exitKeyboardMode() { 827 lastInputWasKeyboard = false; 828 $('body').removeClass('kb-nav'); 829 } 830 /** 831 * Keyboard navigation support 832 */ 833 let lastInputWasKeyboard = false; 834 let didExitKbOnMouse = false; 835 836 $(document).on('mousedown pointerdown touchstart', function () { 837 // If we were in keyboard mode and an inline submenu is open, close it cleanly 838 if (lastInputWasKeyboard) { 839 closeSubmenu(); 840 } 841 842 lastInputWasKeyboard = false; 843 $('body').removeClass('kb-nav'); 844 updateDebugBadge(); 845 }); 846 $(document).on('mousemove', function () { 847 if (!$('body').hasClass('kb-nav')) return; 848 if (didExitKbOnMouse) return; 849 didExitKbOnMouse = true; 850 exitKeyboardMode(); 851 updateDebugBadge(); 852 }); 853 854 $(document).on('keydown', menuLinkClass + ', ' + menuSubmenuToggleClass, function (e) { 855 const $current = $(this).closest(menuItemClass); 856 const $allItems = $(menuItemClass + ':visible'); 857 const $submenuLinks = $current.find(menuSubmenuClass + ' ' + menuLinkClass); 858 859 // treat Tab + arrows as keyboard-nav intent 860 if (e.key === 'Tab' || e.key.startsWith('Arrow')) { 861 didExitKbOnMouse = false; 862 lastInputWasKeyboard = true; 863 updateDebugBadge(); 864 $('body').addClass('kb-nav'); 865 } 866 switch (e.key) { 867 case 'ArrowDown': 868 e.preventDefault(); 869 if ($submenuLinks.length) { 870 $submenuLinks.first().focus(); 871 } 872 break; 873 874 case 'ArrowRight': 875 e.preventDefault(); 876 const next = $allItems.eq($allItems.index($current) + 1).find(menuLinkClass); 877 if (next.length) next.focus(); 878 break; 879 880 case 'ArrowLeft': 881 e.preventDefault(); 882 const prev = $allItems.eq($allItems.index($current) - 1).find(menuLinkClass); 883 if (prev.length) prev.focus(); 884 break; 885 886 case 'Escape': 887 e.preventDefault(); 888 closeSubmenu(); 889 $(menuLinkClass + ':focus, ' + menuSubmenuToggleClass + ':focus').blur(); 890 break; 891 892 case 'Enter': 893 case ' ': 894 // Only treat as toggle when we're NOT in hover-driven mode 895 if (!shouldUseHover() && $current.hasClass(menuItemHasChildrenClass)) { 896 e.preventDefault(); 897 const expanded = $current.find(menuLinkClass).attr('aria-expanded') === 'true'; 898 if (!expanded) { 899 openSubmenu($current); 900 } else { 901 closeSubmenu(); 902 } 903 } 904 break; 905 } 906 }); 907 // Trap focus inside submenu if open (basic loop) 908 $(document).on('keydown', subMenuPlaceholderId, function (e) { 909 if (e.key !== 'Tab') return; 910 911 const focusable = $subMenuPlaceholder.find('a, button').filter(':visible'); 912 const first = focusable.first()[0]; 913 const last = focusable.last()[0]; 914 915 if (e.shiftKey && document.activeElement === first) { 916 e.preventDefault(); 917 last.focus(); 918 } else if (!e.shiftKey && document.activeElement === last) { 919 e.preventDefault(); 920 first.focus(); 921 } 922 }); 923 /** 924 * Handle interaction mode changes 925 * This is called on resize and orientation change 926 */ 927 function handleInteractionModeChange() { 928 const newMode = getInteractionMode(); 929 interactionMode = newMode; 930 dzDebugState.interactionMode = newMode; 931 // clean up first 932 closeSubmenu(); 933 closeAllInlineSubmenus(); 934 resetMobileToggleMenu(); 935 closeMenuForMobile(); 936 $('body').removeClass(menuOpenCSSClass); // Remove overlay lock 937 /* 938 // If switching *away* from mobile, restore original menu if needed 939 if (lastInteractionMode === 'mobile' && newMode !== 'mobile') { 940 restoreDesktopMainMenu($menu); 941 const $originalMenu = $(navClass + '[aria-hidden="true"]'); 942 // log here 943 console.log('Restoring original menu:', $originalMenu, $originalMenu.length); 944 if ($originalMenu.length) { 945 //closeMenuForMobile($menu); 946 } 947 948 } else if (lastInteractionMode !== 'mobile' && newMode === 'mobile') { 949 // If switching to mobile, hide original menu 950 hideDesktopMainMenu($menu); 951 } 952 */ 953 const burgerVisible = isBurgerVisible(); 954 const burgerChanged = (lastBurgerVisible === null) ? true : (burgerVisible !== lastBurgerVisible); 955 956 if (newMode !== lastInteractionMode || !$menu.data('eventsBound') || burgerChanged) { 957 lastBurgerVisible = burgerVisible; 958 959 if (themeSettings.debug) { 960 console.log('[Mode Switch] lastInteractionMode:', lastInteractionMode); 961 } 962 lastInteractionMode = newMode; 963 dzDebugState.lastSwitchAt = new Date().toLocaleTimeString(); 964 965 dzLogModeChange({ 966 mode: newMode, 967 reason: dzDebugState.modeReason, 968 burgerVisible, 969 burgerChanged, 970 wrap: dzDebugState.wrapResult, 971 measurable: dzDebugState.menuMeasurable, 972 viewportW: window.innerWidth, 973 viewportH: window.innerHeight 974 }); 975 976 $menu.data('eventsBound', true); 977 978 $('body') 979 .removeClass('mode-mobile mode-desktop mode-hybrid') 980 .addClass('mode-' + newMode); 981 982 // Unbind everything first 983 $menuItemHasChildren.off(); 984 $subMenuPlaceholder.off(); 985 $menuButton.off(); 986 $submenuToggle.off(); 987 $(menuItemHasChildrenClass + ' > ' + menuLinkClass).off(); 988 989 // Rebind based on new mode 990 if (isBurgerVisible()) { 991 bindMobileEvents(); 992 } else if (shouldUseHover()) { 993 bindDesktopEvents(); 994 }
994 else if (shouldUseClickToggle()) { 995 bindMobileEvents(); 996 } 997 998 if (themeSettings.debug) { 999 console.log('[Mode Switch] New interactionMode:', newMode); 1000 } 1001 } 1002 } 1003 1004 $(function () { 1005 bindKeyboardFocusClose(); 1006 getHeaderOffset(); 1007 syncCurrentMenuState(); 1008 lastInteractionMode = null; 1009 1010 afterLayout(() => { 1011 handleInteractionModeChange(); 1012 syncCurrentMenuState(); 1013 updateDebugBadge(); 1014 }); 1015 }); 1016 1017})(jQuery); 1018 1019 1020
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.