@hortonstudio/main 1.9.8 → 1.9.10

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,3 +1,58 @@
1
+ // Module-level state (shared across all inits)
2
+ let globalDragInstance = null;
3
+ const elementRefs = new WeakMap();
4
+
5
+ // Global event handlers for drag operations
6
+ function handleGlobalMouseMove(e) {
7
+ if (globalDragInstance) {
8
+ const rect = globalDragInstance.imageWrap.getBoundingClientRect();
9
+ const x = e.clientX - rect.left;
10
+ const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
11
+
12
+ globalDragInstance.slider.style.left = `${percentage}%`;
13
+ globalDragInstance.updateSliderPosition(
14
+ globalDragInstance.instance,
15
+ globalDragInstance.instance.currentIndex,
16
+ percentage,
17
+ );
18
+ globalDragInstance.instance.sliderPosition = percentage;
19
+ }
20
+ }
21
+
22
+ function handleGlobalTouchMove(e) {
23
+ if (globalDragInstance) {
24
+ e.preventDefault();
25
+ const rect = globalDragInstance.imageWrap.getBoundingClientRect();
26
+ const x = e.touches[0].clientX - rect.left;
27
+ const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
28
+
29
+ globalDragInstance.slider.style.left = `${percentage}%`;
30
+ globalDragInstance.updateSliderPosition(
31
+ globalDragInstance.instance,
32
+ globalDragInstance.instance.currentIndex,
33
+ percentage,
34
+ );
35
+ globalDragInstance.instance.sliderPosition = percentage;
36
+ }
37
+ }
38
+
39
+ function handleGlobalDragEnd() {
40
+ if (globalDragInstance) {
41
+ globalDragInstance.slider.style.cursor = "grab";
42
+ globalDragInstance = null;
43
+ document.body.style.userSelect = "";
44
+ document.body.style.cursor = "";
45
+ }
46
+ }
47
+
48
+ // Set up global event handlers once (module-level)
49
+ if (typeof document !== "undefined") {
50
+ document.addEventListener("mousemove", handleGlobalMouseMove);
51
+ document.addEventListener("touchmove", handleGlobalTouchMove, { passive: false });
52
+ document.addEventListener("mouseup", handleGlobalDragEnd);
53
+ document.addEventListener("touchend", handleGlobalDragEnd);
54
+ }
55
+
1
56
  export function init() {
2
57
  const config = {
3
58
  defaultMode: "split",
@@ -15,11 +70,34 @@ export function init() {
15
70
  Object.assign(config, newConfig);
16
71
  }
17
72
 
73
+ // Live region for announcing state changes
74
+ function announceChange(instance, message) {
75
+ const liveRegion = elementRefs.get(instance.wrapper)?.liveRegion;
76
+ if (liveRegion) {
77
+ liveRegion.textContent = message;
78
+ // Clear after announcement to allow repeated announcements
79
+ setTimeout(() => {
80
+ liveRegion.textContent = '';
81
+ }, 1000);
82
+ }
83
+ }
84
+
18
85
  function initInstance(wrapper) {
19
86
  const instanceId = instances.length;
20
87
  const items = Array.from(wrapper.children);
21
88
 
22
- if (items.length === 0) return;
89
+ if (items.length === 0) {
90
+ console.warn('[hs-before-after] Wrapper element has no child items');
91
+ return;
92
+ }
93
+
94
+ // Create and append live region for announcements
95
+ const liveRegion = document.createElement('div');
96
+ liveRegion.setAttribute('aria-live', 'polite');
97
+ liveRegion.setAttribute('aria-atomic', 'true');
98
+ liveRegion.className = 'sr-only';
99
+ liveRegion.style.cssText = 'position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border-width: 0;';
100
+ wrapper.appendChild(liveRegion);
23
101
 
24
102
  const instance = {
25
103
  id: instanceId,
@@ -35,6 +113,9 @@ export function init() {
35
113
  cachedElements: new Map(),
36
114
  };
37
115
 
116
+ // Store live region reference in WeakMap
117
+ elementRefs.set(wrapper, { liveRegion });
118
+
38
119
  instances.push(instance);
39
120
  currentSlideIndex[instanceId] = 0;
40
121
 
@@ -50,8 +131,22 @@ export function init() {
50
131
  items.forEach((item, index) => {
51
132
  item.style.display = index === 0 ? "block" : "none";
52
133
 
53
- // Set default clip path for after image
134
+ // Check for required elements and warn if missing
135
+ const imageWrapper = item.querySelector('[data-hs-ba="image-wrapper"]');
136
+ const beforeImage = item.querySelector('[data-hs-ba="image-before"]');
54
137
  const afterImage = item.querySelector('[data-hs-ba="image-after"]');
138
+
139
+ if (!imageWrapper) {
140
+ console.warn(`[hs-before-after] Missing required element in item ${index + 1}: data-hs-ba="image-wrapper"`);
141
+ }
142
+ if (!beforeImage) {
143
+ console.warn(`[hs-before-after] Missing required element in item ${index + 1}: data-hs-ba="image-before"`);
144
+ }
145
+ if (!afterImage) {
146
+ console.warn(`[hs-before-after] Missing required element in item ${index + 1}: data-hs-ba="image-after"`);
147
+ }
148
+
149
+ // Set default clip path for after image
55
150
  if (afterImage) {
56
151
  afterImage.style.clipPath = `polygon(${config.sliderPosition}% 0%, 100% 0%, 100% 100%, ${config.sliderPosition}% 100%)`;
57
152
  }
@@ -64,13 +159,40 @@ export function init() {
64
159
 
65
160
  function setupItemInteractions(instance, item, itemIndex) {
66
161
  const modeButtons = item.querySelectorAll('[data-hs-ba^="mode-"]');
67
- const leftArrow = item.querySelector('[data-hs-ba="left"]');
68
- const rightArrow = item.querySelector('[data-hs-ba="right"]');
162
+ const leftArrowWrapper = item.querySelector('[data-hs-ba="left-arrow"]');
163
+ const rightArrowWrapper = item.querySelector('[data-hs-ba="right-arrow"]');
69
164
  const slider = item.querySelector('[data-hs-ba="slider"]');
70
165
  const paginationContainer = item.querySelector('[data-hs-ba="pagination"]');
71
166
 
72
- modeButtons.forEach((button) => {
73
- const mode = button.getAttribute("data-hs-ba").replace("mode-", "");
167
+ // Setup mode buttons
168
+ modeButtons.forEach((modeWrapper) => {
169
+ const mode = modeWrapper.getAttribute("data-hs-ba").replace("mode-", "");
170
+
171
+ // Find the element with data-button-style attribute
172
+ const styleElement = modeWrapper.querySelector('[data-button-style]');
173
+
174
+ // Find the actual button inside clickable
175
+ const clickableElement = modeWrapper.querySelector('[data-site-clickable="element"]');
176
+ const button = clickableElement ? clickableElement.children[0] : null;
177
+
178
+ if (!button) return;
179
+
180
+ // Store references in WeakMap for later use
181
+ if (!elementRefs.has(modeWrapper)) {
182
+ elementRefs.set(modeWrapper, {});
183
+ }
184
+ const refs = elementRefs.get(modeWrapper);
185
+ refs.button = button;
186
+ refs.styleElement = styleElement;
187
+
188
+ // Add descriptive aria-label based on mode
189
+ const ariaLabels = {
190
+ before: "Switch to before view",
191
+ after: "Switch to after view",
192
+ split: "Switch to split view"
193
+ };
194
+ button.setAttribute("aria-label", ariaLabels[mode] || `Switch to ${mode} view`);
195
+
74
196
  button.addEventListener("click", (e) => {
75
197
  e.preventDefault();
76
198
 
@@ -83,18 +205,31 @@ export function init() {
83
205
  });
84
206
  });
85
207
 
86
- if (leftArrow) {
87
- leftArrow.addEventListener("click", (e) => {
88
- e.preventDefault();
89
- navigateSlide(instance, -1);
90
- });
208
+ // Setup arrow buttons
209
+ if (leftArrowWrapper) {
210
+ const clickableElement = leftArrowWrapper.querySelector('[data-site-clickable="element"]');
211
+ const leftButton = clickableElement ? clickableElement.children[0] : null;
212
+
213
+ if (leftButton) {
214
+ leftButton.setAttribute('aria-label', 'Previous image');
215
+ leftButton.addEventListener("click", (e) => {
216
+ e.preventDefault();
217
+ navigateSlide(instance, -1);
218
+ });
219
+ }
91
220
  }
92
221
 
93
- if (rightArrow) {
94
- rightArrow.addEventListener("click", (e) => {
95
- e.preventDefault();
96
- navigateSlide(instance, 1);
97
- });
222
+ if (rightArrowWrapper) {
223
+ const clickableElement = rightArrowWrapper.querySelector('[data-site-clickable="element"]');
224
+ const rightButton = clickableElement ? clickableElement.children[0] : null;
225
+
226
+ if (rightButton) {
227
+ rightButton.setAttribute('aria-label', 'Next image');
228
+ rightButton.addEventListener("click", (e) => {
229
+ e.preventDefault();
230
+ navigateSlide(instance, 1);
231
+ });
232
+ }
98
233
  }
99
234
 
100
235
  if (slider) {
@@ -113,12 +248,21 @@ export function init() {
113
248
  const sliderHandle = item.querySelector('[data-hs-ba="slider"]');
114
249
  const modeButtons = item.querySelectorAll('[data-hs-ba^="mode-"]');
115
250
 
116
- modeButtons.forEach((btn) => {
117
- const btnMode = btn.getAttribute("data-hs-ba").replace("mode-", "");
118
- if (btnMode === mode) {
119
- btn.classList.add("is-active");
120
- } else {
121
- btn.classList.remove("is-active");
251
+ // Update mode button states
252
+ modeButtons.forEach((modeWrapper) => {
253
+ const btnMode = modeWrapper.getAttribute("data-hs-ba").replace("mode-", "");
254
+ const refs = elementRefs.get(modeWrapper);
255
+
256
+ if (refs && refs.button && refs.styleElement) {
257
+ if (btnMode === mode) {
258
+ // Active state
259
+ refs.styleElement.setAttribute('data-button-style', 'primary');
260
+ refs.button.setAttribute('aria-pressed', 'true');
261
+ } else {
262
+ // Inactive state
263
+ refs.styleElement.setAttribute('data-button-style', 'secondary');
264
+ refs.button.setAttribute('aria-pressed', 'false');
265
+ }
122
266
  }
123
267
  });
124
268
 
@@ -145,6 +289,14 @@ export function init() {
145
289
  }
146
290
 
147
291
  instance.mode = mode;
292
+
293
+ // Announce mode change
294
+ const modeLabels = {
295
+ before: "Before view",
296
+ after: "After view",
297
+ split: "Split view"
298
+ };
299
+ announceChange(instance, modeLabels[mode] || mode);
148
300
  }
149
301
 
150
302
  function setupSliderDragging(instance, slider, itemIndex) {
@@ -152,64 +304,22 @@ export function init() {
152
304
  // Find the image wrapper using the data attribute
153
305
  const imageWrap = item.querySelector('[data-hs-ba="image-wrapper"]');
154
306
 
155
- if (!imageWrap) return;
307
+ if (!imageWrap) {
308
+ console.warn('[hs-before-after] Missing required element: data-hs-ba="image-wrapper"');
309
+ return;
310
+ }
156
311
 
157
312
  function startDrag(e) {
158
- instance.isDragging = true;
159
- instance.dragInstance = { instance, itemIndex, imageWrap, slider };
313
+ globalDragInstance = { instance, imageWrap, slider, updateSliderPosition };
160
314
  document.body.style.userSelect = "none";
161
315
  document.body.style.cursor = "grabbing";
162
316
  slider.style.cursor = "grabbing";
163
317
  e.preventDefault();
164
318
  }
165
319
 
166
- function handleDrag(clientX) {
167
- if (!instance.isDragging || !instance.dragInstance) return;
168
-
169
- const rect = instance.dragInstance.imageWrap.getBoundingClientRect();
170
- const x = clientX - rect.left;
171
- const percentage = Math.max(0, Math.min(100, (x / rect.width) * 100));
172
-
173
- // Move the slider element directly
174
- instance.dragInstance.slider.style.left = `${percentage}%`;
175
-
176
- // Update the clip path
177
- updateSliderPosition(
178
- instance.dragInstance.instance,
179
- instance.dragInstance.itemIndex,
180
- percentage,
181
- );
182
- instance.dragInstance.instance.sliderPosition = percentage;
183
- }
184
-
185
- function endDrag() {
186
- instance.isDragging = false;
187
- if (instance.dragInstance && instance.dragInstance.slider) {
188
- instance.dragInstance.slider.style.cursor = "grab";
189
- }
190
- instance.dragInstance = null;
191
- document.body.style.userSelect = "";
192
- document.body.style.cursor = "";
193
- }
194
-
195
320
  slider.addEventListener("mousedown", startDrag);
196
321
  slider.addEventListener("touchstart", startDrag, { passive: false });
197
322
 
198
- document.addEventListener("mousemove", (e) => handleDrag(e.clientX));
199
- document.addEventListener(
200
- "touchmove",
201
- (e) => {
202
- if (instance.isDragging) {
203
- e.preventDefault();
204
- handleDrag(e.touches[0].clientX);
205
- }
206
- },
207
- { passive: false },
208
- );
209
-
210
- document.addEventListener("mouseup", endDrag);
211
- document.addEventListener("touchend", endDrag);
212
-
213
323
  imageWrap.addEventListener("click", (e) => {
214
324
  if (instance.mode !== "split") return;
215
325
 
@@ -218,7 +328,7 @@ export function init() {
218
328
  const percentage = (x / rect.width) * 100;
219
329
 
220
330
  slider.style.left = `${percentage}%`;
221
- updateSliderPosition(instance, itemIndex, percentage);
331
+ updateSliderPosition(instance, instance.currentIndex, percentage);
222
332
  instance.sliderPosition = percentage;
223
333
  });
224
334
  }
@@ -300,6 +410,11 @@ export function init() {
300
410
 
301
411
  updatePagination(instance);
302
412
  setMode(instance, index, instance.mode);
413
+
414
+ // Announce slide change
415
+ if (instance.items.length > 1) {
416
+ announceChange(instance, `Image ${index + 1} of ${instance.items.length}`);
417
+ }
303
418
  }
304
419
 
305
420
  function setupPagination(instance, paginationContainer) {
@@ -309,6 +424,10 @@ export function init() {
309
424
 
310
425
  if (!templateDot) return; // No template found
311
426
 
427
+ // Add pagination accessibility attributes
428
+ paginationContainer.setAttribute("role", "group");
429
+ paginationContainer.setAttribute("aria-label", "Image pagination");
430
+
312
431
  // Clear existing dots
313
432
  paginationContainer.innerHTML = "";
314
433
 
@@ -444,7 +563,8 @@ export function init() {
444
563
 
445
564
  // Set up main wrapper accessibility
446
565
  instance.wrapper.setAttribute("tabindex", "0");
447
- instance.wrapper.setAttribute("role", "application");
566
+ instance.wrapper.setAttribute("role", "region");
567
+ instance.wrapper.setAttribute("aria-roledescription", "image comparison carousel");
448
568
  instance.wrapper.setAttribute(
449
569
  "aria-label",
450
570
  "Before and after image comparison, use arrow keys to navigate items",
@@ -490,21 +610,34 @@ export function init() {
490
610
  return {
491
611
  result: "before-after initialized",
492
612
  destroy: () => {
613
+ // Reset global drag state
614
+ globalDragInstance = null;
615
+
493
616
  // Clean up all instances
494
617
  instances.forEach((instance) => {
495
- // Remove event listeners from wrapper
496
- const newWrapper = instance.wrapper.cloneNode(true);
497
- instance.wrapper.parentNode.replaceChild(newWrapper, instance.wrapper);
618
+ // Remove live region element
619
+ const liveRegion = elementRefs.get(instance.wrapper)?.liveRegion;
620
+ if (liveRegion && liveRegion.parentNode) {
621
+ liveRegion.parentNode.removeChild(liveRegion);
622
+ }
498
623
 
499
624
  // Clear cached elements
500
625
  instance.cachedElements.clear();
626
+
627
+ // Remove event listeners by cloning and replacing wrapper
628
+ const newWrapper = instance.wrapper.cloneNode(true);
629
+ instance.wrapper.parentNode.replaceChild(newWrapper, instance.wrapper);
501
630
  });
502
631
 
503
- // Remove initialized markers
632
+ // Remove initialized markers and accessibility attributes
504
633
  document
505
634
  .querySelectorAll('[data-hs-ba="wrapper"][data-initialized]')
506
635
  .forEach((wrapper) => {
507
636
  wrapper.removeAttribute("data-initialized");
637
+ wrapper.removeAttribute("tabindex");
638
+ wrapper.removeAttribute("role");
639
+ wrapper.removeAttribute("aria-roledescription");
640
+ wrapper.removeAttribute("aria-label");
508
641
  });
509
642
 
510
643
  // Reset module state