diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index c7017b466b..bd671ae684 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -199,6 +199,50 @@ test.describe("Accessibility", () => { await expect(page.locator(":focus")).toBeVisible(); }); + test("skip link is the first Tab stop, becomes visible on focus, and moves focus to main on activation", async ({ + page, + }) => { + // Mock everything the app calls while booting, so this test does not + // depend on the dev-server proxy having a real backend behind it (see + // the same technique in labels-operation-picker.spec.ts). The shared + // beforeEach above already navigated once before these routes existed, + // so reload to get a fresh, intercepted navigation. + await page.route(/\/api\//, async (route) => { + const path = new URL(route.request().url()).pathname.replace(/^\/api/, ""); + const json = (body: unknown) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }); + if (path === "/health") return json({ status: "healthy" }); + if (path === "/auth/config") { + return json({ clientId: "", tenantId: "", allowedGroupIds: "" }); + } + if (path === "/version") return json({ version: "a11y-test", display: "a11y-test" }); + if (path === "/labels") { + return json({ source: "attacks", labels: { operator: ["roakey"], operation: [] } }); + } + if (path === "/attacks") return json({ items: [], total: 0, limit: 5, offset: 0 }); + return json({}); + }); + await page.reload(); + + await expect(page.getByTitle("Home")).toBeVisible(); + + const skipLink = page.getByRole("link", { name: "Skip to main content" }); + await expect(skipLink).not.toBeInViewport(); + + // See the note on "should be navigable with keyboard" above: dispatch + // through `body` to guarantee the document has focus when Tab fires. + await page.locator("body").press("Tab"); + await expect(skipLink).toBeFocused(); + await expect(skipLink).toBeInViewport({ ratio: 1 }); + + await page.keyboard.press("Enter"); + await expect(page.locator("#main-content")).toBeFocused(); + }); + test("should have proper focus management", async ({ page }) => { // Mock a target so the input is enabled await page.route(/\/api\/targets/, async (route) => { diff --git a/frontend/src/components/Layout/MainLayout.styles.ts b/frontend/src/components/Layout/MainLayout.styles.ts index 83d2dec345..e9670a730b 100644 --- a/frontend/src/components/Layout/MainLayout.styles.ts +++ b/frontend/src/components/Layout/MainLayout.styles.ts @@ -8,6 +8,30 @@ export const useMainLayoutStyles = makeStyles({ width: '100vw', overflow: 'hidden', }, + skipLink: { + position: 'absolute', + top: '0', + left: '0', + zIndex: 1000, + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + backgroundColor: tokens.colorBrandBackground, + color: tokens.colorNeutralForegroundOnBrand, + fontWeight: tokens.fontWeightSemibold, + textDecorationLine: 'none', + borderBottomRightRadius: tokens.borderRadiusMedium, + // translateY(-100%) hides the link above the viewport regardless of its + // own rendered height (text zoom, a different font, or longer copy can + // all change that height), unlike a fixed 'top' offset. + transform: 'translateY(-100%)', + transitionProperty: 'transform', + transitionDuration: tokens.durationFast, + '@media (prefers-reduced-motion: reduce)': { + transitionDuration: '0s', + }, + ':focus-visible': { + transform: 'translateY(0)', + }, + }, topBar: { height: '60px', backgroundColor: tokens.colorNeutralBackground3, diff --git a/frontend/src/components/Layout/MainLayout.test.tsx b/frontend/src/components/Layout/MainLayout.test.tsx index 3cd1412c16..e93f73d383 100644 --- a/frontend/src/components/Layout/MainLayout.test.tsx +++ b/frontend/src/components/Layout/MainLayout.test.tsx @@ -231,4 +231,32 @@ describe("MainLayout", () => { expect(mockedVersionApi.getVersion).toHaveBeenCalled(); }); }); + + it("renders a skip link as the first focusable element that targets the main landmark", async () => { + mockedVersionApi.getVersion.mockResolvedValue({ version: "1.0.0" }); + + const { container } = renderWithProvider( + +
Content
+
+ ); + + const skipLink = screen.getByRole("link", { name: /skip to main content/i }); + expect(skipLink).toHaveAttribute("href", "#main-content"); + + const main = container.querySelector("main"); + expect(main).toHaveAttribute("id", "main-content"); + expect(main).toHaveAttribute("tabIndex", "-1"); + + // The skip link must be the first focusable element in the shell so + // keyboard users reach it on the very first Tab press. + const focusable = container.querySelectorAll( + 'a[href], button, [tabindex]:not([tabindex="-1"])' + ); + expect(focusable[0]).toBe(skipLink); + + await waitFor(() => { + expect(mockedVersionApi.getVersion).toHaveBeenCalled(); + }); + }); }); diff --git a/frontend/src/components/Layout/MainLayout.tsx b/frontend/src/components/Layout/MainLayout.tsx index a106fbc497..4cea9256e6 100644 --- a/frontend/src/components/Layout/MainLayout.tsx +++ b/frontend/src/components/Layout/MainLayout.tsx @@ -44,6 +44,9 @@ export default function MainLayout({ return (
+ + Skip to main content +
-
{children}
+
+ {children} +
)