247 lines
7.7 KiB
TypeScript
247 lines
7.7 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: "Experience", exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("link", { name: "More work", exact: true }),
|
|
).toBeVisible();
|
|
await expect(
|
|
page.getByRole("link", { name: "Additional work", exact: true }),
|
|
).toHaveCount(0);
|
|
|
|
await page.setViewportSize({ width: 320, height: 800 });
|
|
await revealNavigationLinks(page);
|
|
const experience = page.getByRole("link", { name: "Experience", exact: true });
|
|
await expect(experience).toHaveText("Experience");
|
|
await expect(
|
|
page.getByRole("link", { name: "More work", exact: true }),
|
|
).toHaveText("More work");
|
|
await expect(
|
|
page.getByRole("link", { name: "Additional work", exact: true }),
|
|
).toHaveCount(0);
|
|
|
|
await experience.focus();
|
|
await page.keyboard.press("Enter");
|
|
await expect(page.locator("#experience-heading")).toBeFocused();
|
|
});
|