feat(showcase): moldura de navegador, um video por vez e video ampliado nos cards
- cards com moldura de navegador (endereco real) e brilho na cor do site no card que toca - um video por vez: o do card sob o mouse/foco, ou o mais perto do centro da tela; nada baixa antes de tocar, e um botao pausa os videos que se mexem sozinhos - janela ampliada em <dialog> nativo: troca computador/celular mantendo o ponto do site, capitulos clicaveis, controles proprios, setas entre projetos e foco devolvido ao fechar - dados e capitulos de cada projeto em lib/project-showcase.ts (substitui project-videos.ts) Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
1 parent
60dc2f1878
commit
a1a1e91135
18 files changed
+3564
-155
No files matched your search
@@ -0,0 +1,393 @@
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import { StrictMode } from "react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import {
|
||||
chapterFraction,
|
||||
getShowcaseConfig,
|
||||
mobileFractionAt,
|
||||
} from "@/lib/project-showcase";
|
||||
import { ShowcaseProvider, useShowcaseCard } from "./ShowcaseProvider";
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
|
||||
function Card({ id }: { id: string }) {
|
||||
const { frameRef, open } = useShowcaseCard(id);
|
||||
return (
|
||||
<article className="ds-card">
|
||||
<div ref={frameRef} />
|
||||
<button type="button" onClick={open}>
|
||||
ampliar {id}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const projects = getFeaturedProjects(null);
|
||||
const lavacao = getShowcaseConfig("lavacao-047");
|
||||
|
||||
function setup(id = "lavacao-047") {
|
||||
render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<Card id="sev-modaintima" />
|
||||
<Card id="lavacao-047" />
|
||||
<Card id="nutriscan" />
|
||||
<Card id="as-servicos" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: `ampliar ${id}` }));
|
||||
return screen.getByRole("dialog");
|
||||
}
|
||||
|
||||
/** jsdom não carrega mídia: o teste dá ao <video> uma duração e deixa o componente reagir como no navegador. */
|
||||
function loadVideo(dialog: HTMLElement, duration = 30) {
|
||||
const video = dialog.querySelector("video") as HTMLVideoElement;
|
||||
Object.defineProperty(video, "duration", {
|
||||
configurable: true,
|
||||
value: duration,
|
||||
});
|
||||
let time = 0;
|
||||
Object.defineProperty(video, "currentTime", {
|
||||
configurable: true,
|
||||
get: () => time,
|
||||
set: (value: number) => {
|
||||
time = value;
|
||||
},
|
||||
});
|
||||
act(() => {
|
||||
video.dispatchEvent(new Event("loadedmetadata"));
|
||||
});
|
||||
return video;
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
// 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"));
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
reducedMotion = false;
|
||||
jest
|
||||
.spyOn(HTMLMediaElement.prototype, "play")
|
||||
.mockImplementation(function play(this: HTMLMediaElement) {
|
||||
// jsdom não toca nada: o teste mantém `paused` e os eventos como o navegador faria
|
||||
Object.defineProperty(this, "paused", {
|
||||
configurable: true,
|
||||
value: false,
|
||||
});
|
||||
this.dispatchEvent(new Event("play"));
|
||||
return Promise.resolve();
|
||||
});
|
||||
jest
|
||||
.spyOn(HTMLMediaElement.prototype, "pause")
|
||||
.mockImplementation(function pause(this: HTMLMediaElement) {
|
||||
Object.defineProperty(this, "paused", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
this.dispatchEvent(new Event("pause"));
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
document.documentElement.classList.remove("ds-lb-open");
|
||||
});
|
||||
|
||||
describe("ProjectLightbox", () => {
|
||||
it("opens a modal dialog for the clicked project, locks page scroll and puts focus on close", () => {
|
||||
const dialog = setup();
|
||||
|
||||
expect(dialog).toHaveAttribute("open");
|
||||
expect(dialog).toHaveAccessibleName("Lavação 047");
|
||||
expect(dialog).toHaveAttribute("data-lenis-prevent");
|
||||
expect(document.documentElement).toHaveClass("ds-lb-open");
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Fechar" }),
|
||||
).toHaveFocus();
|
||||
expect(within(dialog).getByText("02 / 05")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the desktop video in a browser frame by default and the phone version on request", () => {
|
||||
const dialog = setup();
|
||||
const toggle = within(dialog).getByRole("group", {
|
||||
name: "Versão do site",
|
||||
});
|
||||
|
||||
expect(
|
||||
within(toggle).getByRole("button", { name: "Computador" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
expect(dialog.querySelector(".ds-lb-frame")).not.toBeNull();
|
||||
expect(dialog.querySelector('source[src$="desktop.webm"]')).not.toBeNull();
|
||||
|
||||
fireEvent.click(within(toggle).getByRole("button", { name: "Celular" }));
|
||||
expect(
|
||||
within(toggle).getByRole("button", { name: "Celular" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
expect(dialog.querySelector(".ds-lb-phone")).not.toBeNull();
|
||||
expect(dialog.querySelector('source[src$="mobile.webm"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it("keeps the same point of the site when switching devices", () => {
|
||||
if (!lavacao) throw new Error("config ausente");
|
||||
const dialog = setup();
|
||||
const desktop = loadVideo(dialog, lavacao.durations.desktop);
|
||||
|
||||
// meio do vídeo desktop
|
||||
desktop.currentTime = lavacao.durations.desktop * 0.5;
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
|
||||
const phone = loadVideo(dialog, lavacao.durations.mobile);
|
||||
|
||||
expect(phone.currentTime).toBeCloseTo(
|
||||
mobileFractionAt(lavacao, 0.5) * lavacao.durations.mobile,
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
it("jumps to a chapter and marks it as current", () => {
|
||||
if (!lavacao) throw new Error("config ausente");
|
||||
const dialog = setup();
|
||||
const video = loadVideo(dialog, lavacao.durations.desktop);
|
||||
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: /Do sujo ao brilho/ }),
|
||||
);
|
||||
|
||||
expect(video.currentTime).toBeCloseTo(
|
||||
chapterFraction(lavacao, 2, "desktop") * lavacao.durations.desktop,
|
||||
5,
|
||||
);
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: /Do sujo ao brilho/ }),
|
||||
).toHaveAttribute("aria-current", "true");
|
||||
});
|
||||
|
||||
it("toggles play and pause with an accessible name", () => {
|
||||
const dialog = setup();
|
||||
loadVideo(dialog);
|
||||
|
||||
const play = within(dialog).getByRole("button", { name: "Reproduzir" });
|
||||
fireEvent.click(play);
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Pausar" }),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Pausar" }));
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Reproduzir" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("seeks with the range slider", () => {
|
||||
const dialog = setup();
|
||||
const video = loadVideo(dialog, 30);
|
||||
|
||||
fireEvent.change(
|
||||
within(dialog).getByRole("slider", { name: "Posição do vídeo" }),
|
||||
{ target: { value: "500" } },
|
||||
);
|
||||
expect(video.currentTime).toBeCloseTo(15, 5);
|
||||
});
|
||||
|
||||
it("moves between projects and disables the arrows at both ends", () => {
|
||||
const dialog = setup("sev-modaintima");
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Projeto anterior" }),
|
||||
).toBeDisabled();
|
||||
|
||||
fireEvent.click(
|
||||
within(dialog).getByRole("button", { name: "Próximo projeto" }),
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toHaveAccessibleName("Lavação 047");
|
||||
|
||||
fireEvent.keyDown(screen.getByRole("dialog"), { key: "ArrowLeft" });
|
||||
expect(screen.getByRole("dialog")).toHaveAccessibleName("SEV Moda Íntima");
|
||||
});
|
||||
|
||||
it("offers the site and the code when they exist", () => {
|
||||
const dialog = setup("lavacao-047");
|
||||
expect(
|
||||
within(dialog).getByRole("link", { name: /Abrir projeto/ }),
|
||||
).toHaveAttribute("href", "https://landing-page-lavacao047.vercel.app");
|
||||
expect(
|
||||
within(dialog).queryByRole("link", { name: /Ver codigo/ }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("closes with the close button, with the backdrop or with Esc, and unlocks the page", () => {
|
||||
setup();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Fechar" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(document.documentElement).not.toHaveClass("ds-lb-open");
|
||||
|
||||
cleanup();
|
||||
const dialog = setup();
|
||||
fireEvent.pointerDown(dialog);
|
||||
fireEvent.click(dialog); // clique no fundo (o próprio <dialog>)
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
cleanup();
|
||||
const again = setup();
|
||||
act(() => {
|
||||
again.removeAttribute("open"); // o Esc nativo fecha o <dialog> e só então dispara `close`
|
||||
again.dispatchEvent(new Event("close"));
|
||||
});
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(document.documentElement).not.toHaveClass("ds-lb-open");
|
||||
});
|
||||
|
||||
it("does not autoplay for users who prefer reduced motion", () => {
|
||||
reducedMotion = true;
|
||||
const dialog = setup();
|
||||
expect(dialog.querySelector("video")).not.toHaveAttribute("autoplay");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectLightbox: revisão", () => {
|
||||
it("survives React StrictMode in development: the double effect must not close the window it just opened", () => {
|
||||
// o `close` real é assíncrono: o teste reproduz isso para pegar o evento atrasado
|
||||
HTMLDialogElement.prototype.close = function close(
|
||||
this: HTMLDialogElement,
|
||||
) {
|
||||
this.removeAttribute("open");
|
||||
queueMicrotask(() => this.dispatchEvent(new Event("close")));
|
||||
};
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
render(
|
||||
<StrictMode>
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<Card id="lavacao-047" />
|
||||
</ShowcaseProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "ampliar lavacao-047" }),
|
||||
);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(50);
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toHaveAttribute("open");
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
HTMLDialogElement.prototype.close = function close(
|
||||
this: HTMLDialogElement,
|
||||
) {
|
||||
this.removeAttribute("open");
|
||||
this.dispatchEvent(new Event("close"));
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores a late close event from a dialog that is open again", () => {
|
||||
const dialog = setup();
|
||||
act(() => {
|
||||
dialog.dispatchEvent(new Event("close"));
|
||||
});
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not close when a press starts inside the panel and is released on the backdrop", () => {
|
||||
const dialog = setup();
|
||||
fireEvent.pointerDown(
|
||||
within(dialog).getByRole("slider", { name: "Posição do vídeo" }),
|
||||
);
|
||||
fireEvent.click(dialog);
|
||||
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays paused after switching device when the visitor had paused", () => {
|
||||
const dialog = setup();
|
||||
loadVideo(dialog);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Reproduzir" }));
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Pausar" }));
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
|
||||
expect(dialog.querySelector("video")).not.toHaveAttribute("autoplay");
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Reproduzir" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps playing after switching device when the video was playing", () => {
|
||||
const dialog = setup();
|
||||
loadVideo(dialog);
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Reproduzir" }));
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
|
||||
expect(dialog.querySelector("video")).toHaveAttribute("autoplay");
|
||||
});
|
||||
|
||||
it("keeps the position when the visitor flips the device twice before the new video loads", () => {
|
||||
if (!lavacao) throw new Error("config ausente");
|
||||
const dialog = setup();
|
||||
const desktop = loadVideo(dialog, lavacao.durations.desktop);
|
||||
desktop.currentTime = lavacao.durations.desktop * 0.5;
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
|
||||
// o vídeo do celular ainda não carregou (sem duração): volta de novo para o computador
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Computador" }));
|
||||
const back = loadVideo(dialog, lavacao.durations.desktop);
|
||||
|
||||
expect(back.currentTime / lavacao.durations.desktop).toBeCloseTo(0.5, 1);
|
||||
});
|
||||
|
||||
it("moves focus to the opposite arrow when the pressed one reaches its end", () => {
|
||||
const dialog = setup("nutriscan");
|
||||
// anterior, anterior: chega ao primeiro e o botão "Anterior" fica desabilitado
|
||||
let current = within(dialog).getByRole("button", {
|
||||
name: "Projeto anterior",
|
||||
});
|
||||
current.focus();
|
||||
fireEvent.click(current);
|
||||
current = within(screen.getByRole("dialog")).getByRole("button", {
|
||||
name: "Projeto anterior",
|
||||
});
|
||||
current.focus();
|
||||
fireEvent.click(current);
|
||||
|
||||
const after = within(screen.getByRole("dialog"));
|
||||
expect(screen.getByRole("dialog")).toHaveAccessibleName("SEV Moda Íntima");
|
||||
expect(
|
||||
after.getByRole("button", { name: "Projeto anterior" }),
|
||||
).toBeDisabled();
|
||||
expect(
|
||||
after.getByRole("button", { name: "Próximo projeto" }),
|
||||
).toHaveFocus();
|
||||
});
|
||||
|
||||
it("announces which project is on screen", () => {
|
||||
const dialog = setup("nutriscan");
|
||||
expect(dialog.querySelector("[aria-live='polite']")).toHaveTextContent(
|
||||
"NutriScan, projeto 3 de 5",
|
||||
);
|
||||
});
|
||||
|
||||
it("ignores arrow keys combined with modifiers, which belong to the browser", () => {
|
||||
const dialog = setup("lavacao-047");
|
||||
fireEvent.keyDown(dialog, { key: "ArrowRight", altKey: true });
|
||||
expect(screen.getByRole("dialog")).toHaveAccessibleName("Lavação 047");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user