@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.
Files changed (124) hide show
  1. package/.prettierrc +8 -0
  2. package/README.md +146 -0
  3. package/eslint.config.js +32 -0
  4. package/index.ts +275 -0
  5. package/package.json +19 -2
  6. package/public/bootstrap.js +16 -0
  7. package/src/animations/animations.ts +93 -0
  8. package/src/animations/functions/counter/counter.ts +137 -0
  9. package/src/config.json +570 -0
  10. package/src/config.ts +105 -0
  11. package/src/modules/default/README.md +167 -0
  12. package/src/modules/default/default.ts +71 -0
  13. package/{autoInit → src/modules/default/functions}/accessibility/README.md +44 -12
  14. package/src/modules/default/functions/accessibility/accessibility.ts +54 -0
  15. package/src/modules/default/functions/accordion/README.md +451 -0
  16. package/src/modules/default/functions/accordion/accordion.ts +189 -0
  17. package/src/modules/default/functions/comparison/comparison.ts +424 -0
  18. package/src/modules/default/functions/marquee/marquee.ts +206 -0
  19. package/src/modules/default/functions/navbar/README.md +393 -0
  20. package/src/modules/default/functions/navbar/functions/arrow-navigation/arrow-navigation.ts +183 -0
  21. package/src/modules/default/functions/navbar/functions/dropdown/dropdown.ts +313 -0
  22. package/src/modules/default/functions/navbar/functions/menu/menu.ts +315 -0
  23. package/src/modules/default/functions/navbar/navbar.ts +51 -0
  24. package/{autoInit → src/modules/default/functions}/smooth-scroll/README.md +45 -14
  25. package/{autoInit/smooth-scroll/smooth-scroll.js → src/modules/default/functions/smooth-scroll/smooth-scroll.ts} +33 -38
  26. package/{autoInit → src/modules/default/functions}/transition/README.md +59 -32
  27. package/src/modules/default/functions/transition/transition.ts +290 -0
  28. package/src/modules/normalize/README.md +172 -0
  29. package/src/modules/normalize/functions/clickable/README.md +84 -0
  30. package/src/modules/normalize/functions/clickable/clickable.ts +43 -0
  31. package/src/modules/normalize/functions/clickable/functions/normalize/README.md +213 -0
  32. package/src/modules/normalize/functions/clickable/functions/normalize/normalize.ts +68 -0
  33. package/src/modules/normalize/functions/dupe/README.md +405 -0
  34. package/src/modules/normalize/functions/dupe/dupe.ts +197 -0
  35. package/src/modules/normalize/functions/sync/sync.ts +378 -0
  36. package/src/modules/normalize/normalize.ts +58 -0
  37. package/src/modules/structure/README.md +190 -0
  38. package/src/modules/structure/functions/form/README.md +94 -0
  39. package/src/modules/structure/functions/form/form.ts +54 -0
  40. package/src/modules/structure/functions/form/functions/honeypot/README.md +77 -0
  41. package/src/modules/structure/functions/form/functions/honeypot/honeypot.ts +37 -0
  42. package/src/modules/structure/functions/form/functions/range/README.md +410 -0
  43. package/src/modules/structure/functions/form/functions/range/range.ts +92 -0
  44. package/src/modules/structure/functions/form/functions/select/README.md +393 -0
  45. package/src/modules/structure/functions/form/functions/select/functions/custom-select/custom-select.ts +637 -0
  46. package/src/modules/structure/functions/form/functions/select/functions/states/states.ts +118 -0
  47. package/src/modules/structure/functions/form/functions/select/select.ts +48 -0
  48. package/src/modules/structure/functions/form/functions/test/test.ts +132 -0
  49. package/src/modules/structure/functions/pagination/README.md +527 -0
  50. package/src/modules/structure/functions/pagination/pagination.ts +493 -0
  51. package/src/modules/structure/functions/site-settings/README.md +395 -0
  52. package/src/modules/structure/functions/site-settings/site-settings.ts +158 -0
  53. package/{autoInit/accessibility → src/modules/structure}/functions/toc/README.md +18 -15
  54. package/{autoInit/accessibility/functions/toc/toc.js → src/modules/structure/functions/toc/functions/heading-links/heading-links.ts} +43 -63
  55. package/src/modules/structure/functions/toc/functions/progress-bar/progress-bar.ts +101 -0
  56. package/src/modules/structure/functions/toc/toc.ts +35 -0
  57. package/{autoInit/accessibility → src/modules/structure}/functions/year-replacement/README.md +7 -6
  58. package/src/modules/structure/functions/year-replacement/year-replacement.ts +59 -0
  59. package/src/modules/structure/structure.ts +59 -0
  60. package/src/utils/attributeSelector.ts +78 -0
  61. package/src/utils/cssVariables.ts +24 -0
  62. package/src/utils/gsap.ts +198 -0
  63. package/src/utils/heightAnimator.ts +130 -0
  64. package/src/utils/modalManager.ts +150 -0
  65. package/src/utils.ts +54 -0
  66. package/tsconfig.json +24 -0
  67. package/vite.config.js +45 -0
  68. package/.claude/settings.local.json +0 -70
  69. package/archive/hero.js +0 -794
  70. package/archive/modal.js +0 -80
  71. package/archive/text.js +0 -628
  72. package/autoInit/accessibility/accessibility.js +0 -53
  73. package/autoInit/accessibility/functions/blog-remover/README.md +0 -61
  74. package/autoInit/accessibility/functions/blog-remover/blog-remover.js +0 -31
  75. package/autoInit/accessibility/functions/click-forwarding/README.md +0 -60
  76. package/autoInit/accessibility/functions/click-forwarding/click-forwarding.js +0 -82
  77. package/autoInit/accessibility/functions/dropdown/README.md +0 -212
  78. package/autoInit/accessibility/functions/dropdown/dropdown.js +0 -167
  79. package/autoInit/accessibility/functions/list-accessibility/README.md +0 -56
  80. package/autoInit/accessibility/functions/list-accessibility/list-accessibility.js +0 -23
  81. package/autoInit/accessibility/functions/pagination/README.md +0 -428
  82. package/autoInit/accessibility/functions/pagination/pagination.js +0 -359
  83. package/autoInit/accessibility/functions/text-synchronization/README.md +0 -62
  84. package/autoInit/accessibility/functions/text-synchronization/text-synchronization.js +0 -101
  85. package/autoInit/accessibility/functions/year-replacement/year-replacement.js +0 -43
  86. package/autoInit/button/README.md +0 -122
  87. package/autoInit/button/button.js +0 -51
  88. package/autoInit/counter/README.md +0 -274
  89. package/autoInit/counter/counter.js +0 -185
  90. package/autoInit/form/README.md +0 -338
  91. package/autoInit/form/form.js +0 -374
  92. package/autoInit/navbar/README.md +0 -366
  93. package/autoInit/navbar/navbar.js +0 -786
  94. package/autoInit/site-settings/README.md +0 -218
  95. package/autoInit/site-settings/site-settings.js +0 -134
  96. package/autoInit/transition/transition.js +0 -116
  97. package/index.js +0 -305
  98. package/utils/before-after/README.md +0 -520
  99. package/utils/before-after/before-after.js +0 -653
  100. package/utils/css-animations/buttons/main/bgbasic/btn-main-bgbasic.html +0 -10
  101. package/utils/css-animations/buttons/main/bgfill/btn-main-bgfill.html +0 -29
  102. package/utils/css-animations/buttons/navbar/bgbasic/navbar-main-bgbasic.html +0 -17
  103. package/utils/css-animations/buttons/navbar/bgbasic/navbar-menu-bgbasic.html +0 -16
  104. package/utils/css-animations/buttons/navbar/bgfill/navbar-main-bgfill.html +0 -46
  105. package/utils/css-animations/buttons/navbar/bgfill/navbar-menu-bgfill.html +0 -39
  106. package/utils/css-animations/buttons/navbar/color/navbar-announce-color.html +0 -5
  107. package/utils/css-animations/buttons/navbar/color/navbar-main-color.html +0 -7
  108. package/utils/css-animations/buttons/navbar/color/navbar-menu-color.html +0 -7
  109. package/utils/css-animations/buttons/navbar/double-slide/navbar-announce-double-slide.html +0 -40
  110. package/utils/css-animations/buttons/navbar/double-slide/navbar-main-double-slide.html +0 -77
  111. package/utils/css-animations/buttons/navbar/scale/navbar-announce-scale.html +0 -6
  112. package/utils/css-animations/buttons/navbar/scale/navbar-main-scale.html +0 -9
  113. package/utils/css-animations/buttons/navbar/scale/navbar-menu-scale.html +0 -8
  114. package/utils/css-animations/buttons/navbar/underline/navbar-announce-underline.html +0 -32
  115. package/utils/css-animations/buttons/navbar/underline/navbar-main-underline.html +0 -56
  116. package/utils/css-animations/buttons/text/color/text-footer-color.html +0 -5
  117. package/utils/css-animations/buttons/text/color/text-main-color.html +0 -5
  118. package/utils/css-animations/buttons/text/double-slide/text-main-double-slide.html +0 -56
  119. package/utils/css-animations/buttons/text/scale/text-footer-scale.html +0 -6
  120. package/utils/css-animations/buttons/text/scale/text-main-scale.html +0 -6
  121. package/utils/css-animations/buttons/text/underline/text-footer-underline.html +0 -45
  122. package/utils/css-animations/buttons/text/underline/text-main-underline.html +0 -58
  123. package/utils/css-animations/cards/card-clickable.html +0 -11
  124. 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
+ }