@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.
- package/autoInit/modal.js +75 -0
- package/index.js +5 -3
- package/package.json +1 -1
- package/utils/navbar.js +600 -112
|
@@ -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.
|
|
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
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
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
127
|
+
let currentMenuItemIndex = -1;
|
|
36
128
|
|
|
37
|
-
//
|
|
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
|
-
|
|
140
|
+
dropdownList.inert = false;
|
|
54
141
|
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
//
|
|
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 =
|
|
156
|
+
const shouldRestoreFocus = dropdownList.contains(document.activeElement);
|
|
90
157
|
|
|
91
158
|
isOpen = false;
|
|
92
159
|
currentMenuItemIndex = -1;
|
|
93
160
|
|
|
94
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
//
|
|
147
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
157
|
-
|
|
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(),
|
|
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'
|
|
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
|
-
|
|
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
|
+
}
|