From c368aded0eefa4eb2ac9e26e515827c294c21e4e Mon Sep 17 00:00:00 2001 From: olayinkaadelakun Date: Mon, 22 Jun 2026 06:28:17 -0700 Subject: [PATCH] fix(a11y): restore focus-visible indicators (#13664) * fix(a11y): restore focus-visible indicators * [autofix.ci] apply automated fixes --------- Co-authored-by: Olayinka Adelakun Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Viktor Avelino <64113566+viktoravelino@users.noreply.github.com> --- .../components/OutputComponent/index.tsx | 2 +- .../core/dropdownComponent/index.tsx | 2 +- .../dbProviderInputComponent/index.tsx | 2 +- .../components/ModelTrigger.tsx | 2 +- .../components/chat-header-actions.tsx | 2 +- src/frontend/src/style/applies.css | 10 +- src/frontend/src/style/index.css | 8 ++ src/frontend/tailwind.config.mjs | 4 - .../extended/features/focus-visible.spec.ts | 120 ++++++++++++++++++ 9 files changed, 134 insertions(+), 18 deletions(-) create mode 100644 src/frontend/tests/extended/features/focus-visible.spec.ts diff --git a/src/frontend/src/CustomNodes/GenericNode/components/OutputComponent/index.tsx b/src/frontend/src/CustomNodes/GenericNode/components/OutputComponent/index.tsx index a67d7cfd4f..49cc2a6d6f 100644 --- a/src/frontend/src/CustomNodes/GenericNode/components/OutputComponent/index.tsx +++ b/src/frontend/src/CustomNodes/GenericNode/components/OutputComponent/index.tsx @@ -76,7 +76,7 @@ export default function OutputComponent({ unstyled role="combobox" ref={refButton} - className="no-focus-visible group flex items-center gap-2" + className="focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 group flex items-center gap-2" data-testid={`dropdown-output-${outputName?.toLowerCase()}`} >
diff --git a/src/frontend/src/components/core/dropdownComponent/index.tsx b/src/frontend/src/components/core/dropdownComponent/index.tsx index a1319f8977..f1bf44648d 100644 --- a/src/frontend/src/components/core/dropdownComponent/index.tsx +++ b/src/frontend/src/components/core/dropdownComponent/index.tsx @@ -380,7 +380,7 @@ export default function Dropdown({ editNode ? "dropdown-component-outline input-edit-node" : "dropdown-component-false-outline py-2", - "no-focus-visible w-full justify-between font-normal disabled:bg-muted disabled:text-muted-foreground", + "focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2 w-full justify-between font-normal disabled:bg-muted disabled:text-muted-foreground", )} > diff --git a/src/frontend/src/style/applies.css b/src/frontend/src/style/applies.css index d34efc6fbe..f1a813f381 100644 --- a/src/frontend/src/style/applies.css +++ b/src/frontend/src/style/applies.css @@ -1240,7 +1240,7 @@ input[type="password"]::-ms-clear { @apply top-[-23px]; } .popover-input { - @apply h-fit w-fit flex-1 border-none bg-transparent p-0 shadow-none outline-none ring-0 ring-offset-0 placeholder:text-muted-foreground focus:border-foreground focus:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50; + @apply h-fit w-fit flex-1 border-none bg-transparent p-0 shadow-none outline-none ring-0 ring-offset-0 placeholder:text-muted-foreground focus:border-foreground focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50; } .beta-badge { @@ -1295,14 +1295,6 @@ input[type="password"]::-ms-clear { @apply flex h-[32px] w-[32px] items-center justify-center rounded-md bg-muted font-bold transition-all; } - .no-focus-visible { - @apply focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0; - --tw-ring-offset-width: none !important; - --tw-ring-shadow: none !important; - --tw-ring-offset-shadow: none !important; - --tw-ring-color: none !important; - } - .helper-lines { @apply pointer-events-none absolute left-0 top-0 z-10 h-full w-full; } diff --git a/src/frontend/src/style/index.css b/src/frontend/src/style/index.css index 4cb54cce74..14127dea32 100644 --- a/src/frontend/src/style/index.css +++ b/src/frontend/src/style/index.css @@ -493,3 +493,11 @@ --indigo-foreground: 234.5 89.5% 73.9%; } } + +/* WCAG 2.4.7 — visible focus indicator fallback for any element not covered + by component-level Tailwind ring classes. Component ring classes (box-shadow) + take precedence over this outline via the cascade. */ +:focus-visible { + outline: 2px solid hsl(var(--ring)); + outline-offset: 2px; +} diff --git a/src/frontend/tailwind.config.mjs b/src/frontend/tailwind.config.mjs index d683703cfd..ba56569b30 100644 --- a/src/frontend/tailwind.config.mjs +++ b/src/frontend/tailwind.config.mjs @@ -480,10 +480,6 @@ const config = { ".text-align-last-right": { "text-align-last": "right", }, - ":focus-visible": { - outline: "none !important", - outlineOffset: "0px !important", - }, ".note-node-markdown": { lineHeight: "1", "& ul li::marker": { diff --git a/src/frontend/tests/extended/features/focus-visible.spec.ts b/src/frontend/tests/extended/features/focus-visible.spec.ts new file mode 100644 index 0000000000..2269ca801d --- /dev/null +++ b/src/frontend/tests/extended/features/focus-visible.spec.ts @@ -0,0 +1,120 @@ +import { expect, test } from "../../fixtures"; + +/** + * WCAG 2.4.7 Focus Visible regression tests. + * + * Each test tabs to an interactive element and confirms it has a visible + * focus indicator — either a non-zero outline or a non-"none" box-shadow + * (Tailwind ring classes render as box-shadow). + */ + +async function getFocusStyle(page) { + return page.evaluate(() => { + const el = document.activeElement as HTMLElement | null; + if (!el) return null; + const style = window.getComputedStyle(el); + return { + tag: el.tagName.toLowerCase(), + testId: el.getAttribute("data-testid") ?? "", + outlineWidth: style.outlineWidth, + outlineStyle: style.outlineStyle, + boxShadow: style.boxShadow, + }; + }); +} + +function hasFocusIndicator(focusStyle: { + outlineWidth: string; + outlineStyle: string; + boxShadow: string; +}) { + const hasOutline = + focusStyle.outlineStyle !== "none" && + focusStyle.outlineWidth !== "0px" && + focusStyle.outlineWidth !== ""; + const hasRing = + focusStyle.boxShadow !== "none" && focusStyle.boxShadow !== ""; + return hasOutline || hasRing; +} + +test( + "login page — every interactive element shows a visible focus indicator when tabbed to", + { tag: ["@release", "@workspace"] }, + async ({ page }) => { + await page.goto("/login"); + await page.waitForLoadState("networkidle"); + + // Tab through up to 20 focusable elements + const violations: string[] = []; + for (let i = 0; i < 20; i++) { + await page.keyboard.press("Tab"); + const focusStyle = await getFocusStyle(page); + if (!focusStyle) continue; + + // Skip elements that are intentionally not interactive (body, html) + if (["body", "html"].includes(focusStyle.tag)) continue; + + if (!hasFocusIndicator(focusStyle)) { + violations.push( + `Element <${focusStyle.tag}> data-testid="${focusStyle.testId}" has no visible focus indicator (outline: ${focusStyle.outlineWidth} ${focusStyle.outlineStyle}, box-shadow: ${focusStyle.boxShadow})`, + ); + } + } + + expect( + violations, + `Focus visible violations found:\n${violations.join("\n")}`, + ).toHaveLength(0); + }, +); + +test( + "canvas controls — add note, zoom, and fit view buttons show focus ring when tabbed to", + { tag: ["@release", "@workspace"] }, + async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + const controlTestIds = [ + "canvas-add-note-button", + "zoom_in", + "zoom_out", + "fit_view", + ]; + + for (const testId of controlTestIds) { + const button = page.getByTestId(testId); + if (!(await button.isVisible())) continue; + + await button.focus(); + const focusStyle = await getFocusStyle(page); + if (!focusStyle) continue; + + expect( + hasFocusIndicator(focusStyle), + `Canvas control [data-testid="${testId}"] has no visible focus indicator`, + ).toBe(true); + } + }, +); + +test( + "dropdown trigger shows focus ring on keyboard focus", + { tag: ["@release", "@workspace"] }, + async ({ page }) => { + await page.goto("/"); + await page.waitForLoadState("networkidle"); + + // Find any visible button-role element and tab to it + const triggers = page.locator('[role="combobox"]:visible').first(); + if (!(await triggers.isVisible())) return; + + await triggers.focus(); + const focusStyle = await getFocusStyle(page); + + expect( + focusStyle && hasFocusIndicator(focusStyle), + `Dropdown trigger has no visible focus indicator (outline: ${focusStyle?.outlineWidth}, box-shadow: ${focusStyle?.boxShadow})`, + ).toBe(true); + }, +);