Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(
<TooltipProvider>
<ShadTooltip content="Settings">
<button aria-label="Settings">X</button>
</ShadTooltip>
</TooltipProvider>,
);

expect(await axe(container)).toHaveNoViolations();
});

it("should_have_no_axe_violations when open, with the redundant description suppressed", async () => {
const { container } = render(
<TooltipProvider>
<ShadTooltip content="Settings" open ariaDescribedBy={undefined}>
<button aria-label="Settings">X</button>
</ShadTooltip>
</TooltipProvider>,
);

expect(await axe(container)).toHaveNoViolations();
});

it("should_have_no_axe_violations when open, with Radix's default aria-describedby intact", async () => {
const { container } = render(
<TooltipProvider>
<ShadTooltip content="Extra detail on hover" open>
<button aria-label="Settings">X</button>
</ShadTooltip>
</TooltipProvider>,
);

expect(await axe(container)).toHaveNoViolations();
});
});
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};

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(
<TooltipProvider>
<ShadTooltip content="Component settings" ariaDescribedBy={undefined}>
<button aria-label="Component settings">X</button>
</ShadTooltip>
</TooltipProvider>,
);

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(
<TooltipProvider>
<ShadTooltip content="Hint" ariaDescribedBy="custom-desc-id">
<button aria-label="Settings">X</button>
</ShadTooltip>
</TooltipProvider>,
);

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(
<TooltipProvider>
<ShadTooltip content="Some hint">
<button aria-label="Settings">X</button>
</ShadTooltip>
</TooltipProvider>,
);

expect("aria-describedby" in lastTriggerProps).toBe(false);
});
});
109 changes: 58 additions & 51 deletions src/frontend/src/components/common/shadTooltipComponent/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,60 +37,67 @@ MemoizedTooltipContent.displayName = "MemoizedTooltipContent";

// Memoize the main tooltip component
const ShadTooltip = memo(
forwardRef<HTMLDivElement, ShadToolTipType>(
(
{
content,
side,
asChild = true,
children,
styleClasses,
delayDuration = 500,
open,
align,
setOpen,
avoidCollisions = false,
},
ref,
) => {
// Early return if no content
if (!content) {
return children;
}
forwardRef<HTMLDivElement, ShadToolTipType>((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 (
<Tooltip {...tooltipProps}>
<TooltipTrigger asChild={asChild}>{children}</TooltipTrigger>
<MemoizedTooltipContent
ref={ref}
className={tooltipClassName}
side={side}
avoidCollisions={avoidCollisions}
align={align}
>
{content}
</MemoizedTooltipContent>
</Tooltip>
);
},
),
return (
<Tooltip {...tooltipProps}>
<TooltipTrigger
asChild={asChild}
{...(hasAriaDescribedByOverride
? { "aria-describedby": ariaDescribedBy }
: {})}
>
{children}
</TooltipTrigger>
<MemoizedTooltipContent
ref={ref}
className={tooltipClassName}
side={side}
avoidCollisions={avoidCollisions}
align={align}
>
{content}
</MemoizedTooltipContent>
</Tooltip>
);
}),
);

// Add display name for dev tools
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 <div>, 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();

Expand All @@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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(
<TooltipProvider>
<AppHeader />
</TooltipProvider>,
);

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();
});
});
Loading
Loading