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

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

View file

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

View file

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

View file

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