Files

394 lines
13 KiB
TypeScript

import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { usePathname } from "next/navigation";
import { COOKIE_BAR_ID } from "@/lib/analytics";
import { Header } from "./Header";
/**
* O menu do celular aberto (complemento de Header.test.tsx): botão de contato acima da barra de cookies, fecha ao
* trocar de rota e ao tocar no logo, deixa o conteúdo e o rodapé inertes, trava a rolagem, prende o foco e fecha
* com Esc (ouvido no document), devolvendo o foco ao botão do menu.
*/
jest.mock("next/link", () => ({
__esModule: true,
default: ({
children,
prefetch,
...props
}: { children?: React.ReactNode; prefetch?: boolean | null } & Record<
string,
unknown
>) => (
<a data-prefetch={String(prefetch)} {...props}>
{children}
</a>
),
}));
/** O cabeçalho com as duas partes da página que o menu torna inertes: o conteúdo e o rodapé (ids do layout). */
function Page() {
return (
<>
<Header />
<div id="main-content" tabIndex={-1}>
<button type="button">Botão do conteúdo</button>
</div>
<div id="site-footer">
<a href="#privacidade">Privacidade</a>
</div>
</>
);
}
const main = () => document.getElementById("main-content") as HTMLElement;
const footer = () => document.getElementById("site-footer") as HTMLElement;
const menuButton = () =>
screen.getByRole("button", { name: /^(Abrir|Fechar) menu$/ });
const openMenu = () =>
fireEvent.click(screen.getByRole("button", { name: "Abrir menu" }));
const mobileMenu = () =>
screen.queryByRole("navigation", { name: "Navegação mobile" });
/** Os controles que o Tab percorre com o menu aberto: logo, botão do menu, os cinco links e o botão de contato do painel. */
function trapOrder() {
const panel = within(mobileMenu() as HTMLElement);
return [
screen.getByRole("link", { name: "Evandro.dev.br — início" }),
menuButton(),
...panel.getAllByRole("link"),
];
}
beforeEach(() => {
jest.mocked(usePathname).mockReturnValue("/");
document.documentElement.style.removeProperty("overflow");
});
afterEach(() => {
document.documentElement.style.removeProperty("overflow");
});
describe("Header: menu do celular e a barra de cookies", () => {
it("adds the height of the cookie bar to the bottom spacing, so the contact button stays above it", () => {
render(<Page />);
openMenu();
const panel = (mobileMenu() as HTMLElement).parentElement
?.parentElement as HTMLElement;
expect(panel.className).toContain(
"pb-[calc(var(--sp-6)+env(safe-area-inset-bottom)+var(--cookie-bar-height,0px))]",
);
});
});
describe("Header: o menu fecha sozinho", () => {
it("closes when the route changes", () => {
const { rerender } = render(<Page />);
openMenu();
expect(mobileMenu()).toBeInTheDocument();
jest.mocked(usePathname).mockReturnValue("/cases");
rerender(<Page />);
expect(mobileMenu()).toBeNull();
expect(menuButton()).toHaveAttribute("aria-expanded", "false");
expect(main()).not.toHaveAttribute("inert");
});
it("stays open on a re-render that keeps the same route", () => {
const { rerender } = render(<Page />);
openMenu();
rerender(<Page />);
expect(mobileMenu()).toBeInTheDocument();
});
it("closes when the logo is tapped", () => {
render(<Page />);
openMenu();
fireEvent.click(
screen.getByRole("link", { name: "Evandro.dev.br — início" }),
);
expect(mobileMenu()).toBeNull();
expect(main()).not.toHaveAttribute("inert");
expect(document.documentElement.style.overflow).toBe("");
});
it("closes when the window grows to the desktop layout, where the menu button no longer exists", () => {
const original = window.matchMedia;
let onChange: ((event: { matches: boolean }) => void) | undefined;
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
addEventListener: (_type: string, listener: typeof onChange) => {
onChange = listener;
},
removeEventListener: jest.fn(),
}));
try {
render(<Page />);
openMenu();
expect(window.matchMedia).toHaveBeenCalledWith("(min-width: 1280px)");
act(() => onChange?.({ matches: false }));
expect(mobileMenu()).toBeInTheDocument();
act(() => onChange?.({ matches: true }));
expect(mobileMenu()).toBeNull();
expect(document.documentElement.style.overflow).toBe("");
} finally {
window.matchMedia = original;
}
});
});
describe("Header: página inerte e sem rolagem com o menu aberto", () => {
it("makes the content and the footer inert and locks the page scroll while the menu is open, and undoes both on close", () => {
render(<Page />);
expect(main()).not.toHaveAttribute("inert");
openMenu();
expect(main()).toHaveAttribute("inert");
expect(footer()).toHaveAttribute("inert");
expect(document.documentElement.style.overflow).toBe("hidden");
// o próprio cabeçalho nunca fica inerte
expect(screen.getByRole("banner")).not.toHaveAttribute("inert");
fireEvent.click(menuButton());
expect(main()).not.toHaveAttribute("inert");
expect(footer()).not.toHaveAttribute("inert");
expect(document.documentElement.style.overflow).toBe("");
});
it("gives back the overflow the page already had", () => {
document.documentElement.style.overflow = "scroll";
render(<Page />);
openMenu();
expect(document.documentElement.style.overflow).toBe("hidden");
fireEvent.click(menuButton());
expect(document.documentElement.style.overflow).toBe("scroll");
});
it("works on a page without a footer wrapper (the parts that exist are still made inert)", () => {
render(<Header />);
document.body.insertAdjacentHTML(
"beforeend",
'<div id="main-content"></div>',
);
try {
openMenu();
expect(main()).toHaveAttribute("inert");
} finally {
main().remove();
}
});
it("undoes everything when the header unmounts with the menu open", () => {
const { unmount } = render(<Page />);
openMenu();
unmount();
expect(document.documentElement.style.overflow).toBe("");
});
it("releases the page before any other click handler of the document runs, so a link to #id finds its target focusable", () => {
render(<Page />);
openMenu();
const states: Array<{ inert: boolean; overflow: string }> = [];
const record = () =>
states.push({
inert: main().hasAttribute("inert"),
overflow: document.documentElement.style.overflow,
});
// o gerenciador de âncoras da home escuta o clique no document, na fase de captura
document.addEventListener("click", record, true);
try {
fireEvent.click(
within(mobileMenu() as HTMLElement).getByRole("link", {
name: "Sobre",
}),
);
} finally {
document.removeEventListener("click", record, true);
}
expect(states).toEqual([{ inert: false, overflow: "" }]);
expect(mobileMenu()).toBeNull();
});
it("does not release the page for a click outside the header", () => {
render(<Page />);
openMenu();
// com o conteúdo inerte o clique real nem chega; o teste dispara direto para provar que o ouvinte só reage ao cabeçalho
fireEvent.click(footer().querySelector("a") as HTMLElement);
expect(main()).toHaveAttribute("inert");
expect(mobileMenu()).toBeInTheDocument();
});
});
describe("Header: Esc e foco com o menu aberto", () => {
it("closes with Escape heard on the document, wherever the focus is, and puts the focus back on the menu button", () => {
render(<Page />);
openMenu();
act(() => document.body.focus());
expect(fireEvent.keyDown(document, { key: "Escape" })).toBe(false);
expect(mobileMenu()).toBeNull();
expect(main()).not.toHaveAttribute("inert");
expect(menuButton()).toHaveAttribute("aria-expanded", "false");
expect(menuButton()).toHaveFocus();
});
it("ignores Escape while the menu is closed", () => {
render(<Page />);
// sem menu aberto o ouvinte nem existe: o evento segue sem ser cancelado
expect(fireEvent.keyDown(document, { key: "Escape" })).toBe(true);
expect(mobileMenu()).toBeNull();
});
it("traps Tab: from the last control it goes to the first, and Shift+Tab from the first goes to the last", () => {
render(<Page />);
openMenu();
const order = trapOrder();
const first = order[0];
const last = order[order.length - 1];
// o botão de contato do painel é o último; o da barra de desktop e a navegação de desktop ficam fora do ciclo
expect(last).toHaveAccessibleName("Falar sobre meu projeto");
expect(order).toHaveLength(1 + 1 + 5 + 1);
act(() => last.focus());
expect(fireEvent.keyDown(last, { key: "Tab" })).toBe(false);
expect(first).toHaveFocus();
expect(fireEvent.keyDown(first, { key: "Tab", shiftKey: true })).toBe(
false,
);
expect(last).toHaveFocus();
});
it("lets Tab move normally between two controls of the cycle", () => {
render(<Page />);
openMenu();
const order = trapOrder();
act(() => order[2].focus());
expect(fireEvent.keyDown(order[2], { key: "Tab" })).toBe(true);
expect(fireEvent.keyDown(order[2], { key: "Tab", shiftKey: true })).toBe(
true,
);
});
it("brings the focus back into the cycle when it is outside it, in the direction of the key", () => {
render(<Page />);
openMenu();
const order = trapOrder();
act(() => document.body.focus());
expect(fireEvent.keyDown(document.body, { key: "Tab" })).toBe(false);
expect(order[0]).toHaveFocus();
act(() => document.body.focus());
expect(
fireEvent.keyDown(document.body, { key: "Tab", shiftKey: true }),
).toBe(false);
expect(order[order.length - 1]).toHaveFocus();
});
it("does not trap Tab while the menu is closed", () => {
render(<Page />);
act(() => document.body.focus());
expect(fireEvent.keyDown(document.body, { key: "Tab" })).toBe(true);
});
it("closes with the menu button and keeps the focus on it", () => {
render(<Page />);
openMenu();
act(() => menuButton().focus());
fireEvent.click(menuButton());
expect(mobileMenu()).toBeNull();
expect(menuButton()).toHaveFocus();
});
});
describe("Header: a barra de cookies aberta entra na armadilha de foco", () => {
/** Como no layout (GoogleAnalytics.tsx): a barra vem antes do cabeçalho e não fica inerte; link da política, Aceitar e Recusar. */
function PageWithCookieBar() {
return (
<>
<section id={COOKIE_BAR_ID} role="dialog" aria-label="Cookies">
<p>
Usamos métricas de navegação do Google só se você permitir.{" "}
<a href="/privacidade">Política de privacidade</a>
</p>
<button type="button">Aceitar</button>
<button type="button">Recusar</button>
</section>
<Page />
</>
);
}
it("cycles through the policy link, Aceitar and Recusar before the header, and back from the contact button", () => {
render(<PageWithCookieBar />);
openMenu();
const policy = screen.getByRole("link", {
name: "Política de privacidade",
});
const accept = screen.getByRole("button", { name: "Aceitar" });
const decline = screen.getByRole("button", { name: "Recusar" });
const order = trapOrder();
const last = order[order.length - 1];
expect(policy).not.toHaveAttribute("inert");
expect(accept).not.toHaveAttribute("inert");
// do botão de contato (o último do cabeçalho) o Tab vai para o link da política, em vez de dar a volta no cabeçalho
act(() => last.focus());
expect(fireEvent.keyDown(last, { key: "Tab" })).toBe(false);
expect(policy).toHaveFocus();
// entre o link, Aceitar e Recusar o Tab segue normal; de Recusar vai para o logo, o primeiro do cabeçalho
expect(fireEvent.keyDown(policy, { key: "Tab" })).toBe(true);
act(() => accept.focus());
expect(fireEvent.keyDown(accept, { key: "Tab" })).toBe(true);
act(() => decline.focus());
expect(fireEvent.keyDown(decline, { key: "Tab" })).toBe(true);
// Shift+Tab do link da política (o primeiro do ciclo) volta ao botão de contato
act(() => policy.focus());
expect(fireEvent.keyDown(policy, { key: "Tab", shiftKey: true })).toBe(
false,
);
expect(last).toHaveFocus();
// e Shift+Tab de Aceitar ou de Recusar não é preso: o navegador anda um controle para trás
act(() => accept.focus());
expect(fireEvent.keyDown(accept, { key: "Tab", shiftKey: true })).toBe(
true,
);
});
it("closes with Escape and puts the focus back on the menu button, even from the cookie bar", () => {
render(<PageWithCookieBar />);
openMenu();
act(() => screen.getByRole("button", { name: "Recusar" }).focus());
fireEvent.keyDown(document, { key: "Escape" });
expect(mobileMenu()).toBeNull();
expect(menuButton()).toHaveFocus();
});
});