PageSourceSearch

https://sc-ai.net/static/js/ui/lightbox.js

js sc-ai.net collected 2026-10-07 20:49:07 UTC 10,008 bytes, 291 lines download raw bytes

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">&times;</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.