@hortonstudio/main 1.9.10 → 1.9.20
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/.prettierrc +8 -0
- package/README.md +146 -0
- package/eslint.config.js +32 -0
- package/index.ts +275 -0
- package/package.json +19 -2
- package/public/bootstrap.js +16 -0
- package/src/animations/animations.ts +93 -0
- package/src/animations/functions/counter/counter.ts +137 -0
- package/src/config.json +570 -0
- package/src/config.ts +105 -0
- package/src/modules/default/README.md +167 -0
- package/src/modules/default/default.ts +71 -0
- package/{autoInit → src/modules/default/functions}/accessibility/README.md +44 -12
- package/src/modules/default/functions/accessibility/accessibility.ts +54 -0
- package/src/modules/default/functions/accordion/README.md +451 -0
- package/src/modules/default/functions/accordion/accordion.ts +189 -0
- package/src/modules/default/functions/comparison/comparison.ts +424 -0
- package/src/modules/default/functions/marquee/marquee.ts +206 -0
- package/src/modules/default/functions/navbar/README.md +393 -0
- package/src/modules/default/functions/navbar/functions/arrow-navigation/arrow-navigation.ts +183 -0
- package/src/modules/default/functions/navbar/functions/dropdown/dropdown.ts +313 -0
- package/src/modules/default/functions/navbar/functions/menu/menu.ts +315 -0
- package/src/modules/default/functions/navbar/navbar.ts +51 -0
- package/{autoInit → src/modules/default/functions}/smooth-scroll/README.md +45 -14
- package/{autoInit/smooth-scroll/smooth-scroll.js → src/modules/default/functions/smooth-scroll/smooth-scroll.ts} +33 -38
- package/{autoInit → src/modules/default/functions}/transition/README.md +59 -32
- package/src/modules/default/functions/transition/transition.ts +290 -0
- package/src/modules/normalize/README.md +172 -0
- package/src/modules/normalize/functions/clickable/README.md +84 -0
- package/src/modules/normalize/functions/clickable/clickable.ts +43 -0
- package/src/modules/normalize/functions/clickable/functions/normalize/README.md +213 -0
- package/src/modules/normalize/functions/clickable/functions/normalize/normalize.ts +68 -0
- package/src/modules/normalize/functions/dupe/README.md +405 -0
- package/src/modules/normalize/functions/dupe/dupe.ts +197 -0
- package/src/modules/normalize/functions/sync/sync.ts +378 -0
- package/src/modules/normalize/normalize.ts +58 -0
- package/src/modules/structure/README.md +190 -0
- package/src/modules/structure/functions/form/README.md +94 -0
- package/src/modules/structure/functions/form/form.ts +54 -0
- package/src/modules/structure/functions/form/functions/honeypot/README.md +77 -0
- package/src/modules/structure/functions/form/functions/honeypot/honeypot.ts +37 -0
- package/src/modules/structure/functions/form/functions/range/README.md +410 -0
- package/src/modules/structure/functions/form/functions/range/range.ts +92 -0
- package/src/modules/structure/functions/form/functions/select/README.md +393 -0
- package/src/modules/structure/functions/form/functions/select/functions/custom-select/custom-select.ts +637 -0
- package/src/modules/structure/functions/form/functions/select/functions/states/states.ts +118 -0
- package/src/modules/structure/functions/form/functions/select/select.ts +48 -0
- package/src/modules/structure/functions/form/functions/test/test.ts +132 -0
- package/src/modules/structure/functions/pagination/README.md +527 -0
- package/src/modules/structure/functions/pagination/pagination.ts +493 -0
- package/src/modules/structure/functions/site-settings/README.md +395 -0
- package/src/modules/structure/functions/site-settings/site-settings.ts +158 -0
- package/{autoInit/accessibility → src/modules/structure}/functions/toc/README.md +18 -15
- package/{autoInit/accessibility/functions/toc/toc.js → src/modules/structure/functions/toc/functions/heading-links/heading-links.ts} +43 -63
- package/src/modules/structure/functions/toc/functions/progress-bar/progress-bar.ts +101 -0
- package/src/modules/structure/functions/toc/toc.ts +35 -0
- package/{autoInit/accessibility → src/modules/structure}/functions/year-replacement/README.md +7 -6
- package/src/modules/structure/functions/year-replacement/year-replacement.ts +59 -0
- package/src/modules/structure/structure.ts +59 -0
- package/src/utils/attributeSelector.ts +78 -0
- package/src/utils/cssVariables.ts +24 -0
- package/src/utils/gsap.ts +198 -0
- package/src/utils/heightAnimator.ts +130 -0
- package/src/utils/modalManager.ts +150 -0
- package/src/utils.ts +54 -0
- package/tsconfig.json +24 -0
- package/vite.config.js +45 -0
- package/.claude/settings.local.json +0 -70
- package/archive/hero.js +0 -794
- package/archive/modal.js +0 -80
- package/archive/text.js +0 -628
- package/autoInit/accessibility/accessibility.js +0 -53
- package/autoInit/accessibility/functions/blog-remover/README.md +0 -61
- package/autoInit/accessibility/functions/blog-remover/blog-remover.js +0 -31
- package/autoInit/accessibility/functions/click-forwarding/README.md +0 -60
- package/autoInit/accessibility/functions/click-forwarding/click-forwarding.js +0 -82
- package/autoInit/accessibility/functions/dropdown/README.md +0 -212
- package/autoInit/accessibility/functions/dropdown/dropdown.js +0 -167
- package/autoInit/accessibility/functions/list-accessibility/README.md +0 -56
- package/autoInit/accessibility/functions/list-accessibility/list-accessibility.js +0 -23
- package/autoInit/accessibility/functions/pagination/README.md +0 -428
- package/autoInit/accessibility/functions/pagination/pagination.js +0 -359
- package/autoInit/accessibility/functions/text-synchronization/README.md +0 -62
- package/autoInit/accessibility/functions/text-synchronization/text-synchronization.js +0 -101
- package/autoInit/accessibility/functions/year-replacement/year-replacement.js +0 -43
- package/autoInit/button/README.md +0 -122
- package/autoInit/button/button.js +0 -51
- package/autoInit/counter/README.md +0 -274
- package/autoInit/counter/counter.js +0 -185
- package/autoInit/form/README.md +0 -338
- package/autoInit/form/form.js +0 -374
- package/autoInit/navbar/README.md +0 -366
- package/autoInit/navbar/navbar.js +0 -786
- package/autoInit/site-settings/README.md +0 -218
- package/autoInit/site-settings/site-settings.js +0 -134
- package/autoInit/transition/transition.js +0 -116
- package/index.js +0 -305
- package/utils/before-after/README.md +0 -520
- package/utils/before-after/before-after.js +0 -653
- package/utils/css-animations/buttons/main/bgbasic/btn-main-bgbasic.html +0 -10
- package/utils/css-animations/buttons/main/bgfill/btn-main-bgfill.html +0 -29
- package/utils/css-animations/buttons/navbar/bgbasic/navbar-main-bgbasic.html +0 -17
- package/utils/css-animations/buttons/navbar/bgbasic/navbar-menu-bgbasic.html +0 -16
- package/utils/css-animations/buttons/navbar/bgfill/navbar-main-bgfill.html +0 -46
- package/utils/css-animations/buttons/navbar/bgfill/navbar-menu-bgfill.html +0 -39
- package/utils/css-animations/buttons/navbar/color/navbar-announce-color.html +0 -5
- package/utils/css-animations/buttons/navbar/color/navbar-main-color.html +0 -7
- package/utils/css-animations/buttons/navbar/color/navbar-menu-color.html +0 -7
- package/utils/css-animations/buttons/navbar/double-slide/navbar-announce-double-slide.html +0 -40
- package/utils/css-animations/buttons/navbar/double-slide/navbar-main-double-slide.html +0 -77
- package/utils/css-animations/buttons/navbar/scale/navbar-announce-scale.html +0 -6
- package/utils/css-animations/buttons/navbar/scale/navbar-main-scale.html +0 -9
- package/utils/css-animations/buttons/navbar/scale/navbar-menu-scale.html +0 -8
- package/utils/css-animations/buttons/navbar/underline/navbar-announce-underline.html +0 -32
- package/utils/css-animations/buttons/navbar/underline/navbar-main-underline.html +0 -56
- package/utils/css-animations/buttons/text/color/text-footer-color.html +0 -5
- package/utils/css-animations/buttons/text/color/text-main-color.html +0 -5
- package/utils/css-animations/buttons/text/double-slide/text-main-double-slide.html +0 -56
- package/utils/css-animations/buttons/text/scale/text-footer-scale.html +0 -6
- package/utils/css-animations/buttons/text/scale/text-main-scale.html +0 -6
- package/utils/css-animations/buttons/text/underline/text-footer-underline.html +0 -45
- package/utils/css-animations/buttons/text/underline/text-main-underline.html +0 -58
- package/utils/css-animations/cards/card-clickable.html +0 -11
- package/utils/css-animations/defaults.html +0 -69
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import {
|
|
2
|
+
querySelectorAll,
|
|
3
|
+
querySelector,
|
|
4
|
+
getSelector,
|
|
5
|
+
globalConfig,
|
|
6
|
+
animateHeight,
|
|
7
|
+
setHeight,
|
|
8
|
+
} from '@utils';
|
|
9
|
+
|
|
10
|
+
// Module-scoped config (set during init)
|
|
11
|
+
let moduleConfig = null;
|
|
12
|
+
|
|
13
|
+
export function init(config) {
|
|
14
|
+
// Store config at module scope for helper functions
|
|
15
|
+
moduleConfig = config;
|
|
16
|
+
|
|
17
|
+
const cleanup = {
|
|
18
|
+
observers: [],
|
|
19
|
+
handlers: [],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const addObserver = (observer) => cleanup.observers.push(observer);
|
|
23
|
+
const addHandler = (element, event, handler, options) => {
|
|
24
|
+
element.addEventListener(event, handler, options);
|
|
25
|
+
cleanup.handlers.push({ element, event, handler, options });
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function sanitizeForID(text) {
|
|
29
|
+
return text
|
|
30
|
+
.toLowerCase()
|
|
31
|
+
.replace(/[^a-z0-9\s]/g, '')
|
|
32
|
+
.replace(/\s+/g, '-')
|
|
33
|
+
.replace(/^-+|-+$/g, '')
|
|
34
|
+
.substring(0, 50);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function setupDropdown(addObserver, addHandler) {
|
|
38
|
+
const dropdownWrappers = querySelectorAll(moduleConfig, 'wrapper');
|
|
39
|
+
const hoverDropdowns = []; // Track hover-type dropdowns for focus-loss handling
|
|
40
|
+
|
|
41
|
+
dropdownWrappers.forEach((wrapper) => {
|
|
42
|
+
const clickableSelector = getSelector(globalConfig.clickable, 'button');
|
|
43
|
+
const toggle = wrapper.querySelector(clickableSelector);
|
|
44
|
+
const dropdownList = querySelector(moduleConfig, 'list', wrapper);
|
|
45
|
+
|
|
46
|
+
if (!toggle || !dropdownList) {
|
|
47
|
+
console.warn('[dropdown] Dropdown wrapper missing required elements:', wrapper);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Get dropdown type (hover or click)
|
|
52
|
+
const type = wrapper.getAttribute('data-hs-nav-dropdown-type') || 'hover';
|
|
53
|
+
|
|
54
|
+
// Check for visual height wrapper (contains data-hs-height="element")
|
|
55
|
+
// If found, animate that instead of the list (list is for ARIA only)
|
|
56
|
+
// If not found, no height animation (optional feature)
|
|
57
|
+
const heightWrapper = wrapper.querySelector('[data-hs-height="element"]');
|
|
58
|
+
const animationTarget = heightWrapper ? heightWrapper.parentElement : null;
|
|
59
|
+
|
|
60
|
+
const toggleText = toggle.textContent?.trim() || 'dropdown';
|
|
61
|
+
const sanitizedText = sanitizeForID(toggleText);
|
|
62
|
+
const toggleId = `navbar-dropdown-${sanitizedText}-toggle`;
|
|
63
|
+
const listId = `navbar-dropdown-${sanitizedText}-list`;
|
|
64
|
+
|
|
65
|
+
toggle.id = toggleId;
|
|
66
|
+
toggle.setAttribute('aria-haspopup', 'menu');
|
|
67
|
+
toggle.setAttribute('aria-expanded', 'false');
|
|
68
|
+
toggle.setAttribute('aria-controls', listId);
|
|
69
|
+
|
|
70
|
+
dropdownList.id = listId;
|
|
71
|
+
dropdownList.setAttribute('role', 'menu');
|
|
72
|
+
dropdownList.inert = true; // Initial state: hidden and non-interactive
|
|
73
|
+
|
|
74
|
+
const menuItems = Array.from(dropdownList.querySelectorAll(clickableSelector));
|
|
75
|
+
menuItems.forEach((item, index) => {
|
|
76
|
+
item.setAttribute('role', 'menuitem');
|
|
77
|
+
item.setAttribute('tabindex', '-1');
|
|
78
|
+
|
|
79
|
+
// Add context for first item to help screen readers understand dropdown content
|
|
80
|
+
if (index === 0) {
|
|
81
|
+
const toggleText = toggle.textContent?.trim() || 'menu';
|
|
82
|
+
const existingLabel = item.getAttribute('aria-label');
|
|
83
|
+
if (!existingLabel) {
|
|
84
|
+
item.setAttribute('aria-label', `${item.textContent?.trim()}, ${toggleText} submenu`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
let currentMenuItemIndex = -1;
|
|
90
|
+
|
|
91
|
+
// Function to check if dropdown is open
|
|
92
|
+
function isDropdownOpen() {
|
|
93
|
+
return wrapper.classList.contains(globalConfig.classes.active);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Update ARIA states based on current visual state
|
|
97
|
+
function updateARIAStates() {
|
|
98
|
+
const isOpen = isDropdownOpen();
|
|
99
|
+
const wasOpen = toggle.getAttribute('aria-expanded') === 'true';
|
|
100
|
+
|
|
101
|
+
// If dropdown is closing (was open, now closed), focus the toggle first
|
|
102
|
+
if (wasOpen && !isOpen && dropdownList.contains(document.activeElement)) {
|
|
103
|
+
toggle.focus();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
toggle.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
|
107
|
+
dropdownList.inert = !isOpen; // Enable/disable interaction based on state
|
|
108
|
+
menuItems.forEach((item) => {
|
|
109
|
+
item.setAttribute('tabindex', isOpen ? '0' : '-1');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
if (!isOpen) {
|
|
113
|
+
currentMenuItemIndex = -1;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Animate height wrapper (visual container, not ARIA list)
|
|
117
|
+
animateHeight(animationTarget, isOpen, {
|
|
118
|
+
duration: type === 'hover' ? 200 : 300,
|
|
119
|
+
ease: 'power2.inOut',
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Set initial height without animation
|
|
124
|
+
setHeight(animationTarget, isDropdownOpen());
|
|
125
|
+
|
|
126
|
+
// Set initial ARIA states
|
|
127
|
+
updateARIAStates();
|
|
128
|
+
|
|
129
|
+
// Monitor for class changes and update ARIA states
|
|
130
|
+
const observer = new MutationObserver(() => {
|
|
131
|
+
updateARIAStates();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
observer.observe(wrapper, {
|
|
135
|
+
attributes: true,
|
|
136
|
+
attributeFilter: ['class'],
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
addObserver(observer);
|
|
140
|
+
|
|
141
|
+
// ========================================
|
|
142
|
+
// TYPE-SPECIFIC HANDLERS
|
|
143
|
+
// ========================================
|
|
144
|
+
|
|
145
|
+
if (type === 'hover') {
|
|
146
|
+
// HOVER TYPE: Desktop behavior with hover + full keyboard navigation
|
|
147
|
+
|
|
148
|
+
// Track this as a hover dropdown for focus-loss handling
|
|
149
|
+
hoverDropdowns.push(wrapper);
|
|
150
|
+
|
|
151
|
+
// Hover handlers
|
|
152
|
+
const mouseenterHandler = () => {
|
|
153
|
+
wrapper.classList.add('is-active');
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const mouseleaveHandler = () => {
|
|
157
|
+
wrapper.classList.remove('is-active');
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
addHandler(wrapper, 'mouseenter', mouseenterHandler);
|
|
161
|
+
addHandler(wrapper, 'mouseleave', mouseleaveHandler);
|
|
162
|
+
|
|
163
|
+
// Full keyboard navigation for toggle
|
|
164
|
+
const toggleKeydownHandler = function (e) {
|
|
165
|
+
if (e.key === 'ArrowDown') {
|
|
166
|
+
e.preventDefault();
|
|
167
|
+
|
|
168
|
+
if (!isDropdownOpen()) {
|
|
169
|
+
wrapper.classList.add(globalConfig.classes.active);
|
|
170
|
+
|
|
171
|
+
// Focus first menu item after brief delay
|
|
172
|
+
if (menuItems.length > 0) {
|
|
173
|
+
setTimeout(() => {
|
|
174
|
+
currentMenuItemIndex = 0;
|
|
175
|
+
menuItems[0].focus();
|
|
176
|
+
}, 100);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
} else if (e.key === ' ' || e.key === 'Enter') {
|
|
180
|
+
e.preventDefault();
|
|
181
|
+
|
|
182
|
+
if (!isDropdownOpen()) {
|
|
183
|
+
wrapper.classList.add(globalConfig.classes.active);
|
|
184
|
+
|
|
185
|
+
// Focus first menu item after brief delay
|
|
186
|
+
if (menuItems.length > 0) {
|
|
187
|
+
setTimeout(() => {
|
|
188
|
+
currentMenuItemIndex = 0;
|
|
189
|
+
menuItems[0].focus();
|
|
190
|
+
}, 100);
|
|
191
|
+
}
|
|
192
|
+
} else {
|
|
193
|
+
wrapper.classList.remove(globalConfig.classes.active);
|
|
194
|
+
}
|
|
195
|
+
} else if (e.key === 'ArrowUp') {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
|
|
198
|
+
if (!isDropdownOpen()) {
|
|
199
|
+
wrapper.classList.add(globalConfig.classes.active);
|
|
200
|
+
|
|
201
|
+
// Focus last menu item after brief delay
|
|
202
|
+
if (menuItems.length > 0) {
|
|
203
|
+
setTimeout(() => {
|
|
204
|
+
currentMenuItemIndex = menuItems.length - 1;
|
|
205
|
+
menuItems[currentMenuItemIndex].focus();
|
|
206
|
+
}, 100);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
} else if (e.key === 'Escape') {
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
|
|
212
|
+
if (isDropdownOpen()) {
|
|
213
|
+
wrapper.classList.remove(globalConfig.classes.active);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
addHandler(toggle, 'keydown', toggleKeydownHandler);
|
|
219
|
+
|
|
220
|
+
// Handle navigation within open dropdown (arrow keys)
|
|
221
|
+
const dropdownKeydownHandler = function (e) {
|
|
222
|
+
if (!isDropdownOpen()) return;
|
|
223
|
+
if (!wrapper.contains(document.activeElement)) return;
|
|
224
|
+
|
|
225
|
+
if (e.key === 'ArrowDown') {
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
if (currentMenuItemIndex < menuItems.length - 1) {
|
|
228
|
+
currentMenuItemIndex++;
|
|
229
|
+
menuItems[currentMenuItemIndex].focus();
|
|
230
|
+
}
|
|
231
|
+
} else if (e.key === 'ArrowUp') {
|
|
232
|
+
e.preventDefault();
|
|
233
|
+
if (currentMenuItemIndex === 0) {
|
|
234
|
+
wrapper.classList.remove(globalConfig.classes.active);
|
|
235
|
+
toggle.focus();
|
|
236
|
+
currentMenuItemIndex = -1;
|
|
237
|
+
} else if (currentMenuItemIndex > 0) {
|
|
238
|
+
currentMenuItemIndex--;
|
|
239
|
+
menuItems[currentMenuItemIndex].focus();
|
|
240
|
+
} else {
|
|
241
|
+
toggle.focus();
|
|
242
|
+
currentMenuItemIndex = -1;
|
|
243
|
+
}
|
|
244
|
+
} else if (e.key === 'Escape') {
|
|
245
|
+
e.preventDefault();
|
|
246
|
+
wrapper.classList.remove('is-active');
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
addHandler(document, 'keydown', dropdownKeydownHandler);
|
|
251
|
+
} else if (type === 'click') {
|
|
252
|
+
// CLICK TYPE: Mobile/accordion behavior with click only + simple keyboard
|
|
253
|
+
|
|
254
|
+
// Click handler to toggle
|
|
255
|
+
const clickHandler = function (e) {
|
|
256
|
+
e.preventDefault();
|
|
257
|
+
wrapper.classList.toggle(globalConfig.classes.active);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
addHandler(toggle, 'click', clickHandler);
|
|
261
|
+
|
|
262
|
+
// Simple keyboard navigation (NO arrow keys)
|
|
263
|
+
const toggleKeydownHandler = function (e) {
|
|
264
|
+
if (e.key === ' ' || e.key === 'Enter') {
|
|
265
|
+
e.preventDefault();
|
|
266
|
+
wrapper.classList.toggle(globalConfig.classes.active);
|
|
267
|
+
} else if (e.key === 'Escape') {
|
|
268
|
+
e.preventDefault();
|
|
269
|
+
if (isDropdownOpen()) {
|
|
270
|
+
wrapper.classList.remove(globalConfig.classes.active);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
addHandler(toggle, 'keydown', toggleKeydownHandler);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
// Close hover dropdowns when focus leaves (click dropdowns stay open)
|
|
280
|
+
const focusinHandler = function (e) {
|
|
281
|
+
hoverDropdowns.forEach((wrapper) => {
|
|
282
|
+
if (wrapper.classList.contains('is-active') && !wrapper.contains(e.target)) {
|
|
283
|
+
wrapper.classList.remove('is-active');
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
// Only add handler if there are hover dropdowns
|
|
289
|
+
if (hoverDropdowns.length > 0) {
|
|
290
|
+
addHandler(document, 'focusin', focusinHandler);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
setupDropdown(addObserver, addHandler);
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
result: 'dropdown initialized',
|
|
298
|
+
destroy: () => {
|
|
299
|
+
// Disconnect all observers
|
|
300
|
+
cleanup.observers.forEach((obs) => obs.disconnect());
|
|
301
|
+
cleanup.observers.length = 0;
|
|
302
|
+
|
|
303
|
+
// Remove all event listeners
|
|
304
|
+
cleanup.handlers.forEach(({ element, event, handler, options }) => {
|
|
305
|
+
element.removeEventListener(event, handler, options);
|
|
306
|
+
});
|
|
307
|
+
cleanup.handlers.length = 0;
|
|
308
|
+
|
|
309
|
+
// Clear module config
|
|
310
|
+
moduleConfig = null;
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import {
|
|
2
|
+
querySelectorAll,
|
|
3
|
+
querySelector,
|
|
4
|
+
getSelector,
|
|
5
|
+
globalConfig,
|
|
6
|
+
cssVariables,
|
|
7
|
+
animateHeight,
|
|
8
|
+
setHeight,
|
|
9
|
+
openModal,
|
|
10
|
+
closeModal,
|
|
11
|
+
} from '@utils';
|
|
12
|
+
|
|
13
|
+
export function init(config, navbarConfig) {
|
|
14
|
+
const cleanup = {
|
|
15
|
+
observers: [],
|
|
16
|
+
handlers: [],
|
|
17
|
+
state: {
|
|
18
|
+
focusTrapHandler: null,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const addObserver = (observer) => cleanup.observers.push(observer);
|
|
23
|
+
const addHandler = (element, event, handler, options) => {
|
|
24
|
+
element.addEventListener(event, handler, options);
|
|
25
|
+
cleanup.handlers.push({ element, event, handler, options });
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
function createFocusTrap(menuButton) {
|
|
29
|
+
const navbarWrapper = document.querySelector('[data-hs-nav="wrapper"]');
|
|
30
|
+
|
|
31
|
+
if (!navbarWrapper) return;
|
|
32
|
+
|
|
33
|
+
const focusTrapHandler = (e) => {
|
|
34
|
+
if (e.key === 'Tab') {
|
|
35
|
+
const clickableSelector = getSelector(globalConfig.clickable, 'button');
|
|
36
|
+
const clickableItems = Array.from(navbarWrapper.querySelectorAll(clickableSelector));
|
|
37
|
+
const formElements = navbarWrapper.querySelectorAll(
|
|
38
|
+
'input, select, textarea, [tabindex]:not([tabindex="-1"])'
|
|
39
|
+
);
|
|
40
|
+
const focusableArray = [...clickableItems, ...Array.from(formElements)];
|
|
41
|
+
const firstElement = focusableArray[0];
|
|
42
|
+
const lastElement = focusableArray[focusableArray.length - 1];
|
|
43
|
+
|
|
44
|
+
if (e.shiftKey) {
|
|
45
|
+
// Shift+Tab: moving backwards
|
|
46
|
+
if (document.activeElement === firstElement) {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
lastElement.focus();
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
// Tab: moving forwards
|
|
52
|
+
if (document.activeElement === lastElement) {
|
|
53
|
+
e.preventDefault();
|
|
54
|
+
firstElement.focus();
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
document.addEventListener('keydown', focusTrapHandler);
|
|
61
|
+
return focusTrapHandler;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function removeFocusTrap(focusTrapHandler) {
|
|
65
|
+
if (focusTrapHandler) {
|
|
66
|
+
document.removeEventListener('keydown', focusTrapHandler);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function setupMenu(addObserver, addHandler) {
|
|
71
|
+
const menuButtons = querySelectorAll(config, 'button');
|
|
72
|
+
const menu = querySelector(config, 'wrapper');
|
|
73
|
+
|
|
74
|
+
if (!menuButtons.length || !menu) return;
|
|
75
|
+
|
|
76
|
+
// Get navbar wrapper for styling (e.g., backdrop blur)
|
|
77
|
+
const navbarWrapper = querySelector(navbarConfig, 'wrapper');
|
|
78
|
+
|
|
79
|
+
const menuId = `menu-${Date.now()}`;
|
|
80
|
+
|
|
81
|
+
menu.id = menuId;
|
|
82
|
+
menu.setAttribute('role', 'dialog');
|
|
83
|
+
menu.setAttribute('aria-modal', 'true');
|
|
84
|
+
menu.inert = true; // Initial state: hidden and non-interactive
|
|
85
|
+
|
|
86
|
+
// Check for visual height wrapper (optional)
|
|
87
|
+
// Only look for a descendant with data-hs-height="element"
|
|
88
|
+
// If found, animate that element's parent. If not found, no height animation.
|
|
89
|
+
const heightWrapper = menu.querySelector('[data-hs-height="element"]');
|
|
90
|
+
const animationTarget = heightWrapper ? heightWrapper.parentElement : null;
|
|
91
|
+
|
|
92
|
+
menuButtons.forEach((menuButton) => {
|
|
93
|
+
menuButton.setAttribute('aria-expanded', 'false');
|
|
94
|
+
menuButton.setAttribute('aria-controls', menuId);
|
|
95
|
+
menuButton.setAttribute('aria-label', 'Open navigation menu');
|
|
96
|
+
|
|
97
|
+
let focusTrapHandler = null;
|
|
98
|
+
let shouldAutoFocus = false; // Track if menu should autofocus (keyboard activation)
|
|
99
|
+
|
|
100
|
+
// Check if menu is open by looking for is-active class on button
|
|
101
|
+
function isMenuOpen() {
|
|
102
|
+
return menuButton.classList.contains(globalConfig.classes.active);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Update ARIA states and menu behavior based on current visual state
|
|
106
|
+
function updateMenuState() {
|
|
107
|
+
const isOpen = isMenuOpen();
|
|
108
|
+
const wasOpen = menuButton.getAttribute('aria-expanded') === 'true';
|
|
109
|
+
|
|
110
|
+
if (isOpen && !wasOpen) {
|
|
111
|
+
// Opening
|
|
112
|
+
openModal();
|
|
113
|
+
|
|
114
|
+
// Add is-active to menu wrapper and enable interaction
|
|
115
|
+
menu.classList.add(globalConfig.classes.active);
|
|
116
|
+
menu.inert = false; // Enable interaction
|
|
117
|
+
|
|
118
|
+
// Add class to navbar wrapper for styling (e.g., backdrop blur)
|
|
119
|
+
if (navbarWrapper) {
|
|
120
|
+
navbarWrapper.classList.add('hs-menu-open');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
menuButtons.forEach((btn) => {
|
|
124
|
+
btn.setAttribute('aria-expanded', 'true');
|
|
125
|
+
btn.setAttribute('aria-label', 'Close navigation menu');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Animate height if configured
|
|
129
|
+
if (animationTarget) {
|
|
130
|
+
animateHeight(animationTarget, true, { duration: 300, ease: 'power2.inOut' });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Create focus trap for navbar
|
|
134
|
+
focusTrapHandler = createFocusTrap(menuButton);
|
|
135
|
+
|
|
136
|
+
// Only autofocus for keyboard activation
|
|
137
|
+
if (shouldAutoFocus) {
|
|
138
|
+
setTimeout(() => {
|
|
139
|
+
const menuClickableSelector = getSelector(globalConfig.clickable, 'button');
|
|
140
|
+
const allElements = Array.from(menu.querySelectorAll(menuClickableSelector));
|
|
141
|
+
|
|
142
|
+
if (shouldAutoFocus === 'last') {
|
|
143
|
+
// Focus last item (ArrowUp)
|
|
144
|
+
const lastElement = allElements[allElements.length - 1];
|
|
145
|
+
if (lastElement) {
|
|
146
|
+
lastElement.focus();
|
|
147
|
+
}
|
|
148
|
+
} else {
|
|
149
|
+
// Focus first item (Space/Enter/ArrowDown)
|
|
150
|
+
const firstElement = allElements[0];
|
|
151
|
+
if (firstElement) {
|
|
152
|
+
firstElement.focus();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}, 100);
|
|
156
|
+
shouldAutoFocus = false; // Reset flag
|
|
157
|
+
}
|
|
158
|
+
} else if (!isOpen && wasOpen) {
|
|
159
|
+
// Closing - focus button BEFORE updating ARIA
|
|
160
|
+
if (menu.contains(document.activeElement)) {
|
|
161
|
+
menuButton.focus();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Now update ARIA and visual state
|
|
165
|
+
closeModal();
|
|
166
|
+
|
|
167
|
+
// Remove is-active from menu wrapper and disable interaction
|
|
168
|
+
menu.classList.remove(globalConfig.classes.active);
|
|
169
|
+
menu.inert = true; // Disable interaction
|
|
170
|
+
|
|
171
|
+
// Remove class from navbar wrapper
|
|
172
|
+
if (navbarWrapper) {
|
|
173
|
+
navbarWrapper.classList.remove('hs-menu-open');
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
menuButtons.forEach((btn) => {
|
|
177
|
+
btn.setAttribute('aria-expanded', 'false');
|
|
178
|
+
btn.setAttribute('aria-label', 'Open navigation menu');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Animate height if configured
|
|
182
|
+
if (animationTarget) {
|
|
183
|
+
animateHeight(animationTarget, false, { duration: 300, ease: 'power2.inOut' });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Remove focus trap
|
|
187
|
+
removeFocusTrap(focusTrapHandler);
|
|
188
|
+
focusTrapHandler = null;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Set initial height without animation (if configured)
|
|
193
|
+
if (animationTarget) {
|
|
194
|
+
setHeight(animationTarget, isMenuOpen());
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Set initial ARIA states
|
|
198
|
+
updateMenuState();
|
|
199
|
+
|
|
200
|
+
// Monitor for class changes on button and update menu state
|
|
201
|
+
const observer = new MutationObserver(() => {
|
|
202
|
+
updateMenuState();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
observer.observe(menuButton, {
|
|
206
|
+
attributes: true,
|
|
207
|
+
attributeFilter: ['class'],
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
addObserver(observer);
|
|
211
|
+
|
|
212
|
+
// Click handler
|
|
213
|
+
const clickHandler = function (e) {
|
|
214
|
+
e.preventDefault();
|
|
215
|
+
menuButton.classList.toggle('is-active');
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
addHandler(menuButton, 'click', clickHandler);
|
|
219
|
+
|
|
220
|
+
// Keyboard navigation (dialog/modal pattern)
|
|
221
|
+
const keydownHandler = function (e) {
|
|
222
|
+
if (e.key === ' ' || e.key === 'Enter') {
|
|
223
|
+
e.preventDefault();
|
|
224
|
+
shouldAutoFocus = true; // Set flag for keyboard activation
|
|
225
|
+
menuButton.classList.toggle(globalConfig.classes.active);
|
|
226
|
+
} else if (e.key === 'ArrowDown') {
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
if (!isMenuOpen()) {
|
|
229
|
+
shouldAutoFocus = true; // Open and focus first item
|
|
230
|
+
menuButton.classList.add(globalConfig.classes.active);
|
|
231
|
+
}
|
|
232
|
+
} else if (e.key === 'ArrowUp') {
|
|
233
|
+
e.preventDefault();
|
|
234
|
+
if (!isMenuOpen()) {
|
|
235
|
+
shouldAutoFocus = 'last'; // Open and focus last item
|
|
236
|
+
menuButton.classList.add(globalConfig.classes.active);
|
|
237
|
+
}
|
|
238
|
+
} else if (e.key === 'Escape') {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
|
|
241
|
+
if (isMenuOpen()) {
|
|
242
|
+
menuButton.classList.remove(globalConfig.classes.active);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
addHandler(menuButton, 'keydown', keydownHandler);
|
|
248
|
+
|
|
249
|
+
// Store focus trap handler for cleanup
|
|
250
|
+
cleanup.state.focusTrapHandler = focusTrapHandler;
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function setupMenuDisplayObserver(addObserver) {
|
|
255
|
+
const menu = querySelector(config, 'wrapper');
|
|
256
|
+
if (!menu) return;
|
|
257
|
+
|
|
258
|
+
let previousState = null;
|
|
259
|
+
|
|
260
|
+
function handleStateChange() {
|
|
261
|
+
const computedStyle = window.getComputedStyle(menu);
|
|
262
|
+
const currentState = computedStyle.getPropertyValue(cssVariables.state).trim();
|
|
263
|
+
|
|
264
|
+
// Detect state change from active (1) to inactive (0)
|
|
265
|
+
if (
|
|
266
|
+
previousState === globalConfig.cssVars.state.values.active &&
|
|
267
|
+
currentState === globalConfig.cssVars.state.values.inactive
|
|
268
|
+
) {
|
|
269
|
+
// Get menu button to check if menu is open
|
|
270
|
+
const menuButton = document.querySelector('[data-hs-nav-menu="button"]');
|
|
271
|
+
const isMenuOpen = menuButton && menuButton.getAttribute('aria-expanded') === 'true';
|
|
272
|
+
|
|
273
|
+
// If menu is open, close it cleanly (triggers animation and proper cleanup)
|
|
274
|
+
if (isMenuOpen && menuButton) {
|
|
275
|
+
menuButton.classList.remove('is-active');
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
previousState = currentState;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const stateObserver = new ResizeObserver(handleStateChange);
|
|
283
|
+
stateObserver.observe(menu);
|
|
284
|
+
addObserver(stateObserver);
|
|
285
|
+
// Initial check
|
|
286
|
+
handleStateChange();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
setupMenu(addObserver, addHandler);
|
|
290
|
+
setupMenuDisplayObserver(addObserver);
|
|
291
|
+
|
|
292
|
+
return {
|
|
293
|
+
result: 'menu initialized',
|
|
294
|
+
destroy: () => {
|
|
295
|
+
// Clean up focus trap
|
|
296
|
+
if (cleanup.state.focusTrapHandler) {
|
|
297
|
+
document.removeEventListener('keydown', cleanup.state.focusTrapHandler);
|
|
298
|
+
cleanup.state.focusTrapHandler = null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Disconnect all observers
|
|
302
|
+
cleanup.observers.forEach((obs) => obs.disconnect());
|
|
303
|
+
cleanup.observers.length = 0;
|
|
304
|
+
|
|
305
|
+
// Remove all event listeners
|
|
306
|
+
cleanup.handlers.forEach(({ element, event, handler, options }) => {
|
|
307
|
+
element.removeEventListener(event, handler, options);
|
|
308
|
+
});
|
|
309
|
+
cleanup.handlers.length = 0;
|
|
310
|
+
|
|
311
|
+
// Remove body overflow class if present
|
|
312
|
+
document.body.classList.remove('u-overflow-hidden');
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navbar Orchestrator
|
|
3
|
+
* Manages navigation functions in parallel
|
|
4
|
+
*
|
|
5
|
+
* Uses static imports and passes config down to functions
|
|
6
|
+
* @version 2.0.0
|
|
7
|
+
*/
|
|
8
|
+
import { init as dropdownInit } from './functions/dropdown/dropdown.ts';
|
|
9
|
+
import { init as menuInit } from './functions/menu/menu.ts';
|
|
10
|
+
import { init as arrowNavigationInit } from './functions/arrow-navigation/arrow-navigation.ts';
|
|
11
|
+
|
|
12
|
+
export async function init(navbarConfig) {
|
|
13
|
+
const cleanup = { destroyFunctions: [] };
|
|
14
|
+
|
|
15
|
+
// Load all functions in parallel - use allSettled for resilient loading
|
|
16
|
+
const results = await Promise.allSettled([
|
|
17
|
+
dropdownInit(navbarConfig.dropdown),
|
|
18
|
+
menuInit(navbarConfig.menu, navbarConfig),
|
|
19
|
+
arrowNavigationInit(navbarConfig['arrow-navigation']),
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
// Collect destroy functions from successful inits
|
|
23
|
+
results.forEach((result) => {
|
|
24
|
+
if (result.status === 'fulfilled' && result.value?.destroy) {
|
|
25
|
+
cleanup.destroyFunctions.push(result.value.destroy);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// Log summary
|
|
30
|
+
const succeeded = results.filter((r) => r.status === 'fulfilled').length;
|
|
31
|
+
const failed = results.length - succeeded;
|
|
32
|
+
if (failed > 0) {
|
|
33
|
+
console.warn(
|
|
34
|
+
`[navbar] ${succeeded}/${results.length} functions loaded successfully. ${failed} failed but won't affect other functions.`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
result: 'navbar initialized',
|
|
40
|
+
destroy: () => {
|
|
41
|
+
cleanup.destroyFunctions.forEach((destroyFn) => {
|
|
42
|
+
try {
|
|
43
|
+
destroyFn();
|
|
44
|
+
} catch (error) {
|
|
45
|
+
console.error('[navbar] Error during cleanup:', error);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
cleanup.destroyFunctions.length = 0;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|