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.
518 lines
16 KiB
TypeScript
518 lines
16 KiB
TypeScript
import { act, render } from "@testing-library/react";
|
|
import { useRef } from "react";
|
|
import {
|
|
anchorDuration,
|
|
ENTER_OFFSET,
|
|
enterScene,
|
|
exitScene,
|
|
IDLE_SECONDS,
|
|
JUMP_SCREENS,
|
|
MAX_ANCHOR_SECONDS,
|
|
PASSAGE_END,
|
|
useCinematicScroll,
|
|
} from "./useCinematicScroll";
|
|
|
|
/* ------------------------------------------------------------------------------------------------
|
|
* Passagem de verdade (GSAP real, sem ScrollTrigger): o que cada cena recebe no começo e no fim.
|
|
* ------------------------------------------------------------------------------------------------ */
|
|
|
|
// o resto do arquivo troca o GSAP por um fake: aqui vale o de verdade
|
|
const { gsap: realGsap } = jest.requireActual<typeof import("gsap")>("gsap");
|
|
|
|
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();
|
|
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: (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() {
|
|
return MockLenis;
|
|
},
|
|
}));
|
|
|
|
const TOPS: Record<string, number> = {
|
|
home: 0,
|
|
projects: 1800,
|
|
short: 3000,
|
|
about: 3300,
|
|
contact: 25000,
|
|
};
|
|
|
|
function Home() {
|
|
const ref = useRef<HTMLElement>(null);
|
|
useCinematicScroll(ref);
|
|
return (
|
|
<main ref={ref}>
|
|
<section id="home" data-top={TOPS.home}>
|
|
<a href="/#contact">Conversar</a>
|
|
<a href="/#projects">Projetos</a>
|
|
</section>
|
|
<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>
|
|
<section id="about" data-top={TOPS.about}>
|
|
<h2>Sobre</h2>
|
|
</section>
|
|
<section id="contact" data-top={TOPS.contact}>
|
|
<h2>Contato</h2>
|
|
</section>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
const originalMatchMedia = window.matchMedia;
|
|
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 mount() {
|
|
const view = render(<Home />);
|
|
// as importações dinâmicas do hook (gsap, ScrollTrigger, Lenis) resolvem 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"));
|
|
|
|
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;
|
|
mockMedia([]);
|
|
window.history.replaceState(null, "", "/");
|
|
jest.spyOn(window, "scrollTo").mockImplementation(() => {});
|
|
Object.defineProperty(window, "innerHeight", {
|
|
configurable: true,
|
|
value: 800,
|
|
});
|
|
});
|
|
|
|
afterEach(() => {
|
|
window.matchMedia = originalMatchMedia;
|
|
jest.restoreAllMocks();
|
|
});
|
|
|
|
describe("useCinematicScroll", () => {
|
|
it("never hides a scene: no visibility, no sticky pin, nothing inline before the scroll runs", 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("");
|
|
}
|
|
// 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 () => {
|
|
mockMedia(["(prefers-reduced-motion: reduce)"]);
|
|
await mount();
|
|
expect(mockTimelines).toHaveLength(0);
|
|
expect(mockLenis).toHaveLength(0);
|
|
expect(mockTicker.add).not.toHaveBeenCalled();
|
|
});
|
|
|
|
describe("smooth scroll", () => {
|
|
it("is not created on a touch screen, where the passages still run on the native scroll", 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("is created with a mouse, and the GSAP clock sleeps once the scroll is idle and wakes on the wheel", async () => {
|
|
await mount();
|
|
expect(mockLenis).toHaveLength(1);
|
|
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
|
|
lenis.isScrolling = "smooth";
|
|
tick(0.5);
|
|
expect(lenis.raf).toHaveBeenLastCalledWith(500);
|
|
expect(mockTicker.remove).not.toHaveBeenCalled();
|
|
|
|
// parado há mais que o tempo de espera: o relógio é solto
|
|
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);
|
|
|
|
// a roda do mouse o acorda de novo
|
|
mockTicker.time = 10;
|
|
act(() => {
|
|
window.dispatchEvent(new Event("wheel"));
|
|
});
|
|
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);
|
|
});
|
|
|
|
it("jumps straight to a far anchor (the header button to #contact) and animates a near one for 0.8 s at most", async () => {
|
|
await mount();
|
|
const lenis = mockLenis[0];
|
|
|
|
act(() => {
|
|
(document.querySelector('a[href="/#contact"]') as HTMLElement).click();
|
|
});
|
|
expect(lenis.scrollTo).toHaveBeenCalledTimes(1);
|
|
const [farTop, farOptions] = lenis.scrollTo.mock.calls[0];
|
|
expect(farTop).toBe(TOPS.contact);
|
|
expect(farOptions).toMatchObject({ immediate: true });
|
|
expect(window.location.hash).toBe("#contact");
|
|
expect(document.getElementById("contact")).toHaveFocus();
|
|
|
|
act(() => {
|
|
(document.querySelector('a[href="/#projects"]') as HTMLElement).click();
|
|
});
|
|
const [nearTop, nearOptions] = lenis.scrollTo.mock.calls[1];
|
|
expect(nearTop).toBe(TOPS.projects);
|
|
expect(nearOptions.immediate).toBe(false);
|
|
expect(nearOptions.duration).toBeGreaterThan(0);
|
|
expect(nearOptions.duration).toBeLessThanOrEqual(MAX_ANCHOR_SECONDS);
|
|
});
|
|
|
|
it("without Lenis (touch) a far anchor is an instant jump", async () => {
|
|
mockMedia(["(pointer: coarse)"]);
|
|
await mount();
|
|
act(() => {
|
|
(document.querySelector('a[href="/#contact"]') as HTMLElement).click();
|
|
});
|
|
expect(window.scrollTo).toHaveBeenCalledWith({
|
|
top: TOPS.contact,
|
|
behavior: "instant",
|
|
});
|
|
|
|
act(() => {
|
|
(document.querySelector('a[href="/#projects"]') as HTMLElement).click();
|
|
});
|
|
expect(window.scrollTo).toHaveBeenLastCalledWith({
|
|
top: TOPS.projects,
|
|
behavior: "smooth",
|
|
});
|
|
});
|
|
|
|
it("releases the clock, the listeners and Lenis 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();
|
|
act(() => {
|
|
window.dispatchEvent(new Event("wheel"));
|
|
});
|
|
expect(mockTicker.add).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
});
|