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
@@ -0,0 +1,141 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { type RefObject, useEffect, useState } from "react";
|
||||
|
||||
interface InViewOptions {
|
||||
/** Fração do elemento (0 a 1) que precisa estar à vista; em elementos altos, vale também essa fração da altura da janela. */
|
||||
amount?: number;
|
||||
/** Verdadeiro (padrão): depois de aparecer uma vez, continua verdadeiro. Falso: acompanha a entrada e a saída. */
|
||||
once?: boolean;
|
||||
}
|
||||
|
||||
function isShown(entry: IntersectionObserverEntry, amount: number) {
|
||||
return (
|
||||
entry.isIntersecting &&
|
||||
(entry.intersectionRatio >= amount ||
|
||||
entry.intersectionRect.height >= window.innerHeight * amount)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* O elemento está à vista? Usa IntersectionObserver (sem ouvir a rolagem). Sem IntersectionObserver devolve
|
||||
* verdadeiro logo na montagem: o conteúdo nunca fica escondido por falta do recurso.
|
||||
*/
|
||||
export function useInView(
|
||||
ref: RefObject<Element | null>,
|
||||
{ amount = 0.2, once = true }: InViewOptions = {},
|
||||
): boolean {
|
||||
const [inView, setInView] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setInView(true);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
const shown = isShown(entry, amount);
|
||||
if (shown) {
|
||||
setInView(true);
|
||||
if (once) observer.disconnect();
|
||||
} else if (!once) setInView(false);
|
||||
}
|
||||
},
|
||||
{ threshold: [0, amount] },
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [ref, amount, once]);
|
||||
|
||||
return inView;
|
||||
}
|
||||
@@ -1,49 +1,92 @@
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { createElement } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { useReducedMotion } from "./useReducedMotion";
|
||||
|
||||
// Mock do Framer Motion
|
||||
jest.mock("motion/react", () => ({
|
||||
useReducedMotion: jest.fn(),
|
||||
}));
|
||||
type Listener = () => void;
|
||||
|
||||
/** matchMedia que responde só à consulta de movimento reduzido e deixa o teste mudar a preferência depois. */
|
||||
function mockPreference(initial: boolean) {
|
||||
const listeners = new Set<Listener>();
|
||||
const query = {
|
||||
matches: initial,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
addEventListener: jest.fn((_: string, listener: Listener) =>
|
||||
listeners.add(listener),
|
||||
),
|
||||
removeEventListener: jest.fn((_: string, listener: Listener) =>
|
||||
listeners.delete(listener),
|
||||
),
|
||||
};
|
||||
window.matchMedia = jest.fn().mockReturnValue(query);
|
||||
return {
|
||||
query,
|
||||
change(value: boolean) {
|
||||
query.matches = value;
|
||||
for (const listener of listeners) listener();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const original = window.matchMedia;
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = original;
|
||||
});
|
||||
|
||||
describe("useReducedMotion", () => {
|
||||
it("deve retornar false quando prefers-reduced-motion não está ativo", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("motion/react");
|
||||
mockUseReducedMotion.mockReturnValue(false);
|
||||
it("is false when the system does not ask for reduced motion", () => {
|
||||
mockPreference(false);
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("deve retornar true quando prefers-reduced-motion está ativo", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("motion/react");
|
||||
mockUseReducedMotion.mockReturnValue(true);
|
||||
it("is true when the system asks for reduced motion", () => {
|
||||
mockPreference(true);
|
||||
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
expect(window.matchMedia).toHaveBeenCalledWith(
|
||||
"(prefers-reduced-motion: reduce)",
|
||||
);
|
||||
});
|
||||
|
||||
it("deve retornar false quando o hook do Framer Motion retorna null", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("motion/react");
|
||||
mockUseReducedMotion.mockReturnValue(null);
|
||||
|
||||
it("follows the preference when it changes while the page is open", () => {
|
||||
const preference = mockPreference(false);
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => preference.change(true));
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
act(() => preference.change(false));
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("deve retornar false quando o hook do Framer Motion retorna undefined", () => {
|
||||
const { useReducedMotion: mockUseReducedMotion } =
|
||||
jest.requireMock("motion/react");
|
||||
mockUseReducedMotion.mockReturnValue(undefined);
|
||||
it("stops listening when the component unmounts", () => {
|
||||
const preference = mockPreference(false);
|
||||
const { unmount } = renderHook(() => useReducedMotion());
|
||||
|
||||
unmount();
|
||||
|
||||
expect(preference.query.removeEventListener).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("is false on the server and in browsers without matchMedia, so the HTML always hydrates the same", () => {
|
||||
mockPreference(true);
|
||||
function Probe() {
|
||||
return createElement("i", { "data-reduced": String(useReducedMotion()) });
|
||||
}
|
||||
expect(renderToString(createElement(Probe))).toContain(
|
||||
'data-reduced="false"',
|
||||
);
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: simula um navegador sem a API
|
||||
(window as any).matchMedia = undefined;
|
||||
const { result } = renderHook(() => useReducedMotion());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,31 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useReducedMotion as useFramerReducedMotion } from "motion/react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const QUERY = "(prefers-reduced-motion: reduce)";
|
||||
|
||||
/**
|
||||
* Hook que detecta a preferência prefers-reduced-motion do sistema operacional.
|
||||
* Wrapper sobre o hook useReducedMotion do Framer Motion.
|
||||
*
|
||||
* @returns {boolean} true se prefers-reduced-motion está ativo, false caso contrário
|
||||
* Preferência `prefers-reduced-motion` do sistema operacional, lida com matchMedia (sem biblioteca de animação).
|
||||
* Falso no servidor e na primeira renderização no navegador, para o HTML hidratar igual ao do servidor.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const reducedMotion = useReducedMotion();
|
||||
*
|
||||
* return (
|
||||
* <motion.div
|
||||
* animate={{ opacity: 1, y: reducedMotion ? 0 : 20 }}
|
||||
* />
|
||||
* );
|
||||
* ```
|
||||
* return <div data-calm={reducedMotion || undefined} />;
|
||||
*/
|
||||
export function useReducedMotion(): boolean {
|
||||
const systemPreference = useFramerReducedMotion();
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [reduced, setReduced] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
if (typeof window.matchMedia !== "function") return;
|
||||
const query = window.matchMedia(QUERY);
|
||||
const update = () => setReduced(query.matches);
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
return isMounted && systemPreference === true;
|
||||
return reduced;
|
||||
}
|
||||
Reference in new issue
Block a user