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,245 @@
|
||||
import { act, render, screen } from "@testing-library/react";
|
||||
import { DrawLine } from "./animations/DrawLine";
|
||||
import { LazyRevealImage } from "./animations/LazyRevealImage";
|
||||
import { Reveal } from "./Reveal";
|
||||
import { Section } from "./Section";
|
||||
|
||||
/**
|
||||
* Entradas por rolagem sem biblioteca de animação: o componente só marca data-shown (ou data-loaded) quando o
|
||||
* IntersectionObserver avisa e o CSS (globals.css) faz a transição. Aqui vale o contrato com o CSS: as classes,
|
||||
* as variáveis e o momento em que as marcas aparecem.
|
||||
*/
|
||||
|
||||
type Observer = {
|
||||
callback: IntersectionObserverCallback;
|
||||
observed: Element[];
|
||||
};
|
||||
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) {
|
||||
this.record = { callback, observed: [] };
|
||||
observers.push(this.record);
|
||||
}
|
||||
observe(target: Element) {
|
||||
this.record.observed.push(target);
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.IntersectionObserver = original;
|
||||
});
|
||||
|
||||
/** Avisa que `target` entrou inteiro na janela. */
|
||||
function reveal(target: Element) {
|
||||
act(() => {
|
||||
for (const observer of observers)
|
||||
if (observer.observed.includes(target))
|
||||
observer.callback(
|
||||
[
|
||||
{
|
||||
isIntersecting: true,
|
||||
intersectionRatio: 1,
|
||||
intersectionRect: { height: 400 },
|
||||
target,
|
||||
} as unknown as IntersectionObserverEntry,
|
||||
],
|
||||
{} as IntersectionObserver,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
describe("Reveal", () => {
|
||||
it("renders its children and waits for the viewport to mark them as shown", () => {
|
||||
const { container } = render(
|
||||
<Reveal>
|
||||
<p>Conteúdo</p>
|
||||
</Reveal>,
|
||||
);
|
||||
const wrapper = container.firstElementChild as HTMLElement;
|
||||
|
||||
expect(screen.getByText("Conteúdo")).toBeInTheDocument();
|
||||
expect(wrapper).toHaveClass("ds-reveal");
|
||||
expect(wrapper).not.toHaveAttribute("data-shown");
|
||||
|
||||
reveal(wrapper);
|
||||
expect(wrapper).toHaveAttribute("data-shown");
|
||||
});
|
||||
|
||||
it("hands the CSS its offset, duration and delay as custom properties", () => {
|
||||
const { container } = render(
|
||||
<Reveal y={40} duration={0.9} delay={0.12} className="extra">
|
||||
x
|
||||
</Reveal>,
|
||||
);
|
||||
const wrapper = container.firstElementChild as HTMLElement;
|
||||
|
||||
expect(wrapper.style.getPropertyValue("--reveal-y")).toBe("40px");
|
||||
expect(wrapper.style.getPropertyValue("--reveal-duration")).toBe("0.9s");
|
||||
expect(wrapper.style.getPropertyValue("--reveal-delay")).toBe("0.12s");
|
||||
expect(wrapper).toHaveClass("ds-reveal", "extra");
|
||||
});
|
||||
|
||||
it("uses no inline opacity or transform: the hidden state lives in the CSS, so no script means visible content", () => {
|
||||
const { container } = render(<Reveal>x</Reveal>);
|
||||
const wrapper = container.firstElementChild as HTMLElement;
|
||||
|
||||
expect(wrapper.style.opacity).toBe("");
|
||||
expect(wrapper.style.transform).toBe("");
|
||||
expect(wrapper.style.clipPath).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DrawLine", () => {
|
||||
it("draws once the line is mostly on screen, and takes its delay from the prop", () => {
|
||||
const { container } = render(<DrawLine delay={0.16} className="x" />);
|
||||
const line = container.firstElementChild as HTMLElement;
|
||||
|
||||
expect(line).toHaveClass("ds-draw-line", "x");
|
||||
expect(line.style.getPropertyValue("--reveal-delay")).toBe("0.16s");
|
||||
expect(line).not.toHaveAttribute("data-shown");
|
||||
|
||||
reveal(line);
|
||||
expect(line).toHaveAttribute("data-shown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("LazyRevealImage", () => {
|
||||
it("shows the picture (data-loaded) only after it has loaded", async () => {
|
||||
const { container } = render(
|
||||
<LazyRevealImage src="/foto.webp" alt="Foto" className="h-40" />,
|
||||
);
|
||||
const wrapper = container.firstElementChild as HTMLElement;
|
||||
const image = screen.getByAltText("Foto");
|
||||
|
||||
expect(wrapper).toHaveClass("ds-lazy-reveal", "h-40");
|
||||
expect(wrapper).not.toHaveAttribute("data-loaded");
|
||||
|
||||
// o next/image só repassa o onLoad depois de decodificar a imagem (uma microtarefa)
|
||||
await act(async () => {
|
||||
image.dispatchEvent(new Event("load"));
|
||||
});
|
||||
expect(wrapper).toHaveAttribute("data-loaded");
|
||||
});
|
||||
|
||||
it("does not wait forever for an image that was already complete before the page hydrated", async () => {
|
||||
const complete = jest
|
||||
.spyOn(HTMLImageElement.prototype, "complete", "get")
|
||||
.mockReturnValue(true);
|
||||
const width = jest
|
||||
.spyOn(HTMLImageElement.prototype, "naturalWidth", "get")
|
||||
.mockReturnValue(640);
|
||||
|
||||
let view: ReturnType<typeof render> | undefined;
|
||||
await act(async () => {
|
||||
view = render(<LazyRevealImage src="/foto.webp" alt="Foto" />);
|
||||
});
|
||||
|
||||
expect(view?.container.firstElementChild).toHaveAttribute("data-loaded");
|
||||
complete.mockRestore();
|
||||
width.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("Section", () => {
|
||||
it("renders the heading, the description and the content, and marks only the header as it enters", () => {
|
||||
const { container } = render(
|
||||
<Section
|
||||
id="sobre"
|
||||
eyebrow="Sobre"
|
||||
title="Quem faz"
|
||||
description="Texto de apoio"
|
||||
aside={<span>lateral</span>}
|
||||
>
|
||||
<p>Corpo</p>
|
||||
</Section>,
|
||||
);
|
||||
const header = container.querySelector(".ds-section-header") as HTMLElement;
|
||||
|
||||
expect(container.querySelector("section#sobre")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 2, name: "Quem faz" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Texto de apoio")).toHaveClass(
|
||||
"ds-section-description",
|
||||
"ds-reveal",
|
||||
);
|
||||
expect(screen.getByText("Sobre")).toHaveClass("ds-eyebrow", "ds-reveal");
|
||||
expect(screen.getByText("lateral").closest("aside")).toHaveClass(
|
||||
"ds-reveal",
|
||||
);
|
||||
expect(screen.getByText("Corpo")).toBeInTheDocument();
|
||||
expect(header).not.toHaveAttribute("data-shown");
|
||||
|
||||
reveal(header);
|
||||
expect(header).toHaveAttribute("data-shown");
|
||||
});
|
||||
|
||||
it("staggers the header items with growing delays", () => {
|
||||
render(
|
||||
<Section
|
||||
eyebrow="Sobre"
|
||||
title="Quem faz"
|
||||
description="Texto"
|
||||
aside={<span>lateral</span>}
|
||||
>
|
||||
x
|
||||
</Section>,
|
||||
);
|
||||
const delay = (element: HTMLElement) =>
|
||||
Number.parseFloat(element.style.getPropertyValue("--reveal-delay"));
|
||||
|
||||
const eyebrow = delay(screen.getByText("Sobre"));
|
||||
const description = delay(screen.getByText("Texto"));
|
||||
const aside = delay(
|
||||
screen.getByText("lateral").closest("aside") as HTMLElement,
|
||||
);
|
||||
|
||||
expect(eyebrow).toBeLessThan(description);
|
||||
expect(description).toBeLessThan(aside);
|
||||
});
|
||||
|
||||
it("wraps the body in Reveal, unless the section animates its own children", () => {
|
||||
const { container, rerender } = render(
|
||||
<Section title="Titulo">
|
||||
<p>Corpo</p>
|
||||
</Section>,
|
||||
);
|
||||
expect(screen.getByText("Corpo").closest(".ds-reveal")).toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<Section title="Titulo" disableChildrenAnimation>
|
||||
<p>Corpo</p>
|
||||
</Section>,
|
||||
);
|
||||
expect(screen.getByText("Corpo").closest(".ds-reveal")).toBeNull();
|
||||
expect(container.querySelector(".ds-section-shell")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the dark skin when asked", () => {
|
||||
const { container } = render(
|
||||
<Section title="Titulo" dark>
|
||||
x
|
||||
</Section>,
|
||||
);
|
||||
|
||||
expect(container.querySelector("section")).toHaveClass(
|
||||
"ds-section",
|
||||
"ds-section-dark",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user