From d8ab7f11084fb6929515f4aeb322c7288ba8b6c8 Mon Sep 17 00:00:00 2001 From: Andrea Lisi Date: Wed, 23 Sep 2026 19:25:48 +0200 Subject: [PATCH] fix: preview Deepfield screenshots in an accessible dialog --- src/components/projects.astro | 20 +++++++- src/components/projects.css | 70 ++++++++++++++++++++++++++- src/i18n/en.ts | 6 +++ src/i18n/it.ts | 6 +++ src/i18n/types.ts | 1 + src/scripts/image-preview.ts | 52 +++++++++++++++++++++ tests/e2e/homepage.spec.ts | 6 +-- tests/e2e/image-preview.spec.ts | 83 +++++++++++++++++++++++++++++++++ 8 files changed, 237 insertions(+), 7 deletions(-) create mode 100644 src/scripts/image-preview.ts create mode 100644 tests/e2e/image-preview.spec.ts diff --git a/src/components/projects.astro b/src/components/projects.astro index c5af118..23835a7 100644 --- a/src/components/projects.astro +++ b/src/components/projects.astro @@ -34,10 +34,10 @@ const head = copy.sections.projects; {project.gallery && ( )} @@ -53,3 +53,19 @@ const head = copy.sections.projects; ))} + + +
+ +
+
+
+ +
+

+
+ + diff --git a/src/components/projects.css b/src/components/projects.css index 6379eb7..26141e1 100644 --- a/src/components/projects.css +++ b/src/components/projects.css @@ -57,15 +57,81 @@ display: grid; gap: 18px; } -.projects .project-gallery a { +.projects .preview-trigger { display: block; + width: 100%; + padding: 0; + border: 0; + background: none; + font-family: inherit; + text-align: left; color: var(--acc); font-size: 13px; - text-underline-offset: 3px; + cursor: zoom-in; +} +.projects .preview-trigger:disabled { + cursor: default; } .projects .project-gallery span { display: inline-block; margin-top: 7px; + text-decoration: underline; + text-underline-offset: 3px; +} +.image-preview-open { + overflow: hidden; +} +.image-preview { + width: min(1500px, calc(100% - 32px)); + max-width: none; + max-height: calc(100dvh - 32px); + padding: 16px; + border: 1px solid var(--line); + border-radius: 5px; + background: var(--bg); + color: var(--body); +} +.image-preview::backdrop { + background: rgb(0 0 0 / 85%); +} +.image-preview-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} +.image-preview-toolbar button { + min-height: 44px; + padding: 8px 12px; + border: 1px solid var(--line); + border-radius: 4px; + background: var(--panel); + color: var(--acc); + font: inherit; + cursor: pointer; +} +.image-preview-viewport { + max-height: max(100px, calc(100dvh - 210px)); + overflow: auto; + overscroll-behavior: contain; +} +.image-preview .image-preview-viewport img { + display: block; + width: auto; + height: auto; + max-width: 100%; + max-height: max(100px, calc(100dvh - 210px)); + margin-inline: auto; +} +.image-preview.is-zoomed img { + max-width: none; + max-height: none; +} +.image-preview-caption { + margin: 12px 0 0; + font-size: 13px; + line-height: 1.5; } .projects .command-preview { border: 1px solid var(--line); diff --git a/src/i18n/en.ts b/src/i18n/en.ts index 7d48475..85dc836 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -216,6 +216,12 @@ export const en: Copy = { }, ], + imagePreview: { + title: 'Image preview', + close: 'Close', + zoom: 'Original size', + fit: 'Fit to screen', + }, projects: [ { name: 'Lumina', diff --git a/src/i18n/it.ts b/src/i18n/it.ts index 996ac29..f05d74c 100644 --- a/src/i18n/it.ts +++ b/src/i18n/it.ts @@ -224,6 +224,12 @@ export const it: Copy = { }, ], + imagePreview: { + title: 'Anteprima immagine', + close: 'Chiudi', + zoom: 'Dimensione originale', + fit: 'Adatta allo schermo', + }, projects: [ { name: 'Lumina', diff --git a/src/i18n/types.ts b/src/i18n/types.ts index 8e60140..5e31601 100644 --- a/src/i18n/types.ts +++ b/src/i18n/types.ts @@ -138,6 +138,7 @@ export type Copy = { work: WorkEntry[]; projects: ProjectEntry[]; + imagePreview: { title: string; close: string; zoom: string; fit: string }; /** Small print at the very bottom. */ footer: { diff --git a/src/scripts/image-preview.ts b/src/scripts/image-preview.ts new file mode 100644 index 0000000..cfe404d --- /dev/null +++ b/src/scripts/image-preview.ts @@ -0,0 +1,52 @@ +/** Native dialog provides modal focus containment, background inertness and Escape. */ +export function initImagePreview(): void { + const dialog = document.querySelector('#project-image-preview'); + const image = dialog?.querySelector('[data-preview-image]'); + const caption = dialog?.querySelector('[data-preview-caption]'); + const zoom = dialog?.querySelector('[data-preview-zoom]'); + const viewport = dialog?.querySelector('.image-preview-viewport'); + if (!dialog || !image || !caption || !zoom || !viewport) return; + let opener: HTMLButtonElement | undefined; + + const setZoom = (expanded: boolean): void => { + dialog.classList.toggle('is-zoomed', expanded); + zoom.setAttribute('aria-pressed', String(expanded)); + zoom.textContent = (expanded ? zoom.dataset.fitLabel : zoom.dataset.zoomLabel) ?? ''; + viewport.scrollTo(0, 0); + }; + + for (const trigger of document.querySelectorAll('[data-preview-open]')) { + const thumbnail = trigger.querySelector('img'); + if (!thumbnail) continue; + // With scripting disabled the screenshots remain visible, without dead controls. + trigger.disabled = false; + trigger.addEventListener('click', () => { + opener = trigger; + image.src = thumbnail.src; + image.alt = thumbnail.alt; + image.width = Number(thumbnail.getAttribute('width')); + image.height = Number(thumbnail.getAttribute('height')); + caption.textContent = thumbnail.alt; + setZoom(false); + dialog.showModal(); + document.documentElement.classList.add('image-preview-open'); + }); + } + zoom.addEventListener('click', () => setZoom(zoom.getAttribute('aria-pressed') !== 'true')); + dialog.addEventListener('click', (event) => { + if (event.target !== dialog) return; + const rect = dialog.getBoundingClientRect(); + if ( + event.clientX < rect.left || + event.clientX > rect.right || + event.clientY < rect.top || + event.clientY > rect.bottom + ) + dialog.close(); + }); + dialog.addEventListener('close', () => { + document.documentElement.classList.remove('image-preview-open'); + setZoom(false); + opener?.focus({ preventScroll: true }); + }); +} diff --git a/tests/e2e/homepage.spec.ts b/tests/e2e/homepage.spec.ts index 7fb045d..bcdc529 100644 --- a/tests/e2e/homepage.spec.ts +++ b/tests/e2e/homepage.spec.ts @@ -130,9 +130,9 @@ for (const locale of ['en', 'it'] as const) { ); } for (const shot of project.gallery ?? []) { - await expect(page.getByRole('link', { name: shot.label })).toHaveAttribute( - 'href', - shot.src, + await expect(page.getByRole('button', { name: shot.label })).toHaveAttribute( + 'aria-controls', + 'project-image-preview', ); } } diff --git a/tests/e2e/image-preview.spec.ts b/tests/e2e/image-preview.spec.ts new file mode 100644 index 0000000..7df1378 --- /dev/null +++ b/tests/e2e/image-preview.spec.ts @@ -0,0 +1,83 @@ +import AxeBuilder from '@axe-core/playwright'; +import { expect, test } from '@playwright/test'; +import { getCopy, hrefFor } from '../../src/i18n'; + +for (const locale of ['it', 'en'] as const) { + const copy = getCopy(locale); + const shots = copy.projects.find((project) => project.name === 'Deepfield')?.gallery ?? []; + for (const viewport of [ + { width: 1280, height: 900 }, + { width: 390, height: 844 }, + ]) { + test(`image preview ${locale} at ${viewport.width}px stays in-page and is keyboard accessible`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await page.emulateMedia({ reducedMotion: 'reduce' }); + let downloads = 0; + let popups = 0; + page.on('download', () => downloads++); + page.on('popup', () => popups++); + await page.goto(hrefFor(locale, 'home')); + const url = page.url(); + const dialog = page.getByRole('dialog', { name: copy.imagePreview.title }); + for (const shot of shots) { + const trigger = page.getByRole('button', { name: shot.label }); + await trigger.focus(); + await trigger.press('Enter'); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('img')).toHaveAttribute('src', new URL(shot.src, url).href); + await expect(dialog.locator('img')).toHaveAttribute('alt', shot.alt); + await expect + .poll(() => dialog.locator('img').evaluate((img: HTMLImageElement) => img.naturalWidth)) + .toBe(1600); + await expect(page.locator('html')).toHaveCSS('overflow', 'hidden'); + const zoom = dialog.locator('[data-preview-zoom]'); + await zoom.click(); + await expect(zoom).toHaveAttribute('aria-pressed', 'true'); + await expect(dialog.locator('img')).toHaveCSS('width', '1600px'); + await dialog.getByRole('button', { name: copy.imagePreview.fit }).click(); + const bounds = await dialog.boundingBox(); + expect(bounds).not.toBeNull(); + expect(bounds?.x ?? -1).toBeGreaterThanOrEqual(0); + expect(bounds?.width ?? Infinity).toBeLessThanOrEqual(viewport.width); + await dialog.getByRole('button', { name: copy.imagePreview.close }).focus(); + await page.keyboard.press('Tab'); + expect(await dialog.evaluate((el) => el.contains(document.activeElement))).toBe(true); + await page.keyboard.press('Escape'); + await expect(dialog).not.toBeVisible(); + await expect(trigger).toBeFocused(); + await expect(page.locator('html')).not.toHaveClass(/image-preview-open/); + } + const first = page.getByRole('button', { name: shots[0]?.label }); + await first.click(); + await expect(dialog).toBeVisible(); + const results = await new AxeBuilder({ page }) + .withTags(['wcag2a', 'wcag2aa', 'wcag21aa']) + .analyze(); + expect( + results.violations.filter((v) => v.impact === 'critical' || v.impact === 'serious'), + ).toEqual([]); + await dialog.getByRole('button', { name: copy.imagePreview.close }).click(); + await expect(first).toBeFocused(); + await first.click(); + await page.mouse.click(2, 2); + await expect(dialog).not.toBeVisible(); + await expect(first).toBeFocused(); + expect(page.url()).toBe(url); + expect(downloads).toBe(0); + expect(popups).toBe(0); + }); + } + test(`screenshots remain visible without JavaScript (${locale})`, async ({ browser }) => { + const context = await browser.newContext({ javaScriptEnabled: false }); + const page = await context.newPage(); + await page.goto(hrefFor(locale, 'home')); + for (const shot of shots) { + const trigger = page.getByRole('button', { name: shot.label }); + await expect(trigger).toBeDisabled(); + await expect(trigger.locator('img')).toBeVisible(); + } + await context.close(); + }); +}