feat(showcase): vitrine animada presa na rolagem, com capitulos e celular sincronizado
Abaixo dos cards, uma cena sticky em que o site de cada projeto rola dentro de um notebook (com um celular ao lado) conforme a pagina rola. Cada projeto tem um trecho com pausa no comeco e no fim; capitulos nomeiam o que esta na tela, levam ao ponto do video e mantem notebook e celular no mesmo trecho do site. So o projeto em cena e os vizinhos baixam video (nada com economizar dados ligado, e o notebook escondido no celular nao baixa). Um bloco da mesma altura segura o lugar antes de montar, para saltos como #contact cairem certo. Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
a1a1e91135
commit
540fe9f87a
3 files changed
+1066
-112
No files matched your search
@@ -0,0 +1,374 @@
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
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";
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
|
||||
const PIN = 800;
|
||||
const CONTAINER = 7000;
|
||||
const SCROLLABLE = CONTAINER - PIN;
|
||||
const HEADER = 60;
|
||||
let reelTop = HEADER;
|
||||
|
||||
const projects = getFeaturedProjects(null);
|
||||
const weights = [
|
||||
"sev-modaintima",
|
||||
"lavacao-047",
|
||||
"nutriscan",
|
||||
"sc-plus",
|
||||
"as-servicos",
|
||||
].map((id) => getShowcaseConfig(id)?.weight ?? 1);
|
||||
|
||||
const originalRect = Element.prototype.getBoundingClientRect;
|
||||
const originalOffsetHeight = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"offsetHeight",
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
if (this.classList.contains("ds-reel")) return CONTAINER;
|
||||
if (this.classList.contains("ds-reel-pin")) return PIN;
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
Element.prototype.getBoundingClientRect = function rect(this: Element) {
|
||||
if (this.classList.contains("ds-reel"))
|
||||
return {
|
||||
top: reelTop,
|
||||
bottom: reelTop + CONTAINER,
|
||||
height: CONTAINER,
|
||||
left: 0,
|
||||
right: 1000,
|
||||
width: 1000,
|
||||
x: 0,
|
||||
y: reelTop,
|
||||
} as DOMRect;
|
||||
return originalRect.call(this);
|
||||
};
|
||||
window.scrollTo = jest.fn();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Element.prototype.getBoundingClientRect = originalRect;
|
||||
if (originalOffsetHeight)
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
"offsetHeight",
|
||||
originalOffsetHeight,
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
reducedMotion = false;
|
||||
reelTop = HEADER;
|
||||
(window.scrollTo as jest.Mock).mockClear();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function renderReel() {
|
||||
const view = render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<ProjectReel />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(50);
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
/** jsdom não carrega mídia: cada <video> da vitrine ganha duração e prontidão para aceitar buscas. */
|
||||
function readyVideos() {
|
||||
for (const video of document.querySelectorAll("video")) {
|
||||
Object.defineProperty(video, "readyState", {
|
||||
configurable: true,
|
||||
value: 4,
|
||||
});
|
||||
Object.defineProperty(video, "duration", { configurable: true, value: 30 });
|
||||
let time = 0;
|
||||
Object.defineProperty(video, "currentTime", {
|
||||
configurable: true,
|
||||
get: () => time,
|
||||
set: (value: number) => {
|
||||
time = value;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function scrollTo(progress: number) {
|
||||
reelTop = HEADER - progress * SCROLLABLE;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(1500);
|
||||
});
|
||||
}
|
||||
|
||||
const sources = () =>
|
||||
[...document.querySelectorAll(".ds-reel-layer video")].map((video) =>
|
||||
video.getAttribute("src"),
|
||||
);
|
||||
|
||||
const activeLayer = () =>
|
||||
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
|
||||
|
||||
describe("ProjectReel", () => {
|
||||
it("renders nothing for users who prefer reduced motion", () => {
|
||||
reducedMotion = true;
|
||||
renderReel();
|
||||
expect(
|
||||
screen.queryByRole("region", { name: /Vitrine animada/ }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("lists every project by name, starting on the first with its chapters", () => {
|
||||
renderReel();
|
||||
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
|
||||
for (const name of [
|
||||
"SEV Moda Íntima",
|
||||
"Lavação 047",
|
||||
"NutriScan",
|
||||
"SC Plus Aquicultura",
|
||||
"AS Serviços",
|
||||
])
|
||||
expect(within(nav).getByRole("button", { name })).toBeInTheDocument();
|
||||
expect(
|
||||
within(nav).getByRole("button", { name: "SEV Moda Íntima" }),
|
||||
).toHaveAttribute("aria-current", "true");
|
||||
expect(
|
||||
within(nav).getByRole("list", { name: "Trechos de SEV Moda Íntima" }),
|
||||
).toBeInTheDocument();
|
||||
expect(activeLayer()).toHaveTextContent("SEV Moda Íntima");
|
||||
});
|
||||
|
||||
it("hides the layers that are not on screen from assistive technology and from the keyboard", () => {
|
||||
renderReel();
|
||||
const layers = [...document.querySelectorAll(".ds-reel-layer")];
|
||||
expect(layers).toHaveLength(5);
|
||||
for (const layer of layers.slice(1)) {
|
||||
expect(layer).toHaveAttribute("aria-hidden", "true");
|
||||
expect(layer).toHaveAttribute("inert");
|
||||
}
|
||||
expect(layers[0]).not.toHaveAttribute("inert");
|
||||
});
|
||||
|
||||
it("moves to the next project and syncs the laptop and the phone to the same point of the site", () => {
|
||||
renderReel();
|
||||
readyVideos();
|
||||
|
||||
// meio do trecho da Lavação (segundo projeto)
|
||||
const t = reelOffset(weights, 1, 0.5);
|
||||
scrollTo(t);
|
||||
|
||||
const layer = activeLayer();
|
||||
expect(layer).toHaveTextContent("Lavação 047");
|
||||
const lavacao = getShowcaseConfig("lavacao-047");
|
||||
if (!lavacao) throw new Error("config ausente");
|
||||
const p = videoProgress(0.5);
|
||||
const desktop = layer.querySelector(
|
||||
".ds-reel-screen video",
|
||||
) as HTMLVideoElement;
|
||||
const phone = layer.querySelector(
|
||||
".ds-phone-screen video",
|
||||
) as HTMLVideoElement;
|
||||
expect(desktop.currentTime).toBeCloseTo(p * 30, 1);
|
||||
expect(phone.currentTime).toBeCloseTo(mobileFractionAt(lavacao, p) * 30, 1);
|
||||
});
|
||||
|
||||
it("names the chapter on screen and counts them", () => {
|
||||
renderReel();
|
||||
readyVideos();
|
||||
scrollTo(reelOffset(weights, 1, 0.5)); // Lavação, a meio caminho
|
||||
|
||||
const layer = activeLayer();
|
||||
const lavacao = getShowcaseConfig("lavacao-047");
|
||||
expect(lavacao).toBeDefined();
|
||||
expect(layer.querySelector(".ds-reel-chapter-count")?.textContent).toMatch(
|
||||
/^0\d\/06$/,
|
||||
);
|
||||
const labels = lavacao?.chapters.map((chapter) => chapter.label) ?? [];
|
||||
expect(labels).toContain(
|
||||
layer.querySelector(".ds-reel-chapter strong")?.textContent,
|
||||
);
|
||||
});
|
||||
|
||||
it("holds the first and last frame at the ends of the pinned range", () => {
|
||||
renderReel();
|
||||
readyVideos();
|
||||
|
||||
scrollTo(0);
|
||||
const first = document.querySelector(
|
||||
'.ds-reel-layer[data-state="active"] .ds-reel-screen video',
|
||||
) as HTMLVideoElement;
|
||||
expect(first.currentTime).toBe(0);
|
||||
|
||||
scrollTo(1);
|
||||
expect(activeLayer()).toHaveTextContent("AS Serviços");
|
||||
const last = activeLayer().querySelector(
|
||||
".ds-reel-screen video",
|
||||
) as HTMLVideoElement;
|
||||
expect(last.currentTime).toBeCloseTo(30, 1);
|
||||
});
|
||||
|
||||
it("downloads nothing while the page is far from the reel", () => {
|
||||
reelTop = 6000;
|
||||
renderReel();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
expect(sources().every((src) => src === null)).toBe(true);
|
||||
});
|
||||
|
||||
it("downloads only the project on screen and its neighbours, and lets go of the rest", () => {
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
let loaded = sources().filter(Boolean);
|
||||
expect(
|
||||
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
loaded.some((src) => src?.includes("lavacao-047/scrub-mobile.mp4")),
|
||||
).toBe(true);
|
||||
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(false);
|
||||
expect(loaded.some((src) => src?.includes("as-servicos"))).toBe(false);
|
||||
|
||||
// vai para o último projeto: o primeiro sai da janela e solta o arquivo
|
||||
scrollTo(1);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
loaded = sources().filter(Boolean);
|
||||
expect(
|
||||
loaded.some((src) => src?.includes("as-servicos/scrub-desktop.mp4")),
|
||||
).toBe(true);
|
||||
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
|
||||
});
|
||||
|
||||
it("only downloads where the scroll settles, not every project it flies past", () => {
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
// atravessa vários projetos em menos de 300 ms
|
||||
for (const project of [1, 2, 3]) {
|
||||
reelTop = HEADER - reelOffset(weights, project, 0.5) * SCROLLABLE;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(60);
|
||||
});
|
||||
}
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
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("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("respects the data saver: no scroll video is downloaded", () => {
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("holds the page height before it mounts, so a jump to #contact lands in the right place", () => {
|
||||
const html = renderToString(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<ProjectReel />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
expect(html).toContain("ds-reel-placeholder");
|
||||
expect(html).toContain("--reel-sum");
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
expect(html).not.toContain("<video");
|
||||
});
|
||||
|
||||
it("scrolls to the start of a project when its button is pressed", () => {
|
||||
renderReel();
|
||||
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
|
||||
fireEvent.click(within(nav).getByRole("button", { name: /NutriScan/ }));
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1200);
|
||||
});
|
||||
|
||||
const calls = (window.scrollTo as jest.Mock).mock.calls;
|
||||
const last = calls[calls.length - 1][0] as { top: number };
|
||||
const expected =
|
||||
HEADER - HEADER + reelOffset(weights, 2, 0) * SCROLLABLE + 2;
|
||||
expect(last.top).toBeCloseTo(expected, 0);
|
||||
});
|
||||
|
||||
it("scrolls to a chapter inside the project when its link is pressed", () => {
|
||||
renderReel();
|
||||
const nav = screen.getByRole("navigation", { name: "Projetos da vitrine" });
|
||||
fireEvent.click(within(nav).getByRole("button", { name: /As favoritas/ }));
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(1200);
|
||||
});
|
||||
|
||||
const calls = (window.scrollTo as jest.Mock).mock.calls;
|
||||
const last = calls[calls.length - 1][0] as { top: number };
|
||||
expect(last.top).toBeGreaterThan(reelOffset(weights, 0, 0) * SCROLLABLE);
|
||||
expect(last.top).toBeLessThan(reelOffset(weights, 1, 0) * SCROLLABLE);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user