This commit is contained in:
Andras Schmelczer 2026-05-31 13:06:47 +01:00
commit 1914991250
19 changed files with 1003 additions and 499 deletions

View file

@ -1,109 +1,273 @@
import { ResponsiveImage } from './model/responsive-image';
import { createImage } from './helper/create-image';
import type { ImageSource, Photo } from './photo-types';
export class Photos {
private readonly images: Array<HTMLImageElement>;
private _selectedId: number;
private timerId: NodeJS.Timeout = null;
private shouldNotChange = false;
const AUTO_ADVANCE_DELAY_MS = 7000;
const FRAME_SIZES =
'(max-width: 899px) calc(100vw - 2rem), calc(100vw - 18rem)';
public constructor(
images: Array<ResponsiveImage>,
private readonly landscapesContainer: HTMLElement,
private readonly portraitsContainer: HTMLElement,
private readonly pictureFrame: HTMLImageElement
) {
this.images = this.convertImages(images);
this.placeImages();
this.addEventListeners();
this.startAutoChange();
this.selectedId = 0;
type GalleryOptions = {
readonly photos: readonly Photo[];
readonly gallery: HTMLElement;
readonly frame: HTMLElement;
readonly toggle: HTMLButtonElement;
readonly autoAdvance?: boolean;
};
type SelectOptions = {
readonly focusThumbnail?: boolean;
readonly pause?: boolean;
};
export const requiredElement = <T extends Element>(
selector: string,
constructor: { new (...args: never[]): T }
): T => {
const element = document.querySelector(selector);
if (!(element instanceof constructor)) {
throw new Error(`Required element not found: ${selector}`);
}
private get selectedId(): number {
return this._selectedId;
return element;
};
export const wrapIndex = (value: number, count: number): number => {
if (count <= 0) {
throw new Error('Cannot wrap an index without items.');
}
private set selectedId(value: number) {
if (value < 0) {
value += this.images.length - 1;
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 => {
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 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';
picture.append(image);
const caption = document.createElement('figcaption');
caption.id = 'frame-caption';
caption.textContent = photo.caption;
figure.append(picture, caption);
return figure;
};
export class PhotoGallery {
private selectedIndex = 0;
private timerId: number | null = null;
private readonly thumbnails: HTMLAnchorElement[];
private readonly reducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
);
public constructor(private readonly options: GalleryOptions) {
if (options.photos.length === 0) {
throw new Error('The gallery needs at least one photo.');
}
this._selectedId = value % this.images.length;
this.loadToFrame(this.images[this.selectedId]);
this.images[this.selectedId].focus();
this.shouldNotChange = true;
}
private convertImages(images: Array<ResponsiveImage>) {
let portraits = 0; // evens
let landscapes = 1; // odds
const imgs = images.map((image, i) =>
createImage(
image,
'a photo',
Photos.isLandscape(image) ? (landscapes += 2) : (portraits += 2)
)
this.thumbnails = this.options.photos.map((photo) =>
this.thumbnailFor(photo)
);
this.addEventListeners();
this.select(0);
return imgs.sort((a, b) => a.tabIndex - b.tabIndex);
if (this.options.autoAdvance !== false && !this.reducedMotion.matches) {
this.startAutoAdvance();
} else {
this.updateToggle(false);
}
}
private placeImages() {
this.images.forEach(img => {
if (Photos.isLandscape(img)) {
img.classList.add('landscape');
this.landscapesContainer.appendChild(img);
} else {
img.classList.add('portrait');
this.portraitsContainer.appendChild(img);
private addEventListeners(): void {
this.options.gallery.addEventListener('click', (event) => {
const thumbnail = (event.target as Element).closest<HTMLAnchorElement>(
'[data-photo-id]'
);
if (!thumbnail) {
return;
}
});
}
private addEventListeners() {
window.document.addEventListener('keydown', e => {
switch (e.key) {
event.preventDefault();
this.selectById(thumbnail.dataset.photoId, { pause: true });
});
this.options.gallery.addEventListener('keydown', (event) => {
if (!this.options.gallery.contains(event.target as Node)) {
return;
}
switch (event.key) {
case 'ArrowLeft':
this.selectedId--;
event.preventDefault();
this.select(this.selectedIndex - 1, {
focusThumbnail: true,
pause: true,
});
break;
case 'ArrowRight':
this.selectedId++;
event.preventDefault();
this.select(this.selectedIndex + 1, {
focusThumbnail: true,
pause: true,
});
break;
case 'Home':
event.preventDefault();
this.select(0, { focusThumbnail: true, pause: true });
break;
case 'End':
event.preventDefault();
this.select(this.options.photos.length - 1, {
focusThumbnail: true,
pause: true,
});
break;
case 'Tab':
if (this.timerId !== null) {
clearInterval(this.timerId);
this.timerId = null;
}
}
});
window.document.addEventListener(
'scroll',
() => (this.shouldNotChange = true)
this.options.gallery.addEventListener('pointerdown', () =>
this.pauseAutoAdvance()
);
this.options.gallery.addEventListener(
'wheel',
() => this.pauseAutoAdvance(),
{ passive: true }
);
this.options.gallery.addEventListener(
'touchstart',
() => this.pauseAutoAdvance(),
{ passive: true }
);
this.images.forEach((image, i) =>
image.addEventListener('click', () => (this.selectedId = i))
);
}
private startAutoChange() {
this.timerId = setInterval(() => {
if (!this.shouldNotChange) {
this.selectedId++;
this.options.toggle.addEventListener('click', () => {
if (this.timerId === null) {
this.startAutoAdvance();
} else {
this.pauseAutoAdvance();
}
this.shouldNotChange = false;
}, 7000);
});
this.reducedMotion.addEventListener('change', () => {
if (this.reducedMotion.matches) {
this.pauseAutoAdvance();
}
});
}
private loadToFrame(img: HTMLImageElement) {
this.pictureFrame.src = img.src;
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 static isLandscape(
image: HTMLImageElement | ResponsiveImage
): boolean {
return image.width > image.height;
private selectById(id: string | undefined, options: SelectOptions): void {
const index = this.options.photos.findIndex((photo) => photo.id === id);
if (index >= 0) {
this.select(index, options);
}
}
private select(value: number, options: SelectOptions = {}): void {
this.selectedIndex = wrapIndex(value, this.options.photos.length);
const photo = this.options.photos[this.selectedIndex];
if (!photo) {
throw new Error('Selected photo is missing.');
}
this.options.frame.replaceChildren(createFrameFigure(photo));
this.updateSelectedThumbnail(photo, 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;
if (selected) {
thumbnail.setAttribute('aria-current', 'true');
} else {
thumbnail.removeAttribute('aria-current');
}
thumbnail.classList.toggle('is-selected', selected);
if (selected && shouldFocus) {
thumbnail.focus({ preventScroll: true });
thumbnail.scrollIntoView({
block: 'nearest',
inline: 'nearest',
});
}
}
}
private startAutoAdvance(): void {
this.pauseAutoAdvance(false);
if (this.reducedMotion.matches) {
this.updateToggle(false);
return;
}
this.timerId = window.setInterval(() => {
this.select(this.selectedIndex + 1);
}, AUTO_ADVANCE_DELAY_MS);
this.updateToggle(true);
}
private pauseAutoAdvance(updateToggle = true): void {
if (this.timerId !== null) {
window.clearInterval(this.timerId);
this.timerId = null;
}
if (updateToggle) {
this.updateToggle(false);
}
}
private updateToggle(isRunning: boolean): void {
this.options.toggle.setAttribute('aria-pressed', String(isRunning));
this.options.toggle.textContent = isRunning ? 'Pause' : 'Play';
}
}