perf(movimento): uma transição, 3D leve e vídeo certo por aparelho
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.
This commit is contained in:
1 parent
8d07b8c6bb
commit
c9a0b80656
48 files changed
+7210
-1241
No files matched your search
@@ -1,10 +1,12 @@
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { MOBILE_QUERY } from "@/lib/device";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import { getShowcaseConfig, mobileFractionAt } from "@/lib/project-showcase";
|
||||
import { ProjectReel } from "./ProjectReel";
|
||||
import { ShowcaseProvider } from "./ShowcaseProvider";
|
||||
import { reelOffset, videoProgress } from "./showcase-math";
|
||||
import { PINNED_QUERY } from "./useShowcaseMedia";
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
@@ -16,6 +18,21 @@ const CONTAINER = 7000;
|
||||
const SCROLLABLE = CONTAINER - PIN;
|
||||
const HEADER = 60;
|
||||
let reelTop = HEADER;
|
||||
/** largura do celular da moldura: 0 simula uma moldura escondida por CSS */
|
||||
let phoneWidth = 90;
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
|
||||
/** Tela de desktop por padrão (cabe a vitrine fixa); `phone` simula um celular. */
|
||||
function mockViewport({ phone = false }: { phone?: boolean } = {}) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches:
|
||||
(query === MOBILE_QUERY && phone) || (query === PINNED_QUERY && !phone),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
}
|
||||
|
||||
const projects = getFeaturedProjects(null);
|
||||
const weights = [
|
||||
@@ -31,6 +48,10 @@ const originalOffsetHeight = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"offsetHeight",
|
||||
);
|
||||
const originalOffsetWidth = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"offsetWidth",
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||
@@ -41,6 +62,22 @@ beforeAll(() => {
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return this.classList.contains("ds-reel-phone") ? phoneWidth : 0;
|
||||
},
|
||||
});
|
||||
// jsdom ainda não implementa o <dialog> modal
|
||||
HTMLDialogElement.prototype.showModal = function showModal(
|
||||
this: HTMLDialogElement,
|
||||
) {
|
||||
this.setAttribute("open", "");
|
||||
};
|
||||
HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) {
|
||||
this.removeAttribute("open");
|
||||
this.dispatchEvent(new Event("close"));
|
||||
};
|
||||
Element.prototype.getBoundingClientRect = function rect(this: Element) {
|
||||
if (this.classList.contains("ds-reel"))
|
||||
return {
|
||||
@@ -66,12 +103,20 @@ afterAll(() => {
|
||||
"offsetHeight",
|
||||
originalOffsetHeight,
|
||||
);
|
||||
if (originalOffsetWidth)
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
"offsetWidth",
|
||||
originalOffsetWidth,
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
reducedMotion = false;
|
||||
reelTop = HEADER;
|
||||
phoneWidth = 90;
|
||||
mockViewport();
|
||||
(window.scrollTo as jest.Mock).mockClear();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
@@ -80,6 +125,11 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
window.matchMedia = originalMatchMedia;
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
function renderReel() {
|
||||
@@ -130,12 +180,51 @@ const activeLayer = () =>
|
||||
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
|
||||
|
||||
describe("ProjectReel", () => {
|
||||
it("renders nothing for users who prefer reduced motion", () => {
|
||||
it("falls back to a plain project list, nothing pinned, for users who prefer reduced motion", () => {
|
||||
reducedMotion = true;
|
||||
renderReel();
|
||||
expect(
|
||||
screen.queryByRole("region", { name: /Vitrine animada/ }),
|
||||
).toBeNull();
|
||||
expect(document.querySelector(".ds-reel")).toBeNull();
|
||||
const list = screen.getByRole("region", { name: "Projetos de clientes" });
|
||||
expect(within(list).getAllByRole("heading", { level: 3 })).toHaveLength(5);
|
||||
expect(document.querySelector("video")).toBeNull();
|
||||
});
|
||||
|
||||
it("is a touch carousel of posters, with nothing pinned and no video, on a phone", () => {
|
||||
mockViewport({ phone: true });
|
||||
renderReel();
|
||||
|
||||
expect(document.querySelector(".ds-reel")).toBeNull();
|
||||
const list = screen.getByRole("region", { name: "Projetos de clientes" });
|
||||
expect(list).not.toHaveAttribute("data-pending");
|
||||
expect(document.querySelector("video")).toBeNull();
|
||||
// o item chega a ~1 tela da janela (o IntersectionObserver do jest.setup avisa): só então vem o pôster
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(50);
|
||||
});
|
||||
const posters = [...list.querySelectorAll("img")].map((image) =>
|
||||
decodeURIComponent(image.getAttribute("src") ?? ""),
|
||||
);
|
||||
expect(posters.length).toBeGreaterThan(0);
|
||||
for (const poster of posters) expect(poster).toContain("mobile.webp");
|
||||
expect(list.querySelectorAll(".ds-phone")).toHaveLength(5);
|
||||
expect(list.querySelector(".ds-bframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("still opens the enlarged video from the list, without any card in the page", () => {
|
||||
mockViewport({ phone: true });
|
||||
renderReel();
|
||||
const item = screen
|
||||
.getByRole("heading", { level: 3, name: "SEV Moda Íntima" })
|
||||
.closest("li") as HTMLElement;
|
||||
fireEvent.click(
|
||||
within(item).getByRole("button", { name: /Ampliar vídeo/ }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "SEV Moda Íntima" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists every project by name, starting on the first with its chapters", () => {
|
||||
@@ -201,7 +290,7 @@ describe("ProjectReel", () => {
|
||||
const lavacao = getShowcaseConfig("lavacao-047");
|
||||
expect(lavacao).toBeDefined();
|
||||
expect(layer.querySelector(".ds-reel-chapter-count")?.textContent).toMatch(
|
||||
/^0\d\/06$/,
|
||||
/^0\d\/03$/,
|
||||
);
|
||||
const labels = lavacao?.chapters.map((chapter) => chapter.label) ?? [];
|
||||
expect(labels).toContain(
|
||||
@@ -237,7 +326,7 @@ describe("ProjectReel", () => {
|
||||
expect(sources().every((src) => src === null)).toBe(true);
|
||||
});
|
||||
|
||||
it("downloads only the project on screen and its neighbours, and lets go of the rest", () => {
|
||||
it("downloads video only for the project on screen (neighbours stay on the poster), and lets go of the rest", () => {
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
act(() => {
|
||||
@@ -248,10 +337,14 @@ describe("ProjectReel", () => {
|
||||
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
loaded.some((src) => src?.includes("lavacao-047/scrub-mobile.mp4")),
|
||||
loaded.some((src) => src?.includes("sev-modaintima/scrub-mobile.mp4")),
|
||||
).toBe(true);
|
||||
// o vizinho (Lavação) e os demais ficam sem nenhum vídeo
|
||||
expect(loaded.some((src) => src?.includes("lavacao-047"))).toBe(false);
|
||||
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(false);
|
||||
expect(loaded.some((src) => src?.includes("as-servicos"))).toBe(false);
|
||||
for (const video of document.querySelectorAll(".ds-reel-layer video"))
|
||||
expect(video).toHaveAttribute("preload", "metadata");
|
||||
|
||||
// vai para o último projeto: o primeiro sai da janela e solta o arquivo
|
||||
scrollTo(1);
|
||||
@@ -281,34 +374,38 @@ describe("ProjectReel", () => {
|
||||
});
|
||||
const loaded = sources().filter(Boolean);
|
||||
expect(loaded.some((src) => src?.includes("sc-plus"))).toBe(true);
|
||||
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(true); // vizinho do projeto onde parou
|
||||
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(false); // vizinho: só o pôster
|
||||
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not download the hidden laptop video on a phone-sized screen", () => {
|
||||
const original = window.matchMedia;
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes("max-width: 639px"),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
try {
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
const loaded = sources().filter(Boolean);
|
||||
expect(loaded.some((src) => src?.includes("scrub-mobile.mp4"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(loaded.some((src) => src?.includes("scrub-desktop.mp4"))).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
window.matchMedia = original;
|
||||
}
|
||||
it("does not download the phone video while the phone frame is not on screen", () => {
|
||||
phoneWidth = 0;
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
const loaded = sources().filter(Boolean);
|
||||
expect(
|
||||
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
|
||||
).toBe(true);
|
||||
expect(loaded.some((src) => src?.includes("scrub-mobile.mp4"))).toBe(false);
|
||||
});
|
||||
|
||||
it("asks for posters only near the reel, and only for the project on screen and its neighbours", () => {
|
||||
reelTop = 6000;
|
||||
renderReel();
|
||||
const background = (index: number) =>
|
||||
(document.querySelectorAll(".ds-reel-screen")[index] as HTMLElement).style
|
||||
.backgroundImage;
|
||||
expect(background(0)).toBe("");
|
||||
expect(document.querySelectorAll("video[poster]")).toHaveLength(0);
|
||||
|
||||
scrollTo(0); // chegou a menos de uma tela
|
||||
expect(background(0)).toContain("sev-modaintima/desktop.webp");
|
||||
expect(background(1)).toContain("lavacao-047/desktop.webp");
|
||||
expect(background(3)).toBe("");
|
||||
expect(background(4)).toBe("");
|
||||
});
|
||||
|
||||
it("respects the data saver: no scroll video is downloaded", () => {
|
||||
@@ -316,31 +413,113 @@ describe("ProjectReel", () => {
|
||||
configurable: true,
|
||||
value: { saveData: true },
|
||||
});
|
||||
try {
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
expect(sources().every((src) => src === null)).toBe(true);
|
||||
} finally {
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
expect(sources().every((src) => src === null)).toBe(true);
|
||||
// o pôster continua lá
|
||||
expect(
|
||||
(document.querySelectorAll(".ds-reel-screen")[0] as HTMLElement).style
|
||||
.backgroundImage,
|
||||
).toContain("desktop.webp");
|
||||
});
|
||||
|
||||
it("holds the page height before it mounts, so a jump to #contact lands in the right place", () => {
|
||||
it("gives the server the project list (text and links, no media) and holds the pinned height before mounting", () => {
|
||||
const html = renderToString(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<ProjectReel />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
// lugar reservado para a vitrine fixa: a página não muda de altura quando ela aparece (salto para #contact)
|
||||
expect(html).toContain("ds-reel-placeholder");
|
||||
expect(html).toContain("--reel-sum");
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
expect(html).not.toContain("<video");
|
||||
expect(html).not.toContain("/videos/");
|
||||
// sem JavaScript os projetos continuam ali, com o link do case
|
||||
expect(html).toContain("ds-reel-list");
|
||||
expect(html).toContain("data-pending");
|
||||
expect(html).toContain("SEV Moda Íntima");
|
||||
expect(html).toContain('href="/cases/lavacao-047"');
|
||||
expect(html).not.toContain("Ampliar vídeo");
|
||||
});
|
||||
|
||||
it("has an h2 before the project h3, in the pinned scene and in the list", () => {
|
||||
const { unmount } = renderReel();
|
||||
const region = screen.getByRole("region", { name: /Vitrine animada/ });
|
||||
const order = [...region.querySelectorAll("h2, h3")].map(
|
||||
(heading) => heading.tagName,
|
||||
);
|
||||
expect(order[0]).toBe("H2");
|
||||
expect(order).toContain("H3");
|
||||
unmount();
|
||||
|
||||
mockViewport({ phone: true });
|
||||
renderReel();
|
||||
const list = screen.getByRole("region", { name: "Projetos de clientes" });
|
||||
expect(list.querySelector("h2, h3")?.tagName).toBe("H2");
|
||||
});
|
||||
|
||||
it("limits each project to three chapters, in the index and in the caption", () => {
|
||||
renderReel();
|
||||
for (const layer of document.querySelectorAll(".ds-reel-layer"))
|
||||
expect(
|
||||
layer.querySelectorAll(".ds-reel-ticks li").length,
|
||||
).toBeLessThanOrEqual(3);
|
||||
const chapters = screen.getByRole("list", {
|
||||
name: "Trechos de SEV Moda Íntima",
|
||||
});
|
||||
expect(within(chapters).getAllByRole("button")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("shows the real domain in the address bar, or just the project name, never github.com", () => {
|
||||
renderReel();
|
||||
const bars = [...document.querySelectorAll(".ds-bframe-url")].map(
|
||||
(bar) => bar.textContent,
|
||||
);
|
||||
expect(bars).toContain("sevmodaintima.com.br");
|
||||
expect(bars).toContain("scplus.evandro.dev.br");
|
||||
expect(bars).toContain("AS Serviços");
|
||||
expect(document.body.innerHTML).not.toContain("github.com");
|
||||
});
|
||||
|
||||
it("offers Ver case (outline), Abrir site (text link, only with a live site) and Ampliar vídeo, none of them lime", () => {
|
||||
renderReel();
|
||||
const layer = activeLayer();
|
||||
const actions = layer.querySelector(".ds-reel-actions") as HTMLElement;
|
||||
expect(
|
||||
within(actions).getByRole("link", { name: /Ver case/ }),
|
||||
).toHaveAttribute("href", "/cases/sev-modaintima");
|
||||
const site = within(actions).getByRole("link", { name: /Abrir site/ });
|
||||
expect(site).toHaveAttribute("href", "https://sevmodaintima.com.br");
|
||||
expect(site).toHaveAttribute("target", "_blank");
|
||||
expect(site).toHaveClass("ds-link-text");
|
||||
expect(
|
||||
within(actions).getByRole("button", { name: /Ampliar vídeo/ }),
|
||||
).toHaveClass("ds-link-muted");
|
||||
expect(document.querySelector(".ds-reel .ds-link-accent")).toBeNull();
|
||||
|
||||
// sem demoUrl não há "Abrir site"
|
||||
const asServicos = [...document.querySelectorAll(".ds-reel-layer")].at(-1);
|
||||
expect(asServicos).toBeDefined();
|
||||
expect(
|
||||
within(asServicos as HTMLElement).queryByRole("link", {
|
||||
name: /Abrir site/,
|
||||
hidden: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the enlarged video from the pinned scene", () => {
|
||||
renderReel();
|
||||
fireEvent.click(
|
||||
within(activeLayer()).getByRole("button", { name: /Ampliar vídeo/ }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "SEV Moda Íntima" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("scrolls to the start of a project when its button is pressed", () => {
|
||||
|
||||
Reference in new issue
Block a user