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.
142 lines
4.0 KiB
TypeScript
142 lines
4.0 KiB
TypeScript
import { act, render } from "@testing-library/react";
|
|
import { useRef } from "react";
|
|
import { useInView } from "./useInView";
|
|
|
|
type Observer = {
|
|
callback: IntersectionObserverCallback;
|
|
options?: IntersectionObserverInit;
|
|
observed: Element[];
|
|
disconnect: jest.Mock;
|
|
};
|
|
let observers: Observer[] = [];
|
|
|
|
const original = global.IntersectionObserver;
|
|
|
|
beforeEach(() => {
|
|
observers = [];
|
|
Object.defineProperty(window, "innerHeight", {
|
|
configurable: true,
|
|
value: 800,
|
|
});
|
|
global.IntersectionObserver = class {
|
|
private readonly record: Observer;
|
|
constructor(
|
|
callback: IntersectionObserverCallback,
|
|
options?: IntersectionObserverInit,
|
|
) {
|
|
this.record = { callback, options, observed: [], disconnect: jest.fn() };
|
|
observers.push(this.record);
|
|
}
|
|
observe(target: Element) {
|
|
this.record.observed.push(target);
|
|
}
|
|
unobserve() {}
|
|
disconnect() {
|
|
this.record.disconnect();
|
|
}
|
|
takeRecords() {
|
|
return [];
|
|
}
|
|
} as unknown as typeof IntersectionObserver;
|
|
});
|
|
|
|
afterEach(() => {
|
|
global.IntersectionObserver = original;
|
|
});
|
|
|
|
function report(ratio: number, visibleHeight: number) {
|
|
act(() => {
|
|
for (const observer of observers)
|
|
observer.callback(
|
|
[
|
|
{
|
|
isIntersecting: ratio > 0,
|
|
intersectionRatio: ratio,
|
|
intersectionRect: { height: visibleHeight },
|
|
target: observer.observed[0],
|
|
} as unknown as IntersectionObserverEntry,
|
|
],
|
|
{} as IntersectionObserver,
|
|
);
|
|
});
|
|
}
|
|
|
|
function Probe({ amount, once }: { amount?: number; once?: boolean }) {
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const inView = useInView(ref, { amount, once });
|
|
return <div ref={ref} data-in-view={String(inView)} />;
|
|
}
|
|
|
|
const state = (container: HTMLElement) =>
|
|
container.firstElementChild?.getAttribute("data-in-view");
|
|
|
|
describe("useInView", () => {
|
|
it("is false until enough of the element is on screen (20 % by default)", () => {
|
|
const { container } = render(<Probe />);
|
|
expect(state(container)).toBe("false");
|
|
expect(observers).toHaveLength(1);
|
|
expect(observers[0].observed).toHaveLength(1);
|
|
|
|
report(0.1, 40); // pouco à vista: ainda não
|
|
expect(state(container)).toBe("false");
|
|
|
|
report(0.25, 100);
|
|
expect(state(container)).toBe("true");
|
|
});
|
|
|
|
it("stays true after the element leaves (once), and stops observing", () => {
|
|
const { container } = render(<Probe />);
|
|
report(1, 300);
|
|
expect(state(container)).toBe("true");
|
|
expect(observers[0].disconnect).toHaveBeenCalled();
|
|
|
|
report(0, 0);
|
|
expect(state(container)).toBe("true");
|
|
});
|
|
|
|
it("follows the element in and out when once is false", () => {
|
|
const { container } = render(<Probe once={false} />);
|
|
report(1, 300);
|
|
expect(state(container)).toBe("true");
|
|
|
|
report(0, 0);
|
|
expect(state(container)).toBe("false");
|
|
|
|
report(0.5, 200);
|
|
expect(state(container)).toBe("true");
|
|
});
|
|
|
|
it("counts a very tall element once a fifth of the window is showing, even if that is a tiny share of it", () => {
|
|
const { container } = render(<Probe />);
|
|
|
|
report(0.02, 100); // 100 px de um bloco de 5000: menos de 20% da janela (800 px)
|
|
expect(state(container)).toBe("false");
|
|
|
|
report(0.04, 200); // 200 px >= 20% de 800
|
|
expect(state(container)).toBe("true");
|
|
});
|
|
|
|
it("asks the observer for the amount it was given", () => {
|
|
render(<Probe amount={0.7} />);
|
|
|
|
expect(observers[0].options?.threshold).toEqual([0, 0.7]);
|
|
});
|
|
|
|
it("is true at once when the browser has no IntersectionObserver (content is never left hidden)", () => {
|
|
// biome-ignore lint/suspicious/noExplicitAny: simula um navegador sem a API
|
|
(global as any).IntersectionObserver = undefined;
|
|
|
|
const { container } = render(<Probe />);
|
|
|
|
expect(state(container)).toBe("true");
|
|
});
|
|
|
|
it("disconnects the observer when the component unmounts", () => {
|
|
const { unmount } = render(<Probe />);
|
|
|
|
unmount();
|
|
|
|
expect(observers[0].disconnect).toHaveBeenCalled();
|
|
});
|
|
});
|