PageSourceSearch

https://www.bbbs.org/wp-content/themes/BBBSA-2025/assets/js/hero-s…&new_bbbs=1&ver=1791489787&new_bbbs=1

js bbbs.org collected 2026-10-09 23:28:01 UTC 10,527 bytes, 342 lines download raw bytes

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.