@hortonstudio/main 1.9.8 → 1.9.9

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/index.js CHANGED
@@ -25,8 +25,8 @@ const initializeHsMain = async () => {
25
25
 
26
26
  const moduleMap = {
27
27
  transition: () => import("./autoInit/transition/transition.js"),
28
- "data-hs-util-ba": () => import("./utils/before-after.js"),
29
- "data-hs-util-slider": () => import("./utils/slider.js"),
28
+ "data-hs-util-ba": () => import("./utils/before-after/before-after.js"),
29
+ "data-hs-util-slider": () => import("./utils/slider/slider.js"),
30
30
  "smooth-scroll": () => import("./autoInit/smooth-scroll/smooth-scroll.js"),
31
31
  navbar: () => import("./autoInit/navbar/navbar.js"),
32
32
  accessibility: () => import("./autoInit/accessibility/accessibility.js"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hortonstudio/main",
3
- "version": "1.9.8",
3
+ "version": "1.9.9",
4
4
  "description": "Animation and utility library for client websites",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -0,0 +1,243 @@
1
+ # **Before/After Image Comparison**
2
+
3
+ ## **Overview**
4
+
5
+ Interactive before/after image comparison utility with multiple viewing modes, slider controls, and carousel functionality. Supports keyboard navigation, touch gestures, and accessibility features.
6
+
7
+ **Note:** This utility is loaded via `data-hs-util-ba` attribute on the script tag in index.js.
8
+
9
+ ---
10
+
11
+ ## **Features**
12
+
13
+ - **Three Viewing Modes**: Before, After, and Split (slider)
14
+ - **Multiple Items**: Carousel navigation with arrows and pagination
15
+ - **Slider Control**: Draggable slider with click-to-position
16
+ - **Keyboard Support**: Arrow keys for navigation and slider control
17
+ - **Touch Gestures**: Full mobile touch support
18
+ - **Accessibility**: ARIA attributes and keyboard navigation
19
+
20
+ ---
21
+
22
+ ## **Required Elements**
23
+
24
+ **Wrapper**
25
+ * data-hs-ba="wrapper"
26
+ * Container for entire before/after instance
27
+
28
+ **Image Wrapper**
29
+ * data-hs-ba="image-wrapper"
30
+ * Container for before/after images
31
+ * Focusable for keyboard slider control
32
+
33
+ **Before Image**
34
+ * data-hs-ba="image-before"
35
+ * Background/base image layer
36
+
37
+ **After Image**
38
+ * data-hs-ba="image-after"
39
+ * Foreground image with clip-path
40
+
41
+ **Slider** *(optional)*
42
+ * data-hs-ba="slider"
43
+ * Draggable handle for split view
44
+
45
+ **Mode Buttons** *(optional)*
46
+ * data-hs-ba="mode-before"
47
+ * data-hs-ba="mode-after"
48
+ * data-hs-ba="mode-split"
49
+
50
+ **Navigation** *(optional, for multiple items)*
51
+ * data-hs-ba="left" - Previous item
52
+ * data-hs-ba="right" - Next item
53
+
54
+ **Pagination** *(optional)*
55
+ * data-hs-ba="pagination"
56
+ * Container with template dot (first child)
57
+
58
+ ---
59
+
60
+ ## **Usage Example**
61
+
62
+ ### **Single Comparison**
63
+
64
+ ```html
65
+ <div data-hs-ba="wrapper">
66
+ <div data-hs-ba="image-wrapper">
67
+ <img data-hs-ba="image-before" src="before.jpg" alt="Before">
68
+ <img data-hs-ba="image-after" src="after.jpg" alt="After">
69
+ <div data-hs-ba="slider">
70
+ <!-- Slider handle content -->
71
+ </div>
72
+ </div>
73
+
74
+ <!-- Mode buttons -->
75
+ <button data-hs-ba="mode-before">Before</button>
76
+ <button data-hs-ba="mode-split">Split</button>
77
+ <button data-hs-ba="mode-after">After</button>
78
+ </div>
79
+ ```
80
+
81
+ ---
82
+
83
+ ### **Multiple Items with Carousel**
84
+
85
+ ```html
86
+ <div data-hs-ba="wrapper">
87
+ <!-- Item 1 -->
88
+ <div>
89
+ <div data-hs-ba="image-wrapper">
90
+ <img data-hs-ba="image-before" src="before-1.jpg">
91
+ <img data-hs-ba="image-after" src="after-1.jpg">
92
+ <div data-hs-ba="slider"></div>
93
+ </div>
94
+ <button data-hs-ba="mode-before">Before</button>
95
+ <button data-hs-ba="mode-split">Split</button>
96
+ <button data-hs-ba="mode-after">After</button>
97
+ </div>
98
+
99
+ <!-- Item 2 -->
100
+ <div>
101
+ <div data-hs-ba="image-wrapper">
102
+ <img data-hs-ba="image-before" src="before-2.jpg">
103
+ <img data-hs-ba="image-after" src="after-2.jpg">
104
+ <div data-hs-ba="slider"></div>
105
+ </div>
106
+ <button data-hs-ba="mode-before">Before</button>
107
+ <button data-hs-ba="mode-split">Split</button>
108
+ <button data-hs-ba="mode-after">After</button>
109
+ </div>
110
+
111
+ <!-- Navigation -->
112
+ <button data-hs-ba="left">Previous</button>
113
+ <button data-hs-ba="right">Next</button>
114
+
115
+ <!-- Pagination -->
116
+ <div data-hs-ba="pagination">
117
+ <div class="dot"></div> <!-- Template -->
118
+ </div>
119
+ </div>
120
+ ```
121
+
122
+ ---
123
+
124
+ ## **Viewing Modes**
125
+
126
+ ### **Before Mode**
127
+ - Shows only the before image
128
+ - Hides slider
129
+ - Clips after image to 100% (invisible)
130
+
131
+ ### **After Mode**
132
+ - Shows only the after image
133
+ - Hides slider
134
+ - Clips after image to 0% (fully visible)
135
+
136
+ ### **Split Mode** *(default)*
137
+ - Shows both images side by side
138
+ - Slider visible and draggable
139
+ - Default split at 50%
140
+ - Clicking split mode button resets to 50%
141
+
142
+ ---
143
+
144
+ ## **Keyboard Navigation**
145
+
146
+ ### **Main Wrapper Focus:**
147
+ - `Arrow Left/Right/Up/Down`: Navigate between items
148
+
149
+ ### **Image Wrapper Focus:**
150
+ - `Arrow Left`: Move slider left (in split mode)
151
+ - `Arrow Right`: Move slider right (in split mode)
152
+ - Step size: 5% per press (configurable)
153
+
154
+ ---
155
+
156
+ ## **Configuration**
157
+
158
+ Default configuration can be updated via `window.hsmain.utilBeforeAfter.updateConfig()`:
159
+
160
+ ```javascript
161
+ {
162
+ defaultMode: "split", // Initial viewing mode
163
+ sliderPosition: 50, // Default slider position (%)
164
+ touchSensitivity: 1, // Touch drag sensitivity
165
+ keyboardStep: 5, // Keyboard arrow step (%)
166
+ autoPlay: false, // Auto-advance items
167
+ autoPlayInterval: 5000 // Auto-play interval (ms)
168
+ }
169
+ ```
170
+
171
+ ---
172
+
173
+ ## **API Methods**
174
+
175
+ Accessible via `window.hsmain.utilBeforeAfter`:
176
+
177
+ ### **showSlide(instanceId, index)**
178
+ Navigate to specific item in carousel
179
+
180
+ ```javascript
181
+ window.hsmain.utilBeforeAfter.showSlide(0, 2); // Go to third item
182
+ ```
183
+
184
+ ### **setMode(instanceId, mode)**
185
+ Change viewing mode programmatically
186
+
187
+ ```javascript
188
+ window.hsmain.utilBeforeAfter.setMode(0, 'before');
189
+ ```
190
+
191
+ ### **updateConfig(newConfig)**
192
+ Update global configuration
193
+
194
+ ```javascript
195
+ window.hsmain.utilBeforeAfter.updateConfig({
196
+ sliderPosition: 60,
197
+ keyboardStep: 10
198
+ });
199
+ ```
200
+
201
+ ---
202
+
203
+ ## **Accessibility Features**
204
+
205
+ - Wrapper has `role="application"` with descriptive aria-label
206
+ - Image wrapper has `role="img"` and keyboard instructions
207
+ - Pagination dots have proper ARIA attributes
208
+ - All interactive elements are keyboard accessible
209
+ - Focus management between items
210
+ - Slider position persists across item navigation
211
+
212
+ ---
213
+
214
+ ## **Touch Support**
215
+
216
+ - Drag slider handle on touch devices
217
+ - Click/tap image wrapper to position slider
218
+ - Touch-friendly navigation buttons
219
+ - Prevents default scroll during drag
220
+
221
+ ---
222
+
223
+ ## **How It Works**
224
+
225
+ 1. **Initialization**: Finds all `[data-hs-ba="wrapper"]` elements
226
+ 2. **Instance Creation**: Each wrapper becomes independent instance
227
+ 3. **Carousel Setup**: Multiple items get navigation and pagination
228
+ 4. **Mode Management**: Buttons control image visibility via clip-path
229
+ 5. **Slider Dragging**: Mouse/touch events update clip-path in real-time
230
+ 6. **Keyboard Control**: Arrow keys navigate items and adjust slider
231
+ 7. **State Persistence**: Slider position maintained across item changes
232
+
233
+ ---
234
+
235
+ ## **Notes**
236
+
237
+ - Each wrapper is an independent instance
238
+ - Slider position persists when navigating items
239
+ - Pagination automatically generated from items
240
+ - First pagination dot used as template
241
+ - Supports any number of items (1+)
242
+ - Cleanup on destroy for Barba.js compatibility
243
+ - Caches DOM queries for performance
@@ -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
@@ -0,0 +1,299 @@
1
+ # **Slider/Carousel Utility**
2
+
3
+ ## **Overview**
4
+
5
+ Flexible slider/carousel system supporting infinite carousels and paginated lists with responsive layouts, keyboard navigation, and accessibility features.
6
+
7
+ **Note:** This utility is loaded via `data-hs-util-slider` attribute on the script tag in index.js.
8
+
9
+ ---
10
+
11
+ ## **Features**
12
+
13
+ - **Infinite Carousel**: Seamless looping with GSAP animations
14
+ - **Pagination Slider**: Multi-item pages with infinite loop
15
+ - **Responsive**: Desktop/mobile breakpoints with different item counts
16
+ - **Keyboard Support**: Full keyboard navigation
17
+ - **Accessibility**: ARIA attributes, live regions, focus management
18
+ - **Dot Navigation**: Click dots to jump to any page
19
+
20
+ ---
21
+
22
+ ## **Slider Types**
23
+
24
+ ### **1. Infinite Carousel**
25
+
26
+ Basic infinite loop slider with next/prev buttons.
27
+
28
+ **Required Elements:**
29
+ - `data-hs-slider="wrapper"` - Container for slides
30
+ - `data-hs-slider="next"` - Next button
31
+ - `data-hs-slider="previous"` - Previous button
32
+
33
+ **Example:**
34
+ ```html
35
+ <div data-hs-slider="wrapper">
36
+ <div>Slide 1</div>
37
+ <div>Slide 2</div>
38
+ <div>Slide 3</div>
39
+ </div>
40
+
41
+ <button data-hs-slider="previous">Prev</button>
42
+ <button data-hs-slider="next">Next</button>
43
+ ```
44
+
45
+ **How it works:**
46
+ - Clones first and last slides for seamless loop
47
+ - Uses GSAP for smooth animations
48
+ - Instant reset at loop endpoints
49
+
50
+ ---
51
+
52
+ ### **2. Pagination Slider**
53
+
54
+ Multi-item slider with page-based navigation.
55
+
56
+ **Required Elements:**
57
+ - `data-hs-slider="pagination"` - Main container
58
+ - `data-hs-slider="pagination-list"` - List of items
59
+ - `data-hs-slider="pagination-next"` - Next page button
60
+ - `data-hs-slider="pagination-previous"` - Previous page button
61
+
62
+ **Optional Elements:**
63
+ - `data-hs-slider="pagination-counter"` - Page counter display
64
+ - `data-hs-slider="pagination-controls"` - Controls container with config
65
+ - `data-hs-slider="dots-wrap"` - Dot navigation container
66
+
67
+ ---
68
+
69
+ ## **Configuration**
70
+
71
+ Add to `pagination-controls` element via `data-hs-config`:
72
+
73
+ ### **Syntax:**
74
+ ```
75
+ data-hs-config="[option], [option], ..."
76
+ ```
77
+
78
+ ### **Options:**
79
+
80
+ **Show Items (Desktop):**
81
+ ```html
82
+ data-hs-config="show-6"
83
+ ```
84
+ Shows 6 items per page on desktop (default: 6)
85
+
86
+ **Show Items (Mobile):**
87
+ ```html
88
+ data-hs-config="show-3-mobile"
89
+ ```
90
+ Shows 3 items per page on mobile (default: desktop value)
91
+
92
+ **Infinite Mode:**
93
+ ```html
94
+ data-hs-config="infinite"
95
+ ```
96
+ Disables pagination completely (hides controls and dots)
97
+
98
+ **Combined Example:**
99
+ ```html
100
+ <div data-hs-slider="pagination-controls" data-hs-config="show-6, show-3-mobile">
101
+ <!-- Controls -->
102
+ </div>
103
+ ```
104
+
105
+ ---
106
+
107
+ ## **Pagination Example**
108
+
109
+ ```html
110
+ <div data-hs-slider="pagination">
111
+ <!-- List wrapper (auto-managed) -->
112
+ <div>
113
+ <div data-hs-slider="pagination-list">
114
+ <div>Item 1</div>
115
+ <div>Item 2</div>
116
+ <div>Item 3</div>
117
+ <div>Item 4</div>
118
+ <div>Item 5</div>
119
+ <div>Item 6</div>
120
+ <div>Item 7</div>
121
+ <div>Item 8</div>
122
+ </div>
123
+ </div>
124
+
125
+ <!-- Controls -->
126
+ <div data-hs-slider="pagination-controls" data-hs-config="show-4, show-2-mobile">
127
+ <button data-hs-slider="pagination-previous">Previous</button>
128
+ <div data-hs-slider="pagination-counter">1 / 2</div>
129
+ <button data-hs-slider="pagination-next">Next</button>
130
+ </div>
131
+
132
+ <!-- Dot navigation -->
133
+ <div data-hs-slider="dots-wrap">
134
+ <div class="dot"></div> <!-- Template (active) -->
135
+ <div class="dot"></div> <!-- Template (inactive) -->
136
+ </div>
137
+ </div>
138
+ ```
139
+
140
+ ---
141
+
142
+ ## **Dot Navigation**
143
+
144
+ ### **Setup:**
145
+ 1. Add `data-hs-slider="dots-wrap"` container
146
+ 2. Include template dots as children
147
+ 3. First dot with `is-active` class = active template
148
+ 4. Second dot without class = inactive template
149
+
150
+ ### **Behavior:**
151
+ - Dots auto-generated for each page
152
+ - Click or press Enter/Space to navigate
153
+ - Active dot gets `is-active` class and `aria-current="page"`
154
+ - Hidden when only 1 page or infinite mode
155
+
156
+ ### **Example:**
157
+ ```html
158
+ <div data-hs-slider="dots-wrap">
159
+ <div class="dot is-active"></div> <!-- Active template -->
160
+ <div class="dot"></div> <!-- Inactive template -->
161
+ </div>
162
+ ```
163
+
164
+ ---
165
+
166
+ ## **Responsive Breakpoints**
167
+
168
+ Uses CSS custom property `--data-hs-break` to detect layout:
169
+
170
+ ```css
171
+ .pagination-list {
172
+ --data-hs-break: "desktop";
173
+ }
174
+
175
+ @media (max-width: 768px) {
176
+ .pagination-list {
177
+ --data-hs-break: "mobile";
178
+ }
179
+ }
180
+ ```
181
+
182
+ When breakpoint changes:
183
+ - Re-calculates items per page
184
+ - Re-generates pagination
185
+ - Maintains current position when possible
186
+
187
+ ---
188
+
189
+ ## **Keyboard Navigation**
190
+
191
+ All controls support keyboard:
192
+ - `Tab`: Navigate between controls
193
+ - `Enter/Space`: Activate buttons and dots
194
+ - Focus managed with `inert` attribute on inactive pages
195
+
196
+ ---
197
+
198
+ ## **Accessibility Features**
199
+
200
+ ### **ARIA Attributes:**
201
+ - `aria-label` on all buttons
202
+ - `aria-live="polite"` on counter
203
+ - `aria-current="page"` on active dot
204
+ - `aria-hidden` on hidden controls/dots
205
+
206
+ ### **Screen Reader Announcements:**
207
+ - Page changes announced via live region
208
+ - Format: "Page X of Y"
209
+ - Temporary announcement (clears after 1s)
210
+
211
+ ### **Focus Management:**
212
+ - Uses `inert` attribute on inactive pages
213
+ - Prevents tab navigation to hidden content
214
+ - Focus maintained on controls during navigation
215
+
216
+ ---
217
+
218
+ ## **Pagination Behavior**
219
+
220
+ ### **Single Page:**
221
+ - Controls and dots hidden
222
+ - All items visible
223
+ - No interaction needed
224
+
225
+ ### **Multiple Pages:**
226
+ - Infinite loop (last page → first page)
227
+ - Clones pages at start/end for seamless loop
228
+ - Instant position reset at boundaries
229
+ - Smooth CSS transitions between pages
230
+
231
+ ### **Mobile Scroll:**
232
+ On mobile, after page change:
233
+ - Auto-scrolls to show controls
234
+ - 5rem clearance from bottom
235
+ - Smooth scroll behavior
236
+
237
+ ---
238
+
239
+ ## **How It Works**
240
+
241
+ ### **Infinite Carousel:**
242
+ 1. Clones first/last slides
243
+ 2. Positions wrapper at slide 1 (middle)
244
+ 3. On next/prev, animates to target
245
+ 4. On loop, instantly resets position
246
+ 5. Uses GSAP for smooth animations
247
+
248
+ ### **Pagination:**
249
+ 1. Calculates total pages based on config
250
+ 2. Splits items into page lists
251
+ 3. Clones pages for infinite loop
252
+ 4. Positions wrapper at page 1 (middle)
253
+ 5. On navigate, transitions to target page
254
+ 6. On loop, instantly resets to real page
255
+ 7. Manages height based on active page
256
+
257
+ ---
258
+
259
+ ## **Edge Cases**
260
+
261
+ ### **No Items:**
262
+ - Early exit, no initialization
263
+
264
+ ### **Single Item (Pagination):**
265
+ - No controls shown
266
+ - Single item displayed
267
+ - No pagination needed
268
+
269
+ ### **Infinite Mode:**
270
+ - Controls completely hidden
271
+ - Dots completely hidden
272
+ - List displayed as-is
273
+ - No pagination logic runs
274
+
275
+ ### **Layout Changes:**
276
+ - ResizeObserver detects breakpoint changes
277
+ - Re-initializes pagination with new item count
278
+ - Tries to maintain current page position
279
+
280
+ ---
281
+
282
+ ## **Performance**
283
+
284
+ - Uses CSS custom properties for breakpoint detection
285
+ - ResizeObserver for efficient layout monitoring
286
+ - Inert attribute prevents unnecessary focus handling
287
+ - Cleanup on destroy for Barba.js compatibility
288
+ - Efficient DOM cloning and manipulation
289
+
290
+ ---
291
+
292
+ ## **Notes**
293
+
294
+ - Requires GSAP for infinite carousel animations
295
+ - CSS transitions handle pagination animations
296
+ - Each pagination container is independent
297
+ - Dot templates must exist before initialization
298
+ - Mobile breakpoint detected via CSS custom property
299
+ - All event listeners cleaned up on destroy
File without changes