1//v2.1.2 2/** 3 * Navigation component 4 * 5 * Usage: 6 * import { initNavigation } from './navigation.js'; 7 * initNavigation(document.getElementById('nav-root'), { 8 * menuData: { ... }, // see nav-data.js for schema. 9 * isSignedIn: true, // optional â defaults to cookie detection. 10 * username: 'John', // optional â defaults to cookie detection. 11 * }); 12 */ 13 14// --- Constants --- 15 16const FOCUSABLE = 'a, button, input, [tabindex="0"]'; 17 18const CHEVRON_RIGHT = ` 19 <svg class="nav__chevron" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none"> 20 <path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> 21 </svg>`; 22 23const CHEVRON_LEFT = ` 24 <svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none"> 25 <path d="M15 6L9 12L15 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> 26 </svg>`; 27 28const CLOSE_ICON = ` 29 <svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none"> 30 <path d="M18 6L6 18M6 6L18 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> 31 </svg>`; 32 33const HAMBURGER_ICON = ` 34 <svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none"> 35 <path d="M4 5L20 5M4 12L20 12M4 19L20 19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> 36 </svg>`; 37 38const SEARCH_ICON = ` 39 <svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none"> 40 <path d="M21 21L16.65 16.65M19 11C19 15.4183 15.4183 19 11 19C6.58172 19 3 15.4183 3 11C3 6.58172 6.58172 3 11 3C15.4183 3 19 6.58172 19 11Z" 41 stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> 42 </svg>`; 43 44const SEARCH_DESKTOP_QUERY = '(min-width: 992px)'; 45const DEFAULT_SEARCH_PLACEHOLDER = 'Search..'; 46 47const SIGN_OUT_ICON = ` 48 <svg aria-hidden="true" width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"> 49 <path d="M16 17L21 12M21 12L16 7M21 12H9M9 21H5C4.46957 21 3.96086 20.7893 3.58579 20.4142C3.21071 20.0391 3 19.5304 3 19V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H9" 50 stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/> 51 </svg>`; 52 53 54export function initNavigation(container, options = {}) { 55 const data = options.menuData || {}; 56 const signedIn = options.isSignedIn ?? isUserSignedIn(); 57 const username = options.username ?? getUsername(); 58 const activeSection = Object.prototype.hasOwnProperty.call(options, 'activeSection') 59 ? options.activeSection 60 : getActiveSectionFromPath(data); 61 62 const state = { 63 mobileOpen: false, 64 levelStack: [], // [{ item, panelEl, triggerEl }] 65 trapCleanup: null, 66 activeMega: null, // id of the open mega panel, null when closed 67 megaCloseTimer: null, // keeps the mega open while the cursor moves from bar to panel 68 megaTrapCleanup: null, 69 }; 70 71 const SECTION_PROJECT_MAP = { discovery: 'discovery', stories: 'blog' }; 72 const dataProject = Object.prototype.hasOwnProperty.call(options, 'dataProject') 73 ? (options.dataProject ?? '') 74 : (SECTION_PROJECT_MAP[activeSection] ?? ''); 75 const searchPlaceholder = getSearchPlaceholder(); 76 const searchScriptUrl = options.searchScriptUrl ?? null; 77 const searchVisible = hasSearchForSection(data, activeSection); 78 79 container.innerHTML = buildHTML(data, signedIn, username, activeSection); 80 mountSearchBar(container, searchVisible, dataProject, searchPlaceholder); 81 bindEvents(container, state, data); 82 83 if (searchScriptUrl) { 84 const s = document.createElement('script'); 85 s.src = searchScriptUrl; 86 document.head.appendChild(s); 87 } 88} 89 90function isUserSignedIn() { 91 return document.cookie.split(';').some(c => c.trim().startsWith('token=')); 92} 93 94function getUsername() { 95 // Cookie name TBC with client â placeholder until their backend ships it. 96 const match = document.cookie.split(';').find(c => c.trim().startsWith('p-first-name=')); 97 return match ? decodeURIComponent(match.split('=')[1]) : null; 98} 99 100function getActiveSectionFromPath(data) { 101 if (typeof window === 'undefined') return null; 102 103 const [firstSegment] = window.location.pathname.split('/').filter(
103Boolean); 104 if (!firstSegment) return null; 105 106 const candidate = decodeURIComponent(firstSegment).toLowerCase(); 107 const topLevelItems = (data.groups || []).flatMap(g => g.items || []); 108 return topLevelItems.some(item => item.id === candidate) ? candidate : null; 109} 110 111function bindEvents(container, state, data) { 112 const nav = container.querySelector('.nav'); 113 function handleLogout(e) { 114 const signOut = e.target.closest('.nav__link--sign-out'); 115 if (!signOut) return; 116 e.preventDefault(); 117 window.dispatchEvent(new CustomEvent('logout', {})); 118 } 119 // Delegation on the nav root covers dynamically added panels without re-binding. 120 nav.addEventListener('click', (e) => { 121 const hamburger = e.target.closest('.nav__hamburger'); 122 const close = e.target.closest('.nav__close'); 123 const back = e.target.closest('.nav__back'); 124 const navItem = e.target.closest('[data-nav-item]'); 125 const signOut = e.target.closest('.nav__link--sign-out'); 126 127 if (signOut) { 128 handleLogout(e); 129 return; 130 } 131 132 // Close mobile overlay on any link click (handles in-page anchors with no page reload) 133 const clickedLink = e.target.closest('a'); 134 if (clickedLink && clickedLink.closest('.nav__mobile-overlay') && state.mobileOpen) { 135 closeMobileMenu(container, state); 136 } 137 138 if (hamburger) { 139 openMobileMenu(container, state); 140 return; 141 } 142 143 if (close) { 144 closeMobileMenu(container, state); 145 return; 146 } 147 148 if (back) { 149 goBack(container, state); 150 return; 151 } 152 153 if (navItem) { 154 const li = navItem.closest('.nav__item--has-children'); 155 if (!li) return; // plain link â let it navigate naturally. 156 const activePanel = nav.querySelector('.nav__panel--active'); 157 if (!activePanel || !activePanel.contains(navItem)) return; 158 159 e.preventDefault(); 160 const id = navItem.dataset.navItem; 161 const item = findItemById(data, id); 162 if (item) goDeeper(container, state, item, navItem); 163 } 164 }); 165 166 nav.addEventListener('keydown', (e) => { 167 if (e.key !== 'Escape') return; 168 if (state.activeMega) { closeMega(container, state); return; } 169 if (!state.mobileOpen) return; 170 if (state.levelStack.length > 0) goBack(container, state); 171 else closeMobileMenu(container, state); 172 }); 173 174 bindDesktopEvents(container, state, data); 175} 176 177function bindDesktopEvents(container, state, data) { 178 const nav = container.querySelector('.nav'); 179 const desktopBar = nav.querySelector('.nav__desktop-bar'); 180 const megaContainer = nav.querySelector('.nav__mega-container'); 181 182 if (!desktopBar) return; 183 184 desktopBar.addEventListener('mouseover', (e) => { 185 const li = e.target.closest('.nav__item'); 186 if (!li) return; 187 const link = li.querySelector('[data-nav-item]'); 188 if (!link) return; 189 190 clearTimeout(state.megaCloseTimer); 191 192 const id = link.dataset.navItem; 193 if (nav.querySelector(`[data-mega-for="${id}"]`)) { 194 openMega(container, state, data, id); 195 } else if (state.activeMega) { 196 scheduleMegaClose(container, state); 197 } 198 }); 199 200 desktopBar.addEventListener('mouseleave', () => scheduleMegaClose(container, state)); 201 202 nav.querySelector('.nav__overlay')?.addEventListener('click', () => closeMega(container, state)); 203 204 if (!megaContainer) return; 205 206 // Cancelling the close timer lets users move from the bar into the panel without it closing. 207 megaContainer.addEventListener('mouseenter', () => clearTimeout(state.megaCloseTimer)); 208 megaContainer.addEventListener('mouseleave', () => scheduleMegaClose(container, state)); 209 210 megaContainer.addEventListener('mouseover', (e) => { 211 // Left column â switching active L2 item rebuilds the whole right area. 212 const leftBtn = e.target.closest('.nav__mega-col--left [data-mega-item]'); 213 if (leftBtn) { 214 const megaPanel = leftBtn.closest('.nav__mega'); 215 if (!megaPanel) return; 216 217 const parentItem = findItemById(data, megaPanel.dataset.megaFor); 218 if (!parentItem) return; 219 220 const childItem = (parentItem.children || []).find(c => c.id === leftBtn.dataset.megaItem); 221 if (!childItem) return; 222 223 megaPanel.querySelectorAll('.nav__mega-col--left [data-mega-item]').forEach(b => { 224 const active = b === leftBtn; 225 b.classList.toggle('nav__link--active', active); 226 b.setAttribute('aria-expanded', active ? 'true' : 'false'); 227 }); 228 229 const contentEl = megaPanel.querySelector('.nav__mega-content'); 230 if (contentEl) contentEl.innerHTML = buildMegaContent(childItem); 231 return; 232 } 233 234 // Middle column â hovering an L3 item updates the group panel to its right. 235 const middleLink = e.target.closest('.nav__mega-col--middle [data-mega-item]'); 236 if (!middleLink) return; 237 238 const megaContent = middleLink.closest('.nav__mega-content'); 239 if (!megaContent) return; 240 241 megaContent.querySelectorAll('.nav__mega-col--middle [data-mega-item]').forEach(link => { 242 link.classList.toggle('nav__link--active', link === middleLink); 243 });
244 245 const rightContent = megaContent.querySelector('.nav__mega-right-content'); 246 if (!rightContent) return; 247 248 const item = findItemById(data, middleLink.dataset.megaItem); 249 rightContent.innerHTML = item?.groups?.length ? buildMegaContent(item) : ''; 250 }); 251} 252 253function openMega(container, state, data, id) { 254 if (state.activeMega === id) return; 255 256 closeMega(container, state); 257 258 const nav = container.querySelector('.nav'); 259 const mega = nav.querySelector(`[data-mega-for="${id}"]`); 260 if (!mega) return; 261 262 // Always open at the first L2 item â we don't preserve state between visits. 263 const parentItem = findItemById(data, id); 264 if (parentItem?.children?.length) { 265 const firstChild = parentItem.children[0]; 266 mega.querySelectorAll('.nav__mega-col--left [data-mega-item]').forEach((btn, i) => { 267 btn.classList.toggle('nav__link--active', i === 0); 268 btn.setAttribute('aria-expanded', i === 0 ? 'true' : 'false'); 269 }); 270 const contentEl = mega.querySelector('.nav__mega-content'); 271 if (contentEl) contentEl.innerHTML = buildMegaContent(firstChild); 272 } 273 274 mega.setAttribute('aria-hidden', 'false'); 275 state.activeMega = id; 276 277 const link = nav.querySelector(`.nav__desktop-bar [data-nav-item="${id}"]`); 278 link?.classList.add('nav__link--mega-open'); 279 link?.setAttribute('aria-expanded', 'true'); 280 281 nav.querySelector('.nav__mega-container')?.setAttribute('aria-hidden', 'false'); 282 nav.querySelector('.nav__overlay')?.classList.add('nav__overlay--visible'); 283 284 document.body.style.overflow = 'hidden'; 285 state.megaTrapCleanup = trapFocus(nav); 286} 287 288function closeMega(container, state) { 289 if (!state.activeMega) return; 290 291 const nav = container.querySelector('.nav'); 292 const mega = nav.querySelector(`[data-mega-for="${state.activeMega}"]`); 293 const link = nav.querySelector(`.nav__desktop-bar [data-nav-item="${state.activeMega}"]`); 294 295 mega?.setAttribute('aria-hidden', 'true'); 296 link?.classList.remove('nav__link--mega-open'); 297 link?.setAttribute('aria-expanded', 'false'); 298 299 nav.querySelector('.nav__mega-container')?.setAttribute('aria-hidden', 'true'); 300 nav.querySelector('.nav__overlay')?.classList.remove('nav__overlay--visible'); 301 state.activeMega = null; 302 303 document.body.style.overflow = ''; 304 state.megaTrapCleanup?.(); 305 state.megaTrapCleanup = null; 306} 307 308function scheduleMegaClose(container, state) { 309 clearTimeout(state.megaCloseTimer); 310 state.megaCloseTimer = setTimeout(() => closeMega(container, state), 150); 311} 312 313function openMobileMenu(container, state) { 314 const overlay = container.querySelector('.nav__mobile-overlay'); 315 const hamburger = container.querySelector('.nav__hamburger'); 316 317 overlay.setAttribute('aria-hidden', 'false'); 318 hamburger.setAttribute('aria-expanded', 'true'); 319 document.body.style.overflow = 'hidden'; 320 state.mobileOpen = true; 321 322 const firstFocusable = getFirstFocusable(overlay); 323 firstFocusable?.focus(); 324 325 state.trapCleanup = trapFocus(overlay); 326} 327 328function closeMobileMenu(container, state) { 329 const overlay = container.querySelector('.nav__mobile-overlay'); 330 const hamburger = container.querySelector('.nav__hamburger'); 331 const panels = container.querySelector('.nav__panels'); 332 333 overlay.setAttribute('aria-hidden', 'true'); 334 hamburger.setAttribute('aria-expanded', 'false'); 335 document.body.style.overflow = ''; 336 state.mobileOpen = false; 337 338 // Remove drill-down panels, leaving only the root L1 panel. 339 const dynamic = panels.querySelectorAll('[data-panel]:not([data-panel="0"])'); 340 dynamic.forEach(p => p.remove()); 341 342 const l1 = panels.querySelector('[data-panel="0"]'); 343 if (l1) { 344 l1.classList.add('nav__panel--active'); 345 } 346 347 state.levelStack = []; 348 state.trapCleanup?.(); 349 state.trapCleanup = null; 350 351 hamburger.focus(); 352} 353 354function goDeeper(container, state, item, triggerEl) { 355 const panels = container.querySelector('.nav__panels'); 356 const current = panels.querySelector('.nav__panel--active'); 357 const level = state.levelStack.length + 1; 358 359 const next = buildNextPanel(item, level); 360 panels.appendChild(next); 361 362 current.classList.remove('nav__panel--active'); 363 next.classList.add('nav__panel--active'); 364 365 state.levelStack.push({ item, panelEl: current, triggerEl }); 366 367 state.trapCleanup?.(); 368 state.trapCleanup = trapFocus(container.querySelector('.nav__mobile-overlay')); 369 370 const firstFocusable = getFirstFocusable(next); 371 firstFocusable?.focus(); 372 373 announce(container, item.label); 374} 375 376function goBack(container, state) { 377 if (state.levelStack.length === 0) return; 378 379 const panels = container.querySelector('.nav__panels');
380 const current = panels.querySelector('.nav__panel--active'); 381 const { panelEl, triggerEl } = state.levelStack.pop(); 382 383 current.classList.remove('nav__panel--active'); 384 panelEl.classList.add('nav__panel--active'); 385 386 current.remove(); 387 388 state.trapCleanup?.(); 389 state.trapCleanup = trapFocus(container.querySelector('.nav__mobile-overlay')); 390 391 triggerEl?.focus(); 392} 393 394function buildNextPanel(item, level) { 395 const panel = document.createElement('div'); 396 panel.className = 'nav__panel'; 397 panel.dataset.panel = level; 398 const panelTitleHTML = item.href 399 ? `<a href="${item.href}" class="nav__panel-title">${item.label}</a>` 400 : `<span class="nav__panel-title">${item.label}</span>`; 401 402 let bodyHTML = ''; 403 404 if (item.children?.length) { 405 bodyHTML = ` 406 <ul class="nav__list" role="menu"> 407 ${item.children.map(child => buildMobileListItem(child)).join('')} 408 </ul> 409 `; 410 } else if (item.groups?.length) { 411 bodyHTML = buildMobileGroups(item.groups); 412 } 413 414 panel.innerHTML = ` 415 <div class="nav__panel-header nav__panel-header--sub"> 416 <button class="nav__back" aria-label="Go back">${CHEVRON_LEFT}</button> 417 ${panelTitleHTML} 418 <button class="nav__close" aria-label="Close navigation menu">${CLOSE_ICON}</button> 419 </div> 420 <div class="nav__panel-body"> 421 ${bodyHTML} 422 </div> 423 `; 424 425 return panel; 426} 427 428function buildMobileGroups(groups) { 429 const sections = groups.map(group => ` 430 <div class="nav__group"> 431 <p class="nav__group-label">${group.label}</p> 432 <ul class="nav__group-items" role="menu"> 433 ${group.items.map(link => ` 434 <li role="none"> 435 ${link.href 436 ? `<a href="${link.href}" class="nav__group-link" role="menuitem">${link.label}</a>` 437 : `<span class="nav__group-link" role="menuitem">${link.label}</span>`} 438 </li> 439 `).join('')} 440 </ul> 441 </div> 442 `).join(''); 443 444 return `<div class="nav__group-list">${sections}</div>`; 445} 446 447function trapFocus(el) { 448 function getFocusables() { 449 return Array.from(el.querySelectorAll(FOCUSABLE)) 450 .filter(node => !node.disabled && node.offsetParent !== null); 451 } 452 453 function onKeydown(e) { 454 if (e.key !== 'Tab') return; 455 456 const focusables = getFocusables(); 457 if (!focusables.length) return; 458 459 const first = focusables[0]; 460 const last = focusables[focusables.length - 1]; 461 462 if (e.shiftKey) { 463 if (document.activeElement === first) { 464 e.preventDefault(); 465 last.focus(); 466 } 467 } else { 468 if (document.activeElement === last) { 469 e.preventDefault(); 470 first.focus(); 471 } 472 } 473 } 474 475 el.addEventListener('keydown', onKeydown); 476 return () => el.removeEventListener('keydown', onKeydown); 477} 478 479function getFirstFocusable(el) { 480 return el.querySelector(FOCUSABLE); 481} 482 483function announce(container, text) { 484 const region = container.querySelector('.nav__announcer'); 485 if (!region) return; 486 region.textContent = ''; 487 // Clear first so the same text re-triggers the screen reader if repeated. 488 setTimeout(() => { region.textContent = text; }, 50); 489} 490 491function findItemById(data, id) { 492 if (data.myAccount) { 493 const found = searchItem(data.myAccount, id); 494 if (found) return found; 495 } 496 for (const group of data.groups || []) { 497 for (const item of group.items || []) { 498 const found = searchItem(item, id); 499 if (found) return found; 500 } 501 } 502 return null; 503} 504 505function searchItem(item, id) { 506 if (item.id === id) return item; 507 for (const child of item.children || []) { 508 const found = searchItem(child, id); 509 if (found) return found; 510 } 511 return null; 512} 513 514function hasSearchForSection(data, activeSection) { 515 const allItems = (data.groups || []).flatMap(g => g.items || []); 516 return !!(allItems.find(i => i.id === activeSection)?.hasSearch); 517} 518 519function getSearchPlaceholder(pathname = window?.location?.pathname || '') { 520 const pathSegments = pathname 521 .split('/') 522 .filter(Boolean) 523 .map(segment => decodeURIComponent(segment).toLowerCase()); 524 525 if (pathSegments.includes('stories')) return 'Search by keyword or topic'; 526 if (pathSegments.includes('discovery')) return 'Search by location or venue'; 527 528 return DEFAULT_SEARCH_PLACEHOLDER; 529} 530 531function buildHTML(data, signedIn, username, activeSection) { 532 const allItems = (data.groups || []).flatMap(g => g.items || []); 533 534 return ` 535 <nav class="nav" aria-label="Main navigation"> 536 537 <!-- Mobile: collapsed header with hamburger --> 538 <div class="nav__mobile-header"> 539 <div class="nav__mobile-header-row"> 540 ${buildLogoLink(data)} 541 <button 542 class="nav__hamburger" 543 aria-label="Open navigation menu" 544 aria-expanded="false" 545 aria-controls="nav-mobile-panel" 546 > 547 ${HAMBURGER_ICON} 548 </button> 549 </div> 550 <div class="nav__search-slot" data-nav-search-slot="mobile"></div> 551 </div> 552 553 <!-- Mobile: full-screen overlay --> 554 <div class="nav__mobile-overlay menu" id="nav-mobile-panel" aria-hidden="true" role="dialog" aria-modal="true" aria-label="Navigation menu"> 555 <div class="nav__panels"> 556 557 <!-- Level 1 panel --> 558 <div class="nav__panel nav__panel--active" data-panel="0"> 559 <div class="nav__panel-header"> 560 ${buildLogoLink(data)} 561 <button class="nav__close" aria-label="Close navigation menu">${CLOSE_ICON}</button> 562 </div> 563 564 <div class="nav__panel-body"> 565 ${buildMobileAuthRow(signedIn, username)} 566 <div class="nav__divider" role="separator"></div> 567 ${buildMobileL1(data.groups || [])} 568 </div> 569 </div> 570 571 </div> 572 <div aria-live="polite" aria-atomic="true" class="nav__announcer visually-hidden"></div> 573 574 </div> 575 576 <!-- Desktop: horizontal bar --> 577 <div class="nav__desktop"> 578 <div class="nav__desktop-inner"> 579 <div class="nav__desktop-top"> 580 ${buildLogoLink(data)} 581 ${buildDesktopAuth()} 582 </div> 583 584 <div class="nav__desktop-bar"> 585 <ul class="nav__list menu" role="menubar"> 586 ${allItems.map(item => buildDesktopBarItem(item, activeSection)).join('')} 587 </ul> 588 <div class="nav__search-slot" data-nav-search-slot="desktop"></div> 589 </div> 590 </div> 591 </div> 592 593 <!-- Desktop: mega menu panels (one per top-level item) --> 594 <div class="nav__mega-container menu" aria-hidden="true"> 595 ${allItems.filter(item => item.children?.length).map(item => buildMegaMenu(item)).join('')} 596 </div> 597 598 <!-- Desktop: overlay backdrop --> 599 <div class="nav__overlay" aria-hidden="true"></div> 600 601 </nav> 602 `; 603} 604 605// Webpuzzle search.js targets #search-input and #show-results by id. 606// Keep one compatible search node and move it between mobile/desktop slots. 607function buildSearchBar(visible, dataProject = '', placeholder = DEFAULT_SEARCH_PLACEHOLDER) { 608 return ` 609 <div class="nav__search${visible ? '' : ' nav__search--hidden'}" role="search"> 610 <input 611 type="text" 612 id="search-input" 613 name="search-input" 614 class="nav__search-input prompt" 615 data-min-chars="2" 616 data-project="${dataProject}" 617 placeholder="${placeholder}" 618 aria-label="Search" 619 autocomplete="off" 620 /> 621 ${SEARCH_ICON} 622 </div> 623 `; 624} 625 626function mountSearchBar(container, visible, dataProject, placeholder) { 627 const mobileSlot = container.querySelector('[data-nav-search-slot="mobile"]'); 628 const desktopSlot = container.querySelector('[data-nav-search-slot="desktop"]'); 629 if (!mobileSlot || !desktopSlot) return; 630 631 const template = document.createElement('template'); 632 template.innerHTML = buildSearchBar(visible, dataProject, placeholder).trim(); 633 const searchEl = template.content.firstElementChild; 634 const desktopQuery = window.matchMedia(SEARCH_DESKTOP_QUERY); 635 636 function moveSearchBar() { 637 const target = desktopQuery.matches ? desktopSlot : mobileSlot; 638 if (searchEl.parentElement !== target) target.appendChild(searchEl); 639 } 640 641 moveSearchBar(); 642 if (desktopQuery.addEventListener) { 643 desktopQuery.addEventListener('change', moveSearchBar); 644 } else { 645 desktopQuery.addListener(moveSearchBar); 646 } 647} 648 649function buildLogoLink(data) { 650 const href = data.logo?.href || '/'; 651 const src = data.logo?.src || ''; 652 const alt = data.logo?.alt || '50 Best'; 653 return ` 654 <a href="${href}" class="nav__logo" aria-label="${alt} â Home"> 655 <img src="${src}" alt="${alt}" /> 656 </a> 657 `; 658} 659 660function buildMobileAuthRow(signedIn, username) { 661 if (signedIn) { 662 const greeting = username ? `Welcome, ${username}` : 'Welcome back'; 663 return ` 664 <ul class="nav__list" role="menu"> 665 <li class="nav__item nav__item--has-children nav__item--split" role="none">
666 <span class="nav__link nav__welcome" role="menuitem">${greeting}</span> 667 <button 668 class="nav__item-trigger" 669 role="menuitem" 670 aria-expanded="false" 671 data-nav-item="my-account" 672 >${CHEVRON_RIGHT}</button> 673 </li> 674 </ul> 675 676 <div class="pref-nav-btns" style="display:none"> 677 <div id="preferences-login-mobile"> 678 <div class="login"></div> 679 </div> 680 </div> 681 `; 682 } 683 684 return ` 685 <div class="pref-nav-btns"> 686 <div id="preferences-login-mobile"> 687 <div class="login"></div> 688 </div> 689 </div> 690 `; 691} 692 693function buildMobileL1(groups) { 694 return groups.map((group, i) => { 695 const items = group.items || []; 696 const divider = i > 0 ? '<div class="nav__divider" role="separator"></div>' : ''; 697 698 return ` 699 ${divider} 700 <ul class="nav__list" role="menu"> 701 ${items.map(item => buildMobileListItem(item)).join('')} 702 </ul> 703 `; 704 }).join(''); 705} 706 707 708function buildMobileListItem(item) { 709 const hasChildren = (item.children?.length > 0) || (item.groups?.length > 0); 710 const clickable = !!(item.href); 711 const separatorClass = item.separator ? ' nav__item--separator' : ''; 712 713 if (item.signOut) { 714 return ` 715 <li class="nav__item${separatorClass}" role="none"> 716 <a href="" class="nav__link nav__link--sign-out" role="menuitem"> 717 ${SIGN_OUT_ICON}${item.label} 718 </a> 719 </li> 720 `; 721 } 722 723 const labelEl = clickable 724 ? `<a href="${item.href}" class="nav__link" role="menuitem">${item.label}</a>` 725 : `<span class="nav__link" role="menuitem">${item.label}</span>`; 726 727 if (hasChildren) { 728 return ` 729 <li class="nav__item nav__item--has-children nav__item--split${separatorClass}" role="none"> 730 ${labelEl} 731 <button 732 class="nav__item-trigger" 733 role="menuitem" 734 aria-expanded="false" 735 data-nav-item="${item.id}" 736 >${CHEVRON_RIGHT}</button> 737 </li> 738 `; 739 } 740 741 if (clickable) { 742 return ` 743 <li class="nav__item${separatorClass}" role="none"> 744 <a href="${item.href}" class="nav__link" role="menuitem">${item.label}</a> 745 </li> 746 `; 747 } 748 749 return ` 750 <li class="nav__item${separatorClass}" role="none"> 751 <span class="nav__link" role="menuitem">${item.label}</span> 752 </li> 753 `; 754} 755 756function buildDesktopAuth() { 757 return ` 758 <div class="pref-nav-btns"> 759 <div id="preferences-login-desktop"> 760 <div class="login"></div> 761 </div> 762 </div> 763 `; 764} 765 766function buildDesktopBarItem(item, activeSection) { 767 const hasMega = item.children?.length > 0; 768 const clickable = !!(item.href); 769 const isActive = item.id === activeSection; 770 const liClass = `nav__item${hasMega ? ' nav__item--has-children' : ''}`; 771 const linkClass = `nav__link${isActive ? ' nav__link--active' : ''}`; 772 const megaAttrs = hasMega ? `aria-expanded="false" aria-haspopup="true"` : ''; 773 774 if (clickable) { 775 return ` 776 <li class="${liClass}" role="none"> 777 <a href="${item.href}" class="${linkClass}" role="menuitem" ${megaAttrs} data-nav-item="${item.id}"> 778 ${item.label} 779 </a> 780 </li> 781 `; 782 } 783 784 if (hasMega) { 785 return ` 786 <li class="${liClass}" role="none"> 787 <button class="${linkClass}" role="menuitem" ${megaAttrs} data-nav-item="${item.id}"> 788 ${item.label} 789 </button> 790 </li> 791 `; 792 } 793 794 return ` 795 <li class="${liClass}" role="none"> 796 <span class="nav__link" role="menuitem">${item.label}</span> 797 </li> 798 `; 799} 800 801function buildMegaMenu(item) { 802 return ` 803 <div class="nav__mega" id="nav-mega-${item.id}" aria-hidden="true" data-mega-for="${item.id}"> 804 <div class="nav__mega-inner"> 805 806 <!-- Left column: L2 items --> 807 <div class="nav__mega-col nav__mega-col--left"> 808 <ul class="nav__list"> 809 ${(item.children || []).map((child, i) => { 810 const activeClass = i === 0 ? ' nav__link--active' : ''; 811 const chevron = (child.children?.length || child.groups?.length) ? CHEVRON_RIGHT : ''; 812 const liClass = `nav__item${child.separator ? ' nav__item--separator' : ''}`; 813 const attrs = `class="nav__link${activeClass}" data-mega-item="${child.id}" aria-expanded="${i === 0 ? 'true' : 'false'}"`; 814 815 if (child.href) { 816 return ` 817 <li class="${liClass}" role="none"> 818 <a href="${child.href}" ${attrs}>${child.label}${chevron}</a> 819 </li>`; 820 } 821 return ` 822 <li class="${liClass}" role="none">
823 <span ${attrs}>${child.label}${chevron}</span> 824 </li>`; 825 }).join('')} 826 </ul> 827 </div> 828 829 <!-- Right area: content for the active left-column item --> 830 <div class="nav__mega-content" data-mega-content="${item.id}"> 831 ${buildMegaContent(item.children?.[0])} 832 </div> 833 834 </div> 835 </div> 836 `; 837} 838 839function buildMegaContent(item) { 840 if (!item) return ''; 841 842 // Item has sub-items: middle column of links + a right panel for the first child's groups. 843 if (item.children?.length) { 844 const firstChild = item.children[0]; 845 return ` 846 <div class="nav__mega-col nav__mega-col--middle"> 847 <ul class="nav__list"> 848 ${item.children.map((child, i) => { 849 const linkClass = `nav__link${i === 0 ? ' nav__link--active' : ''}`; 850 const label = `${child.label}${child.groups?.length ? CHEVRON_RIGHT : ''}`; 851 const itemAttrs = `class="${linkClass}" data-mega-item="${child.id}"`; 852 853 return ` 854 <li class="nav__item${child.separator ? ' nav__item--separator' : ''}" role="none"> 855 ${child.href 856 ? `<a href="${child.href}" ${itemAttrs}>${label}</a>` 857 : `<span ${itemAttrs}>${label}</span>`} 858 </li> 859 `; 860 }).join('')} 861 </ul> 862 </div> 863 <div class="nav__mega-right-content"> 864 ${buildMegaContent(firstChild)} 865 </div> 866 `; 867 } 868 869 // Terminal level: labelled groups of plain links (e.g. The Lists, The Experience). 870 if (item.groups?.length) { 871 return ` 872 <div class="nav__mega-groups"> 873 ${item.groups.map(group => ` 874 <div class="nav__mega-col nav__mega-col--group"> 875 <p class="nav__mega-heading">${group.label}</p> 876 <ul class="nav__list"> 877 ${group.items.map(link => ` 878 <li class="nav__item" role="none"> 879 ${link.href 880 ? `<a href="${link.href}" class="nav__link">${link.label}</a>` 881 : `<span class="nav__link">${link.label}</span>`} 882 </li> 883 `).join('')} 884 </ul> 885 </div> 886 `).join('')} 887 </div> 888 `; 889 } 890 891 return ''; 892}
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.