@hortonstudio/main 1.8.2 → 1.9.1

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,51 @@
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
+ };
14
+
15
+ function setupBlogListCleanup() {
16
+ const wrappers = document.querySelectorAll('[data-site-blog="wrapper"]');
17
+
18
+ wrappers.forEach(wrapper => {
19
+ // Check if wrapper has the delete-if-no-list config
20
+ const shouldDelete = wrapper.getAttribute('data-site-blog-config') === 'delete-if-no-list';
21
+
22
+ if (!shouldDelete) {
23
+ return;
24
+ }
25
+
26
+ // Check if there's a descendant with data-site-blog="list"
27
+ const hasList = wrapper.querySelector('[data-site-blog="list"]') !== null;
28
+
29
+ // Delete wrapper if it doesn't have a list
30
+ if (!hasList) {
31
+ wrapper.remove();
32
+ }
33
+ });
34
+ }
3
35
 
4
36
  function setupGeneralAccessibility() {
5
37
  setupListAccessibility();
6
38
  setupRemoveListAccessibility();
7
- setupFAQAccessibility();
39
+ setupFAQAccessibility(addHandler);
8
40
  setupConvertToSpan();
9
41
  setupYearReplacement();
10
- setupPreventDefault();
11
- setupRichTextAccessibility();
12
- setupSummaryAccessibility();
42
+ setupPreventDefault(addHandler);
43
+ setupRichTextAccessibility(addObserver, addHandler, cleanup);
44
+ setupSummaryAccessibility(addHandler);
13
45
  setupCustomValuesReplacement();
14
- setupClickForwarding();
15
- setupTextSynchronization();
46
+ setupClickForwarding(addHandler);
47
+ setupTextSynchronization(addObserver);
48
+ setupBlogListCleanup();
16
49
  }
17
50
 
18
51
  function setupListAccessibility() {
@@ -88,36 +121,35 @@ export function init() {
88
121
  });
89
122
  }
90
123
 
91
- function setupFAQAccessibility() {
124
+ function setupFAQAccessibility(addHandler) {
92
125
  const faqContainers = document.querySelectorAll('[data-hs-a11y="faq-wrap"]');
93
126
 
94
127
  faqContainers.forEach((container, index) => {
95
128
  const button = container.querySelector('[data-hs-a11y="faq-btn"]');
96
129
  const contentWrapper = container.querySelector('[data-hs-a11y="faq-content"]');
97
-
130
+
98
131
  if (!button || !contentWrapper) return;
99
-
132
+
100
133
  const buttonId = `faq-button-${index}`;
101
134
  const contentId = `faq-content-${index}`;
102
-
135
+
103
136
  button.setAttribute('id', buttonId);
104
137
  button.setAttribute('aria-expanded', 'false');
105
138
  button.setAttribute('aria-controls', contentId);
106
-
139
+
107
140
  contentWrapper.setAttribute('id', contentId);
108
141
  contentWrapper.setAttribute('aria-hidden', 'true');
109
142
  contentWrapper.setAttribute('role', 'region');
110
143
  contentWrapper.setAttribute('aria-labelledby', buttonId);
111
-
144
+
112
145
  function toggleFAQ() {
113
146
  const isOpen = button.getAttribute('aria-expanded') === 'true';
114
-
147
+
115
148
  button.setAttribute('aria-expanded', !isOpen);
116
149
  contentWrapper.setAttribute('aria-hidden', isOpen);
117
150
  }
118
-
119
- button.addEventListener('click', toggleFAQ);
120
-
151
+
152
+ addHandler(button, 'click', toggleFAQ);
121
153
  });
122
154
  }
123
155
 
@@ -216,26 +248,28 @@ export function init() {
216
248
  });
217
249
  }
218
250
 
219
- function setupPreventDefault() {
251
+ function setupPreventDefault(addHandler) {
220
252
  const elements = document.querySelectorAll('[data-hs-a11y="prevent-default"]');
221
-
253
+
222
254
  elements.forEach(element => {
223
255
  // Prevent click
224
- element.addEventListener('click', (e) => {
256
+ const clickHandler = (e) => {
225
257
  e.preventDefault();
226
258
  e.stopPropagation();
227
259
  return false;
228
- });
229
-
260
+ };
261
+ addHandler(element, 'click', clickHandler);
262
+
230
263
  // Prevent keyboard activation
231
- element.addEventListener('keydown', (e) => {
264
+ const keydownHandler = (e) => {
232
265
  if (e.key === 'Enter' || e.key === ' ') {
233
266
  e.preventDefault();
234
267
  e.stopPropagation();
235
268
  return false;
236
269
  }
237
- });
238
-
270
+ };
271
+ addHandler(element, 'keydown', keydownHandler);
272
+
239
273
  // Additional prevention for anchor links
240
274
  if (element.tagName.toLowerCase() === 'a') {
241
275
  // Remove or modify href to prevent scroll
@@ -247,27 +281,23 @@ export function init() {
247
281
  element.setAttribute('tabindex', '0');
248
282
  }
249
283
  }
250
-
251
284
  });
252
285
  }
253
286
 
254
- function setupSummaryAccessibility() {
287
+ function setupSummaryAccessibility(addHandler) {
255
288
  const summaryContainers = document.querySelectorAll('[data-hs-a11y="summary-wrap"]');
256
289
 
257
-
258
290
  summaryContainers.forEach((container, index) => {
259
-
260
291
  const button = container.querySelector('[data-hs-a11y="summary-btn"]');
261
292
  const contentWrapper = container.querySelector('[data-hs-a11y="summary-content"]');
262
-
293
+
263
294
  if (!button || !contentWrapper) {
264
295
  return;
265
296
  }
266
-
267
-
297
+
268
298
  const buttonId = `summary-button-${index}`;
269
299
  const contentId = `summary-content-${index}`;
270
-
300
+
271
301
  // Get original button text from first text node only
272
302
  const walker = document.createTreeWalker(
273
303
  button,
@@ -275,14 +305,14 @@ export function init() {
275
305
  null,
276
306
  false
277
307
  );
278
-
308
+
279
309
  let firstTextNode = walker.nextNode();
280
310
  while (firstTextNode && !firstTextNode.textContent.trim()) {
281
311
  firstTextNode = walker.nextNode();
282
312
  }
283
-
313
+
284
314
  const originalButtonText = firstTextNode ? firstTextNode.textContent.trim() : button.textContent.trim();
285
-
315
+
286
316
  // Function to update all text nodes in button
287
317
  function updateButtonText(newText) {
288
318
  // Find all text nodes and update them
@@ -292,7 +322,7 @@ export function init() {
292
322
  null,
293
323
  false
294
324
  );
295
-
325
+
296
326
  const textNodes = [];
297
327
  let node;
298
328
  while (node = walker.nextNode()) {
@@ -300,27 +330,27 @@ export function init() {
300
330
  textNodes.push(node);
301
331
  }
302
332
  }
303
-
333
+
304
334
  textNodes.forEach(textNode => {
305
335
  textNode.textContent = newText;
306
336
  });
307
337
  }
308
-
338
+
309
339
  button.setAttribute('id', buttonId);
310
340
  button.setAttribute('aria-expanded', 'false');
311
341
  button.setAttribute('aria-controls', contentId);
312
342
  button.setAttribute('aria-label', 'View Summary');
313
-
343
+
314
344
  contentWrapper.setAttribute('id', contentId);
315
345
  contentWrapper.setAttribute('aria-hidden', 'true');
316
346
  contentWrapper.setAttribute('role', 'region');
317
347
  contentWrapper.setAttribute('aria-labelledby', buttonId);
318
-
348
+
319
349
  // Summary is closed by default - no need to check initial state
320
-
350
+
321
351
  function toggleSummary() {
322
352
  const isOpen = button.getAttribute('aria-expanded') === 'true';
323
-
353
+
324
354
  if (isOpen) {
325
355
  // Closing
326
356
  button.setAttribute('aria-expanded', 'false');
@@ -334,11 +364,9 @@ export function init() {
334
364
  updateButtonText('Close');
335
365
  contentWrapper.setAttribute('aria-hidden', 'false');
336
366
  }
337
-
338
367
  }
339
-
340
- button.addEventListener('click', toggleSummary);
341
-
368
+
369
+ addHandler(button, 'click', toggleSummary);
342
370
  });
343
371
  }
344
372
 
@@ -434,54 +462,56 @@ export function init() {
434
462
  });
435
463
  }
436
464
 
437
- function setupClickForwarding() {
465
+ function setupClickForwarding(addHandler) {
438
466
  // Find all clickable elements (custom styled elements users click)
439
467
  const clickableElements = document.querySelectorAll('[data-hs-a11y*="clickable"]');
440
-
468
+
441
469
  clickableElements.forEach(clickableElement => {
442
470
  const attribute = clickableElement.getAttribute('data-hs-a11y');
443
-
471
+
444
472
  // Parse the attribute: "click-trigger-[identifier], clickable"
445
473
  const parts = attribute.split(',').map(part => part.trim());
446
-
474
+
447
475
  // Find the part with click-trigger and the part with clickable
448
476
  const triggerPart = parts.find(part => part.startsWith('click-trigger-'));
449
477
  const rolePart = parts.find(part => part === 'clickable');
450
-
478
+
451
479
  if (!triggerPart || !rolePart) {
452
480
  return;
453
481
  }
454
-
482
+
455
483
  // Extract identifier from "click-trigger-[identifier]"
456
484
  const identifier = triggerPart.replace('click-trigger-', '').trim();
457
-
485
+
458
486
  // Find the corresponding trigger element
459
487
  const triggerSelector = `[data-hs-a11y*="click-trigger-${identifier}"][data-hs-a11y*="trigger"]`;
460
488
  const triggerElement = document.querySelector(triggerSelector);
461
-
489
+
462
490
  if (!triggerElement) {
463
491
  return;
464
492
  }
465
-
493
+
466
494
  // Add click event listener to forward clicks
467
- clickableElement.addEventListener('click', (event) => {
495
+ const clickHandler = (event) => {
468
496
  // Prevent default behavior on the clickable element
469
497
  event.preventDefault();
470
498
  event.stopPropagation();
471
-
499
+
472
500
  // Trigger click on the target element
473
501
  triggerElement.click();
474
- });
475
-
502
+ };
503
+ addHandler(clickableElement, 'click', clickHandler);
504
+
476
505
  // Also handle keyboard events for accessibility
477
- clickableElement.addEventListener('keydown', (event) => {
506
+ const keydownHandler = (event) => {
478
507
  if (event.key === 'Enter' || event.key === ' ') {
479
508
  event.preventDefault();
480
509
  event.stopPropagation();
481
510
  triggerElement.click();
482
511
  }
483
- });
484
-
512
+ };
513
+ addHandler(clickableElement, 'keydown', keydownHandler);
514
+
485
515
  // Ensure clickable element is keyboard accessible
486
516
  if (!clickableElement.hasAttribute('tabindex')) {
487
517
  clickableElement.setAttribute('tabindex', '0');
@@ -492,44 +522,44 @@ export function init() {
492
522
  });
493
523
  }
494
524
 
495
- function setupTextSynchronization() {
525
+ function setupTextSynchronization(addObserver) {
496
526
  // Find all original elements (source of truth)
497
527
  const originalElements = document.querySelectorAll('[data-hs-a11y*="original"]');
498
-
528
+
499
529
  originalElements.forEach(originalElement => {
500
530
  const attribute = originalElement.getAttribute('data-hs-a11y');
501
-
531
+
502
532
  // Parse the attribute: "match-text-[identifier], original"
503
533
  const parts = attribute.split(',').map(part => part.trim());
504
-
534
+
505
535
  // Find the part with match-text and the part with original
506
536
  const textPart = parts.find(part => part.startsWith('match-text-'));
507
537
  const rolePart = parts.find(part => part === 'original');
508
-
538
+
509
539
  if (!textPart || !rolePart) {
510
540
  return;
511
541
  }
512
-
542
+
513
543
  // Extract identifier from "match-text-[identifier]"
514
544
  const identifier = textPart.replace('match-text-', '').trim();
515
-
545
+
516
546
  // Find all corresponding match elements
517
547
  const matchSelector = `[data-hs-a11y*="match-text-${identifier}"][data-hs-a11y*="match"]`;
518
548
  const matchElements = document.querySelectorAll(matchSelector);
519
-
549
+
520
550
  if (matchElements.length === 0) {
521
551
  return;
522
552
  }
523
-
553
+
524
554
  // Function to synchronize text and aria-label
525
555
  function synchronizeContent() {
526
556
  const originalText = originalElement.textContent;
527
557
  const originalAriaLabel = originalElement.getAttribute('aria-label');
528
-
558
+
529
559
  matchElements.forEach(matchElement => {
530
560
  // Copy text content
531
561
  matchElement.textContent = originalText;
532
-
562
+
533
563
  // Synchronize aria-label
534
564
  if (originalAriaLabel) {
535
565
  // If original has aria-label, copy it to match
@@ -542,27 +572,27 @@ export function init() {
542
572
  }
543
573
  });
544
574
  }
545
-
575
+
546
576
  // Initial synchronization
547
577
  synchronizeContent();
548
-
578
+
549
579
  // Set up MutationObserver to watch for changes
550
580
  const observer = new MutationObserver((mutations) => {
551
581
  let shouldSync = false;
552
-
582
+
553
583
  mutations.forEach((mutation) => {
554
- if (mutation.type === 'childList' ||
555
- mutation.type === 'characterData' ||
584
+ if (mutation.type === 'childList' ||
585
+ mutation.type === 'characterData' ||
556
586
  (mutation.type === 'attributes' && mutation.attributeName === 'aria-label')) {
557
587
  shouldSync = true;
558
588
  }
559
589
  });
560
-
590
+
561
591
  if (shouldSync) {
562
592
  synchronizeContent();
563
593
  }
564
594
  });
565
-
595
+
566
596
  // Observe text changes and attribute changes
567
597
  observer.observe(originalElement, {
568
598
  childList: true,
@@ -571,16 +601,16 @@ export function init() {
571
601
  attributes: true,
572
602
  attributeFilter: ['aria-label']
573
603
  });
604
+
605
+ addObserver(observer);
574
606
  });
575
607
  }
576
608
 
577
- function setupRichTextAccessibility() {
609
+ function setupRichTextAccessibility(addObserver, addHandler, cleanup) {
578
610
  const contentAreas = document.querySelectorAll('[data-hs-a11y="rich-content"]');
579
611
  const tocLists = document.querySelectorAll('[data-hs-a11y="rich-toc"]');
580
612
 
581
-
582
613
  contentAreas.forEach((contentArea) => {
583
-
584
614
  // Since there's only 1 content area and 1 TOC list per page, use the first TOC list
585
615
  const tocList = tocLists[0];
586
616
 
@@ -592,7 +622,6 @@ export function init() {
592
622
  return;
593
623
  }
594
624
 
595
-
596
625
  const template = tocList.children[0].cloneNode(true);
597
626
  // Remove is-active class from template if it exists
598
627
  const templateLink = template.querySelector("a");
@@ -643,7 +672,7 @@ export function init() {
643
672
  link.appendChild(document.createTextNode(heading.textContent));
644
673
 
645
674
  // Add click handler for smooth scrolling
646
- link.addEventListener("click", (e) => {
675
+ const clickHandler = (e) => {
647
676
  e.preventDefault();
648
677
 
649
678
  const targetSection = document.getElementById(sectionId);
@@ -654,7 +683,8 @@ export function init() {
654
683
  targetSection.focus();
655
684
  }, 100);
656
685
  }
657
- });
686
+ };
687
+ addHandler(link, "click", clickHandler);
658
688
 
659
689
  // Ensure sections are focusable for keyboard users but use CSS to control focus visibility
660
690
  const targetSection = document.getElementById(sectionId);
@@ -719,17 +749,38 @@ export function init() {
719
749
 
720
750
  // Observe all sections
721
751
  sections.forEach(section => observer.observe(section));
752
+ addObserver(observer);
722
753
 
723
754
  // 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
- });
755
+ const scrollHandler = () => {
756
+ if (cleanup.scrollTimeout) clearTimeout(cleanup.scrollTimeout);
757
+ cleanup.scrollTimeout = setTimeout(updateActiveLink, 50);
758
+ };
759
+ addHandler(window, 'scroll', scrollHandler);
729
760
 
730
761
  });
731
762
  }
732
763
 
733
764
  setupGeneralAccessibility();
734
- return { result: "accessibility initialized" };
765
+
766
+ return {
767
+ result: "accessibility initialized",
768
+ destroy: () => {
769
+ // Disconnect all observers
770
+ cleanup.observers.forEach(obs => obs.disconnect());
771
+ cleanup.observers.length = 0;
772
+
773
+ // Remove all event listeners
774
+ cleanup.handlers.forEach(({ element, event, handler, options }) => {
775
+ element.removeEventListener(event, handler, options);
776
+ });
777
+ cleanup.handlers.length = 0;
778
+
779
+ // Clear scroll timeout
780
+ if (cleanup.scrollTimeout) {
781
+ clearTimeout(cleanup.scrollTimeout);
782
+ cleanup.scrollTimeout = null;
783
+ }
784
+ }
785
+ };
735
786
  }
@@ -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
  }