Files
Portfolio/tests/browser/accessibility-keyboard.spec.ts

253 lines
7.9 KiB
TypeScript

import type { Page } from "@playwright/test";
import { expect, revealNavigationLinks, test } from "../helpers/serve-dist";
const navigationHrefs = [
"#start",
"#profile",
"#experience",
"#infrastructure",
"#selected-builds",
"#additional-work",
"#toolkit",
"#contact",
] as const;
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" });
await revealNavigationLinks(page);
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 compactNavigation = await page
.locator(".site-navigation summary")
.isVisible();
await revealNavigationLinks(page);
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;
}
const expectedPrefix = [
"#portfolio-content",
...(compactNavigation ? ["Navigate"] : []),
...navigationHrefs,
];
expect(visited.slice(0, expectedPrefix.length)).toEqual(expectedPrefix);
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" });
const compactNavigation = await page
.locator(".site-navigation summary")
.isVisible();
await revealNavigationLinks(page);
const contactTabIndex = compactNavigation ? 10 : 9;
for (let index = 0; index < contactTabIndex; 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();
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,
});
});
test("responsive navigation exposes the exact visible label as each link's accessible name", async ({
page,
distUrl,
}) => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto(distUrl, { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("link", { name: "Career", exact: true }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "More work", exact: true }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Experience", exact: true }),
).toHaveCount(0);
await expect(
page.getByRole("link", { name: "Additional work", exact: true }),
).toHaveCount(0);
await page.setViewportSize({ width: 320, height: 800 });
await revealNavigationLinks(page);
const career = page.getByRole("link", { name: "Career", exact: true });
await expect(career).toHaveText("Career");
await expect(
page.getByRole("link", { name: "More work", exact: true }),
).toHaveText("More work");
await expect(
page.getByRole("link", { name: "Experience", exact: true }),
).toHaveCount(0);
await expect(
page.getByRole("link", { name: "Additional work", exact: true }),
).toHaveCount(0);
await career.focus();
await page.keyboard.press("Enter");
await expect(page.locator("#experience-heading")).toBeFocused();
});