PageSourceSearch

https://www.bernstein.io/assets/controllers/news_carousel_controller-472ac151.js

js bernstein.io collected 2026-10-04 08:55:35 UTC 8,621 bytes, 242 lines download raw bytes

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.