Legenda do mergulho até Projetos cobrir a cena; transição de página sem blur e com fundo escuro; foco no conteúdo ao trocar de página; listas com marcador e dois pesos de título; vídeos da SEV e da SC Plus regravados sem quadros brancos; scrub a 12-15 fps (-30%); vitrine com uma parada de Tab e setas, contador no celular e CSS sob demanda; âncoras num módulo só (fragment-nav); FramePulse com setInterval; CSS morto removido.
301 lines
9.7 KiB
TypeScript
301 lines
9.7 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("refuses a link to the same path with another query string", () => {
|
|
window.history.replaceState(null, "", "/?a=1");
|
|
expect(
|
|
samePageHashTarget(clickOn(`<a href="/?a=2#alvo">x</a>`)),
|
|
).toBeNull();
|
|
expect(
|
|
samePageHashTarget(clickOn(`<a href="/?a=1#alvo">x</a>`))?.hash,
|
|
).toBe("#alvo");
|
|
});
|
|
});
|
|
|
|
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");
|
|
});
|
|
});
|