@hortonstudio/main 1.2.27 → 1.2.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,75 @@
1
+ function initModal() {
2
+ const config = {
3
+ transitionDuration: 0.3,
4
+ blurOpacity: 0.5
5
+ };
6
+
7
+ function openModal(element) {
8
+ document.body.classList.add('u-overflow-clip');
9
+
10
+ // Add blur to all other modals
11
+ document.querySelectorAll('[data-hs-modal]').forEach(modal => {
12
+ if (modal !== element) {
13
+ modal.style.display = 'block';
14
+ modal.style.opacity = config.blurOpacity;
15
+ modal.style.transition = `opacity ${config.transitionDuration}s ease`;
16
+ }
17
+ });
18
+ }
19
+
20
+ function closeModal(element) {
21
+ document.body.classList.remove('u-overflow-clip');
22
+
23
+ // Remove blur from all other modals
24
+ document.querySelectorAll('[data-hs-modal]').forEach(modal => {
25
+ if (modal !== element) {
26
+ modal.style.display = 'none';
27
+ modal.style.opacity = '0';
28
+ modal.style.transition = `opacity ${config.transitionDuration}s ease`;
29
+ }
30
+ });
31
+ }
32
+
33
+ function toggleModal(element) {
34
+ element.x = ((element.x || 0) + 1) % 2;
35
+
36
+ if (element.x) {
37
+ openModal(element);
38
+ } else {
39
+ closeModal(element);
40
+ }
41
+ }
42
+
43
+ // Initialize openclose functionality
44
+ document.querySelectorAll('[data-hs-modal="openclose"]').forEach(trigger => {
45
+ trigger.addEventListener('click', function() {
46
+ toggleModal(this);
47
+ });
48
+ });
49
+
50
+ // Initialize open functionality
51
+ document.querySelectorAll('[data-hs-modal="open"]').forEach(trigger => {
52
+ trigger.addEventListener('click', function() {
53
+ openModal(this);
54
+ });
55
+ });
56
+
57
+ // Initialize close functionality
58
+ document.querySelectorAll('[data-hs-modal="close"]').forEach(trigger => {
59
+ trigger.addEventListener('click', function() {
60
+ closeModal(this);
61
+ });
62
+ });
63
+
64
+ return { result: 'modal initialized' };
65
+ }
66
+
67
+ export function init() {
68
+ if (document.readyState === 'loading') {
69
+ document.addEventListener('DOMContentLoaded', initModal);
70
+ } else {
71
+ initModal();
72
+ }
73
+
74
+ return { result: 'modal initialized' };
75
+ }
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // Version:1.2.27
1
+ // Version:1.2.28
2
2
 
3
3
  const API_NAME = 'hsmain';
4
4
 
@@ -22,7 +22,8 @@ const initializeHsMain = async () => {
22
22
  };
23
23
 
24
24
  const autoInitModules = {
25
- 'smooth-scroll': true
25
+ 'smooth-scroll': true,
26
+ 'modal': true
26
27
  };
27
28
 
28
29
  const allDataAttributes = { ...animationModules, ...utilityModules };
@@ -47,7 +48,8 @@ const initializeHsMain = async () => {
47
48
  'data-hs-util-toc': () => import('./utils/toc.js'),
48
49
  'data-hs-util-progress': () => import('./utils/scroll-progress.js'),
49
50
  'data-hs-util-navbar': () => import('./utils/navbar.js'),
50
- 'smooth-scroll': () => import('./autoInit/smooth-scroll.js')
51
+ 'smooth-scroll': () => import('./autoInit/smooth-scroll.js'),
52
+ 'modal': () => import('./autoInit/modal.js')
51
53
  };
52
54
 
53
55
  let scripts = [...document.querySelectorAll(`script[type="module"][src="${import.meta.url}"]`)];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hortonstudio/main",
3
- "version": "1.2.27",
3
+ "version": "1.2.28",
4
4
  "description": "Animation and utility library for client websites",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/utils/navbar.js CHANGED
@@ -1,6 +1,72 @@
1
1
  export const init = () => {
2
+ // Mobile menu accessibility
3
+ const mobileMenuButton = document.querySelector('[data-hs-hero="nav-menu"]');
4
+ const mobileMenu = document.getElementById('mobile-navigation-menu');
5
+
6
+ if (mobileMenuButton && mobileMenu) {
7
+ let mobileMenuOpen = false;
8
+
9
+ // Initialize mobile menu button ARIA attributes
10
+ mobileMenuButton.setAttribute('aria-expanded', 'false');
11
+ mobileMenuButton.setAttribute('aria-controls', 'mobile-navigation-menu');
12
+ mobileMenuButton.setAttribute('aria-label', 'Open navigation menu');
13
+
14
+ // Function to toggle mobile menu
15
+ function toggleMobileMenu() {
16
+ mobileMenuOpen = !mobileMenuOpen;
17
+
18
+ // Update ARIA attributes
19
+ mobileMenuButton.setAttribute('aria-expanded', mobileMenuOpen);
20
+ mobileMenuButton.setAttribute('aria-label',
21
+ mobileMenuOpen ? 'Close navigation menu' : 'Open navigation menu'
22
+ );
23
+ }
24
+
25
+ mobileMenuButton.addEventListener('click', toggleMobileMenu);
26
+
27
+ mobileMenuButton.addEventListener('keydown', function(e) {
28
+ if (e.key === 'Enter' || e.key === ' ') {
29
+ e.preventDefault();
30
+ toggleMobileMenu();
31
+ }
32
+ });
33
+ }
34
+
35
+ // Setup dynamic dropdown system
36
+ setupDynamicDropdowns();
37
+
38
+ // Setup dynamic mobile menu button
39
+ setupMobileMenuButton();
40
+
41
+ // Dynamic mobile menu keyboard navigation
42
+ const menuContainer = document.querySelector('[data-hs-nav="menu"]');
43
+ if (menuContainer) {
44
+ const allInteractiveElements = menuContainer.querySelectorAll('button, a');
45
+
46
+ allInteractiveElements.forEach(element => {
47
+ element.addEventListener('keydown', function(e) {
48
+ if (e.key === 'Escape') {
49
+ // Close mobile menu and focus button
50
+ const mobileMenuButton = document.querySelector('[data-hs-hero="nav-menu"]');
51
+ if (mobileMenuButton) {
52
+ mobileMenuButton.click();
53
+ mobileMenuButton.focus();
54
+ }
55
+ }
56
+ });
57
+ });
58
+ }
59
+
60
+ // Setup mobile menu ARIA attributes
61
+ setupMobileMenuARIA();
62
+
63
+ return { result: 'navbar initialized' };
64
+ };
65
+
66
+ // New dynamic dropdown system for Webflow interactions
67
+ function setupDynamicDropdowns() {
2
68
  // Find all dropdown wrappers
3
- const dropdownWrappers = document.querySelectorAll('[data-hs-nav-dropdown="wrapper"]');
69
+ const dropdownWrappers = document.querySelectorAll('[data-hs-nav="dropdown"]');
4
70
 
5
71
  // Global array to track all dropdown instances
6
72
  const allDropdowns = [];
@@ -15,34 +81,55 @@ export const init = () => {
15
81
  };
16
82
 
17
83
  dropdownWrappers.forEach(wrapper => {
18
- const animationDuration = 0.3;
19
-
20
- // Find elements within this wrapper
21
- const toggle = wrapper.querySelector('a'); // the toggle link
22
- const list = wrapper.querySelector('[data-hs-nav-dropdown="list"]');
23
- const contain = list.querySelector('[data-hs-nav-dropdown="container"]');
24
- const arrow = toggle.querySelector('[data-hs-nav-dropdown="arrow"]');
25
- const text = toggle.querySelector('[data-hs-nav-dropdown="text"]'); // find the text element
26
-
27
- // Set initial states with GSAP
28
- gsap.set(contain, { yPercent: -110 });
29
- gsap.set(list, { display: 'none' });
30
- gsap.set(arrow, { rotation: 0, scale: 1, x: 0, color: '' });
31
- gsap.set(text, { scale: 1, color: '' }); // empty string = default color
32
-
33
- // Track if dropdown is open
84
+ // Auto-detect toggle and dropdown list
85
+ const toggle = wrapper.querySelector('a'); // First <a> element
86
+ if (!toggle) return; // Skip if no toggle found
87
+
88
+ // Find dropdown list: element with 2+ <a> tags that doesn't contain the toggle
89
+ const allElements = wrapper.querySelectorAll('*');
90
+ let dropdownList = null;
91
+
92
+ for (const element of allElements) {
93
+ const links = element.querySelectorAll('a');
94
+ if (links.length >= 2 && !element.contains(toggle)) {
95
+ dropdownList = element;
96
+ break;
97
+ }
98
+ }
99
+
100
+ if (!dropdownList) return; // Skip if no dropdown list found
101
+
102
+ // Generate unique IDs for ARIA attributes
103
+ const toggleText = toggle.textContent?.trim() || 'dropdown';
104
+ const sanitizedText = sanitizeForID(toggleText);
105
+ const toggleId = `navbar-dropdown-${sanitizedText}-toggle`;
106
+ const listId = `navbar-dropdown-${sanitizedText}-list`;
107
+
108
+ // Set up ARIA attributes for toggle
109
+ toggle.id = toggleId;
110
+ toggle.setAttribute('aria-haspopup', 'menu');
111
+ toggle.setAttribute('aria-expanded', 'false');
112
+ toggle.setAttribute('aria-controls', listId);
113
+
114
+ // Set up ARIA attributes for dropdown list
115
+ dropdownList.id = listId;
116
+ dropdownList.setAttribute('role', 'menu');
117
+ dropdownList.inert = true;
118
+
119
+ // Set up ARIA attributes for menu items
120
+ const menuItems = dropdownList.querySelectorAll('a');
121
+ menuItems.forEach(item => {
122
+ item.setAttribute('role', 'menuitem');
123
+ });
124
+
125
+ // Track dropdown state
34
126
  let isOpen = false;
35
- let currentTimeline = null;
127
+ let currentMenuItemIndex = -1;
36
128
 
37
- // Open animation
129
+ // Function to open dropdown (simulate click)
38
130
  function openDropdown() {
39
131
  if (isOpen) return;
40
132
 
41
- // Kill any existing timeline
42
- if (currentTimeline) {
43
- currentTimeline.kill();
44
- }
45
-
46
133
  // Close all other dropdowns first
47
134
  closeAllDropdowns(wrapper);
48
135
 
@@ -50,115 +137,203 @@ export const init = () => {
50
137
 
51
138
  // Update ARIA states
52
139
  toggle.setAttribute('aria-expanded', 'true');
53
- list.setAttribute('aria-hidden', 'false');
140
+ dropdownList.inert = false;
54
141
 
55
- // GSAP animation
56
- currentTimeline = gsap.timeline();
57
- currentTimeline.set(list, { display: 'flex' })
58
- .to(contain, {
59
- yPercent: 0,
60
- duration: animationDuration,
61
- ease: 'ease'
62
- }, 0)
63
- .to(arrow, {
64
- rotation: 90,
65
- scale: 1.2,
66
- x: 4,
67
- color: 'var(--swatch--brand)',
68
- duration: animationDuration,
69
- ease: 'ease'
70
- }, 0)
71
- .to(text, {
72
- scale: 1.1,
73
- color: 'var(--swatch--brand)',
74
- duration: animationDuration,
75
- ease: 'ease'
76
- }, 0);
142
+ // Simulate click on wrapper to trigger Webflow interactions
143
+ const clickEvent = new MouseEvent('click', {
144
+ bubbles: true,
145
+ cancelable: true,
146
+ view: window
147
+ });
148
+ wrapper.dispatchEvent(clickEvent);
77
149
  }
78
150
 
79
- // Close animation
151
+ // Function to close dropdown (simulate second click)
80
152
  function closeDropdown() {
81
153
  if (!isOpen) return;
82
154
 
83
- // Kill any existing timeline
84
- if (currentTimeline) {
85
- currentTimeline.kill();
86
- }
87
-
88
155
  // Check if focus should be restored to toggle
89
- const shouldRestoreFocus = list.contains(document.activeElement);
156
+ const shouldRestoreFocus = dropdownList.contains(document.activeElement);
90
157
 
91
158
  isOpen = false;
92
159
  currentMenuItemIndex = -1;
93
160
 
94
- // Update ARIA states
95
- toggle.setAttribute('aria-expanded', 'false');
96
- list.setAttribute('aria-hidden', 'true');
97
-
98
- // Temporarily remove role="menu" to help screen readers understand menu is closed
99
- const originalRole = list.getAttribute('role');
100
- list.removeAttribute('role');
101
-
102
- // GSAP animation
103
- currentTimeline = gsap.timeline();
104
- currentTimeline.to(contain, {
105
- yPercent: -110,
106
- duration: animationDuration,
107
- ease: 'ease'
108
- }, 0)
109
- .to(arrow, {
110
- rotation: 0,
111
- scale: 1,
112
- x: 0,
113
- color: '', // back to default color
114
- duration: animationDuration,
115
- ease: 'ease'
116
- }, 0)
117
- .to(text, {
118
- scale: 1,
119
- color: '', // back to default color
120
- duration: animationDuration,
121
- ease: 'ease'
122
- }, 0)
123
- .set(list, { display: 'none' })
124
- .call(() => {
125
- // Restore role after animation completes
126
- list.setAttribute('role', originalRole || 'menu');
127
- });
128
-
129
- // Restore focus to toggle only if focus was inside dropdown
161
+ // Move focus away BEFORE setting aria-hidden to avoid ARIA violation
130
162
  if (shouldRestoreFocus) {
131
- // Small delay to ensure screen reader announces the state change
132
- setTimeout(() => {
133
- toggle.focus();
134
- }, 50);
163
+ toggle.focus();
135
164
  }
165
+
166
+ // Update ARIA states after focus is moved
167
+ toggle.setAttribute('aria-expanded', 'false');
168
+ dropdownList.inert = true;
169
+
170
+ // Simulate second click on wrapper to trigger Webflow interactions
171
+ const clickEvent = new MouseEvent('click', {
172
+ bubbles: true,
173
+ cancelable: true,
174
+ view: window
175
+ });
176
+ wrapper.dispatchEvent(clickEvent);
136
177
  }
137
178
 
138
- // Get all menu items for navigation
139
- const menuItems = list.querySelectorAll('a, button, [role="menuitem"]');
140
- let currentMenuItemIndex = -1;
179
+ // Hover events (native) - trigger click events for Webflow
180
+ wrapper.addEventListener('mouseenter', () => {
181
+ if (!isOpen) {
182
+ // Trigger click to open
183
+ const clickEvent = new MouseEvent('click', {
184
+ bubbles: true,
185
+ cancelable: true,
186
+ view: window
187
+ });
188
+ wrapper.dispatchEvent(clickEvent);
189
+
190
+ // Update our state
191
+ closeAllDropdowns(wrapper);
192
+ isOpen = true;
193
+ toggle.setAttribute('aria-expanded', 'true');
194
+ dropdownList.inert = false;
195
+ }
196
+ });
141
197
 
142
- // Hover events
143
- toggle.addEventListener('mouseenter', openDropdown);
144
- wrapper.addEventListener('mouseleave', closeDropdown);
198
+ wrapper.addEventListener('mouseleave', () => {
199
+ if (isOpen) {
200
+ // Move focus away BEFORE setting aria-hidden to avoid ARIA violation
201
+ if (dropdownList.contains(document.activeElement)) {
202
+ toggle.focus();
203
+ }
204
+
205
+ // Trigger click to close
206
+ const clickEvent = new MouseEvent('click', {
207
+ bubbles: true,
208
+ cancelable: true,
209
+ view: window
210
+ });
211
+ wrapper.dispatchEvent(clickEvent);
212
+
213
+ // Update our state after focus is moved
214
+ isOpen = false;
215
+ toggle.setAttribute('aria-expanded', 'false');
216
+ dropdownList.inert = true;
217
+ currentMenuItemIndex = -1;
218
+ }
219
+ });
145
220
 
146
- // Arrow key navigation within dropdown
147
- list.addEventListener('keydown', function(e) {
221
+ // Keyboard navigation within dropdown - attach to document to catch all events
222
+ document.addEventListener('keydown', function(e) {
148
223
  if (!isOpen) return;
149
224
 
225
+ // Only handle if focus is on toggle or within wrapper
226
+ if (!wrapper.contains(document.activeElement)) return;
227
+
150
228
  if (e.key === 'ArrowDown') {
151
229
  e.preventDefault();
152
- currentMenuItemIndex = (currentMenuItemIndex + 1) % menuItems.length;
153
- menuItems[currentMenuItemIndex].focus();
230
+ // If currently on toggle, start at first item
231
+ if (document.activeElement === toggle) {
232
+ currentMenuItemIndex = 0;
233
+ menuItems[currentMenuItemIndex].focus();
234
+ } else {
235
+ // If at last item in dropdown, move to next sibling element
236
+ if (currentMenuItemIndex === menuItems.length - 1) {
237
+ // Find next focusable element after this dropdown
238
+ const nextElement = wrapper.nextElementSibling?.querySelector('a, button') ||
239
+ document.querySelector('.navbar_cartsearch_wrap a, .navbar_cartsearch_wrap button');
240
+ if (nextElement) {
241
+ closeDropdown();
242
+ nextElement.focus();
243
+ return;
244
+ }
245
+ }
246
+ currentMenuItemIndex = (currentMenuItemIndex + 1) % menuItems.length;
247
+ menuItems[currentMenuItemIndex].focus();
248
+ }
154
249
  } else if (e.key === 'ArrowUp') {
155
250
  e.preventDefault();
156
- currentMenuItemIndex = currentMenuItemIndex <= 0 ? menuItems.length - 1 : currentMenuItemIndex - 1;
157
- menuItems[currentMenuItemIndex].focus();
251
+ // If currently on toggle, start at last item
252
+ if (document.activeElement === toggle) {
253
+ currentMenuItemIndex = menuItems.length - 1;
254
+ menuItems[currentMenuItemIndex].focus();
255
+ } else {
256
+ // If at first item in dropdown, move to previous sibling element
257
+ if (currentMenuItemIndex === 0) {
258
+ // Find previous focusable element before this dropdown
259
+ const prevElement = wrapper.previousElementSibling?.querySelector('a, button');
260
+ if (prevElement) {
261
+ closeDropdown();
262
+ prevElement.focus();
263
+ return;
264
+ } else {
265
+ // If no previous sibling, go back to toggle
266
+ closeDropdown();
267
+ toggle.focus();
268
+ return;
269
+ }
270
+ }
271
+ currentMenuItemIndex = currentMenuItemIndex <= 0 ? menuItems.length - 1 : currentMenuItemIndex - 1;
272
+ menuItems[currentMenuItemIndex].focus();
273
+ }
274
+ } else if (e.key === 'ArrowLeft') {
275
+ e.preventDefault();
276
+ // Navigate to previous dropdown/element
277
+ const prevElement = wrapper.previousElementSibling?.querySelector('a, button');
278
+ if (prevElement) {
279
+ closeDropdown();
280
+ prevElement.focus();
281
+ } else {
282
+ closeDropdown();
283
+ toggle.focus();
284
+ }
285
+ } else if (e.key === 'ArrowRight') {
286
+ e.preventDefault();
287
+ // Navigate to next dropdown/element
288
+ const nextElement = wrapper.nextElementSibling?.querySelector('a, button') ||
289
+ document.querySelector('.navbar_cartsearch_wrap a, .navbar_cartsearch_wrap button');
290
+ if (nextElement) {
291
+ closeDropdown();
292
+ nextElement.focus();
293
+ }
294
+ } else if (e.key === 'Tab') {
295
+ // Handle Tab navigation within dropdown
296
+ if (e.shiftKey) {
297
+ // Shift+Tab going backwards
298
+ if (document.activeElement === menuItems[0]) {
299
+ e.preventDefault();
300
+ closeDropdown();
301
+ toggle.focus();
302
+ }
303
+ } else {
304
+ // Tab going forwards
305
+ if (document.activeElement === menuItems[menuItems.length - 1]) {
306
+ e.preventDefault();
307
+ // Find next element BEFORE closing dropdown to avoid focus issues
308
+ const nextElement = wrapper.nextElementSibling?.querySelector('a, button') ||
309
+ document.querySelector('.navbar_cartsearch_wrap a, .navbar_cartsearch_wrap button');
310
+
311
+ // Close dropdown first
312
+ closeDropdown();
313
+
314
+ // Then move focus to next element
315
+ if (nextElement) {
316
+ setTimeout(() => {
317
+ nextElement.focus();
318
+ }, 10);
319
+ }
320
+ }
321
+ }
158
322
  } else if (e.key === 'Escape') {
159
323
  e.preventDefault();
160
324
  closeDropdown();
161
325
  toggle.focus();
326
+ } else if (e.key === 'Home') {
327
+ e.preventDefault();
328
+ currentMenuItemIndex = 0;
329
+ menuItems[0].focus();
330
+ } else if (e.key === 'End') {
331
+ e.preventDefault();
332
+ currentMenuItemIndex = menuItems.length - 1;
333
+ menuItems[menuItems.length - 1].focus();
334
+ } else if (e.key === ' ') {
335
+ e.preventDefault();
336
+ // Just prevent space from scrolling page - Enter activates links
162
337
  }
163
338
  });
164
339
 
@@ -170,20 +345,34 @@ export const init = () => {
170
345
  // Focus first menu item after opening
171
346
  if (menuItems.length > 0) {
172
347
  currentMenuItemIndex = 0;
173
- setTimeout(() => menuItems[0].focus(), 50);
348
+ setTimeout(() => menuItems[0].focus(), 100);
174
349
  }
175
350
  } else if (e.key === ' ') {
176
351
  e.preventDefault();
177
- // Simple toggle: if closed open, if open close
178
352
  if (isOpen) {
179
353
  closeDropdown();
180
354
  } else {
181
355
  openDropdown();
356
+ // Focus first item when opening with spacebar
357
+ if (menuItems.length > 0) {
358
+ currentMenuItemIndex = 0;
359
+ setTimeout(() => menuItems[0].focus(), 100);
360
+ }
182
361
  }
183
- } else if (e.key === 'ArrowUp' || e.key === 'Escape') {
362
+ } else if (e.key === 'ArrowUp') {
363
+ e.preventDefault();
364
+ if (isOpen) {
365
+ // Focus last menu item
366
+ currentMenuItemIndex = menuItems.length - 1;
367
+ menuItems[currentMenuItemIndex].focus();
368
+ } else {
369
+ closeDropdown();
370
+ }
371
+ } else if (e.key === 'Escape') {
184
372
  e.preventDefault();
185
373
  closeDropdown();
186
374
  }
375
+ // Note: Enter key naturally follows the link - no preventDefault needed
187
376
  });
188
377
 
189
378
  // Close dropdown when clicking outside
@@ -197,7 +386,9 @@ export const init = () => {
197
386
  allDropdowns.push({
198
387
  wrapper,
199
388
  isOpen: () => isOpen,
200
- closeDropdown
389
+ closeDropdown,
390
+ toggle,
391
+ dropdownList
201
392
  });
202
393
  });
203
394
 
@@ -210,5 +401,302 @@ export const init = () => {
210
401
  });
211
402
  });
212
403
 
213
- return { result: 'navbar initialized' };
214
- };
404
+ // Global arrow key navigation for navbar
405
+ document.addEventListener('keydown', function(e) {
406
+ // Only handle arrow keys
407
+ if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
408
+
409
+ // Only handle if we're in the navbar
410
+ const navbar = document.querySelector('.navbar_component, [data-hs-nav]');
411
+ if (!navbar || !navbar.contains(document.activeElement)) return;
412
+
413
+ // Allow left/right navigation from dropdown items, but handle it specially
414
+ const activeDropdown = allDropdowns.find(d => d.wrapper.contains(document.activeElement) && d.isOpen());
415
+
416
+ e.preventDefault();
417
+
418
+ if (activeDropdown) {
419
+ // If we're in a dropdown, navigate to sibling dropdowns/elements
420
+ if (e.key === 'ArrowRight') {
421
+ // Find next focusable element after this dropdown
422
+ const nextElement = activeDropdown.wrapper.nextElementSibling?.querySelector('a, button') ||
423
+ document.querySelector('.navbar_cartsearch_wrap a, .navbar_cartsearch_wrap button');
424
+ if (nextElement) {
425
+ activeDropdown.closeDropdown();
426
+ nextElement.focus();
427
+ }
428
+ } else { // ArrowLeft
429
+ // Find previous focusable element before this dropdown
430
+ const prevElement = activeDropdown.wrapper.previousElementSibling?.querySelector('a, button');
431
+ if (prevElement) {
432
+ activeDropdown.closeDropdown();
433
+ prevElement.focus();
434
+ } else {
435
+ // If no previous sibling, go to toggle of this dropdown
436
+ activeDropdown.closeDropdown();
437
+ activeDropdown.toggle.focus();
438
+ }
439
+ }
440
+ } else {
441
+ // Normal navigation for non-dropdown elements
442
+ const focusableElements = navbar.querySelectorAll('a:not([inert] a), button:not([inert] button)');
443
+ const focusableArray = Array.from(focusableElements);
444
+
445
+ const currentIndex = focusableArray.indexOf(document.activeElement);
446
+ if (currentIndex === -1) return;
447
+
448
+ let nextIndex;
449
+ if (e.key === 'ArrowRight') {
450
+ nextIndex = (currentIndex + 1) % focusableArray.length;
451
+ } else { // ArrowLeft
452
+ nextIndex = currentIndex === 0 ? focusableArray.length - 1 : currentIndex - 1;
453
+ }
454
+
455
+ focusableArray[nextIndex].focus();
456
+ }
457
+ });
458
+ }
459
+
460
+ // Dynamic mobile menu button system for Webflow interactions
461
+ function setupMobileMenuButton() {
462
+ // Find mobile menu button and menu
463
+ const menuButton = document.querySelector('[data-hs-nav="menubtn"]');
464
+ const mobileMenu = document.querySelector('[data-hs-nav="menu"]');
465
+
466
+ if (!menuButton || !mobileMenu) return;
467
+
468
+ // Generate unique ID for the menu
469
+ const menuId = `mobile-menu-${Date.now()}`;
470
+
471
+ // Set up ARIA attributes for button
472
+ menuButton.setAttribute('aria-expanded', 'false');
473
+ menuButton.setAttribute('aria-controls', menuId);
474
+ menuButton.setAttribute('aria-label', 'Open navigation menu');
475
+
476
+ // Set up ARIA attributes for menu
477
+ mobileMenu.id = menuId;
478
+ mobileMenu.setAttribute('role', 'dialog');
479
+ mobileMenu.setAttribute('aria-modal', 'true');
480
+ mobileMenu.inert = true;
481
+
482
+ // Track menu state
483
+ let isMenuOpen = false;
484
+
485
+ // Function to open menu (simulate click)
486
+ function openMenu() {
487
+ if (isMenuOpen) return;
488
+
489
+ isMenuOpen = true;
490
+
491
+ // Update ARIA states
492
+ menuButton.setAttribute('aria-expanded', 'true');
493
+ menuButton.setAttribute('aria-label', 'Close navigation menu');
494
+ mobileMenu.inert = false;
495
+
496
+ // Simulate click on button to trigger Webflow interactions
497
+ const clickEvent = new MouseEvent('click', {
498
+ bubbles: true,
499
+ cancelable: true,
500
+ view: window
501
+ });
502
+ menuButton.dispatchEvent(clickEvent);
503
+ }
504
+
505
+ // Function to close menu (simulate second click)
506
+ function closeMenu() {
507
+ if (!isMenuOpen) return;
508
+
509
+ isMenuOpen = false;
510
+
511
+ // Move focus away BEFORE setting aria-hidden to avoid ARIA violation
512
+ if (mobileMenu.contains(document.activeElement)) {
513
+ menuButton.focus();
514
+ }
515
+
516
+ // Update ARIA states after focus is moved
517
+ menuButton.setAttribute('aria-expanded', 'false');
518
+ menuButton.setAttribute('aria-label', 'Open navigation menu');
519
+ mobileMenu.inert = true;
520
+
521
+ // Simulate second click on button to trigger Webflow interactions
522
+ const clickEvent = new MouseEvent('click', {
523
+ bubbles: true,
524
+ cancelable: true,
525
+ view: window
526
+ });
527
+ menuButton.dispatchEvent(clickEvent);
528
+ }
529
+
530
+ // Function to toggle menu state
531
+ function toggleMenu() {
532
+ if (isMenuOpen) {
533
+ closeMenu();
534
+ } else {
535
+ openMenu();
536
+ }
537
+ }
538
+
539
+ // Keyboard events for button
540
+ menuButton.addEventListener('keydown', function(e) {
541
+ if (e.key === 'Enter' || e.key === ' ') {
542
+ e.preventDefault();
543
+ toggleMenu();
544
+ }
545
+ });
546
+
547
+ // Listen for actual clicks to update state (for cases where Webflow triggers clicks)
548
+ menuButton.addEventListener('click', function(e) {
549
+ // Only update state if this wasn't triggered by our own events
550
+ if (!e.isTrusted) return;
551
+
552
+ // If closing and focus is inside menu, move it out first
553
+ if (isMenuOpen && mobileMenu.contains(document.activeElement)) {
554
+ menuButton.focus();
555
+ }
556
+
557
+ // Toggle our internal state
558
+ isMenuOpen = !isMenuOpen;
559
+
560
+ // Update ARIA attributes after focus is handled
561
+ menuButton.setAttribute('aria-expanded', isMenuOpen);
562
+ menuButton.setAttribute('aria-label',
563
+ isMenuOpen ? 'Close navigation menu' : 'Open navigation menu'
564
+ );
565
+ mobileMenu.inert = !isMenuOpen;
566
+ });
567
+ }
568
+
569
+ // Helper function to sanitize text for HTML IDs
570
+ function sanitizeForID(text) {
571
+ return text
572
+ .toLowerCase()
573
+ .replace(/[^a-z0-9\s]/g, '') // Remove special characters
574
+ .replace(/\s+/g, '-') // Replace spaces with hyphens
575
+ .replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
576
+ .substring(0, 50); // Limit length
577
+ }
578
+
579
+ // Setup mobile menu ARIA automation
580
+ function setupMobileMenuARIA() {
581
+ const menuContainer = document.querySelector('[data-hs-nav="menu"]');
582
+ if (!menuContainer) return;
583
+
584
+ // Find all buttons and links inside the menu container
585
+ const buttons = menuContainer.querySelectorAll('button');
586
+ const links = menuContainer.querySelectorAll('a');
587
+
588
+ // Process each button (dropdown toggles)
589
+ buttons.forEach(button => {
590
+ const buttonText = button.textContent?.trim();
591
+ if (!buttonText) return;
592
+
593
+ const sanitizedText = sanitizeForID(buttonText);
594
+ const buttonId = `navbar-mobile-${sanitizedText}-toggle`;
595
+ const listId = `navbar-mobile-${sanitizedText}-list`;
596
+
597
+ // Set button ID and ARIA attributes
598
+ button.id = buttonId;
599
+ button.setAttribute('aria-expanded', 'false');
600
+ button.setAttribute('aria-controls', listId);
601
+
602
+ // Find associated dropdown list (look for next sibling or nearby element with links)
603
+ let dropdownList = button.nextElementSibling;
604
+
605
+ // If next sibling doesn't contain links, look for parent's next sibling
606
+ if (!dropdownList || !dropdownList.querySelector('a')) {
607
+ dropdownList = button.parentElement?.nextElementSibling;
608
+ }
609
+
610
+ // If still not found, look for a nearby element that contains multiple links
611
+ if (!dropdownList || !dropdownList.querySelector('a')) {
612
+ const parent = button.closest('[data-hs-nav="menu"]');
613
+ const allListElements = parent?.querySelectorAll('div, ul, nav');
614
+ dropdownList = Array.from(allListElements || []).find(el =>
615
+ el.querySelectorAll('a').length > 1 &&
616
+ !el.contains(button)
617
+ );
618
+ }
619
+
620
+ // Set dropdown list ID and initial hidden state if found
621
+ if (dropdownList && dropdownList.querySelector('a')) {
622
+ dropdownList.id = listId;
623
+ dropdownList.inert = true;
624
+
625
+ // Add click listener to button to manage dropdown state
626
+ button.addEventListener('click', function() {
627
+ const isExpanded = button.getAttribute('aria-expanded') === 'true';
628
+ const newState = !isExpanded;
629
+
630
+ // Update button state
631
+ button.setAttribute('aria-expanded', newState);
632
+
633
+ // Update dropdown state
634
+ dropdownList.inert = !newState;
635
+ });
636
+ }
637
+ });
638
+
639
+ // Process each link (navigation items) - but don't override dropdown links
640
+ links.forEach(link => {
641
+ const linkText = link.textContent?.trim();
642
+ if (!linkText) return;
643
+
644
+ const sanitizedText = sanitizeForID(linkText);
645
+ const linkId = `navbar-mobile-${sanitizedText}-link`;
646
+
647
+ // Set link ID
648
+ link.id = linkId;
649
+
650
+ // Links inside inert containers are automatically non-focusable
651
+ });
652
+
653
+ // Add arrow key navigation for mobile menu
654
+ setupMobileMenuArrowNavigation(menuContainer);
655
+ }
656
+
657
+ // Arrow key navigation for mobile menu
658
+ function setupMobileMenuArrowNavigation(menuContainer) {
659
+ // Get all focusable elements in the mobile menu
660
+ function getFocusableElements() {
661
+ return menuContainer.querySelectorAll('button:not([tabindex="-1"]), a:not([tabindex="-1"])');
662
+ }
663
+
664
+ let currentFocusIndex = -1;
665
+
666
+ menuContainer.addEventListener('keydown', function(e) {
667
+ const focusableElements = Array.from(getFocusableElements());
668
+ if (focusableElements.length === 0) return;
669
+
670
+ // Find current focus index
671
+ const activeElement = document.activeElement;
672
+ currentFocusIndex = focusableElements.indexOf(activeElement);
673
+
674
+ if (e.key === 'ArrowDown') {
675
+ e.preventDefault();
676
+ currentFocusIndex = (currentFocusIndex + 1) % focusableElements.length;
677
+ focusableElements[currentFocusIndex].focus();
678
+ } else if (e.key === 'ArrowUp') {
679
+ e.preventDefault();
680
+ currentFocusIndex = currentFocusIndex <= 0 ? focusableElements.length - 1 : currentFocusIndex - 1;
681
+ focusableElements[currentFocusIndex].focus();
682
+ } else if (e.key === 'Home') {
683
+ e.preventDefault();
684
+ currentFocusIndex = 0;
685
+ focusableElements[0].focus();
686
+ } else if (e.key === 'End') {
687
+ e.preventDefault();
688
+ currentFocusIndex = focusableElements.length - 1;
689
+ focusableElements[focusableElements.length - 1].focus();
690
+ } else if (e.key === ' ' && activeElement.tagName === 'A') {
691
+ e.preventDefault();
692
+ // Just prevent space from scrolling page - Enter activates links
693
+ } else if (e.key === 'Escape') {
694
+ // Close mobile menu
695
+ const mobileMenuButton = document.querySelector('[data-hs-nav="menubtn"]');
696
+ if (mobileMenuButton) {
697
+ mobileMenuButton.click();
698
+ mobileMenuButton.focus();
699
+ }
700
+ }
701
+ });
702
+ }