@hortonstudio/main 1.9.11 → 1.9.20

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.
Files changed (120) hide show
  1. package/.prettierrc +8 -0
  2. package/README.md +146 -0
  3. package/eslint.config.js +32 -0
  4. package/index.ts +275 -0
  5. package/package.json +19 -2
  6. package/public/bootstrap.js +16 -0
  7. package/src/animations/animations.ts +93 -0
  8. package/src/animations/functions/counter/counter.ts +137 -0
  9. package/src/config.json +570 -0
  10. package/src/config.ts +105 -0
  11. package/src/modules/default/README.md +167 -0
  12. package/src/modules/default/default.ts +71 -0
  13. package/{autoInit → src/modules/default/functions}/accessibility/README.md +44 -12
  14. package/src/modules/default/functions/accessibility/accessibility.ts +54 -0
  15. package/src/modules/default/functions/accordion/README.md +451 -0
  16. package/src/modules/default/functions/accordion/accordion.ts +189 -0
  17. package/src/modules/default/functions/comparison/comparison.ts +424 -0
  18. package/src/modules/default/functions/marquee/marquee.ts +206 -0
  19. package/src/modules/default/functions/navbar/README.md +393 -0
  20. package/src/modules/default/functions/navbar/functions/arrow-navigation/arrow-navigation.ts +183 -0
  21. package/src/modules/default/functions/navbar/functions/dropdown/dropdown.ts +313 -0
  22. package/src/modules/default/functions/navbar/functions/menu/menu.ts +315 -0
  23. package/src/modules/default/functions/navbar/navbar.ts +51 -0
  24. package/{autoInit → src/modules/default/functions}/smooth-scroll/README.md +45 -14
  25. package/{autoInit/smooth-scroll/smooth-scroll.js → src/modules/default/functions/smooth-scroll/smooth-scroll.ts} +33 -38
  26. package/{autoInit → src/modules/default/functions}/transition/README.md +59 -32
  27. package/src/modules/default/functions/transition/transition.ts +290 -0
  28. package/src/modules/normalize/README.md +172 -0
  29. package/src/modules/normalize/functions/clickable/README.md +84 -0
  30. package/src/modules/normalize/functions/clickable/clickable.ts +43 -0
  31. package/src/modules/normalize/functions/clickable/functions/normalize/README.md +213 -0
  32. package/src/modules/normalize/functions/clickable/functions/normalize/normalize.ts +68 -0
  33. package/src/modules/normalize/functions/dupe/README.md +405 -0
  34. package/src/modules/normalize/functions/dupe/dupe.ts +197 -0
  35. package/src/modules/normalize/functions/sync/sync.ts +378 -0
  36. package/src/modules/normalize/normalize.ts +58 -0
  37. package/src/modules/structure/README.md +190 -0
  38. package/src/modules/structure/functions/form/README.md +94 -0
  39. package/src/modules/structure/functions/form/form.ts +54 -0
  40. package/src/modules/structure/functions/form/functions/honeypot/README.md +77 -0
  41. package/src/modules/structure/functions/form/functions/honeypot/honeypot.ts +37 -0
  42. package/src/modules/structure/functions/form/functions/range/README.md +410 -0
  43. package/src/modules/structure/functions/form/functions/range/range.ts +92 -0
  44. package/src/modules/structure/functions/form/functions/select/README.md +393 -0
  45. package/src/modules/structure/functions/form/functions/select/functions/custom-select/custom-select.ts +637 -0
  46. package/src/modules/structure/functions/form/functions/select/functions/states/states.ts +118 -0
  47. package/src/modules/structure/functions/form/functions/select/select.ts +48 -0
  48. package/src/modules/structure/functions/form/functions/test/test.ts +132 -0
  49. package/{autoInit/accessibility → src/modules/structure}/functions/pagination/README.md +147 -72
  50. package/{autoInit/accessibility/functions/pagination/pagination.js → src/modules/structure/functions/pagination/pagination.ts} +98 -50
  51. package/{autoInit → src/modules/structure/functions}/site-settings/README.md +57 -27
  52. package/{autoInit/site-settings/site-settings.js → src/modules/structure/functions/site-settings/site-settings.ts} +36 -32
  53. package/{autoInit/accessibility → src/modules/structure}/functions/toc/README.md +18 -15
  54. package/{autoInit/accessibility/functions/toc/toc.js → src/modules/structure/functions/toc/functions/heading-links/heading-links.ts} +43 -63
  55. package/src/modules/structure/functions/toc/functions/progress-bar/progress-bar.ts +101 -0
  56. package/src/modules/structure/functions/toc/toc.ts +35 -0
  57. package/{autoInit/accessibility → src/modules/structure}/functions/year-replacement/README.md +7 -6
  58. package/src/modules/structure/functions/year-replacement/year-replacement.ts +59 -0
  59. package/src/modules/structure/structure.ts +59 -0
  60. package/src/utils/attributeSelector.ts +78 -0
  61. package/src/utils/cssVariables.ts +24 -0
  62. package/src/utils/gsap.ts +198 -0
  63. package/src/utils/heightAnimator.ts +130 -0
  64. package/src/utils/modalManager.ts +150 -0
  65. package/src/utils.ts +54 -0
  66. package/tsconfig.json +24 -0
  67. package/vite.config.js +45 -0
  68. package/.claude/settings.local.json +0 -70
  69. package/archive/hero.js +0 -794
  70. package/archive/modal.js +0 -80
  71. package/archive/text.js +0 -628
  72. package/autoInit/accessibility/accessibility.js +0 -53
  73. package/autoInit/accessibility/functions/blog-remover/README.md +0 -61
  74. package/autoInit/accessibility/functions/blog-remover/blog-remover.js +0 -31
  75. package/autoInit/accessibility/functions/click-forwarding/README.md +0 -60
  76. package/autoInit/accessibility/functions/click-forwarding/click-forwarding.js +0 -82
  77. package/autoInit/accessibility/functions/dropdown/README.md +0 -212
  78. package/autoInit/accessibility/functions/dropdown/dropdown.js +0 -167
  79. package/autoInit/accessibility/functions/list-accessibility/README.md +0 -56
  80. package/autoInit/accessibility/functions/list-accessibility/list-accessibility.js +0 -23
  81. package/autoInit/accessibility/functions/text-synchronization/README.md +0 -62
  82. package/autoInit/accessibility/functions/text-synchronization/text-synchronization.js +0 -101
  83. package/autoInit/accessibility/functions/year-replacement/year-replacement.js +0 -43
  84. package/autoInit/button/README.md +0 -122
  85. package/autoInit/button/button.js +0 -51
  86. package/autoInit/counter/README.md +0 -274
  87. package/autoInit/counter/counter.js +0 -185
  88. package/autoInit/form/README.md +0 -338
  89. package/autoInit/form/form.js +0 -374
  90. package/autoInit/navbar/README.md +0 -366
  91. package/autoInit/navbar/navbar.js +0 -786
  92. package/autoInit/transition/transition.js +0 -116
  93. package/index.js +0 -305
  94. package/utils/before-after/README.md +0 -520
  95. package/utils/before-after/before-after.js +0 -653
  96. package/utils/css-animations/buttons/main/bgbasic/btn-main-bgbasic.html +0 -10
  97. package/utils/css-animations/buttons/main/bgfill/btn-main-bgfill.html +0 -29
  98. package/utils/css-animations/buttons/navbar/bgbasic/navbar-main-bgbasic.html +0 -17
  99. package/utils/css-animations/buttons/navbar/bgbasic/navbar-menu-bgbasic.html +0 -16
  100. package/utils/css-animations/buttons/navbar/bgfill/navbar-main-bgfill.html +0 -46
  101. package/utils/css-animations/buttons/navbar/bgfill/navbar-menu-bgfill.html +0 -39
  102. package/utils/css-animations/buttons/navbar/color/navbar-announce-color.html +0 -5
  103. package/utils/css-animations/buttons/navbar/color/navbar-main-color.html +0 -7
  104. package/utils/css-animations/buttons/navbar/color/navbar-menu-color.html +0 -7
  105. package/utils/css-animations/buttons/navbar/double-slide/navbar-announce-double-slide.html +0 -40
  106. package/utils/css-animations/buttons/navbar/double-slide/navbar-main-double-slide.html +0 -77
  107. package/utils/css-animations/buttons/navbar/scale/navbar-announce-scale.html +0 -6
  108. package/utils/css-animations/buttons/navbar/scale/navbar-main-scale.html +0 -9
  109. package/utils/css-animations/buttons/navbar/scale/navbar-menu-scale.html +0 -8
  110. package/utils/css-animations/buttons/navbar/underline/navbar-announce-underline.html +0 -32
  111. package/utils/css-animations/buttons/navbar/underline/navbar-main-underline.html +0 -56
  112. package/utils/css-animations/buttons/text/color/text-footer-color.html +0 -5
  113. package/utils/css-animations/buttons/text/color/text-main-color.html +0 -5
  114. package/utils/css-animations/buttons/text/double-slide/text-main-double-slide.html +0 -56
  115. package/utils/css-animations/buttons/text/scale/text-footer-scale.html +0 -6
  116. package/utils/css-animations/buttons/text/scale/text-main-scale.html +0 -6
  117. package/utils/css-animations/buttons/text/underline/text-footer-underline.html +0 -45
  118. package/utils/css-animations/buttons/text/underline/text-main-underline.html +0 -58
  119. package/utils/css-animations/cards/card-clickable.html +0 -11
  120. package/utils/css-animations/defaults.html +0 -69
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Comparison Module
3
+ *
4
+ * Before/after image comparison slider with GSAP Draggable.
5
+ * Uses template list to manage multiple slides with a single persistent slider.
6
+ *
7
+ * Structure:
8
+ * <!-- Template list (hidden, inside wrapper) -->
9
+ * <div data-hs-comparison="wrapper">
10
+ * <div data-hs-comparison-template="list" class="u-display-none">
11
+ * <div data-hs-comparison-template="item">
12
+ * <h3 data-hs-comparison-template="name">Project 1</h3>
13
+ * <div data-hs-comparison-template="description">Description 1</div>
14
+ * <img data-hs-comparison-template="before" src="before1.jpg" alt="Before">
15
+ * <img data-hs-comparison-template="after" src="after1.jpg" alt="After">
16
+ * </div>
17
+ * </div>
18
+ *
19
+ * <!-- Visible slider elements -->
20
+ * <div data-hs-comparison="image-wrapper">
21
+ * <div data-hs-comparison-image="before">
22
+ * <img src="placeholder.jpg">
23
+ * </div>
24
+ * <div data-hs-comparison-image="after">
25
+ * <img src="placeholder.jpg">
26
+ * </div>
27
+ * </div>
28
+ * <div data-hs-comparison="slider"></div>
29
+ * <span data-hs-comparison="name">Placeholder</span>
30
+ * <span data-hs-comparison="description">Placeholder</span>
31
+ * </div>
32
+ */
33
+
34
+ import {
35
+ querySelector,
36
+ querySelectorAll,
37
+ getSelector,
38
+ globalConfig,
39
+ getGsap,
40
+ cssVariables,
41
+ } from '@utils';
42
+
43
+ export async function init(config: any) {
44
+ const gsapLib = getGsap('comparison');
45
+ if (!gsapLib) {
46
+ return { result: 'comparison skipped - GSAP not loaded' };
47
+ }
48
+
49
+ const { gsap, Draggable } = gsapLib;
50
+
51
+ if (!Draggable) {
52
+ console.warn('[comparison] GSAP Draggable plugin not loaded, drag functionality disabled');
53
+ }
54
+
55
+ const wrappers = querySelectorAll(config, 'wrapper');
56
+ const cleanup = {
57
+ draggables: [] as any[],
58
+ handlers: [] as Array<{ element: Element; event: string; handler: EventListener }>,
59
+ };
60
+
61
+ // Cache template selectors once (they never change)
62
+ const templateNameSelector = getSelector(config, 'template-name');
63
+ const templateDescSelector = getSelector(config, 'template-description');
64
+ const templateBeforeImageSelector = getSelector(config, 'template-before-image');
65
+ const templateAfterImageSelector = getSelector(config, 'template-after-image');
66
+
67
+ // Helper functions with access to config and gsap via closure
68
+ function updateClipPath(wrapper: HTMLElement, percentage: number) {
69
+ // Set CSS variable on wrapper, CSS handles the actual clip-path
70
+ wrapper.style.setProperty(cssVariables.clip, `${percentage}%`);
71
+ }
72
+
73
+ function loadSlide(instance: any, index: number) {
74
+ const templateItem = instance.templateItems[index];
75
+ if (!templateItem) return;
76
+
77
+ // Update name
78
+ const templateName = templateItem.querySelector(templateNameSelector);
79
+ if (instance.nameElement && templateName) {
80
+ instance.nameElement.textContent = templateName.textContent;
81
+ }
82
+
83
+ // Update description
84
+ const templateDesc = templateItem.querySelector(templateDescSelector);
85
+ if (instance.descElement && templateDesc) {
86
+ instance.descElement.textContent = templateDesc.textContent;
87
+ }
88
+
89
+ // Update before image
90
+ const templateBeforeImage = templateItem.querySelector(
91
+ templateBeforeImageSelector
92
+ ) as HTMLImageElement;
93
+
94
+ if (instance.beforeImage && templateBeforeImage) {
95
+ instance.beforeImage.src = templateBeforeImage.src;
96
+ instance.beforeImage.alt = templateBeforeImage.alt || '';
97
+ }
98
+
99
+ // Update after image
100
+ const templateAfterImage = templateItem.querySelector(
101
+ templateAfterImageSelector
102
+ ) as HTMLImageElement;
103
+
104
+ if (instance.afterImage && templateAfterImage) {
105
+ instance.afterImage.src = templateAfterImage.src;
106
+ instance.afterImage.alt = templateAfterImage.alt || '';
107
+
108
+ // Apply current mode to maintain slider state
109
+ applyMode(instance);
110
+ }
111
+
112
+ instance.currentIndex = index;
113
+
114
+ // Update pagination
115
+ updatePagination(instance.wrapper, index);
116
+ }
117
+
118
+ function applyMode(instance: any) {
119
+ const slider = instance.slider;
120
+ const wrapper = instance.wrapper;
121
+
122
+ switch (instance.mode) {
123
+ case 'before':
124
+ // Set variable to 100% (hide after image completely)
125
+ wrapper.style.setProperty(cssVariables.clip, '100%');
126
+ slider.style.display = 'none';
127
+ break;
128
+ case 'after':
129
+ // Set variable to 0% (show after image completely)
130
+ wrapper.style.setProperty(cssVariables.clip, '0%');
131
+ slider.style.display = 'none';
132
+ break;
133
+ case 'split':
134
+ // Restore slider position without resetting
135
+ updateClipPath(wrapper, instance.sliderPosition);
136
+ slider.style.display = 'flex';
137
+ break;
138
+ }
139
+ }
140
+
141
+ function setMode(instance: any, mode: string) {
142
+ const wrapper = instance.wrapper;
143
+ const modeButtons = wrapper.querySelectorAll(`[${config.attributes.properties.modeType}]`);
144
+ const activeClass = globalConfig.classes.active;
145
+
146
+ // Update button states
147
+ modeButtons.forEach((btn: Element) => {
148
+ const btnMode = btn.getAttribute(config.attributes.properties.modeType);
149
+ if (btnMode === mode) {
150
+ btn.classList.add(activeClass);
151
+ } else {
152
+ btn.classList.remove(activeClass);
153
+ }
154
+ });
155
+
156
+ instance.mode = mode;
157
+ applyMode(instance);
158
+ }
159
+
160
+ function updatePagination(wrapper: HTMLElement, index: number) {
161
+ const paginationContainer = querySelector(config, 'pagination', wrapper);
162
+ if (!paginationContainer) return;
163
+
164
+ const activeClass = globalConfig.classes.active;
165
+ const dots = Array.from(paginationContainer.children);
166
+ dots.forEach((dot, dotIndex) => {
167
+ if (dotIndex === index) {
168
+ dot.classList.add(activeClass);
169
+ } else {
170
+ dot.classList.remove(activeClass);
171
+ }
172
+ });
173
+ }
174
+
175
+ function navigateSlide(instance: any, direction: number) {
176
+ const newIndex = instance.currentIndex + direction;
177
+ const maxIndex = instance.templateItems.length - 1;
178
+
179
+ let targetIndex: number;
180
+ if (newIndex > maxIndex) {
181
+ targetIndex = 0;
182
+ } else if (newIndex < 0) {
183
+ targetIndex = maxIndex;
184
+ } else {
185
+ targetIndex = newIndex;
186
+ }
187
+
188
+ loadSlide(instance, targetIndex);
189
+ }
190
+
191
+ function setupNavigation(instance: any, wrapper: HTMLElement) {
192
+ const leftArrow = wrapper.querySelector(`[${config.attributes.properties.navType}="previous"]`);
193
+ const rightArrow = wrapper.querySelector(`[${config.attributes.properties.navType}="next"]`);
194
+
195
+ if (leftArrow) {
196
+ const clickableSelector = getSelector(globalConfig.clickable, 'button');
197
+ const button = (leftArrow.querySelector(clickableSelector) || leftArrow) as HTMLElement;
198
+ button.setAttribute('aria-label', 'Previous image');
199
+
200
+ const handler = (e: Event) => {
201
+ e.preventDefault();
202
+ navigateSlide(instance, -1);
203
+ };
204
+
205
+ button.addEventListener('click', handler);
206
+ cleanup.handlers.push({ element: button, event: 'click', handler });
207
+ }
208
+
209
+ if (rightArrow) {
210
+ const clickableSelector = getSelector(globalConfig.clickable, 'button');
211
+ const button = (rightArrow.querySelector(clickableSelector) || rightArrow) as HTMLElement;
212
+ button.setAttribute('aria-label', 'Next image');
213
+
214
+ const handler = (e: Event) => {
215
+ e.preventDefault();
216
+ navigateSlide(instance, 1);
217
+ };
218
+
219
+ button.addEventListener('click', handler);
220
+ cleanup.handlers.push({ element: button, event: 'click', handler });
221
+ }
222
+ }
223
+
224
+ function setupPagination(instance: any, paginationContainer: HTMLElement) {
225
+ const templateDot = paginationContainer.children[0];
226
+ if (!templateDot) return;
227
+
228
+ const activeClass = globalConfig.classes.active;
229
+ paginationContainer.innerHTML = '';
230
+
231
+ instance.templateItems.forEach((_: any, index: number) => {
232
+ const dot = templateDot.cloneNode(true) as HTMLElement;
233
+
234
+ // Remove active class from all dots, then add only to first
235
+ dot.classList.remove(activeClass);
236
+ if (index === 0) {
237
+ dot.classList.add(activeClass);
238
+ }
239
+
240
+ const clickHandler = () => {
241
+ loadSlide(instance, index);
242
+ };
243
+
244
+ dot.addEventListener('click', clickHandler);
245
+ cleanup.handlers.push({ element: dot, event: 'click', handler: clickHandler });
246
+
247
+ paginationContainer.appendChild(dot);
248
+ });
249
+ }
250
+
251
+ function setupKeyboardNav(instance: any) {
252
+ const keydownHandler = (e: KeyboardEvent) => {
253
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
254
+ e.preventDefault();
255
+ navigateSlide(instance, -1);
256
+ } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
257
+ e.preventDefault();
258
+ navigateSlide(instance, 1);
259
+ }
260
+ };
261
+
262
+ instance.wrapper.addEventListener('keydown', keydownHandler);
263
+ instance.wrapper.setAttribute('tabindex', '0');
264
+ cleanup.handlers.push({ element: instance.wrapper, event: 'keydown', handler: keydownHandler });
265
+ }
266
+
267
+ // Main initialization loop
268
+ wrappers.forEach((wrapper) => {
269
+ // Find template list inside wrapper
270
+ const templateList = querySelector(config, 'template-list', wrapper);
271
+ if (!templateList) {
272
+ console.warn('[comparison] No template list found in wrapper');
273
+ return;
274
+ }
275
+
276
+ const templateItemsSelector = getSelector(config, 'template-item');
277
+ const templateItems = Array.from(templateList.querySelectorAll(templateItemsSelector));
278
+
279
+ if (templateItems.length === 0) {
280
+ console.warn('[comparison] No template items found');
281
+ return;
282
+ }
283
+
284
+ // Add simple aria-live for screen reader announcements
285
+ wrapper.setAttribute('aria-live', 'polite');
286
+ wrapper.setAttribute('aria-label', 'Before and after image comparison');
287
+
288
+ // Get references to visible elements and cache them
289
+ const imageWrapper = querySelector(config, 'image-wrapper', wrapper) as HTMLElement;
290
+ const slider = querySelector(config, 'slider', wrapper) as HTMLElement;
291
+ const beforeImageWrapper = querySelector(config, 'before-image', wrapper) as HTMLElement;
292
+ const afterImageWrapper = querySelector(config, 'after-image', wrapper) as HTMLElement;
293
+ const nameElement = querySelector(config, 'name', wrapper) as HTMLElement;
294
+ const descElement = querySelector(config, 'description', wrapper) as HTMLElement;
295
+
296
+ if (!imageWrapper || !slider || !afterImageWrapper) {
297
+ const missing = [];
298
+ if (!imageWrapper) missing.push('image-wrapper (data-hs-comparison="image-wrapper")');
299
+ if (!slider) missing.push('slider (data-hs-comparison="slider")');
300
+ if (!afterImageWrapper) missing.push('after-image (data-hs-comparison-image="after")');
301
+ console.warn(`[comparison] Missing required elements: ${missing.join(', ')}`);
302
+ return;
303
+ }
304
+
305
+ const instance = {
306
+ wrapper: wrapper as HTMLElement,
307
+ templateItems,
308
+ currentIndex: 0,
309
+ mode: 'split',
310
+ sliderPosition: 50,
311
+ imageWrapper,
312
+ slider,
313
+ beforeImage: beforeImageWrapper?.querySelector('img') as HTMLImageElement,
314
+ afterImage: afterImageWrapper?.querySelector('img') as HTMLImageElement,
315
+ nameElement,
316
+ descElement,
317
+ draggable: null as any,
318
+ };
319
+
320
+ // Load first slide
321
+ loadSlide(instance, 0);
322
+
323
+ // Set initial clip path to 50%
324
+ updateClipPath(wrapper as HTMLElement, 50);
325
+
326
+ // Setup GSAP Draggable for slider
327
+ if (Draggable) {
328
+ const draggableInstance = Draggable.create(slider, {
329
+ type: 'x',
330
+ bounds: imageWrapper,
331
+ onDrag: function () {
332
+ const rect = imageWrapper.getBoundingClientRect();
333
+ const sliderRect = slider.getBoundingClientRect();
334
+ const x = sliderRect.left - rect.left;
335
+ const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
336
+
337
+ updateClipPath(wrapper as HTMLElement, percentage);
338
+ instance.sliderPosition = percentage;
339
+ },
340
+ });
341
+
342
+ cleanup.draggables.push(draggableInstance);
343
+ instance.draggable = draggableInstance[0]; // Store reference to draggable
344
+
345
+ // Click on image wrapper to jump slider
346
+ const clickHandler = (e: MouseEvent) => {
347
+ if (instance.mode !== 'split') return;
348
+
349
+ const rect = imageWrapper.getBoundingClientRect();
350
+ const clickX = e.clientX - rect.left;
351
+ const percentage = Math.max(0, Math.min(100, (clickX / rect.width) * 100));
352
+
353
+ // Calculate target position accounting for slider's center point
354
+ // Draggable starts from slider's initial position (50% = center)
355
+ const centerX = rect.width / 2;
356
+ const offsetX = clickX - centerX;
357
+
358
+ // Update Draggable's x position from center
359
+ if (instance.draggable) {
360
+ gsap.set(slider, { x: offsetX });
361
+ instance.draggable.update();
362
+ }
363
+
364
+ updateClipPath(wrapper as HTMLElement, percentage);
365
+ instance.sliderPosition = percentage;
366
+ };
367
+
368
+ imageWrapper.addEventListener('click', clickHandler);
369
+ cleanup.handlers.push({ element: imageWrapper, event: 'click', handler: clickHandler });
370
+ }
371
+
372
+ // Setup mode buttons
373
+ const modeButtons = wrapper.querySelectorAll(`[${config.attributes.properties.modeType}]`);
374
+ modeButtons.forEach((modeWrapper) => {
375
+ const mode = modeWrapper.getAttribute(config.attributes.properties.modeType);
376
+ const clickableSelector = getSelector(globalConfig.clickable, 'button');
377
+ const button = (modeWrapper.querySelector(clickableSelector) || modeWrapper) as HTMLElement;
378
+
379
+ // Set initial state
380
+ if (mode === instance.mode) {
381
+ modeWrapper.classList.add(globalConfig.classes.active);
382
+ }
383
+
384
+ const clickHandler = (e: Event) => {
385
+ e.preventDefault();
386
+ setMode(instance, mode!);
387
+ };
388
+
389
+ button.addEventListener('click', clickHandler);
390
+ cleanup.handlers.push({ element: button, event: 'click', handler: clickHandler });
391
+ });
392
+
393
+ // Setup navigation
394
+ setupNavigation(instance, wrapper as HTMLElement);
395
+
396
+ // Setup pagination
397
+ const paginationContainer = querySelector(config, 'pagination', wrapper);
398
+ if (paginationContainer && templateItems.length > 1) {
399
+ setupPagination(instance, paginationContainer as HTMLElement);
400
+ }
401
+
402
+ // Setup keyboard navigation
403
+ setupKeyboardNav(instance);
404
+ });
405
+
406
+ return {
407
+ result: `comparison initialized (${wrappers.length} instances)`,
408
+ destroy: () => {
409
+ // Kill all draggables
410
+ cleanup.draggables.forEach((d) => {
411
+ if (d && d[0] && d[0].kill) d[0].kill();
412
+ });
413
+
414
+ // Remove all event listeners
415
+ cleanup.handlers.forEach(({ element, event, handler }) => {
416
+ element.removeEventListener(event, handler);
417
+ });
418
+
419
+ // Clear arrays
420
+ cleanup.draggables.length = 0;
421
+ cleanup.handlers.length = 0;
422
+ },
423
+ };
424
+ }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Marquee Module
3
+ *
4
+ * GSAP-powered infinite scrolling marquee with scroll-based interactions.
5
+ * Works with normalize/dupe module for content duplication.
6
+ *
7
+ * Usage:
8
+ * <div data-hs-marquee="wrapper" data-hs-marquee-direction="left">
9
+ * <div data-hs-dupe="child" data-hs-dupe-count="2">
10
+ * <!-- Content here -->
11
+ * </div>
12
+ * </div>
13
+ *
14
+ * Attributes:
15
+ * data-hs-marquee-direction: "left" | "right" (required)
16
+ * data-hs-marquee-duration: Number in seconds (default: "25")
17
+ * data-hs-marquee-scroll: Scroll speed multiplier, 0-1 range (default: "0", disabled)
18
+ * data-hs-marquee-type: "reverse" (optional, enables scroll-direction reversal)
19
+ *
20
+ * Scroll Behavior:
21
+ * - Default: Speeds up when scrolling (magnitude only)
22
+ * - Type "reverse": Scroll down speeds up, scroll up reverses animation
23
+ * - Smooth velocity interpolation for buttery transitions
24
+ */
25
+
26
+ import {
27
+ querySelectorAll,
28
+ globalConfig,
29
+ getGsap,
30
+ prefersReducedMotion,
31
+ initScrollVelocityTracking,
32
+ getScrollVelocity,
33
+ getScrollDirection,
34
+ } from '@utils';
35
+
36
+ export async function init(config) {
37
+ // Skip if user prefers reduced motion
38
+ if (prefersReducedMotion()) {
39
+ return { result: 'marquee skipped - prefers-reduced-motion enabled' };
40
+ }
41
+
42
+ const gsapLib = getGsap('marquee');
43
+ if (!gsapLib) {
44
+ return { result: 'marquee skipped - GSAP not loaded' };
45
+ }
46
+
47
+ const { gsap, ScrollTrigger } = gsapLib;
48
+
49
+ if (ScrollTrigger) {
50
+ gsap.registerPlugin(ScrollTrigger);
51
+ }
52
+
53
+ // Initialize global scroll velocity tracking (only runs once)
54
+ initScrollVelocityTracking();
55
+
56
+ const marquees = querySelectorAll(config, 'wrapper');
57
+ const cleanup = {
58
+ timelines: [],
59
+ scrollTriggers: [],
60
+ tickers: [],
61
+ };
62
+
63
+ marquees.forEach((marquee) => {
64
+ const direction = marquee.getAttribute(config.attributes.properties.direction);
65
+ const children = Array.from(marquee.children);
66
+
67
+ if (children.length === 0) {
68
+ console.warn('[marquee] No children found in marquee wrapper:', marquee);
69
+ return;
70
+ }
71
+
72
+ // Get duration - check attribute first, then use config default
73
+ const durationAttr = marquee.getAttribute(config.attributes.properties.duration);
74
+ const duration = durationAttr ? parseFloat(durationAttr) : parseFloat(config.defaults.duration);
75
+
76
+ // Get scroll multiplier - check attribute first, then use config default
77
+ const scrollAttr = marquee.getAttribute(config.attributes.properties.scrollMultiplier);
78
+ const scrollMultiplier = scrollAttr
79
+ ? parseFloat(scrollAttr)
80
+ : parseFloat(config.defaults.scrollMultiplier);
81
+
82
+ // Get marquee type (reverse or default)
83
+ const marqueeType = marquee.getAttribute(config.attributes.properties.type);
84
+
85
+ // Set attributes with defaults if not present (for visibility in DOM)
86
+ if (!durationAttr) {
87
+ marquee.setAttribute(config.attributes.properties.duration, config.defaults.duration);
88
+ }
89
+ if (!scrollAttr) {
90
+ marquee.setAttribute(
91
+ config.attributes.properties.scrollMultiplier,
92
+ config.defaults.scrollMultiplier
93
+ );
94
+ }
95
+
96
+ // Add class to indicate GSAP is controlling animation
97
+ marquee.classList.add(globalConfig.classes.gsap);
98
+
99
+ // Create GSAP timeline
100
+ const tl = gsap.timeline({ repeat: -1 });
101
+
102
+ // Animate all children together at the same time
103
+ children.forEach((child) => {
104
+ if (direction === 'left') {
105
+ // Left: 0% → -100%
106
+ tl.to(
107
+ child,
108
+ {
109
+ xPercent: -100,
110
+ duration: duration,
111
+ ease: 'none',
112
+ },
113
+ 0
114
+ );
115
+ } else if (direction === 'right') {
116
+ // Right: -100% → 0%
117
+ gsap.set(child, { xPercent: -100 }); // Start from -100%
118
+ tl.to(
119
+ child,
120
+ {
121
+ xPercent: 0,
122
+ duration: duration,
123
+ ease: 'none',
124
+ },
125
+ 0
126
+ );
127
+ }
128
+ });
129
+
130
+ cleanup.timelines.push(tl);
131
+
132
+ // Add scroll-based speed boost if scroll multiplier is set
133
+ if (scrollMultiplier > 0) {
134
+ let lastTimeScale = 1;
135
+
136
+ const tickerFunc = () => {
137
+ // Get global scroll velocity and direction
138
+ const scrollSpeed = getScrollVelocity();
139
+ const scrollDir = getScrollDirection();
140
+ let newTimeScale = 1;
141
+
142
+ if (marqueeType === 'reverse') {
143
+ // Reverse type: scroll down speeds up, scroll up reverses
144
+ if (scrollDir === 1) {
145
+ // Scrolling down: speed up in normal direction
146
+ newTimeScale = 1 + scrollSpeed * scrollMultiplier;
147
+ } else if (scrollDir === -1) {
148
+ // Scrolling up: reverse the animation
149
+ // Use absolute minimum to prevent getting stuck at 0
150
+ const reverseScale = scrollSpeed * scrollMultiplier;
151
+ newTimeScale = reverseScale > 0.5 ? -reverseScale : 1;
152
+ } else {
153
+ // Not scrolling: return to base speed
154
+ newTimeScale = 1;
155
+ }
156
+ } else {
157
+ // Default type: just speed up based on velocity (no direction)
158
+ const speedBoost = scrollSpeed * scrollMultiplier;
159
+ newTimeScale = 1 + speedBoost;
160
+ }
161
+
162
+ // Only update if changed (avoid unnecessary updates)
163
+ if (Math.abs(newTimeScale - lastTimeScale) > 0.001) {
164
+ (tl as any).timeScale(newTimeScale);
165
+ lastTimeScale = newTimeScale;
166
+ }
167
+ };
168
+
169
+ // Add ticker function to GSAP's ticker
170
+ (gsap as any).ticker.add(tickerFunc);
171
+
172
+ // Store ticker function and gsap reference for cleanup
173
+ cleanup.tickers.push({ tickerFunc, gsap });
174
+ }
175
+ });
176
+
177
+ return {
178
+ result: `marquee initialized (${marquees.length} instances)`,
179
+ destroy: () => {
180
+ // Kill all timelines
181
+ cleanup.timelines.forEach((tl) => {
182
+ if (tl) (tl as any).kill();
183
+ });
184
+
185
+ // Kill all scroll triggers
186
+ cleanup.scrollTriggers.forEach((st) => {
187
+ if (st) (st as any).kill();
188
+ });
189
+
190
+ // Remove all ticker functions
191
+ cleanup.tickers.forEach(({ tickerFunc, gsap: gsapInstance }) => {
192
+ (gsapInstance as any).ticker.remove(tickerFunc);
193
+ });
194
+
195
+ // Remove GSAP class from all marquees
196
+ marquees.forEach((marquee) => {
197
+ marquee.classList.remove(globalConfig.classes.gsap);
198
+ });
199
+
200
+ // Clear arrays
201
+ cleanup.timelines.length = 0;
202
+ cleanup.scrollTriggers.length = 0;
203
+ cleanup.tickers.length = 0;
204
+ },
205
+ };
206
+ }