Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 159 additions & 14 deletions src/scripts/modules/figures.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,171 @@
import { qs, qsa } from './query.js';
import { qsa } from './query.js';

const activeClass = 'magnify-icon';
const inset = { wide: 50, narrow: 16 };
const duration = 400;

/** @type {(() => void) | null} */
let dismiss = null;

function stillFrames() {
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}

/**
* Where the image lands: its own aspect ratio, centred, never wider than the
* source really is.
*
* @param {HTMLImageElement} img
*/
function targetRect(img) {
const padding = window.innerWidth < 640 ? inset.narrow : inset.wide;
const room = {
width: window.innerWidth - padding * 2,
height: window.innerHeight - padding * 2,
};

const ratio = img.naturalWidth / img.naturalHeight;
const native = Number(img.getAttribute('width')) || img.naturalWidth;

let width = Math.min(room.width, room.height * ratio, native);
let height = width / ratio;

return {
left: (window.innerWidth - width) / 2,
top: (window.innerHeight - height) / 2,
width,
height,
};
}

/**
* @param {HTMLElement} node
* @param {{ left: number, top: number, width: number, height: number }} rect
*/
function place(node, rect) {
node.style.left = `${rect.left}px`;
node.style.top = `${rect.top}px`;
node.style.width = `${rect.width}px`;
node.style.height = `${rect.height}px`;
}

/**
* @param {HTMLImageElement} img
*/
function open(img) {
dismiss?.();

const overlay = document.createElement('div');
overlay.className = 'zoom-overlay';

const lightbox = document.createElement('div');
lightbox.className = 'zoom-lightbox';

const close = document.createElement('button');
close.type = 'button';
close.className = 'zoom-close';
close.textContent = '×';
close.setAttribute('aria-label', 'Close image');

const frame = document.createElement('div');
const clone = /** @type {HTMLImageElement} */ (img.cloneNode());

// src is the original. Every srcset entry is a variant generated for a
// column, capped at 2000px, so keep the clone off them.
clone.removeAttribute('srcset');
clone.removeAttribute('sizes');

frame.appendChild(clone);
lightbox.append(frame, close);
document.body.append(overlay, lightbox);

// Hiding the document's overflow would take the scrollbar away, widening
// the viewport and shifting the page out from under the fixed header.
const refuse = (/** @type {Event} */ event) => event.preventDefault();
lightbox.addEventListener('wheel', refuse, { passive: false });
lightbox.addEventListener('touchmove', refuse, { passive: false });

place(frame, img.getBoundingClientRect());
img.dataset.hidden = 'true';

if (stillFrames()) {
place(frame, targetRect(img));
} else {
frame.getBoundingClientRect();
requestAnimationFrame(() => {
frame.style.transition = `all ${duration}ms ease-out`;
place(frame, targetRect(img));
});
}

/** @param {KeyboardEvent} event */
const onKey = (event) => {
if (event.key === 'Escape') dismiss?.();
};

dismiss = () => {
dismiss = null;
document.removeEventListener('keydown', onKey);
overlay.classList.add('zoom-overlay--closing');

// Leaving it up for the length of the animation reads as a missed click
close.remove();

let closed = false;
const done = () => {
if (closed) return;
closed = true;

overlay.remove();
lightbox.remove();
delete img.dataset.hidden;
img.focus({ preventScroll: true });
};

if (stillFrames()) {
done();
return;
}

// Recomputed, because the keyboard can still scroll the page underneath
place(frame, img.getBoundingClientRect());
frame.addEventListener('transitionend', done, { once: true });
setTimeout(done, duration + 50);
};

overlay.addEventListener('click', () => dismiss?.());
lightbox.addEventListener('click', () => dismiss?.());
document.addEventListener('keydown', onKey);

// Focus would otherwise sit on the hidden image, sending the first Tab into
// the page behind
close.focus({ preventScroll: true });
}

/**
* Enables opening image in new tab
* Opens content images in a full-viewport lightbox. The interaction sits on
* the image itself, so the markup stays a plain <img> and tabindex keeps it
* reachable without a wrapper.
*/
function enhanceFigures() {
qsa(`figure > p > img, [data-image] > .image__img`).forEach((node) => {
const src = node.src;
qsa('figure img').forEach((img) => {
img.tabIndex = 0;

const magnify = document.createElement('button');
magnify.classList.add(activeClass);
magnify.title = 'Enlarge';
// Fetch the original before the click, so the zoom does not open on a
// stretched thumbnail
const warm = () => {
if (img.src !== img.currentSrc) new Image().src = img.src;
};

const magnifyContainer = document.createElement('div');
magnifyContainer.className = 'magnify-container';
magnifyContainer.appendChild(magnify);
img.addEventListener('pointerenter', warm, { once: true });
img.addEventListener('focus', warm, { once: true });
img.addEventListener('touchstart', warm, { once: true, passive: true });

node.insertAdjacentElement('beforebegin', magnifyContainer);
img.addEventListener('click', () => open(img));
img.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;

magnify.addEventListener('click', async () => {
window.open(src);
event.preventDefault();
open(img);
});
});
}
Expand Down
148 changes: 59 additions & 89 deletions src/styles/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -227,64 +227,82 @@ img {
background-color: var(--color-base-primary);
}

/* Image component */
.image {
border-radius: 0.9375rem;
border: 0.0625rem solid var(--navy-200);
background: var(--grey-lighter);
display: flex;
flex-direction: column;
gap: 1.56rem;
padding: 2rem;
position: relative;
}

@media (max-width: 930px) {
.image {
padding: 1rem;
}
}

.image .magnify-container {
position: absolute;
right: 0;
figure img {
display: block;
/* Only html is border-box, so without this the border pushes the image
2px past the max-width: 100% it inherits from the global img rule */
box-sizing: border-box;
margin-inline: auto;
border: var(--borderWidth1) solid var(--colorBorderPrimary);
border-radius: 0.5rem;
cursor: zoom-in;
}

.image__img {
border-radius: 0.6875rem;
box-shadow: 0px 4px 20px 0px rgba(0, 0, 0, 0.1);
width: fit-content;
margin: 0 auto;
figure img[data-hidden='true'] {
visibility: hidden;
}

.image__caption {
color: var(--color-heading);
font-size: var(--fontSizeBase);
font-weight: var(--fontWeight400);
text-align: start;
margin-block-start: 0.8125rem;
}

/* TODO: Once all images are used with Image component these styles should be removed */
figure > p > img {
display: flex;
margin: 0 auto;
border-radius: 0.9375rem;
.zoom-overlay {
position: fixed;
inset: 0;
z-index: 100;
background: var(--color-base-primary);
animation: zoom-fade 200ms ease-out;
}

figure:has(p > img) {
width: fit-content;
.zoom-overlay--closing {
animation: zoom-fade 200ms ease-out reverse forwards;
}

figure:has(p > img) p {
border: 0.0625rem solid var(--navy-200);
border-radius: 0.9375rem;
background: var(--grey-lighter);
padding: 2rem;
.zoom-lightbox {
position: fixed;
inset: 0;
z-index: 101;
cursor: zoom-out;
}

@media (max-width: 930px) {
figure:has(p > img) p {
padding: 1rem;
.zoom-lightbox > div {
position: absolute;
}

.zoom-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
width: 2.75rem;
height: 2.75rem;
border-radius: 50%;
background: none;
color: var(--color-heading);
font-size: 1.75rem;
line-height: 1;
cursor: pointer;
}

.zoom-close:hover,
.zoom-close:focus-visible {
background: var(--bg-color-menu);
}

.zoom-lightbox img {
box-sizing: border-box;
width: 100%;
height: 100%;
border: var(--borderWidth1) solid var(--colorBorderPrimary);
border-radius: 0.5rem;
}

@keyframes zoom-fade {
from {
opacity: 0;
}
}

Expand Down Expand Up @@ -2776,54 +2794,6 @@ a[data-youtube] {
cursor: pointer;
}

.magnify-container {
max-height: 0px;
margin: 0;
width: 100%;
text-align: end;
z-index: 1;
position: relative;
top: -1rem;
}

.magnify-icon {
opacity: 0;
border-radius: 0.2rem;
color: var(--body-link-color);
display: inline-block;
cursor: pointer;
background-color: var(--color-base-primary);
border-radius: 50%;
padding: 2px;
width: 2.6rem;
height: 2.6rem;
}

.input-touch .magnify-icon {
opacity: 1;
}

.magnify-icon:before {
/* fa-magnifying-glass-plus */
content: '\f00e';
font-family: fa-solid;
font-size: var(--fontSizeLarge);
line-height: 2rem;
}

figure:hover .magnify-icon,
figure:focus .magnify-icon {
opacity: 1;
}

.magnify-icon:hover,
.magnify-icon:focus,
.magnify-icon:focus-within {
stroke: var(--fore-link);
transform: rotate(4deg);
opacity: 1;
}

/* Custom Divisions */

.simple-grid {
Expand Down