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.