Files
Evandro Fonseca Junior 204ebd5fa5 fix(navegacao): foco em âncora de outra página, vitrine acessível e 3D mais leve
Foco cai no destino ao chegar de outra página; âncoras ignoram ?tipo=; movimento reduzido salta em todos os cliques; cronômetro da janela ampliada; degradê no topo de Projetos e legendas do mergulho com intervalos testados; vitrine acima da barra de cookies e com 2 paradas por projeto; reel-list.css no global; 3D montado em tarefas curtas, limitado a ~30 qps e estático com WebGL por software; pôsteres da vitrine de 330 para 158 KB; SC Plus regravado; scripts sem funções acima de complexidade 10.
2026-10-14 19:14:48 -03:00

312 lines
10 KiB
TypeScript

import { fireEvent, screen } from "@testing-library/react";
import {
FRAGMENT_NAV_BOOT_SCRIPT,
focusFragment,
isPlainPrimaryClick,
onFragmentClick,
samePageHashTarget,
setScrollRestoration,
} from "./fragment-nav";
/*
* Âncoras da home num lugar só: a leitura do clique (quem é um link de âncora da mesma página), o único ouvinte
* de clique e o único dono de history.scrollRestoration.
*/
describe("isPlainPrimaryClick", () => {
const click = (init: MouseEventInit = {}) =>
new MouseEvent("click", { cancelable: true, button: 0, ...init });
it("accepts a plain click of the main button", () => {
expect(isPlainPrimaryClick(click())).toBe(true);
});
it.each(["metaKey", "ctrlKey", "shiftKey", "altKey"] as const)(
"refuses a click with %s held (new tab, selection)",
(modifier) => {
expect(isPlainPrimaryClick(click({ [modifier]: true }))).toBe(false);
},
);
it.each([1, 2])("refuses mouse button %i", (button) => {
expect(isPlainPrimaryClick(click({ button }))).toBe(false);
});
it("refuses a click somebody else already cancelled", () => {
const event = click();
event.preventDefault();
expect(isPlainPrimaryClick(event)).toBe(false);
});
});
describe("samePageHashTarget", () => {
afterEach(() => {
document.body.innerHTML = "";
});
function clickOn(markup: string, selector: string) {
document.body.innerHTML = markup;
const target = document.querySelector(selector) as Element;
const event = new MouseEvent("click", { bubbles: true });
Object.defineProperty(event, "target", { value: target });
return event;
}
it("finds the element a same-page #link points to", () => {
const event = clickOn(
`<a href="/#alvo">ir</a><section id="alvo"></section>`,
"a",
);
const hit = samePageHashTarget(event);
expect(hit?.hash).toBe("#alvo");
expect(hit?.target.id).toBe("alvo");
});
it("works from inside the link (the text, an icon)", () => {
const event = clickOn(
`<a href="/#alvo"><span id="inside">ir</span></a><section id="alvo"></section>`,
"#inside",
);
expect(samePageHashTarget(event)?.target.id).toBe("alvo");
});
it.each([
["another site", `<a href="https://example.com/#alvo">x</a>`],
["another page of this site", `<a href="/outra#alvo">x</a>`],
["no anchor", `<a href="/">x</a>`],
["an empty anchor", `<a href="/#">x</a>`],
["a new tab", `<a href="/#alvo" target="_blank">x</a>`],
["an id that does not exist", `<a href="/#nada">x</a>`],
["a malformed percent-encoding", `<a href="/#%E0%A4%A">x</a>`],
["no link at all", `<p>x</p>`],
])("returns null for %s", (_label, markup) => {
const event = clickOn(
`${markup}<section id="alvo"></section>`,
markup.startsWith("<a") ? "a" : "p",
);
expect(samePageHashTarget(event)).toBeNull();
});
it("handles target=_self like a plain link", () => {
const event = clickOn(
`<a href="/#alvo" target="_self">x</a><section id="alvo"></section>`,
"a",
);
expect(samePageHashTarget(event)?.target.id).toBe("alvo");
});
it("ignores a click whose target is not an element", () => {
const event = new MouseEvent("click", { bubbles: true });
Object.defineProperty(event, "target", { value: document });
expect(samePageHashTarget(event)).toBeNull();
});
});
describe("samePageHashTarget (rules shared with the arrival code)", () => {
afterEach(() => {
document.body.innerHTML = "";
window.history.replaceState(null, "", "/");
});
function clickOn(markup: string) {
document.body.innerHTML = `${markup}<section id="alvo"></section>`;
const target = document.querySelector("a") as Element;
const event = new MouseEvent("click", { bubbles: true });
Object.defineProperty(event, "target", { value: target });
return event;
}
it("refuses a link that downloads a file", () => {
expect(
samePageHashTarget(clickOn(`<a href="/#alvo" download>x</a>`)),
).toBeNull();
});
it("ignores the query string: the page is the same, so the header anchors keep scrolling with ?tipo= in the URL", () => {
window.history.replaceState(null, "", "/?tipo=site");
// O link do cabeçalho não repete a busca; um link com outra busca ainda é a mesma página.
expect(samePageHashTarget(clickOn(`<a href="/#alvo">x</a>`))?.hash).toBe(
"#alvo",
);
expect(
samePageHashTarget(clickOn(`<a href="/?tipo=app#alvo">x</a>`))?.hash,
).toBe("#alvo");
expect(
samePageHashTarget(clickOn(`<a href="/?tipo=site#alvo">x</a>`))?.hash,
).toBe("#alvo");
});
it("still refuses a link to another path, with or without a query string", () => {
window.history.replaceState(null, "", "/?tipo=site");
expect(
samePageHashTarget(clickOn(`<a href="/outra?tipo=site#alvo">x</a>`)),
).toBeNull();
});
});
describe("onFragmentClick", () => {
let add: jest.SpyInstance;
let remove: jest.SpyInstance;
beforeEach(() => {
add = jest.spyOn(document, "addEventListener");
remove = jest.spyOn(document, "removeEventListener");
document.body.innerHTML = `<a href="/#alvo">ir</a><a href="/outra">fora</a><section id="alvo"></section>`;
window.history.replaceState(null, "", "/");
});
afterEach(() => {
jest.restoreAllMocks();
document.body.innerHTML = "";
});
const clicks = () => add.mock.calls.filter(([type]) => type === "click");
const unclicks = () => remove.mock.calls.filter(([type]) => type === "click");
const link = (name: string) => screen.getByRole("link", { name });
it("installs ONE document click listener no matter how many subscribe, and removes it with the last", () => {
const first = jest.fn();
const second = jest.fn();
const stopFirst = onFragmentClick(first);
const stopSecond = onFragmentClick(second);
expect(clicks()).toHaveLength(1);
expect(clicks()[0][2]).toBe(true);
fireEvent.click(link("ir"));
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledTimes(1);
stopFirst();
expect(unclicks()).toHaveLength(0);
fireEvent.click(link("ir"));
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledTimes(2);
stopSecond();
expect(unclicks()).toHaveLength(1);
});
it("hands every subscriber the same interpreted hit, in subscription order", () => {
const order: string[] = [];
const stops = [
onFragmentClick((hit) => order.push(`a:${hit.hash}:${hit.target.id}`)),
onFragmentClick((hit) => order.push(`b:${hit.hash}`)),
];
fireEvent.click(link("ir"));
expect(order).toEqual(["a:#alvo:alvo", "b:#alvo"]);
for (const stop of stops) stop();
});
it("ignores links to other pages, modified clicks and clicks somebody already cancelled", () => {
const handler = jest.fn();
const stop = onFragmentClick(handler);
fireEvent.click(link("fora"));
fireEvent.click(link("ir"), { ctrlKey: true });
fireEvent.click(link("ir"), { button: 1 });
expect(handler).not.toHaveBeenCalled();
stop();
});
it("lets a subscriber cancel the navigation (preventDefault) without the others being skipped", () => {
const second = jest.fn();
const stops = [
onFragmentClick((_hit, event) => event.preventDefault()),
onFragmentClick(second),
];
expect(fireEvent.click(link("ir"))).toBe(false);
expect(second).toHaveBeenCalledTimes(1);
for (const stop of stops) stop();
});
it("tolerates a subscriber that leaves while the click is being delivered", () => {
const calls: string[] = [];
const stopFirst = onFragmentClick(() => {
calls.push("first");
stopFirst();
});
const stopSecond = onFragmentClick(() => calls.push("second"));
fireEvent.click(link("ir"));
expect(calls).toEqual(["first", "second"]);
stopSecond();
});
});
describe("focusFragment", () => {
afterEach(() => {
document.body.innerHTML = "";
});
it("focuses the destination without scrolling and gives it a tabindex only when it has none", () => {
document.body.innerHTML = `<section id="a"></section><section id="b" tabindex="0"></section>`;
const a = document.getElementById("a") as HTMLElement;
const b = document.getElementById("b") as HTMLElement;
const focus = jest.spyOn(a, "focus");
focusFragment(a);
focusFragment(b);
expect(a).toHaveAttribute("tabindex", "-1");
expect(b).toHaveAttribute("tabindex", "0");
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
expect(b).toHaveFocus();
});
});
describe("history.scrollRestoration ownership", () => {
const run = () => new Function(FRAGMENT_NAV_BOOT_SCRIPT)();
// o jsdom não tem performance.getEntriesByType: o teste o define e o tira no fim
const navigationType = (type: string) =>
Object.defineProperty(performance, "getEntriesByType", {
configurable: true,
value: () => [{ type }],
});
afterEach(() => {
Reflect.deleteProperty(performance, "getEntriesByType");
window.history.scrollRestoration = "auto";
window.history.replaceState(null, "", "/");
});
it("setScrollRestoration is the runtime writer", () => {
setScrollRestoration("manual");
expect(window.history.scrollRestoration).toBe("manual");
setScrollRestoration("auto");
expect(window.history.scrollRestoration).toBe("auto");
});
it("the boot script (run before hydration) goes manual on a fresh arrival at / and back to auto on pagehide", () => {
navigationType("navigate");
run();
expect(window.history.scrollRestoration).toBe("manual");
window.dispatchEvent(new Event("pagehide"));
expect(window.history.scrollRestoration).toBe("auto");
});
it("the boot script leaves a history traversal to the browser", () => {
navigationType("back_forward");
run();
expect(window.history.scrollRestoration).toBe("auto");
});
it("the boot script drops the stale #fragment on a reload", () => {
window.history.replaceState(null, "", "/#contato");
navigationType("reload");
run();
expect(window.location.hash).toBe("");
});
it("the boot script does nothing outside the home", () => {
window.history.replaceState(null, "", "/cases");
navigationType("navigate");
run();
expect(window.history.scrollRestoration).toBe("auto");
});
});