From 857007103be2b38a602547d5cd08c25ca7a8d345 Mon Sep 17 00:00:00 2001 From: joeseverino Date: Thu, 18 Jun 2026 11:27:37 -0500 Subject: [PATCH] Use real buttons for lightbox triggers --- src/components/Lightbox.astro | 29 ++++++++++++++++------------ src/styles/base.css | 25 ++++++++++++++---------- tests/playwright/a11y.single.spec.ts | 2 +- tests/playwright/lightbox.spec.ts | 29 ++++++++++++++-------------- 4 files changed, 48 insertions(+), 37 deletions(-) diff --git a/src/components/Lightbox.astro b/src/components/Lightbox.astro index ce9bb85..5e4a635 100644 --- a/src/components/Lightbox.astro +++ b/src/components/Lightbox.astro @@ -21,19 +21,24 @@ let lastTrigger: HTMLElement | null = null; let restoreFocusOnClose = false; - // Mark eligible images once. The .zoomable class (added only when JS runs) - // is both the cursor affordance and the activation hook, so no-JS images - // never look or behave clickable. Skip anything opted out via data-no-zoom. + // Wrap eligible images in real buttons only when JS can open the dialog. + // No-JS images stay plain images. Skip anything opted out via data-no-zoom. for (const img of document.querySelectorAll('.prose img')) { if (img.closest('[data-no-zoom]')) continue; - img.classList.add('zoomable'); - img.tabIndex = 0; - img.setAttribute('role', 'button'); - img.setAttribute('aria-haspopup', 'dialog'); + const trigger = document.createElement('button'); + const media = img.closest('picture') ?? img; + trigger.className = 'image-zoom'; + trigger.type = 'button'; + trigger.setAttribute('aria-haspopup', 'dialog'); + trigger.setAttribute('aria-label', img.alt ? `Open image: ${img.alt}` : 'Open image'); + media.before(trigger); + trigger.append(media); } - const open = (img: HTMLImageElement, restoreFocus: boolean) => { - lastTrigger = img; + const open = (trigger: HTMLElement, restoreFocus: boolean) => { + const img = trigger.querySelector('img'); + if (!img) return; + lastTrigger = trigger; restoreFocusOnClose = restoreFocus; // Reuse the variant the browser already rendered and cached (currentSrc), // so the modal is instant rather than re-fetching the full-size fallback. @@ -58,7 +63,7 @@ document.addEventListener('click', (event) => { const target = event.target as HTMLElement; - const trigger = target.closest('.prose img.zoomable'); + const trigger = target.closest('.prose .image-zoom'); if (trigger) { open(trigger, false); return; @@ -74,9 +79,9 @@ document.addEventListener('keydown', (event) => { if (event.key !== 'Enter' && event.key !== ' ') return; const target = event.target as HTMLElement; - if (!target.matches('.prose img.zoomable')) return; + if (!target.matches('.prose .image-zoom')) return; event.preventDefault(); - open(target as HTMLImageElement, true); + open(target, true); }); dialog.addEventListener('close', () => { diff --git a/src/styles/base.css b/src/styles/base.css index 17c257c..2ebcfab 100644 --- a/src/styles/base.css +++ b/src/styles/base.css @@ -1563,9 +1563,15 @@ figure { /* Image lightbox: native opened with showModal() for a real modal (focus trap, Escape, focus return). */ -.prose img.zoomable { +.prose .image-zoom { + display: block; + max-inline-size: 100%; + margin-inline: auto; + padding: 0; + border: 0; + background: transparent; + color: inherit; cursor: zoom-in; - -webkit-tap-highlight-color: transparent; &:focus:not(:focus-visible) { outline: none; @@ -1577,6 +1583,12 @@ figure { } } +.prose .image-zoom, +.lightbox-img, +.lightbox-close { + -webkit-tap-highlight-color: transparent; +} + .lightbox { max-inline-size: min(92vw, 1400px); max-block-size: 92vh; @@ -1602,27 +1614,21 @@ figure { .lightbox-img { display: block; - max-inline-size: 100%; max-block-size: 82vh; - inline-size: auto; - block-size: auto; object-fit: contain; border-radius: var(--radius-sm); box-shadow: var(--shadow-md); cursor: zoom-out; - -webkit-tap-highlight-color: transparent; } .lightbox-caption { - max-inline-size: min(92vw, 1400px); + max-inline-size: 100%; margin: 0; color: var(--color-text); font-size: var(--font-2xs); line-height: 1.5; text-align: center; text-wrap: balance; - -webkit-user-select: text; - user-select: text; } .lightbox-close { @@ -1642,7 +1648,6 @@ figure { font-size: var(--font-md); line-height: 1; cursor: pointer; - -webkit-tap-highlight-color: transparent; -webkit-user-select: none; user-select: none; transition: background var(--motion-duration-fast) var(--motion-ease-standard); diff --git a/tests/playwright/a11y.single.spec.ts b/tests/playwright/a11y.single.spec.ts index 5d3db78..bc27501 100644 --- a/tests/playwright/a11y.single.spec.ts +++ b/tests/playwright/a11y.single.spec.ts @@ -37,7 +37,7 @@ for (const path of pages) { test('axe finds no WCAG A/AA violations with the lightbox open', async ({ page }) => { await page.goto(imageHeavyWriteup(), { waitUntil: 'load' }); - await page.locator('.prose img.zoomable').first().click(); + await page.locator('.prose .image-zoom').first().click(); await expect(page.locator('dialog.lightbox')).toBeVisible(); expect(summarize(await scan(page))).toEqual([]); diff --git a/tests/playwright/lightbox.spec.ts b/tests/playwright/lightbox.spec.ts index af5f8b3..84909a7 100644 --- a/tests/playwright/lightbox.spec.ts +++ b/tests/playwright/lightbox.spec.ts @@ -6,10 +6,11 @@ const WRITEUP = imageHeavyWriteup(); test.describe('figure lightbox', () => { test('clicking a body figure opens the modal and locks scroll', async ({ page }) => { await page.goto(WRITEUP); - const img = page.locator('.prose img.zoomable').first(); - await expect(img).toHaveAttribute('role', 'button'); + const trigger = page.locator('.prose .image-zoom').first(); + await expect(trigger).toHaveAttribute('type', 'button'); + await expect(trigger).toHaveAttribute('aria-haspopup', 'dialog'); - await img.click(); + await trigger.click(); const dialog = page.locator('dialog.lightbox'); await expect(dialog).toBeVisible(); @@ -19,8 +20,8 @@ test.describe('figure lightbox', () => { test('pointer open and close do not leave visible focus outlines', async ({ page }) => { await page.goto(WRITEUP); - const img = page.locator('.prose img.zoomable').first(); - await img.click(); + const trigger = page.locator('.prose .image-zoom').first(); + await trigger.click(); const dialog = page.locator('dialog.lightbox'); await expect(dialog).toBeVisible(); @@ -30,13 +31,13 @@ test.describe('figure lightbox', () => { await page.getByRole('button', { name: 'Close' }).click(); await expect(dialog).toBeHidden(); await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden'); - await expect(img).not.toBeFocused(); + await expect(trigger).not.toBeFocused(); }); test('keyboard close returns focus to the trigger', async ({ page }) => { await page.goto(WRITEUP); - const img = page.locator('.prose img.zoomable').first(); - await img.focus(); + const trigger = page.locator('.prose .image-zoom').first(); + await trigger.focus(); await page.keyboard.press('Enter'); const dialog = page.locator('dialog.lightbox'); @@ -44,20 +45,20 @@ test.describe('figure lightbox', () => { await page.keyboard.press('Escape'); await expect(dialog).toBeHidden(); - await expect(img).toBeFocused(); + await expect(trigger).toBeFocused(); }); test('closes via the close button and the backdrop', async ({ page }) => { await page.goto(WRITEUP); - const img = page.locator('.prose img.zoomable').first(); + const trigger = page.locator('.prose .image-zoom').first(); const dialog = page.locator('dialog.lightbox'); - await img.click(); + await trigger.click(); await expect(dialog).toBeVisible(); await page.getByRole('button', { name: 'Close' }).click(); await expect(dialog).toBeHidden(); - await img.click(); + await trigger.click(); await expect(dialog).toBeVisible(); // A click anywhere in the overlay outside the caption dismisses it. await page.mouse.click(5, 5); @@ -66,8 +67,8 @@ test.describe('figure lightbox', () => { test('opens via the keyboard on a focused figure', async ({ page }) => { await page.goto(WRITEUP); - const img = page.locator('.prose img.zoomable').first(); - await img.focus(); + const trigger = page.locator('.prose .image-zoom').first(); + await trigger.focus(); await page.keyboard.press('Enter'); await expect(page.locator('dialog.lightbox')).toBeVisible();