PageSourceSearch

https://seattlemag.com/wp-content/themes/seattlemag/js/site.js?ver=1790799927

js seattlemag.com collected 2026-10-01 10:23:49 UTC 19,508 bytes, 578 lines download raw bytes

1/**
2 * Site-wide front-end behavior.
3 *
4 * - [site_nav class="site-nav__dropdown"] menus: progressive enhancement. Mouse
5 *   hover is handled entirely in CSS; this adds the click/tap and keyboard path
6 *   — a chevron toggle button per parent item, with aria-expanded, single-open,
7 *   Escape + outside-click to close. If this script never loads, hover still works.
8 */
9(function () {
10	'use strict';
11
12	var DROPDOWN = '.site-nav__dropdown';
13	var ITEM = '.site-nav__item';
14	var SUBLIST = '.site-nav__sublist';
15	var idSeq = 0;
16
17	function closeItem(item) {
18		if (!item || !item.classList.contains('is-open')) {
19			return;
20		}
21		item.classList.remove('is-open');
22		var toggle = item.querySelector(':scope > .site-nav__toggle');
23		if (toggle) {
24			toggle.setAttribute('aria-expanded', 'false');
25		}
26	}
27
28	function setupNav(nav) {
29		// Top-level items that actually have a sub-menu.
30		var items = nav.querySelectorAll(':scope > .site-nav__list > ' + ITEM);
31
32		Array.prototype.forEach.call(items, function (item) {
33			var sublist = item.querySelector(':scope > ' + SUBLIST);
34			if (!sublist) {
35				return;
36			}
37
38			item.classList.add('site-nav__item--has-dropdown');
39
40			if (!sublist.id) {
41				idSeq += 1;
42				sublist.id = 'site-nav__dropdown-' + idSeq;
43			}
44
45			// Label the toggle from the parent link/text.
46			var label = item.querySelector(':scope > .site-nav__link, :scope > .site-nav__text');
47			var labelText = label ? label.textContent.trim() : 'submenu';
48
49			var toggle = document.createElement('button');
50			toggle.type = 'button';
51			toggle.className = 'site-nav__toggle';
52			toggle.setAttribute('aria-expanded', 'false');
53			toggle.setAttribute('aria-controls', sublist.id);
54			toggle.setAttribute('aria-label', 'Toggle ' + labelText + ' submenu');
55
56			// Insert right after the label (so the label itself still navigates).
57			if (label && label.nextSibling) {
58				item.insertBefore(toggle, label.nextSibling);
59			} else if (label) {
60				item.insertBefore(toggle, sublist);
61			} else {
62				item.insertBefore(toggle, item.firstChild);
63			}
64
65			toggle.addEventListener('click', function () {
66				var isOpen = item.classList.contains('is-open');
67
68				// Single-open: close siblings in this nav first.
69				Array.prototype.forEach.call(items, function (other) {
70					if (other !== item) {
71						closeItem(other);
72					}
73				});
74
75				if (isOpen) {
76					closeItem(item);
77				} else {
78					item.classList.add('is-open');
79					toggle.setAttribute('aria-expanded', 'true');
80				}
81			});
82		});
83	}
84
85	function init() {
86		// This file only enhances the .site-nav__dropdown menus.
87		var navs = document.querySelectorAll(DROPDOWN);
88		if (!navs.length) {
89			return;
90		}
91
92		Array.prototype.forEach.call(navs, setupNav);
93
94		// Click outside any open dropdown closes it.
95		document.addEventListener('click', function (e) {
96			Array.prototype.forEach.call(
97				document.querySelectorAll(DROPDOWN + ' ' + ITEM + '.is-open'),
98				function (item) {
99					if (!item.contains(e.target)) {
100						closeItem(item);
101					}
102				}
103			);
104		});
105
106		// Escape closes the open dropdown and returns focus to its toggle.
107		// With more than one nav able to be open at once, close the one that
108		// contains focus (falling back to the first open item).
109		document.addEventListener('keydown', function (e) {
110			if (e.key !== 'Escape' && e.key !== 'Esc') {
111				return;
112			}
113			var openItems = document.querySelectorAll(DROPDOWN + ' ' + ITEM + '.is-open');
114			if (!openItems.length) {
115				return;
116			}
117
118			var active = document.activeElement;
119			var target = null;
120			Array.prototype.forEach.call(openItems, function (item) {
121				if (active && item.contains(active)) {
122					target = item;
123				}
124			});
125			if (!target) {
126				target = openItems[0];
127			}
128
129			var toggle = target.querySelector(':scope > .site-nav__toggle');
130			closeItem(target);
131			if (toggle) {
132				toggle.focus();
133			}
134		});
135	}
136
137	if (document.readyState === 'loading') {
138		document.addEventListener('DOMContentLoaded', init);
139	} else {
140		init();
141	}
142})();
143
144
145/**
146 * Keep --sem-utility-bar-h in sync with the dark utility bar's height.
147 *
148 * The header uses `display: contents` and its two bars are sticky individually
149 * (see _site-header.scss); the beige nav sticks just below the utility bar, so
150 * its sticky `top` is that bar's height. Runs site-wide (the header is global),
151 * independent of the flyout.
152 */
153(function () {
154	'use strict';
155
156	var toggle = document.querySelector('header [data-site-nav-toggle]');
157	if (!toggle) {
158		return;
159	}
160	var header = toggle.closest('header');
161	var bar = toggle;
162	while (bar.parentElement && bar.parentElement !== header) {
163		bar = bar.parentElement;
164	}
165
166	// Publish the element so the flyout's mobile drawer can measure this bar at
167	// open time, when it works out how far to scroll the page for the menu's top
168	// to land flush under it. Cheaper than a third copy of the walk above (the
169	// ride's navBottom() is the second).
170	window.semUtilityBar = bar;
171
172	function sync() {
173		document.documentElement.style.setProperty('--sem-utility-bar-h', bar.offsetHeight + 'px');
174	}
175
176	sync();
177	window.addEventListener('load', sync);
178	window.addEventListener('resize', sync);
179})();
180
181
182/**
183 * #site-nav-flyout — desktop scroll ride ("pinMenu", ported from the local
184 * flyout demo and adapted to the real markup).
185 *
186 * The panel lives in a Beaver Builder block (.fl-builder-content-100000117007)
187 * that is a child of .fl-page (position:relative), right after the sticky
188 * <header>. One scroller: the window. The panel has no internal scroll; it is
189 * moved between fixed-top / absolute / fixed-bottom so it rides with the page
190 * and clamps at BOTH ends — the top is reachable scrolling up, the bottom
191 * scrolling down. Smoothness comes from riding as position:absolute (the browser
192 * scrolls it natively — no per-frame JS reposition); position:fixed is used only
193 * at the two clamped ends.
194 *
195 * Everything is measured live from the header's bounding box, so it tracks the
196 * leaderboard ad scrolling away AND the header's shrink-on-scroll automatically.
197 * No-ops on pages without the flyout, and at <=768px, where the panel drops into
198 * the document flow and is scrolled by the page itself (see _site-header.scss
199 * and the open/close module below).
200 */
201(function () {
202	'use strict';
203
204	// `fl-builder-edit` is BB's own body class, set whenever the builder UI is
205	// open (FLBuilder::body_class()). Bail there: the ride writes inline
206	// position/top/left onto the wrapper, and inline styles outrank the
207	// editor-mode rules in _site-header.scss that put the panel back into normal
208	// flow so it can be laid out.
209	var wrap = document.querySelector('.fl-builder-content-100000117007');
210	var header = document.querySelector('.fl-page > header');
211	var flpage = document.querySelector('.fl-page');
212	if (!wrap || !header || !flpage || document.body.classList.contains('fl-builder-edit')) {
213		return;
214	}
215
216	// Width, not touch: the old `ontouchstart` test was true on touch-screen
217	// laptops at 1440px and was captured once at init, so it never noticed a
218	// resize. 768px is the Beaver Builder medium breakpoint and the theme's
219	// dominant one (see _post-grid.scss).
220	var mobile = window.matchMedia('(max-width: 768px)');
221	var mode = 'pinTop';   // 'pinTop' | 'pinBottom' | 'riding'
222	var anchorDoc = 0;     // document-y the panel's top is anchored to while riding
223	var lastY = window.pageYOffset;
224	var queued = false;
225
226	// .fl-page's top in document coords (the panel is absolutely positioned within
227	// it, so ride offsets are measured from here). Ad-independent: the ad sits
228	// above .fl-page, so it never enters the panel's own coordinate space.
229	function flpageTop() {
230		return flpage.getBoundingClientRect().top + window.pageYOffset;
231	}
232	function flpageLeft() {
233		return flpage.getBoundingClientRect().left;
234	}
235
236	// The panel's home offset below the top of .fl-page: the height of the bar that
237	// CONTAINS the flyout's hamburger (the dark utility bar). That bar sits at the
238	// top of .fl-page, so its own height IS both its resting bottom offset and (once
239	// it's pinned) its viewport bottom — constant, and unaffected by the ad above
240	// .fl-page or the header's `display: contents` (whose own box measures as zero).
241	function navBottom() {
242		var toggle = header.querySelector('[data-site-nav-toggle]');
243		if (toggle) {
244			var bar = toggle;
245			while (bar.parentElement && bar.parentElement !== header) {
246				bar = bar.parentElement;
247			}
248			return bar.offsetHeight;
249		}
250		return header.offsetHeight;
251	}
252
253	// Ride with the page: absolute, anchored at document-y `docPos` (measured
254	// within .fl-page). The browser scrolls it — no per-frame updates needed.
255	function toAbsolute(docPos) {
256		wrap.style.position = 'absolute';
257		wrap.style.top = (docPos - flpageTop()) + 'px';
258		wrap.style.bottom = 'auto';
259		wrap.style.left = '0';
260	}
261	// Pinned under the header (fixed is viewport-relative, so mirror .fl-page's x).
262	function toFixedTop(viewportTop) {
263		wrap.style.position = 'fixed';
264		wrap.style.top = viewportTop + 'px';
265		wrap.style.bottom = 'auto';
266		wrap.style.left = flpageLeft() + 'px';
267	}
268	// Pinned at the viewport bottom.
269	function toFixedBottom() {
270		wrap.style.position = 'fixed';
271		wrap.style.top = 'auto';
272		wrap.style.bottom = '0';
273		wrap.style.left = flpageLeft() + 'px';
274	}
275
276	function ride() {
277		queued = false;
278		if (mobile.matches) {
279			// The open/close module parks this wrapper itself while the drawer
280			// slides shut, so don't yank it out from under that.
281			if (window.semFlyoutParked) {
282				return;
283			}
284			// Otherwise CSS owns the mobile drawer. Inline styles outrank the
285			// media query, so hand placement back before returning — `resize`
286			// already re-runs ride(), so crossing the breakpoint cleans up after
287			// itself.
288			if (wrap.style.position) {
289				wrap.style.position = '';
290				wrap.style.top = '';
291				wrap.style.bottom = '';
292				wrap.style.left = '';
293				wrap.style.right = '';
294			}
295			return;
296		}
297
298		var pos = window.pageYOffset;
299		var vh = window.innerHeight;
300		var ph = wrap.offsetHeight;
301		var flTop = flpageTop();            // doc-y of .fl-page's top (just below the ad)
302		var headerH = navBottom();          // visible nav bottom (not the taller header box)
303		var home = flTop + headerH;         // doc-y of the panel's home = the nav's bottom edge
304		var pinned = pos >= flTop;          // has the sticky header pinned to the viewport top?
305
306		// NEAR TOP: the header is still riding up as the leaderboard ad scrolls off.
307		// Anchor the panel at home as position:absolute so the BROWSER scrolls it in
308		// lockstep with the header — no per-frame JS write, so no wobble.
309		if (!pinned) {
310			toAbsolute(home);
311			mode = 'pinTop';
312			lastY = pos;
313			return;
314		}
315
316		// HEADER PINNED: its bottom edge is now a constant (headerH).
317		var H = headerH;
318
319		// FITS under the header -> pin it there. The fixed `top` is a constant, so
320		// re-writing it each frame moves nothing (no wobble).
321		if (ph <= vh - H) {
322			toFixedTop(H);
323			mode = 'pinTop';
324			lastY = pos;
325			return;
326		}
327
328		// TALL. While riding, leave the absolute anchor untouched (native scroll);
329		// only switch state at the clamps.
330		if (mode === 'riding') {
331			var vtop = anchorDoc - pos;
332			if (vtop >= H) {
333				toFixedTop(H);
334				mode = 'pinTop';
335			} else if (vtop + ph <= vh) {
336				toFixedBottom();
337				mode = 'pinBottom';
338			}
339			lastY = pos;
340			return;
341		}
342
343		if (mode === 'pinBottom') {
344			if (pos < lastY) {                 // scrolling up -> release, ride down
345				anchorDoc = (vh - ph) + pos;
346				toAbsolute(anchorDoc);
347				mode = 'riding';
348			} else {
349				toFixedBottom();
350			}
351			lastY = pos;
352			return;
353		}
354
355		// pinTop
356		if (pos > lastY) {                     // scrolling down -> release, ride up
357			anchorDoc = H + pos;
358			toAbsolute(anchorDoc);
359			mode = 'riding';
360		} else {
361			toFixedTop(H);
362		}
363		lastY = pos;
364	}
365
366	function schedule() {
367		if (!queued) {
368			queued = true;
369			window.requestAnimationFrame(ride);
370		}
371	}
372
373	window.addEventListener('scroll', schedule, { passive: true });
374	window.addEventListener('resize', schedule, { passive: true });
375	ride();
376
377	// Let the open/close module below re-seed the ride the moment the panel is
378	// opened — otherwise a menu opened mid-page keeps whatever placement the
379	// last scroll left behind until the next scroll event. Passing `true` forces
380	// the next frame back to the pinned-top placement: with mode reset and no
381	// scroll delta, ride() falls straight through to toFixedTop(H), so the panel
382	// always opens showing its top. The reset is synchronous and the rAF fires
383	// after it, so a frame already queued by a scroll still sees the new state.
384	window.semFlyoutRide = function (reset) {
385		if (reset) {
386			mode = 'pinTop';
387			lastY = window.pageYOffset;
388		}
389		schedule();
390	};
391})();
392
393
394/**
395 * Flyout open / close.
396 *
397 * CSS owns every visual: the panel rests off-screen left (translateX(-100%) on
398 * #site-nav-flyout) and the toggle's three bars morph into an X off its own
399 * aria-expanded. This module only flips that state, so with JS off the menu
400 * simply stays closed rather than sitting open over the page.
401 *
402 * At <=768px it also drives the drawer. There the menu is pulled INTO the
403 * document flow and everything below it is hidden (CSS, off the html class this
404 * module sets), so the page is exactly as tall as the menu and the window's own
405 * scrolling does all the work: the ad scrolls away, the utility bar pins, the
406 * menu rides up under it, and the scroll ends where the menu ends. This module
407 * only has to flip the class, land the page at the menu's top, and put the
408 * scroll position back on close.
409 */
410(function () {
411	'use strict';
412
413	// Scoped to the header, like the two modules above: the toggle is the one in
414	// the dark utility bar, not any other element that happens to carry the hook.
415	// Bail inside the BB editor for the same reason as the ride above: this
416	// module would otherwise hold the panel closed (and the drawer would hide
417	// the page) while you are trying to edit it.
418	var toggle = document.querySelector('header [data-site-nav-toggle]');
419	var flyout = document.getElementById('site-nav-flyout');
420	if (!toggle || !flyout || document.body.classList.contains('fl-builder
420-edit')) {
421		return;
422	}
423
424	var mobile = window.matchMedia('(max-width: 768px)');
425	var root = document.documentElement;
426	var wrap = flyout.closest('.fl-builder-content-100000117007');
427	var savedY = 0;      // page scroll to restore when the drawer closes
428	var thawTimer = 0;
429
430	// The button's markup lives in a Beaver Builder HTML module (outside this
431	// repo), so don't assume its ARIA is intact — a missing aria-expanded would
432	// cost the CSS its X state and leave the control with no announced state.
433	// Only fill in what's absent; authored values win.
434	if (!toggle.hasAttribute('aria-controls')) {
435		toggle.setAttribute('aria-controls', flyout.id);
436	}
437	if (!toggle.hasAttribute('aria-expanded')) {
438		toggle.setAttribute('aria-expanded', 'false');
439	}
440	if (!toggle.hasAttribute('aria-label') && !toggle.textContent.trim()) {
441		toggle.setAttribute('aria-label', 'Open menu');
442	}
443
444	function isOpen() {
445		return toggle.getAttribute('aria-expanded') === 'true';
446	}
447
448	// Undo the parking below. Time-driven rather than `transitionend`-driven,
449	// because prefers-reduced-motion drops the slide entirely and then that event
450	// never fires at all.
451	function thaw() {
452		window.clearTimeout(thawTimer);
453		window.semFlyoutParked = false;
454		if (!wrap) {
455			return;
456		}
457		wrap.style.position = '';
458		wrap.style.top = '';
459		wrap.style.bottom = '';
460		wrap.style.left = '';
461		wrap.style.right = '';
462	}
463
464	function enterDrawer() {
465		// Clears any inline placement on the wrapper before anything is measured:
466		// a quick re-open can land mid slide-out, and crossing the breakpoint with
467		// the menu open arrives here while the desktop ride's `position: fixed` is
468		// still on the element (its own cleanup is a rAF behind this handler).
469		thaw();
470		savedY = window.pageYOffset;
471		root.classList.add('sem-flyout-drawer');
472
473		// Measured only now: the menu is in flow — and the page below it gone —
474		// solely because of the class above. This is where the page has to sit for
475		// the menu's first item to be flush under the pinned utility bar.
476		var barH = window.semUtilityBar ? window.semUtilityBar.offsetHeight : 0;
477		var flush = Math.max(0, flyout.getBoundingClientRect().top + window.pageYOffset - barH);
478
479		// Only ever scrolls back UP to that point, never down to it. Below it — the
480		// case that matters, opening the menu from deep in an article — the page
481		// rises until the menu's top meets the bar. Above it the leaderboard ad is
482		// still on screen with the menu's top already visible underneath, so going
483		// down to `flush` would whip the ad away, when the whole point is that the
484		// reader scrolls it away themselves.
485		if (window.pageYOffset > flush) {
486			window.scrollTo(0, flush);
487		}
488	}
489
490	function exitDrawer() {
491		if (!root.classList.contains('sem-flyout-drawer')) {
492			return;
493		}
494
495		// The menu spends the next 0.25s sliding off to the left, but the class
496		// comes off now — which would snap it back up under the utility bar
497		// mid-slide, since it's no longer riding the page. Park it fixed exactly
498		// where it currently sits and let the slide play out from there. Measure
499		// first: going fixed is itself a layout change.
500		if (wrap && mobile.matches) {
501			var top = wrap.getBoundingClientRect().top;
502			wrap.style.position = 'fixed';
503			wrap.style.top = top + 'px';
504			wrap.style.bottom = 'auto';
505			wrap.style.left = '0';
506			wrap.style.right = '0';
507			window.semFlyoutParked = true;
508			window.clearTimeout(thawTimer);
509			thawTimer = window.setTimeout(thaw, 400);
510		}
511
512		root.classList.remove('sem-flyout-drawer');
513		window.scrollTo(0, savedY);
514	}
515
516	function setOpen(open) {
517		flyout.classList.toggle('is-open', open);
518		toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
519		toggle.setAttribute('aria-label', open ? 'Close menu' : 'Open menu');
520
521		if (!open) {
522			exitDrawer();
523			return;
524		}
525
526		if (mobile.matches) {
527			enterDrawer();
528			return;
529		}
530
531		// Re-seed the ride so the panel shows its top no matter where the page
532		// was scrolled to.
533		if (typeof window.semFlyoutRide === 'function') {
534			window.semFlyoutRide(true);
535		}
536	}
537
538	// Crossing the breakpoint must not strand the drawer — it hides the page body
539	// while it's on — and an open menu has to be re-placed for the mode it just
540	// landed in. exitDrawer() no-ops when the drawer isn't on, so the closed and
541	// already-desktop cases cost nothing.
542	mobile.addEventListener('change', function () {
543		if (!isOpen()) {
544			exitDrawer();
545			return;
546		}
547		if (mobile.matches) {
548			enterDrawer();
549			return;
550		}
551		// Back to desktop: the ride sat out the whole mobile stretch, so its
552		// mode/lastY are stale — re-seed rather than let it resume from them.
553		exitDrawer();
554		if (typeof window.semFlyoutRide === 'function') {
555			window.semFlyoutRide(true);
556		}
557	});
558
559	toggle.addEventListener('click', function () {
560		setOpen(!isOpen());
561	});
562
563	// Escape closes and hands focus back to the toggle.
564	document.addEventListener('keydown', function (e) {
565		if ((e.key === 'Escape' || e.key === 'Esc') && isOpen()) {
566			setOpen(false);
567			toggle.focus();
568		}
569	});
570
571	// A click anywhere outside the panel closes it (the toggle handles itself).
572	document.addEventListener('click', function (e) {
573		if (!isOpen() || flyout.contains(e.target) || toggle.contains(e.target)) {
574			return;
575		}
576		setOpen(false);
577	});
578})();

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.