1import { Controller } from "../stimulus.js"; 2 3export default class extends Controller { 4 static targets = ["list", "slide", "prev", "next"]; 5 6 connect() { 7 if (!this.hasListTarget) return; 8 this._pendingImgHandlers = []; 9 this.whenImagesReady(() => { 10 if (!this.hasListTarget) return; 11 this.setupInfiniteLoop(); 12 this.listTarget.style.scrollBehavior = "smooth"; 13 }); 14 } 15 16 disconnect() { 17 if (this._scrollEndTimer) clearTimeout(this._scrollEndTimer); 18 if (this._scrollEndHandler && this.hasListTarget) { 19 this.listTarget.removeEventListener("scrollend", this._scrollEndHandler); 20 this._scrollEndHandler = null; 21 } 22 (this._pendingImgHandlers || []).forEach(({ img, handler }) => { 23 img.removeEventListener("load", handler); 24 img.removeEventListener("error", handler); 25 }); 26 this._pendingImgHandlers = []; 27 } 28 29 // Slide widths depend on image dimensions; measuring scrollWidth before 30 // images load yields a wrong loop offset. Wait for in-list <img>s first. 31 whenImagesReady(cb) { 32 const imgs = Array.from(this.listTarget.querySelectorAll("img")); 33 const pending = imgs.filter((img) => !img.complete); 34 if (!pending.length) return cb(); 35 36 let remaining = pending.length; 37 pending.forEach((img) => { 38 const handler = () => { 39 img.removeEventListener("load", handler); 40 img.removeEventListener("error", handler); 41 if (--remaining === 0) cb(); 42 }; 43 this._pendingImgHandlers.push({ img, handler }); 44 img.addEventListener("load", handler); 45 img.addEventListener("error", handler); 46 }); 47 } 48 49 setupInfiniteLoop() { 50 if (this.listTarget.querySelector('[data-clone="true"]')) return; 51 const originals = Array.from(this.listTarget.children); 52 if (!originals.length) return; 53 54 const pre = document.createDocumentFragment(); 55 const post = document.createDocumentFragment(); 56 originals.forEach((slide) => { 57 pre.appendChild(this.buildClone(slide)); 58 post.appendChild(this.buildClone(slide)); 59 }); 60 this.listTarget.insertBefore(pre, this.listTarget.firstChild); 61 this.listTarget.appendChild(post); 62 63 // park on the originals (middle third) so scrolling has clones on both sides 64 this.listTarget.scrollLeft = this.loopWidth; 65 } 66 67 buildClone(slide) { 68 const clone = slide.cloneNode(true); 69 clone.setAttribute("aria-hidden", "true"); 70 clone.dataset.clone = "true"; 71 clone.removeAttribute("id"); 72 clone.querySelectorAll("[id]").forEach((el) => el.removeAttribute("id")); 73 clone.querySelectorAll("[for]").forEach((el) => el.removeAttribute("for")); 74 clone 75 .querySelectorAll("[aria-labelledby]") 76 .forEach((el) => el.removeAttribute("aria-labelledby")); 77 clone 78 .querySelectorAll("[aria-controls]") 79 .forEach((el) => el.removeAttribute("aria-controls")); 80 return clone; 81 } 82 83 // one period = width of the originals strip including its trailing gap. 84 // With pre + originals + post, scrollWidth = 3 periods - one trailing gap. 85 get loopWidth() { 86 const gap = this.getGapPx(this.listTarget); 87 return (this.listTarget.scrollWidth + gap) / 3; 88 } 89 90 next() { 91 this.listTarget.scrollBy({ left: this.stepSize(), behavior: "smooth" }); 92 this.afterScroll(() => this.rebaseScroll()); 93 } 94 95 prev() { 96 this.listTarget.scrollBy({ left: -this.stepSize(), behavior: "smooth" }); 97 this.afterScroll(() => this.rebaseScroll()); 98 } 99 100 // after any scroll, keep scrollLeft anchored on the middle (originals) copy 101 rebaseScroll() { 102 const width = this.loopWidth; 103 if (!width) return; 104 if (this.listTarget.scrollLeft >= 2 * width) { 105 this.snapBy(-width); 106 } else if (this.listTarget.scrollLeft < width) { 107 this.snapBy(width); 108 } 109 } 110 111 snapBy(delta) { 112 const prev = this.listTarget.style.scrollBehavior; 113 this.listTarget.style.scrollBehavior = "auto"; 114 this.listTarget.scrollLeft += delta; 115 this.listTarget.style.scrollBehavior = prev; 116 } 117 118 afterScroll(cb) { 119 if (this._scrollEndTimer) clearTimeout(this._scrollEndTimer); 120 if (this._scrollEndHandler) { 121 this.listTarget.removeEventListener("scrollend", this._scrollEndHandler); 122 } 123 const handler = () => { 124 this.listTarget.removeEventListener("scrollend", handler); 125 clearTimeout(this._scrollEndTimer); 126 this._scrollEndHandler = null; 127 cb(); 128 }; 129 this._scrollEndHandler = handler; 130 this.listTarget.addEventListener("scrollend", handler, { once: true }); 131 // fallback for browsers without scrollend 132 this._scrollEndTimer = setTimeout(handler, 600); 133 } 134 135 stepSize() { 136 const first = this.slideTargets[0]; 137 if (!first) return this.listTarget.clientWidth * 0.9; 138 139 const rect = first.getBoundingClientRect(); 140 const gap = this.getGapPx(this.listTarget); 141 return rect.width + gap; 142 } 143 144 getGapPx(el) { 145 const style = getComputedStyle(el); 146 const gap = parseInt(style.gap || style.columnGap || "0", 10); 147 return isNaN(gap) ? 0 : gap; 148 } 149}
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.