1/** 2 * Hero Slider 3 * 4 * Accessible, touch-enabled hero slider. Initialises every `.hero-slider` on 5 * the page, so a page may hold more than one instance (homepage hero, plus 6 * any Hero Slider rows added through the Pillar flex layout). 7 * 8 * Per-instance settings come from data attributes written by the template 9 * part: data-autoplay, data-interval. 10 */ 11(function () { 12 'use strict'; 13 14 class HeroSlider { 15 constructor(root, options = {}) { 16 this.root = typeof root === 'string' ? document.querySelector(root) : root; 17 if (!this.root) { 18 return; 19 } 20 21 this.options = { 22 interval: 5000, 23 autoplay: true, 24 pauseOnHover: true, 25 pauseOnFocus: true, 26 swipeThreshold: 50, 27 ...options 28 }; 29 30 this.track = this.root.querySelector('.hhs-track'); 31 this.slides = Array.from(this.root.querySelectorAll('.hhs-slide')); 32 this.dotsWrap = this.root.querySelector('.hhs-dots'); 33 34 if (this.slides.length === 0) { 35 return; 36 } 37 38 // State 39 this.index = 0; 40 this.timer = null; 41 this.isUserInteracting = false; 42 this.motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)'); 43 this.reducedMotion = this.motionQuery.matches; 44 45 this.touchState = { startX: null, startY: null, startTime: null }; 46 47 this.init(); 48 } 49 50 init() { 51 // Data attributes override the constructor defaults 52 const autoplayAttr = this.root.dataset.autoplay; 53 const intervalAttr = this.root.dataset.interval; 54 55 if (autoplayAttr !== undefined) { 56 this.options.autoplay = autoplayAttr === 'true'; 57 } 58 if (intervalAttr !== undefined) { 59 const parsed = parseInt(intervalAttr, 10); 60 if (!isNaN(parsed) && parsed > 0) { 61 this.options.interval = parsed; 62 } 63 } 64 65 this.hasMultiple = this.slides.length > 1; 66 67 if (this.hasMultiple && this.dotsWrap) { 68 this.createDots(); 69 } else if (this.dotsWrap) { 70 this.dotsWrap.hidden = true; 71 } 72 73 this.bindEvents(); 74 75 // First slide is already marked active and visible by the template part; 76 // trigger its entrance animation once the browser has painted it. 77 this.slides[0].setAttribute('aria-hidden', 'false'); 78 window.requestAnimationFrame(() => { 79 this.slides[0].classList.add('is-animating'); 80 }); 81 82 if (this.options.autoplay && !this.reducedMotion && this.hasMultiple) { 83 this.startAutoplay(); 84 } 85 86 this.root.setAttribute('aria-live', 'polite'); 87 this.root.setAttribute('aria-atomic', 'false'); 88 89 // Tells the inline stamp in the template part that we took over, so it 90 // leaves `data-hhs-init` in place instead of clearing it as a failsafe 91 this.root.dataset.hhsReady = 'true'; 92 } 93 94 createDots() { 95 this.dotsWrap.innerHTML = ''; 96 this.dots = this.slides.map((slide, i) => { 97 const button = document.createElement('button'); 98 button.type = 'button'; 99 button.setAttribute('role', 'tab'); 100 button.setAttribute('aria-controls', slide.id); 101 button.setAttribute('aria-label', `Go to slide ${i + 1}`); 102 button.setAttribute('aria-selected', i === 0 ? 'true' : 'false'); 103 button.addEventListener('click', () => this.show(i, true)); 104 this.dotsWrap.appendChild(button); 105 return button; 106 }); 107 } 108 109 bindEvents() { 110 if (this.options.pauseOnHover) { 111 this.root.addEventListener('mouseenter', () => this.handleUserStart()); 112 this.root.addEventListener('mouseleave', () => this.handleUserEnd()); 113 } 114 115 if (this.options.pauseOnFocus) { 116 this.root.addEventListener('focusin', () => this.handleUserStart()); 117 this.root.addEventListener('focusout', (e) => { 118 if (!this.root.contains(e.relatedTarget)) { 119 this.handleUserEnd(); 120 } 121 }); 122 } 123 124 this.root.addEventListener('keydown', (e) => this.handleKeydown(e)); 125 126 this.root.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: true }); 127 this.root.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: false }); 128 this.root.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: true }); 129 130 document.addEventListener('visibilitychange', () => { 131 if (document.hidden) { 132 this.stopAutoplay(); 133 } else if (this.options.autoplay && !this.isUserInteracting && !this.reducedMotion) { 134 this.startAutoplay(); 135 } 136 }); 137 138 // Safari < 14 only supports the deprecated addListener signature 139 const onMotionChange = (e) => { 140 this.reducedMotion = e.matches; 141 if (this.reducedMotion) { 142 this.stopAutoplay(); 143 } else if (this.options.autoplay && !this.isUserInteracting) { 144 this.startAutoplay(); 145 } 146 }; 147 148 if (typeof this.motionQuery.addEventListener === 'function') { 149 this.motionQuery.addEventListener('change', onMotionChange); 150 } else if (typeof this.motionQuery.addListener === 'function') { 151 this.motionQuery.addListener(onMotionChange); 152 } 153 } 154 155 handleKeydown(e) { 156 switch (e.key) { 157 case 'ArrowRight': 158 case 'ArrowDown': 159 e.preventDefault(); 160 this.next(true); 161 break; 162 case 'ArrowLeft': 163 case 'ArrowUp': 164 e.preventDefault(); 165 this.prev(true); 166 break; 167 case 'Home': 168 e.preventDefault(); 169 this.show(0, true); 170 break; 171 case 'End': 172 e.preventDefault(); 173 this.show(this.slides.length - 1, true); 174 break; 175 } 176 } 177 178 handleTouchStart(e) { 179 this.touchState.startX = e.touches[0].clientX; 180 this.touchState.startY = e.touches[0].clientY; 181 this.touchState.startTime = Date.now(); 182 this.handleUserStart(); 183 } 184 185 handleTouchMove(e) { 186 if (this.touchState.startX === null) return; 187 188 const diffX = e.touches[0].clientX - this.touchState.startX; 189 const diffY = e.touches[0].clientY - this.touchState.startY; 190 191 // Only claim the gesture once it's clearly horizontal 192 if (Math.abs(diffX) > Math.abs(diffY) && e.cancelable) { 193 e.preventDefault(); 194 } 195 } 196 197 handleTouchEnd(e) { 198 if (this.touchState.startX === null) return; 199 200 const endX = e.changedTouches[0].clientX; 201 const endY = e.changedTouches[0].clientY; 202 const diffX = this.touchState.startX - endX; 203 const diffY = this.touchState.startY - endY; 204 const timeDiff = Date.now() - this.touchState.startTime; 205 206 if (Math.abs(diffX) > Math.abs(diffY) && 207 Math.abs(diffX) > this.options.swipeThreshold && 208 timeDiff < 300) { 209 if (diffX > 0) { 210 this.next(true); 211 } else { 212 this.prev(true); 213 } 214 } 215 216 this.touchState = { startX: null, startY: null, startTime: null }; 217 this.handleUserEnd(); 218 } 219 220 handleUserStart() { 221 this.isUserInteracting = true; 222 this.stopAutoplay(); 223 } 224 225 handleUserEnd() { 226 this.isUserInteracting = false;
227 if (this.options.autoplay && !this.reducedMotion && this.hasMultiple) { 228 // Short delay so a click-then-leave doesn't restart mid-transition 229 setTimeout(() => { 230 if (!this.isUserInteracting) { 231 this.startAutoplay(); 232 } 233 }, 100); 234 } 235 } 236 237 startAutoplay() { 238 if (this.reducedMotion || !this.hasMultiple) return; 239 this.stopAutoplay(); 240 this.timer = setInterval(() => this.next(false), this.options.interval); 241 } 242 243 stopAutoplay() { 244 if (this.timer) { 245 clearInterval(this.timer); 246 this.timer = null; 247 } 248 } 249 250 next(userInitiated) { 251 this.show((this.index + 1) % this.slides.length, userInitiated); 252 } 253 254 prev(userInitiated) { 255 this.show((this.index - 1 + this.slides.length) % this.slides.length, userInitiated); 256 } 257 258 show(newIndex, userInitiated) { 259 if (newIndex === this.index) return; 260 261 const oldIndex = this.index; 262 const oldSlide = this.slides[oldIndex]; 263 const newSlide = this.slides[newIndex]; 264 265 if (oldSlide) { 266 oldSlide.classList.remove('is-active', 'is-animating'); 267 oldSlide.setAttribute('aria-hidden', 'true'); 268 } 269 270 newSlide.classList.add('is-active'); 271 newSlide.setAttribute('aria-hidden', 'false'); 272 273 // Restart the entrance animation on the incoming slide. Two frames: 274 // one for the class removal to land, one to re-add it. 275 window.requestAnimationFrame(() => { 276 window.requestAnimationFrame(() => { 277 newSlide.classList.add('is-animating'); 278 }); 279 }); 280 281 if (this.dots) { 282 this.dots[oldIndex]?.setAttribute('aria-selected', 'false'); 283 this.dots[newIndex]?.setAttribute('aria-selected', 'true'); 284 } 285 286 this.index = newIndex; 287 288 if (userInitiated) { 289 this.announceSlide(newIndex); 290 this.handleUserEnd(); 291 } 292 } 293 294 announceSlide(index) { 295 // Throttle so rapid arrow-key presses don't flood the live region 296 if (this.lastAnnouncement && Date.now() - this.lastAnnouncement < 1000) return; 297 298 const title = this.slides[index].querySelector('.hhs-title'); 299 if (!title) return; 300 301 const announcement = document.createElement('div'); 302 announcement.setAttribute('aria-live', 'polite'); 303 announcement.setAttribute('aria-atomic', 'true'); 304 announcement.className = 'hhs-sr-only'; 305 announcement.textContent = `Slide ${index + 1} of ${this.slides.length}: ${title.textContent.trim()}`; 306 307 document.body.appendChild(announcement); 308 setTimeout(() => announcement.remove(), 1000); 309 310 this.lastAnnouncement = Date.now(); 311 } 312 313 // Public API 314 goTo(index) { 315 if (index >= 0 && index < this.slides.length) { 316 this.show(index, true); 317 } 318 } 319 320 destroy() { 321 this.stopAutoplay(); 322 } 323 } 324 325 function initHeroSliders() { 326 document.querySelectorAll('.hero-slider').forEach((el) => { 327 // `hhsReady` is set at the end of init(); `hhsInit` belongs to the 328 // template part's inline stamp and is not an initialisation guard 329 if (!el.dataset.hhsReady) { 330 new HeroSlider(el); 331 } 332 }); 333 } 334 335 if (document.readyState === 'loading') { 336 document.addEventListener('DOMContentLoaded', initHeroSliders); 337 } else { 338 initHeroSliders(); 339 } 340 341 window.HeroSlider = HeroSlider; 342})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.