@hortonstudio/main 1.4.1 → 1.4.3

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,22 +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
207
  invalidateOnRefresh: true,
180
208
  toggleActions: "play none none none",
181
209
  once: true,
182
210
  },
183
211
  onComplete: () => {
184
- if (textElement.splitTextInstance) {
185
- textElement.splitTextInstance.revert();
186
- delete textElement.splitTextInstance;
212
+ if (container.splitTextInstance) {
213
+ container.splitTextInstance.revert();
214
+ delete container.splitTextInstance;
215
+ container.removeAttribute('data-split-processed');
187
216
  }
188
217
  },
189
218
  });
@@ -211,16 +240,22 @@ const WordSplitAnimations = {
211
240
  return;
212
241
  }
213
242
 
214
- const elements = document.querySelectorAll(".a-word-split > *:first-child");
243
+ const elements = document.querySelectorAll('[data-hs-anim="word"]');
215
244
 
216
- 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;
217
251
  try {
218
252
  const split = SplitText.create(textElement, {
219
253
  type: "words",
220
254
  mask: "words",
221
255
  wordsClass: "word",
222
256
  });
223
- textElement.splitTextInstance = split;
257
+ container.splitTextInstance = split;
258
+ container.setAttribute('data-split-processed', 'true');
224
259
 
225
260
  gsap.set(split.words, {
226
261
  yPercent: config.wordSplit.yPercent,
@@ -241,22 +276,25 @@ const WordSplitAnimations = {
241
276
  }
242
277
 
243
278
  document
244
- .querySelectorAll(".a-word-split > *:first-child")
245
- .forEach((textElement) => {
279
+ .querySelectorAll('[data-hs-anim="word"]')
280
+ .forEach((container) => {
281
+ const textElement = findTextElement(container);
282
+ if (!textElement) return;
246
283
  try {
247
- const words = textElement.querySelectorAll(".word");
284
+ const words = container.querySelectorAll(".word");
248
285
  const tl = gsap.timeline({
249
286
  scrollTrigger: {
250
- trigger: textElement,
287
+ trigger: container,
251
288
  start: config.wordSplit.start,
252
289
  invalidateOnRefresh: true,
253
290
  toggleActions: "play none none none",
254
291
  once: true,
255
292
  },
256
293
  onComplete: () => {
257
- if (textElement.splitTextInstance) {
258
- textElement.splitTextInstance.revert();
259
- delete textElement.splitTextInstance;
294
+ if (container.splitTextInstance) {
295
+ container.splitTextInstance.revert();
296
+ delete container.splitTextInstance;
297
+ container.removeAttribute('data-split-processed');
260
298
  }
261
299
  },
262
300
  });
@@ -284,16 +322,22 @@ const LineSplitAnimations = {
284
322
  return;
285
323
  }
286
324
 
287
- const elements = document.querySelectorAll(".a-line-split > *:first-child");
325
+ const elements = document.querySelectorAll('[data-hs-anim="line"]');
288
326
 
289
- 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;
290
333
  try {
291
334
  const split = SplitText.create(textElement, {
292
335
  type: "lines",
293
336
  mask: "lines",
294
337
  linesClass: "line",
295
338
  });
296
- textElement.splitTextInstance = split;
339
+ container.splitTextInstance = split;
340
+ container.setAttribute('data-split-processed', 'true');
297
341
 
298
342
  gsap.set(split.lines, {
299
343
  yPercent: config.lineSplit.yPercent,
@@ -314,22 +358,25 @@ const LineSplitAnimations = {
314
358
  }
315
359
 
316
360
  document
317
- .querySelectorAll(".a-line-split > *:first-child")
318
- .forEach((textElement) => {
361
+ .querySelectorAll('[data-hs-anim="line"]')
362
+ .forEach((container) => {
363
+ const textElement = findTextElement(container);
364
+ if (!textElement) return;
319
365
  try {
320
- const lines = textElement.querySelectorAll(".line");
366
+ const lines = container.querySelectorAll(".line");
321
367
  const tl = gsap.timeline({
322
368
  scrollTrigger: {
323
- trigger: textElement,
369
+ trigger: container,
324
370
  start: config.lineSplit.start,
325
371
  invalidateOnRefresh: true,
326
372
  toggleActions: "play none none none",
327
373
  once: true,
328
374
  },
329
375
  onComplete: () => {
330
- if (textElement.splitTextInstance) {
331
- textElement.splitTextInstance.revert();
332
- delete textElement.splitTextInstance;
376
+ if (container.splitTextInstance) {
377
+ container.splitTextInstance.revert();
378
+ delete container.splitTextInstance;
379
+ container.removeAttribute('data-split-processed');
333
380
  }
334
381
  },
335
382
  });
@@ -357,7 +404,7 @@ const AppearAnimations = {
357
404
  return;
358
405
  }
359
406
 
360
- const elements = document.querySelectorAll(".a-appear");
407
+ const elements = document.querySelectorAll('[data-hs-anim="appear"]');
361
408
  elements.forEach((element) => {
362
409
  try {
363
410
  gsap.set(element, {
@@ -377,7 +424,7 @@ const AppearAnimations = {
377
424
  return;
378
425
  }
379
426
 
380
- document.querySelectorAll(".a-appear").forEach((element) => {
427
+ document.querySelectorAll('[data-hs-anim="appear"]').forEach((element) => {
381
428
  try {
382
429
  const tl = gsap.timeline({
383
430
  scrollTrigger: {
@@ -412,7 +459,7 @@ const RevealAnimations = {
412
459
  return;
413
460
  }
414
461
 
415
- const elements = document.querySelectorAll(".a-reveal");
462
+ const elements = document.querySelectorAll('[data-hs-anim="reveal"]');
416
463
  elements.forEach((element) => {
417
464
  try {
418
465
  gsap.set(element, {
@@ -432,7 +479,7 @@ const RevealAnimations = {
432
479
  return;
433
480
  }
434
481
 
435
- document.querySelectorAll(".a-reveal").forEach((element) => {
482
+ document.querySelectorAll('[data-hs-anim="reveal"]').forEach((element) => {
436
483
  try {
437
484
  const tl = gsap.timeline({
438
485
  scrollTrigger: {
@@ -459,6 +506,64 @@ const RevealAnimations = {
459
506
  },
460
507
  };
461
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
+
462
567
  async function setInitialStates() {
463
568
  await Promise.all([
464
569
  CharSplitAnimations.initial(),
@@ -466,6 +571,7 @@ async function setInitialStates() {
466
571
  LineSplitAnimations.initial(),
467
572
  AppearAnimations.initial(),
468
573
  RevealAnimations.initial(),
574
+ GroupAnimations.initial(),
469
575
  ]);
470
576
  }
471
577
 
@@ -482,6 +588,7 @@ async function initAnimations() {
482
588
  LineSplitAnimations.animate(),
483
589
  AppearAnimations.animate(),
484
590
  RevealAnimations.animate(),
591
+ GroupAnimations.animate(),
485
592
  ]);
486
593
  }
487
594
 
@@ -493,7 +600,7 @@ export async function init() {
493
600
  if (
494
601
  elements.length === 0 &&
495
602
  document.querySelectorAll(
496
- ".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])',
497
604
  ).length === 0
498
605
  ) {
499
606
  return { result: "anim-text already initialized", destroy: () => {} };
@@ -503,7 +610,7 @@ export async function init() {
503
610
  elements.forEach((el) => el.setAttribute("data-initialized", "true"));
504
611
  document
505
612
  .querySelectorAll(
506
- ".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"]',
507
614
  )
508
615
  .forEach((el) => {
509
616
  el.setAttribute("data-initialized", "true");
@@ -0,0 +1,224 @@
1
+ export function init() {
2
+ const customValues = new Map();
3
+ let isInitialized = false;
4
+
5
+ const config = {
6
+ searchAttributes: [
7
+ 'href', 'src', 'alt', 'title', 'aria-label', 'data-src',
8
+ 'data-href', 'action', 'placeholder', 'value'
9
+ ],
10
+ excludeSelectors: [
11
+ 'script', 'style', 'meta', 'link', 'title', 'head',
12
+ '[data-hs-custom="list"]', '[data-hs-custom="name"]', '[data-hs-custom="value"]'
13
+ ],
14
+ phoneFormatting: {
15
+ telAttributes: ['href'],
16
+ phonePattern: /^[\+]?[1-9]?[\d\s\-\(\)\.]{7,15}$/,
17
+ defaultCountryCode: '+1',
18
+ cleanForTel: (phone) => {
19
+ const cleaned = phone.replace(/[^\d+]/g, '');
20
+ if (!cleaned.startsWith('+')) {
21
+ return config.phoneFormatting.defaultCountryCode + cleaned;
22
+ }
23
+ return cleaned;
24
+ },
25
+ formatForDisplay: (phone) => phone
26
+ }
27
+ };
28
+
29
+ function isPhoneNumber(value) {
30
+ return config.phoneFormatting.phonePattern.test(value.trim());
31
+ }
32
+
33
+ function formatValueForContext(value, isAttribute, attributeName) {
34
+ if (isPhoneNumber(value)) {
35
+ if (isAttribute && config.phoneFormatting.telAttributes.includes(attributeName)) {
36
+ return config.phoneFormatting.cleanForTel(value);
37
+ } else {
38
+ return config.phoneFormatting.formatForDisplay(value);
39
+ }
40
+ }
41
+ return value;
42
+ }
43
+
44
+ function extractCustomValues() {
45
+ const currentYear = new Date().getFullYear().toString();
46
+ customValues.set('{{year}}', currentYear);
47
+
48
+ const customList = document.querySelector('[data-hs-custom="list"]');
49
+ if (!customList) {
50
+ return customValues.size > 0;
51
+ }
52
+
53
+ const nameElements = customList.querySelectorAll('[data-hs-custom="name"]');
54
+ const valueElements = customList.querySelectorAll('[data-hs-custom="value"]');
55
+
56
+ for (let i = 0; i < Math.min(nameElements.length, valueElements.length); i++) {
57
+ const name = nameElements[i].textContent.trim();
58
+ const value = valueElements[i].textContent.trim();
59
+
60
+ if (name && value) {
61
+ const key = `{{${name.toLowerCase()}}}`;
62
+ customValues.set(key, value);
63
+ }
64
+ }
65
+
66
+ return customValues.size > 0;
67
+ }
68
+
69
+ function replaceInText(text, isAttribute = false, attributeName = null) {
70
+ if (!text || typeof text !== 'string') return text;
71
+
72
+ let result = text;
73
+
74
+ customValues.forEach((value, placeholder) => {
75
+ const regex = new RegExp(placeholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
76
+ const matches = text.match(regex);
77
+ if (matches) {
78
+ const formattedValue = formatValueForContext(value, isAttribute, attributeName);
79
+ result = result.replace(regex, formattedValue);
80
+ }
81
+ });
82
+
83
+ return result;
84
+ }
85
+
86
+ function replaceInAttributes(element) {
87
+ config.searchAttributes.forEach(attr => {
88
+ const value = element.getAttribute(attr);
89
+ if (value) {
90
+ const newValue = replaceInText(value, true, attr);
91
+ if (newValue !== value) {
92
+ element.setAttribute(attr, newValue);
93
+ }
94
+ }
95
+ });
96
+ }
97
+
98
+ function shouldExcludeElement(element) {
99
+ return config.excludeSelectors.some(selector => {
100
+ return element.matches(selector);
101
+ });
102
+ }
103
+
104
+ function processTextNodes(element) {
105
+ const walker = document.createTreeWalker(
106
+ element,
107
+ NodeFilter.SHOW_TEXT,
108
+ {
109
+ acceptNode: (node) => {
110
+ if (shouldExcludeElement(node.parentElement)) {
111
+ return NodeFilter.FILTER_REJECT;
112
+ }
113
+
114
+ return node.textContent.includes('{{') && node.textContent.includes('}}')
115
+ ? NodeFilter.FILTER_ACCEPT
116
+ : NodeFilter.FILTER_SKIP;
117
+ }
118
+ }
119
+ );
120
+
121
+ const textNodes = [];
122
+ let node;
123
+ while (node = walker.nextNode()) {
124
+ textNodes.push(node);
125
+ }
126
+
127
+ textNodes.forEach(textNode => {
128
+ const originalText = textNode.textContent;
129
+ const newText = replaceInText(originalText);
130
+ if (newText !== originalText) {
131
+ textNode.textContent = newText;
132
+ }
133
+ });
134
+ }
135
+
136
+ function processElements(container) {
137
+ const elements = container.querySelectorAll('*');
138
+
139
+ elements.forEach(element => {
140
+ if (!shouldExcludeElement(element)) {
141
+ replaceInAttributes(element);
142
+ }
143
+ });
144
+ }
145
+
146
+ function performReplacements() {
147
+ if (customValues.size === 0) return;
148
+
149
+ processTextNodes(document.body);
150
+ processElements(document.body);
151
+ replaceInAttributes(document.documentElement);
152
+ }
153
+
154
+ function cleanupCustomList() {
155
+ const customList = document.querySelector('[data-hs-custom="list"]');
156
+ if (customList) {
157
+ customList.remove();
158
+ }
159
+ }
160
+
161
+ function setupDynamicContentHandler() {
162
+ const observer = new MutationObserver((mutations) => {
163
+ let hasNewContent = false;
164
+
165
+ mutations.forEach((mutation) => {
166
+ if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
167
+ mutation.addedNodes.forEach((node) => {
168
+ if (node.nodeType === Node.ELEMENT_NODE) {
169
+ const hasPlaceholders = node.textContent.includes('{{') && node.textContent.includes('}}');
170
+ const hasAttributePlaceholders = config.searchAttributes.some(attr => {
171
+ const value = node.getAttribute?.(attr);
172
+ return value && value.includes('{{') && value.includes('}}');
173
+ });
174
+
175
+ if (hasPlaceholders || hasAttributePlaceholders) {
176
+ hasNewContent = true;
177
+ }
178
+ }
179
+ });
180
+ }
181
+ });
182
+
183
+ if (hasNewContent && customValues.size > 0) {
184
+ clearTimeout(observer.timeout);
185
+ observer.timeout = setTimeout(() => {
186
+ performReplacements();
187
+ }, 100);
188
+ }
189
+ });
190
+
191
+ observer.observe(document.body, {
192
+ childList: true,
193
+ subtree: true
194
+ });
195
+
196
+ return observer;
197
+ }
198
+
199
+ function initializeCustomValues() {
200
+ if (isInitialized) return;
201
+
202
+ const hasCustomValues = extractCustomValues();
203
+
204
+ if (hasCustomValues) {
205
+ performReplacements();
206
+ cleanupCustomList();
207
+ setupDynamicContentHandler();
208
+
209
+ isInitialized = true;
210
+
211
+ return {
212
+ result: `custom-values initialized with ${customValues.size} replacements`,
213
+ count: customValues.size
214
+ };
215
+ } else {
216
+ return {
217
+ result: 'custom-values initialized (no custom values found)',
218
+ count: 0
219
+ };
220
+ }
221
+ }
222
+
223
+ return initializeCustomValues();
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 => {
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // Version:1.4.1
1
+ // Version:1.4.3
2
2
 
3
3
  const API_NAME = "hsmain";
4
4
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hortonstudio/main",
3
- "version": "1.4.1",
3
+ "version": "1.4.3",
4
4
  "description": "Animation and utility library for client websites",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/styles.css CHANGED
@@ -9,6 +9,7 @@ body .transition {display: block}
9
9
  margin-bottom: -.1em;
10
10
  padding-inline: .1em;
11
11
  margin-inline: -.1em;
12
+ will-change: transform, opacity;
12
13
  }
13
14
 
14
15