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.