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();
+ });
+}