diff --git a/src/frontend/src/components/common/shadTooltipComponent/__tests__/shadTooltip.a11y.test.tsx b/src/frontend/src/components/common/shadTooltipComponent/__tests__/shadTooltip.a11y.test.tsx new file mode 100644 index 000000000000..2d9972278f57 --- /dev/null +++ b/src/frontend/src/components/common/shadTooltipComponent/__tests__/shadTooltip.a11y.test.tsx @@ -0,0 +1,48 @@ +import { render } from "@testing-library/react"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { axe } from "@/utils/a11y-test"; + +// jest.setup.js globally mocks ShadTooltip to just render children (for the +// convenience of every other test suite) — bypass that here since this file +// needs the real Radix Tooltip/TooltipTrigger/TooltipContent markup. Unlike +// shadTooltip.test.tsx (which mocks TooltipTrigger to inspect prop-passing +// behavior), this file leaves everything real so axe checks the actual DOM. +const ShadTooltip = jest.requireActual("../index").default; + +describe("ShadTooltip accessibility (real Radix Tooltip, unmocked)", () => { + it("should_have_no_axe_violations when closed", async () => { + const { container } = render( + + + + + , + ); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should_have_no_axe_violations when open, with the redundant description suppressed", async () => { + const { container } = render( + + + + + , + ); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should_have_no_axe_violations when open, with Radix's default aria-describedby intact", async () => { + const { container } = render( + + + + + , + ); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/frontend/src/components/common/shadTooltipComponent/__tests__/shadTooltip.test.tsx b/src/frontend/src/components/common/shadTooltipComponent/__tests__/shadTooltip.test.tsx new file mode 100644 index 000000000000..493da2ecbc57 --- /dev/null +++ b/src/frontend/src/components/common/shadTooltipComponent/__tests__/shadTooltip.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from "@testing-library/react"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +// jest.setup.js globally mocks ShadTooltip to just render children (for the +// convenience of every other test suite) — bypass that here with +// requireActual since this file tests ShadTooltip's own real behavior. +const ShadTooltip = jest.requireActual("../index").default; + +let lastTriggerProps: Record = {}; + +jest.mock("@/components/ui/tooltip", () => { + const actual = jest.requireActual("@/components/ui/tooltip"); + return { + ...actual, + TooltipTrigger: ({ + children, + ...props + }: { + children: React.ReactNode; + [key: string]: unknown; + }) => { + lastTriggerProps = props; + return <>{children}; + }, + }; +}); + +describe("ShadTooltip ariaDescribedBy override", () => { + beforeEach(() => { + lastTriggerProps = {}; + }); + + it("passes aria-describedby=undefined through to suppress the description when explicitly requested", () => { + render( + + + + + , + ); + + expect( + screen.getByRole("button", { name: "Component settings" }), + ).toBeInTheDocument(); + expect("aria-describedby" in lastTriggerProps).toBe(true); + expect(lastTriggerProps["aria-describedby"]).toBeUndefined(); + }); + + it("passes a custom aria-describedby id through when given a string", () => { + render( + + + + + , + ); + + expect("aria-describedby" in lastTriggerProps).toBe(true); + expect(lastTriggerProps["aria-describedby"]).toBe("custom-desc-id"); + }); + + it("does not touch aria-describedby at all when the prop is omitted, preserving Radix's default", () => { + render( + + + + + , + ); + + expect("aria-describedby" in lastTriggerProps).toBe(false); + }); +}); diff --git a/src/frontend/src/components/common/shadTooltipComponent/index.tsx b/src/frontend/src/components/common/shadTooltipComponent/index.tsx index 5c20c386dd07..f28e1a9e5b29 100644 --- a/src/frontend/src/components/common/shadTooltipComponent/index.tsx +++ b/src/frontend/src/components/common/shadTooltipComponent/index.tsx @@ -37,60 +37,67 @@ MemoizedTooltipContent.displayName = "MemoizedTooltipContent"; // Memoize the main tooltip component const ShadTooltip = memo( - forwardRef( - ( - { - content, - side, - asChild = true, - children, - styleClasses, - delayDuration = 500, - open, - align, - setOpen, - avoidCollisions = false, - }, - ref, - ) => { - // Early return if no content - if (!content) { - return children; - } + forwardRef((props, ref) => { + const { + content, + side, + asChild = true, + children, + styleClasses, + delayDuration = 500, + open, + align, + setOpen, + avoidCollisions = false, + ariaDescribedBy, + } = props; + + // Early return if no content + if (!content) { + return children; + } - // Memoize className concatenation - const tooltipClassName = useMemo( - () => cn(BASE_TOOLTIP_CLASSES, styleClasses), - [styleClasses], - ); + // Memoize className concatenation + const tooltipClassName = useMemo( + () => cn(BASE_TOOLTIP_CLASSES, styleClasses), + [styleClasses], + ); + + // Memoize tooltip props + const tooltipProps = useMemo( + () => ({ + defaultOpen: !children, + open, + onOpenChange: setOpen, + delayDuration, + }), + [children, open, setOpen, delayDuration], + ); - // Memoize tooltip props - const tooltipProps = useMemo( - () => ({ - defaultOpen: !children, - open, - onOpenChange: setOpen, - delayDuration, - }), - [children, open, setOpen, delayDuration], - ); + const hasAriaDescribedByOverride = "ariaDescribedBy" in props; - return ( - - {children} - - {content} - - - ); - }, - ), + return ( + + + {children} + + + {content} + + + ); + }), ); // Add display name for dev tools diff --git a/src/frontend/src/components/core/appHeaderComponent/__tests__/appHeader.a11y.test.tsx b/src/frontend/src/components/core/appHeaderComponent/__tests__/appHeader.a11y.test.tsx index afe686eb629c..54a67edafe13 100644 --- a/src/frontend/src/components/core/appHeaderComponent/__tests__/appHeader.a11y.test.tsx +++ b/src/frontend/src/components/core/appHeaderComponent/__tests__/appHeader.a11y.test.tsx @@ -1,5 +1,6 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { TooltipProvider } from "@/components/ui/tooltip"; +import { axe } from "@/utils/a11y-test"; import AppHeader from "../index"; // Mock heavy children — this suite only asserts the header shell semantics @@ -51,17 +52,12 @@ describe("AppHeader accessibility", () => { expect(screen.getByTestId("notification_button")).toBeInTheDocument(); }); - // Known gap (a11y-action-plan 3.3): the app header is a plain
, so - // there is no banner landmark for AT navigation. Fails until the fix lands. it("should_expose_header_as_banner_landmark", () => { renderHeader(); expect(screen.getByRole("banner")).toBeInTheDocument(); }); - // Known gap (a11y-action-plan 2.3): the notification bell button has no - // aria-label; its visible label span is CSS-hidden and the Bell icon is - // the only content. Fails until the fix lands. it("should_name_notification_bell_button", () => { renderHeader(); @@ -78,4 +74,10 @@ describe("AppHeader accessibility", () => { "Go to home", ); }); + + it("should_have_no_axe_violations", async () => { + const { container } = renderHeader(); + + expect(await axe(container)).toHaveNoViolations(); + }); }); diff --git a/src/frontend/src/components/core/appHeaderComponent/__tests__/appHeader.notificationDropdown.test.tsx b/src/frontend/src/components/core/appHeaderComponent/__tests__/appHeader.notificationDropdown.test.tsx new file mode 100644 index 000000000000..e6d90ea8dea0 --- /dev/null +++ b/src/frontend/src/components/core/appHeaderComponent/__tests__/appHeader.notificationDropdown.test.tsx @@ -0,0 +1,70 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { axe } from "@/utils/a11y-test"; +import AppHeader from "../index"; + +// This suite intentionally leaves @/alerts/alertDropDown unmocked — index.tsx +// wraps ShadTooltip's child in a SECOND AlertDropdown (see the outer/inner +// pair around the notification button), which mounts two independent +// Popovers on the same trigger. That's invisible under the pass-through +// mock used by appHeader.a11y.test.tsx, so it needs real-DOM coverage here. +jest.mock("@/assets/LangflowLogo.svg?react", () => ({ + __esModule: true, + default: () => null, +})); +jest.mock("@/components/common/modelProviderCountComponent", () => ({ + __esModule: true, + default: () => null, +})); +jest.mock("@/customization/components/custom-AccountMenu", () => ({ + __esModule: true, + default: () => null, +})); +jest.mock("@/customization/components/custom-langflow-counts", () => ({ + __esModule: true, + default: () => null, +})); +jest.mock("@/customization/components/custom-org-selector", () => ({ + __esModule: true, + CustomOrgSelector: () => null, +})); +jest.mock("@/customization/hooks/use-custom-navigate", () => ({ + useCustomNavigate: () => jest.fn(), +})); +jest.mock("../components/FlowMenu", () => ({ + __esModule: true, + default: () => null, +})); + +const renderHeader = () => + render( + + + , + ); + +describe("AppHeader notification dropdown (real AlertDropdown, unmocked)", () => { + it("should_have_no_axe_violations when closed", async () => { + const { container } = renderHeader(); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("opens exactly one notification popover, not a duplicate nested one", () => { + renderHeader(); + + fireEvent.click(screen.getByTestId("notification_button")); + + expect(screen.getAllByTestId("notification-dropdown-content")).toHaveLength( + 1, + ); + }); + + it("should_have_no_axe_violations when the notification popover is open", async () => { + const { container } = renderHeader(); + + fireEvent.click(screen.getByTestId("notification_button")); + + expect(await axe(container)).toHaveNoViolations(); + }); +}); diff --git a/src/frontend/src/components/core/appHeaderComponent/components/__tests__/langflow-counts.test.tsx b/src/frontend/src/components/core/appHeaderComponent/components/__tests__/langflow-counts.test.tsx new file mode 100644 index 000000000000..49dda451cad4 --- /dev/null +++ b/src/frontend/src/components/core/appHeaderComponent/components/__tests__/langflow-counts.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from "@testing-library/react"; +import { axe } from "@/utils/a11y-test"; +import { LangflowCounts } from "../langflow-counts"; + +type DarkStoreState = { + stars: number; + discordCount: number; +}; + +let mockDarkStoreState: DarkStoreState = { + stars: 0, + discordCount: 0, +}; + +jest.mock("@/stores/darkStore", () => ({ + useDarkStore: (selector: (state: DarkStoreState) => unknown) => + selector(mockDarkStoreState), +})); + +describe("LangflowCounts", () => { + beforeEach(() => { + mockDarkStoreState = { stars: 0, discordCount: 0 }; + }); + + it("should_have_no_axe_violations with zero counts", async () => { + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should_have_no_axe_violations with non-zero counts", async () => { + mockDarkStoreState = { stars: 42000, discordCount: 1234 }; + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("exposes accessible names for the GitHub and Discord links via sr-only text", () => { + render(); + + expect( + screen.getByRole("button", { name: "Go to GitHub repo" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: "Go to Discord server" }), + ).toBeInTheDocument(); + }); + + it("hides the numeric star/discord counts from the accessibility tree (surfaced only via sr-only name)", () => { + mockDarkStoreState = { stars: 42000, discordCount: 1234 }; + render(); + + const starsCount = screen.getByText("42k"); + expect(starsCount).toHaveAttribute("aria-hidden", "true"); + + const discordCountEl = screen.getByText("1k"); + expect(discordCountEl).toHaveAttribute("aria-hidden", "true"); + }); +}); diff --git a/src/frontend/src/components/core/appHeaderComponent/components/langflow-counts.tsx b/src/frontend/src/components/core/appHeaderComponent/components/langflow-counts.tsx index bafd4f2e4e41..ff412903ba71 100644 --- a/src/frontend/src/components/core/appHeaderComponent/components/langflow-counts.tsx +++ b/src/frontend/src/components/core/appHeaderComponent/components/langflow-counts.tsx @@ -21,6 +21,7 @@ export const LangflowCounts = () => { content={t("header.goToGithub")} side="bottom" styleClasses="z-10" + ariaDescribedBy={undefined} > -); + + ); + + // Menu toggles need their own accessible pattern (role="menuitemcheckbox", + // a single interactive control) rather than nesting an independently + // interactive Switch inside a menu item — Radix's CheckboxItem is the + // purpose-built primitive for exactly this. + if (hasToogle) { + return ( + event.preventDefault()} + > + {content} + + ); + } + + return ( + + {content} + + ); +}; export default DropdownControlButton; diff --git a/src/frontend/src/components/core/canvasControlsComponent/HelpDropdownView.tsx b/src/frontend/src/components/core/canvasControlsComponent/HelpDropdownView.tsx index 44b5de6a194e..f2e631aab944 100644 --- a/src/frontend/src/components/core/canvasControlsComponent/HelpDropdownView.tsx +++ b/src/frontend/src/components/core/canvasControlsComponent/HelpDropdownView.tsx @@ -41,6 +41,7 @@ export const HelpDropdownView = ({ size="icon" className="group flex h-8 w-8 items-center justify-center rounded-md hover:bg-muted" title={t("help.title")} + aria-label={t("help.title")} data-testid="canvas_controls_dropdown_help" > and spreads its props, so we can @@ -14,19 +15,26 @@ jest.mock("@xyflow/react", () => ({ // ShadTooltip is the single, styled tooltip — expose its content so we can // confirm the button's label is surfaced through it (and only it). +let lastShadTooltipProps: Record = {}; + jest.mock("@/components/common/shadTooltipComponent", () => ({ __esModule: true, default: ({ content, children, + ...props }: { content?: string; children?: ReactNode; - }) => ( -
- {children} -
- ), + [key: string]: unknown; + }) => { + lastShadTooltipProps = props; + return ( +
+ {children} +
+ ); + }, })); jest.mock("@/components/common/genericIconComponent", () => ({ @@ -50,6 +58,16 @@ describe("CanvasControlButton — single tooltip (no native title)", () => { />, ); + beforeEach(() => { + lastShadTooltipProps = {}; + }); + + it("should_have_no_axe_violations", async () => { + const { container } = setup(); + + expect(await axe(container)).toHaveNoViolations(); + }); + it("does not render a native title attribute (would be a second tooltip)", () => { setup(); const button = screen.getByRole("button"); @@ -64,4 +82,17 @@ describe("CanvasControlButton — single tooltip (no native title)", () => { "Bundles", ); }); + + it("wraps the real button (not a decorative inner div) so aria-describedby suppression actually reaches it", () => { + setup(); + + // ShadTooltip must be the ancestor of the actual - ), -})); + }) => ; + return { + __esModule: true, + default: MockIcon, + ForwardedIconComponent: MockIcon, + }; +}); jest.mock("@/utils/utils", () => ({ - cn: (...args: any[]) => args.filter(Boolean).join(" "), + cn: (...args: (string | undefined | null | boolean)[]) => + args.filter(Boolean).join(" "), })); -jest.mock( - "../../parameterRenderComponent/components/toggleShadComponent", - () => ({ - __esModule: true, - default: ({ value, handleOnNewValue, id }: any) => ( -
{ - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleOnNewValue(); - } - }} - /> - ), - }), -); - jest.mock("../utils/canvasUtils", () => ({ getModifierKey: jest.fn(() => "⌘"), })); +// DropdownMenuItem/DropdownMenuCheckboxItem are real Radix menu primitives — +// they require a real DropdownMenu/DropdownMenuContent ancestor to work, so +// every render here goes through that context (kept open so the content is +// actually mounted). +const renderInMenu = (ui: ReactNode) => + render( + + {ui} + , + ); + describe("DropdownControlButton", () => { const defaultProps = { testId: "test-button", @@ -61,53 +54,80 @@ describe("DropdownControlButton", () => { jest.clearAllMocks(); }); - it("renders basic button with label", () => { - render(); + it("should_have_no_axe_violations as a plain menuitem", async () => { + const { container } = renderInMenu( + , + ); - expect(screen.getByTestId("test-button")).toBeInTheDocument(); + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should_have_no_axe_violations as a menuitemcheckbox (the previously-nested toggle)", async () => { + const { container } = renderInMenu( + , + ); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("renders as a menuitem with the label", () => { + renderInMenu(); + + const item = screen.getByTestId("test-button"); + expect(item).toBeInTheDocument(); + expect(item).toHaveAttribute("role", "menuitem"); expect(screen.getByText("Test Button")).toBeInTheDocument(); }); it("calls onClick handler when clicked", () => { const mockOnClick = jest.fn(); - render(); + renderInMenu( + , + ); fireEvent.click(screen.getByTestId("test-button")); expect(mockOnClick).toHaveBeenCalledTimes(1); }); it("renders with icon when iconName is provided", () => { - render(); + renderInMenu( + , + ); expect(screen.getByTestId("icon-test-icon")).toBeInTheDocument(); }); it("displays shortcut with modifier key", () => { - render(); + renderInMenu(); expect(screen.getByText("⌘")).toBeInTheDocument(); expect(screen.getByText("+")).toBeInTheDocument(); }); it("applies disabled state correctly", () => { - render(); + renderInMenu(); - const button = screen.getByTestId("test-button"); - expect(button).toBeDisabled(); + const item = screen.getByTestId("test-button"); + expect(item).toHaveAttribute("aria-disabled", "true"); }); - it("sets tooltip text as title attribute", () => { + it("exposes an accessible name via aria-label (and title as a hover hint)", () => { const tooltipText = "This is a tooltip"; - render( + renderInMenu( , ); - const button = screen.getByTestId("test-button"); - expect(button).toHaveAttribute("title", tooltipText); + const item = screen.getByTestId("test-button"); + expect(item).toHaveAttribute("aria-label", tooltipText); + expect(item).toHaveAttribute("title", tooltipText); }); - it("renders toggle component when hasToogle is true", () => { - render( + it("renders as a menuitemcheckbox reflecting the toggle state when hasToogle is true", () => { + renderInMenu( { />, ); - expect(screen.getByTestId("toggle-test-button-toggle")).toBeInTheDocument(); - expect(screen.getByTestId("toggle-test-button-toggle")).toHaveAttribute( - "data-value", - "true", - ); + const item = screen.getByTestId("test-button"); + expect(item).toHaveAttribute("role", "menuitemcheckbox"); + expect(item).toHaveAttribute("aria-checked", "true"); }); - it("passes toggle value correctly to toggle component", () => { - render( + it("reflects an unchecked toggle state", () => { + renderInMenu( { />, ); - expect(screen.getByTestId("toggle-test-button-toggle")).toHaveAttribute( - "data-value", + expect(screen.getByTestId("test-button")).toHaveAttribute( + "aria-checked", "false", ); }); - it("handles toggle click through onClick prop", () => { + it("calls onClick (via onCheckedChange) when the toggle item is activated", () => { const mockOnClick = jest.fn(); - render( + renderInMenu( { />, ); - fireEvent.click(screen.getByTestId("toggle-test-button-toggle")); + fireEvent.click(screen.getByTestId("test-button")); expect(mockOnClick).toHaveBeenCalled(); }); it("renders without shortcut when not provided", () => { - render(); + renderInMenu(); expect(screen.queryByText("⌘")).not.toBeInTheDocument(); }); it("uses default onClick when not provided", () => { - render(); + renderInMenu(); // Should not throw error when clicked fireEvent.click(screen.getByTestId("test-button")); }); it("applies correct CSS classes for disabled state", () => { - render(); + renderInMenu(); - const button = screen.getByTestId("test-button"); - expect(button.className).toContain("cursor-not-allowed opacity-50"); + const item = screen.getByTestId("test-button"); + expect(item.className).toContain("cursor-not-allowed opacity-50"); }); it("renders empty label by default", () => { - render(); + renderInMenu(); - const button = screen.getByTestId("test-button"); - expect(button).toBeInTheDocument(); + expect(screen.getByTestId("test-button")).toBeInTheDocument(); }); }); diff --git a/src/frontend/src/components/core/canvasControlsComponent/__tests__/Dropdowns.test.tsx b/src/frontend/src/components/core/canvasControlsComponent/__tests__/Dropdowns.test.tsx index a3023dd649d0..f9f7b9a35884 100644 --- a/src/frontend/src/components/core/canvasControlsComponent/__tests__/Dropdowns.test.tsx +++ b/src/frontend/src/components/core/canvasControlsComponent/__tests__/Dropdowns.test.tsx @@ -1,5 +1,6 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { MemoryRouter, useNavigate } from "react-router-dom"; +import { axe } from "@/utils/a11y-test"; import HelpDropdown from "../HelpDropdown"; jest.mock("@/components/ui/button", () => ({ @@ -20,14 +21,35 @@ jest.mock("@/components/ui/dropdown-menu", () => ({
), DropdownMenuContent: ({ children, ...props }: any) => ( -
+
{children}
), + DropdownMenuItem: ({ children, onClick, ...props }: any) => ( + + ), + DropdownMenuCheckboxItem: ({ + children, + onCheckedChange, + checked, + ...props + }: any) => ( + + ), })); jest.mock("@/components/ui/separator", () => ({ - Separator: () =>
, + Separator: () =>
, })); jest.mock("@/components/common/genericIconComponent", () => ({ @@ -69,12 +91,15 @@ jest.mock("@/stores/darkStore", () => ({ }), })); +const mockFlowStoreState = { + helperLineEnabled: false, + setHelperLineEnabled: jest.fn(), +}; + jest.mock("@/stores/flowStore", () => ({ __esModule: true, - default: () => ({ - helperLineEnabled: false, - setHelperLineEnabled: jest.fn(), - }), + default: (selector: (state: typeof mockFlowStoreState) => unknown) => + selector(mockFlowStoreState), })); // Mock window.open @@ -88,13 +113,23 @@ describe("HelpDropdown", () => { (window.open as jest.Mock).mockClear(); }); + it("should_have_no_axe_violations", async () => { + const { container } = render( + + + , + ); + + expect(await axe(container)).toHaveNoViolations(); + }); + it("opens docs in new tab and navigates to shortcuts", () => { const mockNavigate = jest.fn(); (useNavigate as unknown as jest.Mock).mockReturnValue(mockNavigate); render( - {}} /> + , ); diff --git a/src/frontend/src/components/core/editFlowSettingsComponent/__tests__/editFlowSettingsComponent.test.tsx b/src/frontend/src/components/core/editFlowSettingsComponent/__tests__/editFlowSettingsComponent.test.tsx new file mode 100644 index 000000000000..0925e905916d --- /dev/null +++ b/src/frontend/src/components/core/editFlowSettingsComponent/__tests__/editFlowSettingsComponent.test.tsx @@ -0,0 +1,44 @@ +import * as Form from "@radix-ui/react-form"; +import { render, screen } from "@testing-library/react"; +import { axe } from "@/utils/a11y-test"; +import { EditFlowSettings } from "../index"; + +const renderWithForm = (ui: React.ReactElement) => + render({ui}); + +describe("EditFlowSettings lock switch", () => { + const defaultProps = { + name: "My Flow", + description: "", + setName: jest.fn(), + setDescription: jest.fn(), + }; + + it("should_have_no_axe_violations", async () => { + const { container } = renderWithForm( + , + ); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("exposes a descriptive accessible name on the lock switch", () => { + renderWithForm(); + + expect( + screen.getByRole("switch", { + name: "Lock flow switch", + }), + ).toBeInTheDocument(); + }); + + it("reflects the locked/unlocked state on the correctly named switch", () => { + renderWithForm(); + + expect( + screen.getByRole("switch", { + name: "Lock flow switch", + }), + ).toHaveAttribute("aria-checked", "true"); + }); +}); diff --git a/src/frontend/src/components/core/editFlowSettingsComponent/index.tsx b/src/frontend/src/components/core/editFlowSettingsComponent/index.tsx index cc8893b03789..0f4d964e4d9b 100644 --- a/src/frontend/src/components/core/editFlowSettingsComponent/index.tsx +++ b/src/frontend/src/components/core/editFlowSettingsComponent/index.tsx @@ -217,6 +217,7 @@ export const EditFlowSettings: React.FC< disabled={readOnly} className="data-[state=checked]:bg-primary ml-auto" data-testid="lock-flow-switch" + aria-label={t("flow.lockFlowAriaLabel")} />
diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/mcpComponent/__tests__/McpComponent.test.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/mcpComponent/__tests__/McpComponent.test.tsx index 8deeaa742e2a..be5633c2e461 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/components/mcpComponent/__tests__/McpComponent.test.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/components/mcpComponent/__tests__/McpComponent.test.tsx @@ -2,6 +2,7 @@ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { ReactNode } from "react"; import type { APIClassType } from "@/types/api"; +import { axe } from "@/utils/a11y-test"; import McpComponent from "../index"; const mockRefetchMCPServers = jest.fn(); @@ -113,6 +114,67 @@ describe("McpComponent", () => { ); }); + const defaultProps = () => ({ + id: "mcp-server", + value: { name: "broken-server", config: {} }, + disabled: false, + handleOnNewValue: jest.fn(), + editNode: false, + nodeId: "MCPTools-1", + nodeClass: { + template: { code: { value: "code" } }, + tool_mode: false, + } as APIClassType, + handleNodeClass: jest.fn(), + }); + + it("should_have_no_axe_violations with a server error shown", async () => { + const { container } = render(); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("should_have_no_axe_violations in the unsaved-config (clear/save) state", async () => { + const { container } = render( + , + ); + + expect(await axe(container)).toHaveNoViolations(); + }); + + it("names the save button when a pending server config is shown", () => { + render( + , + ); + + expect(screen.getByTestId("save-mcp-server-button")).toHaveAccessibleName( + "Save server", + ); + }); + + it("names the server-select button by its current value, and the clear button distinctly", () => { + render(); + expect(screen.getByTestId("mcp-server-dropdown")).toHaveAccessibleName( + "broken-server", + ); + + render( + , + ); + expect( + screen.getAllByTestId("mcp-server-dropdown")[1], + ).toHaveAccessibleName("Clear selected server"); + }); + it("shows the MCP server error and refreshes the node on demand", async () => { const user = userEvent.setup(); const nodeClass = { diff --git a/src/frontend/src/components/core/parameterRenderComponent/components/mcpComponent/index.tsx b/src/frontend/src/components/core/parameterRenderComponent/components/mcpComponent/index.tsx index 1d99fd0f3fb6..133c1b569fa1 100644 --- a/src/frontend/src/components/core/parameterRenderComponent/components/mcpComponent/index.tsx +++ b/src/frontend/src/components/core/parameterRenderComponent/components/mcpComponent/index.tsx @@ -230,7 +230,15 @@ export default function McpComponent({ diff --git a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/searchInput.tsx b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/searchInput.tsx index 93d901f81eb7..3037b55e69ff 100644 --- a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/searchInput.tsx +++ b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/searchInput.tsx @@ -29,6 +29,7 @@ export const SearchInput = memo(function SearchInput({ data-testid="sidebar-search-input" inputClassName="w-full rounded-lg bg-background text-sm" placeholder={t("sidebar.searchPlaceholder")} + aria-label={t("sidebar.searchAriaLabel")} onFocus={handleInputFocus} onBlur={handleInputBlur} onChange={handleInputChange} diff --git a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/sidebarDraggableComponent.tsx b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/sidebarDraggableComponent.tsx index d69d1845b847..d090db6ef3f6 100644 --- a/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/sidebarDraggableComponent.tsx +++ b/src/frontend/src/pages/FlowPage/components/flowSidebarComponent/components/sidebarDraggableComponent.tsx @@ -104,6 +104,14 @@ export const SidebarDraggableComponent = forwardRef( } } + const handleKeyDown = (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + e.stopPropagation(); + addComponent(apiClass, itemName); + } + }; + return (