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.