@hortonstudio/main 1.2.28 → 1.2.29

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.
@@ -36,7 +36,8 @@
36
36
  "Bash(tree:*)",
37
37
  "Bash(grep:*)",
38
38
  "Bash(git reset:*)",
39
- "Bash(git checkout:*)"
39
+ "Bash(git checkout:*)",
40
+ "Bash(rg:*)"
40
41
  ],
41
42
  "deny": []
42
43
  }
@@ -45,11 +45,11 @@ const config = {
45
45
  },
46
46
  appear: {
47
47
  y: 50,
48
- duration: 1.5,
48
+ duration: 1,
49
49
  ease: "power3.out"
50
50
  },
51
51
  navStagger: {
52
- duration: 1.5,
52
+ duration: 1,
53
53
  stagger: 0.1,
54
54
  ease: "power3.out"
55
55
  },
@@ -409,7 +409,30 @@ export async function init() {
409
409
 
410
410
  if (navElement) {
411
411
  heroTimeline.to(navElement,
412
- { opacity: 1, y: 0, duration: config.nav.duration, ease: config.nav.ease },
412
+ {
413
+ opacity: 1,
414
+ y: 0,
415
+ duration: config.nav.duration,
416
+ ease: config.nav.ease,
417
+ onComplete: () => {
418
+ // If no advanced nav, restore interactions here
419
+ if (!hasAdvancedNav) {
420
+ const allFocusableElements = document.querySelectorAll('[data-original-tabindex]');
421
+ allFocusableElements.forEach(el => {
422
+ el.style.pointerEvents = '';
423
+ const originalTabindex = el.getAttribute('data-original-tabindex');
424
+ if (originalTabindex === '0') {
425
+ el.removeAttribute('tabindex');
426
+ } else {
427
+ el.setAttribute('tabindex', originalTabindex);
428
+ }
429
+ el.removeAttribute('data-original-tabindex');
430
+ });
431
+ // Restore nav pointer events
432
+ navElement.style.pointerEvents = '';
433
+ }
434
+ }
435
+ },
413
436
  timing.nav
414
437
  );
415
438
  }
@@ -453,7 +476,29 @@ export async function init() {
453
476
 
454
477
  if (hasAdvancedNav && navButton.length > 0) {
455
478
  heroTimeline.to(navButton,
456
- { opacity: 1, duration: config.nav.duration, ease: config.nav.ease },
479
+ {
480
+ opacity: 1,
481
+ duration: config.nav.duration,
482
+ ease: config.nav.ease,
483
+ onComplete: () => {
484
+ // Restore page-wide tabbing and interactions after navbar animations complete
485
+ const allFocusableElements = document.querySelectorAll('[data-original-tabindex]');
486
+ allFocusableElements.forEach(el => {
487
+ el.style.pointerEvents = '';
488
+ const originalTabindex = el.getAttribute('data-original-tabindex');
489
+ if (originalTabindex === '0') {
490
+ el.removeAttribute('tabindex');
491
+ } else {
492
+ el.setAttribute('tabindex', originalTabindex);
493
+ }
494
+ el.removeAttribute('data-original-tabindex');
495
+ });
496
+ // Restore nav pointer events
497
+ if (navElement) {
498
+ navElement.style.pointerEvents = '';
499
+ }
500
+ }
501
+ },
457
502
  timing.navButton
458
503
  );
459
504
  }
@@ -539,43 +584,49 @@ export async function init() {
539
584
  duration: config.appear.duration,
540
585
  ease: config.appear.ease,
541
586
  onComplete: () => {
542
- // Restore page-wide tabbing and interactions after hero animation completes
543
- const allFocusableElements = document.querySelectorAll('[data-original-tabindex]');
544
- allFocusableElements.forEach(el => {
545
- el.style.pointerEvents = '';
546
- const originalTabindex = el.getAttribute('data-original-tabindex');
547
- if (originalTabindex === '0') {
548
- el.removeAttribute('tabindex');
549
- } else {
550
- el.setAttribute('tabindex', originalTabindex);
587
+ // Check if interactions haven't been restored yet
588
+ const stillDisabled = document.querySelector('[data-original-tabindex]');
589
+ if (stillDisabled) {
590
+ const allFocusableElements = document.querySelectorAll('[data-original-tabindex]');
591
+ allFocusableElements.forEach(el => {
592
+ el.style.pointerEvents = '';
593
+ const originalTabindex = el.getAttribute('data-original-tabindex');
594
+ if (originalTabindex === '0') {
595
+ el.removeAttribute('tabindex');
596
+ } else {
597
+ el.setAttribute('tabindex', originalTabindex);
598
+ }
599
+ el.removeAttribute('data-original-tabindex');
600
+ });
601
+ // Restore nav pointer events
602
+ if (navElement) {
603
+ navElement.style.pointerEvents = '';
551
604
  }
552
- el.removeAttribute('data-original-tabindex');
553
- });
554
- // Restore nav pointer events
555
- if (navElement) {
556
- navElement.style.pointerEvents = '';
557
605
  }
558
606
  }
559
607
  },
560
608
  timing.appear
561
609
  );
562
610
  } else {
563
- // If no appear elements, restore tabbing when timeline completes
611
+ // If no appear elements, check if interactions need restoring when timeline completes
564
612
  heroTimeline.call(() => {
565
- const allFocusableElements = document.querySelectorAll('[data-original-tabindex]');
566
- allFocusableElements.forEach(el => {
567
- el.style.pointerEvents = '';
568
- const originalTabindex = el.getAttribute('data-original-tabindex');
569
- if (originalTabindex === '0') {
570
- el.removeAttribute('tabindex');
571
- } else {
572
- el.setAttribute('tabindex', originalTabindex);
613
+ const stillDisabled = document.querySelector('[data-original-tabindex]');
614
+ if (stillDisabled) {
615
+ const allFocusableElements = document.querySelectorAll('[data-original-tabindex]');
616
+ allFocusableElements.forEach(el => {
617
+ el.style.pointerEvents = '';
618
+ const originalTabindex = el.getAttribute('data-original-tabindex');
619
+ if (originalTabindex === '0') {
620
+ el.removeAttribute('tabindex');
621
+ } else {
622
+ el.setAttribute('tabindex', originalTabindex);
623
+ }
624
+ el.removeAttribute('data-original-tabindex');
625
+ });
626
+ // Restore nav pointer events
627
+ if (navElement) {
628
+ navElement.style.pointerEvents = '';
573
629
  }
574
- el.removeAttribute('data-original-tabindex');
575
- });
576
- // Restore nav pointer events
577
- if (navElement) {
578
- navElement.style.pointerEvents = '';
579
630
  }
580
631
  });
581
632
  }
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // Version:1.2.28
1
+ // Version:1.2.29
2
2
 
3
3
  const API_NAME = 'hsmain';
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hortonstudio/main",
3
- "version": "1.2.28",
3
+ "version": "1.2.29",
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,77 +1,15 @@
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
2
  setupDynamicDropdowns();
37
-
38
- // Setup dynamic mobile menu button
39
3
  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
4
  setupMobileMenuARIA();
62
-
63
5
  return { result: 'navbar initialized' };
64
6
  };
65
7
 
66
- // New dynamic dropdown system for Webflow interactions
8
+ // Desktop dropdown system
67
9
  function setupDynamicDropdowns() {
68
- // Find all dropdown wrappers
69
10
  const dropdownWrappers = document.querySelectorAll('[data-hs-nav="dropdown"]');
70
-
71
- // Global array to track all dropdown instances
72
11
  const allDropdowns = [];
73
12
 
74
- // Function to close all dropdowns except the specified one
75
13
  const closeAllDropdowns = (exceptWrapper = null) => {
76
14
  allDropdowns.forEach(dropdown => {
77
15
  if (dropdown.wrapper !== exceptWrapper && dropdown.isOpen) {
@@ -81,11 +19,9 @@ function setupDynamicDropdowns() {
81
19
  };
82
20
 
83
21
  dropdownWrappers.forEach(wrapper => {
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
22
+ const toggle = wrapper.querySelector('a');
23
+ if (!toggle) return;
87
24
 
88
- // Find dropdown list: element with 2+ <a> tags that doesn't contain the toggle
89
25
  const allElements = wrapper.querySelectorAll('*');
90
26
  let dropdownList = null;
91
27
 
@@ -97,49 +33,40 @@ function setupDynamicDropdowns() {
97
33
  }
98
34
  }
99
35
 
100
- if (!dropdownList) return; // Skip if no dropdown list found
36
+ if (!dropdownList) return;
101
37
 
102
- // Generate unique IDs for ARIA attributes
103
38
  const toggleText = toggle.textContent?.trim() || 'dropdown';
104
39
  const sanitizedText = sanitizeForID(toggleText);
105
40
  const toggleId = `navbar-dropdown-${sanitizedText}-toggle`;
106
41
  const listId = `navbar-dropdown-${sanitizedText}-list`;
107
42
 
108
- // Set up ARIA attributes for toggle
109
43
  toggle.id = toggleId;
110
44
  toggle.setAttribute('aria-haspopup', 'menu');
111
45
  toggle.setAttribute('aria-expanded', 'false');
112
46
  toggle.setAttribute('aria-controls', listId);
113
47
 
114
- // Set up ARIA attributes for dropdown list
115
48
  dropdownList.id = listId;
116
49
  dropdownList.setAttribute('role', 'menu');
117
- dropdownList.inert = true;
50
+ dropdownList.setAttribute('aria-hidden', 'true');
118
51
 
119
- // Set up ARIA attributes for menu items
120
52
  const menuItems = dropdownList.querySelectorAll('a');
121
53
  menuItems.forEach(item => {
122
54
  item.setAttribute('role', 'menuitem');
55
+ item.setAttribute('tabindex', '-1');
123
56
  });
124
57
 
125
- // Track dropdown state
126
58
  let isOpen = false;
127
59
  let currentMenuItemIndex = -1;
128
60
 
129
- // Function to open dropdown (simulate click)
130
61
  function openDropdown() {
131
62
  if (isOpen) return;
132
-
133
- // Close all other dropdowns first
134
63
  closeAllDropdowns(wrapper);
135
-
136
64
  isOpen = true;
137
-
138
- // Update ARIA states
139
65
  toggle.setAttribute('aria-expanded', 'true');
140
- dropdownList.inert = false;
141
-
142
- // Simulate click on wrapper to trigger Webflow interactions
66
+ dropdownList.setAttribute('aria-hidden', 'false');
67
+ menuItems.forEach(item => {
68
+ item.setAttribute('tabindex', '0');
69
+ });
143
70
  const clickEvent = new MouseEvent('click', {
144
71
  bubbles: true,
145
72
  cancelable: true,
@@ -148,26 +75,19 @@ function setupDynamicDropdowns() {
148
75
  wrapper.dispatchEvent(clickEvent);
149
76
  }
150
77
 
151
- // Function to close dropdown (simulate second click)
152
78
  function closeDropdown() {
153
79
  if (!isOpen) return;
154
-
155
- // Check if focus should be restored to toggle
156
80
  const shouldRestoreFocus = dropdownList.contains(document.activeElement);
157
-
158
81
  isOpen = false;
159
82
  currentMenuItemIndex = -1;
160
-
161
- // Move focus away BEFORE setting aria-hidden to avoid ARIA violation
162
83
  if (shouldRestoreFocus) {
163
84
  toggle.focus();
164
85
  }
165
-
166
- // Update ARIA states after focus is moved
167
86
  toggle.setAttribute('aria-expanded', 'false');
168
- dropdownList.inert = true;
169
-
170
- // Simulate second click on wrapper to trigger Webflow interactions
87
+ dropdownList.setAttribute('aria-hidden', 'true');
88
+ menuItems.forEach(item => {
89
+ item.setAttribute('tabindex', '-1');
90
+ });
171
91
  const clickEvent = new MouseEvent('click', {
172
92
  bubbles: true,
173
93
  cancelable: true,
@@ -176,65 +96,56 @@ function setupDynamicDropdowns() {
176
96
  wrapper.dispatchEvent(clickEvent);
177
97
  }
178
98
 
179
- // Hover events (native) - trigger click events for Webflow
180
99
  wrapper.addEventListener('mouseenter', () => {
181
100
  if (!isOpen) {
182
- // Trigger click to open
183
101
  const clickEvent = new MouseEvent('click', {
184
102
  bubbles: true,
185
103
  cancelable: true,
186
104
  view: window
187
105
  });
188
106
  wrapper.dispatchEvent(clickEvent);
189
-
190
- // Update our state
191
107
  closeAllDropdowns(wrapper);
192
108
  isOpen = true;
193
109
  toggle.setAttribute('aria-expanded', 'true');
194
- dropdownList.inert = false;
110
+ dropdownList.setAttribute('aria-hidden', 'false');
111
+ menuItems.forEach(item => {
112
+ item.setAttribute('tabindex', '0');
113
+ });
195
114
  }
196
115
  });
197
116
 
198
117
  wrapper.addEventListener('mouseleave', () => {
199
118
  if (isOpen) {
200
- // Move focus away BEFORE setting aria-hidden to avoid ARIA violation
201
119
  if (dropdownList.contains(document.activeElement)) {
202
120
  toggle.focus();
203
121
  }
204
-
205
- // Trigger click to close
206
122
  const clickEvent = new MouseEvent('click', {
207
123
  bubbles: true,
208
124
  cancelable: true,
209
125
  view: window
210
126
  });
211
127
  wrapper.dispatchEvent(clickEvent);
212
-
213
- // Update our state after focus is moved
214
128
  isOpen = false;
215
129
  toggle.setAttribute('aria-expanded', 'false');
216
- dropdownList.inert = true;
130
+ dropdownList.setAttribute('aria-hidden', 'true');
131
+ menuItems.forEach(item => {
132
+ item.setAttribute('tabindex', '-1');
133
+ });
217
134
  currentMenuItemIndex = -1;
218
135
  }
219
136
  });
220
137
 
221
- // Keyboard navigation within dropdown - attach to document to catch all events
222
138
  document.addEventListener('keydown', function(e) {
223
139
  if (!isOpen) return;
224
-
225
- // Only handle if focus is on toggle or within wrapper
226
140
  if (!wrapper.contains(document.activeElement)) return;
227
141
 
228
142
  if (e.key === 'ArrowDown') {
229
143
  e.preventDefault();
230
- // If currently on toggle, start at first item
231
144
  if (document.activeElement === toggle) {
232
145
  currentMenuItemIndex = 0;
233
146
  menuItems[currentMenuItemIndex].focus();
234
147
  } else {
235
- // If at last item in dropdown, move to next sibling element
236
148
  if (currentMenuItemIndex === menuItems.length - 1) {
237
- // Find next focusable element after this dropdown
238
149
  const nextElement = wrapper.nextElementSibling?.querySelector('a, button') ||
239
150
  document.querySelector('.navbar_cartsearch_wrap a, .navbar_cartsearch_wrap button');
240
151
  if (nextElement) {
@@ -248,21 +159,17 @@ function setupDynamicDropdowns() {
248
159
  }
249
160
  } else if (e.key === 'ArrowUp') {
250
161
  e.preventDefault();
251
- // If currently on toggle, start at last item
252
162
  if (document.activeElement === toggle) {
253
163
  currentMenuItemIndex = menuItems.length - 1;
254
164
  menuItems[currentMenuItemIndex].focus();
255
165
  } else {
256
- // If at first item in dropdown, move to previous sibling element
257
166
  if (currentMenuItemIndex === 0) {
258
- // Find previous focusable element before this dropdown
259
167
  const prevElement = wrapper.previousElementSibling?.querySelector('a, button');
260
168
  if (prevElement) {
261
169
  closeDropdown();
262
170
  prevElement.focus();
263
171
  return;
264
172
  } else {
265
- // If no previous sibling, go back to toggle
266
173
  closeDropdown();
267
174
  toggle.focus();
268
175
  return;
@@ -271,47 +178,19 @@ function setupDynamicDropdowns() {
271
178
  currentMenuItemIndex = currentMenuItemIndex <= 0 ? menuItems.length - 1 : currentMenuItemIndex - 1;
272
179
  menuItems[currentMenuItemIndex].focus();
273
180
  }
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
181
  } else if (e.key === 'Tab') {
295
- // Handle Tab navigation within dropdown
296
182
  if (e.shiftKey) {
297
- // Shift+Tab going backwards
298
183
  if (document.activeElement === menuItems[0]) {
299
184
  e.preventDefault();
300
185
  closeDropdown();
301
186
  toggle.focus();
302
187
  }
303
188
  } else {
304
- // Tab going forwards
305
189
  if (document.activeElement === menuItems[menuItems.length - 1]) {
306
190
  e.preventDefault();
307
- // Find next element BEFORE closing dropdown to avoid focus issues
308
191
  const nextElement = wrapper.nextElementSibling?.querySelector('a, button') ||
309
192
  document.querySelector('.navbar_cartsearch_wrap a, .navbar_cartsearch_wrap button');
310
-
311
- // Close dropdown first
312
193
  closeDropdown();
313
-
314
- // Then move focus to next element
315
194
  if (nextElement) {
316
195
  setTimeout(() => {
317
196
  nextElement.focus();
@@ -333,16 +212,13 @@ function setupDynamicDropdowns() {
333
212
  menuItems[menuItems.length - 1].focus();
334
213
  } else if (e.key === ' ') {
335
214
  e.preventDefault();
336
- // Just prevent space from scrolling page - Enter activates links
337
215
  }
338
216
  });
339
217
 
340
- // Keyboard events for toggle
341
218
  toggle.addEventListener('keydown', function(e) {
342
219
  if (e.key === 'ArrowDown') {
343
220
  e.preventDefault();
344
221
  openDropdown();
345
- // Focus first menu item after opening
346
222
  if (menuItems.length > 0) {
347
223
  currentMenuItemIndex = 0;
348
224
  setTimeout(() => menuItems[0].focus(), 100);
@@ -353,7 +229,6 @@ function setupDynamicDropdowns() {
353
229
  closeDropdown();
354
230
  } else {
355
231
  openDropdown();
356
- // Focus first item when opening with spacebar
357
232
  if (menuItems.length > 0) {
358
233
  currentMenuItemIndex = 0;
359
234
  setTimeout(() => menuItems[0].focus(), 100);
@@ -362,7 +237,6 @@ function setupDynamicDropdowns() {
362
237
  } else if (e.key === 'ArrowUp') {
363
238
  e.preventDefault();
364
239
  if (isOpen) {
365
- // Focus last menu item
366
240
  currentMenuItemIndex = menuItems.length - 1;
367
241
  menuItems[currentMenuItemIndex].focus();
368
242
  } else {
@@ -372,17 +246,14 @@ function setupDynamicDropdowns() {
372
246
  e.preventDefault();
373
247
  closeDropdown();
374
248
  }
375
- // Note: Enter key naturally follows the link - no preventDefault needed
376
249
  });
377
250
 
378
- // Close dropdown when clicking outside
379
251
  document.addEventListener('click', function(e) {
380
252
  if (!wrapper.contains(e.target) && isOpen) {
381
253
  closeDropdown();
382
254
  }
383
255
  });
384
256
 
385
- // Add this dropdown instance to the global array
386
257
  allDropdowns.push({
387
258
  wrapper,
388
259
  isOpen: () => isOpen,
@@ -392,7 +263,6 @@ function setupDynamicDropdowns() {
392
263
  });
393
264
  });
394
265
 
395
- // Global focus management - close dropdown when tab focus moves outside
396
266
  document.addEventListener('focusin', function(e) {
397
267
  allDropdowns.forEach(dropdown => {
398
268
  if (dropdown.isOpen() && !dropdown.wrapper.contains(e.target)) {
@@ -401,99 +271,116 @@ function setupDynamicDropdowns() {
401
271
  });
402
272
  });
403
273
 
404
- // Global arrow key navigation for navbar
274
+ addDesktopArrowNavigation();
275
+ }
276
+
277
+ // Desktop left/right arrow navigation
278
+ function addDesktopArrowNavigation() {
405
279
  document.addEventListener('keydown', function(e) {
406
- // Only handle arrow keys
407
280
  if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return;
408
281
 
409
- // Only handle if we're in the navbar
410
- const navbar = document.querySelector('.navbar_component, [data-hs-nav]');
282
+ const mobileMenu = document.querySelector('[data-hs-nav="menu"]');
283
+ if (mobileMenu && mobileMenu.contains(document.activeElement)) return;
284
+
285
+ const navbar = document.querySelector('[data-hs-nav="wrapper"]') ||
286
+ document.querySelector('.navbar_component') ||
287
+ document.querySelector('nav[role="navigation"]') ||
288
+ document.querySelector('nav');
289
+
411
290
  if (!navbar || !navbar.contains(document.activeElement)) return;
412
291
 
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());
292
+ const openDropdownList = navbar.querySelector('[aria-hidden="false"][role="menu"]');
293
+ if (openDropdownList && openDropdownList.contains(document.activeElement)) return;
415
294
 
416
295
  e.preventDefault();
417
296
 
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);
297
+ const allNavbarElements = navbar.querySelectorAll('a, button');
298
+ const focusableElements = Array.from(allNavbarElements).filter(el => {
299
+ if (el.getAttribute('tabindex') === '-1') return false;
300
+
301
+ const isInDropdownList = el.closest('[role="menu"]');
302
+ if (isInDropdownList) return false;
444
303
 
445
- const currentIndex = focusableArray.indexOf(document.activeElement);
446
- if (currentIndex === -1) return;
304
+ const isInMobileMenu = el.closest('[data-hs-nav="menu"]');
305
+ if (isInMobileMenu) return false;
447
306
 
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;
307
+ const computedStyle = window.getComputedStyle(el);
308
+ const isHidden = computedStyle.display === 'none' ||
309
+ computedStyle.visibility === 'hidden' ||
310
+ computedStyle.opacity === '0' ||
311
+ el.offsetWidth === 0 ||
312
+ el.offsetHeight === 0;
313
+ if (isHidden) return false;
314
+
315
+ let parent = el.parentElement;
316
+ while (parent && parent !== navbar) {
317
+ const parentStyle = window.getComputedStyle(parent);
318
+ const parentHidden = parentStyle.display === 'none' ||
319
+ parentStyle.visibility === 'hidden' ||
320
+ parent.offsetWidth === 0 ||
321
+ parent.offsetHeight === 0;
322
+ if (parentHidden) return false;
323
+ parent = parent.parentElement;
453
324
  }
454
325
 
455
- focusableArray[nextIndex].focus();
326
+ return true;
327
+ });
328
+
329
+ const currentIndex = focusableElements.indexOf(document.activeElement);
330
+ if (currentIndex === -1) return;
331
+
332
+ let nextIndex;
333
+ if (e.key === 'ArrowRight') {
334
+ nextIndex = (currentIndex + 1) % focusableElements.length;
335
+ } else {
336
+ nextIndex = currentIndex === 0 ? focusableElements.length - 1 : currentIndex - 1;
456
337
  }
338
+
339
+ focusableElements[nextIndex].focus();
457
340
  });
458
341
  }
459
342
 
460
- // Dynamic mobile menu button system for Webflow interactions
343
+ // Mobile menu button system
461
344
  function setupMobileMenuButton() {
462
- // Find mobile menu button and menu
463
345
  const menuButton = document.querySelector('[data-hs-nav="menubtn"]');
464
346
  const mobileMenu = document.querySelector('[data-hs-nav="menu"]');
465
347
 
466
348
  if (!menuButton || !mobileMenu) return;
467
349
 
468
- // Generate unique ID for the menu
469
350
  const menuId = `mobile-menu-${Date.now()}`;
470
351
 
471
- // Set up ARIA attributes for button
472
352
  menuButton.setAttribute('aria-expanded', 'false');
473
353
  menuButton.setAttribute('aria-controls', menuId);
474
354
  menuButton.setAttribute('aria-label', 'Open navigation menu');
475
355
 
476
- // Set up ARIA attributes for menu
477
356
  mobileMenu.id = menuId;
478
357
  mobileMenu.setAttribute('role', 'dialog');
479
358
  mobileMenu.setAttribute('aria-modal', 'true');
480
359
  mobileMenu.inert = true;
481
360
 
482
- // Track menu state
483
361
  let isMenuOpen = false;
484
362
 
485
- // Function to open menu (simulate click)
486
363
  function openMenu() {
487
364
  if (isMenuOpen) return;
488
-
489
365
  isMenuOpen = true;
490
-
491
- // Update ARIA states
492
366
  menuButton.setAttribute('aria-expanded', 'true');
493
367
  menuButton.setAttribute('aria-label', 'Close navigation menu');
494
368
  mobileMenu.inert = false;
495
369
 
496
- // Simulate click on button to trigger Webflow interactions
370
+ // Prevent tabbing outside navbar using tabindex management
371
+ const navbarWrapper = document.querySelector('[data-hs-nav="wrapper"]') ||
372
+ document.querySelector('.navbar_component') ||
373
+ document.querySelector('nav[role="navigation"]') ||
374
+ document.querySelector('nav');
375
+
376
+ const allFocusableElements = document.querySelectorAll('a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
377
+ allFocusableElements.forEach(el => {
378
+ if (navbarWrapper && !navbarWrapper.contains(el)) {
379
+ el.setAttribute('data-mobile-menu-tabindex', el.getAttribute('tabindex') || '0');
380
+ el.setAttribute('tabindex', '-1');
381
+ }
382
+ });
383
+
497
384
  const clickEvent = new MouseEvent('click', {
498
385
  bubbles: true,
499
386
  cancelable: true,
@@ -502,23 +389,28 @@ function setupMobileMenuButton() {
502
389
  menuButton.dispatchEvent(clickEvent);
503
390
  }
504
391
 
505
- // Function to close menu (simulate second click)
506
392
  function closeMenu() {
507
393
  if (!isMenuOpen) return;
508
-
509
394
  isMenuOpen = false;
510
-
511
- // Move focus away BEFORE setting aria-hidden to avoid ARIA violation
512
395
  if (mobileMenu.contains(document.activeElement)) {
513
396
  menuButton.focus();
514
397
  }
515
-
516
- // Update ARIA states after focus is moved
517
398
  menuButton.setAttribute('aria-expanded', 'false');
518
399
  menuButton.setAttribute('aria-label', 'Open navigation menu');
519
400
  mobileMenu.inert = true;
520
401
 
521
- // Simulate second click on button to trigger Webflow interactions
402
+ // Restore tabbing to entire page using tabindex management
403
+ const elementsToRestore = document.querySelectorAll('[data-mobile-menu-tabindex]');
404
+ elementsToRestore.forEach(el => {
405
+ const originalTabindex = el.getAttribute('data-mobile-menu-tabindex');
406
+ if (originalTabindex === '0') {
407
+ el.removeAttribute('tabindex');
408
+ } else {
409
+ el.setAttribute('tabindex', originalTabindex);
410
+ }
411
+ el.removeAttribute('data-mobile-menu-tabindex');
412
+ });
413
+
522
414
  const clickEvent = new MouseEvent('click', {
523
415
  bubbles: true,
524
416
  cancelable: true,
@@ -527,7 +419,6 @@ function setupMobileMenuButton() {
527
419
  menuButton.dispatchEvent(clickEvent);
528
420
  }
529
421
 
530
- // Function to toggle menu state
531
422
  function toggleMenu() {
532
423
  if (isMenuOpen) {
533
424
  closeMenu();
@@ -536,56 +427,85 @@ function setupMobileMenuButton() {
536
427
  }
537
428
  }
538
429
 
539
- // Keyboard events for button
540
430
  menuButton.addEventListener('keydown', function(e) {
541
431
  if (e.key === 'Enter' || e.key === ' ') {
542
432
  e.preventDefault();
543
433
  toggleMenu();
434
+ } else if (e.key === 'ArrowDown') {
435
+ e.preventDefault();
436
+ if (!isMenuOpen) {
437
+ openMenu();
438
+ }
439
+ const firstElement = mobileMenu.querySelector('button, a');
440
+ if (firstElement) {
441
+ firstElement.focus();
442
+ }
443
+ } else if (e.key === 'ArrowUp') {
444
+ e.preventDefault();
445
+ if (isMenuOpen) {
446
+ closeMenu();
447
+ }
544
448
  }
545
449
  });
546
450
 
547
- // Listen for actual clicks to update state (for cases where Webflow triggers clicks)
548
451
  menuButton.addEventListener('click', function(e) {
549
- // Only update state if this wasn't triggered by our own events
550
452
  if (!e.isTrusted) return;
551
-
552
- // If closing and focus is inside menu, move it out first
553
453
  if (isMenuOpen && mobileMenu.contains(document.activeElement)) {
554
454
  menuButton.focus();
555
455
  }
556
-
557
- // Toggle our internal state
558
456
  isMenuOpen = !isMenuOpen;
559
-
560
- // Update ARIA attributes after focus is handled
561
457
  menuButton.setAttribute('aria-expanded', isMenuOpen);
562
458
  menuButton.setAttribute('aria-label',
563
459
  isMenuOpen ? 'Close navigation menu' : 'Open navigation menu'
564
460
  );
565
461
  mobileMenu.inert = !isMenuOpen;
462
+
463
+ // Handle tabindex management for external clicks
464
+ if (isMenuOpen) {
465
+ const navbarWrapper = document.querySelector('[data-hs-nav="wrapper"]') ||
466
+ document.querySelector('.navbar_component') ||
467
+ document.querySelector('nav[role="navigation"]') ||
468
+ document.querySelector('nav');
469
+
470
+ const allFocusableElements = document.querySelectorAll('a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
471
+ allFocusableElements.forEach(el => {
472
+ if (navbarWrapper && !navbarWrapper.contains(el)) {
473
+ el.setAttribute('data-mobile-menu-tabindex', el.getAttribute('tabindex') || '0');
474
+ el.setAttribute('tabindex', '-1');
475
+ }
476
+ });
477
+ } else {
478
+ const elementsToRestore = document.querySelectorAll('[data-mobile-menu-tabindex]');
479
+ elementsToRestore.forEach(el => {
480
+ const originalTabindex = el.getAttribute('data-mobile-menu-tabindex');
481
+ if (originalTabindex === '0') {
482
+ el.removeAttribute('tabindex');
483
+ } else {
484
+ el.setAttribute('tabindex', originalTabindex);
485
+ }
486
+ el.removeAttribute('data-mobile-menu-tabindex');
487
+ });
488
+ }
566
489
  });
567
490
  }
568
491
 
569
- // Helper function to sanitize text for HTML IDs
570
492
  function sanitizeForID(text) {
571
493
  return text
572
494
  .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
495
+ .replace(/[^a-z0-9\s]/g, '')
496
+ .replace(/\s+/g, '-')
497
+ .replace(/^-+|-+$/g, '')
498
+ .substring(0, 50);
577
499
  }
578
500
 
579
- // Setup mobile menu ARIA automation
501
+ // Mobile menu ARIA setup
580
502
  function setupMobileMenuARIA() {
581
503
  const menuContainer = document.querySelector('[data-hs-nav="menu"]');
582
504
  if (!menuContainer) return;
583
505
 
584
- // Find all buttons and links inside the menu container
585
506
  const buttons = menuContainer.querySelectorAll('button');
586
507
  const links = menuContainer.querySelectorAll('a');
587
508
 
588
- // Process each button (dropdown toggles)
589
509
  buttons.forEach(button => {
590
510
  const buttonText = button.textContent?.trim();
591
511
  if (!buttonText) return;
@@ -594,20 +514,16 @@ function setupMobileMenuARIA() {
594
514
  const buttonId = `navbar-mobile-${sanitizedText}-toggle`;
595
515
  const listId = `navbar-mobile-${sanitizedText}-list`;
596
516
 
597
- // Set button ID and ARIA attributes
598
517
  button.id = buttonId;
599
518
  button.setAttribute('aria-expanded', 'false');
600
519
  button.setAttribute('aria-controls', listId);
601
520
 
602
- // Find associated dropdown list (look for next sibling or nearby element with links)
603
521
  let dropdownList = button.nextElementSibling;
604
522
 
605
- // If next sibling doesn't contain links, look for parent's next sibling
606
523
  if (!dropdownList || !dropdownList.querySelector('a')) {
607
524
  dropdownList = button.parentElement?.nextElementSibling;
608
525
  }
609
526
 
610
- // If still not found, look for a nearby element that contains multiple links
611
527
  if (!dropdownList || !dropdownList.querySelector('a')) {
612
528
  const parent = button.closest('[data-hs-nav="menu"]');
613
529
  const allListElements = parent?.querySelectorAll('div, ul, nav');
@@ -617,68 +533,93 @@ function setupMobileMenuARIA() {
617
533
  );
618
534
  }
619
535
 
620
- // Set dropdown list ID and initial hidden state if found
621
536
  if (dropdownList && dropdownList.querySelector('a')) {
622
537
  dropdownList.id = listId;
623
538
  dropdownList.inert = true;
624
539
 
625
- // Add click listener to button to manage dropdown state
626
540
  button.addEventListener('click', function() {
627
541
  const isExpanded = button.getAttribute('aria-expanded') === 'true';
628
542
  const newState = !isExpanded;
629
-
630
- // Update button state
631
543
  button.setAttribute('aria-expanded', newState);
632
-
633
- // Update dropdown state
634
544
  dropdownList.inert = !newState;
635
545
  });
636
546
  }
637
547
  });
638
548
 
639
- // Process each link (navigation items) - but don't override dropdown links
640
549
  links.forEach(link => {
641
550
  const linkText = link.textContent?.trim();
642
551
  if (!linkText) return;
643
552
 
644
553
  const sanitizedText = sanitizeForID(linkText);
645
554
  const linkId = `navbar-mobile-${sanitizedText}-link`;
646
-
647
- // Set link ID
648
555
  link.id = linkId;
649
-
650
- // Links inside inert containers are automatically non-focusable
651
556
  });
652
557
 
653
- // Add arrow key navigation for mobile menu
654
558
  setupMobileMenuArrowNavigation(menuContainer);
655
559
  }
656
560
 
657
- // Arrow key navigation for mobile menu
561
+ // Mobile menu arrow navigation
658
562
  function setupMobileMenuArrowNavigation(menuContainer) {
659
- // Get all focusable elements in the mobile menu
660
563
  function getFocusableElements() {
661
- return menuContainer.querySelectorAll('button:not([tabindex="-1"]), a:not([tabindex="-1"])');
564
+ const allElements = menuContainer.querySelectorAll('button, a');
565
+ return Array.from(allElements).filter(el => {
566
+ let current = el;
567
+ while (current && current !== menuContainer) {
568
+ if (current.inert === true) {
569
+ return false;
570
+ }
571
+ current = current.parentElement;
572
+ }
573
+ return true;
574
+ });
662
575
  }
663
576
 
664
577
  let currentFocusIndex = -1;
665
578
 
666
579
  menuContainer.addEventListener('keydown', function(e) {
667
- const focusableElements = Array.from(getFocusableElements());
580
+ const focusableElements = getFocusableElements();
668
581
  if (focusableElements.length === 0) return;
669
582
 
670
- // Find current focus index
671
583
  const activeElement = document.activeElement;
672
584
  currentFocusIndex = focusableElements.indexOf(activeElement);
673
585
 
674
586
  if (e.key === 'ArrowDown') {
675
587
  e.preventDefault();
676
- currentFocusIndex = (currentFocusIndex + 1) % focusableElements.length;
588
+ if (currentFocusIndex >= focusableElements.length - 1) {
589
+ currentFocusIndex = 0;
590
+ } else {
591
+ currentFocusIndex = currentFocusIndex + 1;
592
+ }
677
593
  focusableElements[currentFocusIndex].focus();
678
594
  } else if (e.key === 'ArrowUp') {
679
595
  e.preventDefault();
680
- currentFocusIndex = currentFocusIndex <= 0 ? focusableElements.length - 1 : currentFocusIndex - 1;
596
+ if (currentFocusIndex <= 0) {
597
+ const mobileMenuButton = document.querySelector('[data-hs-nav="menubtn"]');
598
+ if (mobileMenuButton) {
599
+ mobileMenuButton.focus();
600
+ return;
601
+ }
602
+ }
603
+ currentFocusIndex = currentFocusIndex - 1;
681
604
  focusableElements[currentFocusIndex].focus();
605
+ } else if (e.key === 'ArrowRight') {
606
+ e.preventDefault();
607
+ if (activeElement.tagName === 'BUTTON' && activeElement.hasAttribute('aria-controls')) {
608
+ const isExpanded = activeElement.getAttribute('aria-expanded') === 'true';
609
+ if (!isExpanded) {
610
+ activeElement.click();
611
+ }
612
+ return;
613
+ }
614
+ } else if (e.key === 'ArrowLeft') {
615
+ e.preventDefault();
616
+ if (activeElement.tagName === 'BUTTON' && activeElement.hasAttribute('aria-controls')) {
617
+ const isExpanded = activeElement.getAttribute('aria-expanded') === 'true';
618
+ if (isExpanded) {
619
+ activeElement.click();
620
+ }
621
+ return;
622
+ }
682
623
  } else if (e.key === 'Home') {
683
624
  e.preventDefault();
684
625
  currentFocusIndex = 0;
@@ -689,9 +630,7 @@ function setupMobileMenuArrowNavigation(menuContainer) {
689
630
  focusableElements[focusableElements.length - 1].focus();
690
631
  } else if (e.key === ' ' && activeElement.tagName === 'A') {
691
632
  e.preventDefault();
692
- // Just prevent space from scrolling page - Enter activates links
693
633
  } else if (e.key === 'Escape') {
694
- // Close mobile menu
695
634
  const mobileMenuButton = document.querySelector('[data-hs-nav="menubtn"]');
696
635
  if (mobileMenuButton) {
697
636
  mobileMenuButton.click();