fix(a11y): restore focus-visible indicators (#13664)

* fix(a11y): restore focus-visible indicators

* [autofix.ci] apply automated fixes

---------

Co-authored-by: Olayinka Adelakun <olayinkaadelakun@Olayinkas-MacBook-Pro.local>
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>
This commit is contained in:
olayinkaadelakun
2026-06-22 06:28:17 -07:00
committed by GitHub
parent c49a8db88a
commit c368aded0e
9 changed files with 134 additions and 18 deletions

View File

@ -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()}`}
>
<div className="flex items-center gap-1 truncate rounded-md px-2 py-1 text-sm font-medium group-hover:bg-primary/10">

View File

@ -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",
)}
>
<span

View File

@ -151,7 +151,7 @@ export function DBProviderInput({
data-testid={id}
className={cn(
"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",
)}
>
<span

View File

@ -82,7 +82,7 @@ const ModelTrigger = ({
data-testid={id}
className={cn(
"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",
)}
>
<span

View File

@ -23,7 +23,7 @@ export function ChatHeaderActions({
}
const actionButtonClasses =
"h-8 w-8 p-2 text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors overflow-hidden no-focus-visible";
"h-8 w-8 p-2 text-muted-foreground hover:text-foreground hover:bg-accent rounded transition-colors overflow-hidden focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-offset-2";
return (
<div className="relative flex items-center gap-2 w-20 justify-end">

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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": {

View File

@ -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);
},
);