1import {setAriaExpanded, setElementHidden} from '../core/aria.js'; 2import {getElementOuterWidth, parsePixelValue, parsePositiveInteger} from '../core/measure.js'; 3import {createRafScheduler} from '../core/rafScheduler.js'; 4 5const DESKTOP_NAV_CONFIG = { 6 triggerMode: 'hover', 7 openDelayMs: 40, 8 closeDelayMs: 140 9}; 10 11const getDropdownOpenState = (dropdown) => dropdown.classList.contains('is-open'); 12 13const setDropdownOpenState = (dropdown, isOpen) => { 14 dropdown.classList.toggle('is-open', isOpen); 15 16 const trigger = dropdown.querySelector('.mega-menu-dropdown__trigger'); 17 const panel = dropdown.querySelector('.mega-menu-dropdown__panel'); 18 setAriaExpanded(trigger, isOpen); 19 20 if (panel) { 21 setElementHidden(panel, !isOpen); 22 } 23}; 24 25const findOpenDropdown = (dropdowns) => dropdowns.find((dropdown) => getDropdownOpenState(dropdown)) || null; 26 27const closeDropdowns = (dropdowns, exceptDropdown = null) => { 28 dropdowns.forEach((dropdown) => { 29 if (dropdown !== exceptDropdown) { 30 setDropdownOpenState(dropdown, false); 31 } 32 }); 33}; 34 35const closeNavigationItem = (element) => { 36 if (element.matches('[data-mega-menu-dropdown]')) { 37 setDropdownOpenState(element, false); 38 } 39 40 if (element.matches('[data-header-submenu]')) { 41 element.classList.remove('is-open'); 42 setAriaExpanded(element.querySelector('.header-submenu__trigger'), false); 43 setElementHidden(element.querySelector('.header-submenu__panel'), true); 44 } 45}; 46 47const openDropdown = (state, dropdown, suppressPanelAnimation = false) => { 48 const currentOpenDropdown = findOpenDropdown(state.dropdowns); 49 if (currentOpenDropdown === dropdown) { 50 return; 51 } 52 53 if (suppressPanelAnimation && state.navRoot instanceof HTMLElement) { 54 state.navRoot.classList.add('desktop-nav--switching'); 55 } 56 57 document.querySelectorAll('[data-header-submenu].is-open').forEach((submenu) => { 58 if (submenu instanceof HTMLElement) { 59 closeNavigationItem(submenu); 60 } 61 }); 62 closeDropdowns(state.dropdowns, dropdown); 63 setDropdownOpenState(dropdown, true); 64 65 if (suppressPanelAnimation && state.navRoot instanceof HTMLElement) { 66 requestAnimationFrame(() => { 67 state.navRoot.classList.remove('desktop-nav--switching'); 68 }); 69 } 70}; 71 72const createDesktopNavState = () => { 73 const dropdowns = Array.from(document.querySelectorAll('[data-mega-menu-dropdown]')); 74 const secondaryNav = document.querySelector('.desktop-nav__categories'); 75 if (!(secondaryNav instanceof HTMLElement)) { 76 return null; 77 } 78 79 const overflowMenu = secondaryNav.querySelector('[data-desktop-nav-overflow]'); 80 const overflowItems = overflowMenu instanceof HTMLElement 81 ? Array.from(overflowMenu.querySelectorAll('[data-desktop-nav-overflow-item]')) 82 : []; 83 84 if (overflowMenu instanceof HTMLElement) { 85 const pinnedImageItem = Array.from(secondaryNav.children).find((item) => ( 86 item instanceof HTMLElement && item.classList.contains('desktop-category-image-link') 87 )); 88 secondaryNav.insertBefore(overflowMenu, pinnedImageItem || null); 89 } 90 91 return { 92 dropdowns, 93 navRoot: document.querySelector('.desktop-nav'), 94 secondaryNav, 95 overflowMenu, 96 overflowItems, 97 visibleItemLimit: parsePositiveInteger(secondaryNav.dataset.desktopNavVisibleCount), 98 config: DESKTOP_NAV_CONFIG, 99 openTimer: null, 100 closeTimer: null, 101 fitScheduler: null, 102 resizeObserver: null 103 }; 104}; 105 106const clearDesktopNavTimers = (state) => { 107 window.clearTimeout(state.openTimer); 108 window.clearTimeout(state.closeTimer); 109}; 110 111const bindDesktopDropdownHoverEvents = (state, dropdown) => { 112 dropdown.addEventListener('mouseenter', () => { 113 clearDesktopNavTimers(state); 114 115 const currentOpenDropdown = findOpenDropdown(state.dropdowns); 116 if (currentOpenDropdown && currentOpenDropdown !== dropdown) { 117 openDropdown(state, dropdown, true); 118 return; 119 } 120 121 state.openTimer = window.setTimeout(() => { 122 openDropdown(state, dropdown); 123 }, state.config.openDelayMs); 124 }); 125 126 dropdown.addEventListener('mouseleave', (event) => { 127 const nextTarget = event.relatedTarget; 128 if ( 129 nextTarget instanceof Node && 130 state.dropdowns.some((candidateDropdown) => candidateDropdown.contains(nextTarget)) 131 ) { 132 return; 133 } 134 135 clearDesktopNavTimers(state); 136 state.closeTimer = window.setTimeout(() => { 137 closeDropdowns(state.dropdowns); 138 }, state.config.closeDelayMs); 139 }); 140}; 141 142const bindDesktopDropdownClickEvents = (state, dropdown, trigger) => { 143 trigger.addEventListener('click', () => { 144 const shouldOpen = !getDropdownOpenState(dropdown); 145 closeDropdowns(state.dropdowns); 146 if (shouldOpen) { 147 openDropdown(state, dropdown); 148 } 149 }); 150}; 151 152const bindDesktopDropdownEvents = (state) => { 153 const isClickMode = state.config.triggerMode === 'click'; 154 155 state.dropdowns.forEach((dropdown) => { 156 const trigger = dropdown.querySelector('.mega-menu-dropdown__trigger'); 157 const closeButton = dropdown.querySelector('[data-dropdown-close]'); 158 if (!(trigger instanceof HTMLElement)) { 159 return; 160 } 161 162 if (isClickMode) { 163 bindDesktopDropdownClickEvents(state, dropdown, trigger); 164 } else { 165 bindDesktopDropdownHoverEvents(state, dropdown); 166 } 167 168 dropdown.addEventListener('focusin', () => { 169 openDropdown(state, dropdown); 170 }); 171 172 if (closeButton instanceof HTMLElement) { 173 closeButton.addEventListener('click', () => { 174 closeDropdowns(state.dropdowns); 175 }); 176 } 177 }); 178}; 179 180const bindDesktopGlobalEvents = (state) => { 181 const isClickMode = state.config.triggerMode === 'click'; 182 183 document.addEventListener('click', (event) => { 184 if (!isClickMode) { 185 return; 186 } 187 188 const target = event.target; 189 if (!(target instanceof Node)) { 190 return; 191 } 192 193 const clickedInsideDropdown = state.dropdowns.some((dropdown) => dropdown.contains(target)); 194 if (!clickedInsideDropdown) { 195 closeDropdowns(state.dropdowns); 196 } 197 }); 198 199 document.addEventListener('keydown', (event) => { 200 if (event.key === 'Escape') { 201 closeDropdowns(state.dropdowns); 202 } 203 }); 204}; 205 206const getSecondaryNavItems = (state) => { 207 if (!(state.secondaryNav instanceof HTMLElement)) { 208 return []; 209 } 210 211 return Array.from(state.secondaryNav.children).filter((item) => ( 212 item instanceof HTMLElement && item !== state.overflowMenu 213 )); 214}; 215 216const getSecondaryNavGap = (secondaryNav) => { 217 const styles = window.getComputedStyle(secondaryNav); 218 return parsePixelValue(styles.columnGap || styles.gap); 219}; 220 221const getOverflowCandidateIndexes = (items) => { 222 const logoIndexes = []; 223 const menuIndexes = []; 224
225 items.forEach((item, index) => { 226 if (item.classList.contains('desktop-category-image-link')) { 227 logoIndexes.push(index); 228 return; 229 } 230 231 menuIndexes.push(index); 232 }); 233 234 return [...menuIndexes.reverse(), ...logoIndexes.reverse()]; 235}; 236 237const getConfiguredHiddenIndexes = (items, visibleItemLimit) => { 238 const hiddenIndexes = new Set(); 239 if (visibleItemLimit <= 0) { 240 return hiddenIndexes; 241 } 242 243 let textItemIndex = 0; 244 items.forEach((item, index) => { 245 if (item.classList.contains('desktop-category-image-link')) { 246 return; 247 } 248 249 if (textItemIndex >= visibleItemLimit) { 250 hiddenIndexes.add(index); 251 } 252 textItemIndex++; 253 }); 254 255 return hiddenIndexes; 256}; 257 258const getItemsWidth = (itemWidths, gapWidth) => ( 259 itemWidths.reduce((total, itemWidth) => total + itemWidth, 0) 260 + gapWidth * Math.max(itemWidths.length - 1, 0) 261); 262 263const calculateOverflowItemIndexes = ( 264 items, 265 itemWidths, 266 overflowWidth, 267 gapWidth, 268 availableWidth, 269 initiallyHiddenIndexes = new Set() 270) => { 271 const hiddenIndexes = new Set(initiallyHiddenIndexes); 272 const widthWithoutOverflow = getItemsWidth(itemWidths, gapWidth); 273 if (hiddenIndexes.size === 0 && widthWithoutOverflow <= availableWidth) { 274 return hiddenIndexes; 275 } 276 277 const visibleItemWidths = itemWidths.filter((_, index) => !hiddenIndexes.has(index)); 278 let visibleWidth = getItemsWidth([...visibleItemWidths, overflowWidth], gapWidth); 279 for (const itemIndex of getOverflowCandidateIndexes(items)) { 280 if (visibleWidth <= availableWidth) { 281 break; 282 } 283 284 if (hiddenIndexes.has(itemIndex)) { 285 continue; 286 } 287 288 hiddenIndexes.add(itemIndex); 289 visibleWidth -= itemWidths[itemIndex] + gapWidth; 290 } 291 292 return hiddenIndexes; 293}; 294 295const findFocusableElement = (element) => { 296 if (element.matches('a[href], button:not([disabled])')) { 297 return element; 298 } 299 300 return element.querySelector('a[href], button:not([disabled])'); 301}; 302 303const restoreFocusFromOverflow = (state, items, hiddenIndexes) => { 304 const activeElement = document.activeElement; 305 if (!(activeElement instanceof HTMLElement) || !(state.overflowMenu instanceof HTMLElement)) { 306 return; 307 } 308 309 if (!state.overflowMenu.contains(activeElement)) { 310 return; 311 } 312 313 const activeOverflowItem = activeElement.closest('[data-desktop-nav-overflow-item]'); 314 const activeItemIndex = Number.parseInt(activeOverflowItem?.dataset.desktopNavOverflowItem || '', 10); 315 if (Number.isFinite(activeItemIndex) && hiddenIndexes.has(activeItemIndex)) { 316 return; 317 } 318 319 if (!Number.isFinite(activeItemIndex) && hiddenIndexes.size > 0) { 320 return; 321 } 322 323 const originalItem = Number.isFinite(activeItemIndex) 324 ? items[activeItemIndex] 325 : items[items.length - 1]; 326 const focusTarget = originalItem instanceof HTMLElement 327 ? findFocusableElement(originalItem) 328 : null; 329 if (focusTarget instanceof HTMLElement) { 330 focusTarget.focus(); 331 } 332}; 333 334const moveFocusFromHiddenOriginal = (state, items, hiddenIndexes) => { 335 const activeElement = document.activeElement; 336 if (!(activeElement instanceof HTMLElement) || !(state.overflowMenu instanceof HTMLElement)) { 337 return; 338 } 339 340 const focusedItemIndex = items.findIndex((item) => item.contains(activeElement)); 341 if (focusedItemIndex < 0 || !hiddenIndexes.has(focusedItemIndex)) { 342 return; 343 } 344 345 const overflowTrigger = state.overflowMenu.querySelector('.desktop-nav-more__trigger'); 346 if (overflowTrigger instanceof HTMLElement) { 347 overflowTrigger.focus(); 348 } 349}; 350 351const updateLastVisibleItem = (state) => { 352 Array.from(state.secondaryNav.children).forEach((item) => { 353 if (item instanceof HTMLElement) { 354 item.classList.remove('desktop-nav__item--last-visible'); 355 } 356 }); 357 358 const visibleItems = Array.from(state.secondaryNav.children).filter((item) => ( 359 item instanceof HTMLElement && !item.hidden 360 )); 361 const lastVisibleItem = visibleItems[visibleItems.length - 1]; 362 if (lastVisibleItem instanceof HTMLElement) { 363 lastVisibleItem.classList.add('desktop-nav__item--last-visible'); 364 } 365}; 366 367const applySecondaryNavVisibility = (state, items, hiddenIndexes) => { 368 restoreFocusFromOverflow(state, items, hiddenIndexes); 369 moveFocusFromHiddenOriginal(state, items, hiddenIndexes); 370
371 items.forEach((item, index) => { 372 const isHidden = hiddenIndexes.has(index); 373 item.hidden = isHidden; 374 if (isHidden) { 375 closeNavigationItem(item); 376 } 377 }); 378 379 state.overflowItems.forEach((item, index) => { 380 item.hidden = !hiddenIndexes.has(index); 381 }); 382 383 if (state.overflowMenu instanceof HTMLElement) { 384 const hasOverflow = hiddenIndexes.size > 0; 385 state.overflowMenu.hidden = !hasOverflow; 386 if (!hasOverflow) { 387 closeNavigationItem(state.overflowMenu); 388 } 389 } 390 391 updateLastVisibleItem(state); 392}; 393 394const fitSecondaryNavItems = (state) => { 395 if (!(state.secondaryNav instanceof HTMLElement)) { 396 return; 397 } 398 399 const items = getSecondaryNavItems(state); 400 if (!items.length) { 401 return; 402 } 403 404 const availableWidth = state.secondaryNav.getBoundingClientRect().width; 405 if (availableWidth <= 0 || !(state.overflowMenu instanceof HTMLElement)) { 406 return; 407 } 408 409 items.forEach((item) => { 410 item.hidden = false; 411 item.classList.remove('desktop-nav__item--last-visible'); 412 }); 413 state.overflowMenu.classList.remove('desktop-nav__item--last-visible'); 414 state.overflowMenu.hidden = false; 415 const gapWidth = getSecondaryNavGap(state.secondaryNav); 416 const configuredHiddenIndexes = getConfiguredHiddenIndexes(items, state.visibleItemLimit); 417 const lastItemWithoutOverflow = items[items.length - 1]; 418 lastItemWithoutOverflow.classList.add('desktop-nav__item--last-visible'); 419 const itemWidthsWithoutOverflow = items.map((item) => getElementOuterWidth(item)); 420 if ( 421 configuredHiddenIndexes.size === 0 422 && getItemsWidth(itemWidthsWithoutOverflow, gapWidth) <= availableWidth 423 ) { 424 applySecondaryNavVisibility(state, items, configuredHiddenIndexes); 425 return; 426 } 427 428 lastItemWithoutOverflow.classList.remove('desktop-nav__item--last-visible'); 429 const itemWidths = items.map((item) => getElementOuterWidth(item)); 430 const overflowWidth = getElementOuterWidth(state.overflowMenu); 431 const hiddenIndexes = calculateOverflowItemIndexes( 432 items, 433 itemWidths, 434 overflowWidth, 435 gapWidth, 436 availableWidth, 437 configuredHiddenIndexes 438 ); 439 440 applySecondaryNavVisibility(state, items, hiddenIndexes); 441}; 442 443const scheduleSecondaryNavFit = (state) => { 444 state.fitScheduler.schedule(); 445}; 446 447const bindDesktopResponsiveEvents = (state) => { 448 window.addEventListener('resize', () => { 449 scheduleSecondaryNavFit(state); 450 }); 451 452 window.addEventListener('load', () => { 453 scheduleSecondaryNavFit(state); 454 }); 455 456 if (typeof ResizeObserver === 'function') { 457 state.resizeObserver = new ResizeObserver(() => { 458 scheduleSecondaryNavFit(state); 459 }); 460 state.resizeObserver.observe(state.secondaryNav); 461 } 462 463 if (document.fonts?.ready) { 464 document.fonts.ready.then(() => { 465 scheduleSecondaryNavFit(state); 466 }); 467 } 468}; 469 470export const initDesktopDropdownNavigation = () => { 471 const state = createDesktopNavState(); 472 if (!state) { 473 return; 474 } 475 476 state.fitScheduler = createRafScheduler(() => fitSecondaryNavItems(state)); 477 closeDropdowns(state.dropdowns); 478 479 bindDesktopDropdownEvents(state); 480 bindDesktopGlobalEvents(state); 481 bindDesktopResponsiveEvents(state); 482 state.fitScheduler.flush(); 483 484 if (state.navRoot instanceof HTMLElement) { 485 state.navRoot.classList.add('desktop-nav--initialized'); 486 } 487};
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.