@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
|
@@ -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)
|
|
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
|
|
@@ -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
|