PageSourceSearch

https://foojay.io/js/nav.js

js foojay.io collected 2026-09-24 15:21:33 UTC 10,311 bytes, 263 lines download raw bytes

1/* foojay.io — primary navigation
2   Dropdown panels open on hover (fine pointers) and on click/keyboard for
3   everyone else; on compact screens the same markup becomes an off-canvas drawer with
4   accordion panels. No dependencies. */
5(function () {
6  var header = document.querySelector('[data-nav]');
7  if (!header) return;
8
9  header.setAttribute('data-nav-ready', '');
10  var shell = document.getElementById('nav-shell');
11  var closeButton = header.querySelector('[data-nav-close]');
12  var backgroundState = [];
13  var toggle   = header.querySelector('[data-nav-toggle]');
14  var nav      = document.getElementById('primary-nav');
15  var backdrop = document.querySelector('[data-nav-backdrop]');
16  var items    = Array.prototype.slice.call(header.querySelectorAll('[data-nav-item]'));
17  var hoverOK  = window.matchMedia('(hover: hover) and (pointer: fine)');
18  var isMobile = function () { return window.matchMedia('(max-width: 64rem)').matches; };
19  var closeTimer;
20
21  function setItem(item, open) {
22    item.dataset.open = open ? 'true' : 'false';
23    var trigger = item.querySelector('[data-nav-trigger]');
24    if (trigger) trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
25    var panel = item.querySelector('.nav-panel');
26    if (panel) panel.inert = !open;
27  }
28
29  function closeAll(except) {
30    items.forEach(function (item) { if (item !== except) setItem(item, false); });
31  }
32
33  items.forEach(function (item) {
34    var trigger = item.querySelector('[data-nav-trigger]');
35    if (!trigger) return;
36    setItem(item, false);
37
38    trigger.addEventListener('click', function (e) {
39      e.preventDefault();
40      var open = item.dataset.open === 'true';
41      closeAll(item);
42      setItem(item, !open);
43    });
44
45    item.addEventListener('mouseenter', function () {
46      if (!hoverOK.matches || isMobile()) return;
47      if (header.contains(document.activeElement) && document.activeElement.matches(':focus-visible')) return;
48      clearTimeout(closeTimer);
49      closeAll(item);
50      setItem(item, true);
51    });
52
53    item.addEventListener('mouseleave', function () {
54      if (!hoverOK.matches || isMobile()) return;
55      if (item.contains(document.activeElement)) return;
56      closeTimer = setTimeout(function () {
57        if (!item.contains(document.activeElement)) setItem(item, false);
58      }, 140);
59    });
60
61    // Tabbing out of the panel closes it.
62    item.addEventListener('focusin', function () { clearTimeout(closeTimer); });
63    item.addEventListener('focusout', function (e) {
64      if (isMobile()) return;
65      if (!item.contains(e.relatedTarget)) setItem(item, false);
66    });
67  });
68
69  // The modal boundary includes its close control as well as the navigation.
70  function drawerStops() {
71    return Array.prototype.filter.call(
72      shell.querySelectorAll('a[href], button:not([disabled]), input, select, textarea'),
73      function (el) { return el.tabIndex >= 0 && !el.closest('[inert]') && el.getClientRects().length && getComputedStyle(el).visibility === 'visible'; }
74    );
75  }
76
77  function setBackgroundInert(open) {
78    if (open) {
79      var outside = Array.from(document.body.children).filter(function (el) {
80        return el !== header && el !== backdrop && !el.matches('script, style, link');
81      }).concat(Array.from(shell.parentElement.children).filter(function (el) { return el !== shell; }));
82      backgroundState = outside.map(function (el) { return [el, el.inert]; });
83      outside.forEach(function (el) { el.inert = true; });
84    } else {
85      backgroundState.forEach(function (entry) { entry[0].inert = entry[1]; });
86      backgroundState = [];
87    }
88  }
89
90  function setDrawer(open) {
91    if (open === (document.body.dataset.navOpen === 'true')) return;
92    document.body.dataset.navOpen = String(open);
93    toggle.setAttribute('aria-expanded', String(open));
94    // Closing animation must never leave the departing drawer in the Tab order.
95    shell.inert = !open && isMobile();
96    if (open) {
97      shell.setAttribute('role', 'dialog');
98      shell.setAttribute('aria-modal', 'true');
99      shell.setAttribute('aria-labelledby', 'nav-drawer-title');
100      backdrop.hidden = false;
101      setBackgroundInert(true);
102    } else {
103      shell.removeAttribute('role');
104      shell.removeAttribute('aria-modal');
105      shell.removeAttribute('aria-labelledby');
106      backdrop.hidden = true;
107      setBackgroundInert(false);
108      closeAll();
109    }
110    document.documentElement.style.overflow = open ? 'hidden' : '';
111    if (open) requestAnimationFrame(function () {
112      if (document.body.dataset.navOpen === 'true' && isMobile()) closeButton.focus();
113    });
114    else {
115      (isMobile() ? toggle : nav.querySelector('[data-nav-trigger], a[href]')).focus();
116    }
117  }
118
119  closeButton.addEventListener('click', function () { setDrawer(false); });
120  shell.addEventListener('keydown', function (e) {
121    if (e.key !== 'Tab' || document.body.dataset.navOpen !== 'true') return;
122    var stops = drawerStops(), first = stops[0], last = stops[stops.length - 1];
123    if (e.shiftKey && document.activeElement === first) { last.focus(); e.preventDefault(); }
124    else if (!e.shiftKey && document.activeElement === last) { first.focus(); e.preventDefault(); }
125  });
126
127  if (toggle) {
128    toggle.addEventListener('click', function () {
129      setDrawer(document.body.dataset.navOpen !== 'true');
130    });
131  }
132  if (backdrop) backdrop.addEventListener('click', function () { setDrawer(false); });
133
134  document.addEventListener('click', function (e) {
135    if (isMobile()) return;
136    if (!header.contains(e.target)) closeAll();
137  });
138
139  document.addEventListener('keydown', function (e) {
140    if (e.key !== 'Escape') return;
141    var focusedItem = items.find(function (i) {
142      return i.dataset.open === 'true' && i.contains(e.target);
143    });
144    if (focusedItem) {
145      setItem(focusedItem, false);
146      focusedItem.querySelector('[data-nav-trigger]').focus();
147      e.preventDefault();
148    } else if (document.body.dataset.navOpen === 'true') {
149      setDrawer(false);
150      e.preventDefault();
151    } else {
152      // A hovered panel may be open while focus is in the page. Closing it
153      // must not pull the reader back to the header.
154      closeAll();
155    }
156  });
157
158  window.addEventListener('resize', function () {
159    var hadFocus = shell.contains(document.activeElement);
160    if (!isMobile() && document.body.dataset.navOpen === 'true') setDrawer(false);
161    shell.inert = isMobile() && document.body.dataset.navOpen !== 'true';
162    if (shell.inert && hadFocus) toggle.focus();
163  });
164  shell.inert = isMobile();
165
166  /* Collapsible header search. The field is a magnifier until asked for, so
167     the bar does not carry an always-open 210px input; the drawer copy is left
168     alone because CSS keeps it open there. The button is a real submit, so a
169     no-JS visitor lands on the search page instead of poking a dead icon —
170     which is why "open" is a preventDefault on the FIRST click only. */
171  var searchForms = Array.prototype.slice.call(header.querySelectorAll('[data-search]'))
172    .filter(function (form) { return !form.closest('.header-actions--mobile'); });
173
174  searchForms.forEach(function (form) {
175    var trigger = form.querySelector('[data-search-toggle]');
176    var input   = form.querySelector('input[type="search"]');
177    if (!trigger || !input) return;
178
179    function setOpen(open) {
180      form.dataset.open = open ? 'true' : 'false';
181      trigger.setAttribute('aria-expanded', open ? 'true' : 'false');
182      input.inert = !open;
183      if (open) input.focus(); else input.blur();
184    }
185    setOpen(false);
186
187    trigger.addEventListener('click', function (e) {
188      var open = form.dataset.open === 'true';
189      // Open, or close again when there is nothing to search for. With a query
190      // typed, the click falls through and the form submits.
191      if (!open || !input.value.trim()) { e.preventDefault(); setOpen(!open); }
192    });
193
194    input.addEventListener('keydown', function (e) {
195      if (e.key === 'Escape') {
196        e.preventDefault();
197        e.stopPropagation();
198        setOpen(false);
199        trigger.focus();
200      }
201    });
202    form.addEventListener('focusout', function (e) {
203      if (!form.contains(e.relatedTarget)) setOpen(false);
204    });
205
206    document.addEventListener('click', function (e) {
207      if (form.dataset.open !== 'true' || form.contains(e.target)) return;
208      if (!input.value.trim()) setOpen(false);
209    });
210  });
211
212  // A wrapped header or enlarged text must not cover the drawer or anchor targets.
213  new ResizeObserver(function () {
214    document.documentElement.style.setProperty('--header-offset', header.getBoundingClientRect().height + 'px');
215  }).observe(header);
216
217  // Subtle elevation once the page scrolls under the sticky header.
218  var onScroll = function () {
219    header.dataset.scrolled = window.scrollY > 4 ? 'true' : 'false';
220  };
221  onScroll();
222  window.addEventListener('scroll', onScroll, { passive: true });
223})();
224
225/* foojay.io — colour scheme toggle
226   The initial value is set by the inline script in baseof.html (before paint);
227   this only wires the buttons and persists the choice. */
228(function () {
229  var root = document.documentElement;
230  var buttons = document.querySelectorAll('[data-theme-toggle]');
231  if (!buttons.length) return;
232
233  var media = window.matchMedia('(prefers-color-scheme: dark)');
234
235  function isDark() { return root.getAttribute('data-theme') === 'dark'; }
236
237  function sync() {
238    var dark = isDark();
239    buttons.forEach(function (b) {
240      b.setAttribute('aria-label', dark ? 'Switch to light theme' : 'Switch to dark theme');
241      b.setAttribute('title', dark ? 'Switch to light theme' : 'Switch to dark theme');
242    });
243  }
244
245  function set(theme, persist) {
246    root.setAttribute('data-theme', theme);
247    if (persist) { try { localStorage.setItem('foojay-theme', theme); } catch (e) {} }
248    sync();
249  }
250
251  buttons.forEach(function (b) {
252    b.addEventListener('click', function () { set(isDark() ? 'light' : 'dark', true); });
253  });
254
255  // Follow the OS while the visitor has never made an explicit choice.
256  media.addEventListener('change', function (e) {
257    var stored = null;
258    try { stored = localStorage.getItem('foojay-theme'); } catch (err) {}
259    if (!stored) set(e.matches ? 'dark' : 'light', false);
260  });
261
262  sync();
263})();

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.