This commit is contained in:
Andras Schmelczer 2026-05-31 14:29:43 +01:00
parent 5bdf68eabd
commit d5aa9c43bb
15 changed files with 166 additions and 285 deletions

View file

@ -10,13 +10,13 @@ on:
permissions: permissions:
contents: read contents: read
concurrency:
group: 'photos-${{ github.ref }}'
cancel-in-progress: true
jobs: jobs:
validate: validate:
runs-on: docker runs-on: docker
# Superseded validation runs can be cancelled freely.
concurrency:
group: 'photos-validate-${{ github.ref }}'
cancel-in-progress: true
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@ -49,6 +49,11 @@ jobs:
runs-on: docker runs-on: docker
needs: validate needs: validate
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# Never cancel an in-flight deploy: rsync --delete must finish or the live
# site can be left half-deleted. Newer deploys queue behind this one.
concurrency:
group: 'photos-deploy-${{ github.ref }}'
cancel-in-progress: false
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4

1
.gitignore vendored
View file

@ -1,6 +1,5 @@
node_modules node_modules
dist dist
target
.DS_Store .DS_Store
public/static/photos public/static/photos
src/generated src/generated

View file

@ -37,7 +37,7 @@ Run the full local validation suite with:
npm run lint:check npm run lint:check
``` ```
This checks the photo catalog, generates assets, checks source photos for EXIF/XMP/IPTC metadata, type-checks, lints TypeScript and SCSS, and checks formatting. This generates assets (validating the photo catalog against `src/pictures/` in the process), checks source photos for EXIF/XMP/IPTC metadata, type-checks, lints TypeScript and SCSS, and checks formatting.
Run unit tests separately: Run unit tests separately:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 243 KiB

View file

@ -7,7 +7,7 @@
"scripts": { "scripts": {
"dev": "node scripts/generate-assets.mjs && vite --host 0.0.0.0", "dev": "node scripts/generate-assets.mjs && vite --host 0.0.0.0",
"lint": "eslint . --fix && stylelint \"src/**/*.scss\" --fix && prettier --write .", "lint": "eslint . --fix && stylelint \"src/**/*.scss\" --fix && prettier --write .",
"lint:check": "node scripts/check-photo-catalog.mjs && node scripts/generate-assets.mjs && node scripts/check-image-metadata.mjs && tsc --noEmit && eslint . && stylelint \"src/**/*.scss\" && prettier --check .", "lint:check": "node scripts/generate-assets.mjs && node scripts/check-image-metadata.mjs && tsc --noEmit && eslint . && stylelint \"src/**/*.scss\" && prettier --check .",
"test": "vitest run", "test": "vitest run",
"test:e2e": "playwright test", "test:e2e": "playwright test",
"build": "node scripts/generate-assets.mjs && vite build" "build": "node scripts/generate-assets.mjs && vite build"
@ -32,9 +32,6 @@
"url": "https://github.com/schmelczerandras/photos/issues" "url": "https://github.com/schmelczerandras/photos/issues"
}, },
"homepage": "https://schmelczer.dev/photos/", "homepage": "https://schmelczer.dev/photos/",
"browserslist": [
"defaults"
],
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@playwright/test": "^1.60.0", "@playwright/test": "^1.60.0",

View file

@ -8,7 +8,7 @@ const rootDir = path.resolve(__dirname, '..');
const pictureDir = path.join(rootDir, 'src/pictures'); const pictureDir = path.join(rootDir, 'src/pictures');
const files = (await readdir(pictureDir)).filter((file) => const files = (await readdir(pictureDir)).filter((file) =>
file.toLowerCase().endsWith('.jpg') /\.jpe?g$/i.test(file)
); );
const offenders = []; const offenders = [];

View file

@ -1,84 +0,0 @@
import { readFile, readdir } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const rootDir = path.resolve(__dirname, '..');
const sourceDir = path.join(rootDir, 'src/pictures');
const catalogPath = path.join(rootDir, 'src/photo-catalog.json');
const isJpeg = (file) => /\.(jpe?g)$/i.test(file);
const formatList = (items) => items.map((item) => `- ${item}`).join('\n');
const main = async () => {
const sourceFiles = (await readdir(sourceDir)).filter(isJpeg).sort();
const catalog = JSON.parse(await readFile(catalogPath, 'utf8'));
if (!Array.isArray(catalog)) {
throw new Error('Photo catalog must be an array.');
}
const catalogFiles = catalog.map((entry, index) => {
if (!entry || typeof entry !== 'object') {
throw new Error(`Photo catalog entry ${index + 1} must be an object.`);
}
if (typeof entry.file !== 'string' || entry.file.trim() === '') {
throw new Error(`Photo catalog entry ${index + 1} is missing "file".`);
}
return entry.file;
});
const sourceFileSet = new Set(sourceFiles);
const catalogFileSet = new Set(catalogFiles);
const duplicateFiles = catalogFiles
.filter((file, index) => catalogFiles.indexOf(file) !== index)
.filter((file, index, files) => files.indexOf(file) === index)
.sort();
const missingFromCatalog = sourceFiles.filter(
(file) => !catalogFileSet.has(file)
);
const missingFromPictures = catalogFiles
.filter((file) => !sourceFileSet.has(file))
.sort();
if (
duplicateFiles.length > 0 ||
missingFromCatalog.length > 0 ||
missingFromPictures.length > 0
) {
const sections = [];
if (duplicateFiles.length > 0) {
sections.push(
`Duplicate catalog file entries:\n${formatList(duplicateFiles)}`
);
}
if (missingFromCatalog.length > 0) {
sections.push(
`Photos missing from src/photo-catalog.json:\n${formatList(
missingFromCatalog
)}`
);
}
if (missingFromPictures.length > 0) {
sections.push(
`Catalog entries missing from src/pictures:\n${formatList(
missingFromPictures
)}`
);
}
throw new Error(sections.join('\n\n'));
}
console.log(
`Photo catalog includes all ${sourceFiles.length} source photos.`
);
};
await main();

View file

@ -15,7 +15,7 @@ const formats = [
{ {
type: 'image/avif', type: 'image/avif',
extension: 'avif', extension: 'avif',
options: { quality: 45, effort: 0 }, options: { quality: 45, effort: 4 },
}, },
{ type: 'image/webp', extension: 'webp', options: { quality: 78 } }, { type: 'image/webp', extension: 'webp', options: { quality: 78 } },
{ {
@ -55,6 +55,19 @@ const uniqueWidthsFor = (sourceWidth) => {
const renderSource = ({ type, srcset }, sizes) => const renderSource = ({ type, srcset }, sizes) =>
`<source type="${type}" srcset="${escapeHtml(srcset)}" sizes="${escapeHtml(sizes)}">`; `<source type="${type}" srcset="${escapeHtml(srcset)}" sizes="${escapeHtml(sizes)}">`;
// The frame is bounded by BOTH the available width and the viewport height
// (CSS max-block-size). For a portrait, the height cap is what limits the
// rendered width, so the displayed width is min(widthCap, heightCap * aspect).
// Expressing that here stops tall photos from over-fetching a too-wide variant.
const frameSizes = (photo) => {
const aspect = photo.aspectRatio;
return (
`(max-width: 899px) min(calc(100vw - 2rem), calc((100vh - 12rem) * ${aspect})), ` +
`min(calc(100vw - 18rem), calc((100vh - 5rem) * ${aspect}))`
);
};
const renderPicture = ( const renderPicture = (
photo, photo,
sizes, sizes,
@ -80,7 +93,17 @@ const renderThumbnail = (photo, isCurrent) => {
(source) => source.type === 'image/jpeg' (source) => source.type === 'image/jpeg'
).variants[0]; ).variants[0];
return `<a class="thumbnail thumbnail--${photo.orientation}" href="${photo.fallback.src}" data-photo-id="${photo.id}"${isCurrent ? ' aria-current="true"' : ''}> // The frame is built at runtime by cloning this thumbnail's <picture>, so the
// data the frame needs but the thumbnail markup lacks is carried on data-*.
const dataAttributes = [
`data-photo-id="${photo.id}"`,
`data-order="${photo.order}"`,
`data-caption="${escapeHtml(photo.caption)}"`,
`data-frame-src="${photo.fallback.src}"`,
`data-frame-sizes="${escapeHtml(frameSizes(photo))}"`,
].join(' ');
return `<a class="thumbnail thumbnail--${photo.orientation}" href="${photo.fallback.src}" ${dataAttributes}${isCurrent ? ' aria-current="true"' : ''}>
${renderPicture(photo, sizes, 'loading="lazy" decoding="async"', smallestJpeg.src)} ${renderPicture(photo, sizes, 'loading="lazy" decoding="async"', smallestJpeg.src)}
</a>`; </a>`;
}; };
@ -88,17 +111,37 @@ const renderThumbnail = (photo, isCurrent) => {
const renderFrame = (photo) => `<figure class="frame-figure"> const renderFrame = (photo) => `<figure class="frame-figure">
${renderPicture( ${renderPicture(
photo, photo,
'(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)', frameSizes(photo),
'id="frame-image" decoding="async" fetchpriority="high"' 'id="frame-image" decoding="async" fetchpriority="high"'
)} )}
<figcaption id="frame-caption">${escapeHtml(photo.caption)}</figcaption> <figcaption id="frame-caption">${escapeHtml(photo.caption)}</figcaption>
</figure>`; </figure>`;
const validateCatalog = (catalog) => {
if (!Array.isArray(catalog)) {
throw new Error('Photo catalog must be an array.');
}
const seen = new Set();
for (const [index, entry] of catalog.entries()) {
if (!entry || typeof entry.file !== 'string' || entry.file.trim() === '') {
throw new Error(
`Photo catalog entry ${index + 1} needs a non-empty "file".`
);
}
if (seen.has(entry.file)) {
throw new Error(`Duplicate photo catalog entry: ${entry.file}`);
}
seen.add(entry.file);
}
};
const assertCatalogMatchesFiles = async (catalog) => { const assertCatalogMatchesFiles = async (catalog) => {
const sourceFiles = new Set( const sourceFiles = new Set(
(await readdir(sourceDir)).filter((file) => (await readdir(sourceDir)).filter((file) => /\.jpe?g$/i.test(file))
file.toLowerCase().endsWith('.jpg')
)
); );
const catalogFiles = new Set(catalog.map((entry) => entry.file)); const catalogFiles = new Set(catalog.map((entry) => entry.file));
@ -116,13 +159,14 @@ const assertCatalogMatchesFiles = async (catalog) => {
}; };
const generate = async () => { const generate = async () => {
const catalog = JSON.parse(await readFile(catalogPath, 'utf8')).map( const rawCatalog = JSON.parse(await readFile(catalogPath, 'utf8'));
(entry, index) => ({ validateCatalog(rawCatalog);
const catalog = rawCatalog.map((entry, index) => ({
...entry, ...entry,
id: slugify(entry.file), id: slugify(entry.file),
order: index + 1, order: index + 1,
}) }));
);
await assertCatalogMatchesFiles(catalog); await assertCatalogMatchesFiles(catalog);
await rm(publicPhotoDir, { recursive: true, force: true }); await rm(publicPhotoDir, { recursive: true, force: true });
@ -205,11 +249,6 @@ const generate = async () => {
}); });
} }
const module = `import type { Photo } from '../photo-types';
export const photos = ${JSON.stringify(photos, null, 2)} as const satisfies readonly Photo[];
`;
const firstPhoto = photos[0]; const firstPhoto = photos[0];
const portraits = photos const portraits = photos
.filter((photo) => photo.orientation === 'portrait') .filter((photo) => photo.orientation === 'portrait')
@ -230,7 +269,6 @@ ${landscapes}
<!-- /landscapes --> <!-- /landscapes -->
`; `;
await writeFile(path.join(generatedDir, 'photos.ts'), module);
await writeFile(path.join(generatedDir, 'gallery.html'), galleryHtml); await writeFile(path.join(generatedDir, 'gallery.html'), galleryHtml);
console.log( console.log(
`Generated ${photos.length} photos and ${photos.reduce( `Generated ${photos.length} photos and ${photos.reduce(

View file

@ -8,7 +8,7 @@ const rootDir = path.resolve(__dirname, '..');
const pictureDir = path.join(rootDir, 'src/pictures'); const pictureDir = path.join(rootDir, 'src/pictures');
const files = (await readdir(pictureDir)).filter((file) => const files = (await readdir(pictureDir)).filter((file) =>
file.toLowerCase().endsWith('.jpg') /\.jpe?g$/i.test(file)
); );
let stripped = 0; let stripped = 0;

View file

@ -56,7 +56,7 @@
<!-- gallery:portraits --> <!-- gallery:portraits -->
</section> </section>
<section class="frame" aria-live="polite"> <section class="frame">
<div id="frame-content" class="frame-content"> <div id="frame-content" class="frame-content">
<!-- gallery:frame --> <!-- gallery:frame -->
</div> </div>

View file

@ -32,7 +32,6 @@ html {
} }
body { body {
min-height: 100dvh;
block-size: 100dvh; block-size: 100dvh;
margin: 0; margin: 0;
display: grid; display: grid;
@ -69,7 +68,6 @@ img {
margin: 0; margin: 0;
font-size: clamp(1.5rem, 4vmin, 3rem); font-size: clamp(1.5rem, 4vmin, 3rem);
font-weight: 300; font-weight: 300;
letter-spacing: 0;
line-height: 1; line-height: 1;
writing-mode: vertical-rl; writing-mode: vertical-rl;
transform: rotate(180deg); transform: rotate(180deg);
@ -206,18 +204,8 @@ img {
background: #5f1724; background: #5f1724;
} }
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
}
}
@media (width < $breakpoint) { @media (width < $breakpoint) {
body { body {
min-height: 100dvh;
display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: auto minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
} }

View file

@ -1,5 +1,4 @@
import './index.scss'; import './index.scss';
import { photos } from './generated/photos';
import { PhotoGallery, requiredElement } from './photos'; import { PhotoGallery, requiredElement } from './photos';
document.documentElement.classList.add('js'); document.documentElement.classList.add('js');
@ -12,7 +11,6 @@ const toggle = requiredElement<HTMLButtonElement>(
); );
new PhotoGallery({ new PhotoGallery({
photos,
gallery, gallery,
frame, frame,
toggle, toggle,

View file

@ -1,28 +0,0 @@
export type PhotoOrientation = 'landscape' | 'portrait';
export type ImageMimeType = 'image/avif' | 'image/webp' | 'image/jpeg';
export type ImageVariant = {
readonly src: string;
readonly width: number;
readonly height: number;
};
export type ImageSource = {
readonly type: ImageMimeType;
readonly variants: readonly ImageVariant[];
readonly srcset: string;
};
export type Photo = {
readonly id: string;
readonly file: string;
readonly order: number;
readonly alt: string;
readonly caption: string;
readonly orientation: PhotoOrientation;
readonly width: number;
readonly height: number;
readonly aspectRatio: number;
readonly sources: readonly ImageSource[];
readonly fallback: ImageVariant;
};

View file

@ -1,47 +1,33 @@
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { PhotoGallery, wrapIndex } from './photos'; import { PhotoGallery, wrapIndex } from './photos';
import type { Photo } from './photo-types';
const variant = (name: string) => ({ const thumbnail = (id: string, order: number, alt: string): string => `
src: `static/photos/${name}.jpg`, <a
width: 320, class="thumbnail"
height: 240, href="static/photos/${id}-2400.jpg"
}); data-photo-id="${id}"
data-order="${order}"
const photo = (id: string, alt: string): Photo => ({ data-caption="${alt}"
id, data-frame-src="static/photos/${id}-2400.jpg"
file: `${id}.jpg`, data-frame-sizes="100vw"
order: 1, >
alt, <picture>
caption: alt, <source type="image/avif" srcset="static/photos/${id}-480.avif 480w" sizes="8vmin" />
orientation: 'landscape', <source type="image/webp" srcset="static/photos/${id}-480.webp 480w" sizes="8vmin" />
width: 320, <img
height: 240, src="static/photos/${id}-480.jpg"
aspectRatio: 1.3333, srcset="static/photos/${id}-480.jpg 480w"
sources: [ sizes="8vmin"
{ width="320"
type: 'image/avif', height="240"
variants: [variant(`${id}.avif`)], alt="${alt}"
srcset: `static/photos/${id}.avif 320w`, loading="lazy"
}, decoding="async"
{ />
type: 'image/webp', </picture>
variants: [variant(`${id}.webp`)], </a>`;
srcset: `static/photos/${id}.webp 320w`,
},
{
type: 'image/jpeg',
variants: [variant(`${id}.jpg`)],
srcset: `static/photos/${id}.jpg 320w`,
},
],
fallback: variant(`${id}.jpg`),
});
const setup = () => { const setup = () => {
vi.stubGlobal('CSS', {
escape: (value: string) => value,
});
vi.stubGlobal('matchMedia', () => ({ vi.stubGlobal('matchMedia', () => ({
matches: false, matches: false,
media: '(prefers-reduced-motion: reduce)', media: '(prefers-reduced-motion: reduce)',
@ -56,27 +42,22 @@ const setup = () => {
document.body.innerHTML = ` document.body.innerHTML = `
<main id="gallery"> <main id="gallery">
<a href="static/photos/one.jpg" data-photo-id="one">One</a> ${thumbnail('one', 1, 'First photo')}
<a href="static/photos/two.jpg" data-photo-id="two">Two</a> ${thumbnail('two', 2, 'Second photo')}
</main> </main>
<section id="frame"></section> <section class="frame"><div id="frame-content"></div></section>
<button id="toggle" type="button">Play</button> <button id="toggle" type="button">Play</button>
`; `;
const gallery = document.querySelector<HTMLElement>('#gallery'); const gallery = document.querySelector<HTMLElement>('#gallery');
const frame = document.querySelector<HTMLElement>('#frame'); const frame = document.querySelector<HTMLElement>('#frame-content');
const toggle = document.querySelector<HTMLButtonElement>('#toggle'); const toggle = document.querySelector<HTMLButtonElement>('#toggle');
if (!gallery || !frame || !toggle) { if (!gallery || !frame || !toggle) {
throw new Error('Test DOM failed to initialize.'); throw new Error('Test DOM failed to initialize.');
} }
return { return { gallery, frame, toggle };
gallery,
frame,
toggle,
photos: [photo('one', 'First photo'), photo('two', 'Second photo')],
};
}; };
describe('wrapIndex', () => { describe('wrapIndex', () => {
@ -94,9 +75,9 @@ describe('wrapIndex', () => {
}); });
describe('PhotoGallery', () => { describe('PhotoGallery', () => {
it('updates the frame and selected state when a thumbnail is clicked', () => { it('builds the frame from the clicked thumbnail and marks it current', () => {
const { gallery, frame, toggle, photos } = setup(); const { gallery, frame, toggle } = setup();
new PhotoGallery({ photos, gallery, frame, toggle, autoAdvance: false }); new PhotoGallery({ gallery, frame, toggle, autoAdvance: false });
gallery gallery
.querySelector<HTMLAnchorElement>('[data-photo-id="two"]') .querySelector<HTMLAnchorElement>('[data-photo-id="two"]')
@ -104,7 +85,11 @@ describe('PhotoGallery', () => {
new MouseEvent('click', { bubbles: true, cancelable: true }) new MouseEvent('click', { bubbles: true, cancelable: true })
); );
expect(frame.querySelector('img')?.alt).toBe('Second photo'); const image = frame.querySelector('img');
expect(image?.alt).toBe('Second photo');
expect(image?.id).toBe('frame-image');
expect(image?.getAttribute('src')).toBe('static/photos/two-2400.jpg');
expect(frame.querySelector('figcaption')?.textContent).toBe('Second photo');
expect( expect(
gallery gallery
.querySelector('[data-photo-id="two"]') .querySelector('[data-photo-id="two"]')
@ -112,9 +97,9 @@ describe('PhotoGallery', () => {
).toBe('true'); ).toBe('true');
}); });
it('uses scoped arrow-key navigation', () => { it('wraps with arrow-key navigation', () => {
const { gallery, frame, toggle, photos } = setup(); const { gallery, frame, toggle } = setup();
new PhotoGallery({ photos, gallery, frame, toggle, autoAdvance: false }); new PhotoGallery({ gallery, frame, toggle, autoAdvance: false });
gallery.dispatchEvent( gallery.dispatchEvent(
new KeyboardEvent('keydown', { new KeyboardEvent('keydown', {

View file

@ -1,11 +1,6 @@
import type { ImageSource, Photo } from './photo-types';
const AUTO_ADVANCE_DELAY_MS = 7000; const AUTO_ADVANCE_DELAY_MS = 7000;
const FRAME_SIZES =
'(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)';
type GalleryOptions = { type GalleryOptions = {
readonly photos: readonly Photo[];
readonly gallery: HTMLElement; readonly gallery: HTMLElement;
readonly frame: HTMLElement; readonly frame: HTMLElement;
readonly toggle: HTMLButtonElement; readonly toggle: HTMLButtonElement;
@ -38,44 +33,44 @@ export const wrapIndex = (value: number, count: number): number => {
return ((value % count) + count) % count; return ((value % count) + count) % count;
}; };
const createSource = (source: ImageSource): HTMLSourceElement => { // Build the frame figure by cloning the thumbnail's own <picture>. The variant
const element = document.createElement('source'); // files are identical to the frame's, so the only differences are the larger
element.type = source.type; // fallback src, the frame-appropriate `sizes`, and the caption — all carried on
element.srcset = source.srcset; // the thumbnail's data-* attributes by the asset generator.
element.sizes = FRAME_SIZES; const createFrameFigure = (thumbnail: HTMLAnchorElement): HTMLElement => {
return element;
};
const createFrameFigure = (photo: Photo): HTMLElement => {
const figure = document.createElement('figure'); const figure = document.createElement('figure');
figure.className = 'frame-figure'; figure.className = 'frame-figure';
const picture = document.createElement('picture'); const original = thumbnail.querySelector('picture');
if (!original) {
for (const source of photo.sources.filter( throw new Error(
({ type }) => type !== 'image/jpeg' `Thumbnail is missing a picture: ${thumbnail.dataset.photoId}`
)) { );
picture.append(createSource(source));
} }
const jpeg = photo.sources.find(({ type }) => type === 'image/jpeg'); const picture = original.cloneNode(true) as HTMLElement;
const image = document.createElement('img'); const image = picture.querySelector('img');
image.id = 'frame-image'; if (!image) {
image.src = photo.fallback.src; throw new Error(
image.srcset = jpeg?.srcset ?? ''; `Thumbnail is missing an image: ${thumbnail.dataset.photoId}`
image.sizes = FRAME_SIZES; );
image.width = photo.width; }
image.height = photo.height;
image.alt = photo.alt;
image.decoding = 'async';
image.fetchPriority = 'high';
picture.append(image); const sizes = thumbnail.dataset.frameSizes ?? '';
for (const element of picture.querySelectorAll('source, img')) {
element.setAttribute('sizes', sizes);
}
image.id = 'frame-image';
if (thumbnail.dataset.frameSrc) {
image.src = thumbnail.dataset.frameSrc;
}
image.removeAttribute('loading');
image.decoding = 'async';
const caption = document.createElement('figcaption'); const caption = document.createElement('figcaption');
caption.id = 'frame-caption'; caption.id = 'frame-caption';
caption.textContent = photo.caption; caption.textContent = thumbnail.dataset.caption ?? '';
figure.append(picture, caption); figure.append(picture, caption);
@ -91,15 +86,19 @@ export class PhotoGallery {
); );
public constructor(private readonly options: GalleryOptions) { public constructor(private readonly options: GalleryOptions) {
if (options.photos.length === 0) { this.thumbnails = Array.from(
options.gallery.querySelectorAll<HTMLAnchorElement>('[data-photo-id]')
).sort((a, b) => Number(a.dataset.order) - Number(b.dataset.order));
if (this.thumbnails.length === 0) {
throw new Error('The gallery needs at least one photo.'); throw new Error('The gallery needs at least one photo.');
} }
this.thumbnails = this.options.photos.map((photo) =>
this.thumbnailFor(photo)
);
this.addEventListeners(); this.addEventListeners();
this.select(0);
// The frame for photo 0 is already server-rendered, so just mark the
// selected thumbnail; rebuilding it would discard the in-flight LCP image.
this.updateSelectedThumbnail(0, false);
if (this.options.autoAdvance !== false && !this.reducedMotion.matches) { if (this.options.autoAdvance !== false && !this.reducedMotion.matches) {
this.startAutoAdvance(); this.startAutoAdvance();
@ -123,10 +122,6 @@ export class PhotoGallery {
}); });
this.options.gallery.addEventListener('keydown', (event) => { this.options.gallery.addEventListener('keydown', (event) => {
if (!this.options.gallery.contains(event.target as Node)) {
return;
}
switch (event.key) { switch (event.key) {
case 'ArrowLeft': case 'ArrowLeft':
event.preventDefault(); event.preventDefault();
@ -148,7 +143,7 @@ export class PhotoGallery {
break; break;
case 'End': case 'End':
event.preventDefault(); event.preventDefault();
this.select(this.options.photos.length - 1, { this.select(this.thumbnails.length - 1, {
focusThumbnail: true, focusThumbnail: true,
pause: true, pause: true,
}); });
@ -164,11 +159,6 @@ export class PhotoGallery {
() => this.pauseAutoAdvance(), () => this.pauseAutoAdvance(),
{ passive: true } { passive: true }
); );
this.options.gallery.addEventListener(
'touchstart',
() => this.pauseAutoAdvance(),
{ passive: true }
);
this.options.toggle.addEventListener('click', () => { this.options.toggle.addEventListener('click', () => {
if (this.timerId === null) { if (this.timerId === null) {
@ -185,20 +175,10 @@ export class PhotoGallery {
}); });
} }
private thumbnailFor(photo: Photo): HTMLAnchorElement {
const thumbnail = this.options.gallery.querySelector<HTMLAnchorElement>(
`[data-photo-id="${CSS.escape(photo.id)}"]`
);
if (!thumbnail) {
throw new Error(`Missing thumbnail for ${photo.id}.`);
}
return thumbnail;
}
private selectById(id: string | undefined, options: SelectOptions): void { private selectById(id: string | undefined, options: SelectOptions): void {
const index = this.options.photos.findIndex((photo) => photo.id === id); const index = this.thumbnails.findIndex(
(thumbnail) => thumbnail.dataset.photoId === id
);
if (index >= 0) { if (index >= 0) {
this.select(index, options); this.select(index, options);
@ -206,24 +186,27 @@ export class PhotoGallery {
} }
private select(value: number, options: SelectOptions = {}): void { private select(value: number, options: SelectOptions = {}): void {
this.selectedIndex = wrapIndex(value, this.options.photos.length); this.selectedIndex = wrapIndex(value, this.thumbnails.length);
const photo = this.options.photos[this.selectedIndex]; const thumbnail = this.thumbnails[this.selectedIndex];
if (!photo) { if (!thumbnail) {
throw new Error('Selected photo is missing.'); throw new Error('Selected photo is missing.');
} }
this.options.frame.replaceChildren(createFrameFigure(photo)); this.options.frame.replaceChildren(createFrameFigure(thumbnail));
this.updateSelectedThumbnail(photo, options.focusThumbnail ?? false); this.updateSelectedThumbnail(
this.selectedIndex,
options.focusThumbnail ?? false
);
if (options.pause) { if (options.pause) {
this.pauseAutoAdvance(); this.pauseAutoAdvance();
} }
} }
private updateSelectedThumbnail(photo: Photo, shouldFocus: boolean): void { private updateSelectedThumbnail(index: number, shouldFocus: boolean): void {
for (const thumbnail of this.thumbnails) { this.thumbnails.forEach((thumbnail, i) => {
const selected = thumbnail.dataset.photoId === photo.id; const selected = i === index;
if (selected) { if (selected) {
thumbnail.setAttribute('aria-current', 'true'); thumbnail.setAttribute('aria-current', 'true');
} else { } else {
@ -238,7 +221,7 @@ export class PhotoGallery {
inline: 'nearest', inline: 'nearest',
}); });
} }
} });
} }
private startAutoAdvance(): void { private startAutoAdvance(): void {