@hortonstudio/main 1.4.1 → 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.
- package/animations/text.js +148 -41
- package/autoInit/custom-values.js +224 -0
- package/autoInit/form.js +296 -0
- package/index.js +1 -1
- package/package.json +1 -1
package/animations/text.js
CHANGED
@@ -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(
|
113
|
-
...document.querySelectorAll(
|
114
|
-
...document.querySelectorAll(
|
115
|
-
...document.querySelectorAll("
|
116
|
-
...document.querySelectorAll("
|
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(
|
161
|
+
const elements = document.querySelectorAll('[data-hs-anim="char"]');
|
142
162
|
|
143
|
-
elements.forEach((
|
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
|
-
|
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(
|
172
|
-
.forEach((
|
197
|
+
.querySelectorAll('[data-hs-anim="char"]')
|
198
|
+
.forEach((container) => {
|
199
|
+
const textElement = findTextElement(container);
|
200
|
+
if (!textElement) return;
|
173
201
|
try {
|
174
|
-
const chars =
|
202
|
+
const chars = container.querySelectorAll(".char");
|
175
203
|
const tl = gsap.timeline({
|
176
204
|
scrollTrigger: {
|
177
|
-
trigger:
|
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 (
|
185
|
-
|
186
|
-
delete
|
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(
|
243
|
+
const elements = document.querySelectorAll('[data-hs-anim="word"]');
|
215
244
|
|
216
|
-
elements.forEach((
|
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
|
-
|
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(
|
245
|
-
.forEach((
|
279
|
+
.querySelectorAll('[data-hs-anim="word"]')
|
280
|
+
.forEach((container) => {
|
281
|
+
const textElement = findTextElement(container);
|
282
|
+
if (!textElement) return;
|
246
283
|
try {
|
247
|
-
const words =
|
284
|
+
const words = container.querySelectorAll(".word");
|
248
285
|
const tl = gsap.timeline({
|
249
286
|
scrollTrigger: {
|
250
|
-
trigger:
|
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 (
|
258
|
-
|
259
|
-
delete
|
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(
|
325
|
+
const elements = document.querySelectorAll('[data-hs-anim="line"]');
|
288
326
|
|
289
|
-
elements.forEach((
|
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
|
-
|
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(
|
318
|
-
.forEach((
|
361
|
+
.querySelectorAll('[data-hs-anim="line"]')
|
362
|
+
.forEach((container) => {
|
363
|
+
const textElement = findTextElement(container);
|
364
|
+
if (!textElement) return;
|
319
365
|
try {
|
320
|
-
const lines =
|
366
|
+
const lines = container.querySelectorAll(".line");
|
321
367
|
const tl = gsap.timeline({
|
322
368
|
scrollTrigger: {
|
323
|
-
trigger:
|
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 (
|
331
|
-
|
332
|
-
delete
|
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("
|
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("
|
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("
|
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("
|
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
|
-
"
|
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
|
-
"
|
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