From 00fa298dd5ae992769778b2ba4b3cb379fc145a9 Mon Sep 17 00:00:00 2001 From: Andras Schmelczer Date: Tue, 2 Jun 2026 19:56:35 +0100 Subject: [PATCH] Add loading animatino --- scripts/generate-assets.mjs | 4 +- src/index.scss | 64 +++++++++++++++++++++++++---- src/photos.test.ts | 63 ++++++++++++++++++++++++++++ src/photos.ts | 82 ++++++++++++++++++++++++++++++++++++- 4 files changed, 201 insertions(+), 12 deletions(-) diff --git a/scripts/generate-assets.mjs b/scripts/generate-assets.mjs index a0ca84d..798c20b 100644 --- a/scripts/generate-assets.mjs +++ b/scripts/generate-assets.mjs @@ -116,7 +116,9 @@ const renderThumbnail = (photo, isCurrent) => { `; }; -const renderFrame = (photo) => `
+const renderFrame = ( + photo +) => `
${renderPicture( photo, frameSizes(photo), diff --git a/src/index.scss b/src/index.scss index 18a1066..00d91e4 100644 --- a/src/index.scss +++ b/src/index.scss @@ -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 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); } diff --git a/src/photos.test.ts b/src/photos.test.ts index febfe97..5056ef0 100644 --- a/src/photos.test.ts +++ b/src/photos.test.ts @@ -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('[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('[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('.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('[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); + }); }); diff --git a/src/photos.ts b/src/photos.ts index c7c3115..dcf31c7 100644 --- a/src/photos.ts +++ b/src/photos.ts @@ -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 {