PageSourceSearch

https://cathedralstl.org/themes/custom/sitetheme/components/events-carousel/events-carousel.js?tlvliw

js cathedralstl.org collected 2026-09-24 21:42:41 UTC 5,772 bytes, 167 lines download raw bytes

1import { ComponentType, ComponentInstance } from "../../lib/component.js";
2
3/**
4 * Pages over the top-level children of the cards grid, PER_PAGE at a time.
5 * The children may be bare event cards or `.views-row` wrappers from a View.
6 */
7class EventsCarousel extends ComponentInstance {
8  // Never window below this many dots — past it the control stops reading as a
9  // pager, and the arrows are the primary way through a long list anyway.
10  static MIN_DOTS = 3;
11
12  init() {
13    this.grid = this.el.querySelector("[data-events-grid]");
14    this.pager = this.el.querySelector("[data-events-pager]");
15    this.dotsWrap = this.el.querySelector("[data-events-dots]");
16    this.prevBtn = this.el.querySelector("[data-events-prev]");
17    this.nextBtn = this.el.querySelector("[data-events-next]");
18    if (!this.grid) {
19      return;
20    }
21
22    this.perPage = parseInt(this.el.getAttribute("data-per-page"), 10) || 3;
23    // Prefer View rows (wrappers are promoted with display:contents in CSS);
24    // fall back to the grid's direct children for bare event cards.
25    const rows = this.grid.querySelectorAll(".ct-events__row, .views-row");
26    this.cards = rows.length ? Array.from(rows) : Array.from(this.grid.children);
27    this.totalPages = Math.ceil(this.cards.length / this.perPage);
28    this.page = 0;
29
30    if (this.totalPages <= 1) {
31      if (this.pager) {
32        this.pager.hidden = true;
33      }
34      return;
35    }
36
37    if (this.pager) {
38      this.pager.hidden = false;
39    }
40    this.buildDots();
41    if (this.prevBtn) {
42      this.prevBtn.addEventListener("click", () => this.go(this.page - 1));
43    }
44    if (this.nextBtn) {
45      this.nextBtn.addEventListener("click", () => this.go(this.page + 1));
46    }
47
48    // How many dots fit depends on the width, so re-window them on resize.
49    let frame = 0;
50    window.addEventListener("resize", () => {
51      cancelAnimationFrame(frame);
52      frame = requestAnimationFrame(() => this.updateDots());
53    });
54
55    this.render();
56  }
57
58  buildDots() {
59    if (!this.dotsWrap) {
60      return;
61    }
62    this.dotsWrap.innerHTML = "";
63    this.dots = [];
64    for (let i = 0; i < this.totalPages; i++) {
65      const dot = document.createElement("button");
66      dot.type = "button";
67      dot.className = "ct-events__dot";
68      dot.setAttribute("aria-label", `Go to events page ${i + 1}`);
69      const index = i;
70      dot.addEventListener("click", () => this.go(index));
71      this.dotsWrap.appendChild(dot);
72      this.dots.push(dot);
73    }
74  }
75
76  /**
77   * How many dots fit on one line.
78   *
79   * A dot costs its own width plus one gap, and the row also has to hold the two
80   * arrows. Measured rather than hardcoded, so the cap follows the theme's dot
81   * size and gap, and a long list of events cannot push the pager wider than the
82   * viewport (50 events at 3 per page is 17 dots — 379px, which overflowed a
83   * 375px phone and dragged the arrows off screen).
84   */
85  maxVisibleDots() {
86    if (!this.pager || !this.dotsWrap || !this.dots || !this.dots.length) {
87      return this.totalPages;
88    }
89
90    const dotsStyle = getComputedStyle(this.dotsWrap);
91    const gap = parseFloat(dotsStyle.columnGap) || 0;
92    // Explicit width in CSS, so this reads correctly even while a dot is hidden.
93    const dotWidth = parseFloat(getComputedStyle(this.dots[0]).width) || 0;
94    const perDot = dotWidth + gap;
95
96    if (perDot <= 0) {
97      return this.totalPages;
98    }
99
100    // Everything the dots have to share the row with: both arrows, the pager's
101    // own flex gap either side of the dots, and the dots container's margins.
102    // The arrows are fixed-width and the pager centres its overflow, so getting
103    // this wrong pushes the previous arrow off the left edge of the screen.
104    const pagerGap = parseFloat(getComputedStyle(this.pager).columnGap) || 0;
105    const available =
106      this.pager.clientWidth -
107      (this.prevBtn ? this.prevBtn.offsetWidth : 0) -
108      (this.nextBtn ? this.nextBtn.offsetWidth : 0) -
109      pagerGap * 2 -
110      parseFloat(dotsStyle.marginLeft) -
111      parseFloat(dotsStyle.marginRight);
112
113    // The last dot needs no trailing gap, hence the `+ gap`.
114    const fits = Math.floor((available + gap) / perDot);
115
116    return Math.max(EventsCarousel.MIN_DOTS, Math.min(this.totalPages, fits));
117  }
118
119  /**
120   * Shows at most a window of dots, kept centred on the current page.
121   *
122   * The outermost dot on a side that has more pages beyond it is marked
123   * `is-edge` and shrinks, so it reads as "the list continues" rather than as
124   * the end of it.
125   */
126  updateDots() {
127    if (!this.dots || !this.dots.length) {
128      return;
129    }
130
131    const max = this.maxVisibleDots();
132    const windowed = max < this.totalPages;
133    const start = windowed
134      ? Math.min(Math.max(this.page - Math.floor(max / 2), 0), this.totalPages - max)
135      : 0;
136    const end = start + (windowed ? max : this.totalPages);
137
138    this.dots.forEach((dot, i) => {
139      const visible = i >= start && i < end;
140      dot.hidden = !visible;
141      dot.classList.toggle("is-active", i === this.page);
142      dot.classList.toggle("is-edge", visible && ((i === start && start > 0) || (i === end - 1 && end < this.totalPages)));
143
144      if (i === this.page) {
145        dot.setAttribute("aria-current", "true");
146      } else {
147        dot.removeAttribute("aria-current");
148      }
149    });
150  }
151
152  go(index) {
153    this.page = (index + this.totalPages) % this.totalPages;
154    this.render();
155  }
156
157  render() {
158    const start = this.page * this.perPage;
159    const end = start + this.perPage;
160    this.cards.forEach((card, i) => {
161      card.hidden = i < start || i >= end;
162    });
163    this.updateDots();
164  }
165}
166
167window.eventsCarousel = new ComponentType(EventsCarousel, "eventsCarousel", "[data-events-carousel]");

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.