From 98f15ee3bc157a31378e61b2d6bc6a4a32733779 Mon Sep 17 00:00:00 2001 From: kOaDT Date: Sat, 6 Jun 2026 04:08:21 +0200 Subject: [PATCH] feat: add accessible image lightbox for post images (#654) * feat: add accessible image lightbox for post images * fix: iOS scroll lock, backdrop blur, dvh image sizing for lightbox * feat: add vanilla pinch-to-zoom to image lightbox * fix: improve touch event handling and scaling logic in image lightbox --------- Co-authored-by: satnaing --- src/pages/posts/[...slug]/index.astro | 266 ++++++++++++++++++++++++++ src/styles/typography.css | 8 + 2 files changed, 274 insertions(+) diff --git a/src/pages/posts/[...slug]/index.astro b/src/pages/posts/[...slug]/index.astro index d9df037..9bfd35d 100644 --- a/src/pages/posts/[...slug]/index.astro +++ b/src/pages/posts/[...slug]/index.astro @@ -260,6 +260,272 @@ const ogImage = ogImageUrl } attachCopyButtons(); + /** Accessible image lightbox for article images. */ + function initLightbox() { + const article = document.getElementById("article"); + if (!article) return; + + const prefersReducedMotion = () => + window.matchMedia("(prefers-reduced-motion: reduce)").matches; + + let overlay = null; + let lastFocused = null; + + // Defer attribute mutations so they don't push the LCP timestamp. + // Event listeners below use delegation and don't need the attributes to exist yet. + requestAnimationFrame(() => { + const images = Array.from(article.querySelectorAll("img")); + for (const image of images) { + if (image.closest("a")) continue; + image.setAttribute("role", "button"); + image.setAttribute("tabindex", "0"); + image.setAttribute("aria-haspopup", "dialog"); + image.setAttribute( + "aria-label", + image.alt ? `Zoom image: ${image.alt}` : "Zoom image" + ); + } + }); + + function open(src, alt, trigger) { + if (overlay) return; + lastFocused = trigger ?? document.activeElement; + + overlay = document.createElement("div"); + overlay.setAttribute("role", "dialog"); + overlay.setAttribute("aria-modal", "true"); + overlay.setAttribute( + "aria-label", + alt ? `Image preview: ${alt}` : "Image preview" + ); + overlay.className = + "fixed inset-0 z-50 flex cursor-zoom-out items-center justify-center bg-black/70 backdrop-blur-sm opacity-0 transition-opacity duration-200 motion-reduce:transition-none"; + + const closeButton = document.createElement("button"); + closeButton.type = "button"; + closeButton.setAttribute("aria-label", "Close image preview"); + closeButton.className = + "absolute end-4 top-4 rounded p-2 text-3xl leading-none text-white"; + closeButton.innerHTML = "✕"; + closeButton.addEventListener("click", close); + + const image = document.createElement("img"); + image.src = src; + image.alt = ""; + image.className = + "max-h-[90dvh] max-w-[90dvw] cursor-default object-contain"; + + overlay.append(closeButton, image); + overlay.addEventListener("click", e => { + if (e.target === overlay && currentScale <= 1) close(); + }); + + let currentScale = 1; + let translateX = 0; + let translateY = 0; + let initialDist = 0; + let initialScale = 1; + let panStartX = 0; + let panStartY = 0; + let panStartTranslateX = 0; + let panStartTranslateY = 0; + let lastTapTime = 0; + + function applyTransform() { + image.style.transform = `scale(${currentScale}) translate(${translateX}px, ${translateY}px)`; + } + + function resetTransform() { + currentScale = 1; + translateX = 0; + translateY = 0; + image.style.transform = ""; + } + + overlay.addEventListener( + "touchstart", + e => { + const t = e.touches; + if (t.length === 2) { + initialDist = Math.hypot( + t[1].clientX - t[0].clientX, + t[1].clientY - t[0].clientY + ); + initialScale = currentScale; + } else if (t.length === 1) { + const now = Date.now(); + if (now - lastTapTime < 300) { + e.preventDefault(); + if (currentScale > 1) { + resetTransform(); + } else { + currentScale = 2; + translateX = 0; + translateY = 0; + applyTransform(); + } + lastTapTime = 0; + panStartX = t[0].clientX; + panStartY = t[0].clientY; + panStartTranslateX = translateX; + panStartTranslateY = translateY; + } else { + lastTapTime = now; + if (currentScale > 1) { + panStartX = t[0].clientX; + panStartY = t[0].clientY; + panStartTranslateX = translateX; + panStartTranslateY = translateY; + } + } + } + }, + { passive: false } + ); + + overlay.addEventListener( + "touchmove", + e => { + const t = e.touches; + if (t.length === 2) { + e.preventDefault(); + const dist = Math.hypot( + t[1].clientX - t[0].clientX, + t[1].clientY - t[0].clientY + ); + currentScale = Math.min( + 4, + Math.max(1, initialScale * (dist / initialDist)) + ); + applyTransform(); + } else if (t.length === 1) { + if (currentScale > 1) { + e.preventDefault(); + translateX = + panStartTranslateX + (t[0].clientX - panStartX) / currentScale; + translateY = + panStartTranslateY + (t[0].clientY - panStartY) / currentScale; + const maxX = Math.max( + 0, + (image.clientWidth - overlay.clientWidth / currentScale) / 2 + ); + const maxY = Math.max( + 0, + (image.clientHeight - overlay.clientHeight / currentScale) / 2 + ); + translateX = Math.min(maxX, Math.max(-maxX, translateX)); + translateY = Math.min(maxY, Math.max(-maxY, translateY)); + applyTransform(); + } else { + e.preventDefault(); + } + } + }, + { passive: false } + ); + + overlay.addEventListener("touchend", e => { + if (e.touches.length === 0 && currentScale <= 1.05) { + resetTransform(); + } + }); + + overlay.addEventListener("touchcancel", e => { + if (e.touches.length === 0 && currentScale <= 1.05) { + resetTransform(); + } + }); + + document.body.appendChild(overlay); + document.body.style.overflow = "hidden"; + document.addEventListener("keydown", onKeyDown); + window.__closeLightbox = close; + + requestAnimationFrame(() => overlay?.classList.add("opacity-100")); + closeButton.focus(); + } + + function close() { + if (!overlay) return; + const el = overlay; + overlay = null; + window.__closeLightbox = null; + + document.removeEventListener("keydown", onKeyDown); + document.body.style.overflow = ""; + lastFocused?.focus(); + lastFocused = null; + + if (prefersReducedMotion()) { + el.remove(); + return; + } + const remove = () => el.remove(); + el.addEventListener("transitionend", remove, { once: true }); + setTimeout(remove, 250); // fallback in case transitionend never fires + el.classList.remove("opacity-100"); + } + + function onKeyDown(e) { + if (e.key === "Escape") { + close(); + } else if (e.key === "Tab") { + trapFocus(e); + } + } + + // Keep keyboard focus inside the open dialog. + function trapFocus(e) { + if (!overlay) return; + const focusables = overlay.querySelectorAll( + 'a[href], button, [tabindex]:not([tabindex="-1"])' + ); + if (focusables.length === 0) return; + const first = focusables[0]; + const last = focusables[focusables.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + } + + function triggerFromEvent(e) { + const image = e.target.closest("img"); + if (!image || !article.contains(image) || image.closest("a")) return null; + return image; + } + + function activate(image) { + open(image.currentSrc || image.src, image.alt, image); + } + + article.addEventListener("click", e => { + const image = triggerFromEvent(e); + if (!image) return; + e.preventDefault(); + activate(image); + }); + + article.addEventListener("keydown", e => { + if (e.key !== "Enter" && e.key !== " " && e.key !== "Spacebar") return; + const image = triggerFromEvent(e); + if (!image) return; + e.preventDefault(); + activate(image); + }); + } + initLightbox(); + + if (!window.__lightboxSwapBound) { + window.__lightboxSwapBound = true; + document.addEventListener("astro:before-swap", () => + window.__closeLightbox?.() + ); + } + document.addEventListener("astro:after-swap", () => window.scrollTo({ left: 0, top: 0, behavior: "instant" }) ); diff --git a/src/styles/typography.css b/src/styles/typography.css index 442c2ab..e55b5c2 100644 --- a/src/styles/typography.css +++ b/src/styles/typography.css @@ -47,6 +47,14 @@ @apply border-border mx-auto border; } + img[role="button"] { + @apply cursor-zoom-in; + + &:focus-visible { + @apply outline-2 outline-offset-2 outline-dashed; + } + } + figcaption { @apply opacity-75; }