From bbb071371b3a2e3ba1b3f11b48a39294237e2dfc Mon Sep 17 00:00:00 2001 From: akshar Date: Tue, 8 Sep 2026 09:44:10 -0700 Subject: [PATCH 1/3] FIX: add a skip-to-main-content link for keyboard users The application shell has no bypass control, so keyboard users must tab through every shell control (Take a tour, primary navigation, Feedback, Security, Theme) before reaching route content on every page. This does not satisfy the WCAG 2.4.1 bypass-blocks requirement. Add a visually-hidden-until-focused skip link as the first focusable element in MainLayout, targeting the existing main landmark. Give
a stable id and tabIndex={-1} so activating the link moves keyboard focus directly into it, not just the scroll position. Fixes #2597 --- .../components/Layout/MainLayout.styles.ts | 17 +++++++++++ .../src/components/Layout/MainLayout.test.tsx | 28 +++++++++++++++++++ frontend/src/components/Layout/MainLayout.tsx | 7 ++++- 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/Layout/MainLayout.styles.ts b/frontend/src/components/Layout/MainLayout.styles.ts index 83d2dec345..0ffb75ef34 100644 --- a/frontend/src/components/Layout/MainLayout.styles.ts +++ b/frontend/src/components/Layout/MainLayout.styles.ts @@ -8,6 +8,23 @@ export const useMainLayoutStyles = makeStyles({ width: '100vw', overflow: 'hidden', }, + skipLink: { + position: 'absolute', + top: '-40px', + left: '0', + zIndex: 1000, + padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, + backgroundColor: tokens.colorBrandBackground, + color: tokens.colorNeutralForegroundOnBrand, + fontWeight: tokens.fontWeightSemibold, + textDecorationLine: 'none', + borderBottomRightRadius: tokens.borderRadiusMedium, + transitionProperty: 'top', + transitionDuration: tokens.durationFast, + ':focus-visible': { + top: '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} +
) From c77392b667000130732bec959e84a007a8343386 Mon Sep 17 00:00:00 2001 From: akshar Date: Tue, 8 Sep 2026 15:08:32 -0700 Subject: [PATCH 2/3] FIX: address review feedback on skip link (height-independent hide, e2e coverage) Per @romanlutz's review: - Hide the skip link with transform: translateY(-100%) instead of a fixed top: -40px offset, so it stays fully off-screen regardless of its own rendered height (text zoom, a different font, or longer copy could otherwise leave part of a taller link visible over the top bar). - Disable the reveal transition under prefers-reduced-motion. - Add a real e2e test (Playwright) that presses Tab, asserts the link is focused and visible, presses Enter, and asserts focus lands on #main-content - the Jest test only checked markup and would still pass if the CSS reveal or the focus-on-activate behavior broke. --- frontend/e2e/accessibility.spec.ts | 43 +++++++++++++++++++ .../components/Layout/MainLayout.styles.ts | 13 ++++-- 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index c7017b466b..a1285d7ceb 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -199,6 +199,49 @@ 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" }); + + // 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).toBeVisible(); + + 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 0ffb75ef34..e9670a730b 100644 --- a/frontend/src/components/Layout/MainLayout.styles.ts +++ b/frontend/src/components/Layout/MainLayout.styles.ts @@ -10,7 +10,7 @@ export const useMainLayoutStyles = makeStyles({ }, skipLink: { position: 'absolute', - top: '-40px', + top: '0', left: '0', zIndex: 1000, padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`, @@ -19,10 +19,17 @@ export const useMainLayoutStyles = makeStyles({ fontWeight: tokens.fontWeightSemibold, textDecorationLine: 'none', borderBottomRightRadius: tokens.borderRadiusMedium, - transitionProperty: 'top', + // 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': { - top: '0', + transform: 'translateY(0)', }, }, topBar: { From d751de835da36c7e64a4e23a606e83cf1595903c Mon Sep 17 00:00:00 2001 From: Roman Lutz Date: Tue, 8 Sep 2026 15:32:10 -0700 Subject: [PATCH 3/3] TEST: verify skip link enters viewport Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- frontend/e2e/accessibility.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index a1285d7ceb..bd671ae684 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -231,12 +231,13 @@ test.describe("Accessibility", () => { 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).toBeVisible(); + await expect(skipLink).toBeInViewport({ ratio: 1 }); await page.keyboard.press("Enter"); await expect(page.locator("#main-content")).toBeFocused();