@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,29 @@
1
1
  export function init() {
2
-
2
+ // Centralized cleanup tracking
3
+ const cleanup = {
4
+ observers: [],
5
+ handlers: [],
6
+ scrollTimeout: null
7
+ };
8
+
9
+ const addObserver = (observer) => cleanup.observers.push(observer);
10
+ const addHandler = (element, event, handler, options) => {
11
+ element.addEventListener(event, handler, options);
12
+ cleanup.handlers.push({ element, event, handler, options });
13
+ };
3
14
 
4
15
  function setupGeneralAccessibility() {
5
16
  setupListAccessibility();
6
17
  setupRemoveListAccessibility();
7
- setupFAQAccessibility();
18
+ setupFAQAccessibility(addHandler);
8
19
  setupConvertToSpan();
9
20
  setupYearReplacement();
10
- setupPreventDefault();
11
- setupRichTextAccessibility();
12
- setupSummaryAccessibility();
21
+ setupPreventDefault(addHandler);
22
+ setupRichTextAccessibility(addObserver, addHandler, cleanup);
23
+ setupSummaryAccessibility(addHandler);
13
24
  setupCustomValuesReplacement();
14
- setupClickForwarding();
15
- setupTextSynchronization();
25
+ setupClickForwarding(addHandler);
26
+ setupTextSynchronization(addObserver);
16
27
  }
17
28
 
18
29
  function setupListAccessibility() {
@@ -88,36 +99,35 @@ export function init() {
88
99
  });
89
100
  }
90
101
 
91
- function setupFAQAccessibility() {
102
+ function setupFAQAccessibility(addHandler) {
92
103
  const faqContainers = document.querySelectorAll('[data-hs-a11y="faq-wrap"]');
93
104
 
94
105
  faqContainers.forEach((container, index) => {
95
106
  const button = container.querySelector('[data-hs-a11y="faq-btn"]');
96
107
  const contentWrapper = container.querySelector('[data-hs-a11y="faq-content"]');
97
-
108
+
98
109
  if (!button || !contentWrapper) return;
99
-
110
+
100
111
  const buttonId = `faq-button-${index}`;
101
112
  const contentId = `faq-content-${index}`;
102
-
113
+
103
114
  button.setAttribute('id', buttonId);
104
115
  button.setAttribute('aria-expanded', 'false');
105
116
  button.setAttribute('aria-controls', contentId);
106
-
117
+
107
118
  contentWrapper.setAttribute('id', contentId);
108
119
  contentWrapper.setAttribute('aria-hidden', 'true');
109
120
  contentWrapper.setAttribute('role', 'region');
110
121
  contentWrapper.setAttribute('aria-labelledby', buttonId);
111
-
122
+
112
123
  function toggleFAQ() {
113
124
  const isOpen = button.getAttribute('aria-expanded') === 'true';
114
-
125
+
115
126
  button.setAttribute('aria-expanded', !isOpen);
116
127
  contentWrapper.setAttribute('aria-hidden', isOpen);
117
128
  }
118
-
119
- button.addEventListener('click', toggleFAQ);
120
-
129
+
130
+ addHandler(button, 'click', toggleFAQ);
121
131
  });
122
132
  }
123
133
 
@@ -216,26 +226,28 @@ export function init() {
216
226
  });
217
227
  }
218
228
 
219
- function setupPreventDefault() {
229
+ function setupPreventDefault(addHandler) {
220
230
  const elements = document.querySelectorAll('[data-hs-a11y="prevent-default"]');
221
-
231
+
222
232
  elements.forEach(element => {
223
233
  // Prevent click
224
- element.addEventListener('click', (e) => {
234
+ const clickHandler = (e) => {
225
235
  e.preventDefault();
226
236
  e.stopPropagation();
227
237
  return false;
228
- });
229
-
238
+ };
239
+ addHandler(element, 'click', clickHandler);
240
+
230
241
  // Prevent keyboard activation
231
- element.addEventListener('keydown', (e) => {
242
+ const keydownHandler = (e) => {
232
243
  if (e.key === 'Enter' || e.key === ' ') {
233
244
  e.preventDefault();
234
245
  e.stopPropagation();
235
246
  return false;
236
247
  }
237
- });
238
-
248
+ };
249
+ addHandler(element, 'keydown', keydownHandler);
250
+
239
251
  // Additional prevention for anchor links
240
252
  if (element.tagName.toLowerCase() === 'a') {
241
253
  // Remove or modify href to prevent scroll
@@ -247,27 +259,23 @@ export function init() {
247
259
  element.setAttribute('tabindex', '0');
248
260
  }
249
261
  }
250
-
251
262
  });
252
263
  }
253
264
 
254
- function setupSummaryAccessibility() {
265
+ function setupSummaryAccessibility(addHandler) {
255
266
  const summaryContainers = document.querySelectorAll('[data-hs-a11y="summary-wrap"]');
256
267
 
257
-
258
268
  summaryContainers.forEach((container, index) => {
259
-
260
269
  const button = container.querySelector('[data-hs-a11y="summary-btn"]');
261
270
  const contentWrapper = container.querySelector('[data-hs-a11y="summary-content"]');
262
-
271
+
263
272
  if (!button || !contentWrapper) {
264
273
  return;
265
274
  }
266
-
267
-
275
+
268
276
  const buttonId = `summary-button-${index}`;
269
277
  const contentId = `summary-content-${index}`;
270
-
278
+
271
279
  // Get original button text from first text node only
272
280
  const walker = document.createTreeWalker(
273
281
  button,
@@ -275,14 +283,14 @@ export function init() {
275
283
  null,
276
284
  false
277
285
  );
278
-
286
+
279
287
  let firstTextNode = walker.nextNode();
280
288
  while (firstTextNode && !firstTextNode.textContent.trim()) {
281
289
  firstTextNode = walker.nextNode();
282
290
  }
283
-
291
+
284
292
  const originalButtonText = firstTextNode ? firstTextNode.textContent.trim() : button.textContent.trim();
285
-
293
+
286
294
  // Function to update all text nodes in button
287
295
  function updateButtonText(newText) {
288
296
  // Find all text nodes and update them
@@ -292,7 +300,7 @@ export function init() {
292
300
  null,
293
301
  false
294
302
  );
295
-
303
+
296
304
  const textNodes = [];
297
305
  let node;
298
306
  while (node = walker.nextNode()) {
@@ -300,27 +308,27 @@ export function init() {
300
308
  textNodes.push(node);
301
309
  }
302
310
  }
303
-
311
+
304
312
  textNodes.forEach(textNode => {
305
313
  textNode.textContent = newText;
306
314
  });
307
315
  }
308
-
316
+
309
317
  button.setAttribute('id', buttonId);
310
318
  button.setAttribute('aria-expanded', 'false');
311
319
  button.setAttribute('aria-controls', contentId);
312
320
  button.setAttribute('aria-label', 'View Summary');
313
-
321
+
314
322
  contentWrapper.setAttribute('id', contentId);
315
323
  contentWrapper.setAttribute('aria-hidden', 'true');
316
324
  contentWrapper.setAttribute('role', 'region');
317
325
  contentWrapper.setAttribute('aria-labelledby', buttonId);
318
-
326
+
319
327
  // Summary is closed by default - no need to check initial state
320
-
328
+
321
329
  function toggleSummary() {
322
330
  const isOpen = button.getAttribute('aria-expanded') === 'true';
323
-
331
+
324
332
  if (isOpen) {
325
333
  // Closing
326
334
  button.setAttribute('aria-expanded', 'false');
@@ -334,11 +342,9 @@ export function init() {
334
342
  updateButtonText('Close');
335
343
  contentWrapper.setAttribute('aria-hidden', 'false');
336
344
  }
337
-
338
345
  }
339
-
340
- button.addEventListener('click', toggleSummary);
341
-
346
+
347
+ addHandler(button, 'click', toggleSummary);
342
348
  });
343
349
  }
344
350
 
@@ -434,54 +440,56 @@ export function init() {
434
440
  });
435
441
  }
436
442
 
437
- function setupClickForwarding() {
443
+ function setupClickForwarding(addHandler) {
438
444
  // Find all clickable elements (custom styled elements users click)
439
445
  const clickableElements = document.querySelectorAll('[data-hs-a11y*="clickable"]');
440
-
446
+
441
447
  clickableElements.forEach(clickableElement => {
442
448
  const attribute = clickableElement.getAttribute('data-hs-a11y');
443
-
449
+
444
450
  // Parse the attribute: "click-trigger-[identifier], clickable"
445
451
  const parts = attribute.split(',').map(part => part.trim());
446
-
452
+
447
453
  // Find the part with click-trigger and the part with clickable
448
454
  const triggerPart = parts.find(part => part.startsWith('click-trigger-'));
449
455
  const rolePart = parts.find(part => part === 'clickable');
450
-
456
+
451
457
  if (!triggerPart || !rolePart) {
452
458
  return;
453
459
  }
454
-
460
+
455
461
  // Extract identifier from "click-trigger-[identifier]"
456
462
  const identifier = triggerPart.replace('click-trigger-', '').trim();
457
-
463
+
458
464
  // Find the corresponding trigger element
459
465
  const triggerSelector = `[data-hs-a11y*="click-trigger-${identifier}"][data-hs-a11y*="trigger"]`;
460
466
  const triggerElement = document.querySelector(triggerSelector);
461
-
467
+
462
468
  if (!triggerElement) {
463
469
  return;
464
470
  }
465
-
471
+
466
472
  // Add click event listener to forward clicks
467
- clickableElement.addEventListener('click', (event) => {
473
+ const clickHandler = (event) => {
468
474
  // Prevent default behavior on the clickable element
469
475
  event.preventDefault();
470
476
  event.stopPropagation();
471
-
477
+
472
478
  // Trigger click on the target element
473
479
  triggerElement.click();
474
- });
475
-
480
+ };
481
+ addHandler(clickableElement, 'click', clickHandler);
482
+
476
483
  // Also handle keyboard events for accessibility
477
- clickableElement.addEventListener('keydown', (event) => {
484
+ const keydownHandler = (event) => {
478
485
  if (event.key === 'Enter' || event.key === ' ') {
479
486
  event.preventDefault();
480
487
  event.stopPropagation();
481
488
  triggerElement.click();
482
489
  }
483
- });
484
-
490
+ };
491
+ addHandler(clickableElement, 'keydown', keydownHandler);
492
+
485
493
  // Ensure clickable element is keyboard accessible
486
494
  if (!clickableElement.hasAttribute('tabindex')) {
487
495
  clickableElement.setAttribute('tabindex', '0');
@@ -492,44 +500,44 @@ export function init() {
492
500
  });
493
501
  }
494
502
 
495
- function setupTextSynchronization() {
503
+ function setupTextSynchronization(addObserver) {
496
504
  // Find all original elements (source of truth)
497
505
  const originalElements = document.querySelectorAll('[data-hs-a11y*="original"]');
498
-
506
+
499
507
  originalElements.forEach(originalElement => {
500
508
  const attribute = originalElement.getAttribute('data-hs-a11y');
501
-
509
+
502
510
  // Parse the attribute: "match-text-[identifier], original"
503
511
  const parts = attribute.split(',').map(part => part.trim());
504
-
512
+
505
513
  // Find the part with match-text and the part with original
506
514
  const textPart = parts.find(part => part.startsWith('match-text-'));
507
515
  const rolePart = parts.find(part => part === 'original');
508
-
516
+
509
517
  if (!textPart || !rolePart) {
510
518
  return;
511
519
  }
512
-
520
+
513
521
  // Extract identifier from "match-text-[identifier]"
514
522
  const identifier = textPart.replace('match-text-', '').trim();
515
-
523
+
516
524
  // Find all corresponding match elements
517
525
  const matchSelector = `[data-hs-a11y*="match-text-${identifier}"][data-hs-a11y*="match"]`;
518
526
  const matchElements = document.querySelectorAll(matchSelector);
519
-
527
+
520
528
  if (matchElements.length === 0) {
521
529
  return;
522
530
  }
523
-
531
+
524
532
  // Function to synchronize text and aria-label
525
533
  function synchronizeContent() {
526
534
  const originalText = originalElement.textContent;
527
535
  const originalAriaLabel = originalElement.getAttribute('aria-label');
528
-
536
+
529
537
  matchElements.forEach(matchElement => {
530
538
  // Copy text content
531
539
  matchElement.textContent = originalText;
532
-
540
+
533
541
  // Synchronize aria-label
534
542
  if (originalAriaLabel) {
535
543
  // If original has aria-label, copy it to match
@@ -542,27 +550,27 @@ export function init() {
542
550
  }
543
551
  });
544
552
  }
545
-
553
+
546
554
  // Initial synchronization
547
555
  synchronizeContent();
548
-
556
+
549
557
  // Set up MutationObserver to watch for changes
550
558
  const observer = new MutationObserver((mutations) => {
551
559
  let shouldSync = false;
552
-
560
+
553
561
  mutations.forEach((mutation) => {
554
- if (mutation.type === 'childList' ||
555
- mutation.type === 'characterData' ||
562
+ if (mutation.type === 'childList' ||
563
+ mutation.type === 'characterData' ||
556
564
  (mutation.type === 'attributes' && mutation.attributeName === 'aria-label')) {
557
565
  shouldSync = true;
558
566
  }
559
567
  });
560
-
568
+
561
569
  if (shouldSync) {
562
570
  synchronizeContent();
563
571
  }
564
572
  });
565
-
573
+
566
574
  // Observe text changes and attribute changes
567
575
  observer.observe(originalElement, {
568
576
  childList: true,
@@ -571,16 +579,16 @@ export function init() {
571
579
  attributes: true,
572
580
  attributeFilter: ['aria-label']
573
581
  });
582
+
583
+ addObserver(observer);
574
584
  });
575
585
  }
576
586
 
577
- function setupRichTextAccessibility() {
587
+ function setupRichTextAccessibility(addObserver, addHandler, cleanup) {
578
588
  const contentAreas = document.querySelectorAll('[data-hs-a11y="rich-content"]');
579
589
  const tocLists = document.querySelectorAll('[data-hs-a11y="rich-toc"]');
580
590
 
581
-
582
591
  contentAreas.forEach((contentArea) => {
583
-
584
592
  // Since there's only 1 content area and 1 TOC list per page, use the first TOC list
585
593
  const tocList = tocLists[0];
586
594
 
@@ -592,7 +600,6 @@ export function init() {
592
600
  return;
593
601
  }
594
602
 
595
-
596
603
  const template = tocList.children[0].cloneNode(true);
597
604
  // Remove is-active class from template if it exists
598
605
  const templateLink = template.querySelector("a");
@@ -643,7 +650,7 @@ export function init() {
643
650
  link.appendChild(document.createTextNode(heading.textContent));
644
651
 
645
652
  // Add click handler for smooth scrolling
646
- link.addEventListener("click", (e) => {
653
+ const clickHandler = (e) => {
647
654
  e.preventDefault();
648
655
 
649
656
  const targetSection = document.getElementById(sectionId);
@@ -654,7 +661,8 @@ export function init() {
654
661
  targetSection.focus();
655
662
  }, 100);
656
663
  }
657
- });
664
+ };
665
+ addHandler(link, "click", clickHandler);
658
666
 
659
667
  // Ensure sections are focusable for keyboard users but use CSS to control focus visibility
660
668
  const targetSection = document.getElementById(sectionId);
@@ -719,17 +727,38 @@ export function init() {
719
727
 
720
728
  // Observe all sections
721
729
  sections.forEach(section => observer.observe(section));
730
+ addObserver(observer);
722
731
 
723
732
  // Also update on scroll for smoother tracking
724
- let scrollTimeout;
725
- window.addEventListener('scroll', () => {
726
- if (scrollTimeout) clearTimeout(scrollTimeout);
727
- scrollTimeout = setTimeout(updateActiveLink, 50);
728
- });
733
+ const scrollHandler = () => {
734
+ if (cleanup.scrollTimeout) clearTimeout(cleanup.scrollTimeout);
735
+ cleanup.scrollTimeout = setTimeout(updateActiveLink, 50);
736
+ };
737
+ addHandler(window, 'scroll', scrollHandler);
729
738
 
730
739
  });
731
740
  }
732
741
 
733
742
  setupGeneralAccessibility();
734
- return { result: "accessibility initialized" };
743
+
744
+ return {
745
+ result: "accessibility initialized",
746
+ destroy: () => {
747
+ // Disconnect all observers
748
+ cleanup.observers.forEach(obs => obs.disconnect());
749
+ cleanup.observers.length = 0;
750
+
751
+ // Remove all event listeners
752
+ cleanup.handlers.forEach(({ element, event, handler, options }) => {
753
+ element.removeEventListener(event, handler, options);
754
+ });
755
+ cleanup.handlers.length = 0;
756
+
757
+ // Clear scroll timeout
758
+ if (cleanup.scrollTimeout) {
759
+ clearTimeout(cleanup.scrollTimeout);
760
+ cleanup.scrollTimeout = null;
761
+ }
762
+ }
763
+ };
735
764
  }
@@ -150,5 +150,21 @@ export function init() {
150
150
  counters,
151
151
  };
152
152
 
153
- return { result: "counter initialized" };
153
+ return {
154
+ result: "counter initialized",
155
+ destroy: () => {
156
+ // Disconnect observer
157
+ if (observer) {
158
+ observer.disconnect();
159
+ }
160
+
161
+ // Clear counters array
162
+ counters.length = 0;
163
+
164
+ // Remove window API
165
+ if (window.hsmain && window.hsmain.counter) {
166
+ delete window.hsmain.counter;
167
+ }
168
+ }
169
+ };
154
170
  }
package/autoInit/form.js CHANGED
@@ -1,6 +1,19 @@
1
1
  export function init() {
2
+ // Centralized cleanup tracking
3
+ const cleanup = {
4
+ observers: [],
5
+ handlers: [],
6
+ honeypotHandler: null
7
+ };
8
+
9
+ const addObserver = (observer) => cleanup.observers.push(observer);
10
+ const addHandler = (element, event, handler, options) => {
11
+ element.addEventListener(event, handler, options);
12
+ cleanup.handlers.push({ element, event, handler, options });
13
+ };
14
+
2
15
  // Honeypot spam prevention
3
- document.addEventListener('submit', (e) => {
16
+ const honeypotHandler = (e) => {
4
17
  const form = e.target;
5
18
  if (form.tagName !== 'FORM') return;
6
19
 
@@ -12,7 +25,10 @@ export function init() {
12
25
  e.stopImmediatePropagation();
13
26
  return false;
14
27
  }
15
- }, true);
28
+ };
29
+
30
+ cleanup.honeypotHandler = honeypotHandler;
31
+ document.addEventListener('submit', honeypotHandler, true);
16
32
 
17
33
  // Simple Custom Select Component for Webflow
18
34
  (function() {
@@ -32,12 +48,12 @@ export function init() {
32
48
  const selectWrappers = document.querySelectorAll('[data-hs-form="select"]');
33
49
 
34
50
  selectWrappers.forEach(wrapper => {
35
- initSingleSelect(wrapper);
51
+ initSingleSelect(wrapper, addHandler, addObserver);
36
52
  });
37
53
  }
38
54
 
39
55
  // Initialize a single custom select
40
- function initSingleSelect(wrapper) {
56
+ function initSingleSelect(wrapper, addHandler, addObserver) {
41
57
  // Find all required elements
42
58
  const realSelect = wrapper.querySelector('select');
43
59
  if (!realSelect) return;
@@ -165,7 +181,7 @@ export function init() {
165
181
  }
166
182
 
167
183
  // Button keyboard events
168
- button.addEventListener('keydown', (e) => {
184
+ const buttonKeydownHandler = (e) => {
169
185
  switch(e.key) {
170
186
  case ' ':
171
187
  case 'Enter':
@@ -197,10 +213,11 @@ export function init() {
197
213
  }
198
214
  break;
199
215
  }
200
- });
216
+ };
217
+ addHandler(button, 'keydown', buttonKeydownHandler);
201
218
 
202
219
  // Option keyboard events (delegated)
203
- customList.addEventListener('keydown', (e) => {
220
+ const listKeydownHandler = (e) => {
204
221
  const option = e.target.closest('[role="option"]');
205
222
  if (!option) return;
206
223
 
@@ -237,15 +254,17 @@ export function init() {
237
254
  button.focus();
238
255
  break;
239
256
  }
240
- });
257
+ };
258
+ addHandler(customList, 'keydown', listKeydownHandler);
241
259
 
242
260
  // Option click events
243
- customList.addEventListener('click', (e) => {
261
+ const listClickHandler = (e) => {
244
262
  const option = e.target.closest('[role="option"]');
245
263
  if (option) {
246
264
  selectOption(option);
247
265
  }
248
- });
266
+ };
267
+ addHandler(customList, 'click', listClickHandler);
249
268
 
250
269
  // Track open/close state
251
270
  const observer = new MutationObserver((mutations) => {
@@ -275,9 +294,10 @@ export function init() {
275
294
  attributes: true,
276
295
  attributeFilter: ['hidden', 'style', 'class']
277
296
  });
297
+ addObserver(observer);
278
298
 
279
299
  // Sync with real select changes
280
- realSelect.addEventListener('change', () => {
300
+ const selectChangeHandler = () => {
281
301
  const selectedOption = realSelect.options[realSelect.selectedIndex];
282
302
  if (selectedOption) {
283
303
  const customOption = customList.querySelector(`[data-value="${selectedOption.value}"]`);
@@ -296,7 +316,8 @@ export function init() {
296
316
  customOption.setAttribute('aria-selected', 'true');
297
317
  }
298
318
  }
299
- });
319
+ };
320
+ addHandler(realSelect, 'change', selectChangeHandler);
300
321
  }
301
322
 
302
323
  // Initialize on DOM ready
@@ -310,5 +331,29 @@ export function init() {
310
331
  window.initCustomSelects = initCustomSelects;
311
332
  })();
312
333
 
313
- return { result: "form initialized" };
334
+ return {
335
+ result: "form initialized",
336
+ destroy: () => {
337
+ // Remove honeypot handler
338
+ if (cleanup.honeypotHandler) {
339
+ document.removeEventListener('submit', cleanup.honeypotHandler, true);
340
+ cleanup.honeypotHandler = null;
341
+ }
342
+
343
+ // Disconnect all observers
344
+ cleanup.observers.forEach(obs => obs.disconnect());
345
+ cleanup.observers.length = 0;
346
+
347
+ // Remove all event listeners
348
+ cleanup.handlers.forEach(({ element, event, handler, options }) => {
349
+ element.removeEventListener(event, handler, options);
350
+ });
351
+ cleanup.handlers.length = 0;
352
+
353
+ // Remove window API
354
+ if (window.initCustomSelects) {
355
+ delete window.initCustomSelects;
356
+ }
357
+ }
358
+ };
314
359
  }