mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-23 18:57:09 +08:00
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:
@ -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">
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
120
src/frontend/tests/extended/features/focus-visible.spec.ts
Normal file
120
src/frontend/tests/extended/features/focus-visible.spec.ts
Normal 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);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user