levineuwirth.org/static/js/photography-slideshow.js

243 lines
9.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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();
});
});
}());