@hortonstudio/main 1.2.35 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,16 +1,15 @@
1
1
  // Page Transition Module
2
- const API_NAME = 'hsmain';
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 $ !== 'undefined') {
7
+ if (typeof $ !== "undefined") {
9
8
  initTransitions();
10
9
  }
11
10
  });
12
-
13
- return { result: 'anim-transition initialized' };
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(() => {$("body").removeClass("no-scroll-transition");}, introDurationMS);
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 === 'complete') {
41
+ if (document.readyState === "complete") {
42
42
  triggerTransition();
43
43
  } else {
44
- window.addEventListener('load', triggerTransition, { once: true });
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 ($(this).prop("hostname") == window.location.host && $(this).attr("href").indexOf("#") === -1 &&
51
- !$(this).hasClass(excludedClass) && $(this).attr("target") !== "_blank" && transitionTrigger.length > 0) {
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 () {window.location = transitionURL;}, exitDurationMS);
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(event) {if (event.persisted) {window.location.reload()}};
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(() => {$(window).on("resize", function () {
65
- setTimeout(() => {$(".transition").css("display", "none");}, 50);});
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,11 @@
1
1
  export function init() {
2
- function setupStatsAccessibility() {
3
- const statsElements = document.querySelectorAll('[data-hs-accessibility="stats"]');
4
-
5
- statsElements.forEach(element => {
6
- // Get all text content from the element and its children
7
- const textContent = extractTextContent(element);
8
-
9
- if (textContent) {
10
- element.setAttribute('aria-label', textContent);
11
- element.setAttribute('role', 'img');
12
-
13
- // Debug: log what we found
14
- console.log('Stats element aria-label:', textContent);
15
- }
16
- });
17
- }
18
-
19
- function extractTextContent(element) {
20
- const textParts = [];
21
-
22
- function processNode(node) {
23
- // Skip elements with aria-hidden="true"
24
- if (node.nodeType === Node.ELEMENT_NODE &&
25
- node.getAttribute('aria-hidden') === 'true') {
26
- return;
27
- }
28
-
29
- // If element has data-original-text, use that instead of current text
30
- if (node.nodeType === Node.ELEMENT_NODE) {
31
- const originalText = node.getAttribute('data-original-text');
32
- if (originalText) {
33
- textParts.push(originalText);
34
- return; // Don't process children
35
- }
36
- }
37
-
38
- // Process text nodes
39
- if (node.nodeType === Node.TEXT_NODE) {
40
- const text = node.textContent.trim();
41
- if (text && text.length > 0) {
42
- textParts.push(text);
43
- }
44
- }
45
-
46
- // Process child nodes
47
- if (node.childNodes) {
48
- node.childNodes.forEach(processNode);
49
- }
50
- }
51
-
52
- processNode(element);
53
-
54
- // Combine all text parts with spaces and clean up
55
- return textParts
56
- .join(' ')
57
- .replace(/\s+/g, ' ')
58
- .trim();
59
- }
2
+ // General accessibility features can be added here
3
+ // Stats accessibility has been moved to counter.js
60
4
 
61
- function init() {
62
- // Wait a bit for counter scripts to initialize and set data-original-text
63
- setTimeout(() => {
64
- setupStatsAccessibility();
65
- }, 100);
66
-
67
- return { result: 'accessibility initialized' };
5
+ function setupGeneralAccessibility() {
6
+ // Add any general accessibility features here
68
7
  }
69
8
 
70
- return init();
71
- }
9
+ setupGeneralAccessibility();
10
+ return { result: "accessibility initialized" };
11
+ }
@@ -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
+ }