perf(movimento): uma transição, 3D leve e vídeo certo por aparelho
Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato.
This commit is contained in:
1 parent
8d07b8c6bb
commit
c9a0b80656
48 files changed
+7210
-1241
No files matched your search
@@ -0,0 +1,720 @@
|
||||
import { act, render } from "@testing-library/react";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
import { useRef } from "react";
|
||||
import { IDLE_SECONDS, useCinematicScroll } from "./useCinematicScroll";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de useCinematicScroll (complemento de useCinematicScroll.test.tsx):
|
||||
* - onClick (âncoras): cada guarda que devolve o clique ao navegador, alvo dentro de uma cena x a própria cena
|
||||
* x fora de qualquer cena, desconto do cabeçalho, pushState só quando a âncora muda, foco no alvo;
|
||||
* - salto direto (acima de 3 telas) x rolagem de até 0,8 s, nos limites exatos;
|
||||
* - criação do Lenis (opções, ligação com o ScrollTrigger), relógio que dorme e acorda, desmontagem antes das
|
||||
* importações terminarem, ref vazia, e o ResizeObserver que remede as passagens com atraso.
|
||||
* GSAP, ScrollTrigger e Lenis são trocados por fakes que registram o que recebem.
|
||||
*/
|
||||
|
||||
type Call = unknown[];
|
||||
const mockTimelines: Array<{ calls: Call[] }> = [];
|
||||
const mockTicker = {
|
||||
add: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
lagSmoothing: jest.fn(),
|
||||
time: 0,
|
||||
};
|
||||
const mockLenis: MockLenis[] = [];
|
||||
|
||||
class MockLenis {
|
||||
isScrolling: false | "smooth" = false;
|
||||
on = jest.fn();
|
||||
raf = jest.fn();
|
||||
scrollTo = jest.fn();
|
||||
destroy = jest.fn();
|
||||
constructor(public options: unknown) {
|
||||
mockLenis.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
jest.mock("gsap", () => ({
|
||||
gsap: {
|
||||
registerPlugin: jest.fn(),
|
||||
ticker: mockTicker,
|
||||
context: (fn: () => void) => {
|
||||
fn();
|
||||
return { revert: jest.fn() };
|
||||
},
|
||||
timeline: () => {
|
||||
const record = { calls: [] as Call[] };
|
||||
mockTimelines.push(record);
|
||||
const chain = {
|
||||
fromTo: (...args: Call) => {
|
||||
record.calls.push(args);
|
||||
return chain;
|
||||
},
|
||||
};
|
||||
return chain;
|
||||
},
|
||||
},
|
||||
}));
|
||||
jest.mock("gsap/ScrollTrigger", () => ({
|
||||
ScrollTrigger: { update: jest.fn(), refresh: jest.fn() },
|
||||
}));
|
||||
jest.mock("lenis", () => ({
|
||||
__esModule: true,
|
||||
get default() {
|
||||
return MockLenis;
|
||||
},
|
||||
}));
|
||||
|
||||
const TOPS: Record<string, number> = {
|
||||
home: 0,
|
||||
projects: 1800,
|
||||
"projects-title": 1900,
|
||||
edge: 2400,
|
||||
contact: 25000,
|
||||
"footer-note": 26000,
|
||||
};
|
||||
|
||||
function Home({ attach = true }: { attach?: boolean }) {
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
useCinematicScroll(ref);
|
||||
return (
|
||||
<>
|
||||
<main ref={attach ? ref : undefined}>
|
||||
<section id="home" data-top={TOPS.home}>
|
||||
<a href="/#contact">contato</a>
|
||||
<a href="/#projects">projetos</a>
|
||||
<a href="/#projects-title">titulo dos projetos</a>
|
||||
<a href="/#edge">limite</a>
|
||||
<a href="/#footer-note">nota fora da cena</a>
|
||||
<a href="/#nao-existe">fantasma</a>
|
||||
<a href="/#%E0%A4%A">malformado</a>
|
||||
<a href="/outra#contact">outra pagina</a>
|
||||
<a href="https://example.com/#contact">outro site</a>
|
||||
<a href="/">sem ancora</a>
|
||||
<a href="/#">ancora vazia</a>
|
||||
<a href="/#contact" target="_blank" rel="noopener">
|
||||
nova aba
|
||||
</a>
|
||||
<a href="/#contact" target="_self">
|
||||
mesma aba
|
||||
</a>
|
||||
<svg aria-hidden="true">
|
||||
<a href="/#contact">
|
||||
<text>svg</text>
|
||||
</a>
|
||||
</svg>
|
||||
<p id="plain">texto</p>
|
||||
</section>
|
||||
<section id="projects" data-top={TOPS.projects}>
|
||||
<h2 id="projects-title" data-top={TOPS["projects-title"]}>
|
||||
Projetos
|
||||
</h2>
|
||||
</section>
|
||||
<section id="edge" data-top={TOPS.edge}>
|
||||
<h2>Limite</h2>
|
||||
</section>
|
||||
{/* biome-ignore lint/a11y/noNoninteractiveTabindex: alvo de âncora que já traz tabindex, para testar que ele é mantido */}
|
||||
<section id="contact" data-top={TOPS.contact} tabIndex={0}>
|
||||
<h2>Contato</h2>
|
||||
</section>
|
||||
</main>
|
||||
<p id="footer-note" data-top={TOPS["footer-note"]}>
|
||||
Nota
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const OriginalResizeObserver = global.ResizeObserver;
|
||||
const originalOffsetTop = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"offsetTop",
|
||||
);
|
||||
|
||||
function mockMedia(active: string[]) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: active.includes(query),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
}
|
||||
|
||||
async function flushImports() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
async function mount(props: { attach?: boolean } = {}) {
|
||||
const view = render(<Home {...props} />);
|
||||
await flushImports();
|
||||
return view;
|
||||
}
|
||||
|
||||
function link(text: string) {
|
||||
const found = [...document.querySelectorAll("a, svg a")].find(
|
||||
(element) => element.textContent === text,
|
||||
);
|
||||
if (!found) throw new Error(`Missing link ${text}`);
|
||||
return found as Element;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispara um clique e diz se o hook cancelou a ação padrão. A ação padrão do navegador (navegar) é cancelada
|
||||
* por um ouvinte na fase de bolha, depois de o hook ter decidido, para o jsdom não navegar de verdade.
|
||||
*/
|
||||
function click(
|
||||
target: Element | Document,
|
||||
init: MouseEventInit = {},
|
||||
): { prevented: boolean } {
|
||||
const event = new MouseEvent("click", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
...init,
|
||||
});
|
||||
let prevented = false;
|
||||
const observe = (clickEvent: Event) => {
|
||||
prevented = clickEvent.defaultPrevented;
|
||||
clickEvent.preventDefault();
|
||||
};
|
||||
window.addEventListener("click", observe);
|
||||
try {
|
||||
act(() => {
|
||||
target.dispatchEvent(event);
|
||||
});
|
||||
} finally {
|
||||
window.removeEventListener("click", observe);
|
||||
}
|
||||
return { prevented };
|
||||
}
|
||||
|
||||
function mockHeaderOffset(px: string) {
|
||||
jest
|
||||
.spyOn(window, "getComputedStyle")
|
||||
.mockReturnValue({ scrollPaddingTop: px } as CSSStyleDeclaration);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return Number(this.dataset.top ?? 0);
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalOffsetTop)
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
"offsetTop",
|
||||
originalOffsetTop,
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockTimelines.length = 0;
|
||||
mockLenis.length = 0;
|
||||
mockTicker.add.mockClear();
|
||||
mockTicker.remove.mockClear();
|
||||
mockTicker.lagSmoothing.mockClear();
|
||||
mockTicker.time = 0;
|
||||
jest.mocked(ScrollTrigger.update).mockClear();
|
||||
jest.mocked(ScrollTrigger.refresh).mockClear();
|
||||
mockMedia([]);
|
||||
window.history.replaceState(null, "", "/");
|
||||
jest.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
});
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
global.ResizeObserver = OriginalResizeObserver;
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("useCinematicScroll: cliques que voltam ao navegador", () => {
|
||||
it.each(["metaKey", "ctrlKey", "shiftKey", "altKey"] as const)(
|
||||
"path: click with %s held -> left alone (new tab / selection behavior)",
|
||||
async (modifier) => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link("contato"), { [modifier]: true });
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([1, 2])(
|
||||
"path: click with mouse button %i -> left alone",
|
||||
async (button) => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link("contato"), { button });
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("path: another handler already cancelled the click -> not handled again", async () => {
|
||||
await mount();
|
||||
const cancel = (event: Event) => event.preventDefault();
|
||||
window.addEventListener("click", cancel, true);
|
||||
try {
|
||||
click(link("contato"));
|
||||
} finally {
|
||||
window.removeEventListener("click", cancel, true);
|
||||
}
|
||||
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: click on text that is not inside a link -> ignored", async () => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(document.getElementById("plain") as Element);
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: the click target is the document itself (not an element) -> ignored", async () => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(document);
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: the closest link is an SVG anchor -> ignored", async () => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(
|
||||
document.querySelector("svg a text") as Element,
|
||||
);
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: link opens in a new tab -> ignored", async () => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link("nova aba"));
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: link with target=_self -> handled like a plain link", async () => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link("mesma aba"));
|
||||
|
||||
expect(prevented).toBe(true);
|
||||
expect(mockLenis[0].scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["to another site", "outro site"],
|
||||
["to another page of this site", "outra pagina"],
|
||||
["without an anchor", "sem ancora"],
|
||||
["with an empty anchor (#)", "ancora vazia"],
|
||||
])("path: link %s -> ignored", async (_label, text) => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link(text));
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: anchor with broken percent-encoding -> no error, left to the browser", async () => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link("malformado"));
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: anchor to an id that does not exist -> left to the browser", async () => {
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link("fantasma"));
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis[0].scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCinematicScroll: para onde a página vai", () => {
|
||||
it("path: target inside a scene -> goes to the target minus the fixed header, never above the scene top", async () => {
|
||||
await mount();
|
||||
mockHeaderOffset("80px");
|
||||
|
||||
click(link("titulo dos projetos"));
|
||||
|
||||
expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1900 - 80);
|
||||
});
|
||||
|
||||
it("path: header taller than the gap to the scene top -> clamped to the scene top", async () => {
|
||||
await mount();
|
||||
mockHeaderOffset("300px");
|
||||
|
||||
click(link("titulo dos projetos"));
|
||||
|
||||
expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1800);
|
||||
});
|
||||
|
||||
it("path: target is the whole scene -> the scene top, ignoring the header offset", async () => {
|
||||
await mount();
|
||||
mockHeaderOffset("80px");
|
||||
|
||||
click(link("projetos"));
|
||||
|
||||
expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1800);
|
||||
});
|
||||
|
||||
it("path: target outside every scene -> measured from the page top, minus the header", async () => {
|
||||
await mount();
|
||||
mockHeaderOffset("100px");
|
||||
|
||||
const { prevented } = click(link("nota fora da cena"));
|
||||
|
||||
expect(prevented).toBe(true);
|
||||
expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(26000 - 100);
|
||||
});
|
||||
|
||||
it("path: no readable header offset -> treated as zero", async () => {
|
||||
await mount();
|
||||
mockHeaderOffset("");
|
||||
|
||||
click(link("titulo dos projetos"));
|
||||
|
||||
expect(mockLenis[0].scrollTo.mock.calls[0][0]).toBe(1900);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCinematicScroll: histórico e foco", () => {
|
||||
it("path: a new anchor -> pushes it to the history and cancels the default jump", async () => {
|
||||
await mount();
|
||||
const push = jest.spyOn(window.history, "pushState");
|
||||
|
||||
const { prevented } = click(link("projetos"));
|
||||
|
||||
expect(prevented).toBe(true);
|
||||
expect(push).toHaveBeenCalledWith(null, "", "#projects");
|
||||
expect(window.location.hash).toBe("#projects");
|
||||
});
|
||||
|
||||
it("path: the anchor is already the current one -> the history is not touched, the page still scrolls", async () => {
|
||||
window.history.replaceState(null, "", "/#projects");
|
||||
await mount();
|
||||
const push = jest.spyOn(window.history, "pushState");
|
||||
|
||||
click(link("projetos"));
|
||||
|
||||
expect(push).not.toHaveBeenCalled();
|
||||
expect(mockLenis[0].scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: destination without tabindex -> gets tabindex -1 and the focus (no scroll jump)", async () => {
|
||||
await mount();
|
||||
const focus = jest.spyOn(HTMLElement.prototype, "focus");
|
||||
const target = document.getElementById("projects-title") as HTMLElement;
|
||||
expect(target.hasAttribute("tabindex")).toBe(false);
|
||||
|
||||
click(link("titulo dos projetos"));
|
||||
|
||||
expect(target.getAttribute("tabindex")).toBe("-1");
|
||||
expect(focus).toHaveBeenCalledWith({ preventScroll: true });
|
||||
expect(document.activeElement).toBe(target);
|
||||
});
|
||||
|
||||
it("path: destination that already has a tabindex -> kept as it was", async () => {
|
||||
await mount();
|
||||
const target = document.getElementById("contact") as HTMLElement;
|
||||
expect(target.getAttribute("tabindex")).toBe("0");
|
||||
|
||||
click(link("contato"));
|
||||
|
||||
expect(target.getAttribute("tabindex")).toBe("0");
|
||||
expect(document.activeElement).toBe(target);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCinematicScroll: salto direto x rolagem curta", () => {
|
||||
it("path: anchor exactly 3 screens away -> still animated, capped at 0.8 s", async () => {
|
||||
await mount();
|
||||
|
||||
click(link("limite")); // 2400 px = 3 telas de 800 px
|
||||
|
||||
const [, options] = mockLenis[0].scrollTo.mock.calls[0];
|
||||
expect(options.immediate).toBe(false);
|
||||
expect(options.duration).toBe(0.8);
|
||||
});
|
||||
|
||||
it("path: anchor just beyond 3 screens -> instant jump with zero duration", async () => {
|
||||
await mount();
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: 799,
|
||||
});
|
||||
|
||||
click(link("limite")); // 2400 px passam de 3 x 799
|
||||
|
||||
const [, options] = mockLenis[0].scrollTo.mock.calls[0];
|
||||
expect(options.immediate).toBe(true);
|
||||
expect(options.duration).toBe(0);
|
||||
});
|
||||
|
||||
it("path: anchor already at the current position -> a minimal animation (0.3 s), not a jump", async () => {
|
||||
Object.defineProperty(window, "scrollY", {
|
||||
configurable: true,
|
||||
value: 1800,
|
||||
});
|
||||
await mount();
|
||||
|
||||
click(link("projetos"));
|
||||
|
||||
const [top, options] = mockLenis[0].scrollTo.mock.calls[0];
|
||||
expect(top).toBe(1800);
|
||||
expect(options.immediate).toBe(false);
|
||||
expect(options.duration).toBeCloseTo(0.3, 9);
|
||||
});
|
||||
|
||||
it("path: scrolled far down, a near anchor above -> distance counts in absolute value", async () => {
|
||||
Object.defineProperty(window, "scrollY", {
|
||||
configurable: true,
|
||||
value: 2400,
|
||||
});
|
||||
await mount();
|
||||
|
||||
click(link("projetos")); // 600 px acima
|
||||
|
||||
const [, options] = mockLenis[0].scrollTo.mock.calls[0];
|
||||
expect(options.immediate).toBe(false);
|
||||
expect(options.duration).toBeCloseTo(0.3 + (600 / 800) * 0.2, 9);
|
||||
});
|
||||
|
||||
it("path: touch screen (no Lenis) -> the native scroll is used: smooth when near, instant when far", async () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
await mount();
|
||||
|
||||
click(link("projetos"));
|
||||
click(link("contato"));
|
||||
|
||||
expect(window.scrollTo).toHaveBeenNthCalledWith(1, {
|
||||
top: 1800,
|
||||
behavior: "smooth",
|
||||
});
|
||||
expect(window.scrollTo).toHaveBeenNthCalledWith(2, {
|
||||
top: 25000,
|
||||
behavior: "instant",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCinematicScroll: Lenis e o relógio do GSAP", () => {
|
||||
it("path: mouse -> Lenis gets the smoothing options and drives ScrollTrigger; the GSAP lag smoothing is turned off", async () => {
|
||||
await mount();
|
||||
|
||||
expect(mockLenis).toHaveLength(1);
|
||||
expect(mockLenis[0].options).toEqual({
|
||||
lerp: 0.075,
|
||||
wheelMultiplier: 0.85,
|
||||
});
|
||||
expect(mockLenis[0].on).toHaveBeenCalledWith(
|
||||
"scroll",
|
||||
ScrollTrigger.update,
|
||||
);
|
||||
expect(mockTicker.lagSmoothing).toHaveBeenCalledWith(0);
|
||||
expect(mockTicker.add).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: touch -> no Lenis, no ticker, no lag-smoothing change and the passages are still created", async () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
await mount();
|
||||
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||
expect(mockTicker.lagSmoothing).not.toHaveBeenCalled();
|
||||
expect(mockTimelines.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: touch -> window scroll events do not touch the clock", async () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
await mount();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
});
|
||||
|
||||
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: the clock went to sleep, then a native scroll event -> wakes it", async () => {
|
||||
await mount();
|
||||
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
|
||||
tick(IDLE_SECONDS + 1); // parado há mais que o tempo de espera
|
||||
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
|
||||
mockTicker.time = 5;
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
expect(mockTicker.add).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("path: the clock went to sleep, then an anchor click -> wakes it before scrolling", async () => {
|
||||
await mount();
|
||||
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
|
||||
tick(IDLE_SECONDS + 1);
|
||||
|
||||
click(link("projetos"));
|
||||
|
||||
expect(mockTicker.add).toHaveBeenCalledTimes(2);
|
||||
expect(mockLenis[0].scrollTo).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: the clock is already asleep and the page unmounts -> it is not removed twice", async () => {
|
||||
const view = await mount();
|
||||
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
|
||||
tick(IDLE_SECONDS + 1);
|
||||
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
|
||||
|
||||
view.unmount();
|
||||
|
||||
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
|
||||
expect(mockLenis[0].destroy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: Lenis still scrolling -> the tick keeps the clock awake and feeds it in milliseconds", async () => {
|
||||
await mount();
|
||||
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
|
||||
mockLenis[0].isScrolling = "smooth";
|
||||
|
||||
tick(IDLE_SECONDS * 10);
|
||||
tick(IDLE_SECONDS * 10 + IDLE_SECONDS / 2);
|
||||
|
||||
expect(mockLenis[0].raf).toHaveBeenLastCalledWith(
|
||||
(IDLE_SECONDS * 10 + IDLE_SECONDS / 2) * 1000,
|
||||
);
|
||||
expect(mockTicker.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCinematicScroll: montagem e desmontagem", () => {
|
||||
it("path: unmounted before the dynamic imports finish -> nothing is created", async () => {
|
||||
const view = render(<Home />);
|
||||
view.unmount();
|
||||
await flushImports();
|
||||
|
||||
expect(mockTimelines).toHaveLength(0);
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: ref not attached to an element -> the hook does nothing", async () => {
|
||||
await mount({ attach: false });
|
||||
|
||||
expect(mockTimelines).toHaveLength(0);
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("path: reduced motion -> nothing, and anchor clicks are left to the browser", async () => {
|
||||
mockMedia(["(prefers-reduced-motion: reduce)"]);
|
||||
await mount();
|
||||
|
||||
const { prevented } = click(link("projetos"));
|
||||
|
||||
expect(prevented).toBe(false);
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("path: unmount -> the document click listener and the wheel/scroll listeners are removed", async () => {
|
||||
const view = await mount();
|
||||
const removeDocument = jest.spyOn(document, "removeEventListener");
|
||||
const removeWindow = jest.spyOn(window, "removeEventListener");
|
||||
|
||||
view.unmount();
|
||||
|
||||
expect(removeDocument).toHaveBeenCalledWith(
|
||||
"click",
|
||||
expect.any(Function),
|
||||
true,
|
||||
);
|
||||
expect(removeWindow.mock.calls.map(([type]) => type)).toEqual(
|
||||
expect.arrayContaining(["wheel", "scroll"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCinematicScroll: remedida depois de a página mudar de altura", () => {
|
||||
type Resize = { callback: () => void; disconnect: jest.Mock };
|
||||
let resizers: Resize[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
resizers = [];
|
||||
global.ResizeObserver = class {
|
||||
private readonly record: Resize;
|
||||
constructor(callback: () => void) {
|
||||
this.record = { callback, disconnect: jest.fn() };
|
||||
resizers.push(this.record);
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {
|
||||
this.record.disconnect();
|
||||
}
|
||||
} as unknown as typeof ResizeObserver;
|
||||
});
|
||||
|
||||
it("path: several size changes in a row -> one refresh, 150 ms after the last", async () => {
|
||||
jest.useFakeTimers();
|
||||
await mount();
|
||||
|
||||
act(() => {
|
||||
resizers[0].callback();
|
||||
jest.advanceTimersByTime(100);
|
||||
resizers[0].callback();
|
||||
jest.advanceTimersByTime(149);
|
||||
});
|
||||
expect(ScrollTrigger.refresh).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1);
|
||||
});
|
||||
expect(ScrollTrigger.refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("path: unmount with a refresh pending -> the refresh never happens and the observer is disconnected", async () => {
|
||||
jest.useFakeTimers();
|
||||
const view = await mount();
|
||||
act(() => {
|
||||
resizers[0].callback();
|
||||
});
|
||||
|
||||
view.unmount();
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1000);
|
||||
});
|
||||
|
||||
expect(ScrollTrigger.refresh).not.toHaveBeenCalled();
|
||||
expect(resizers[0].disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user