PageSourceSearch

https://www.the50.com/filestore/js/navigation.js

js the50.com collected 2026-10-09 04:38:33 UTC 30,728 bytes, 892 lines download raw bytes

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.