@hortonstudio/main 1.6.7 → 1.7.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.
@@ -60,7 +60,8 @@
60
60
  "Bash(npx depcheck:*)",
61
61
  "Bash(npx prettier:*)",
62
62
  "Bash(npx prettier:*)",
63
- "Bash(npx prettier:*)"
63
+ "Bash(npx prettier:*)",
64
+ "Bash(git stash:*)"
64
65
  ],
65
66
  "deny": []
66
67
  }
@@ -11,6 +11,8 @@ export function init() {
11
11
  setupRichTextAccessibility();
12
12
  setupSummaryAccessibility();
13
13
  setupCustomValuesReplacement();
14
+ setupClickForwarding();
15
+ setupTextSynchronization();
14
16
  }
15
17
 
16
18
  function setupListAccessibility() {
@@ -432,6 +434,146 @@ export function init() {
432
434
  });
433
435
  }
434
436
 
437
+ function setupClickForwarding() {
438
+ // Find all clickable elements (custom styled elements users click)
439
+ const clickableElements = document.querySelectorAll('[data-hs-a11y*="clickable"]');
440
+
441
+ clickableElements.forEach(clickableElement => {
442
+ const attribute = clickableElement.getAttribute('data-hs-a11y');
443
+
444
+ // Parse the attribute: "click-trigger-[identifier], clickable"
445
+ const parts = attribute.split(',').map(part => part.trim());
446
+
447
+ // Find the part with click-trigger and the part with clickable
448
+ const triggerPart = parts.find(part => part.startsWith('click-trigger-'));
449
+ const rolePart = parts.find(part => part === 'clickable');
450
+
451
+ if (!triggerPart || !rolePart) {
452
+ return;
453
+ }
454
+
455
+ // Extract identifier from "click-trigger-[identifier]"
456
+ const identifier = triggerPart.replace('click-trigger-', '').trim();
457
+
458
+ // Find the corresponding trigger element
459
+ const triggerSelector = `[data-hs-a11y*="click-trigger-${identifier}"][data-hs-a11y*="trigger"]`;
460
+ const triggerElement = document.querySelector(triggerSelector);
461
+
462
+ if (!triggerElement) {
463
+ return;
464
+ }
465
+
466
+ // Add click event listener to forward clicks
467
+ clickableElement.addEventListener('click', (event) => {
468
+ // Prevent default behavior on the clickable element
469
+ event.preventDefault();
470
+ event.stopPropagation();
471
+
472
+ // Trigger click on the target element
473
+ triggerElement.click();
474
+ });
475
+
476
+ // Also handle keyboard events for accessibility
477
+ clickableElement.addEventListener('keydown', (event) => {
478
+ if (event.key === 'Enter' || event.key === ' ') {
479
+ event.preventDefault();
480
+ event.stopPropagation();
481
+ triggerElement.click();
482
+ }
483
+ });
484
+
485
+ // Ensure clickable element is keyboard accessible
486
+ if (!clickableElement.hasAttribute('tabindex')) {
487
+ clickableElement.setAttribute('tabindex', '0');
488
+ }
489
+ if (!clickableElement.hasAttribute('role')) {
490
+ clickableElement.setAttribute('role', 'button');
491
+ }
492
+ });
493
+ }
494
+
495
+ function setupTextSynchronization() {
496
+ // Find all original elements (source of truth)
497
+ const originalElements = document.querySelectorAll('[data-hs-a11y*="original"]');
498
+
499
+ originalElements.forEach(originalElement => {
500
+ const attribute = originalElement.getAttribute('data-hs-a11y');
501
+
502
+ // Parse the attribute: "match-text-[identifier], original"
503
+ const parts = attribute.split(',').map(part => part.trim());
504
+
505
+ // Find the part with match-text and the part with original
506
+ const textPart = parts.find(part => part.startsWith('match-text-'));
507
+ const rolePart = parts.find(part => part === 'original');
508
+
509
+ if (!textPart || !rolePart) {
510
+ return;
511
+ }
512
+
513
+ // Extract identifier from "match-text-[identifier]"
514
+ const identifier = textPart.replace('match-text-', '').trim();
515
+
516
+ // Find all corresponding match elements
517
+ const matchSelector = `[data-hs-a11y*="match-text-${identifier}"][data-hs-a11y*="match"]`;
518
+ const matchElements = document.querySelectorAll(matchSelector);
519
+
520
+ if (matchElements.length === 0) {
521
+ return;
522
+ }
523
+
524
+ // Function to synchronize text and aria-label
525
+ function synchronizeContent() {
526
+ const originalText = originalElement.textContent;
527
+ const originalAriaLabel = originalElement.getAttribute('aria-label');
528
+
529
+ matchElements.forEach(matchElement => {
530
+ // Copy text content
531
+ matchElement.textContent = originalText;
532
+
533
+ // Synchronize aria-label
534
+ if (originalAriaLabel) {
535
+ // If original has aria-label, copy it to match
536
+ matchElement.setAttribute('aria-label', originalAriaLabel);
537
+ } else {
538
+ // If original has no aria-label, remove it from match (keep in sync)
539
+ if (matchElement.hasAttribute('aria-label')) {
540
+ matchElement.removeAttribute('aria-label');
541
+ }
542
+ }
543
+ });
544
+ }
545
+
546
+ // Initial synchronization
547
+ synchronizeContent();
548
+
549
+ // Set up MutationObserver to watch for changes
550
+ const observer = new MutationObserver((mutations) => {
551
+ let shouldSync = false;
552
+
553
+ mutations.forEach((mutation) => {
554
+ if (mutation.type === 'childList' ||
555
+ mutation.type === 'characterData' ||
556
+ (mutation.type === 'attributes' && mutation.attributeName === 'aria-label')) {
557
+ shouldSync = true;
558
+ }
559
+ });
560
+
561
+ if (shouldSync) {
562
+ synchronizeContent();
563
+ }
564
+ });
565
+
566
+ // Observe text changes and attribute changes
567
+ observer.observe(originalElement, {
568
+ childList: true,
569
+ subtree: true,
570
+ characterData: true,
571
+ attributes: true,
572
+ attributeFilter: ['aria-label']
573
+ });
574
+ });
575
+ }
576
+
435
577
  function setupRichTextAccessibility() {
436
578
  const contentAreas = document.querySelectorAll('[data-hs-a11y="rich-content"]');
437
579
  const tocLists = document.querySelectorAll('[data-hs-a11y="rich-toc"]');
@@ -16,12 +16,21 @@ export async function init() {
16
16
  function initTransitions() {
17
17
  const transitionTrigger = document.querySelector(".transition-trigger");
18
18
  const transitionElement = document.querySelector(".transition");
19
-
20
- let excludedClass = "no-transition";
21
19
 
22
20
  // Page Load - Trigger entrance animation with optional delay
23
21
  if (transitionTrigger) {
24
- // Check if this is the first page load of the session
22
+ // Check if entrance transition should be skipped
23
+ const skipEntrance = sessionStorage.getItem('skip-entrance-transition');
24
+ if (skipEntrance) {
25
+ sessionStorage.removeItem('skip-entrance-transition');
26
+ // Keep transition element hidden when skipping animation
27
+ if (transitionElement) {
28
+ transitionElement.style.display = "none";
29
+ }
30
+ return; // Skip entrance animation
31
+ }
32
+
33
+ // Check if this is the first page load of the session
25
34
  const isFirstLoad = !sessionStorage.getItem('transition-loaded');
26
35
  const delayAttr = transitionElement?.getAttribute('data-hs-delay');
27
36
  const delaySeconds = delayAttr ? parseFloat(delayAttr) : 0;
@@ -58,6 +67,18 @@ function initTransitions() {
58
67
  checkComplete();
59
68
  }
60
69
 
70
+ // Helper function to check if element or any ancestor has data-hs-transition="prevent"
71
+ function hasTransitionPrevented(element) {
72
+ let current = element;
73
+ while (current && current !== document) {
74
+ if (current.getAttribute && current.getAttribute('data-hs-transition') === 'prevent') {
75
+ return true;
76
+ }
77
+ current = current.parentElement;
78
+ }
79
+ return false;
80
+ }
81
+
61
82
  // On Link Click
62
83
  document.addEventListener("click", function (e) {
63
84
  const link = e.target.closest("a");
@@ -67,10 +88,19 @@ function initTransitions() {
67
88
  link.hostname === window.location.hostname &&
68
89
  link.getAttribute("href") &&
69
90
  link.getAttribute("href").indexOf("#") === -1 &&
70
- !link.classList.contains(excludedClass) &&
71
91
  link.getAttribute("target") !== "_blank" &&
72
92
  transitionTrigger
73
93
  ) {
94
+ // Check if transitions are prevented
95
+ const transitionPrevented = hasTransitionPrevented(link);
96
+
97
+ if (transitionPrevented) {
98
+ // Set flag to prevent entrance animation on next page
99
+ sessionStorage.setItem('skip-entrance-transition', 'true');
100
+ // Navigate normally without exit animation
101
+ return;
102
+ }
103
+
74
104
  e.preventDefault();
75
105
 
76
106
  let transitionURL = link.getAttribute("href");
package/index.js CHANGED
@@ -1,4 +1,4 @@
1
- // Version:1.6.7
1
+ // Version:1.7.0
2
2
  const API_NAME = "hsmain";
3
3
 
4
4
  const initializeHsMain = async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hortonstudio/main",
3
- "version": "1.6.7",
3
+ "version": "1.7.0",
4
4
  "description": "Animation and utility library for client websites",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/utils/slider.js CHANGED
@@ -1,95 +1,222 @@
1
- export const init = () => {
2
- // Null checks for DOM elements
1
+ // Infinite carousel slider
2
+ const initInfiniteCarousel = () => {
3
3
  const wrapper = document.querySelector('[data-hs-slider="wrapper"]');
4
4
  const nextBtn = document.querySelector('[data-hs-slider="next"]');
5
5
  const prevBtn = document.querySelector('[data-hs-slider="previous"]');
6
6
 
7
- // Early return if required elements don't exist
8
- if (!wrapper || !nextBtn || !prevBtn) {
9
- return;
10
- }
11
-
12
- // Validate slides exist and have length
7
+ if (!wrapper || !nextBtn || !prevBtn) return;
13
8
  const slides = wrapper.children;
14
- if (!slides || slides.length === 0) {
15
- return;
16
- }
17
-
18
- // Check if gsap is available globally
19
- if (typeof gsap === 'undefined') {
20
- return;
21
- }
9
+ if (!slides?.length) return;
22
10
 
23
- const totalSlides = slides.length;
24
- let currentIndex = 0;
25
- let isAnimating = false;
11
+ const state = { currentIndex: 1, totalSlides: slides.length, isAnimating: false };
12
+
13
+ wrapper.appendChild(slides[0].cloneNode(true));
14
+ wrapper.insertBefore(slides[slides.length - 1].cloneNode(true), slides[0]);
15
+
16
+ gsap.set(wrapper, { xPercent: -100 });
26
17
 
27
- const firstClone = slides[0].cloneNode(true);
28
- const lastClone = slides[slides.length - 1].cloneNode(true);
18
+ const navigate = (direction) => {
19
+ if (state.isAnimating) return;
20
+ state.isAnimating = true;
21
+ state.currentIndex += direction;
22
+
23
+ const isLoop = (direction > 0 && state.currentIndex > state.totalSlides) ||
24
+ (direction < 0 && state.currentIndex < 1);
25
+
26
+ gsap.to(wrapper, {
27
+ xPercent: -state.currentIndex * 100,
28
+ duration: 0.5,
29
+ ease: "power2.inOut",
30
+ onComplete: () => {
31
+ if (isLoop) {
32
+ state.currentIndex = direction > 0 ? 1 : state.totalSlides;
33
+ gsap.set(wrapper, { xPercent: -state.currentIndex * 100 });
34
+ }
35
+ state.isAnimating = false;
36
+ }
37
+ });
38
+ };
29
39
 
30
- wrapper.appendChild(firstClone);
31
- wrapper.insertBefore(lastClone, slides[0]);
40
+ nextBtn.addEventListener('click', () => navigate(1));
41
+ prevBtn.addEventListener('click', () => navigate(-1));
42
+ };
32
43
 
33
- currentIndex = 1;
34
- gsap.set(wrapper, { xPercent: -100 });
44
+ // Infinite pagination slider
45
+ const initInfinitePagination = () => {
46
+ document.querySelectorAll('[data-hs-slider="pagination-list"]')
47
+ .forEach(list => initPaginationInstance(list));
48
+ };
35
49
 
36
- nextBtn.addEventListener('click', () => {
37
- if (isAnimating) return;
38
- isAnimating = true;
50
+ const initPaginationInstance = (originalList) => {
51
+ const wrapper = originalList.parentElement;
52
+ if (!wrapper) return;
53
+
54
+ const container = wrapper.closest('[data-hs-slider*="pagination"]') || wrapper.parentElement;
55
+ const elements = {
56
+ nextBtn: container.querySelector('[data-hs-slider="pagination-next"]'),
57
+ prevBtn: container.querySelector('[data-hs-slider="pagination-previous"]'),
58
+ counter: container.querySelector('[data-hs-slider="pagination-counter"]'),
59
+ controls: container.querySelector('[data-hs-slider="pagination-controls"]')
60
+ };
61
+
62
+ if (!elements.nextBtn || !elements.prevBtn) return;
63
+
64
+ elements.nextBtn.setAttribute('aria-label', 'Go to next page');
65
+ elements.prevBtn.setAttribute('aria-label', 'Go to previous page');
66
+ if (elements.counter) {
67
+ elements.counter.setAttribute('aria-live', 'polite');
68
+ elements.counter.setAttribute('aria-label', 'Current page');
69
+ }
70
+
71
+ const config = originalList.getAttribute('data-hs-config') || '';
72
+ const desktopItems = parseInt(config.match(/show-(\d+)(?!-mobile)/)?.[1]) || 6;
73
+ const mobileItems = parseInt(config.match(/show-(\d+)-mobile/)?.[1]) || desktopItems;
74
+
75
+ const isMobileLayout = () => getComputedStyle(originalList).display === 'flex';
76
+ const allItems = Array.from(originalList.children);
77
+ const totalItems = allItems.length;
78
+ if (!totalItems) return;
79
+
80
+ const state = { totalPages: 1, currentIndex: 1, currentPage: 1, isAnimating: false, itemsPerPage: desktopItems };
81
+ let wrapperChildren = [];
82
+
83
+ const initializePagination = (forceItemsPerPage = null) => {
84
+ const currentIsMobile = isMobileLayout();
85
+ state.itemsPerPage = forceItemsPerPage || (currentIsMobile ? mobileItems : desktopItems);
86
+ state.totalPages = Math.ceil(totalItems / state.itemsPerPage);
39
87
 
40
- if (currentIndex === totalSlides) {
41
- currentIndex++;
42
- gsap.to(wrapper, {
43
- xPercent: -currentIndex * 100,
44
- duration: 0.5,
45
- ease: "power2.inOut",
46
- onComplete: () => {
47
- gsap.set(wrapper, { xPercent: -100 });
48
- currentIndex = 1;
49
- isAnimating = false;
50
- }
51
- });
52
- } else {
53
- currentIndex++;
54
- gsap.to(wrapper, {
55
- xPercent: -currentIndex * 100,
56
- duration: 0.5,
57
- ease: "power2.inOut",
58
- onComplete: () => {
59
- isAnimating = false;
60
- }
61
- });
88
+ Array.from(wrapper.children).forEach(child => {
89
+ if (child !== originalList) wrapper.removeChild(child);
90
+ });
91
+ originalList.innerHTML = '';
92
+ allItems.forEach(item => originalList.appendChild(item));
93
+
94
+ if (state.totalPages <= 1) {
95
+ if (elements.controls) {
96
+ if (elements.controls.contains(document.activeElement)) document.activeElement.blur();
97
+ elements.controls.style.display = 'none';
98
+ elements.controls.setAttribute('aria-hidden', 'true');
99
+ }
100
+ Object.assign(state, { totalPages: 1, currentIndex: 1, currentPage: 1, isAnimating: false });
101
+ wrapper.style.cssText = `transform: translateX(0%); height: ${originalList.offsetHeight}px;`;
102
+ wrapperChildren = [originalList];
103
+ return 1;
62
104
  }
63
- });
64
-
65
- prevBtn.addEventListener('click', () => {
66
- if (isAnimating) return;
67
- isAnimating = true;
68
105
 
69
- if (currentIndex === 1) {
70
- currentIndex--;
71
- gsap.to(wrapper, {
72
- xPercent: -currentIndex * 100,
73
- duration: 0.5,
74
- ease: "power2.inOut",
75
- onComplete: () => {
76
- gsap.set(wrapper, { xPercent: -totalSlides * 100 });
77
- currentIndex = totalSlides;
78
- isAnimating = false;
79
- }
80
- });
81
- } else {
82
- currentIndex--;
83
- gsap.to(wrapper, {
84
- xPercent: -currentIndex * 100,
85
- duration: 0.5,
86
- ease: "power2.inOut",
87
- onComplete: () => {
88
- isAnimating = false;
89
- }
90
- });
106
+ if (elements.controls) {
107
+ elements.controls.style.display = '';
108
+ elements.controls.removeAttribute('aria-hidden');
91
109
  }
110
+
111
+ const pageLists = Array.from({ length: state.totalPages }, (_, page) => {
112
+ const pageList = originalList.cloneNode(false);
113
+ const startIndex = page * state.itemsPerPage;
114
+ const endIndex = Math.min(startIndex + state.itemsPerPage, totalItems);
115
+ allItems.slice(startIndex, endIndex).forEach(item => pageList.appendChild(item.cloneNode(true)));
116
+ return pageList;
117
+ });
118
+
119
+ wrapper.insertBefore(pageLists[pageLists.length - 1].cloneNode(true), originalList);
120
+ pageLists.slice(1).forEach(page => wrapper.appendChild(page));
121
+ wrapper.appendChild(pageLists[0].cloneNode(true));
122
+
123
+ originalList.innerHTML = '';
124
+ Array.from(pageLists[0].children).forEach(item => originalList.appendChild(item));
125
+
126
+ Object.assign(state, { currentIndex: 1, currentPage: 1, isAnimating: false });
127
+ wrapperChildren = Array.from(wrapper.children);
128
+ wrapper.style.transform = 'translateX(-100%)';
129
+
130
+ updateCounter();
131
+ updateHeight();
132
+ manageFocus();
133
+ return state.totalPages;
134
+ };
135
+ const liveRegion = document.createElement('div');
136
+ liveRegion.className = 'sr-only';
137
+ liveRegion.setAttribute('aria-live', 'assertive');
138
+ liveRegion.setAttribute('aria-atomic', 'true');
139
+ liveRegion.style.cssText = 'position: absolute; left: -10000px; width: 1px; height: 1px; overflow: hidden;';
140
+ container.appendChild(liveRegion);
141
+
142
+ const updateCounter = () => elements.counter && (elements.counter.textContent = `${state.currentPage} / ${state.totalPages}`);
143
+
144
+ const announcePageChange = () => {
145
+ liveRegion.textContent = `Page ${state.currentPage} of ${state.totalPages}`;
146
+ setTimeout(() => liveRegion.textContent = '', 1000);
147
+ };
148
+
149
+ const manageFocus = () => wrapperChildren.forEach((page, index) => {
150
+ page[index === state.currentIndex ? 'removeAttribute' : 'setAttribute']('inert', '');
92
151
  });
152
+
153
+ const updateHeight = () => {
154
+ const targetPage = wrapperChildren[state.currentIndex];
155
+ if (targetPage) wrapper.style.height = targetPage.offsetHeight + 'px';
156
+ };
157
+ let currentLayoutIsMobile = isMobileLayout();
158
+
159
+ const checkLayoutChange = () => {
160
+ const newIsMobile = isMobileLayout();
161
+ if (newIsMobile !== currentLayoutIsMobile) {
162
+ currentLayoutIsMobile = newIsMobile;
163
+ initializePagination();
164
+ } else {
165
+ updateHeight();
166
+ }
167
+ };
168
+
169
+ const resizeObserver = new ResizeObserver(checkLayoutChange);
170
+ resizeObserver.observe(wrapper);
171
+ initializePagination();
172
+
173
+ const navigate = (direction) => {
174
+ if (state.isAnimating || state.totalPages <= 1) return;
175
+ state.isAnimating = true;
176
+ state.currentIndex += direction;
177
+
178
+ state.currentPage = state.currentIndex > state.totalPages ? 1 :
179
+ state.currentIndex < 1 ? state.totalPages : state.currentIndex;
180
+
181
+ updateCounter();
182
+ announcePageChange();
183
+ updateHeight();
184
+
185
+ wrapper.style.transition = 'transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
186
+ wrapper.style.transform = `translateX(${-state.currentIndex * 100}%)`;
187
+
188
+ const handleTransitionEnd = () => {
189
+ wrapper.removeEventListener('transitionend', handleTransitionEnd);
190
+ wrapper.style.transition = '';
191
+
192
+ if (state.currentIndex > state.totalPages) {
193
+ state.currentIndex = 1;
194
+ state.currentPage = 1;
195
+ wrapper.style.transform = 'translateX(-100%)';
196
+ } else if (state.currentIndex < 1) {
197
+ state.currentIndex = state.totalPages;
198
+ state.currentPage = state.totalPages;
199
+ wrapper.style.transform = `translateX(${-state.totalPages * 100}%)`;
200
+ }
201
+
202
+ updateCounter();
203
+ announcePageChange();
204
+ updateHeight();
205
+ manageFocus();
206
+ state.isAnimating = false;
207
+ };
208
+
209
+ wrapper.addEventListener('transitionend', handleTransitionEnd);
210
+ };
211
+ elements.nextBtn.addEventListener('click', () => navigate(1));
212
+ elements.prevBtn.addEventListener('click', () => navigate(-1));
213
+
214
+ return () => resizeObserver.disconnect();
215
+ };
216
+
217
+ export const init = () => {
218
+ initInfiniteCarousel();
219
+ initInfinitePagination();
93
220
  };
94
221
 
95
222
  export const version = "1.0.0";