O botão das ofertas rola até o contato com o tipo marcado e não quebra mais as âncoras; ofertas com o que você recebe e primeiro passo, logo depois da vitrine; bloco de IA fora da home; cartão largo da SEV no hero do celular; separador da faixa de clientes só entre itens; favicon de 13 para 1,8 KB; pré-carregamento só no hover e nunca de /#... fora da home; X do menu e cabeçalho opaco; WhatsApp com "o case"; /ia com 3 passos e modelo de relatório ilustrativo; Sobre alinhado ao currículo; CSP Report-Only com os domínios do Ads.
395 lines
13 KiB
TypeScript
395 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/navigation", () => ({ usePathname: jest.fn() }));
|
|
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();
|
|
});
|
|
});
|