PageSourceSearch

https://www.justborn.com/wp-content/themes/studioswift/assets/js/frontend/header.js

js justborn.com collected 2026-10-02 08:00:50 UTC 9,583 bytes, 368 lines download raw bytes

1/**
2 * Header Navigation Scripts
3 *
4 * Handles mobile menu, search modal, and dropdown navigation.
5 *
6 * @package StudioSwift
7 */
8
9(function() {
10	'use strict';
11
12	// Wait for DOM to be ready.
13	document.addEventListener('DOMContentLoaded', function() {
14		initMobileMenu();
15		initDropdowns();
16		initSearchModal();
17		initKeyboardNavigation();
18		initFocusTrap();
19		stickyHeader();
20	});
21
22	/**
23	 * Mobile menu toggle
24	 */
25	function initMobileMenu() {
26		const menuOpen = document.querySelector('.js-menu-open');
27		const menuClose = document.querySelector('.js-menu-close');
28		const header = document.querySelector('.js-header');
29		const nav = document.querySelector('.header__nav');
30		const body = document.body;
31
32		if (!menuOpen || !menuClose || !header || !nav) return;
33
34		// Open menu
35		menuOpen.addEventListener('click', function() {
36			header.classList.add('is-open');
37			body.classList.add('menu-open');
38			menuOpen.setAttribute('aria-expanded', 'true');
39			
40			// Set focus to close button
41			menuClose.focus();
42			
43			// Prevent body scroll
44			body.style.overflow = 'hidden';
45		});
46
47		// Close menu
48		function closeMenu() {
49			header.classList.remove('is-open');
50			body.classList.remove('menu-open');
51			menuOpen.setAttribute('aria-expanded', 'false');
52			body.style.overflow = '';
53			
54			// Return focus to open button
55			menuOpen.focus();
56		}
57
58		menuClose.addEventListener('click', closeMenu);
59
60		// Close on escape key
61		document.addEventListener('keydown', function(e) {
62			if (e.key === 'Escape' && header.classList.contains('is-open')) {
63				closeMenu();
64			}
65		});
66
67		// Close when clicking outside
68		document.addEventListener('click', function(e) {
69			if (header.classList.contains('is-open') && 
70				!nav.contains(e.target) && 
71				!menuOpen.contains(e.target)) {
72				closeMenu();
73			}
74		});
75	}
76
77	/**
78	 * Dropdown menu functionality
79	 */
80	function initDropdowns() {
81		const dropdownToggles = document.querySelectorAll('.dropdown-toggle');
82
83		dropdownToggles.forEach(function(toggle) {
84			toggle.addEventListener('click', function(e) {
85				e.preventDefault();
86				e.stopPropagation();
87
88				const parent = toggle.closest('.has-dropdown');
89				const submenu = parent.querySelector('.sub-menu');
90				const isExpanded = toggle.getAttribute('aria-expanded') === 'true';
91
92				// Close all other dropdowns
93				closeAllDropdowns();
94
95				if (!isExpanded) {
96					// Open this dropdown
97					toggle.setAttribute('aria-expanded', 'true');
98					submenu.setAttribute('aria-hidden', 'false');
99					parent.classList.add('is-open');
100				}
101			});
102		});
103
104		// Close dropdowns when clicking outside
105		document.addEventListener('click', function(e) {
106			if (!e.target.closest('.has-dropdown')) {
107				closeAllDropdowns();
108			}
109		});
110
111		// Close on escape key
112		document.addEventListener('keydown', function(e) {
113			if (e.key === 'Escape') {
114				closeAllDropdowns();
115			}
116		});
117	}
118
119	/**
120	 * Close all dropdown menus
121	 */
122	function closeAllDropdowns() {
123		const dropdowns = document.querySelectorAll('.has-dropdown');
124
125		dropdowns.forEach(function(dropdown) {
126			const toggle = dropdown.querySelector('.dropdown-toggle');
127			const submenu = dropdown.querySelector('.sub-menu');
128
129			if (toggle && submenu) {
130				toggle.setAttribute('aria-expanded', 'false');
131				submenu.setAttribute('aria-hidden', 'true');
132				dropdown.classList.remove('is-open');
133			}
134		});
135	}
136
137	/**
138	 * Search modal functionality
139	 */
140	function initSearchModal() {
141		const searchBtns = document.querySelectorAll('.js-search-btn');
142		const searchCloses = document.querySelectorAll('.js-search-close');
143		const searchModal = document.getElementById('site-search');
144		const searchOverlay = document.querySelector('.js-search-overlay');
145
146		if (!searchModal) return;
147
148		// Open search
149		searchBtns.forEach(function(btn) {
150			btn.addEventListener('click', function() {
151				searchModal.classList.add('is-open');
152				searchModal.previousElementSibling.classList.add('is-open');
153				searchModal.setAttribute('aria-hidden', 'false');
154				searchModal.removeAttribute('inert');
155				btn.setAttribute('aria-expanded', 'true');
156				document.body.style.overflow = 'hidden';
157
158				// Clear search input and results
159				const searchInput = searchModal.querySelector('input[type="search"]');
160				const searchResults = searchModal.querySelector('.site-search__results ul');
161				const searchFooter = searchModal.querySelector('.site-search__footer');
162				
163				if (searchInput) {
164					searchInput.value = '';
165				}
166				
167				if (searchResults) {
168					searchResults.innerHTML = '';
169				}
170				
171				if (searchFooter) {
172					searchFooter.style.display = 'none';
173				}
174
175				// Focus search input
176				if (searchInput) {
177					setTimeout(function() {
178						searchInput.focus();
179					}, 100);
180				}
181			});
182		});
183
184		// Close search
185		function closeSearch() {
186			searchModal.classList.remove('is-open');
187			searchOverlay.classList.remove('is-open');
188			searchModal.setAttribute('aria-hidden', 'true');
189			searchModal.setAttribute('inert', '');
190			searchBtns.forEach(function(btn) {
191				btn.setAttribute('aria-expanded', 'false');
192			});
193			document.body.style.overflow = '';
194
195			// Return focus to search button
196			if (searchBtns[0]) {
197				searchBtns[0].focus();
198			}
199		}
200
201		searchCloses.forEach(function(btn) {
202			btn.addEventListener('click', closeSearch);
203		});
204
205		// Close on escape key
206		document.addEventListener('keydown', function(e) {
207			if (e.key === 'Escape' && searchModal.classList.contains('is-open')) {
208				closeSearch();
209			}
210		});
211	}
212
213	/**
214	 * Keyboard navigation support
215	 */
216	function initKeyboardNavigation() {
217		const nav = document.querySelector('.header__nav');
218		if (!nav) return;
219
220		// Handle arrow keys in dropdown menus
221		nav.addEventListener('keydown', function(e) {
222			const activeElement = document.activeElement;
223			const parent = activeElement.closest('.has-dropdown');
224
225			if (!parent) return;
226
227			const submenu = parent.querySelector('.sub-menu');
228			const toggle = parent.querySelector('.dropdown-toggle');
229
230			// Arrow down: Open dropdown or move to first item
231			if (e.key === 'ArrowDown') {
232				e.preventDefault();
233
234				if (activeElement === toggle || activeElement.closest('.dropdown-link')) {
235					const isExpanded = toggle.getAttribute('aria-expanded') === 'true';
236
237					if (!isExpanded) {
238						toggle.click();
239					}
240
241					if (submenu) {
242						const firstLink = submenu.querySelector('a');
243						if (firstLink) firstLink.focus();
244					}
245				} else if (submenu.contains(activeElement)) {
246					focusNextMenuItem(submenu, activeElement);
247				}
248			}
249
250			// Arrow up: Move to previous item or close
251			if (e.key === 'ArrowUp') {
252				e.preventDefault();
253
254				if (submenu && submenu.contains(activeElement)) {
255					focusPreviousMenuItem(submenu, activeElement);
256				}
257			}
258		});
259	}
260
261	/**
262	 * Focus next menu item
263	 */
264	function focusNextMenuItem(submenu, currentElement) {
265		const items = Array.from(submenu.querySelectorAll('a'));
266		const currentIndex = items.indexOf(currentElement);
267
268		if (currentIndex < items.length - 1) {
269			items[currentIndex + 1].focus();
270		}
271	}
272
273	/**
274	 * Focus previous menu item
275	 */
276	function focusPreviousMenuItem(submenu, currentElement) {
277		const items = Array.from(submenu.querySelectorAll('a'));
278		const currentIndex = items.indexOf(currentElement);
279
280		if (currentIndex > 0) {
281			items[currentIndex - 1].focus();
282		} else {
283			// Focus back on toggle
284			const parent = submenu.closest('.has-dropdown');
285			const toggle = parent.querySelector('.dropdown-toggle');
286			if (toggle) toggle.focus();
287		}
288	}
289
290	/**
291	 * Focus trap for modals (search, mobile menu)
292	 */
293	function initFocusTrap() {
294		// This ensures focus stays within modal when tabbing
295		document.addEventListener('keydown', function(e) {
296			if (e.key !== 'Tab') return;
297
298			const searchModal = document.getElementById('site-search');
299			const header = document.querySelector('.js-header');
300			const nav = document.querySelector('.header__nav');
301
302			// Focus trap in search modal
303			if (searchModal && searchModal.classList.contains('is-open')) {
304				trapFocus(e, searchModal);
305			}
306
307			// Focus trap in mobile menu
308			if (header && header.classList.contains('is-open') && nav) {
309				trapFocus(e, nav);
310			}
311		});
312	}
313
314	/**
315	 * Trap focus within an element
316	 */
317	function trapFocus(e, element) {
318		const focusableElements = element.querySelectorAll(
319			'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
320		);
321
322		const firstFocusable = focusableElements[0];
323		const lastFocusable = focusableElements[focusableElements.length - 1];
324
325		if (e.shiftKey) {
326			// Shift + Tab
327			if (document.activeElement === firstFocusable) {
328				e.preventDefault();
329				lastFocusable.focus();
330			}
331		} else {
332			// Tab
333			if (document.activeElement === lastFocusable) {
334				e.preventDefault();
335				firstFocusable.focus();
336			}
337		}
338	}
339
340	function stickyHeader() {
341		const header = document.querySelector('.js-header');
342		let lastScrollTop = 0;
343		const headerHeight = header.offsetHeight;
344
345		window.addEventListener('scroll', () => {
346		const currentScroll = window.pageYOffset || document.documentElement.scrollTop;
347
348		// Only start hiding after scrolling past header height
349		if (currentScroll > headerHeight) {
350			if (currentScroll > lastScrollTop) {
351			// scrolling down
352			header.classList.add('is-hidden');
353			} else {
354			// scrolling up
355			header.classList.remove('is-hidden');
356			}
357		} else {
358			// always show header if within header height
359			header.classList.remove('hide-header');
360		}
361
362		lastScrollTop = currentScroll <= 0 ? 0 : currentScroll;
363		});
364
365	}
366
367})();
368

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.