@hortonstudio/main 1.8.2 → 1.9.0

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.
@@ -1,18 +1,65 @@
1
1
  export const init = () => {
2
- initializeNavbar();
3
- return { result: "navbar initialized" };
4
- };
2
+ // Centralized cleanup tracking
3
+ const cleanup = {
4
+ observers: [],
5
+ handlers: [],
6
+ state: {
7
+ focusTrapHandler: null,
8
+ dropdownKeydownHandler: null,
9
+ menuKeydownHandler: null
10
+ }
11
+ };
5
12
 
6
- function initializeNavbar() {
7
- setupDynamicDropdowns();
8
- setupMenuButton();
9
- setupMenuARIA();
10
- setupMenuDisplayObserver();
11
- }
13
+ const addObserver = (observer) => cleanup.observers.push(observer);
14
+ const addHandler = (element, event, handler, options) => {
15
+ element.addEventListener(event, handler, options);
16
+ cleanup.handlers.push({ element, event, handler, options });
17
+ };
18
+
19
+ setupDynamicDropdowns(addObserver, addHandler);
20
+ setupMenuButton(addHandler, cleanup.state);
21
+ setupMenuARIA(addHandler);
22
+ setupMenuDisplayObserver(addObserver);
23
+
24
+ return {
25
+ result: "navbar initialized",
26
+ destroy: () => {
27
+ // Disconnect all observers
28
+ cleanup.observers.forEach(obs => obs.disconnect());
29
+ cleanup.observers.length = 0;
30
+
31
+ // Remove all event listeners
32
+ cleanup.handlers.forEach(({ element, event, handler, options }) => {
33
+ element.removeEventListener(event, handler, options);
34
+ });
35
+ cleanup.handlers.length = 0;
36
+
37
+ // Clean up focus trap
38
+ if (cleanup.state.focusTrapHandler) {
39
+ document.removeEventListener('keydown', cleanup.state.focusTrapHandler);
40
+ cleanup.state.focusTrapHandler = null;
41
+ }
42
+
43
+ // Clean up global document listeners
44
+ if (cleanup.state.dropdownKeydownHandler) {
45
+ document.removeEventListener('keydown', cleanup.state.dropdownKeydownHandler);
46
+ cleanup.state.dropdownKeydownHandler = null;
47
+ }
48
+
49
+ if (cleanup.state.menuKeydownHandler) {
50
+ document.removeEventListener('keydown', cleanup.state.menuKeydownHandler);
51
+ cleanup.state.menuKeydownHandler = null;
52
+ }
53
+
54
+ // Remove body overflow class if present
55
+ document.body.classList.remove("u-overflow-hidden");
56
+ }
57
+ };
58
+ };
12
59
 
13
60
 
14
61
  // Desktop dropdown system
15
- function setupDynamicDropdowns() {
62
+ function setupDynamicDropdowns(addObserver, addHandler) {
16
63
  const dropdownWrappers = document.querySelectorAll(
17
64
  '[data-hs-nav="dropdown"]',
18
65
  );
@@ -53,7 +100,7 @@ function setupDynamicDropdowns() {
53
100
  menuItems.forEach((item, index) => {
54
101
  item.setAttribute("role", "menuitem");
55
102
  item.setAttribute("tabindex", "-1");
56
-
103
+
57
104
  // Add context for first item to help screen readers understand dropdown content
58
105
  if (index === 0) {
59
106
  const toggleText = toggle.textContent?.trim() || "menu";
@@ -78,12 +125,12 @@ function setupDynamicDropdowns() {
78
125
  function updateARIAStates() {
79
126
  const isOpen = isDropdownOpen();
80
127
  const wasOpen = toggle.getAttribute("aria-expanded") === "true";
81
-
128
+
82
129
  // If dropdown is closing (was open, now closed), focus the toggle first
83
130
  if (wasOpen && !isOpen && dropdownList.contains(document.activeElement)) {
84
131
  toggle.focus();
85
132
  }
86
-
133
+
87
134
  toggle.setAttribute("aria-expanded", isOpen ? "true" : "false");
88
135
  dropdownList.setAttribute("aria-hidden", isOpen ? "false" : "true");
89
136
  menuItems.forEach((item) => {
@@ -96,27 +143,24 @@ function setupDynamicDropdowns() {
96
143
  }
97
144
 
98
145
  // Monitor for class changes and update ARIA states
99
- function monitorDropdownState() {
100
- const observer = new MutationObserver(() => {
101
- updateARIAStates();
102
- });
103
-
104
- observer.observe(wrapper, {
105
- attributes: true,
106
- attributeFilter: ['class']
107
- });
108
- }
109
-
110
- // Initialize monitoring
111
- monitorDropdownState();
146
+ const observer = new MutationObserver(() => {
147
+ updateARIAStates();
148
+ });
149
+
150
+ observer.observe(wrapper, {
151
+ attributes: true,
152
+ attributeFilter: ['class']
153
+ });
154
+
155
+ addObserver(observer);
112
156
 
113
157
  // Hover interactions now handled entirely by native Webflow
114
158
 
115
159
  // Add keyboard interactions that trigger programmatic mouse events
116
- toggle.addEventListener("keydown", function (e) {
160
+ const toggleKeydownHandler = function (e) {
117
161
  if (e.key === "ArrowDown") {
118
162
  e.preventDefault();
119
-
163
+
120
164
  if (!isDropdownOpen()) {
121
165
  // Trigger programmatic mouseenter to open dropdown
122
166
  const mouseEnterEvent = new MouseEvent("mouseenter", {
@@ -126,7 +170,7 @@ function setupDynamicDropdowns() {
126
170
  relatedTarget: null
127
171
  });
128
172
  wrapper.dispatchEvent(mouseEnterEvent);
129
-
173
+
130
174
  // Focus first menu item after a brief delay
131
175
  if (menuItems.length > 0) {
132
176
  setTimeout(() => {
@@ -137,7 +181,7 @@ function setupDynamicDropdowns() {
137
181
  }
138
182
  } else if (e.key === " ") {
139
183
  e.preventDefault();
140
-
184
+
141
185
  if (!isDropdownOpen()) {
142
186
  // Trigger programmatic mouseenter to open dropdown
143
187
  const mouseEnterEvent = new MouseEvent("mouseenter", {
@@ -147,7 +191,7 @@ function setupDynamicDropdowns() {
147
191
  relatedTarget: null
148
192
  });
149
193
  wrapper.dispatchEvent(mouseEnterEvent);
150
-
194
+
151
195
  // Focus first menu item after a brief delay
152
196
  if (menuItems.length > 0) {
153
197
  setTimeout(() => {
@@ -164,7 +208,7 @@ function setupDynamicDropdowns() {
164
208
  relatedTarget: document.body
165
209
  });
166
210
  wrapper.dispatchEvent(mouseOutEvent);
167
-
211
+
168
212
  const mouseLeaveEvent = new MouseEvent("mouseleave", {
169
213
  bubbles: false,
170
214
  cancelable: false,
@@ -175,7 +219,7 @@ function setupDynamicDropdowns() {
175
219
  }
176
220
  } else if (e.key === "ArrowUp") {
177
221
  e.preventDefault();
178
-
222
+
179
223
  if (!isDropdownOpen()) {
180
224
  // Trigger programmatic mouseenter to open dropdown
181
225
  const mouseEnterEvent = new MouseEvent("mouseenter", {
@@ -185,7 +229,7 @@ function setupDynamicDropdowns() {
185
229
  relatedTarget: null
186
230
  });
187
231
  wrapper.dispatchEvent(mouseEnterEvent);
188
-
232
+
189
233
  // Focus last menu item after a brief delay
190
234
  if (menuItems.length > 0) {
191
235
  setTimeout(() => {
@@ -196,7 +240,7 @@ function setupDynamicDropdowns() {
196
240
  }
197
241
  } else if (e.key === "Escape") {
198
242
  e.preventDefault();
199
-
243
+
200
244
  if (isDropdownOpen()) {
201
245
  // Trigger mouse leave to close dropdown
202
246
  const mouseOutEvent = new MouseEvent("mouseout", {
@@ -206,7 +250,7 @@ function setupDynamicDropdowns() {
206
250
  relatedTarget: document.body
207
251
  });
208
252
  wrapper.dispatchEvent(mouseOutEvent);
209
-
253
+
210
254
  const mouseLeaveEvent = new MouseEvent("mouseleave", {
211
255
  bubbles: false,
212
256
  cancelable: false,
@@ -216,10 +260,12 @@ function setupDynamicDropdowns() {
216
260
  wrapper.dispatchEvent(mouseLeaveEvent);
217
261
  }
218
262
  }
219
- });
263
+ };
264
+
265
+ addHandler(toggle, "keydown", toggleKeydownHandler);
220
266
 
221
267
  // Handle navigation within open dropdown
222
- document.addEventListener("keydown", function (e) {
268
+ const dropdownKeydownHandler = function (e) {
223
269
  if (!isDropdownOpen()) return;
224
270
  if (!wrapper.contains(document.activeElement)) return;
225
271
 
@@ -231,10 +277,7 @@ function setupDynamicDropdowns() {
231
277
  }
232
278
  } else if (e.key === "ArrowUp") {
233
279
  e.preventDefault();
234
- if (currentMenuItemIndex > 0) {
235
- currentMenuItemIndex--;
236
- menuItems[currentMenuItemIndex].focus();
237
- } else if (currentMenuItemIndex === 0) {
280
+ if (currentMenuItemIndex === 0) {
238
281
  // On first item, trigger mouse leave to close dropdown
239
282
  const mouseOutEvent = new MouseEvent("mouseout", {
240
283
  bubbles: true,
@@ -243,7 +286,7 @@ function setupDynamicDropdowns() {
243
286
  relatedTarget: document.body
244
287
  });
245
288
  wrapper.dispatchEvent(mouseOutEvent);
246
-
289
+
247
290
  const mouseLeaveEvent = new MouseEvent("mouseleave", {
248
291
  bubbles: false,
249
292
  cancelable: false,
@@ -251,10 +294,13 @@ function setupDynamicDropdowns() {
251
294
  relatedTarget: document.body
252
295
  });
253
296
  wrapper.dispatchEvent(mouseLeaveEvent);
254
-
297
+
255
298
  // Focus back to toggle
256
299
  toggle.focus();
257
300
  currentMenuItemIndex = -1;
301
+ } else if (currentMenuItemIndex > 0) {
302
+ currentMenuItemIndex--;
303
+ menuItems[currentMenuItemIndex].focus();
258
304
  } else {
259
305
  // Go back to toggle
260
306
  toggle.focus();
@@ -262,7 +308,7 @@ function setupDynamicDropdowns() {
262
308
  }
263
309
  } else if (e.key === "Escape") {
264
310
  e.preventDefault();
265
-
311
+
266
312
  // Trigger mouse leave to close dropdown
267
313
  const mouseOutEvent = new MouseEvent("mouseout", {
268
314
  bubbles: true,
@@ -271,7 +317,7 @@ function setupDynamicDropdowns() {
271
317
  relatedTarget: document.body
272
318
  });
273
319
  wrapper.dispatchEvent(mouseOutEvent);
274
-
320
+
275
321
  const mouseLeaveEvent = new MouseEvent("mouseleave", {
276
322
  bubbles: false,
277
323
  cancelable: false,
@@ -280,7 +326,9 @@ function setupDynamicDropdowns() {
280
326
  });
281
327
  wrapper.dispatchEvent(mouseLeaveEvent);
282
328
  }
283
- });
329
+ };
330
+
331
+ addHandler(document, "keydown", dropdownKeydownHandler);
284
332
 
285
333
  allDropdowns.push({
286
334
  wrapper,
@@ -294,20 +342,22 @@ function setupDynamicDropdowns() {
294
342
  });
295
343
  });
296
344
 
297
- document.addEventListener("focusin", function (e) {
345
+ const focusinHandler = function (e) {
298
346
  allDropdowns.forEach((dropdown) => {
299
347
  if (dropdown.isOpen() && !dropdown.wrapper.contains(e.target)) {
300
348
  dropdown.closeDropdown();
301
349
  }
302
350
  });
303
- });
351
+ };
304
352
 
305
- addDesktopArrowNavigation();
353
+ addHandler(document, "focusin", focusinHandler);
354
+
355
+ addDesktopArrowNavigation(addHandler);
306
356
  }
307
357
 
308
358
  // Desktop left/right arrow navigation
309
- function addDesktopArrowNavigation() {
310
- document.addEventListener("keydown", function (e) {
359
+ function addDesktopArrowNavigation(addHandler) {
360
+ const keydownHandler = function (e) {
311
361
  if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
312
362
 
313
363
  const menu = document.querySelector('[data-hs-nav="menu"]');
@@ -376,11 +426,13 @@ function addDesktopArrowNavigation() {
376
426
  focusableElements[nextIndex].focus();
377
427
  }
378
428
  }
379
- });
429
+ };
430
+
431
+ addHandler(document, "keydown", keydownHandler);
380
432
  }
381
433
 
382
434
  // Menu button system with modal-like functionality
383
- function setupMenuButton() {
435
+ function setupMenuButton(addHandler, cleanupState) {
384
436
  const menuButtons = document.querySelectorAll('[data-hs-nav="menubtn"]');
385
437
  const menu = document.querySelector('[data-hs-nav="menu"]');
386
438
 
@@ -393,7 +445,6 @@ function setupMenuButton() {
393
445
  menu.setAttribute("aria-modal", "true");
394
446
 
395
447
  let isMenuOpen = false;
396
- let focusTrapHandler = null;
397
448
 
398
449
  function shouldPreventMenu() {
399
450
  const menuHideElement = document.querySelector(".menu_hide");
@@ -408,7 +459,7 @@ function setupMenuButton() {
408
459
 
409
460
  if (!navbarWrapper) return;
410
461
 
411
- focusTrapHandler = (e) => {
462
+ const focusTrapHandler = (e) => {
412
463
  if (e.key === 'Tab') {
413
464
  const focusableElements = navbarWrapper.querySelectorAll(
414
465
  'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
@@ -434,12 +485,13 @@ function setupMenuButton() {
434
485
  };
435
486
 
436
487
  document.addEventListener('keydown', focusTrapHandler);
488
+ cleanupState.focusTrapHandler = focusTrapHandler;
437
489
  }
438
490
 
439
491
  function removeFocusTrap() {
440
- if (focusTrapHandler) {
441
- document.removeEventListener('keydown', focusTrapHandler);
442
- focusTrapHandler = null;
492
+ if (cleanupState.focusTrapHandler) {
493
+ document.removeEventListener('keydown', cleanupState.focusTrapHandler);
494
+ cleanupState.focusTrapHandler = null;
443
495
  }
444
496
  }
445
497
 
@@ -487,7 +539,7 @@ function setupMenuButton() {
487
539
  if (activeMenuButton) {
488
540
  activeMenuButton.focus();
489
541
  }
490
-
542
+
491
543
  menuButtons.forEach(btn => {
492
544
  btn.setAttribute("aria-expanded", "false");
493
545
  btn.setAttribute("aria-label", "Open navigation menu");
@@ -506,7 +558,7 @@ function setupMenuButton() {
506
558
 
507
559
  function toggleMenu() {
508
560
  if (shouldPreventMenu()) return;
509
-
561
+
510
562
  if (isMenuOpen) {
511
563
  closeMenu();
512
564
  } else {
@@ -519,7 +571,7 @@ function setupMenuButton() {
519
571
  menuButton.setAttribute("aria-controls", menuId);
520
572
  menuButton.setAttribute("aria-label", "Open navigation menu");
521
573
 
522
- menuButton.addEventListener("keydown", function (e) {
574
+ const keydownHandler = function (e) {
523
575
  if (e.key === "Enter" || e.key === " ") {
524
576
  e.preventDefault();
525
577
  const config = menuButton.getAttribute("data-hs-config");
@@ -531,9 +583,11 @@ function setupMenuButton() {
531
583
  toggleMenu();
532
584
  }
533
585
  }
534
- });
586
+ };
587
+
588
+ addHandler(menuButton, "keydown", keydownHandler);
535
589
 
536
- menuButton.addEventListener("click", function (e) {
590
+ const clickHandler = function (e) {
537
591
  if (!e.isTrusted) return;
538
592
  const config = menuButton.getAttribute("data-hs-config");
539
593
  if (config === "close" && isMenuOpen) {
@@ -543,24 +597,25 @@ function setupMenuButton() {
543
597
  } else if (!config) {
544
598
  toggleMenu();
545
599
  }
546
- });
547
- });
600
+ };
548
601
 
602
+ addHandler(menuButton, "click", clickHandler);
603
+ });
549
604
  }
550
605
 
551
606
 
552
- function setupMenuDisplayObserver() {
607
+ function setupMenuDisplayObserver(addObserver) {
553
608
  function handleDisplayChange() {
554
609
  const menuHideElement = document.querySelector(".menu_hide");
555
610
  if (!menuHideElement) return;
556
-
611
+
557
612
  const computedStyle = window.getComputedStyle(menuHideElement);
558
613
  const isMenuVisible = computedStyle.display !== "none";
559
-
614
+
560
615
  // Get menu button to check if menu is open
561
616
  const menuButton = document.querySelector('[data-hs-nav="menubtn"]');
562
617
  const isMenuOpen = menuButton && menuButton.getAttribute("aria-expanded") === "true";
563
-
618
+
564
619
  const shouldShowModal = isMenuVisible && isMenuOpen;
565
620
 
566
621
  // Toggle modal effects only when menu is visible AND menu is open
@@ -571,6 +626,7 @@ function setupMenuDisplayObserver() {
571
626
  const menuHideElement = document.querySelector(".menu_hide");
572
627
  if (menuHideElement) {
573
628
  displayObserver.observe(menuHideElement);
629
+ addObserver(displayObserver);
574
630
  // Initial check
575
631
  handleDisplayChange();
576
632
  }
@@ -586,7 +642,7 @@ function sanitizeForID(text) {
586
642
  }
587
643
 
588
644
  // Menu ARIA setup
589
- function setupMenuARIA() {
645
+ function setupMenuARIA(addHandler) {
590
646
  const menuContainer = document.querySelector('[data-hs-nav="menu"]');
591
647
  if (!menuContainer) return;
592
648
 
@@ -610,13 +666,14 @@ function setupMenuARIA() {
610
666
  dropdownList.id = listId;
611
667
  dropdownList.setAttribute("aria-hidden", "true");
612
668
 
613
- button.addEventListener("click", function () {
669
+ const clickHandler = function () {
614
670
  const isExpanded = button.getAttribute("aria-expanded") === "true";
615
671
  const newState = !isExpanded;
616
672
  button.setAttribute("aria-expanded", newState);
617
673
  dropdownList.setAttribute("aria-hidden", !newState);
674
+ };
618
675
 
619
- });
676
+ addHandler(button, "click", clickHandler);
620
677
  }
621
678
  });
622
679
 
@@ -627,11 +684,11 @@ function setupMenuARIA() {
627
684
  link.id = linkId;
628
685
  });
629
686
 
630
- setupMenuArrowNavigation(menuContainer);
687
+ setupMenuArrowNavigation(menuContainer, addHandler);
631
688
  }
632
689
 
633
690
  // Menu arrow navigation
634
- function setupMenuArrowNavigation(menuContainer) {
691
+ function setupMenuArrowNavigation(menuContainer, addHandler) {
635
692
  function getFocusableElements() {
636
693
  const allElements = menuContainer.querySelectorAll("button, a");
637
694
  return Array.from(allElements).filter((el) => {
@@ -649,7 +706,7 @@ function setupMenuArrowNavigation(menuContainer) {
649
706
 
650
707
  let currentFocusIndex = -1;
651
708
 
652
- menuContainer.addEventListener("keydown", function (e) {
709
+ const keydownHandler = function (e) {
653
710
  const focusableElements = getFocusableElements();
654
711
  if (focusableElements.length === 0) return;
655
712
 
@@ -713,5 +770,7 @@ function setupMenuArrowNavigation(menuContainer) {
713
770
  menuButton.focus();
714
771
  }
715
772
  }
716
- });
773
+ };
774
+
775
+ addHandler(menuContainer, "keydown", keydownHandler);
717
776
  }
@@ -2,6 +2,11 @@ const API_NAME = "hsmain";
2
2
 
3
3
  export async function init() {
4
4
  const api = window[API_NAME];
5
+
6
+ // Store event handlers for cleanup
7
+ let clickHandler = null;
8
+ let keydownHandler = null;
9
+
5
10
  api.afterWebflowReady(() => {
6
11
  if (typeof $ !== "undefined") {
7
12
  $(document).off("click.wf-scroll");
@@ -52,16 +57,6 @@ export async function init() {
52
57
  });
53
58
  }
54
59
 
55
- // Handle anchor link clicks and keyboard activation
56
- function handleAnchorClicks() {
57
- document.addEventListener("click", handleAnchorActivation);
58
- document.addEventListener("keydown", function (e) {
59
- if (e.key === "Enter" || e.key === " ") {
60
- handleAnchorActivation(e);
61
- }
62
- });
63
- }
64
-
65
60
  function handleAnchorActivation(e) {
66
61
  const link = e.target.closest('a[href^="#"]');
67
62
  if (!link) return;
@@ -82,6 +77,19 @@ export async function init() {
82
77
  }
83
78
  }
84
79
 
80
+ // Handle anchor link clicks and keyboard activation
81
+ function handleAnchorClicks() {
82
+ clickHandler = handleAnchorActivation;
83
+ keydownHandler = function (e) {
84
+ if (e.key === "Enter" || e.key === " ") {
85
+ handleAnchorActivation(e);
86
+ }
87
+ };
88
+
89
+ document.addEventListener("click", clickHandler);
90
+ document.addEventListener("keydown", keydownHandler);
91
+ }
92
+
85
93
  // Initialize anchor link handling
86
94
  handleAnchorClicks();
87
95
 
@@ -93,5 +101,20 @@ export async function init() {
93
101
 
94
102
  return {
95
103
  result: "autoInit-smooth-scroll initialized",
104
+ destroy: () => {
105
+ // Remove event listeners
106
+ if (clickHandler) {
107
+ document.removeEventListener("click", clickHandler);
108
+ clickHandler = null;
109
+ }
110
+ if (keydownHandler) {
111
+ document.removeEventListener("keydown", keydownHandler);
112
+ keydownHandler = null;
113
+ }
114
+
115
+ // Re-enable CSS smooth scrolling
116
+ document.documentElement.style.scrollBehavior = "";
117
+ document.body.style.scrollBehavior = "";
118
+ }
96
119
  };
97
120
  }