/* photography-slideshow.js — sequential full-screen viewing of a grid. * * Slideshow is not a fourth grid layout, so it is deliberately not one of * photography-modes.js's VALID_MODES: it does not rearrange the page, it * replaces it for a while and then gives it back. That is also why it is not * persisted to localStorage — nobody wants to land on a page and find it * already playing. * * Frames come from whatever .photography-grid is on the page, in DOM order, * so the same control works on the section landing, a series, and a by-year * index without knowing anything about them. * * The overlay borrows .lightbox-overlay.darkroom styling from lightbox.js so * the two full-screen experiences look like one idea, but it does not drive * that module: lightbox.js builds its own DOM around a single image and * exposes no API for sequences. */ (function () { 'use strict'; var ADVANCE_MS = 5000; document.addEventListener('DOMContentLoaded', function () { var trigger = document.querySelector('[data-mode="slideshow"]'); var grid = document.querySelector('.photography-grid'); if (!trigger || !grid) return; var frames = []; grid.querySelectorAll('.photo-card').forEach(function (card) { var im = card.querySelector('img'); if (!im) return; var titleEl = card.querySelector('.photo-card-title'); var dateEl = card.querySelector('.photo-card-date'); var link = card.querySelector('.photo-card-link'); frames.push({ src: im.currentSrc || im.src, alt: im.alt || '', title: titleEl ? titleEl.textContent.trim() : '', date: dateEl ? dateEl.textContent.trim() : '', href: link ? link.getAttribute('href') : null }); }); if (frames.length === 0) { trigger.hidden = true; return; } // Autoplay is opt-in for anyone who has asked for less motion. var reduceMotion = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; var index = 0; var timer = null; var lastFocus = null; // ------------------------------------------------------------------ // DOM // ------------------------------------------------------------------ var overlay = document.createElement('div'); overlay.className = 'lightbox-overlay darkroom slideshow-overlay'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', 'Slideshow'); // Visibility is carried by .is-open, exactly as lightbox.js does it. // .lightbox-overlay hides itself with opacity/visibility/pointer-events // rather than display, so toggling the `hidden` attribute did nothing // to it — the overlay stayed invisible while open() had already locked // body scrolling, which read as the page freezing for no reason. var vignette = document.createElement('div'); vignette.className = 'lightbox-vignette'; vignette.setAttribute('aria-hidden', 'true'); var figure = document.createElement('figure'); figure.className = 'slideshow-figure'; var img = document.createElement('img'); img.className = 'slideshow-img'; img.decoding = 'async'; var caption = document.createElement('figcaption'); caption.className = 'slideshow-caption'; figure.appendChild(img); figure.appendChild(caption); function control(cls, label, glyph) { var b = document.createElement('button'); b.type = 'button'; b.className = 'slideshow-btn ' + cls; b.setAttribute('aria-label', label); b.textContent = glyph; return b; } var prevBtn = control('slideshow-prev', 'Previous photograph', '‹'); var nextBtn = control('slideshow-next', 'Next photograph', '›'); var playBtn = control('slideshow-play', 'Play slideshow', '▶'); var closeBtn = control('slideshow-close', 'Close slideshow', '×'); var counter = document.createElement('p'); counter.className = 'slideshow-counter'; // Position changes are announced, but politely — a screen reader // should not interrupt itself every five seconds. counter.setAttribute('aria-live', 'polite'); var bar = document.createElement('div'); bar.className = 'slideshow-bar'; bar.appendChild(prevBtn); bar.appendChild(playBtn); bar.appendChild(nextBtn); bar.appendChild(counter); overlay.appendChild(vignette); overlay.appendChild(closeBtn); overlay.appendChild(figure); overlay.appendChild(bar); document.body.appendChild(overlay); // ------------------------------------------------------------------ // Behaviour // ------------------------------------------------------------------ function preload(i) { if (i < 0 || i >= frames.length) return; var p = new Image(); p.src = frames[i].src; } function show(i) { index = (i + frames.length) % frames.length; var f = frames[index]; img.src = f.src; img.alt = f.alt; caption.innerHTML = ''; if (f.title) { var t = document.createElement('span'); t.className = 'slideshow-title'; t.textContent = f.title; caption.appendChild(t); } if (f.date) { var d = document.createElement('span'); d.className = 'slideshow-date'; d.textContent = f.date; caption.appendChild(d); } counter.textContent = (index + 1) + ' of ' + frames.length; preload(index + 1); } function playing() { return timer !== null; } function play() { if (playing()) return; timer = window.setInterval(function () { show(index + 1); }, ADVANCE_MS); playBtn.textContent = '❚❚'; playBtn.setAttribute('aria-label', 'Pause slideshow'); } function pause() { if (!playing()) return; window.clearInterval(timer); timer = null; playBtn.textContent = '▶'; playBtn.setAttribute('aria-label', 'Play slideshow'); } function open(startAt) { lastFocus = document.activeElement; show(typeof startAt === 'number' ? startAt : 0); overlay.classList.add('is-open'); document.body.classList.add('slideshow-open'); closeBtn.focus(); if (!reduceMotion) play(); } function close() { pause(); overlay.classList.remove('is-open'); document.body.classList.remove('slideshow-open'); if (lastFocus && lastFocus.focus) lastFocus.focus(); } // Manual navigation stops the clock: someone steering does not want // the timer yanking the frame out from under them mid-look. function step(delta) { pause(); show(index + delta); } trigger.addEventListener('click', function (e) { e.preventDefault(); open(0); }); prevBtn.addEventListener('click', function () { step(-1); }); nextBtn.addEventListener('click', function () { step(1); }); closeBtn.addEventListener('click', close); playBtn.addEventListener('click', function () { playing() ? pause() : play(); }); // Clicking the photograph opens its own page — the slideshow is for // looking, and this is the way out of it into reading. img.addEventListener('click', function () { var f = frames[index]; if (f.href) { close(); window.location.href = f.href; } }); overlay.addEventListener('click', function (e) { if (e.target === overlay || e.target === vignette) close(); }); document.addEventListener('keydown', function (e) { if (!overlay.classList.contains('is-open')) return; switch (e.key) { case 'Escape': close(); break; case 'ArrowRight': e.preventDefault(); step(1); break; case 'ArrowLeft': e.preventDefault(); step(-1); break; case 'Home': e.preventDefault(); step(-index); break; case 'End': e.preventDefault(); step(frames.length - 1 - index); break; case ' ': e.preventDefault(); playing() ? pause() : play(); break; case 'Tab': trapTab(e); break; } }); function trapTab(e) { var f = [closeBtn, prevBtn, playBtn, nextBtn]; var first = f[0], last = f[f.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } // A tab switched away should not silently burn through the set. document.addEventListener('visibilitychange', function () { if (document.hidden) pause(); }); }); }());