perf(movimento): sem tela vazia, passagens em CSS e vitrine leve
Projetos sobem sobre o palco do hero e o celular tem hero de uma tela sem mergulho; passagens com animation-timeline em CSS, sem GSAP nem motion; troca da vitrine sem texto duplicado; moldura de celular só com pôster e scrub reencodado em 960 px (9,4 para 5,2 MB); 3D monta na primeira interação e para quando parado; transição entre páginas do mesmo segmento; foco em anel duplo; texto de 15 px no celular; código morto removido.
This commit is contained in:
1 parent
10f170abf4
commit
318a764190
77 files changed
+4293
-4951
No files matched your search
@@ -2,171 +2,41 @@ import { act, render } from "@testing-library/react";
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
anchorDuration,
|
||||
ENTER_OFFSET,
|
||||
enterScene,
|
||||
exitScene,
|
||||
anchorTop,
|
||||
IDLE_SECONDS,
|
||||
isPlainPrimaryClick,
|
||||
JUMP_SCREENS,
|
||||
MAX_ANCHOR_SECONDS,
|
||||
PASSAGE_END,
|
||||
samePageHashTarget,
|
||||
useCinematicScroll,
|
||||
} from "./useCinematicScroll";
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------
|
||||
* Passagem de verdade (GSAP real, sem ScrollTrigger): o que cada cena recebe no começo e no fim.
|
||||
* ------------------------------------------------------------------------------------------------ */
|
||||
/*
|
||||
* A rolagem da home sem biblioteca de animação por quadro: as passagens entre cenas são CSS; este hook só
|
||||
* liga o Lenis (mouse) e leva os links internos até o destino. Aqui: o hook montado (Lenis trocado por um
|
||||
* dublê que registra o que recebe) e as funções puras que ele usa.
|
||||
*/
|
||||
|
||||
// o resto do arquivo troca o GSAP por um fake: aqui vale o de verdade
|
||||
const { gsap: realGsap } = jest.requireActual<typeof import("gsap")>("gsap");
|
||||
// o GSAP e o ScrollTrigger saíram da página: se alguém voltar a importá-los, estes dublês quebram o teste
|
||||
jest.mock(
|
||||
"gsap",
|
||||
() => {
|
||||
throw new Error("gsap não deve ser carregado");
|
||||
},
|
||||
{ virtual: true },
|
||||
);
|
||||
jest.mock(
|
||||
"gsap/ScrollTrigger",
|
||||
() => {
|
||||
throw new Error("gsap/ScrollTrigger não deve ser carregado");
|
||||
},
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
function scene() {
|
||||
const element = document.createElement("section");
|
||||
document.body.append(element);
|
||||
return element;
|
||||
}
|
||||
|
||||
function timeline() {
|
||||
return realGsap.timeline({
|
||||
paused: true,
|
||||
defaults: { ease: "none", duration: 1 },
|
||||
});
|
||||
}
|
||||
|
||||
const translateY = (element: HTMLElement) =>
|
||||
Number(
|
||||
/translate(?:3d)?\(\s*[-\d.]+(?:px)?,\s*([-\d.]+)px/.exec(
|
||||
element.style.transform,
|
||||
)?.[1] ?? Number.NaN,
|
||||
);
|
||||
|
||||
describe("the single passage between scenes", () => {
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
it("brings the next scene in with a fade and a short vertical offset, nothing else", () => {
|
||||
expect(ENTER_OFFSET).toBeGreaterThanOrEqual(24);
|
||||
expect(ENTER_OFFSET).toBeLessThanOrEqual(40);
|
||||
|
||||
const next = scene();
|
||||
const tl = timeline();
|
||||
enterScene(tl, next);
|
||||
|
||||
tl.progress(0.0001);
|
||||
expect(Number(next.style.opacity)).toBeLessThan(0.01);
|
||||
expect(translateY(next)).toBeCloseTo(ENTER_OFFSET, 0);
|
||||
|
||||
tl.progress(0.5);
|
||||
expect(Number(next.style.opacity)).toBeGreaterThan(0.2);
|
||||
expect(Number(next.style.opacity)).toBeLessThan(1);
|
||||
expect(translateY(next)).toBeGreaterThan(0);
|
||||
expect(translateY(next)).toBeLessThan(ENTER_OFFSET);
|
||||
|
||||
// sem recorte, escala, giro nem visibility em nenhum ponto
|
||||
for (const progress of [0.0001, 0.3, 0.7]) {
|
||||
tl.progress(progress);
|
||||
expect(next.style.clipPath).toBe("");
|
||||
expect(next.style.visibility).toBe("");
|
||||
expect(next.style.transform).not.toMatch(/scale|rotate/);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves no inline style behind once the scene has arrived, and brings it back on the way up", () => {
|
||||
const next = scene();
|
||||
const tl = timeline();
|
||||
enterScene(tl, next);
|
||||
|
||||
tl.progress(1);
|
||||
expect(next.style.opacity).toBe("");
|
||||
expect(next.style.transform).toBe("");
|
||||
|
||||
tl.progress(0.4);
|
||||
expect(Number(next.style.opacity)).toBeLessThan(1);
|
||||
expect(next.style.transform).not.toBe("");
|
||||
});
|
||||
|
||||
it("fades the previous scene all the way to opacity 0 at the end of every passage", () => {
|
||||
const previous = scene();
|
||||
const tl = timeline();
|
||||
exitScene(tl, previous);
|
||||
|
||||
tl.progress(0.5);
|
||||
expect(Number(previous.style.opacity)).toBeGreaterThan(0);
|
||||
expect(Number(previous.style.opacity)).toBeLessThan(1);
|
||||
expect(previous.style.visibility).toBe("");
|
||||
expect(previous.style.clipPath).toBe("");
|
||||
|
||||
tl.progress(1);
|
||||
expect(previous.style.opacity).toBe("0");
|
||||
|
||||
// rolando de volta, a cena reaparece inteira
|
||||
tl.progress(0.0001);
|
||||
expect(Number(previous.style.opacity)).toBeGreaterThan(0.99);
|
||||
});
|
||||
|
||||
it("a scene in the middle of the page arrives, stays, and leaves at 0 without fighting itself", () => {
|
||||
const middle = scene();
|
||||
const arrive = timeline();
|
||||
const leave = timeline();
|
||||
enterScene(arrive, middle);
|
||||
exitScene(leave, middle);
|
||||
|
||||
arrive.progress(1);
|
||||
expect(middle.style.opacity).toBe("");
|
||||
leave.progress(1);
|
||||
expect(middle.style.opacity).toBe("0");
|
||||
leave.progress(0);
|
||||
arrive.progress(0.2);
|
||||
expect(Number(middle.style.opacity)).toBeLessThan(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("anchor travel", () => {
|
||||
it("jumps straight to far anchors and animates near ones for 0.8 s at most", () => {
|
||||
expect(MAX_ANCHOR_SECONDS).toBe(0.8);
|
||||
const viewport = 800;
|
||||
expect(anchorDuration(25000, viewport)).toBeNull();
|
||||
expect(anchorDuration(-25000, viewport)).toBeNull();
|
||||
expect(
|
||||
anchorDuration(viewport * (JUMP_SCREENS + 0.5), viewport),
|
||||
).toBeNull();
|
||||
|
||||
for (const distance of [0, 200, 800, 1600, viewport * JUMP_SCREENS]) {
|
||||
const seconds = anchorDuration(distance, viewport);
|
||||
expect(seconds).not.toBeNull();
|
||||
expect(seconds as number).toBeGreaterThan(0);
|
||||
expect(seconds as number).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------------------------------------
|
||||
* O hook: GSAP, ScrollTrigger e Lenis trocados por fakes que registram o que recebem.
|
||||
* ------------------------------------------------------------------------------------------------ */
|
||||
|
||||
type Call = unknown[];
|
||||
const mockTimelines: Array<{ vars: ScrollTriggerLike; calls: Call[] }> = [];
|
||||
const mockTicker = {
|
||||
add: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
lagSmoothing: jest.fn(),
|
||||
time: 0,
|
||||
};
|
||||
const mockLenis: MockLenis[] = [];
|
||||
|
||||
interface ScrollTriggerLike {
|
||||
scrollTrigger: {
|
||||
trigger: HTMLElement;
|
||||
start: () => number;
|
||||
end: () => number;
|
||||
scrub: boolean;
|
||||
onEnter?: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
class MockLenis {
|
||||
isScrolling: false | "smooth" = false;
|
||||
on = jest.fn();
|
||||
raf = jest.fn();
|
||||
scrollTo = jest.fn();
|
||||
destroy = jest.fn();
|
||||
@@ -175,30 +45,6 @@ class MockLenis {
|
||||
}
|
||||
}
|
||||
|
||||
jest.mock("gsap", () => ({
|
||||
gsap: {
|
||||
registerPlugin: jest.fn(),
|
||||
ticker: mockTicker,
|
||||
context: (fn: () => void) => {
|
||||
fn();
|
||||
return { revert: jest.fn() };
|
||||
},
|
||||
timeline: (vars: ScrollTriggerLike) => {
|
||||
const record = { vars, 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() {
|
||||
@@ -226,7 +72,6 @@ function Home() {
|
||||
<section id="projects" data-top={TOPS.projects}>
|
||||
<h2>Projetos</h2>
|
||||
</section>
|
||||
{/* uma cena mais curta que a tela: a saída dela não pode começar antes de a entrada terminar */}
|
||||
<section id="short" data-top={TOPS.short}>
|
||||
<h2>Curta</h2>
|
||||
</section>
|
||||
@@ -257,15 +102,20 @@ function mockMedia(active: string[]) {
|
||||
|
||||
async function mount() {
|
||||
const view = render(<Home />);
|
||||
// as importações dinâmicas do hook (gsap, ScrollTrigger, Lenis) resolvem em microtarefas
|
||||
// a importação dinâmica do Lenis resolve em microtarefas
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
const scenes = () =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>("main > section"));
|
||||
/** Quadros de animação pedidos pelo hook desde que o espião foi criado. */
|
||||
let frames: jest.SpyInstance;
|
||||
|
||||
const advance = (ms: number) =>
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(ms);
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetTop", {
|
||||
@@ -286,12 +136,9 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mockTimelines.length = 0;
|
||||
jest.useFakeTimers();
|
||||
frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
mockLenis.length = 0;
|
||||
mockTicker.add.mockClear();
|
||||
mockTicker.remove.mockClear();
|
||||
mockTicker.lagSmoothing.mockClear();
|
||||
mockTicker.time = 0;
|
||||
mockMedia([]);
|
||||
window.history.replaceState(null, "", "/");
|
||||
jest.spyOn(window, "scrollTo").mockImplementation(() => {});
|
||||
@@ -299,162 +146,286 @@ beforeEach(() => {
|
||||
configurable: true,
|
||||
value: 800,
|
||||
});
|
||||
Object.defineProperty(window, "scrollY", { configurable: true, value: 0 });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("anchor travel", () => {
|
||||
it("jumps straight to far anchors and animates near ones for 0.8 s at most", () => {
|
||||
expect(MAX_ANCHOR_SECONDS).toBe(0.8);
|
||||
const viewport = 800;
|
||||
expect(anchorDuration(25000, viewport)).toBeNull();
|
||||
expect(anchorDuration(-25000, viewport)).toBeNull();
|
||||
expect(
|
||||
anchorDuration(viewport * (JUMP_SCREENS + 0.5), viewport),
|
||||
).toBeNull();
|
||||
|
||||
for (const distance of [0, 200, 800, 1600, viewport * JUMP_SCREENS]) {
|
||||
const seconds = anchorDuration(distance, viewport);
|
||||
expect(seconds).not.toBeNull();
|
||||
expect(seconds as number).toBeGreaterThan(0);
|
||||
expect(seconds as number).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS);
|
||||
}
|
||||
});
|
||||
|
||||
it("takes longer for farther anchors, up to the cap, and the same time up or down", () => {
|
||||
const near = anchorDuration(200, 800) as number;
|
||||
const far = anchorDuration(1600, 800) as number;
|
||||
|
||||
expect(far).toBeGreaterThan(near);
|
||||
expect(anchorDuration(-1600, 800)).toBe(far);
|
||||
expect(anchorDuration(0, 0)).not.toBeNaN();
|
||||
});
|
||||
});
|
||||
|
||||
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("anchorTop", () => {
|
||||
const scenes = () => {
|
||||
document.body.innerHTML = `
|
||||
<section id="a" data-top="1800"><h2 id="a-title" data-top="1900">t</h2></section>
|
||||
<section id="b" data-top="2400"></section>
|
||||
<p id="loose" data-top="9000">x</p>`;
|
||||
return [...document.querySelectorAll<HTMLElement>("section")];
|
||||
};
|
||||
const byId = (id: string) => document.getElementById(id) as HTMLElement;
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = "";
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function headerOffset(px: string) {
|
||||
jest
|
||||
.spyOn(window, "getComputedStyle")
|
||||
.mockReturnValue({ scrollPaddingTop: px } as CSSStyleDeclaration);
|
||||
}
|
||||
|
||||
it("is the scene top when the target is the whole scene (the header offset is ignored)", () => {
|
||||
const all = scenes();
|
||||
headerOffset("80px");
|
||||
|
||||
expect(anchorTop(byId("a"), all)).toBe(1800);
|
||||
});
|
||||
|
||||
it("is the target minus the fixed header when it is inside a scene", () => {
|
||||
const all = scenes();
|
||||
headerOffset("80px");
|
||||
|
||||
expect(anchorTop(byId("a-title"), all)).toBe(1900 - 80);
|
||||
});
|
||||
|
||||
it("never goes above the top of the scene", () => {
|
||||
const all = scenes();
|
||||
headerOffset("300px");
|
||||
|
||||
expect(anchorTop(byId("a-title"), all)).toBe(1800);
|
||||
});
|
||||
|
||||
it("measures from the top of the page, minus the header, for a target outside every scene", () => {
|
||||
const all = scenes();
|
||||
headerOffset("100px");
|
||||
|
||||
expect(anchorTop(byId("loose"), all)).toBe(9000 - 100);
|
||||
});
|
||||
|
||||
it("treats a missing header offset as zero", () => {
|
||||
const all = scenes();
|
||||
headerOffset("");
|
||||
|
||||
expect(anchorTop(byId("a-title"), all)).toBe(1900);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCinematicScroll", () => {
|
||||
it("never hides a scene: no visibility, no sticky pin, nothing inline before the scroll runs", async () => {
|
||||
it("never hides, pins or restyles a scene: the passages are CSS, nothing inline", async () => {
|
||||
await mount();
|
||||
for (const element of scenes()) {
|
||||
expect(element.style.visibility).toBe("");
|
||||
expect(element.style.position).toBe("");
|
||||
expect(element.style.top).toBe("");
|
||||
expect(element.style.opacity).toBe("");
|
||||
expect(element.style.clipPath).toBe("");
|
||||
|
||||
for (const element of document.querySelectorAll<HTMLElement>(
|
||||
"main > section",
|
||||
)) {
|
||||
expect(element.getAttribute("style")).toBeNull();
|
||||
}
|
||||
// o texto do contato continua achável (Ctrl+F) e visível para leitores de tela
|
||||
const contact = document.getElementById("contact") as HTMLElement;
|
||||
expect(contact).toBeVisible();
|
||||
expect(contact).toHaveTextContent("Contato");
|
||||
expect(contact.closest("[aria-hidden]")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses one passage only: opacity and a vertical offset, with the previous scene ending at 0", async () => {
|
||||
await mount();
|
||||
const allowed = new Set([
|
||||
"opacity",
|
||||
"y",
|
||||
"ease",
|
||||
"clearProps",
|
||||
"immediateRender",
|
||||
]);
|
||||
const entering: HTMLElement[] = [];
|
||||
const leaving: HTMLElement[] = [];
|
||||
|
||||
for (const { vars, calls } of mockTimelines) {
|
||||
expect(vars.scrollTrigger.scrub).toBe(true);
|
||||
expect(calls).toHaveLength(1);
|
||||
const [target, from, to] = calls[0] as [
|
||||
HTMLElement,
|
||||
Record<string, unknown>,
|
||||
Record<string, unknown>,
|
||||
];
|
||||
for (const key of [...Object.keys(from), ...Object.keys(to)])
|
||||
expect(allowed.has(key)).toBe(true);
|
||||
expect(JSON.stringify([from, to])).not.toMatch(
|
||||
/autoAlpha|visibility|clipPath|scale|rotation|xPercent/,
|
||||
);
|
||||
if (to.opacity === 1) {
|
||||
entering.push(target);
|
||||
expect(from).toMatchObject({ opacity: 0, y: ENTER_OFFSET });
|
||||
expect(vars.scrollTrigger.trigger).toBe(target);
|
||||
} else {
|
||||
leaving.push(target);
|
||||
expect(from).toMatchObject({ opacity: 1 });
|
||||
expect(to.opacity).toBe(0);
|
||||
}
|
||||
}
|
||||
|
||||
const all = scenes();
|
||||
// todas entram, menos a primeira; todas saem, menos a última
|
||||
expect(entering).toEqual(all.slice(1));
|
||||
expect(leaving).toEqual(all.slice(0, -1));
|
||||
});
|
||||
|
||||
it("starts each exit only after the same scene finished entering, even for a scene shorter than the screen", async () => {
|
||||
await mount();
|
||||
const all = scenes();
|
||||
const ranges = new Map<
|
||||
HTMLElement,
|
||||
{ enter?: [number, number]; exit?: [number, number] }
|
||||
>();
|
||||
for (const { vars, calls } of mockTimelines) {
|
||||
const target = calls[0][0] as HTMLElement;
|
||||
const to = calls[0][2] as { opacity: number };
|
||||
const { start, end } = vars.scrollTrigger;
|
||||
const entry = ranges.get(target) ?? {};
|
||||
if (to.opacity === 1) entry.enter = [start(), end()];
|
||||
else entry.exit = [start(), end()];
|
||||
ranges.set(target, entry);
|
||||
}
|
||||
|
||||
const vh = window.innerHeight;
|
||||
for (const element of all.slice(1)) {
|
||||
const top = TOPS[element.id];
|
||||
expect(ranges.get(element)?.enter).toEqual([
|
||||
top - vh,
|
||||
top - vh * PASSAGE_END,
|
||||
]);
|
||||
}
|
||||
for (const element of all.slice(0, -1)) {
|
||||
const { enter, exit } = ranges.get(element) ?? {};
|
||||
expect(exit).toBeDefined();
|
||||
const [start, end] = exit as [number, number];
|
||||
expect(start).toBeLessThan(end);
|
||||
// termina quando o topo da cena seguinte chegou ao fim da passagem dela
|
||||
const next = all[all.indexOf(element) + 1];
|
||||
expect(end).toBe(TOPS[next.id] - vh * PASSAGE_END);
|
||||
if (enter) expect(start).toBeGreaterThanOrEqual(enter[1]);
|
||||
}
|
||||
// a cena curta (300 px): a saída começa onde a entrada acabou, não 500 px antes
|
||||
const short = ranges.get(document.getElementById("short") as HTMLElement);
|
||||
expect(short?.exit?.[0]).toBe(short?.enter?.[1]);
|
||||
});
|
||||
|
||||
it("does no scroll work for users who prefer reduced motion", async () => {
|
||||
it("does no scroll work for users who prefer reduced motion, and leaves anchor clicks to the browser", async () => {
|
||||
mockMedia(["(prefers-reduced-motion: reduce)"]);
|
||||
await mount();
|
||||
expect(mockTimelines).toHaveLength(0);
|
||||
|
||||
const link = document.querySelector('a[href="/#projects"]') as HTMLElement;
|
||||
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
|
||||
act(() => {
|
||||
link.dispatchEvent(event);
|
||||
});
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||
expect(window.scrollTo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("smooth scroll", () => {
|
||||
it("is not created on a touch screen, where the passages still run on the native scroll", async () => {
|
||||
it("is not created on a touch screen: the package is not even loaded, and the native scroll does the work", 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);
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
advance(500);
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("is created with a mouse, and the GSAP clock sleeps once the scroll is idle and wakes on the wheel", async () => {
|
||||
it("is created with a mouse, with the smoothing options", async () => {
|
||||
await mount();
|
||||
|
||||
expect(mockLenis).toHaveLength(1);
|
||||
expect(mockLenis[0].options).toEqual({
|
||||
lerp: 0.075,
|
||||
wheelMultiplier: 0.85,
|
||||
});
|
||||
});
|
||||
|
||||
it("feeds Lenis in milliseconds while scrolling, and lets the animation clock go once idle", async () => {
|
||||
await mount();
|
||||
const lenis = mockLenis[0];
|
||||
expect(mockTicker.add).toHaveBeenCalledTimes(1);
|
||||
const tick = mockTicker.add.mock.calls[0][0] as (time: number) => void;
|
||||
|
||||
// rolando: segue ligado e alimenta o Lenis em milissegundos
|
||||
// rolando: segue ligado, a cada quadro
|
||||
lenis.isScrolling = "smooth";
|
||||
tick(0.5);
|
||||
expect(lenis.raf).toHaveBeenLastCalledWith(500);
|
||||
expect(mockTicker.remove).not.toHaveBeenCalled();
|
||||
advance(500);
|
||||
const whileScrolling = lenis.raf.mock.calls.length;
|
||||
expect(whileScrolling).toBeGreaterThan(20);
|
||||
expect(lenis.raf.mock.calls.at(-1)?.[0]).toBeGreaterThan(400);
|
||||
|
||||
// parado há mais que o tempo de espera: o relógio é solto
|
||||
// parado: depois de IDLE_SECONDS o relógio é solto de vez (nenhum quadro de animação pedido)
|
||||
lenis.isScrolling = false;
|
||||
tick(0.5 + IDLE_SECONDS / 2);
|
||||
expect(mockTicker.remove).not.toHaveBeenCalled();
|
||||
tick(0.5 + IDLE_SECONDS + 0.1);
|
||||
expect(mockTicker.remove).toHaveBeenCalledTimes(1);
|
||||
expect(mockTicker.remove).toHaveBeenCalledWith(tick);
|
||||
advance(IDLE_SECONDS * 1000 + 200);
|
||||
const stopped = lenis.raf.mock.calls.length;
|
||||
const requested = frames.mock.calls.length;
|
||||
advance(3000);
|
||||
expect(lenis.raf.mock.calls.length).toBe(stopped);
|
||||
expect(frames.mock.calls.length).toBe(requested);
|
||||
});
|
||||
|
||||
it("wakes up again with the wheel or any scroll event, and a second event does not start a second clock", async () => {
|
||||
await mount();
|
||||
const lenis = mockLenis[0];
|
||||
advance(IDLE_SECONDS * 1000 + 500);
|
||||
lenis.raf.mockClear();
|
||||
|
||||
// a roda do mouse o acorda de novo
|
||||
mockTicker.time = 10;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(mockTicker.add).toHaveBeenCalledTimes(2);
|
||||
// e uma roda seguinte, com o relógio já ligado, não o liga duas vezes
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
});
|
||||
expect(mockTicker.add).toHaveBeenCalledTimes(2);
|
||||
advance(100);
|
||||
|
||||
// um laço só: cerca de um quadro a cada 16 ms
|
||||
expect(lenis.raf.mock.calls.length).toBeGreaterThan(0);
|
||||
expect(lenis.raf.mock.calls.length).toBeLessThanOrEqual(8);
|
||||
});
|
||||
|
||||
it("jumps straight to a far anchor (the header button to #contact) and animates a near one for 0.8 s at most", async () => {
|
||||
@@ -467,7 +438,7 @@ describe("useCinematicScroll", () => {
|
||||
expect(lenis.scrollTo).toHaveBeenCalledTimes(1);
|
||||
const [farTop, farOptions] = lenis.scrollTo.mock.calls[0];
|
||||
expect(farTop).toBe(TOPS.contact);
|
||||
expect(farOptions).toMatchObject({ immediate: true });
|
||||
expect(farOptions).toMatchObject({ immediate: true, duration: 0 });
|
||||
expect(window.location.hash).toBe("#contact");
|
||||
expect(document.getElementById("contact")).toHaveFocus();
|
||||
|
||||
@@ -481,7 +452,22 @@ describe("useCinematicScroll", () => {
|
||||
expect(nearOptions.duration).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS);
|
||||
});
|
||||
|
||||
it("without Lenis (touch) a far anchor is an instant jump", async () => {
|
||||
it("wakes the clock before an anchor scroll, so the animation actually runs after a long rest", async () => {
|
||||
await mount();
|
||||
const lenis = mockLenis[0];
|
||||
advance(IDLE_SECONDS * 1000 + 500);
|
||||
lenis.raf.mockClear();
|
||||
|
||||
act(() => {
|
||||
(document.querySelector('a[href="/#projects"]') as HTMLElement).click();
|
||||
});
|
||||
advance(100);
|
||||
|
||||
expect(lenis.scrollTo).toHaveBeenCalledTimes(1);
|
||||
expect(lenis.raf).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("without Lenis (touch) a far anchor is an instant jump and a near one is a native smooth scroll", async () => {
|
||||
mockMedia(["(pointer: coarse)"]);
|
||||
await mount();
|
||||
act(() => {
|
||||
@@ -501,17 +487,33 @@ describe("useCinematicScroll", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("releases the clock, the listeners and Lenis when the page unmounts", async () => {
|
||||
it("releases Lenis, the clock and the listeners when the page unmounts", async () => {
|
||||
const view = await mount();
|
||||
const lenis = mockLenis[0];
|
||||
|
||||
view.unmount();
|
||||
|
||||
expect(lenis.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(mockTicker.remove).toHaveBeenCalled();
|
||||
mockTicker.add.mockClear();
|
||||
lenis.raf.mockClear();
|
||||
const requested = frames.mock.calls.length;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
expect(mockTicker.add).not.toHaveBeenCalled();
|
||||
advance(200);
|
||||
expect(lenis.raf).not.toHaveBeenCalled();
|
||||
expect(frames.mock.calls.length).toBe(requested);
|
||||
});
|
||||
|
||||
it("creates nothing when the page unmounted before the package finished loading", async () => {
|
||||
const view = render(<Home />);
|
||||
view.unmount();
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(mockLenis).toHaveLength(0);
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user