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
20 changes: 18 additions & 2 deletions src/components/projects.astro
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,10 @@ const head = copy.sections.projects;
{project.gallery && (
<div class="project-gallery">
{project.gallery.map((shot) => (
<a href={shot.src} target="_blank" rel="noopener noreferrer" aria-label={shot.label}>
<button type="button" class="preview-trigger" data-preview-open aria-label={shot.label} aria-haspopup="dialog" aria-controls="project-image-preview" disabled>
<img src={shot.src} alt={shot.alt} width={shot.width} height={shot.height} loading="lazy" decoding="async" />
<span>{shot.label}</span>
</a>
</button>
))}
</div>
)}
Expand All @@ -53,3 +53,19 @@ const head = copy.sections.projects;
))}
</div>
</section>

<dialog id="project-image-preview" class="image-preview" aria-label={copy.imagePreview.title}>
<div class="image-preview-toolbar">
<button type="button" data-preview-zoom aria-pressed="false" data-zoom-label={copy.imagePreview.zoom} data-fit-label={copy.imagePreview.fit}>{copy.imagePreview.zoom}</button>
<form method="dialog"><button type="submit">{copy.imagePreview.close} <span aria-hidden="true">×</span></button></form>
</div>
<div class="image-preview-viewport" tabindex="0" role="region" aria-label={copy.imagePreview.title}>
<img data-preview-image alt="" />
</div>
<p data-preview-caption class="image-preview-caption"></p>
</dialog>

<script>
import { initImagePreview } from '@/scripts/image-preview';
initImagePreview();
</script>
70 changes: 68 additions & 2 deletions src/components/projects.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,12 @@ export const en: Copy = {
},
],

imagePreview: {
title: 'Image preview',
close: 'Close',
zoom: 'Original size',
fit: 'Fit to screen',
},
projects: [
{
name: 'Lumina',
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/it.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,12 @@ export const it: Copy = {
},
],

imagePreview: {
title: 'Anteprima immagine',
close: 'Chiudi',
zoom: 'Dimensione originale',
fit: 'Adatta allo schermo',
},
projects: [
{
name: 'Lumina',
Expand Down
1 change: 1 addition & 0 deletions src/i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
52 changes: 52 additions & 0 deletions src/scripts/image-preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/** Native dialog provides modal focus containment, background inertness and Escape. */
export function initImagePreview(): void {
const dialog = document.querySelector<HTMLDialogElement>('#project-image-preview');
const image = dialog?.querySelector<HTMLImageElement>('[data-preview-image]');
const caption = dialog?.querySelector<HTMLElement>('[data-preview-caption]');
const zoom = dialog?.querySelector<HTMLButtonElement>('[data-preview-zoom]');
const viewport = dialog?.querySelector<HTMLElement>('.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<HTMLButtonElement>('[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 });
});
}
6 changes: 3 additions & 3 deletions tests/e2e/homepage.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
}
}
Expand Down
83 changes: 83 additions & 0 deletions tests/e2e/image-preview.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
}
Loading