test: enforce accessible portfolio interaction
This commit is contained in:
188
tests/browser/accessibility-keyboard.spec.ts
Normal file
188
tests/browser/accessibility-keyboard.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, test } from "../helpers/serve-dist";
|
||||
|
||||
interface FocusGeometry {
|
||||
readonly description: string;
|
||||
readonly focusVisible: boolean;
|
||||
readonly insideSafeBounds: boolean;
|
||||
readonly overlapArea: number;
|
||||
}
|
||||
|
||||
async function currentFocusGeometry(page: Page): Promise<FocusGeometry> {
|
||||
return page.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
if (!(active instanceof HTMLElement)) {
|
||||
throw new Error(
|
||||
"Keyboard traversal did not leave an HTMLElement focused.",
|
||||
);
|
||||
}
|
||||
|
||||
const rect = active.getBoundingClientRect();
|
||||
const style = getComputedStyle(active);
|
||||
const viewport = window.visualViewport;
|
||||
const left = viewport?.offsetLeft ?? 0;
|
||||
const top = viewport?.offsetTop ?? 0;
|
||||
const right = left + (viewport?.width ?? window.innerWidth);
|
||||
const bottom = top + (viewport?.height ?? window.innerHeight);
|
||||
const outlineWidth = Number.parseFloat(style.outlineWidth) || 0;
|
||||
const outlineOffset = Number.parseFloat(style.outlineOffset) || 0;
|
||||
const hasInsetIndicator = /\binset\b/iu.test(style.boxShadow);
|
||||
const indicatorExtent = hasInsetIndicator
|
||||
? 0
|
||||
: Math.max(0, outlineWidth + outlineOffset);
|
||||
const fixedSurfaces = [
|
||||
...document.querySelectorAll<HTMLElement>(
|
||||
".journey-hud, .drive-controls",
|
||||
),
|
||||
].filter((surface) => !surface.contains(active));
|
||||
const overlapArea = fixedSurfaces.reduce((total, surface) => {
|
||||
const fixed = surface.getBoundingClientRect();
|
||||
const width = Math.max(
|
||||
0,
|
||||
Math.min(rect.right, fixed.right) - Math.max(rect.left, fixed.left),
|
||||
);
|
||||
const height = Math.max(
|
||||
0,
|
||||
Math.min(rect.bottom, fixed.bottom) - Math.max(rect.top, fixed.top),
|
||||
);
|
||||
return total + width * height;
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
description:
|
||||
active.getAttribute("href") ??
|
||||
active.getAttribute("aria-label") ??
|
||||
active.textContent?.trim() ??
|
||||
active.tagName,
|
||||
focusVisible:
|
||||
((style.outlineStyle !== "none" && outlineWidth >= 2) ||
|
||||
hasInsetIndicator) &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0,
|
||||
insideSafeBounds:
|
||||
rect.left - indicatorExtent >= left - 1 &&
|
||||
rect.right + indicatorExtent <= right + 1 &&
|
||||
rect.top - indicatorExtent >= top - 1 &&
|
||||
rect.bottom + indicatorExtent <= bottom + 1,
|
||||
overlapArea,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
test("every enabled author pointer target is 44 by 44 CSS pixels or a valid inline sentence link", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
|
||||
const failures = await page
|
||||
.locator("a[href], button, input, select, textarea")
|
||||
.evaluateAll((targets) =>
|
||||
targets.flatMap((target) => {
|
||||
if (!(target instanceof HTMLElement)) return [];
|
||||
if (
|
||||
(target instanceof HTMLButtonElement ||
|
||||
target instanceof HTMLInputElement ||
|
||||
target instanceof HTMLSelectElement ||
|
||||
target instanceof HTMLTextAreaElement) &&
|
||||
target.disabled
|
||||
)
|
||||
return [];
|
||||
if (target.getAttribute("aria-disabled") === "true") return [];
|
||||
const style = getComputedStyle(target);
|
||||
const rect = target.getBoundingClientRect();
|
||||
if (
|
||||
style.display === "none" ||
|
||||
style.visibility === "hidden" ||
|
||||
rect.width === 0 ||
|
||||
rect.height === 0
|
||||
)
|
||||
return [];
|
||||
|
||||
const exception = target.getAttribute("data-target-size-exception");
|
||||
const validInlineException =
|
||||
exception === "inline-link" &&
|
||||
target instanceof HTMLAnchorElement &&
|
||||
target.closest("p") !== null;
|
||||
const invalidException = exception !== null && !validInlineException;
|
||||
if (
|
||||
!invalidException &&
|
||||
(validInlineException || (rect.width >= 44 && rect.height >= 44))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
target: target.outerHTML.slice(0, 180),
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
exception,
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
test("keyboard traversal reaches Contact in document order with visible unobscured focus", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
|
||||
const visited: string[] = [];
|
||||
for (let index = 0; index < 64; index += 1) {
|
||||
await page.keyboard.press("Tab");
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const geometry = await currentFocusGeometry(page);
|
||||
return {
|
||||
focusVisible: geometry.focusVisible,
|
||||
insideSafeBounds: geometry.insideSafeBounds,
|
||||
overlapArea: geometry.overlapArea,
|
||||
};
|
||||
})
|
||||
.toEqual({
|
||||
focusVisible: true,
|
||||
insideSafeBounds: true,
|
||||
overlapArea: 0,
|
||||
});
|
||||
const geometry = await currentFocusGeometry(page);
|
||||
visited.push(geometry.description);
|
||||
if (geometry.description.startsWith("mailto:")) break;
|
||||
}
|
||||
|
||||
expect(visited.at(0)).toBe("#portfolio-content");
|
||||
expect(visited.slice(1, 9)).toEqual([
|
||||
"#start",
|
||||
"#profile",
|
||||
"#experience",
|
||||
"#infrastructure",
|
||||
"#selected-builds",
|
||||
"#additional-work",
|
||||
"#toolkit",
|
||||
"#contact",
|
||||
]);
|
||||
expect(visited.some((description) => description.startsWith("mailto:"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("explicit keyboard navigation focuses a section heading without a fixed-surface collision", async ({
|
||||
page,
|
||||
distUrl,
|
||||
}) => {
|
||||
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
|
||||
|
||||
for (let index = 0; index < 9; index += 1) await page.keyboard.press("Tab");
|
||||
await expect(page.locator('nav a[href="#contact"]')).toBeFocused();
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(page.locator("#contact-heading")).toBeFocused();
|
||||
|
||||
const geometry = await currentFocusGeometry(page);
|
||||
expect(geometry.focusVisible).toBe(true);
|
||||
expect(geometry.insideSafeBounds).toBe(true);
|
||||
expect(geometry.overlapArea).toBe(0);
|
||||
});
|
||||
Reference in New Issue
Block a user