@hortonstudio/main 1.4.0 → 1.4.2

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.
@@ -106,14 +106,34 @@ async function waitForFonts() {
106
106
  }
107
107
  }
108
108
 
109
+ function findTextElement(container) {
110
+ // Find the deepest element that contains actual text
111
+ const walker = document.createTreeWalker(
112
+ container,
113
+ NodeFilter.SHOW_ELEMENT,
114
+ {
115
+ acceptNode: (node) => {
116
+ // Check if this element has direct text content (not just whitespace)
117
+ const hasDirectText = Array.from(node.childNodes).some(child =>
118
+ child.nodeType === Node.TEXT_NODE && child.textContent.trim()
119
+ );
120
+ return hasDirectText ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
121
+ }
122
+ }
123
+ );
124
+
125
+ return walker.nextNode() || container;
126
+ }
127
+
109
128
  function showElementsWithoutAnimation() {
110
129
  // Simply show all text elements without any animation or split text
111
130
  const allTextElements = [
112
- ...document.querySelectorAll(".a-char-split > *:first-child"),
113
- ...document.querySelectorAll(".a-word-split > *:first-child"),
114
- ...document.querySelectorAll(".a-line-split > *:first-child"),
115
- ...document.querySelectorAll(".a-appear"),
116
- ...document.querySelectorAll(".a-reveal"),
131
+ ...document.querySelectorAll('[data-hs-anim="char"]'),
132
+ ...document.querySelectorAll('[data-hs-anim="word"]'),
133
+ ...document.querySelectorAll('[data-hs-anim="line"]'),
134
+ ...document.querySelectorAll('[data-hs-anim="appear"]'),
135
+ ...document.querySelectorAll('[data-hs-anim="reveal"]'),
136
+ ...document.querySelectorAll('[data-hs-anim="group"]'),
117
137
  ];
118
138
 
119
139
  allTextElements.forEach((element) => {
@@ -138,16 +158,22 @@ const CharSplitAnimations = {
138
158
  return;
139
159
  }
140
160
 
141
- const elements = document.querySelectorAll(".a-char-split > *:first-child");
161
+ const elements = document.querySelectorAll('[data-hs-anim="char"]');
142
162
 
143
- elements.forEach((textElement) => {
163
+ elements.forEach((container) => {
164
+ // Skip if already processed
165
+ if (container.hasAttribute('data-split-processed')) return;
166
+
167
+ const textElement = findTextElement(container);
168
+ if (!textElement) return;
144
169
  try {
145
170
  const split = SplitText.create(textElement, {
146
171
  type: "words,chars",
147
172
  mask: "chars",
148
173
  charsClass: "char",
149
174
  });
150
- textElement.splitTextInstance = split;
175
+ container.splitTextInstance = split;
176
+ container.setAttribute('data-split-processed', 'true');
151
177
 
152
178
  gsap.set(split.chars, {
153
179
  yPercent: config.charSplit.yPercent,
@@ -168,20 +194,25 @@ const CharSplitAnimations = {
168
194
  }
169
195
 
170
196
  document
171
- .querySelectorAll(".a-char-split > *:first-child")
172
- .forEach((textElement) => {
197
+ .querySelectorAll('[data-hs-anim="char"]')
198
+ .forEach((container) => {
199
+ const textElement = findTextElement(container);
200
+ if (!textElement) return;
173
201
  try {
174
- const chars = textElement.querySelectorAll(".char");
202
+ const chars = container.querySelectorAll(".char");
175
203
  const tl = gsap.timeline({
176
204
  scrollTrigger: {
177
- trigger: textElement,
205
+ trigger: container,
178
206
  start: config.charSplit.start,
179
- invalidateOnRefresh: false,
207
+ invalidateOnRefresh: true,
208
+ toggleActions: "play none none none",
209
+ once: true,
180
210
  },
181
211
  onComplete: () => {
182
- if (textElement.splitTextInstance) {
183
- textElement.splitTextInstance.revert();
184
- delete textElement.splitTextInstance;
212
+ if (container.splitTextInstance) {
213
+ container.splitTextInstance.revert();
214
+ delete container.splitTextInstance;
215
+ container.removeAttribute('data-split-processed');
185
216
  }
186
217
  },
187
218
  });
@@ -209,16 +240,22 @@ const WordSplitAnimations = {
209
240
  return;
210
241
  }
211
242
 
212
- const elements = document.querySelectorAll(".a-word-split > *:first-child");
243
+ const elements = document.querySelectorAll('[data-hs-anim="word"]');
213
244
 
214
- elements.forEach((textElement) => {
245
+ elements.forEach((container) => {
246
+ // Skip if already processed
247
+ if (container.hasAttribute('data-split-processed')) return;
248
+
249
+ const textElement = findTextElement(container);
250
+ if (!textElement) return;
215
251
  try {
216
252
  const split = SplitText.create(textElement, {
217
253
  type: "words",
218
254
  mask: "words",
219
255
  wordsClass: "word",
220
256
  });
221
- textElement.splitTextInstance = split;
257
+ container.splitTextInstance = split;
258
+ container.setAttribute('data-split-processed', 'true');
222
259
 
223
260
  gsap.set(split.words, {
224
261
  yPercent: config.wordSplit.yPercent,
@@ -239,20 +276,25 @@ const WordSplitAnimations = {
239
276
  }
240
277
 
241
278
  document
242
- .querySelectorAll(".a-word-split > *:first-child")
243
- .forEach((textElement) => {
279
+ .querySelectorAll('[data-hs-anim="word"]')
280
+ .forEach((container) => {
281
+ const textElement = findTextElement(container);
282
+ if (!textElement) return;
244
283
  try {
245
- const words = textElement.querySelectorAll(".word");
284
+ const words = container.querySelectorAll(".word");
246
285
  const tl = gsap.timeline({
247
286
  scrollTrigger: {
248
- trigger: textElement,
287
+ trigger: container,
249
288
  start: config.wordSplit.start,
250
- invalidateOnRefresh: false,
289
+ invalidateOnRefresh: true,
290
+ toggleActions: "play none none none",
291
+ once: true,
251
292
  },
252
293
  onComplete: () => {
253
- if (textElement.splitTextInstance) {
254
- textElement.splitTextInstance.revert();
255
- delete textElement.splitTextInstance;
294
+ if (container.splitTextInstance) {
295
+ container.splitTextInstance.revert();
296
+ delete container.splitTextInstance;
297
+ container.removeAttribute('data-split-processed');
256
298
  }
257
299
  },
258
300
  });
@@ -280,16 +322,22 @@ const LineSplitAnimations = {
280
322
  return;
281
323
  }
282
324
 
283
- const elements = document.querySelectorAll(".a-line-split > *:first-child");
325
+ const elements = document.querySelectorAll('[data-hs-anim="line"]');
284
326
 
285
- elements.forEach((textElement) => {
327
+ elements.forEach((container) => {
328
+ // Skip if already processed
329
+ if (container.hasAttribute('data-split-processed')) return;
330
+
331
+ const textElement = findTextElement(container);
332
+ if (!textElement) return;
286
333
  try {
287
334
  const split = SplitText.create(textElement, {
288
335
  type: "lines",
289
336
  mask: "lines",
290
337
  linesClass: "line",
291
338
  });
292
- textElement.splitTextInstance = split;
339
+ container.splitTextInstance = split;
340
+ container.setAttribute('data-split-processed', 'true');
293
341
 
294
342
  gsap.set(split.lines, {
295
343
  yPercent: config.lineSplit.yPercent,
@@ -310,20 +358,25 @@ const LineSplitAnimations = {
310
358
  }
311
359
 
312
360
  document
313
- .querySelectorAll(".a-line-split > *:first-child")
314
- .forEach((textElement) => {
361
+ .querySelectorAll('[data-hs-anim="line"]')
362
+ .forEach((container) => {
363
+ const textElement = findTextElement(container);
364
+ if (!textElement) return;
315
365
  try {
316
- const lines = textElement.querySelectorAll(".line");
366
+ const lines = container.querySelectorAll(".line");
317
367
  const tl = gsap.timeline({
318
368
  scrollTrigger: {
319
- trigger: textElement,
369
+ trigger: container,
320
370
  start: config.lineSplit.start,
321
- invalidateOnRefresh: false,
371
+ invalidateOnRefresh: true,
372
+ toggleActions: "play none none none",
373
+ once: true,
322
374
  },
323
375
  onComplete: () => {
324
- if (textElement.splitTextInstance) {
325
- textElement.splitTextInstance.revert();
326
- delete textElement.splitTextInstance;
376
+ if (container.splitTextInstance) {
377
+ container.splitTextInstance.revert();
378
+ delete container.splitTextInstance;
379
+ container.removeAttribute('data-split-processed');
327
380
  }
328
381
  },
329
382
  });
@@ -351,7 +404,7 @@ const AppearAnimations = {
351
404
  return;
352
405
  }
353
406
 
354
- const elements = document.querySelectorAll(".a-appear");
407
+ const elements = document.querySelectorAll('[data-hs-anim="appear"]');
355
408
  elements.forEach((element) => {
356
409
  try {
357
410
  gsap.set(element, {
@@ -371,13 +424,15 @@ const AppearAnimations = {
371
424
  return;
372
425
  }
373
426
 
374
- document.querySelectorAll(".a-appear").forEach((element) => {
427
+ document.querySelectorAll('[data-hs-anim="appear"]').forEach((element) => {
375
428
  try {
376
429
  const tl = gsap.timeline({
377
430
  scrollTrigger: {
378
431
  trigger: element,
379
432
  start: config.appear.start,
380
- invalidateOnRefresh: false,
433
+ invalidateOnRefresh: true,
434
+ toggleActions: "play none none none",
435
+ once: true,
381
436
  },
382
437
  });
383
438
 
@@ -404,7 +459,7 @@ const RevealAnimations = {
404
459
  return;
405
460
  }
406
461
 
407
- const elements = document.querySelectorAll(".a-reveal");
462
+ const elements = document.querySelectorAll('[data-hs-anim="reveal"]');
408
463
  elements.forEach((element) => {
409
464
  try {
410
465
  gsap.set(element, {
@@ -424,13 +479,15 @@ const RevealAnimations = {
424
479
  return;
425
480
  }
426
481
 
427
- document.querySelectorAll(".a-reveal").forEach((element) => {
482
+ document.querySelectorAll('[data-hs-anim="reveal"]').forEach((element) => {
428
483
  try {
429
484
  const tl = gsap.timeline({
430
485
  scrollTrigger: {
431
486
  trigger: element,
432
487
  start: config.reveal.start,
433
- invalidateOnRefresh: false,
488
+ invalidateOnRefresh: true,
489
+ toggleActions: "play none none none",
490
+ once: true,
434
491
  },
435
492
  });
436
493
 
@@ -449,6 +506,64 @@ const RevealAnimations = {
449
506
  },
450
507
  };
451
508
 
509
+ const GroupAnimations = {
510
+ async initial() {
511
+ await waitForFonts();
512
+
513
+ if (prefersReducedMotion()) {
514
+ return;
515
+ }
516
+
517
+ const elements = document.querySelectorAll('[data-hs-anim="group"]');
518
+ elements.forEach((element) => {
519
+ try {
520
+ const children = Array.from(element.children);
521
+ gsap.set(children, {
522
+ y: config.appear.y,
523
+ opacity: 0,
524
+ });
525
+ } catch (error) {
526
+ console.warn("Error setting group initial state:", error);
527
+ }
528
+ });
529
+ },
530
+
531
+ async animate() {
532
+ await waitForFonts();
533
+
534
+ if (prefersReducedMotion()) {
535
+ return;
536
+ }
537
+
538
+ document.querySelectorAll('[data-hs-anim="group"]').forEach((element) => {
539
+ try {
540
+ const children = Array.from(element.children);
541
+ const tl = gsap.timeline({
542
+ scrollTrigger: {
543
+ trigger: element,
544
+ start: config.appear.start,
545
+ invalidateOnRefresh: true,
546
+ toggleActions: "play none none none",
547
+ once: true,
548
+ },
549
+ });
550
+
551
+ tl.to(children, {
552
+ y: 0,
553
+ opacity: 1,
554
+ duration: config.appear.duration,
555
+ ease: config.appear.ease,
556
+ stagger: 0.1,
557
+ });
558
+
559
+ activeAnimations.push({ timeline: tl, element: element });
560
+ } catch (error) {
561
+ console.warn("Error animating group:", error);
562
+ }
563
+ });
564
+ },
565
+ };
566
+
452
567
  async function setInitialStates() {
453
568
  await Promise.all([
454
569
  CharSplitAnimations.initial(),
@@ -456,6 +571,7 @@ async function setInitialStates() {
456
571
  LineSplitAnimations.initial(),
457
572
  AppearAnimations.initial(),
458
573
  RevealAnimations.initial(),
574
+ GroupAnimations.initial(),
459
575
  ]);
460
576
  }
461
577
 
@@ -472,6 +588,7 @@ async function initAnimations() {
472
588
  LineSplitAnimations.animate(),
473
589
  AppearAnimations.animate(),
474
590
  RevealAnimations.animate(),
591
+ GroupAnimations.animate(),
475
592
  ]);
476
593
  }
477
594
 
@@ -483,7 +600,7 @@ export async function init() {
483
600
  if (
484
601
  elements.length === 0 &&
485
602
  document.querySelectorAll(
486
- ".a-char-split:not([data-initialized]), .a-word-split:not([data-initialized]), .a-line-split:not([data-initialized]), .a-appear:not([data-initialized]), .a-reveal:not([data-initialized])",
603
+ '[data-hs-anim="char"]:not([data-initialized]), [data-hs-anim="word"]:not([data-initialized]), [data-hs-anim="line"]:not([data-initialized]), [data-hs-anim="appear"]:not([data-initialized]), [data-hs-anim="reveal"]:not([data-initialized]), [data-hs-anim="group"]:not([data-initialized])',
487
604
  ).length === 0
488
605
  ) {
489
606
  return { result: "anim-text already initialized", destroy: () => {} };
@@ -493,7 +610,7 @@ export async function init() {
493
610
  elements.forEach((el) => el.setAttribute("data-initialized", "true"));
494
611
  document
495
612
  .querySelectorAll(
496
- ".a-char-split, .a-word-split, .a-line-split, .a-appear, .a-reveal",
613
+ '[data-hs-anim="char"], [data-hs-anim="word"], [data-hs-anim="line"], [data-hs-anim="appear"], [data-hs-anim="reveal"], [data-hs-anim="group"]',
497
614
  )
498
615
  .forEach((el) => {
499
616
  el.setAttribute("data-initialized", "true");
@@ -525,6 +642,23 @@ export async function init() {
525
642
  };
526
643
  window.addEventListener("resize", resizeHandler);
527
644
 
645
+ // Add page load handler for proper ScrollTrigger refresh timing
646
+ const handlePageLoad = () => {
647
+ setTimeout(() => {
648
+ try {
649
+ ScrollTrigger.refresh();
650
+ } catch (error) {
651
+ console.warn("Error refreshing ScrollTrigger on page load:", error);
652
+ }
653
+ }, 100);
654
+ };
655
+
656
+ if (document.readyState === 'complete') {
657
+ handlePageLoad();
658
+ } else {
659
+ window.addEventListener('load', handlePageLoad);
660
+ }
661
+
528
662
  // Initialize API with proper checks
529
663
  if (!window[API_NAME]) {
530
664
  window[API_NAME] = {};
@@ -3,7 +3,190 @@ export function init() {
3
3
  // Stats accessibility has been moved to counter.js
4
4
 
5
5
  function setupGeneralAccessibility() {
6
- // Add any general accessibility features here
6
+ setupListAccessibility();
7
+ setupFAQAccessibility();
8
+ setupConvertToSpan();
9
+ setupYearReplacement();
10
+ setupPreventDefault();
11
+ }
12
+
13
+ function setupListAccessibility() {
14
+ const listElements = document.querySelectorAll('[data-hs-a11y="list"]');
15
+ const listItemElements = document.querySelectorAll('[data-hs-a11y="list-item"]');
16
+
17
+ listElements.forEach(element => {
18
+ element.setAttribute('role', 'list');
19
+ element.removeAttribute('data-hs-a11y');
20
+ });
21
+
22
+ listItemElements.forEach(element => {
23
+ element.setAttribute('role', 'listitem');
24
+ element.removeAttribute('data-hs-a11y');
25
+ });
26
+ }
27
+
28
+ function setupFAQAccessibility() {
29
+ const faqContainers = document.querySelectorAll('[data-hs-a11y="faq"]');
30
+
31
+ faqContainers.forEach((container, index) => {
32
+ const button = container.querySelector('button');
33
+ const contentWrapper = button.parentElement.nextElementSibling;
34
+
35
+ const buttonId = `faq-button-${index}`;
36
+ const contentId = `faq-content-${index}`;
37
+
38
+ button.setAttribute('id', buttonId);
39
+ button.setAttribute('aria-expanded', 'false');
40
+ button.setAttribute('aria-controls', contentId);
41
+
42
+ contentWrapper.setAttribute('id', contentId);
43
+ contentWrapper.setAttribute('aria-hidden', 'true');
44
+ contentWrapper.setAttribute('role', 'region');
45
+ contentWrapper.setAttribute('aria-labelledby', buttonId);
46
+
47
+ if (contentWrapper.style.height !== '0px') {
48
+ button.setAttribute('aria-expanded', 'true');
49
+ contentWrapper.setAttribute('aria-hidden', 'false');
50
+ }
51
+
52
+ function toggleFAQ() {
53
+ const isOpen = button.getAttribute('aria-expanded') === 'true';
54
+
55
+ button.setAttribute('aria-expanded', !isOpen);
56
+ contentWrapper.setAttribute('aria-hidden', isOpen);
57
+ }
58
+
59
+ button.addEventListener('click', toggleFAQ);
60
+
61
+ container.removeAttribute('data-hs-a11y');
62
+ });
63
+ }
64
+
65
+ function setupConvertToSpan() {
66
+ const containers = document.querySelectorAll('[data-hs-a11y="convert-span"]');
67
+
68
+ containers.forEach(container => {
69
+ const skipTags = [
70
+ 'span', 'a', 'button', 'input', 'textarea', 'select', 'img', 'video', 'audio',
71
+ 'iframe', 'object', 'embed', 'canvas', 'svg', 'form', 'table', 'thead', 'tbody',
72
+ 'tr', 'td', 'th', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'h1', 'h2', 'h3', 'h4',
73
+ 'h5', 'h6', 'script', 'style', 'link', 'meta', 'title', 'head', 'html', 'body'
74
+ ];
75
+
76
+ // Convert all child elements first
77
+ const elementsToConvert = container.querySelectorAll('*');
78
+
79
+ elementsToConvert.forEach(element => {
80
+ const tagName = element.tagName.toLowerCase();
81
+
82
+ if (!skipTags.includes(tagName)) {
83
+ const newSpan = document.createElement('span');
84
+
85
+ // Copy all attributes except data-hs-a11y
86
+ Array.from(element.attributes).forEach(attr => {
87
+ if (attr.name !== 'data-hs-a11y') {
88
+ newSpan.setAttribute(attr.name, attr.value);
89
+ }
90
+ });
91
+
92
+ // Move all child nodes
93
+ while (element.firstChild) {
94
+ newSpan.appendChild(element.firstChild);
95
+ }
96
+
97
+ // Replace the element
98
+ element.parentNode.replaceChild(newSpan, element);
99
+ }
100
+ });
101
+
102
+ // Convert the container itself to span
103
+ const containerTagName = container.tagName.toLowerCase();
104
+ if (!skipTags.includes(containerTagName)) {
105
+ const newSpan = document.createElement('span');
106
+
107
+ // Copy all attributes except data-hs-a11y
108
+ Array.from(container.attributes).forEach(attr => {
109
+ if (attr.name !== 'data-hs-a11y') {
110
+ newSpan.setAttribute(attr.name, attr.value);
111
+ }
112
+ });
113
+
114
+ // Move all child nodes
115
+ while (container.firstChild) {
116
+ newSpan.appendChild(container.firstChild);
117
+ }
118
+
119
+ // Replace the container
120
+ container.parentNode.replaceChild(newSpan, container);
121
+ } else {
122
+ // Just remove the attribute if container shouldn't be converted
123
+ container.removeAttribute('data-hs-a11y');
124
+ }
125
+ });
126
+ }
127
+
128
+ function setupYearReplacement() {
129
+ const currentYear = new Date().getFullYear().toString();
130
+ const walker = document.createTreeWalker(
131
+ document.body,
132
+ NodeFilter.SHOW_TEXT,
133
+ {
134
+ acceptNode: (node) => {
135
+ return node.textContent.includes('{{year}}')
136
+ ? NodeFilter.FILTER_ACCEPT
137
+ : NodeFilter.FILTER_SKIP;
138
+ }
139
+ }
140
+ );
141
+
142
+ const textNodes = [];
143
+ let node;
144
+ while (node = walker.nextNode()) {
145
+ textNodes.push(node);
146
+ }
147
+
148
+ textNodes.forEach(textNode => {
149
+ const newText = textNode.textContent.replace(/\{\{year\}\}/gi, currentYear);
150
+ if (newText !== textNode.textContent) {
151
+ textNode.textContent = newText;
152
+ }
153
+ });
154
+ }
155
+
156
+ function setupPreventDefault() {
157
+ const elements = document.querySelectorAll('[data-hs-a11y="prevent-default"]');
158
+
159
+ elements.forEach(element => {
160
+ // Prevent click
161
+ element.addEventListener('click', (e) => {
162
+ e.preventDefault();
163
+ e.stopPropagation();
164
+ return false;
165
+ });
166
+
167
+ // Prevent keyboard activation
168
+ element.addEventListener('keydown', (e) => {
169
+ if (e.key === 'Enter' || e.key === ' ') {
170
+ e.preventDefault();
171
+ e.stopPropagation();
172
+ return false;
173
+ }
174
+ });
175
+
176
+ // Additional prevention for anchor links
177
+ if (element.tagName.toLowerCase() === 'a') {
178
+ // Remove or modify href to prevent scroll
179
+ const originalHref = element.getAttribute('href');
180
+ if (originalHref && (originalHref === '#' || originalHref.startsWith('#'))) {
181
+ element.setAttribute('data-original-href', originalHref);
182
+ element.removeAttribute('href');
183
+ element.setAttribute('role', 'button');
184
+ element.setAttribute('tabindex', '0');
185
+ }
186
+ }
187
+
188
+ element.removeAttribute('data-hs-a11y');
189
+ });
7
190
  }
8
191
 
9
192
  setupGeneralAccessibility();
@@ -2,76 +2,62 @@ export function init() {
2
2
  const customValues = new Map();
3
3
  let isInitialized = false;
4
4
 
5
- // Configuration for performance optimization
6
5
  const config = {
7
- // Attributes to search for placeholders
8
6
  searchAttributes: [
9
7
  'href', 'src', 'alt', 'title', 'aria-label', 'data-src',
10
8
  'data-href', 'action', 'placeholder', 'value'
11
9
  ],
12
- // Elements to exclude from search for performance
13
10
  excludeSelectors: [
14
11
  'script', 'style', 'meta', 'link', 'title', 'head',
15
12
  '[data-hs-custom="list"]', '[data-hs-custom="name"]', '[data-hs-custom="value"]'
16
13
  ],
17
- // Phone number formatting options
18
14
  phoneFormatting: {
19
- // Attributes that should use tel: format
20
15
  telAttributes: ['href'],
21
- // Pattern to detect phone numbers (matches various formats)
22
16
  phonePattern: /^[\+]?[1-9]?[\d\s\-\(\)\.]{7,15}$/,
23
- // Default country code (US/Canada)
24
17
  defaultCountryCode: '+1',
25
- // Clean phone for tel: links (remove all non-digits except +)
26
18
  cleanForTel: (phone) => {
27
19
  const cleaned = phone.replace(/[^\d+]/g, '');
28
- // If no country code, add default
29
20
  if (!cleaned.startsWith('+')) {
30
21
  return config.phoneFormatting.defaultCountryCode + cleaned;
31
22
  }
32
23
  return cleaned;
33
24
  },
34
- // Format for display (keep original formatting)
35
25
  formatForDisplay: (phone) => phone
36
26
  }
37
27
  };
38
28
 
39
- // Detect if a value looks like a phone number
40
29
  function isPhoneNumber(value) {
41
30
  return config.phoneFormatting.phonePattern.test(value.trim());
42
31
  }
43
32
 
44
- // Format value based on context (attribute vs text content)
45
33
  function formatValueForContext(value, isAttribute, attributeName) {
46
34
  if (isPhoneNumber(value)) {
47
- // For href attributes, clean the phone number (no tel: prefix)
48
35
  if (isAttribute && config.phoneFormatting.telAttributes.includes(attributeName)) {
49
36
  return config.phoneFormatting.cleanForTel(value);
50
37
  } else {
51
- // For display, keep original formatting
52
38
  return config.phoneFormatting.formatForDisplay(value);
53
39
  }
54
40
  }
55
41
  return value;
56
42
  }
57
43
 
58
- // Extract custom values from data attributes
59
44
  function extractCustomValues() {
45
+ const currentYear = new Date().getFullYear().toString();
46
+ customValues.set('{{year}}', currentYear);
47
+
60
48
  const customList = document.querySelector('[data-hs-custom="list"]');
61
49
  if (!customList) {
62
- return false;
50
+ return customValues.size > 0;
63
51
  }
64
52
 
65
53
  const nameElements = customList.querySelectorAll('[data-hs-custom="name"]');
66
54
  const valueElements = customList.querySelectorAll('[data-hs-custom="value"]');
67
55
 
68
- // Build mapping from name/value pairs
69
56
  for (let i = 0; i < Math.min(nameElements.length, valueElements.length); i++) {
70
57
  const name = nameElements[i].textContent.trim();
71
58
  const value = valueElements[i].textContent.trim();
72
59
 
73
60
  if (name && value) {
74
- // Store with lowercase key for case-insensitive matching
75
61
  const key = `{{${name.toLowerCase()}}}`;
76
62
  customValues.set(key, value);
77
63
  }
@@ -80,18 +66,15 @@ export function init() {
80
66
  return customValues.size > 0;
81
67
  }
82
68
 
83
- // Replace placeholders in text content
84
69
  function replaceInText(text, isAttribute = false, attributeName = null) {
85
70
  if (!text || typeof text !== 'string') return text;
86
71
 
87
72
  let result = text;
88
73
 
89
74
  customValues.forEach((value, placeholder) => {
90
- // Create case-insensitive regex for exact placeholder match
91
75
  const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
92
76
  const matches = text.match(regex);
93
77
  if (matches) {
94
- // Format value based on context (phone numbers get special treatment)
95
78
  const formattedValue = formatValueForContext(value, isAttribute, attributeName);
96
79
  result = result.replace(regex, formattedValue);
97
80
  }
@@ -100,7 +83,6 @@ export function init() {
100
83
  return result;
101
84
  }
102
85
 
103
- // Replace placeholders in all attributes of an element
104
86
  function replaceInAttributes(element) {
105
87
  config.searchAttributes.forEach(attr => {
106
88
  const value = element.getAttribute(attr);
@@ -113,26 +95,22 @@ export function init() {
113
95
  });
114
96
  }
115
97
 
116
- // Check if element should be excluded from processing
117
98
  function shouldExcludeElement(element) {
118
99
  return config.excludeSelectors.some(selector => {
119
100
  return element.matches(selector);
120
101
  });
121
102
  }
122
103
 
123
- // Process text nodes for placeholder replacement
124
104
  function processTextNodes(element) {
125
105
  const walker = document.createTreeWalker(
126
106
  element,
127
107
  NodeFilter.SHOW_TEXT,
128
108
  {
129
109
  acceptNode: (node) => {
130
- // Skip if parent element should be excluded
131
110
  if (shouldExcludeElement(node.parentElement)) {
132
111
  return NodeFilter.FILTER_REJECT;
133
112
  }
134
113
 
135
- // Only process text nodes with placeholder patterns
136
114
  return node.textContent.includes('{{') && node.textContent.includes('}}')
137
115
  ? NodeFilter.FILTER_ACCEPT
138
116
  : NodeFilter.FILTER_SKIP;
@@ -146,7 +124,6 @@ export function init() {
146
124
  textNodes.push(node);
147
125
  }
148
126
 
149
- // Replace placeholders in collected text nodes
150
127
  textNodes.forEach(textNode => {
151
128
  const originalText = textNode.textContent;
152
129
  const newText = replaceInText(originalText);
@@ -156,7 +133,6 @@ export function init() {
156
133
  });
157
134
  }
158
135
 
159
- // Process all elements for attribute replacement
160
136
  function processElements(container) {
161
137
  const elements = container.querySelectorAll('*');
162
138
 
@@ -167,21 +143,14 @@ export function init() {
167
143
  });
168
144
  }
169
145
 
170
- // Main replacement function
171
146
  function performReplacements() {
172
147
  if (customValues.size === 0) return;
173
148
 
174
- // Process text content
175
149
  processTextNodes(document.body);
176
-
177
- // Process element attributes
178
150
  processElements(document.body);
179
-
180
- // Also check document root attributes
181
151
  replaceInAttributes(document.documentElement);
182
152
  }
183
153
 
184
- // Remove the custom values list from DOM
185
154
  function cleanupCustomList() {
186
155
  const customList = document.querySelector('[data-hs-custom="list"]');
187
156
  if (customList) {
@@ -189,7 +158,6 @@ export function init() {
189
158
  }
190
159
  }
191
160
 
192
- // Handle dynamic content with MutationObserver
193
161
  function setupDynamicContentHandler() {
194
162
  const observer = new MutationObserver((mutations) => {
195
163
  let hasNewContent = false;
@@ -198,7 +166,6 @@ export function init() {
198
166
  if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
199
167
  mutation.addedNodes.forEach((node) => {
200
168
  if (node.nodeType === Node.ELEMENT_NODE) {
201
- // Check if new content contains placeholders
202
169
  const hasPlaceholders = node.textContent.includes('{{') && node.textContent.includes('}}');
203
170
  const hasAttributePlaceholders = config.searchAttributes.some(attr => {
204
171
  const value = node.getAttribute?.(attr);
@@ -214,7 +181,6 @@ export function init() {
214
181
  });
215
182
 
216
183
  if (hasNewContent && customValues.size > 0) {
217
- // Debounce replacements for performance
218
184
  clearTimeout(observer.timeout);
219
185
  observer.timeout = setTimeout(() => {
220
186
  performReplacements();
@@ -230,21 +196,14 @@ export function init() {
230
196
  return observer;
231
197
  }
232
198
 
233
- // Initialize the custom values system
234
199
  function initializeCustomValues() {
235
200
  if (isInitialized) return;
236
201
 
237
- // Extract custom values from data attributes
238
202
  const hasCustomValues = extractCustomValues();
239
203
 
240
204
  if (hasCustomValues) {
241
- // Perform initial replacements
242
205
  performReplacements();
243
-
244
- // Clean up the custom list
245
206
  cleanupCustomList();
246
-
247
- // Set up dynamic content handling
248
207
  setupDynamicContentHandler();
249
208
 
250
209
  isInitialized = true;
@@ -261,6 +220,5 @@ export function init() {
261
220
  }
262
221
  }
263
222
 
264
- // Initialize on page load
265
223
  return initializeCustomValues();
266
224
  }
package/autoInit/form.js CHANGED
@@ -27,6 +27,302 @@ export function init() {
27
27
  let originalErrorTemplate = null;
28
28
  let isValidating = false;
29
29
 
30
+ // Simple Custom Select Component for Webflow
31
+ (function() {
32
+ 'use strict';
33
+
34
+ // Initialize all custom selects on the page
35
+ function initCustomSelects() {
36
+ // Unwrap any divs inside select elements (Webflow component slots)
37
+ document.querySelectorAll('select > div').forEach(div => {
38
+ const select = div.parentElement;
39
+ while (div.firstChild) {
40
+ select.appendChild(div.firstChild);
41
+ }
42
+ div.remove();
43
+ });
44
+
45
+ const selectWrappers = document.querySelectorAll('[data-hs-form="select"]');
46
+
47
+ selectWrappers.forEach(wrapper => {
48
+ initSingleSelect(wrapper);
49
+ });
50
+ }
51
+
52
+ // Initialize a single custom select
53
+ function initSingleSelect(wrapper) {
54
+ // Find all required elements
55
+ const realSelect = wrapper.querySelector('select');
56
+ if (!realSelect) return;
57
+
58
+ const selectName = realSelect.getAttribute('name') || 'custom-select';
59
+ const customList = wrapper.querySelector('[data-hs-form="select-list"]');
60
+ const button = wrapper.querySelector('button') || wrapper.querySelector('[role="button"]');
61
+
62
+ if (!customList || !button) return;
63
+
64
+ // Get and clone the option template
65
+ const optionTemplate = customList.firstElementChild;
66
+ if (!optionTemplate) return;
67
+
68
+ const templateClone = optionTemplate.cloneNode(true);
69
+ optionTemplate.remove();
70
+
71
+ // Build options from real select
72
+ const realOptions = realSelect.querySelectorAll('option');
73
+ realOptions.forEach((option, index) => {
74
+ const optionElement = templateClone.cloneNode(true);
75
+ const textSpan = optionElement.querySelector('span');
76
+
77
+ if (textSpan) {
78
+ textSpan.textContent = option.textContent;
79
+ }
80
+
81
+ // Add attributes
82
+ optionElement.setAttribute('data-value', option.value);
83
+ optionElement.setAttribute('role', 'option');
84
+ optionElement.setAttribute('id', `${selectName}-option-${index}`);
85
+ optionElement.setAttribute('tabindex', '-1');
86
+
87
+ // Set selected state if this option is selected
88
+ if (option.selected) {
89
+ optionElement.setAttribute('aria-selected', 'true');
90
+ // Update button text
91
+ const buttonText = button.querySelector('span') || button;
92
+ if (buttonText.tagName === 'SPAN') {
93
+ buttonText.textContent = option.textContent;
94
+ }
95
+ } else {
96
+ optionElement.setAttribute('aria-selected', 'false');
97
+ }
98
+
99
+ customList.appendChild(optionElement);
100
+ });
101
+
102
+ // Add ARIA attributes
103
+ customList.setAttribute('role', 'listbox');
104
+ customList.setAttribute('id', `${selectName}-listbox`);
105
+
106
+ button.setAttribute('role', 'combobox');
107
+ button.setAttribute('aria-haspopup', 'listbox');
108
+ button.setAttribute('aria-controls', `${selectName}-listbox`);
109
+ button.setAttribute('aria-expanded', 'false');
110
+ button.setAttribute('id', `${selectName}-button`);
111
+
112
+ // Find and connect label if exists
113
+ const label = wrapper.querySelector('label') ||
114
+ document.querySelector(`label[for="${realSelect.id}"]`);
115
+ if (label) {
116
+ const labelId = label.id || `${selectName}-label`;
117
+ label.id = labelId;
118
+ // Ensure real select has proper ID for label connection
119
+ if (!realSelect.id) {
120
+ realSelect.id = `${selectName}-select`;
121
+ }
122
+ label.setAttribute('for', realSelect.id);
123
+ button.setAttribute('aria-labelledby', labelId);
124
+ }
125
+
126
+ // Track state
127
+ let currentIndex = -1;
128
+ let isOpen = false;
129
+
130
+ // Update expanded state
131
+ function updateExpandedState(expanded) {
132
+ isOpen = expanded;
133
+ button.setAttribute('aria-expanded', expanded.toString());
134
+ }
135
+
136
+ // Focus option by index
137
+ function focusOption(index) {
138
+ const options = customList.querySelectorAll('[role="option"]');
139
+ if (index < 0 || index >= options.length) return;
140
+
141
+ // Remove previous focus
142
+ options.forEach(opt => {
143
+ opt.classList.remove('focused');
144
+ opt.setAttribute('tabindex', '-1');
145
+ });
146
+
147
+ // Add new focus
148
+ currentIndex = index;
149
+ options[index].classList.add('focused');
150
+ options[index].setAttribute('tabindex', '0');
151
+ options[index].focus();
152
+ button.setAttribute('aria-activedescendant', options[index].id);
153
+ }
154
+
155
+ // Select option
156
+ function selectOption(optionElement) {
157
+ const value = optionElement.getAttribute('data-value');
158
+ const text = optionElement.querySelector('span')?.textContent || optionElement.textContent;
159
+
160
+ // Update real select
161
+ realSelect.value = value;
162
+ realSelect.dispatchEvent(new Event('change', { bubbles: true }));
163
+
164
+ // Update button text
165
+ const buttonText = button.querySelector('span') || button;
166
+ if (buttonText.tagName === 'SPAN') {
167
+ buttonText.textContent = text;
168
+ }
169
+
170
+ // Update aria-selected
171
+ customList.querySelectorAll('[role="option"]').forEach(opt => {
172
+ opt.setAttribute('aria-selected', 'false');
173
+ });
174
+ optionElement.setAttribute('aria-selected', 'true');
175
+
176
+ // Click the button to close
177
+ button.click();
178
+ }
179
+
180
+ // Button keyboard events
181
+ button.addEventListener('keydown', (e) => {
182
+ switch(e.key) {
183
+ case ' ':
184
+ case 'Enter':
185
+ e.preventDefault();
186
+ button.click();
187
+ break;
188
+
189
+ case 'ArrowDown':
190
+ e.preventDefault();
191
+ if (!isOpen) {
192
+ button.click();
193
+ } else {
194
+ focusOption(0);
195
+ }
196
+ break;
197
+
198
+ case 'ArrowUp':
199
+ e.preventDefault();
200
+ if (isOpen) {
201
+ const options = customList.querySelectorAll('[role="option"]');
202
+ focusOption(options.length - 1);
203
+ }
204
+ break;
205
+
206
+ case 'Escape':
207
+ if (isOpen) {
208
+ e.preventDefault();
209
+ button.click();
210
+ }
211
+ break;
212
+ }
213
+ });
214
+
215
+ // Option keyboard events (delegated)
216
+ customList.addEventListener('keydown', (e) => {
217
+ const option = e.target.closest('[role="option"]');
218
+ if (!option) return;
219
+
220
+ const options = Array.from(customList.querySelectorAll('[role="option"]'));
221
+ const currentIdx = options.indexOf(option);
222
+
223
+ switch(e.key) {
224
+ case 'ArrowDown':
225
+ e.preventDefault();
226
+ if (currentIdx < options.length - 1) {
227
+ focusOption(currentIdx + 1);
228
+ }
229
+ break;
230
+
231
+ case 'ArrowUp':
232
+ e.preventDefault();
233
+ if (currentIdx === 0) {
234
+ button.click();
235
+ button.focus();
236
+ } else {
237
+ focusOption(currentIdx - 1);
238
+ }
239
+ break;
240
+
241
+ case 'Enter':
242
+ case ' ':
243
+ e.preventDefault();
244
+ selectOption(option);
245
+ break;
246
+
247
+ case 'Escape':
248
+ e.preventDefault();
249
+ button.click();
250
+ button.focus();
251
+ break;
252
+ }
253
+ });
254
+
255
+ // Option click events
256
+ customList.addEventListener('click', (e) => {
257
+ const option = e.target.closest('[role="option"]');
258
+ if (option) {
259
+ selectOption(option);
260
+ }
261
+ });
262
+
263
+ // Track open/close state
264
+ const observer = new MutationObserver((mutations) => {
265
+ mutations.forEach((mutation) => {
266
+ if (mutation.type === 'attributes') {
267
+ // Check if dropdown is visible
268
+ const isVisible = !customList.hidden &&
269
+ customList.style.display !== 'none' &&
270
+ !customList.classList.contains('hidden');
271
+
272
+ updateExpandedState(isVisible);
273
+
274
+ if (!isVisible) {
275
+ currentIndex = -1;
276
+ button.removeAttribute('aria-activedescendant');
277
+ // Return focus to button if it was in the list
278
+ if (document.activeElement?.closest('[data-hs-form="select-list"]') === customList) {
279
+ button.focus();
280
+ }
281
+ }
282
+ }
283
+ });
284
+ });
285
+
286
+ // Observe the custom list for visibility changes
287
+ observer.observe(customList, {
288
+ attributes: true,
289
+ attributeFilter: ['hidden', 'style', 'class']
290
+ });
291
+
292
+ // Sync with real select changes
293
+ realSelect.addEventListener('change', () => {
294
+ const selectedOption = realSelect.options[realSelect.selectedIndex];
295
+ if (selectedOption) {
296
+ const customOption = customList.querySelector(`[data-value="${selectedOption.value}"]`);
297
+ if (customOption) {
298
+ // Update button text
299
+ const text = customOption.querySelector('span')?.textContent || customOption.textContent;
300
+ const buttonText = button.querySelector('span') || button;
301
+ if (buttonText.tagName === 'SPAN') {
302
+ buttonText.textContent = text;
303
+ }
304
+
305
+ // Update aria-selected
306
+ customList.querySelectorAll('[role="option"]').forEach(opt => {
307
+ opt.setAttribute('aria-selected', 'false');
308
+ });
309
+ customOption.setAttribute('aria-selected', 'true');
310
+ }
311
+ }
312
+ });
313
+ }
314
+
315
+ // Initialize on DOM ready
316
+ if (document.readyState === 'loading') {
317
+ document.addEventListener('DOMContentLoaded', initCustomSelects);
318
+ } else {
319
+ initCustomSelects();
320
+ }
321
+
322
+ // Reinitialize for dynamic content
323
+ window.initCustomSelects = initCustomSelects;
324
+ })();
325
+
30
326
  const initializeForms = () => {
31
327
  const forms = document.querySelectorAll(config.selectors.form);
32
328
  forms.forEach(form => {
@@ -363,6 +363,16 @@ function setupDynamicDropdowns() {
363
363
  }
364
364
  });
365
365
 
366
+ toggle.addEventListener("click", function(e) {
367
+ if (e.isTrusted) {
368
+ // This is a real user click - prevent it
369
+ e.preventDefault();
370
+ e.stopPropagation();
371
+ return false;
372
+ }
373
+ // Programmatic clicks (from hover/keyboard) proceed normally
374
+ });
375
+
366
376
  document.addEventListener("click", function (e) {
367
377
  if (!wrapper.contains(e.target) && isOpen) {
368
378
  closeDropdown();
@@ -726,7 +736,7 @@ function setupMobileMenuButton() {
726
736
 
727
737
  // Cleanup function for window.mobileMenuState
728
738
  if (typeof window !== "undefined") {
729
- window.addEventListener("unload", () => {
739
+ window.addEventListener("pagehide", () => {
730
740
  if (window.mobileMenuState) {
731
741
  delete window.mobileMenuState;
732
742
  }
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // Version:1.4.0
1
+ // Version:1.4.2
2
2
 
3
3
  const API_NAME = "hsmain";
4
4
 
@@ -31,7 +31,6 @@ const initializeHsMain = async () => {
31
31
  navbar: true,
32
32
  accessibility: true,
33
33
  counter: true,
34
- "custom-values": true,
35
34
  form: true,
36
35
  };
37
36
 
@@ -55,7 +54,6 @@ const initializeHsMain = async () => {
55
54
  navbar: () => import("./autoInit/navbar.js"),
56
55
  accessibility: () => import("./autoInit/accessibility.js"),
57
56
  counter: () => import("./autoInit/counter.js"),
58
- "custom-values": () => import("./autoInit/custom-values.js"),
59
57
  form: () => import("./autoInit/form.js"),
60
58
  };
61
59
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hortonstudio/main",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Animation and utility library for client websites",
5
5
  "main": "index.js",
6
6
  "type": "module",