Clean up
This commit is contained in:
parent
5bdf68eabd
commit
d5aa9c43bb
15 changed files with 166 additions and 285 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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', {
|
||||
|
|
|
|||
125
src/photos.ts
125
src/photos.ts
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue