@hortonstudio/main 1.2.35 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/settings.local.json +22 -1
- package/TEMP-before-after-attributes.md +158 -0
- package/animations/hero.js +741 -611
- package/animations/text.js +532 -317
- package/animations/transition.js +36 -21
- package/autoInit/accessibility.js +173 -50
- package/autoInit/counter.js +338 -0
- package/autoInit/form.js +471 -0
- package/autoInit/modal.js +43 -38
- package/autoInit/navbar.js +494 -371
- package/autoInit/smooth-scroll.js +86 -84
- package/index.js +138 -88
- package/package.json +1 -1
- package/utils/before-after.js +279 -146
- package/utils/scroll-progress.js +26 -21
- package/utils/toc.js +73 -66
- package/CLAUDE.md +0 -45
- package/debug-version.html +0 -37
package/animations/transition.js
CHANGED
@@ -1,16 +1,15 @@
|
|
1
1
|
// Page Transition Module
|
2
|
-
const API_NAME =
|
2
|
+
const API_NAME = "hsmain";
|
3
3
|
export async function init() {
|
4
|
-
|
5
4
|
// Register the transition logic to run after library is ready
|
6
5
|
window[API_NAME].afterReady(() => {
|
7
6
|
// Only run if jQuery is available
|
8
|
-
if (typeof $ !==
|
7
|
+
if (typeof $ !== "undefined") {
|
9
8
|
initTransitions();
|
10
9
|
}
|
11
10
|
});
|
12
|
-
|
13
|
-
return { result:
|
11
|
+
|
12
|
+
return { result: "anim-transition initialized" };
|
14
13
|
}
|
15
14
|
|
16
15
|
function initTransitions() {
|
@@ -18,10 +17,9 @@ function initTransitions() {
|
|
18
17
|
let introDurationMS = 800;
|
19
18
|
let exitDurationMS = 400;
|
20
19
|
let excludedClass = "no-transition";
|
21
|
-
|
20
|
+
|
22
21
|
// On Page Load
|
23
22
|
if (transitionTrigger.length > 0) {
|
24
|
-
|
25
23
|
function triggerTransition() {
|
26
24
|
if (window.Webflow && window.Webflow.push) {
|
27
25
|
Webflow.push(function () {
|
@@ -34,34 +32,51 @@ function initTransitions() {
|
|
34
32
|
}, 100);
|
35
33
|
}
|
36
34
|
$("body").addClass("no-scroll-transition");
|
37
|
-
setTimeout(() => {
|
35
|
+
setTimeout(() => {
|
36
|
+
$("body").removeClass("no-scroll-transition");
|
37
|
+
}, introDurationMS);
|
38
38
|
}
|
39
|
-
|
39
|
+
|
40
40
|
// Wait for full page load (images, fonts, etc.) for Safari
|
41
|
-
if (document.readyState ===
|
41
|
+
if (document.readyState === "complete") {
|
42
42
|
triggerTransition();
|
43
43
|
} else {
|
44
|
-
window.addEventListener(
|
44
|
+
window.addEventListener("load", triggerTransition, { once: true });
|
45
45
|
}
|
46
46
|
}
|
47
|
-
|
47
|
+
|
48
48
|
// On Link Click
|
49
49
|
$("a").on("click", function (e) {
|
50
|
-
if (
|
51
|
-
|
50
|
+
if (
|
51
|
+
$(this).prop("hostname") == window.location.host &&
|
52
|
+
$(this).attr("href").indexOf("#") === -1 &&
|
53
|
+
!$(this).hasClass(excludedClass) &&
|
54
|
+
$(this).attr("target") !== "_blank" &&
|
55
|
+
transitionTrigger.length > 0
|
56
|
+
) {
|
52
57
|
e.preventDefault();
|
53
58
|
$("body").addClass("no-scroll-transition");
|
54
59
|
let transitionURL = $(this).attr("href");
|
55
60
|
transitionTrigger.click();
|
56
|
-
setTimeout(function () {
|
61
|
+
setTimeout(function () {
|
62
|
+
window.location = transitionURL;
|
63
|
+
}, exitDurationMS);
|
57
64
|
}
|
58
65
|
});
|
59
|
-
|
66
|
+
|
60
67
|
// On Back Button Tap
|
61
|
-
window.onpageshow = function
|
62
|
-
|
68
|
+
window.onpageshow = function (event) {
|
69
|
+
if (event.persisted) {
|
70
|
+
window.location.reload();
|
71
|
+
}
|
72
|
+
};
|
73
|
+
|
63
74
|
// Hide Transition on Window Width Resize
|
64
|
-
setTimeout(() => {
|
65
|
-
|
75
|
+
setTimeout(() => {
|
76
|
+
$(window).on("resize", function () {
|
77
|
+
setTimeout(() => {
|
78
|
+
$(".transition").css("display", "none");
|
79
|
+
}, 50);
|
80
|
+
});
|
66
81
|
}, introDurationMS);
|
67
|
-
}
|
82
|
+
}
|
@@ -1,71 +1,194 @@
|
|
1
1
|
export function init() {
|
2
|
-
|
3
|
-
|
2
|
+
// General accessibility features can be added here
|
3
|
+
// Stats accessibility has been moved to counter.js
|
4
|
+
|
5
|
+
function setupGeneralAccessibility() {
|
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"]');
|
4
16
|
|
5
|
-
|
6
|
-
|
7
|
-
|
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);
|
8
41
|
|
9
|
-
|
10
|
-
|
11
|
-
|
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';
|
12
54
|
|
13
|
-
|
14
|
-
|
55
|
+
button.setAttribute('aria-expanded', !isOpen);
|
56
|
+
contentWrapper.setAttribute('aria-hidden', isOpen);
|
15
57
|
}
|
58
|
+
|
59
|
+
button.addEventListener('click', toggleFAQ);
|
60
|
+
|
61
|
+
container.removeAttribute('data-hs-a11y');
|
16
62
|
});
|
17
63
|
}
|
18
64
|
|
19
|
-
function
|
20
|
-
const
|
65
|
+
function setupConvertToSpan() {
|
66
|
+
const containers = document.querySelectorAll('[data-hs-a11y="convert-span"]');
|
21
67
|
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
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('*');
|
28
78
|
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
if (
|
33
|
-
|
34
|
-
|
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);
|
35
99
|
}
|
36
|
-
}
|
100
|
+
});
|
37
101
|
|
38
|
-
//
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
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);
|
43
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');
|
44
124
|
}
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
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
|
+
}
|
49
139
|
}
|
140
|
+
);
|
141
|
+
|
142
|
+
const textNodes = [];
|
143
|
+
let node;
|
144
|
+
while (node = walker.nextNode()) {
|
145
|
+
textNodes.push(node);
|
50
146
|
}
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
.trim();
|
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
|
+
});
|
59
154
|
}
|
60
155
|
|
61
|
-
function
|
62
|
-
|
63
|
-
setTimeout(() => {
|
64
|
-
setupStatsAccessibility();
|
65
|
-
}, 100);
|
156
|
+
function setupPreventDefault() {
|
157
|
+
const elements = document.querySelectorAll('[data-hs-a11y="prevent-default"]');
|
66
158
|
|
67
|
-
|
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
|
+
});
|
68
190
|
}
|
69
191
|
|
70
|
-
|
71
|
-
}
|
192
|
+
setupGeneralAccessibility();
|
193
|
+
return { result: "accessibility initialized" };
|
194
|
+
}
|
@@ -0,0 +1,338 @@
|
|
1
|
+
// Named constants
|
2
|
+
const ACCESSIBILITY_UPDATE_DELAY = 100;
|
3
|
+
|
4
|
+
export function init() {
|
5
|
+
const config = {
|
6
|
+
duration: 3000,
|
7
|
+
keyboardStep: 5,
|
8
|
+
observerThreshold: 0,
|
9
|
+
observerRootMargin: "0px",
|
10
|
+
};
|
11
|
+
|
12
|
+
let counters = [];
|
13
|
+
let observer = null;
|
14
|
+
|
15
|
+
function updateConfig(newConfig) {
|
16
|
+
Object.assign(config, newConfig);
|
17
|
+
}
|
18
|
+
|
19
|
+
// Power4 out easing function
|
20
|
+
function easeOutQuart(t) {
|
21
|
+
return 1 - Math.pow(1 - t, 4);
|
22
|
+
}
|
23
|
+
|
24
|
+
// Extract number and symbols from text
|
25
|
+
function parseText(text) {
|
26
|
+
const match = text.match(/([+\-]?)(\d*\.?\d+)([%+\-]?.*)/);
|
27
|
+
if (!match) return null;
|
28
|
+
|
29
|
+
return {
|
30
|
+
prefix: match[1] || "",
|
31
|
+
number: parseFloat(match[2]),
|
32
|
+
suffix: match[3] || "",
|
33
|
+
};
|
34
|
+
}
|
35
|
+
|
36
|
+
// Format number back to text with symbols
|
37
|
+
function formatNumber(value, prefix, suffix, decimals = 0) {
|
38
|
+
const formattedNumber =
|
39
|
+
decimals > 0 ? value.toFixed(decimals) : Math.round(value);
|
40
|
+
return `${prefix}${formattedNumber}${suffix}`;
|
41
|
+
}
|
42
|
+
|
43
|
+
// Check for reduced motion preference
|
44
|
+
function prefersReducedMotion() {
|
45
|
+
return (
|
46
|
+
window.matchMedia &&
|
47
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
48
|
+
);
|
49
|
+
}
|
50
|
+
|
51
|
+
// Animate a single counter by manipulating text content
|
52
|
+
function animateCounter(
|
53
|
+
element,
|
54
|
+
targetValue,
|
55
|
+
prefix,
|
56
|
+
suffix,
|
57
|
+
decimals,
|
58
|
+
duration = config.duration,
|
59
|
+
) {
|
60
|
+
// If user prefers reduced motion, show final value immediately
|
61
|
+
if (prefersReducedMotion()) {
|
62
|
+
const originalContent = element.getAttribute("data-hs-original");
|
63
|
+
if (originalContent) {
|
64
|
+
element.textContent = originalContent;
|
65
|
+
}
|
66
|
+
updateStatsAccessibility();
|
67
|
+
return;
|
68
|
+
}
|
69
|
+
|
70
|
+
const startTime = Date.now();
|
71
|
+
const startValue = 0;
|
72
|
+
|
73
|
+
const animate = () => {
|
74
|
+
const elapsed = Date.now() - startTime;
|
75
|
+
const progress = Math.min(elapsed / duration, 1);
|
76
|
+
|
77
|
+
// Apply power4 out easing
|
78
|
+
const easedProgress = easeOutQuart(progress);
|
79
|
+
const currentValue =
|
80
|
+
startValue + (targetValue - startValue) * easedProgress;
|
81
|
+
|
82
|
+
// Update the text content directly
|
83
|
+
const animatedText = formatNumber(currentValue, prefix, suffix, decimals);
|
84
|
+
element.textContent = animatedText;
|
85
|
+
|
86
|
+
if (progress < 1) {
|
87
|
+
requestAnimationFrame(animate);
|
88
|
+
} else {
|
89
|
+
// Animation complete - restore original content
|
90
|
+
const originalContent = element.getAttribute("data-hs-original");
|
91
|
+
if (originalContent) {
|
92
|
+
element.textContent = originalContent;
|
93
|
+
}
|
94
|
+
|
95
|
+
// Update accessibility after animation completes
|
96
|
+
updateStatsAccessibility();
|
97
|
+
}
|
98
|
+
};
|
99
|
+
|
100
|
+
// Ensure we start from 0
|
101
|
+
element.textContent = formatNumber(0, prefix, suffix, decimals);
|
102
|
+
|
103
|
+
// Start animation on next frame
|
104
|
+
requestAnimationFrame(animate);
|
105
|
+
}
|
106
|
+
|
107
|
+
// Set up intersection observer
|
108
|
+
function setupObserver() {
|
109
|
+
const options = {
|
110
|
+
root: null,
|
111
|
+
rootMargin: "0px",
|
112
|
+
threshold: 0,
|
113
|
+
};
|
114
|
+
|
115
|
+
observer = new IntersectionObserver((entries) => {
|
116
|
+
// Group entries by their container to animate together
|
117
|
+
const containersToAnimate = new Set();
|
118
|
+
|
119
|
+
entries.forEach((entry) => {
|
120
|
+
if (!entry.target.dataset.animated) {
|
121
|
+
// Check if bottom of viewport reaches top of element
|
122
|
+
const rect = entry.target.getBoundingClientRect();
|
123
|
+
const viewportHeight = window.innerHeight;
|
124
|
+
|
125
|
+
if (rect.top <= viewportHeight) {
|
126
|
+
// Find the parent container (like stats section)
|
127
|
+
const container =
|
128
|
+
entry.target.closest('[data-hs-accessibility="stats"]') ||
|
129
|
+
entry.target.closest(".g_content") ||
|
130
|
+
entry.target;
|
131
|
+
containersToAnimate.add(container);
|
132
|
+
}
|
133
|
+
}
|
134
|
+
});
|
135
|
+
|
136
|
+
// Animate all counters in each container simultaneously
|
137
|
+
containersToAnimate.forEach((container) => {
|
138
|
+
const countersInContainer = container.querySelectorAll(
|
139
|
+
'[data-hs="stats-counter"]:not([data-animated])',
|
140
|
+
);
|
141
|
+
countersInContainer.forEach((counter) => {
|
142
|
+
startAnimation(counter);
|
143
|
+
counter.dataset.animated = "true";
|
144
|
+
});
|
145
|
+
});
|
146
|
+
}, options);
|
147
|
+
}
|
148
|
+
|
149
|
+
// Start animation for a specific element
|
150
|
+
function startAnimation(element) {
|
151
|
+
// Get original content from data attribute (the real value to animate to)
|
152
|
+
const originalText = element.getAttribute("data-hs-original");
|
153
|
+
if (!originalText) return;
|
154
|
+
|
155
|
+
const parsed = parseText(originalText);
|
156
|
+
if (!parsed) {
|
157
|
+
return;
|
158
|
+
}
|
159
|
+
|
160
|
+
// Determine decimal places from original number
|
161
|
+
const decimals = (parsed.number.toString().split(".")[1] || "").length;
|
162
|
+
|
163
|
+
// Start animation
|
164
|
+
animateCounter(
|
165
|
+
element,
|
166
|
+
parsed.number,
|
167
|
+
parsed.prefix,
|
168
|
+
parsed.suffix,
|
169
|
+
decimals,
|
170
|
+
config.duration,
|
171
|
+
);
|
172
|
+
}
|
173
|
+
|
174
|
+
// Stats accessibility functionality (moved from accessibility.js)
|
175
|
+
function setupStatsAccessibility() {
|
176
|
+
const statsElements = document.querySelectorAll(
|
177
|
+
'[data-hs-accessibility="stats"]',
|
178
|
+
);
|
179
|
+
|
180
|
+
statsElements.forEach((element) => {
|
181
|
+
// Get all text content from the element and its children
|
182
|
+
const textContent = extractTextContent(element);
|
183
|
+
|
184
|
+
if (textContent) {
|
185
|
+
element.setAttribute("aria-label", textContent);
|
186
|
+
element.setAttribute("role", "img");
|
187
|
+
|
188
|
+
// Hide all child elements from screen readers to prevent redundant reading
|
189
|
+
const allChildren = element.querySelectorAll("*");
|
190
|
+
allChildren.forEach((child) => {
|
191
|
+
child.setAttribute("aria-hidden", "true");
|
192
|
+
});
|
193
|
+
}
|
194
|
+
});
|
195
|
+
}
|
196
|
+
|
197
|
+
function extractTextContent(element) {
|
198
|
+
const textParts = [];
|
199
|
+
|
200
|
+
function processNode(node) {
|
201
|
+
// Skip elements with aria-hidden="true"
|
202
|
+
if (
|
203
|
+
node.nodeType === Node.ELEMENT_NODE &&
|
204
|
+
node.getAttribute("aria-hidden") === "true"
|
205
|
+
) {
|
206
|
+
return;
|
207
|
+
}
|
208
|
+
|
209
|
+
// Use original content if available for counter elements
|
210
|
+
if (
|
211
|
+
node.nodeType === Node.ELEMENT_NODE &&
|
212
|
+
node.hasAttribute &&
|
213
|
+
node.hasAttribute("data-hs-original")
|
214
|
+
) {
|
215
|
+
const originalText = node.getAttribute("data-hs-original");
|
216
|
+
if (originalText) {
|
217
|
+
textParts.push(originalText);
|
218
|
+
return; // Don't process children
|
219
|
+
}
|
220
|
+
}
|
221
|
+
|
222
|
+
// Process text nodes
|
223
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
224
|
+
const text = node.textContent.trim();
|
225
|
+
if (text && text.length > 0) {
|
226
|
+
textParts.push(text);
|
227
|
+
}
|
228
|
+
}
|
229
|
+
|
230
|
+
// Process child nodes
|
231
|
+
if (node.childNodes) {
|
232
|
+
node.childNodes.forEach(processNode);
|
233
|
+
}
|
234
|
+
}
|
235
|
+
|
236
|
+
processNode(element);
|
237
|
+
|
238
|
+
// Combine all text parts with spaces and clean up
|
239
|
+
return textParts.join(" ").replace(/\s+/g, " ").trim();
|
240
|
+
}
|
241
|
+
|
242
|
+
function updateStatsAccessibility() {
|
243
|
+
// Update accessibility after counter animations complete
|
244
|
+
setTimeout(() => {
|
245
|
+
setupStatsAccessibility();
|
246
|
+
}, ACCESSIBILITY_UPDATE_DELAY);
|
247
|
+
}
|
248
|
+
|
249
|
+
// Initialize all counters
|
250
|
+
function initCounters() {
|
251
|
+
// Find all elements with data-hs="stats-counter"
|
252
|
+
const counterElements = document.querySelectorAll(
|
253
|
+
'[data-hs="stats-counter"]',
|
254
|
+
);
|
255
|
+
|
256
|
+
counterElements.forEach((element) => {
|
257
|
+
// Parse and validate the text contains a number
|
258
|
+
const parsed = parseText(element.textContent.trim());
|
259
|
+
if (parsed) {
|
260
|
+
// Store original content for accessibility and copy/paste
|
261
|
+
element.setAttribute("data-hs-original", element.textContent.trim());
|
262
|
+
|
263
|
+
// Keep original content visible until animation starts
|
264
|
+
// This ensures copy/paste gets real values before animation
|
265
|
+
|
266
|
+
counters.push(element);
|
267
|
+
}
|
268
|
+
});
|
269
|
+
|
270
|
+
// Set up intersection observer
|
271
|
+
setupObserver();
|
272
|
+
|
273
|
+
// Observe all counter elements
|
274
|
+
counters.forEach((counter) => {
|
275
|
+
observer.observe(counter);
|
276
|
+
});
|
277
|
+
|
278
|
+
// Set up initial accessibility (will use data-hs-original for screen readers)
|
279
|
+
setupStatsAccessibility();
|
280
|
+
}
|
281
|
+
|
282
|
+
// Method to manually trigger animation (useful for testing)
|
283
|
+
function triggerAnimation(selector) {
|
284
|
+
const element = document.querySelector(selector);
|
285
|
+
if (element && !element.dataset.animated) {
|
286
|
+
startAnimation(element);
|
287
|
+
element.dataset.animated = "true";
|
288
|
+
}
|
289
|
+
}
|
290
|
+
|
291
|
+
// Reset all counters (useful for re-triggering animations)
|
292
|
+
function reset() {
|
293
|
+
counters.forEach((element) => {
|
294
|
+
delete element.dataset.animated;
|
295
|
+
// Remove any overlay elements and restore original styling
|
296
|
+
const overlay = element.querySelector(
|
297
|
+
'span[style*="position: absolute"]',
|
298
|
+
);
|
299
|
+
if (overlay) {
|
300
|
+
overlay.remove();
|
301
|
+
}
|
302
|
+
element.style.removeProperty("position");
|
303
|
+
element.style.removeProperty("color");
|
304
|
+
});
|
305
|
+
|
306
|
+
// Reset observer if it exists
|
307
|
+
if (observer) {
|
308
|
+
counters.forEach((counter) => {
|
309
|
+
observer.unobserve(counter);
|
310
|
+
});
|
311
|
+
|
312
|
+
// Re-observe all counters
|
313
|
+
counters.forEach((counter) => {
|
314
|
+
observer.observe(counter);
|
315
|
+
});
|
316
|
+
}
|
317
|
+
|
318
|
+
// Reset accessibility
|
319
|
+
setupStatsAccessibility();
|
320
|
+
}
|
321
|
+
|
322
|
+
// Initialize everything
|
323
|
+
initCounters();
|
324
|
+
|
325
|
+
// Global API exposure
|
326
|
+
if (typeof window !== "undefined") {
|
327
|
+
window.hsmain = window.hsmain || {};
|
328
|
+
window.hsmain.counter = {
|
329
|
+
config,
|
330
|
+
updateConfig,
|
331
|
+
triggerAnimation,
|
332
|
+
reset,
|
333
|
+
counters,
|
334
|
+
};
|
335
|
+
}
|
336
|
+
|
337
|
+
return { result: "counter initialized" };
|
338
|
+
}
|