Add loading animatino

This commit is contained in:
Andras Schmelczer 2026-06-02 19:56:35 +01:00
parent 9272fd43a5
commit 00fa298dd5
4 changed files with 201 additions and 12 deletions

View file

@ -116,7 +116,9 @@ const renderThumbnail = (photo, isCurrent) => {
</a>`;
};
const renderFrame = (photo) => `<figure class="frame-figure">
const renderFrame = (
photo
) => `<figure class="frame-figure" style="--frame-ratio:${photo.aspectRatio}">
${renderPicture(
photo,
frameSizes(photo),

View file

@ -144,25 +144,67 @@ img {
min-width: 0;
min-height: 0;
display: grid;
place-items: center;
// A full-width column gives the figure a definite width to size against, so
// `100%` below resolves to the real available width (not the image's own,
// content-collapsed width) even before the photo has loaded.
grid-template-columns: minmax(0, 1fr);
align-content: center;
justify-items: center;
position: relative;
}
.frame-content.is-loading::after {
content: '';
position: absolute;
inset: 0;
margin: auto;
inline-size: 3rem;
block-size: 3rem;
border: 4px solid rgb(32 33 36 / 20%);
border-block-start-color: var(--color-accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.frame-content.is-loading::after {
animation: none;
}
}
// Holds the next photo's <picture> off-screen so the browser fetches it before
// the slideshow advances. Kept renderable (not display: none) so loading runs.
.frame-preload {
position: absolute;
inline-size: 0;
block-size: 0;
overflow: hidden;
pointer-events: none;
}
// Size the framed photo to its final, contained dimensions up front width is
// capped by the available width (100%) and by the height limit scaled by the
// photo's aspect ratio — so the white frame never grows from a tiny box to the
// photo's size while the image is still loading. --frame-ratio is set per photo.
.frame-figure {
min-width: 0;
margin: 0;
display: grid;
justify-items: center;
inline-size: min(100%, calc((100dvh - 5rem) * var(--frame-ratio, 1)));
}
.frame-figure picture {
display: grid;
place-items: center;
min-width: 0;
display: block;
}
.frame-figure img {
inline-size: auto;
max-inline-size: 100%;
inline-size: 100%;
block-size: auto;
max-block-size: calc(100dvh - 5rem);
border: var(--border-width) solid var(--color-text);
background: var(--color-text);
@ -257,6 +299,10 @@ img {
padding: var(--space-s);
}
.frame-figure {
inline-size: min(100%, calc((100dvh - 12rem) * var(--frame-ratio, 1)));
}
.frame-figure img {
max-block-size: calc(100dvh - 12rem);
}

View file

@ -109,4 +109,67 @@ describe('PhotoGallery', () => {
expect(frame.querySelector('img')?.alt).toBe('Second photo');
});
it('preloads the next photo off-screen without duplicating the frame id', () => {
const { gallery, frame, toggle } = setup();
new PhotoGallery({ gallery, frame, toggle, autoAdvance: false });
const preload = document.querySelector('.frame-preload img');
expect(preload?.getAttribute('src')).toBe('static/photos/two-2400.jpg');
// The off-screen copy must not steal the displayed image's id.
expect(preload?.id).toBe('');
expect(document.querySelectorAll('#frame-image')).toHaveLength(0);
});
it('moves the preload to the next photo as the selection advances', () => {
const { gallery, frame, toggle } = setup();
new PhotoGallery({ gallery, frame, toggle, autoAdvance: false });
gallery
.querySelector<HTMLAnchorElement>('[data-photo-id="two"]')
?.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true })
);
// Showing 'two' leaves exactly one framed image and preloads the wrap-around.
expect(document.querySelectorAll('#frame-image')).toHaveLength(1);
expect(
document.querySelector('.frame-preload img')?.getAttribute('src')
).toBe('static/photos/one-2400.jpg');
});
it('sets the aspect ratio so the frame reserves the photo size before it loads', () => {
const { gallery, frame, toggle } = setup();
new PhotoGallery({ gallery, frame, toggle, autoAdvance: false });
gallery
.querySelector<HTMLAnchorElement>('[data-photo-id="one"]')
?.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true })
);
// Thumbnail markup is 320x240, so the figure carries that ratio for CSS.
const figure = frame.querySelector<HTMLElement>('.frame-figure');
expect(
parseFloat(figure?.style.getPropertyValue('--frame-ratio') ?? '')
).toBeCloseTo(320 / 240, 4);
});
it('shows a loading animation for a user load and clears it when ready', () => {
const { gallery, frame, toggle } = setup();
new PhotoGallery({ gallery, frame, toggle, autoAdvance: false });
// 'one' is not the preloaded photo, so clicking it builds a fresh figure
// whose image is still loading.
gallery
.querySelector<HTMLAnchorElement>('[data-photo-id="one"]')
?.dispatchEvent(
new MouseEvent('click', { bubbles: true, cancelable: true })
);
expect(frame.classList.contains('is-loading')).toBe(true);
frame.querySelector('img')?.dispatchEvent(new Event('load'));
expect(frame.classList.contains('is-loading')).toBe(false);
});
});

View file

@ -63,13 +63,21 @@ const createFrameFigure = (thumbnail: HTMLAnchorElement): HTMLElement => {
element.setAttribute('sizes', sizes);
}
image.id = 'frame-image';
// The `frame-image` id is assigned by showFigure when the figure is placed in
// the frame, so a figure waiting in the preload holder never duplicates it.
if (thumbnail.dataset.frameSrc) {
image.src = thumbnail.dataset.frameSrc;
}
image.removeAttribute('loading');
image.decoding = 'async';
// Reserve the photo's final size before it loads (see --frame-ratio in CSS).
const ratio =
Number(image.getAttribute('width')) / Number(image.getAttribute('height'));
if (Number.isFinite(ratio) && ratio > 0) {
figure.style.setProperty('--frame-ratio', String(ratio));
}
figure.append(picture);
return figure;
@ -78,7 +86,9 @@ const createFrameFigure = (thumbnail: HTMLAnchorElement): HTMLElement => {
export class PhotoGallery {
private selectedIndex = 0;
private timerId: number | null = null;
private preloaded: { index: number; figure: HTMLElement } | null = null;
private readonly thumbnails: HTMLAnchorElement[];
private readonly preloadHolder = document.createElement('div');
private readonly reducedMotion = window.matchMedia(
'(prefers-reduced-motion: reduce)'
);
@ -92,11 +102,18 @@ export class PhotoGallery {
throw new Error('The gallery needs at least one photo.');
}
// Off-screen holder that warms the next photo's image so advancing to it is
// instant; see preloadNext.
this.preloadHolder.className = 'frame-preload';
this.preloadHolder.setAttribute('aria-hidden', 'true');
document.body.append(this.preloadHolder);
this.addEventListeners();
// 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);
this.preloadNext();
if (this.options.autoAdvance !== false && !this.reducedMotion.matches) {
this.startAutoAdvance();
@ -201,7 +218,11 @@ export class PhotoGallery {
throw new Error('Selected photo is missing.');
}
this.options.frame.replaceChildren(createFrameFigure(thumbnail));
// Auto-advance shows the preloaded figure instantly; a user-initiated jump
// (which carries a source) may need a download, so it gets the spinner.
this.showFigure(this.takeFigure(this.selectedIndex, thumbnail), {
loading: options.source !== undefined,
});
this.updateSelectedThumbnail(
this.selectedIndex,
options.focusThumbnail ?? false
@ -219,6 +240,63 @@ export class PhotoGallery {
source: options.source,
});
}
this.preloadNext();
}
// Reuse the figure preloaded for this index when we have it — the browser has
// already fetched its image — otherwise build a fresh one.
private takeFigure(index: number, thumbnail: HTMLAnchorElement): HTMLElement {
if (this.preloaded?.index === index) {
const { figure } = this.preloaded;
this.preloaded = null;
return figure;
}
return createFrameFigure(thumbnail);
}
private showFigure(figure: HTMLElement, options: { loading: boolean }): void {
const image = figure.querySelector('img');
if (image) {
image.id = 'frame-image';
}
this.options.frame.replaceChildren(figure);
// Show a spinner only while a not-yet-loaded image arrives; a preloaded
// (already complete) image is displayed immediately, with no flash.
if (options.loading && image && !image.complete) {
this.options.frame.classList.add('is-loading');
const clear = (): void =>
this.options.frame.classList.remove('is-loading');
image.addEventListener('load', clear, { once: true });
image.addEventListener('error', clear, { once: true });
} else {
this.options.frame.classList.remove('is-loading');
}
}
// Build the photo the slideshow will advance to next and park it off-screen
// so the browser downloads it before we need to show it.
private preloadNext(): void {
if (this.thumbnails.length <= 1) {
return;
}
const nextIndex = wrapIndex(this.selectedIndex + 1, this.thumbnails.length);
if (this.preloaded?.index === nextIndex) {
return;
}
const thumbnail = this.thumbnails[nextIndex];
if (!thumbnail) {
return;
}
const figure = createFrameFigure(thumbnail);
this.preloaded = { index: nextIndex, figure };
this.preloadHolder.replaceChildren(figure);
}
private updateSelectedThumbnail(index: number, shouldFocus: boolean): void {