1import { Controller } from "@hotwired/stimulus" 2 3// Latest-news carousel: shows up to 5 article cards, 3 at a time on desktop 4// (2 on tablet, 1 on mobile), and auto-advances one card every `interval`ms, 5// ping-ponging direction at the ends (right to the last card, then back left). 6// 7// Autoplay pauses on hover, focus, touch, when the tab is hidden, or when the 8// carousel scrolls out of view, and never starts at all under 9// prefers-reduced-motion. Prev/next arrows, dot pagination and Left/Right arrow 10// keys (when the carousel is focused or hovered) drive it manually; any manual 11// interaction restarts the autoplay clock so it never fights the user. 12export default class extends Controller { 13 static targets = ["track"] 14 static values = { interval: { type: Number, default: 5000 } } 15 16 connect() { 17 this.slides = Array.from(this.trackTarget.children) 18 if (this.slides.length <= 1) return 19 20 this.hovered = false 21 this.focused = false 22 23 this.buildArrows() 24 this.buildDots() 25 26 this.trackTarget.addEventListener("scroll", this.onScroll, { passive: true }) 27 this.element.addEventListener("mouseenter", this.onPointerEnter) 28 this.element.addEventListener("mouseleave", this.onPointerLeave) 29 this.element.addEventListener("focusin", this.onFocusIn) 30 this.element.addEventListener("focusout", this.onFocusOut) 31 this.element.addEventListener("touchstart", this.pauseAutoplay, { passive: true }) 32 document.addEventListener("visibilitychange", this.onVisibilityChange) 33 34 // Pause while the carousel is off-screen so it isn't silently cycling in a 35 // background section of the page. 36 this.visibilityObserver = new IntersectionObserver(this.onIntersect, { threshold: 0.25 }) 37 this.visibilityObserver.observe(this.element) 38 39 // Keep the arrows vertically centred on the card image as the layout reflows 40 // (the image height is responsive, so it can't be pinned in CSS alone). 41 this.layoutObserver = new ResizeObserver(() => this.positionArrows()) 42 this.layoutObserver.observe(this.element) 43 44 this.updateDots() 45 requestAnimationFrame(() => this.positionArrows()) 46 if (!this.prefersReducedMotion) this.startAutoplay() 47 } 48 49 disconnect() { 50 this.stopAutoplay() 51 this.trackTarget.removeEventListener("scroll", this.onScroll) 52 this.element.removeEventListener("mouseenter", this.onPointerEnter) 53 this.element.removeEventListener("mouseleave", this.onPointerLeave) 54 this.element.removeEventListener("focusin", this.onFocusIn) 55 this.element.removeEventListener("focusout", this.onFocusOut) 56 this.element.removeEventListener("touchstart", this.pauseAutoplay) 57 document.removeEventListener("visibilitychange", this.onVisibilityChange) 58 this.visibilityObserver?.disconnect() 59 this.layoutObserver?.disconnect() 60 this.arrowLeft?.remove() 61 this.arrowRight?.remove() 62 this.dotsEl?.remove() 63 } 64 65 get prefersReducedMotion() { 66 return window.matchMedia("(prefers-reduced-motion: reduce)").matches 67 } 68 69 // #################################################################### 70 // Manual navigation 71 // #################################################################### 72 73 next() { 74 this.advance(1) 75 this.restartAutoplay() 76 } 77 78 previous() { 79 this.advance(-1) 80 this.restartAutoplay() 81 } 82 83 // Move one card in `direction`, wrapping around: past the last card jumps to 84 // the first, before the first jumps to the last â so navigation is infinite. 85 advance(direction) { 86 const track = this.trackTarget 87 const maxScroll = track.scrollWidth - track.clientWidth 88 if (direction > 0 && track.scrollLeft >= maxScroll - 2) { 89 this.scrollToIndex(0) 90 } else if (direction < 0 && track.scrollLeft <= 2) { 91 this.scrollToIndex(this.slides.length - 1) 92 } else { 93 this.scrollByCards(direction) 94 } 95 } 96 97 onKeydown = (event) => { 98 if (event.key === "ArrowRight") { 99 event.preventDefault() 100 this.next() 101 } else if (event.key === "ArrowLeft") { 102 event.preventDefault() 103 this.previous() 104 } 105 } 106 107 // Advance by `count` card widths, clamped to the scrollable range. 108 scrollByCards(count) { 109 const track = this.trackTarget 110 const target = track.scrollLeft + count * this.cardStep() 111 const maxScroll = track.scrollWidth - track.clientWidth 112 track.scrollTo({ left: Math.max(0, Math.min(maxScroll, target)), behavior: "smooth" }) 113 } 114 115 cardStep() { 116 const gap = parseFloat(getComputedStyle(this.trackTarget).columnGap
116) || 0 117 return this.slides[0].offsetWidth + gap 118 } 119 120 // #################################################################### 121 // Autoplay (ping-pong) 122 // #################################################################### 123 124 startAutoplay() { 125 this.stopAutoplay() 126 this.timer = setInterval(() => this.tick(), this.intervalValue) 127 } 128 129 stopAutoplay() { 130 if (this.timer) clearInterval(this.timer) 131 this.timer = null 132 } 133 134 restartAutoplay() { 135 if (this.prefersReducedMotion || this.hovered || this.focused) return 136 this.startAutoplay() 137 } 138 139 // Autoplay always moves forward, looping from the last card back to the first. 140 tick() { 141 this.advance(1) 142 } 143 144 pauseAutoplay = () => this.stopAutoplay() 145 146 onPointerEnter = () => { this.hovered = true; this.stopAutoplay() } 147 onPointerLeave = () => { this.hovered = false; this.restartAutoplay() } 148 onFocusIn = () => { this.focused = true; this.stopAutoplay() } 149 onFocusOut = () => { this.focused = false; this.restartAutoplay() } 150 151 onVisibilityChange = () => { 152 if (document.hidden) this.stopAutoplay() 153 else this.restartAutoplay() 154 } 155 156 onIntersect = (entries) => { 157 const visible = entries[0]?.isIntersecting 158 if (visible) this.restartAutoplay() 159 else this.stopAutoplay() 160 } 161 162 // #################################################################### 163 // Controls (arrows + dots), built in JS like horizontal_scroll_controller 164 // #################################################################### 165 166 buildArrows() { 167 this.arrowLeft = this.makeArrow("left", "fa-chevron-left", "Previous articles") 168 this.arrowRight = this.makeArrow("right", "fa-chevron-right", "Next articles") 169 this.element.appendChild(this.arrowLeft) 170 this.element.appendChild(this.arrowRight) 171 } 172 173 // Align each arrow's vertical centre to the middle of the card image rather 174 // than the middle of the whole card (title + excerpt would drag it too low). 175 positionArrows() { 176 if (!this.arrowLeft) return 177 const image = this.slides[0]?.querySelector(".blog-article-card__image") 178 if (!image) return 179 180 const carouselTop = this.element.getBoundingClientRect().top 181 const imageRect = image.getBoundingClientRect() 182 const centerY = imageRect.top - carouselTop + imageRect.height / 2 183 184 this.arrowLeft.style.top = `${centerY}px` 185 this.arrowRight.style.top = `${centerY}px` 186 } 187 188 makeArrow(side, iconClass, label) { 189 const button = document.createElement("button") 190 button.type = "button" 191 button.className = `news-carousel__arrow news-carousel__arrow--${side}` 192 button.setAttribute("aria-label", label) 193 const icon = document.createElement("i") 194 icon.className = `fa-solid ${iconClass}` 195 button.appendChild(icon) 196 button.addEventListener("click", () => (side === "left" ? this.previous() : this.next())) 197 return button 198 } 199 200 buildDots() { 201 this.dotsEl = document.createElement("div") 202 this.dotsEl.className = "news-carousel__dots" 203 this.dotsEl.setAttribute("role", "tablist") 204 this.dotsEl.setAttribute("aria-label", "Latest news navigation") 205 206 this.slides.forEach((_slide, index) => { 207 const dot = document.createElement("button") 208 dot.type = "button" 209 dot.className = "news-carousel__dot" 210 dot.setAttribute("role", "tab") 211 dot.setAttribute("aria-label", `Go to article ${index + 1}`) 212 dot.addEventListener("click", () => { 213 this.scrollToIndex(index) 214 this.restartAutoplay() 215 }) 216 this.dotsEl.appendChild(dot) 217 }) 218 219 this.element.appendChild(this.dotsEl) 220 } 221 222 scrollToIndex(index) { 223 const slide = this.slides[index] 224 if (slide) this.trackTarget.scrollTo({ left: slide.offsetLeft - this.trackTarget.offsetLeft, behavior: "smooth" }) 225 } 226 227 onScroll = () => { 228 this.updateDots() 229 } 230 231 activeIndex() { 232 return Math.round(this.trackTarget.scrollLeft / this.cardStep()) 233 } 234 235 updateDots() { 236 if (!this.dotsEl) return 237 const active = this.activeIndex() 238 Array.from(this.dotsEl.children).forEach((dot, index) => { 239 dot.classList.toggle("news-carousel__dot--active", index === active) 240 }) 241 } 242}
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.