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,277 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import {
|
||||
buildShowcaseItems,
|
||||
ShowcaseProvider,
|
||||
useShowcaseCard,
|
||||
} from "./ShowcaseProvider";
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
jest.mock("./ProjectLightbox", () => ({
|
||||
ProjectLightbox: ({
|
||||
index,
|
||||
onClose,
|
||||
}: {
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
}) => (
|
||||
<div role="dialog" aria-label="ampliado">
|
||||
aberto:{index}
|
||||
<button type="button" onClick={onClose}>
|
||||
fechar
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
type FakeObserver = {
|
||||
callback: IntersectionObserverCallback;
|
||||
targets: Set<Element>;
|
||||
};
|
||||
let observers: FakeObserver[] = [];
|
||||
const OriginalObserver = global.IntersectionObserver;
|
||||
|
||||
class FakeIntersectionObserver {
|
||||
private readonly state: FakeObserver;
|
||||
constructor(callback: IntersectionObserverCallback) {
|
||||
this.state = { callback, targets: new Set() };
|
||||
observers.push(this.state);
|
||||
}
|
||||
observe(element: Element) {
|
||||
this.state.targets.add(element);
|
||||
}
|
||||
unobserve(element: Element) {
|
||||
this.state.targets.delete(element);
|
||||
}
|
||||
disconnect() {
|
||||
this.state.targets.clear();
|
||||
}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function setVisibility(element: Element, ratio: number) {
|
||||
for (const observer of observers) {
|
||||
if (!observer.targets.has(element)) continue;
|
||||
act(() => {
|
||||
observer.callback(
|
||||
[
|
||||
{
|
||||
target: element,
|
||||
intersectionRatio: ratio,
|
||||
isIntersecting: ratio > 0,
|
||||
} as IntersectionObserverEntry,
|
||||
],
|
||||
observer as unknown as IntersectionObserver,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function place(element: Element, top: number) {
|
||||
element.getBoundingClientRect = () =>
|
||||
({
|
||||
top,
|
||||
height: 300,
|
||||
bottom: top + 300,
|
||||
left: 0,
|
||||
right: 400,
|
||||
width: 400,
|
||||
x: 0,
|
||||
y: top,
|
||||
}) as DOMRect;
|
||||
}
|
||||
|
||||
function flush() {
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
}
|
||||
|
||||
function Card({ id }: { id: string }) {
|
||||
const { frameRef, active, open } = useShowcaseCard(id);
|
||||
return (
|
||||
<article className="ds-card">
|
||||
<div ref={frameRef} data-testid={id} data-active={active} />
|
||||
<button type="button" onClick={open}>
|
||||
ampliar {id}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const projects = getFeaturedProjects(null);
|
||||
|
||||
function setup() {
|
||||
const view = render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<Card id="sev-modaintima" />
|
||||
<Card id="lavacao-047" />
|
||||
<Card id="nutriscan" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
const frames = {
|
||||
sev: screen.getByTestId("sev-modaintima"),
|
||||
lavacao: screen.getByTestId("lavacao-047"),
|
||||
nutri: screen.getByTestId("nutriscan"),
|
||||
};
|
||||
// tela de 768px de altura (jsdom): o meio fica em 384
|
||||
place(frames.sev, 100); // centro 250
|
||||
place(frames.lavacao, 230); // centro 380
|
||||
place(frames.nutri, 900); // fora da tela
|
||||
return { view, frames };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
reducedMotion = false;
|
||||
observers = [];
|
||||
global.IntersectionObserver =
|
||||
FakeIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
global.IntersectionObserver = OriginalObserver;
|
||||
});
|
||||
|
||||
const isActive = (element: Element) =>
|
||||
element.getAttribute("data-active") === "true";
|
||||
|
||||
describe("ShowcaseProvider", () => {
|
||||
it("lists only projects that have a recorded showcase, in showcase order", () => {
|
||||
const items = buildShowcaseItems(projects);
|
||||
expect(items.map((item) => item.config.id)).toEqual([
|
||||
"sev-modaintima",
|
||||
"lavacao-047",
|
||||
"nutriscan",
|
||||
"sc-plus",
|
||||
"as-servicos",
|
||||
]);
|
||||
expect(
|
||||
buildShowcaseItems(projects.filter((p) => p.id === "twins-lab")),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("plays nothing until a card is on screen, then the one closest to the middle", () => {
|
||||
const { frames } = setup();
|
||||
flush();
|
||||
expect(Object.values(frames).some(isActive)).toBe(false);
|
||||
|
||||
setVisibility(frames.sev, 0.8);
|
||||
setVisibility(frames.lavacao, 0.8);
|
||||
flush();
|
||||
expect(isActive(frames.lavacao)).toBe(true);
|
||||
expect(isActive(frames.sev)).toBe(false);
|
||||
});
|
||||
|
||||
it("does not count a card with less than a third on screen", () => {
|
||||
const { frames } = setup();
|
||||
setVisibility(frames.sev, 0.2);
|
||||
setVisibility(frames.lavacao, 0.1);
|
||||
flush();
|
||||
expect(Object.values(frames).some(isActive)).toBe(false);
|
||||
});
|
||||
|
||||
it("hands the video to the card under the mouse and takes it back when the mouse leaves", () => {
|
||||
const { frames } = setup();
|
||||
setVisibility(frames.sev, 0.8);
|
||||
setVisibility(frames.lavacao, 0.8);
|
||||
flush();
|
||||
|
||||
const sevCard = frames.sev.closest(".ds-card") as HTMLElement;
|
||||
fireEvent.pointerEnter(sevCard, { pointerType: "mouse" });
|
||||
flush();
|
||||
expect(isActive(frames.sev)).toBe(true);
|
||||
expect(isActive(frames.lavacao)).toBe(false);
|
||||
|
||||
fireEvent.pointerLeave(sevCard, { pointerType: "mouse" });
|
||||
flush();
|
||||
expect(isActive(frames.lavacao)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores touch pointers: there is no hover on a phone", () => {
|
||||
const { frames } = setup();
|
||||
setVisibility(frames.sev, 0.8);
|
||||
setVisibility(frames.lavacao, 0.8);
|
||||
flush();
|
||||
|
||||
fireEvent.pointerEnter(frames.sev.closest(".ds-card") as HTMLElement, {
|
||||
pointerType: "touch",
|
||||
});
|
||||
flush();
|
||||
expect(isActive(frames.sev)).toBe(false);
|
||||
expect(isActive(frames.lavacao)).toBe(true);
|
||||
});
|
||||
|
||||
it("follows keyboard focus inside a card", () => {
|
||||
const { frames } = setup();
|
||||
setVisibility(frames.sev, 0.8);
|
||||
setVisibility(frames.lavacao, 0.8);
|
||||
flush();
|
||||
|
||||
fireEvent.focusIn(
|
||||
screen.getByRole("button", { name: "ampliar sev-modaintima" }),
|
||||
);
|
||||
flush();
|
||||
expect(isActive(frames.sev)).toBe(true);
|
||||
});
|
||||
|
||||
it("re-evaluates on scroll", () => {
|
||||
const { frames } = setup();
|
||||
setVisibility(frames.sev, 0.8);
|
||||
setVisibility(frames.lavacao, 0.8);
|
||||
flush();
|
||||
expect(isActive(frames.lavacao)).toBe(true);
|
||||
|
||||
place(frames.sev, 230);
|
||||
place(frames.lavacao, 600);
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
flush();
|
||||
expect(isActive(frames.sev)).toBe(true);
|
||||
});
|
||||
|
||||
it("pauses every card while the enlarged window is open, and resumes after", () => {
|
||||
const { frames } = setup();
|
||||
setVisibility(frames.lavacao, 0.8);
|
||||
flush();
|
||||
expect(isActive(frames.lavacao)).toBe(true);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "ampliar lavacao-047" }),
|
||||
);
|
||||
flush();
|
||||
expect(screen.getByRole("dialog", { name: "ampliado" })).toHaveTextContent(
|
||||
"aberto:1",
|
||||
);
|
||||
expect(isActive(frames.lavacao)).toBe(false);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "fechar" }));
|
||||
flush();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(isActive(frames.lavacao)).toBe(true);
|
||||
});
|
||||
|
||||
it("never plays a card for users who prefer reduced motion", () => {
|
||||
reducedMotion = true;
|
||||
const { frames } = setup();
|
||||
setVisibility(frames.lavacao, 1);
|
||||
flush();
|
||||
expect(isActive(frames.lavacao)).toBe(false);
|
||||
});
|
||||
|
||||
it("stops listening when it unmounts", () => {
|
||||
const { view, frames } = setup();
|
||||
setVisibility(frames.lavacao, 1);
|
||||
view.unmount();
|
||||
expect(observers.every((observer) => observer.targets.size === 0)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user