PageSourceSearch

https://www.360dentalcare.co.uk/wp-content/themes/360-dental-theme…/js/smile-gallery-carousel.js?ver=2.73.1

js 360dentalcare.co.uk collected 2026-10-08 13:54:37 UTC 8,929 bytes, 225 lines download raw bytes

1/**
2 * 360 Dental Care — Smile Gallery Carousel
3 * @package 360_Dental
4 *
5 * Editorial-smooth carousel for the before/after smile gallery sections.
6 * Picks up every .sg-carousel on the page automatically.
7 *
8 * Behaviour:
9 *   - Mobile (≤768px): 1 card in view + 8% peek
10 *   - Desktop (>768px): 2 cards in view + 8% peek
11 *   - Arrows: always visible, sit in outer gutters (CSS), disable at ends
12 *   - Dots: thin pills underneath, elongate when active
13 *   - Cross-fade thumbnails on slide change via .is-changing on the carousel
14 *   - Keyboard arrows when the carousel has focus
15 *   - prefers-reduced-motion: arrow taps jump directly (no smooth scroll),
16 *     scale/opacity transitions removed by CSS
17 *
18 * No external dependencies. Vanilla JS, IE11-incompatible by design.
19 */
20(function () {
21  'use strict';
22
23  var prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
24
25  function initCarousel(root) {
26    // If already initialised, tear down first so we can rebuild with the
27    // current set of cards (used after load-more appends new cards to the
28    // archive carousels).
29    if (root._sgCarouselTeardown) {
30      root._sgCarouselTeardown();
31    }
32
33    var track    = root.querySelector('.sg-carousel__track');
34    var prevBtn  = root.querySelector('[data-sg-arrow="prev"]');
35    var nextBtn  = root.querySelector('[data-sg-arrow="next"]');
36    var dotsHost = root.querySelector('[data-sg-dots]');
37    var cards    = track ? Array.prototype.slice.call(track.querySelectorAll('.sg-card')) : [];
38
39    if (!track || cards.length === 0) return;
40
41    // ───── Build dots ─────
42    // One dot per card. Aria-label tells screen readers which case they're
43    // jumping to. Dots are decorative wrt the parent (aria-hidden via the
44    // template's data attribute) but each dot is a real button for keyboard.
45    var dots = [];
46    if (dotsHost) {
47      dotsHost.innerHTML = '';
48      for (var i = 0; i < cards.length; i++) {
49        var dot = document.createElement('button');
50        dot.type = 'button';
51        dot.className = 'sg-carousel__dot';
52        dot.setAttribute('data-sg-dot-index', String(i));
53        dot.setAttribute('aria-label', 'Go to case ' + (i + 1));
54        dotsHost.appendChild(dot);
55        dots.push(dot);
56      }
57      // Dots host can be visible to screen readers since each dot is labelled
58      dotsHost.removeAttribute('aria-hidden');
59    }
60
61    // ───── Active index tracking ─────
62    var activeIndex = 0;
63
64    function setActive(index) {
65      if (index < 0) index = 0;
66      if (index >= cards.length) index = cards.length - 1;
67      activeIndex = index;
68
69      cards.forEach(function (card, i) {
70        card.classList.toggle('is-active', i === index);
71      });
72      dots.forEach(function (dot, i) {
73        dot.classList.toggle('is-active', i === index);
74      });
75
76      // Update arrow disabled state at the ends
77      if (prevBtn) prevBtn.disabled = (index === 0);
78      if (nextBtn) nextBtn.disabled = (index === cards.length - 1);
79    }
80
81    // ───── Scroll-to-card with cross-fade ─────
82    // The cross-fade is implemented by toggling a class on the carousel root
83    // for ~340ms (matching the CSS transition duration on .sg-card img).
84    var changingTimeout = null;
85
86    // Flag set while a programmatic scroll (arrow/dot/keyboard) is in
87    // progress, so the IntersectionObserver below doesn't fight the click
88    // handler and over-set activeIndex while the scroll animation is mid-
89    // flight. Without this, clicking "next" once moves the carousel by 2
90    // because the observer briefly sees card N+1 as the most-intersecting
91    // mid-animation before settling on card N. (v2.16.14 fix.)
92    var programmaticScrollUntil = 0;
93    function scrollToCard(index, smooth) {
94      if (index < 0 || index >= cards.length) return;
95      var card = cards[index];
96      if (!card) return;
97
98      // Compute the target scrollLeft so the card sits centred in the viewport.
99      // scrollIntoView works but inline:'center' has inconsistent behaviour
100      // across browsers when the parent overflow is the scroll container.
101      var trackRect = track.getBoundingClientRect();
102      var cardRect = card.getBoundingClientRect();
103      var currentScrollLeft = track.scrollLeft;
104      var delta = (cardRect.left - trackRect.left) - (trackRect.width / 2) + (cardRect.width / 2);
105      var target = currentScrollLeft + delta;
106
107      // Set the active state IMMEDIATELY so click handlers reading
108      // activeIndex (e.g. successive arrow clicks) see the freshly
109      // chosen index, and so the observer's suppression window below is
110      // measured against the right target.
111      setActive(index);
112
113      // Suppress the observer for ~600ms — long enough for smooth-scroll
114      // to complete (browsers typically animate scrollTo in 200–500ms).
115      programmaticScrollUntil = Date.now() + 600;
116
117      // Trigger the cross-fade
118      root.classList.add('is-changing');
119      if (changingTimeout) clearTimeout(changingTimeout);
120      changingTimeout = setTimeout(function () {
121        root.classList.remove('is-changing');
122      }, 340);
123
124      if (smooth && !prefersReducedMotion && typeof track.scrollTo === 'function') {
125        track.scrollTo({ left: target, behavior: 'smooth' });
126      } else {
127        track.scrollLeft = target;
128      }
129    }
130
131    // ───── Detect which card is currently centred (via scroll observation) ─────
132    // We use IntersectionObserver to find the most-visible card. This handles
133    // user-driven swipes; arrow clicks bypass it via programmaticScrollUntil.
134    var io = new IntersectionObserver(function (entries) {
135      // Skip if we're mid programmatic scroll — the click handler already set
136      // the correct active index, and observer noise during the smooth-scroll
137      // animation would otherwise advance past it.
138      if (Date.now() < programmaticScrollUntil) return;
139
140      // Find the entry with the largest intersection ratio
141      var best = null;
142      entries.forEach(function (e) {
143        if (!best || e.intersectionRatio > best.intersectionRatio) best = e;
144      });
145      if (best && best.intersectionRatio > 0.55) {
146        var index = cards.indexOf(best.target);
147        if (index >= 0 && index !== activeIndex) {
148          setActive(index);
149        }
150      }
151    }, {
152      root: track,
153      // Threshold steps so we update precisely as cards cross the centre
154      threshold: [0.25, 0.55, 0.75, 1]
155    });
156
157    cards.forEach(function (card) { io.observe(card); });
158
159    // ───── Wire up controls ─────
160    if (prevBtn) {
161      prevBtn.addEventListener('click', function () {
162        scrollToCard(activeIndex - 1, true);
163      });
164    }
165    if (nextBtn) {
166      nextBtn.addEventListener('click', function () {
167        scrollToCard(activeIndex + 1, true);
168      });
169    }
170    dots.forEach(function (dot, i) {
171      dot.addEventListener('click', function () {
172        scrollToCard(i, true);
173      });
174    });
175
176    // Keyboard: when arrow buttons or dots have focus, left/right arrow keys
177    // navigate. Native scroll handles ArrowDown/Up so we don't trap those.
178    root.addEventListener('keydown', function (e) {
179      if (e.key === 'ArrowLeft') {
180        e.preventDefault();
181        scrollToCard(activeIndex - 1, true);
182      } else if (e.key === 'ArrowRight') {
183        e.preventDefault();
184        scrollToCard(activeIndex + 1, true);
185      }
186    });
187
188    // Initialise: first card active
189    setActive(0);
190
191    // Store teardown so re-init can clean up before rebuilding
192    root._sgCarouselTeardown = function () {
193      io.disconnect();
194      // Strip dots so init can rebuild them
195      if (dotsHost) dotsHost.innerHTML = '';
196      // Clear active state from cards
197      cards.forEach(function (c) { c.classList.remove('is-active'); });
198    };
199
200    // After fonts/images settle, re-centre on the active card so any layout
201    // shift doesn't leave us off-snap. Two passes catches both font and image.
202    setTimeout(function () { scrollToCard(0, false); }, 50);
203    if (document.fonts && document.fonts.ready) {
204      document.fonts.ready.then(function () { scrollToCard(activeIndex, false); });
205    }
206  }
207
208  function init() {
209    var carousels = document.querySelectorAll('[data-sg-carousel]');
210    Array.prototype.forEach.call(carousels, initCarousel);
211  }
212
213  // Re-initialise archive carousels when load-more appends new cards so the
214  // dot count and active-card tracking reflect the new total.
215  document.addEventListener('sg:cards-appended', function () {
216    var archiveCarousels = document.querySelectorAll('[data-sg-carousel^="archive-"]');
217    Array.prototype.forEach.call(archiveCarousels, initCarousel);
218  });
219
220  if (document.readyState === 'loading') {
221    document.addEventListener('DOMContentLoaded', init);
222  } else {
223    init();
224  }
225})();

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.