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/navigation", () => ({ usePathname: jest.fn() })); jest.mock("next/link", () => ({ __esModule: true, default: ({ children, prefetch, ...props }: { children?: React.ReactNode; prefetch?: boolean | null } & Record< string, unknown >) => ( {children} ), })); /** 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 ( <>
); } 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(); 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(); openMenu(); expect(mobileMenu()).toBeInTheDocument(); jest.mocked(usePathname).mockReturnValue("/cases"); rerender(); 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(); openMenu(); rerender(); expect(mobileMenu()).toBeInTheDocument(); }); it("closes when the logo is tapped", () => { render(); 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(); 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(); 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(); 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(
); document.body.insertAdjacentHTML( "beforeend", '
', ); try { openMenu(); expect(main()).toHaveAttribute("inert"); } finally { main().remove(); } }); it("undoes everything when the header unmounts with the menu open", () => { const { unmount } = render(); 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(); 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(); 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(); 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(); // 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(); 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(); 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(); 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(); 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(); 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 ( <> ); } it("cycles through the policy link, Aceitar and Recusar before the header, and back from the contact button", () => { render(); 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(); openMenu(); act(() => screen.getByRole("button", { name: "Recusar" }).focus()); fireEvent.keyDown(document, { key: "Escape" }); expect(mobileMenu()).toBeNull(); expect(menuButton()).toHaveFocus(); }); });