PageSourceSearch

https://updater.com/wp-content/plugins/project-frontdoor/shared/carousel.js?v=a16aa360a327

js updater.com collected 2026-10-01 13:03:01 UTC 7,793 bytes, 217 lines download raw bytes

1/* =============================================================================
2 * Project Frontdoor — shared carousel controller (zero-dependency, vanilla).
3 * -----------------------------------------------------------------------------
4 * Scope: JUST the prev/next arrow mechanics for any `.fd-carousel` on the page
5 * (the scroll-snap CSS shell lives in base.css). Generic — it initialises every
6 * `.fd-carousel` it finds, so T13 (mobile) and T14 (both breakpoints) reuse it.
7 * Task 17 owns the broader scroll-state animation controller — not here.
8 *
9 * Behaviour: clicking `.fd-carousel__arrow--prev/--next` scrolls the track by
10 * one card (card width + the instance's --fd-carousel-gap). Arrows disable at
11 * the ends. Programmatic scrolls respect prefers-reduced-motion (jump vs
12 * smooth). With JS off the track still scrolls/snaps natively; the arrows are
13 * a progressive enhancement.
14 *
15 * Loaded deferred + versioned (?v=<build_id>) from the section partial that
16 * renders a carousel (mirroring how partials/nav.php loads shared/nav.js), and
17 * carries a CSP nonce when one is present.
18 * ========================================================================== */
19(function () {
20  "use strict";
21
22  var carousels = document.querySelectorAll(".fd-carousel");
23  if (!carousels.length) {
24    return;
25  }
26
27  var reduceMotion = window.matchMedia
28    ? window.matchMedia("(prefers-reduced-motion: reduce)").matches
29    : false;
30  var behavior = reduceMotion ? "auto" : "smooth";
31
32  function step(track) {
33    var card = track.firstElementChild;
34    var cardWidth = card ? card.getBoundingClientRect().width : track.clientWidth;
35    var gap = parseFloat(getComputedStyle(track).columnGap) || 0;
36    return cardWidth + gap;
37  }
38
39  /* ---------------------------------------------------------------------
40   * OPT-IN image warm-up. `data-fd-carousel-preload="N"` on a `.fd-carousel`
41   * promotes the next N off-screen card images from lazy to eager, so they are
42   * already fetched when the user reaches them. Without the attribute nothing
43   * below runs and a carousel behaves exactly as it did.
44   *
45   * Why it is needed: `loading="lazy"` fires only once an image is near the
46   * viewport, and for a horizontal scroller "near" is narrow. Measured on the
47   * Careers rail over a 900kbps/150ms pipe, every photo STARTED fetching
48   * 383-655ms AFTER the click that revealed it — the late snap-in is exactly
49   * that latency. The lazy threshold is not author-adjustable, so promoting
50   * `loading` from script is the only lever.
51   *
52   * Why not mark them eager in the markup: tried, and it regressed mobile LCP
53   * by 480ms (1976ms -> 2456ms) because all the fetches contend for one pipe
54   * and the LCP image finishes third.
55   *
56   * So the warm-up WAITS FOR `load`. That is the whole trick: once the eager
57   * above-fold images are done there is no LCP left to starve, so warmed
58   * fetches can take normal priority and actually arrive. A first attempt kept
59   * them at fetchPriority "low" to be polite — on a throttled pipe that
60   * deprioritised them into uselessness, warming only one card instead of N.
61   * Nothing is promoted before `load`, so the LCP path is untouched either way.
62   * ------------------------------------------------------------------- */
63  function warmer(carousel, track) {
64    var ahead = parseInt(carousel.getAttribute("data-fd-carousel-preload") || "", 10);
65    if (!ahead || ahead < 1) {
66      return null;
67    }
68
69    var lazy = [];
70    Array.prototype.forEach.call(track.children, function (card, i) {
71      var img = card.querySelector("img");
72      if (img && img.loading === "lazy") {
73        lazy.push({ i: i, card: card, img: img });
74      }
75    });
76    if (!lazy.length) {
77      return null;
78    }
79
80    var armed = false;
81
82    function promote() {
83      if (!armed || !lazy.length) {
84        return;
85      }
86      // The last card whose leading edge the viewport has reached, then N past it.
87      var edge = track.scrollLeft + track.clientWidth;
88      var reached = 0;
89      Array.prototype.forEach.call(track.children, function (card, i) {
90        if (card.offsetLeft < edge) {
91          reached = i;
92        }
93      });
94
95      var limit = reached + ahead;
96      lazy = lazy.filter(function (entry) {
97        if (entry.i > limit) {
98          return true; // still too far out; keep watching it.
99        }
100        entry.img.loading = "eager"; // promoting `loading` starts the fetch.
101        return false;
102      });
103    }
104
105    if (document.readyState === "complete") {
106      armed = true;
107      promote();
108    } else {
109      window.addEventListener(
110        "load",
111        function () {
112          armed = true;
113          promote();
114        },
115        { once: true }
116      );
117    }
118
119    return promote;
120  }
121
122  function init(carousel) {
123    var track = carousel.querySelector(".fd-carousel__track");
124    if (!track) {
125      return;
126    }
127    var prev = carousel.querySelector(".fd-carousel__arrow--prev");
128    var next = carousel.querySelector(".fd-carousel__arrow--next");
129    // Remember the shipped landmark label so we can restore it if a resize
130    // makes a non-scrolling grid scrollable again.
131    var trackLabel = track.getAttribute("aria-label");
132
133    function atStart() {
134      return track.scrollLeft <= 1;
135    }
136    function atEnd() {
137      // 1px slack absorbs sub-pixel rounding of scrollWidth/clientWidth.
138      return track.scrollLeft + track.clientWidth >= track.scrollWidth - 1;
139    }
140    function sync() {
141      var overflowing = track.scrollWidth > track.clientWidth + 1;
142      if (prev) {
143        prev.disabled = !overflowing || atStart();
144      }
145      if (next) {
146        next.disabled = !overflowing || atEnd();
147      }
148      // a11y: the track ships as a keyboard-scrollable region (tabindex="0" +
149      // role="region" + aria-label, works with JS off). When it does NOT
150      // overflow (e.g. the complex desktop grid) there is nothing to scroll,
151      // so drop the tab stop AND the carousel landmark so the static grid
152      // doesn't advertise itself as a carousel; restore all three if a resize
153      // makes the track scrollable again.
154      if (overflowing) {
155        track.setAttribute("tabindex", "0");
156        track.setAttribute("role", "region");
157        if (trackLabel) {
158          track.setAttribute("aria-label", trackLabel);
159        }
160      } else {
161        track.removeAttribute("tabindex");
162        track.removeAttribute("role");
163        track.removeAttribute("aria-label");
164      }
165    }
166
167    if (prev) {
168      prev.addEventListener("click", function () {
169        track.scrollBy({ left: -step(track), behavior: behavior });
170      });
171    }
172    if (next) {
173      next.addEventListener("click", function () {
174        track.scrollBy({ left: step(track), behavior: behavior });
175      });
176    }
177
178    var warm = warmer(carousel, track);
179
180    // Keep arrow state in sync with scroll position (rAF-throttled) + resize.
181    var ticking = false;
182    track.addEventListener(
183      "scroll",
184      function () {
185        if (ticking) {
186          return;
187        }
188        ticking = true;
189        window.requestAnimationFrame(function () {
190          sync();
191          if (warm) {
192            warm();
193          }
194          ticking = false;
195        });
196      },
197      { passive: true }
198    );
199    window.addEventListener("resize", function () {
200      sync();
201      if (warm) {
202        warm();
203      }
204    });
205
206    sync();
207
208    // One warm-up pass on the NEXT frame rather than now: at init the eager
209    // above-fold images are still in flight, and the whole point of "low" is to
210    // stay out of their way. A rAF hop puts the promotion after first paint.
211    if (warm) {
212      window.requestAnimationFrame(warm);
213    }
214  }
215
216  Array.prototype.forEach.call(carousels, init);
217})();

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.