fix(a11y): resolve WCAG 2.1 Level A and AA violations on settings general page

This commit is contained in:
Olayinka Adelakun
2026-06-24 14:38:37 -04:00
parent 6f7c1f88ec
commit 49d091ef17
13 changed files with 298 additions and 5 deletions

View File

@ -1,4 +1,5 @@
import type { To } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { CustomBanner } from "@/customization/components/custom-banner";
import { useCustomNavigate } from "@/customization/hooks/use-custom-navigate";
import { Button } from "../../ui/button";
@ -21,11 +22,16 @@ export default function PageLayout({
backTo?: To;
}) {
const navigate = useCustomNavigate();
const { t } = useTranslation();
return (
<div className="flex w-full flex-1 flex-col justify-between overflow-auto overflow-x-hidden bg-background">
<div className="mx-auto flex w-full max-w-[1440px] flex-1 flex-col">
<div className="flex flex-col gap-4 p-6 pt-0">
<div
role="region"
aria-labelledby="page-layout-heading"
className="flex flex-col gap-4 p-6 pt-0"
>
<CustomBanner />
<div className="flex w-full items-center justify-between gap-4 space-y-0.5 pb-2 pt-10">
<div className="flex w-full flex-col">
@ -37,6 +43,7 @@ export default function PageLayout({
navigate(backTo);
}}
data-testid="back_page_button"
aria-label={t("navigation.goBack")}
>
<ForwardedIconComponent
name="ChevronLeft"
@ -45,6 +52,7 @@ export default function PageLayout({
</Button>
)}
<h2
id="page-layout-heading"
className="text-2xl font-bold tracking-tight"
data-testid="mainpage_title"
>

View File

@ -1,3 +1,4 @@
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { CustomLink } from "@/customization/components/custom-link";
import { useIsMobile } from "@/hooks/use-mobile";
@ -21,13 +22,19 @@ type SideBarButtonsComponentProps = {
};
const SideBarButtonsComponent = ({ items }: SideBarButtonsComponentProps) => {
const { t } = useTranslation();
const location = useLocation();
const pathname = location.pathname;
const isMobile = useIsMobile();
return (
<Sidebar collapsible={isMobile ? "icon" : "none"} className="border-none">
<Sidebar
collapsible={isMobile ? "icon" : "none"}
className="border-none"
role="navigation"
aria-label={t("settings.nav.label")}
>
<SidebarContent className="pr-6">
<SidebarGroup>
<SidebarGroupContent>

View File

@ -1656,6 +1656,7 @@
"settings.mcpClient.title": "Langflow MCP-Client",
"settings.nav.apiKeys": "Langflow-API-Schlüssel",
"settings.nav.dbProviders": "Datenbankanbieter",
"settings.nav.label": "Einstellungsnavigation",
"settings.nav.general": "Allgemein",
"settings.nav.globalVariables": "Globale Variablen",
"settings.nav.mcpClient": "Langflow MCP-Client",
@ -1672,6 +1673,8 @@
"settings.passwordTitle": "Speicherposition",
"settings.profilePictureCategory.People": "Personen",
"settings.profilePictureCategory.Space": "Bereich",
"settings.avatarAlt": "{{folder}} Avatar {{index}}",
"navigation.goBack": "Zurück",
"settings.profilePictureDescription": "Wähle das Bild aus, das als dein Profilbild angezeigt werden soll.",
"settings.profilePictureTitle": "Profilbild",
"settings.recordingKeyboard": "Aufnahme Ihres Keyboards",

View File

@ -288,6 +288,8 @@
"settings.profilePictureDescription": "Choose the image that appears as your profile picture.",
"settings.profilePictureCategory.People": "People",
"settings.profilePictureCategory.Space": "Space",
"settings.avatarAlt": "{{folder}} avatar {{index}}",
"navigation.goBack": "Go back",
"settings.apiKeysTitle": "Langflow API Keys",
"settings.apiKeys.columnName": "Name",
"settings.apiKeys.columnKey": "Key",
@ -501,6 +503,7 @@
"sidebar.emptyMessage": "Start creating a project or flow",
"sidebar.knowledge": "Knowledge",
"sidebar.myFiles": "My Files",
"settings.nav.label": "Settings navigation",
"settings.nav.general": "General",
"settings.nav.mcpServers": "MCP Servers",
"settings.nav.mcpClient": "Langflow MCP Client",

View File

@ -1656,6 +1656,7 @@
"settings.mcpClient.title": "Cliente Langflow MCP",
"settings.nav.apiKeys": "Claves API de Langflow",
"settings.nav.dbProviders": "Proveedores de bases de datos",
"settings.nav.label": "Navegación de configuración",
"settings.nav.general": "Tema general",
"settings.nav.globalVariables": "Variables globales",
"settings.nav.mcpClient": "Cliente Langflow MCP",
@ -1672,6 +1673,8 @@
"settings.passwordTitle": "Contraseña",
"settings.profilePictureCategory.People": "Personas",
"settings.profilePictureCategory.Space": "Espacio",
"settings.avatarAlt": "avatar {{folder}} {{index}}",
"navigation.goBack": "Volver",
"settings.profilePictureDescription": "Elige la imagen que quieras que aparezca como foto de perfil.",
"settings.profilePictureTitle": "Foto de perfil",
"settings.recordingKeyboard": "Grabar el teclado",

View File

@ -1656,6 +1656,7 @@
"settings.mcpClient.title": "Client Langflow MCP",
"settings.nav.apiKeys": "Clés API Langflow",
"settings.nav.dbProviders": "Fournisseurs de bases de données",
"settings.nav.label": "Navigation des paramètres",
"settings.nav.general": "Général",
"settings.nav.globalVariables": "Variables globales",
"settings.nav.mcpClient": "Client Langflow MCP",
@ -1672,6 +1673,8 @@
"settings.passwordTitle": "Mot de passe",
"settings.profilePictureCategory.People": "Personnes",
"settings.profilePictureCategory.Space": "Espace",
"settings.avatarAlt": "avatar {{folder}} {{index}}",
"navigation.goBack": "Retour",
"settings.profilePictureDescription": "Choisissez l'image qui apparaîtra comme photo de profil.",
"settings.profilePictureTitle": "Photo de profil",
"settings.recordingKeyboard": "Enregistrer votre clavier",

View File

@ -1656,6 +1656,7 @@
"settings.mcpClient.title": "Langflow MCP クライアント",
"settings.nav.apiKeys": "Langflow API キー",
"settings.nav.dbProviders": "DBプロバイダー",
"settings.nav.label": "設定ナビゲーション",
"settings.nav.general": "一般",
"settings.nav.globalVariables": "グローバル変数",
"settings.nav.mcpClient": "Langflow MCP クライアント",
@ -1672,6 +1673,8 @@
"settings.passwordTitle": "パスワード",
"settings.profilePictureCategory.People": "担当者",
"settings.profilePictureCategory.Space": "スペース",
"settings.avatarAlt": "{{folder}}アバター{{index}}",
"navigation.goBack": "戻る",
"settings.profilePictureDescription": "プロフィール写真として表示される画像を選んでください。",
"settings.profilePictureTitle": "プロフィール写真",
"settings.recordingKeyboard": "キーボードの録音",

View File

@ -1656,6 +1656,7 @@
"settings.mcpClient.title": "Cliente Langflow MCP",
"settings.nav.apiKeys": "Chaves de API do Langflow",
"settings.nav.dbProviders": "Provedores de banco de dados",
"settings.nav.label": "Navegação de configurações",
"settings.nav.general": "Geral",
"settings.nav.globalVariables": "Variáveis globais",
"settings.nav.mcpClient": "Cliente Langflow MCP",
@ -1672,6 +1673,8 @@
"settings.passwordTitle": "Senha",
"settings.profilePictureCategory.People": "Pessoas",
"settings.profilePictureCategory.Space": "Espaço",
"settings.avatarAlt": "avatar {{folder}} {{index}}",
"navigation.goBack": "Voltar",
"settings.profilePictureDescription": "Escolha a imagem que aparecerá como sua foto de perfil.",
"settings.profilePictureTitle": "Foto de perfil",
"settings.recordingKeyboard": "Gravando o teclado",

View File

@ -1656,6 +1656,7 @@
"settings.mcpClient.title": "Langflow MCP 客户端",
"settings.nav.apiKeys": "Langflow API 密钥",
"settings.nav.dbProviders": "数据库提供商",
"settings.nav.label": "设置导航",
"settings.nav.general": "常规",
"settings.nav.globalVariables": "全局变量",
"settings.nav.mcpClient": "Langflow MCP 客户端",
@ -1672,6 +1673,8 @@
"settings.passwordTitle": "密码",
"settings.profilePictureCategory.People": "人员",
"settings.profilePictureCategory.Space": "空间",
"settings.avatarAlt": "{{folder}}头像{{index}}",
"navigation.goBack": "返回",
"settings.profilePictureDescription": "选择要作为个人头像显示的图片。",
"settings.profilePictureTitle": "头像",
"settings.recordingKeyboard": "录制键盘",

View File

@ -102,8 +102,6 @@ export function TraceDetailView({ traceId, flowName }: TraceDetailViewProps) {
);
}
const headerTitle = `${trace.name || flowName || "Trace"}`;
return (
<div
className="flex h-full flex-col overflow-hidden"

View File

@ -0,0 +1,254 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
jest.mock("react-i18next", () => ({
__esModule: true,
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) => {
if (key === "settings.avatarAlt" && opts) {
return `${opts.folder} avatar ${opts.index}`;
}
return key;
},
}),
initReactI18next: {
type: "3rdParty",
init: () => {},
},
}));
jest.mock("@/customization/utils/custom-pre-load-image-url", () => ({
customPreLoadImageUrl: (path: string) =>
`/api/v1/files/profile_pictures/${path}`,
}));
jest.mock("@/stores/darkStore", () => ({
useDarkStore: (selector: (s: { dark: boolean }) => unknown) =>
selector({ dark: false }),
}));
jest.mock("../hooks/use-preload-images", () => {
const { useEffect } = require("react");
return {
__esModule: true,
default: (setImagesLoaded: (v: boolean) => void) => {
useEffect(() => {
setImagesLoaded(true);
}, []);
},
};
});
jest.mock("@/components/ui/loading", () => ({
__esModule: true,
default: () => <div data-testid="loading-spinner" />,
}));
jest.mock("@/components/ui/button", () => ({
Button: ({
children,
onClick,
unstyled: _unstyled,
...rest
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
unstyled?: boolean;
children: React.ReactNode;
}) => (
<button onClick={onClick} {...rest}>
{children}
</button>
),
}));
import ProfilePictureChooserComponent from "../index";
const PROFILE_PICTURES = {
files: [],
People: ["avatar-01.svg", "avatar-02.svg"],
Space: ["space-01.svg"],
};
describe("ProfilePictureChooserComponent", () => {
it("shows loading spinner when loading prop is true", () => {
render(
<ProfilePictureChooserComponent
loading={true}
profilePictures={PROFILE_PICTURES}
value=""
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId("loading-spinner")).toBeInTheDocument();
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
it("renders images with src URLs built from customPreLoadImageUrl", async () => {
render(
<ProfilePictureChooserComponent
loading={false}
profilePictures={PROFILE_PICTURES}
value=""
onChange={jest.fn()}
/>,
);
await screen.findAllByRole("button");
expect(screen.getByAltText("People avatar 1")).toHaveAttribute(
"src",
"/api/v1/files/profile_pictures/People/avatar-01.svg",
);
expect(screen.getByAltText("Space avatar 1")).toHaveAttribute(
"src",
"/api/v1/files/profile_pictures/Space/space-01.svg",
);
});
it("renders folder category labels when loaded", async () => {
render(
<ProfilePictureChooserComponent
loading={false}
profilePictures={PROFILE_PICTURES}
value=""
onChange={jest.fn()}
/>,
);
await screen.findByText("settings.profilePictureCategory.People");
expect(
screen.getByText("settings.profilePictureCategory.Space"),
).toBeInTheDocument();
});
it("renders one button per avatar path", async () => {
render(
<ProfilePictureChooserComponent
loading={false}
profilePictures={PROFILE_PICTURES}
value=""
onChange={jest.fn()}
/>,
);
const buttons = await screen.findAllByRole("button");
// 2 People + 1 Space = 3
expect(buttons).toHaveLength(3);
});
it("each image has a descriptive alt attribute", async () => {
render(
<ProfilePictureChooserComponent
loading={false}
profilePictures={PROFILE_PICTURES}
value=""
onChange={jest.fn()}
/>,
);
await screen.findAllByRole("button");
expect(screen.getByAltText("People avatar 1")).toBeInTheDocument();
expect(screen.getByAltText("People avatar 2")).toBeInTheDocument();
expect(screen.getByAltText("Space avatar 1")).toBeInTheDocument();
});
it("selected button has aria-pressed=true, others have aria-pressed=false", async () => {
render(
<ProfilePictureChooserComponent
loading={false}
profilePictures={PROFILE_PICTURES}
value="People/avatar-01.svg"
onChange={jest.fn()}
/>,
);
const buttons = await screen.findAllByRole("button");
expect(buttons[0]).toHaveAttribute("aria-pressed", "true");
expect(buttons[1]).toHaveAttribute("aria-pressed", "false");
expect(buttons[2]).toHaveAttribute("aria-pressed", "false");
});
it("all buttons have aria-pressed=false when no value is selected", async () => {
render(
<ProfilePictureChooserComponent
loading={false}
profilePictures={PROFILE_PICTURES}
value=""
onChange={jest.fn()}
/>,
);
const buttons = await screen.findAllByRole("button");
buttons.forEach((btn) => {
expect(btn).toHaveAttribute("aria-pressed", "false");
});
});
it("calls onChange with the correct folder/path key when clicked", async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ProfilePictureChooserComponent
loading={false}
profilePictures={PROFILE_PICTURES}
value=""
onChange={onChange}
/>,
);
const buttons = await screen.findAllByRole("button");
await user.click(buttons[1]);
expect(onChange).toHaveBeenCalledWith("People/avatar-02.svg");
});
it("calls onChange once per click", async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(
<ProfilePictureChooserComponent
loading={false}
profilePictures={PROFILE_PICTURES}
value=""
onChange={onChange}
/>,
);
const buttons = await screen.findAllByRole("button");
await user.click(buttons[0]);
expect(onChange).toHaveBeenCalledTimes(1);
});
it("does not render buttons when profilePictures is empty", async () => {
render(
<ProfilePictureChooserComponent
loading={false}
profilePictures={{ files: [] }}
value=""
onChange={jest.fn()}
/>,
);
await waitFor(() => {
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
it("renders buttons from all folders when multiple folders are present", async () => {
const multiFolder = {
files: [],
People: ["a.svg", "b.svg", "c.svg"],
Space: ["x.svg", "y.svg"],
};
render(
<ProfilePictureChooserComponent
loading={false}
profilePictures={multiFolder}
value=""
onChange={jest.fn()}
/>,
);
const buttons = await screen.findAllByRole("button");
expect(buttons).toHaveLength(5);
});
it("does not show buttons while loading even when profilePictures is provided", () => {
render(
<ProfilePictureChooserComponent
loading={true}
profilePictures={PROFILE_PICTURES}
value=""
onChange={jest.fn()}
/>,
);
expect(screen.queryAllByRole("button")).toHaveLength(0);
});
});

View File

@ -55,10 +55,15 @@ export default function ProfilePictureChooserComponent({
unstyled
onClick={() => onChange(folder + "/" + path)}
className="shrink-0 px-0.5 py-2"
aria-pressed={value === folder + "/" + path}
>
<img
key={idx}
src={customPreLoadImageUrl(`${folder}/${path}`)}
alt={t("settings.avatarAlt", {
folder,
index: idx + 1,
})}
style={{
filter:
value === folder + "/" + path

View File

@ -13,7 +13,7 @@
--foreground: 0 0% 0%; /* hsl(0, 0%, 0%) */
--background: 0 0% 100%; /* hsl(0, 0%, 100%) */
--muted: 240 5% 96%; /* hsl(240, 5%, 96%) */
--muted-foreground: 240 4% 46%; /* hsl(240, 4%, 46%) */
--muted-foreground: 240 4% 43%; /* hsl(240, 4%, 43%) — darkened from 46% to meet WCAG AA 4.5:1 */
--card: 0 0% 100%; /* hsl(0, 0%, 100%) */
--card-foreground: 0 0% 0%; /* hsl(0, 0%, 0%) */
--popover: 0 0% 100%; /* hsl(0, 0%, 100%) */