Add loading animatino
This commit is contained in:
parent
9272fd43a5
commit
00fa298dd5
4 changed files with 201 additions and 12 deletions
|
|
@ -116,7 +116,9 @@ const renderThumbnail = (photo, isCurrent) => {
|
||||||
</a>`;
|
</a>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderFrame = (photo) => `<figure class="frame-figure">
|
const renderFrame = (
|
||||||
|
photo
|
||||||
|
) => `<figure class="frame-figure" style="--frame-ratio:${photo.aspectRatio}">
|
||||||
${renderPicture(
|
${renderPicture(
|
||||||
photo,
|
photo,
|
||||||
frameSizes(photo),
|
frameSizes(photo),
|
||||||
|
|
|
||||||
|
|
@ -144,25 +144,67 @@ img {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
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 {
|
.frame-figure {
|
||||||
min-width: 0;
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: grid;
|
inline-size: min(100%, calc((100dvh - 5rem) * var(--frame-ratio, 1)));
|
||||||
justify-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.frame-figure picture {
|
.frame-figure picture {
|
||||||
display: grid;
|
display: block;
|
||||||
place-items: center;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.frame-figure img {
|
.frame-figure img {
|
||||||
inline-size: auto;
|
inline-size: 100%;
|
||||||
max-inline-size: 100%;
|
block-size: auto;
|
||||||
max-block-size: calc(100dvh - 5rem);
|
max-block-size: calc(100dvh - 5rem);
|
||||||
border: var(--border-width) solid var(--color-text);
|
border: var(--border-width) solid var(--color-text);
|
||||||
background: var(--color-text);
|
background: var(--color-text);
|
||||||
|
|
@ -257,6 +299,10 @@ img {
|
||||||
padding: var(--space-s);
|
padding: var(--space-s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.frame-figure {
|
||||||
|
inline-size: min(100%, calc((100dvh - 12rem) * var(--frame-ratio, 1)));
|
||||||
|
}
|
||||||
|
|
||||||
.frame-figure img {
|
.frame-figure img {
|
||||||
max-block-size: calc(100dvh - 12rem);
|
max-block-size: calc(100dvh - 12rem);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -109,4 +109,67 @@ describe('PhotoGallery', () => {
|
||||||
|
|
||||||
expect(frame.querySelector('img')?.alt).toBe('Second photo');
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -63,13 +63,21 @@ const createFrameFigure = (thumbnail: HTMLAnchorElement): HTMLElement => {
|
||||||
element.setAttribute('sizes', sizes);
|
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) {
|
if (thumbnail.dataset.frameSrc) {
|
||||||
image.src = thumbnail.dataset.frameSrc;
|
image.src = thumbnail.dataset.frameSrc;
|
||||||
}
|
}
|
||||||
image.removeAttribute('loading');
|
image.removeAttribute('loading');
|
||||||
image.decoding = 'async';
|
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);
|
figure.append(picture);
|
||||||
|
|
||||||
return figure;
|
return figure;
|
||||||
|
|
@ -78,7 +86,9 @@ const createFrameFigure = (thumbnail: HTMLAnchorElement): HTMLElement => {
|
||||||
export class PhotoGallery {
|
export class PhotoGallery {
|
||||||
private selectedIndex = 0;
|
private selectedIndex = 0;
|
||||||
private timerId: number | null = null;
|
private timerId: number | null = null;
|
||||||
|
private preloaded: { index: number; figure: HTMLElement } | null = null;
|
||||||
private readonly thumbnails: HTMLAnchorElement[];
|
private readonly thumbnails: HTMLAnchorElement[];
|
||||||
|
private readonly preloadHolder = document.createElement('div');
|
||||||
private readonly reducedMotion = window.matchMedia(
|
private readonly reducedMotion = window.matchMedia(
|
||||||
'(prefers-reduced-motion: reduce)'
|
'(prefers-reduced-motion: reduce)'
|
||||||
);
|
);
|
||||||
|
|
@ -92,11 +102,18 @@ export class PhotoGallery {
|
||||||
throw new Error('The gallery needs at least one photo.');
|
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();
|
this.addEventListeners();
|
||||||
|
|
||||||
// The frame for photo 0 is already server-rendered, so just mark the
|
// The frame for photo 0 is already server-rendered, so just mark the
|
||||||
// selected thumbnail; rebuilding it would discard the in-flight LCP image.
|
// selected thumbnail; rebuilding it would discard the in-flight LCP image.
|
||||||
this.updateSelectedThumbnail(0, false);
|
this.updateSelectedThumbnail(0, false);
|
||||||
|
this.preloadNext();
|
||||||
|
|
||||||
if (this.options.autoAdvance !== false && !this.reducedMotion.matches) {
|
if (this.options.autoAdvance !== false && !this.reducedMotion.matches) {
|
||||||
this.startAutoAdvance();
|
this.startAutoAdvance();
|
||||||
|
|
@ -201,7 +218,11 @@ export class PhotoGallery {
|
||||||
throw new Error('Selected photo is missing.');
|
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.updateSelectedThumbnail(
|
||||||
this.selectedIndex,
|
this.selectedIndex,
|
||||||
options.focusThumbnail ?? false
|
options.focusThumbnail ?? false
|
||||||
|
|
@ -219,6 +240,63 @@ export class PhotoGallery {
|
||||||
source: options.source,
|
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 {
|
private updateSelectedThumbnail(index: number, shouldFocus: boolean): void {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue