1
What we are building A dropdown that opens on click or keyboard, respects Escape, and closes when the user clicks outside.
What we are building For website navigation, the ARIA Authoring Practices Guide recommends the disclosure pattern — a plain<button> that toggles a hidden list — over the full role="menu" pattern. It is simpler, more compatible with every screen reader, and the one you should reach for first.
Testing approach Pattern: render → axe → click trigger → assert aria-expanded flips → Escape → focus returns .
Jest + Testing Library + jest-axe AccessibleDropdown.test.jsx Copy // Jest + @testing-library/react + jest-axe
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
import AccessibleDropdown from "./AccessibleDropdown";
expect.extend(toHaveNoViolations);
const items = [{ href: "/a", label: "A" }, { href: "/b", label: "B" }];
test("trigger exposes aria-expanded state", async () => {
const user = userEvent.setup();
render(<AccessibleDropdown label="Open" items={items} />);
const btn = screen.getByRole("button", { name: "Open" });
expect(btn).toHaveAttribute("aria-expanded", "false");
await user.click(btn);
expect(btn).toHaveAttribute("aria-expanded", "true");
});
test("Escape closes the menu and returns focus to the trigger", async () => {
const user = userEvent.setup();
render(<AccessibleDropdown label="Open" items={items} />);
const btn = screen.getByRole("button", { name: "Open" });
await user.click(btn);
await user.keyboard("{Escape}");
expect(btn).toHaveAttribute("aria-expanded", "false");
expect(btn).toHaveFocus();
});
test("has no axe violations", async () => {
const { container } = render(
<AccessibleDropdown label="Open" items={items} />
);
expect(await axe(container)).toHaveNoViolations();
});Vitest + Testing Library AccessibleDropdown.test.jsx Copy // Vitest + @testing-library/react
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import AccessibleDropdown from "./AccessibleDropdown";
test("menu hidden by default", () => {
render(<AccessibleDropdown label="Open" items={[{ href:"/a", label:"A" }]} />);
expect(screen.getByRole("link", { name: "A", hidden: true })).toBeInTheDocument();
});Playwright + @axe-core/playwright // Playwright + @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
test("dropdown-menu page has no serious a11y violations", async ({ page }) => {
await page.goto("/en/accessible-dropdown-menu");
const results = await new AxeBuilder({ page })
.withTags(["wcag2a", "wcag2aa", "wcag22aa"])
.analyze();
const serious = results.violations.filter(
(v) => v.impact === "critical" || v.impact === "serious"
);
expect(serious).toEqual([]);
});
test("click outside closes the menu", async ({ page }) => {
await page.goto("/en/accessible-dropdown-menu");
const trigger = page.getByRole("button", { name: "Products" }).first();
await trigger.click();
await expect(trigger).toHaveAttribute("aria-expanded", "true");
await page.locator("body").click({ position: { x: 10, y: 10 } });
await expect(trigger).toHaveAttribute("aria-expanded", "false");
});Reality check: axe will not catch a hover-only menu — the menu is already visible when the SR scans the DOM. Always Tab through the page with the mouse disconnected.