@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.
- package/autoInit/accessibility/README.md +9 -1
- package/autoInit/accessibility/accessibility.js +2 -1
- package/autoInit/accessibility/functions/pagination/README.md +428 -0
- package/{utils/slider.js → autoInit/accessibility/functions/pagination/pagination.js} +173 -242
- package/index.js +1 -3
- package/package.json +1 -1
- package/utils/before-after/README.md +520 -0
- package/utils/{before-after.js → before-after/before-after.js} +208 -75
|
@@ -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)
|
|
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
|
-
//
|
|
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
|
|
68
|
-
const
|
|
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
|
-
|
|
73
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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 (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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)
|
|
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
|
-
|
|
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,
|
|
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", "
|
|
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
|
|
496
|
-
const
|
|
497
|
-
|
|
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
|