1/** 2 * Flyer lightbox. 3 * 4 * One dialog per page, mounted lazily the first time something is opened, and 5 * driven by a single delegated listener. Renderers therefore emit plain 6 * `[data-flyer-set]` buttons and never have to re-bind after a render pass - 7 * the same reason the season switcher delegates its trigger. 8 * 9 * Built on a native `<dialog>` opened with `showModal()`. That buys the focus 10 * trap, Escape, background inertness and - the reason it matters here - the top 11 * layer, which sits above the seasonal particle canvas without joining the 12 * z-index ladder at all. A hand-rolled overlay would have to out-stack 13 * `.season-layer` (15), `.render-error` (40) and `.season-switcher` (45) and 14 * would still let snow drift over the poster. 15 * 16 * A talk may carry several flyers - typically a wide banner and a vertical 17 * poster of the same event - so the dialog is a small gallery: variant pills, 18 * arrow keys, and a counter. With a single flyer none of that chrome renders. 19 */ 20 21import { icon } from "./icons.js"; 22import { escapeHtml } from "../utils/html.js"; 23 24const TRIGGER_SELECTOR = "[data-flyer-set]"; 25const ID = "flyer-lightbox"; 26 27/* Long enough for the exit keyframe in lightbox.css to finish. Only a fallback: 28 `animationend` normally closes the dialog first, and under reduced motion the 29 global 0.01ms override makes that fire almost immediately. */ 30const EXIT_FALLBACK_MS = 400; 31 32/* Read per call rather than cached, so a preference changed mid-session is 33 honoured. reveal.js keeps its own private copy of this check; it is three 34 lines and exporting it would couple the motion engine to the lightbox. */ 35const prefersReducedMotion = () => 36 typeof window.matchMedia === "function" && 37 window.matchMedia("(prefers-reduced-motion: reduce)").matches; 38 39let view = null; 40 41const parseSet = (trigger) => { 42 try { 43 const parsed = JSON.parse(trigger.getAttribute("data-flyer-set") || ""); 44 const items = Array.isArray(parsed?.items) ? parsed.items.filter((item) => item?.href) : []; 45 return items.length ? { title: parsed.title || "", items } : null; 46 } catch { 47 return null; 48 } 49}; 50 51const mount = () => { 52 const dialog = document.createElement("dialog"); 53 dialog.className = "flyer-lightbox"; 54 dialog.id = ID; 55 dialog.innerHTML = ` 56 <div class="flyer-lightbox-frame"> 57 <button class="flyer-lightbox-close" type="button" data-close aria-label="Close flyer"> 58 <span aria-hidden="true">×</span> 59 </button> 60 <button class="flyer-lightbox-step is-prev" type="button" data-step="-1" aria-label="Previous flyer"> 61 ${icon("chevron-left")} 62 </button> 63 <button class="flyer-lightbox-step is-next" type="button" data-step="1" aria-label="Next flyer"> 64 ${icon("chevron-right")} 65 </button> 66 <figure class="flyer-lightbox-figure"> 67 <img class="flyer-lightbox-image" data-image alt=""> 68 <figcaption class="flyer-lightbox-caption"> 69 <span class="flyer-lightbox-title" data-title></span> 70 <span class="flyer-lightbox-meta" data-meta aria-live="polite"></span> 71 <span class="flyer-lightbox-actions"> 72 <a class="flyer-lightbox-download" data-download target="_blank" rel="noopener">Open full size</a> 73 </span> 74 </figcaption> 75 </figure> 76 <div class="flyer-lightbox-variants" data-variants></div> 77 </div> 78 `; 79 document.body.appendChild(dialog); 80 return dialog; 81}; 82 83const build = () => { 84 const dialog = mount(); 85 const image = dialog.querySelector("[data-image]"); 86 const titleEl = dialog.querySelector("[data-title]"); 87 const metaEl = dialog.querySelector("[data-meta]"); 88 const download = dialog.querySelector("[data-download]"); 89 const variants = dialog.querySelector("[data-variants]"); 90 const steps = Array.from(dialog.querySelectorAll("[data-step]")); 91 92 let items = []; 93 let title = ""; 94 let index = 0; 95 let closing = false; 96 /* Bumped by every open and every close. A close schedules two ways to finish 97 (animationend and a timer) and either may land after the dialog has already 98 been reopened - without this the stale one would shut the new flyer. */ 99 let generation = 0; 100 101 /* Fading the swap rather than blanking it keeps the frame from flashing 102 white between two large posters. Cached images are `complete` on arrival 103 and skip straight to the loaded state. */ 104 const showImage = (item) => { 105 image.classList.remove("is-loaded"); 106 image.src = item.href; 107 image.alt = item.alt || item.label || "Flyer"; 108 if (image.complete) { 109 image.classList.add("is-loaded"); 110 } 111 }; 112 113 const render = () => { 114 const item = items[index]; 115 if (!item) { 116 return; 117 } 118 119 showImage(item); 120 titleEl.textContent = title; 121 metaEl.textContent = 122 items.length > 1 ? `${item.label} â ${index + 1} of ${items.length}` : item.label; 123 download.href = item.href; 124 125 const many = items.length > 1; 126 steps.forEach((step) => { 127 step.hidden = !many; 128 }); 129 variants.hidden = !many; 130 if (!many) { 131 // Cleared rather than just hidden, so a single-flyer talk opened after a 132 // multi-flyer one cannot leave the previous talk's pills in the DOM. 133 variants.innerHTML = ""; 134 } else { 135 variants.innerHTML = items 136 .map( 137 (entry, position) => 138 `<button class="flyer-lightbox-variant${ 139 position === index ? " is-current" : "" 140 }" type="button" data-go="${position}"${ 141 position === index ? ' aria-current="true"' : "" 142 }>${escapeHtml(entry.label)}</button>` 143 ) 144 .join(""); 145 } 146 147 /* The next poster is a few hundred KB; fetching it now makes the arrow 148 keys feel instant instead of showing the fade on every step. */ 149 const next = items[(index + 1) % items.length]; 150 if (next && next !== item) { 151 new Image().src = next.href; 152 } 153 }; 154 155 const go = (position) => { 156 if (items.length < 2) { 157 return; 158 } 159 index = (position + items.length) % items.length; 160 render(); 161 }; 162 163 const close = () => { 164 if (closing || !dialog.open) { 165 return; 166 } 167 closing = true; 168 const token = (generation += 1); 169 dialog.classList.add("is-closing"); 170 171 const finish = () => { 172 if (token !== generation) { 173 return; 174 } 175 dialog.classList.remove("is-closing"); 176 closing = false;
177 document.body.classList.remove("flyer-open"); 178 if (dialog.open) { 179 dialog.close(); 180 } 181 }; 182 183 /* Under reduced motion the global rule in motion.css collapses the exit to 184 0.01ms, and the browser then fires no `animationend` at all - waiting for 185 one would leave the dialog up for the whole fallback timer, which is the 186 opposite of what the preference asks for. So: close now. */ 187 if (prefersReducedMotion()) { 188 finish(); 189 return; 190 } 191 192 /* Whichever lands first wins; `finish` is guarded by its token. The timer 193 covers a browser that never fires `animationend` for a hidden or 194 interrupted animation, which would otherwise strand the page behind a 195 locked scroll. */ 196 dialog.addEventListener("animationend", finish, { once: true }); 197 window.setTimeout(finish, EXIT_FALLBACK_MS); 198 }; 199 200 const open = (set) => { 201 // Retires any close still in flight, including the entrance animation's own 202 // animationend, which would otherwise be read as that close finishing. 203 generation += 1; 204 closing = false; 205 dialog.classList.remove("is-closing"); 206 items = set.items; 207 title = set.title; 208 index = 0; 209 render(); 210 if (!dialog.open) { 211 dialog.showModal(); 212 /* `showModal` does not lock the page behind it; without this the flyer 213 stays put while the document scrolls underneath. */ 214 document.body.classList.add("flyer-open"); 215 } 216 dialog.querySelector("[data-close]").focus(); 217 }; 218 219 image.addEventListener("load", () => image.classList.add("is-loaded")); 220 image.addEventListener("error", () => image.classList.add("is-loaded")); 221 222 dialog.addEventListener("click", (event) => { 223 if (event.target.closest("[data-close]")) { 224 close(); 225 return; 226 } 227 const step = event.target.closest("[data-step]"); 228 if (step) { 229 go(index + Number(step.dataset.step)); 230 return; 231 } 232 const jump = event.target.closest("[data-go]"); 233 if (jump) { 234 go(Number(jump.dataset.go)); 235 return; 236 } 237 /* Anything that is not the poster, the caption or a control is backdrop. 238 `::backdrop` is not itself an event target, so the dialog element sees 239 those clicks and the frame is what tells them apart. */ 240 if (!event.target.closest(".flyer-lightbox-frame") || event.target === dialog) { 241 close(); 242 } 243 }); 244 245 /* Escape already closes a modal dialog, but the default close skips the exit 246 animation and leaves the scroll lock on, so it is handled here instead. */ 247 dialog.addEventListener("cancel", (event) => { 248 event.preventDefault(); 249 close(); 250 }); 251 252 dialog.addEventListener("keydown", (event) => { 253 if (event.key === "ArrowRight") { 254 event.preventDefault(); 255 go(index + 1); 256 } else if (event.key === "ArrowLeft") { 257 event.preventDefault(); 258 go(index - 1); 259 } 260 }); 261 262 return { open, close }; 263}; 264 265/** 266 * Bind the delegated trigger. Safe to call on every page; the dialog itself is 267 * not built until something is actually opened, so pages with no flyers pay 268 * nothing. 269 */ 270export const bindFlyerLightbox = () => { 271 if (document.documentElement.dataset.flyerLightbox === "bound") { 272 return; 273 } 274 document.documentElement.dataset.flyerLightbox = "bound"; 275 276 document.addEventListener("click", (event) => { 277 const trigger = event.target.closest?.(TRIGGER_SELECTOR); 278 if (!trigger) { 279 return; 280 } 281 const set = parseSet(trigger); 282 if (!set) { 283 return; 284 } 285 event.preventDefault(); 286 if (!view) { 287 view = build(); 288 } 289 view.open(set); 290 }); 291};
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.