mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-23 22:15:24 +08:00
fix(a11y): resolve WCAG 2.1 Level A and AA violations on settings general page
This commit is contained in:
@ -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"
|
||||
>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "キーボードの録音",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "录制键盘",
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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%) */
|
||||
|
||||
Reference in New Issue
Block a user