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,40 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { PhoneFrame } from "./PhoneFrame";
|
||||
|
||||
describe("PhoneFrame", () => {
|
||||
it("shows only the poster when it has no children (the pinned reel does not download the phone video)", () => {
|
||||
const { container } = render(
|
||||
<PhoneFrame poster="/videos/x/mobile.webp" className="ds-reel-phone" />,
|
||||
);
|
||||
|
||||
const phone = container.firstElementChild as HTMLElement;
|
||||
const screenEl = phone.querySelector(".ds-phone-screen") as HTMLElement;
|
||||
expect(phone).toHaveClass("ds-phone", "ds-reel-phone");
|
||||
expect(screenEl.style.backgroundImage).toContain("/videos/x/mobile.webp");
|
||||
expect(screenEl).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders its children inside the screen", () => {
|
||||
render(
|
||||
<PhoneFrame>
|
||||
<p>demo</p>
|
||||
</PhoneFrame>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("demo").parentElement).toHaveClass(
|
||||
"ds-phone-screen",
|
||||
);
|
||||
});
|
||||
|
||||
it("has no background without a poster, and passes the style along", () => {
|
||||
const { container } = render(<PhoneFrame style={{ width: 120 }} />);
|
||||
|
||||
expect(
|
||||
(container.querySelector(".ds-phone-screen") as HTMLElement).style
|
||||
.backgroundImage,
|
||||
).toBe("");
|
||||
expect((container.firstElementChild as HTMLElement).style.width).toBe(
|
||||
"120px",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,16 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
/** Moldura de celular para o vídeo mobile (proporção 390x844 do vídeo; o recorte da tela segue a mesma conta). */
|
||||
/**
|
||||
* Moldura de celular para o vídeo mobile (proporção 390x844 do vídeo; o recorte da tela segue a mesma conta).
|
||||
* Sem filhos, a tela mostra só o pôster.
|
||||
*/
|
||||
export function PhoneFrame({
|
||||
children,
|
||||
poster,
|
||||
className = "",
|
||||
style,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
children?: ReactNode;
|
||||
poster?: string;
|
||||
className?: string;
|
||||
style?: CSSProperties;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
getShowcaseConfig,
|
||||
mobileFractionAt,
|
||||
} from "@/lib/project-showcase";
|
||||
import { ShowcaseProvider, useShowcaseCard } from "./ShowcaseProvider";
|
||||
import { ShowcaseProvider, useShowcaseOpen } from "./ShowcaseProvider";
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
@@ -21,11 +21,10 @@ jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
}));
|
||||
|
||||
function Card({ id }: { id: string }) {
|
||||
const { frameRef, open } = useShowcaseCard(id);
|
||||
const openProject = useShowcaseOpen();
|
||||
return (
|
||||
<article className="ds-card">
|
||||
<div ref={frameRef} />
|
||||
<button type="button" onClick={open}>
|
||||
<button type="button" onClick={() => openProject(id)}>
|
||||
ampliar {id}
|
||||
</button>
|
||||
</article>
|
||||
|
||||
@@ -1,257 +0,0 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { MOBILE_QUERY } from "@/lib/device";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import { ProjectMedia } from "./ProjectMedia";
|
||||
import { ShowcaseProvider } from "./ShowcaseProvider";
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
jest.mock("./ProjectLightbox", () => ({
|
||||
ProjectLightbox: () => <div role="dialog" aria-label="ampliado" />,
|
||||
}));
|
||||
|
||||
const projects = getFeaturedProjects(null);
|
||||
const nutriscan = projects.find((project) => project.id === "nutriscan");
|
||||
if (!nutriscan) throw new Error("projeto ausente");
|
||||
|
||||
type Callback = IntersectionObserverCallback;
|
||||
const callbacks: Callback[] = [];
|
||||
const Original = global.IntersectionObserver;
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
|
||||
function mockPhone() {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: query === MOBILE_QUERY,
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
reducedMotion = false;
|
||||
callbacks.length = 0;
|
||||
global.IntersectionObserver = class {
|
||||
constructor(callback: Callback) {
|
||||
callbacks.push(callback);
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
global.IntersectionObserver = Original;
|
||||
window.matchMedia = originalMatchMedia;
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
function renderCard() {
|
||||
const view = render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<article className="ds-card">
|
||||
<ProjectMedia project={nutriscan as NonNullable<typeof nutriscan>} />
|
||||
</article>
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
const frame = view.container.querySelector(".ds-frame") as HTMLElement;
|
||||
frame.getBoundingClientRect = () =>
|
||||
({
|
||||
top: 300,
|
||||
height: 300,
|
||||
bottom: 600,
|
||||
left: 0,
|
||||
right: 400,
|
||||
width: 400,
|
||||
x: 0,
|
||||
y: 300,
|
||||
}) as DOMRect;
|
||||
return { ...view, frame };
|
||||
}
|
||||
|
||||
function show(frame: HTMLElement) {
|
||||
act(() => {
|
||||
for (const callback of callbacks)
|
||||
callback(
|
||||
[
|
||||
{
|
||||
target: frame,
|
||||
intersectionRatio: 1,
|
||||
isIntersecting: true,
|
||||
} as unknown as IntersectionObserverEntry,
|
||||
],
|
||||
{} as IntersectionObserver,
|
||||
);
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
}
|
||||
|
||||
describe("ProjectMedia", () => {
|
||||
it("frames the video with the project's address and a button that enlarges it", () => {
|
||||
renderCard();
|
||||
|
||||
expect(screen.getByText("nutriscan.evandro.dev.br")).toBeInTheDocument();
|
||||
const expand = screen.getByRole("button", {
|
||||
name: "Ampliar o vídeo de NutriScan",
|
||||
});
|
||||
expect(expand).toHaveAttribute("aria-haspopup", "dialog");
|
||||
fireEvent.click(expand);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "ampliado" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("plays only while the card is the active one", () => {
|
||||
const { frame, container } = renderCard();
|
||||
const video = container.querySelector("video") as HTMLVideoElement;
|
||||
expect(container.querySelector(".ds-frame")).toHaveAttribute(
|
||||
"data-active",
|
||||
"false",
|
||||
);
|
||||
expect(video.querySelectorAll("source")).toHaveLength(0);
|
||||
|
||||
show(frame);
|
||||
expect(container.querySelector(".ds-frame")).toHaveAttribute(
|
||||
"data-active",
|
||||
"true",
|
||||
);
|
||||
expect(video.querySelectorAll("source")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("lets the visitor pause the videos that play on their own, and resume them", () => {
|
||||
const { frame, container } = renderCard();
|
||||
show(frame);
|
||||
expect(container.querySelector(".ds-frame")).toHaveAttribute(
|
||||
"data-active",
|
||||
"true",
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Pausar os vídeos dos projetos" }),
|
||||
);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
expect(container.querySelector(".ds-frame")).toHaveAttribute(
|
||||
"data-active",
|
||||
"false",
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Retomar os vídeos dos projetos" }),
|
||||
);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(40);
|
||||
});
|
||||
expect(container.querySelector(".ds-frame")).toHaveAttribute(
|
||||
"data-active",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("has no pause button when nothing plays on its own (reduced motion)", () => {
|
||||
reducedMotion = true;
|
||||
renderCard();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /vídeos dos projetos/ }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("sends no media at all in the server HTML: the device is only known in the browser", () => {
|
||||
const html = renderToString(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<ProjectMedia project={nutriscan as NonNullable<typeof nutriscan>} />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
expect(html).toContain("ds-frame-screen");
|
||||
expect(html).not.toContain("<video");
|
||||
expect(html).not.toContain("/videos/");
|
||||
});
|
||||
|
||||
it("asks for the poster only once the cover is within about a screen of the window", () => {
|
||||
const { frame, container } = renderCard();
|
||||
const screenElement = container.querySelector(
|
||||
".ds-frame-screen",
|
||||
) as HTMLElement;
|
||||
const video = container.querySelector("video") as HTMLVideoElement;
|
||||
expect(screenElement.style.backgroundImage).toBe("");
|
||||
expect(video).not.toHaveAttribute("poster");
|
||||
expect(video).toHaveAttribute("preload", "metadata");
|
||||
|
||||
show(frame);
|
||||
expect(screenElement.style.backgroundImage).toContain(
|
||||
"/videos/nutriscan/desktop.webp",
|
||||
);
|
||||
expect(video).toHaveAttribute("poster", "/videos/nutriscan/desktop.webp");
|
||||
});
|
||||
|
||||
it("uses the phone version of the poster and the video below 768 px", () => {
|
||||
mockPhone();
|
||||
const { frame, container } = renderCard();
|
||||
show(frame);
|
||||
|
||||
const screenElement = container.querySelector(
|
||||
".ds-frame-screen",
|
||||
) as HTMLElement;
|
||||
expect(screenElement.style.backgroundImage).toContain(
|
||||
"/videos/nutriscan/mobile.webp",
|
||||
);
|
||||
const sources = [...container.querySelectorAll("video source")].map(
|
||||
(source) => source.getAttribute("src"),
|
||||
);
|
||||
expect(sources).toEqual([
|
||||
"/videos/nutriscan/mobile.webm",
|
||||
"/videos/nutriscan/mobile.mp4",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the poster and never attaches a video when the data saver is on", () => {
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: { saveData: true },
|
||||
});
|
||||
const { frame, container } = renderCard();
|
||||
show(frame);
|
||||
|
||||
expect(container.querySelector("video")).toBeNull();
|
||||
expect(container.querySelector("source")).toBeNull();
|
||||
expect(
|
||||
screen.getByRole("img", { name: "Captura de tela de NutriScan" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing for a project without a recorded video", () => {
|
||||
const { container } = render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<ProjectMedia project={{ id: "twins-lab", title: "Twins Lab" }} />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
expect(container.querySelector(".ds-frame")).toBeNull();
|
||||
});
|
||||
|
||||
it("does nothing, without crashing, when used outside the provider", () => {
|
||||
const { container } = render(
|
||||
<ProjectMedia project={nutriscan as NonNullable<typeof nutriscan>} />,
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Ampliar o vídeo de NutriScan" }),
|
||||
);
|
||||
expect(container.querySelector(".ds-frame")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,94 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import type { CSSProperties } from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { getProjectVideo, getShowcaseConfig } from "@/lib/project-showcase";
|
||||
import { ProjectVideo } from "../ProjectVideo";
|
||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||
import { useShowcaseCard } from "./ShowcaseProvider";
|
||||
import { useNearViewport, useViewportDevice } from "./useShowcaseMedia";
|
||||
|
||||
/**
|
||||
* Capa do card com vídeo: o vídeo dentro de uma moldura de navegador, com brilho da cor do site quando é o
|
||||
* card que toca, e o botão que amplia (a tela inteira é o botão). A versão do aparelho (celular abaixo de
|
||||
* 768 px) só é escolhida no navegador; no servidor e antes de a capa chegar a ~1 tela da janela, nem o pôster
|
||||
* é pedido (a moldura já reserva o espaço).
|
||||
*/
|
||||
export function ProjectMedia({
|
||||
project,
|
||||
}: {
|
||||
project: {
|
||||
id: string;
|
||||
title: string;
|
||||
demoUrl?: string | null;
|
||||
};
|
||||
}) {
|
||||
const { frameRef, active, open, userPaused, toggleAutoplay } =
|
||||
useShowcaseCard(project.id);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const device = useViewportDevice();
|
||||
const near = useNearViewport(frameRef);
|
||||
const video = device ? getProjectVideo(project.id, device) : undefined;
|
||||
const config = getShowcaseConfig(project.id);
|
||||
if (!getProjectVideo(project.id) || !config) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={frameRef}
|
||||
className="ds-frame"
|
||||
data-active={active}
|
||||
style={{ "--accent": config.accent } as CSSProperties}
|
||||
>
|
||||
<BrowserFrame url={displayUrl(project)}>
|
||||
<div
|
||||
className="ds-frame-screen"
|
||||
style={
|
||||
video && near
|
||||
? { backgroundImage: `url(${video.poster})` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{video && (
|
||||
<ProjectVideo
|
||||
video={video}
|
||||
title={project.title}
|
||||
active={active}
|
||||
showPoster={near}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ds-frame-expand"
|
||||
onClick={open}
|
||||
aria-haspopup="dialog"
|
||||
aria-label={`Ampliar o vídeo de ${project.title}`}
|
||||
>
|
||||
<span className="ds-frame-chip" aria-hidden="true">
|
||||
Ampliar
|
||||
</span>
|
||||
</button>
|
||||
{!reducedMotion && (
|
||||
<button
|
||||
type="button"
|
||||
className="ds-frame-pause"
|
||||
onClick={toggleAutoplay}
|
||||
aria-label={
|
||||
userPaused
|
||||
? "Retomar os vídeos dos projetos"
|
||||
: "Pausar os vídeos dos projetos"
|
||||
}
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
{userPaused ? (
|
||||
<path d="M4 2.5v11l9-5.5z" />
|
||||
) : (
|
||||
<path d="M4 3h3v10H4zM9 3h3v10H9z" />
|
||||
)}
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -239,6 +239,7 @@ const listEl = () =>
|
||||
screen.queryByRole("region", { name: "Projetos de clientes" });
|
||||
const activeLayer = () =>
|
||||
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
|
||||
const caption = () => document.querySelector(".ds-reel-caption") as HTMLElement;
|
||||
|
||||
describe("ProjectReel: qual versão entra", () => {
|
||||
it("path: no projects in the showcase -> renders nothing at all", () => {
|
||||
@@ -354,7 +355,10 @@ describe("ProjectReel: measure()", () => {
|
||||
|
||||
scrollTo(0.9);
|
||||
|
||||
expect(activeLayer()).toHaveTextContent("SEV Moda Íntima");
|
||||
expect(activeLayer().querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||
"sevmodaintima.com.br",
|
||||
);
|
||||
expect(caption()).toHaveTextContent("SEV Moda Íntima");
|
||||
});
|
||||
|
||||
it("path: scrolled to a later project -> that project becomes the active layer", () => {
|
||||
@@ -586,6 +590,70 @@ describe("ProjectReel: vídeos", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectReel: estado das camadas, traços de capítulo e legenda", () => {
|
||||
const states = (selector: string, attribute = "data-state") =>
|
||||
[...document.querySelectorAll(selector)].map((element) =>
|
||||
element.getAttribute(attribute),
|
||||
);
|
||||
|
||||
it("path: a middle project in scene -> layers before it are 'before', the ones after are 'after'", () => {
|
||||
renderReel();
|
||||
|
||||
scrollTo(reelOffset(WEIGHTS, 2, 0.5));
|
||||
|
||||
const layers = states(".ds-reel-layer");
|
||||
expect(layers.length).toBeGreaterThan(3);
|
||||
expect(layers).toEqual(
|
||||
layers.map((_, i) => (i < 2 ? "before" : i === 2 ? "active" : "after")),
|
||||
);
|
||||
});
|
||||
|
||||
it("path: first project, first chapter -> the ticks read now, todo, todo", () => {
|
||||
renderReel();
|
||||
|
||||
scrollTo(0);
|
||||
|
||||
expect(states(".ds-reel-ticks li")).toEqual(["now", "todo", "todo"]);
|
||||
expect(caption().querySelector(".ds-reel-chapter-count")).toHaveTextContent(
|
||||
"01/03",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: end of the project -> the chapter ticks before the current one are 'done', the ones after 'todo'", () => {
|
||||
renderReel();
|
||||
|
||||
scrollTo(reelOffset(WEIGHTS, 0, 0.95));
|
||||
|
||||
const ticks = states(".ds-reel-ticks li");
|
||||
const current = ticks.indexOf("now");
|
||||
expect(current).toBeGreaterThan(0);
|
||||
expect(ticks).toEqual(
|
||||
ticks.map((_, i) =>
|
||||
i < current ? "done" : i === current ? "now" : "todo",
|
||||
),
|
||||
);
|
||||
expect(ticks.filter((state) => state === "done")).toHaveLength(current);
|
||||
});
|
||||
|
||||
it("path: project with a subtitle -> shown under the title", () => {
|
||||
expect(projects[0].subtitle).toBeTruthy();
|
||||
renderReel();
|
||||
|
||||
expect(caption().querySelector(".ds-reel-sub")).toHaveTextContent(
|
||||
projects[0].subtitle,
|
||||
);
|
||||
});
|
||||
|
||||
it("path: project without a subtitle -> no empty subtitle line in the caption", () => {
|
||||
renderReel([{ ...projects[0], subtitle: "" }, ...projects.slice(1)]);
|
||||
|
||||
expect(caption().querySelector(".ds-reel-sub")).toBeNull();
|
||||
expect(caption().querySelector(".ds-reel-title")).toHaveTextContent(
|
||||
projects[0].title,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectReel: rolagem suave até um projeto", () => {
|
||||
function pressProject(name: string) {
|
||||
fireEvent.click(
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
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 { getShowcaseConfig } from "@/lib/project-showcase";
|
||||
import { CAPTION_OUT_MS, ProjectReel } from "./ProjectReel";
|
||||
import { ShowcaseProvider } from "./ShowcaseProvider";
|
||||
import { reelOffset, videoProgress } from "./showcase-math";
|
||||
import { PINNED_QUERY } from "./useShowcaseMedia";
|
||||
@@ -169,6 +171,10 @@ function scrollTo(progress: number) {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(1500);
|
||||
});
|
||||
// a legenda leva CAPTION_OUT_MS para trocar de projeto
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(CAPTION_OUT_MS + 20);
|
||||
});
|
||||
}
|
||||
|
||||
const sources = () =>
|
||||
@@ -179,6 +185,9 @@ const sources = () =>
|
||||
const activeLayer = () =>
|
||||
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
|
||||
|
||||
/** A legenda do projeto em cena: um bloco só, fora das camadas. */
|
||||
const caption = () => document.querySelector(".ds-reel-caption") as HTMLElement;
|
||||
|
||||
describe("ProjectReel", () => {
|
||||
it("falls back to a plain project list, nothing pinned, for users who prefer reduced motion", () => {
|
||||
reducedMotion = true;
|
||||
@@ -244,7 +253,7 @@ describe("ProjectReel", () => {
|
||||
expect(
|
||||
within(nav).getByRole("list", { name: "Trechos de SEV Moda Íntima" }),
|
||||
).toBeInTheDocument();
|
||||
expect(activeLayer()).toHaveTextContent("SEV Moda Íntima");
|
||||
expect(caption()).toHaveTextContent("SEV Moda Íntima");
|
||||
});
|
||||
|
||||
it("hides the layers that are not on screen from assistive technology and from the keyboard", () => {
|
||||
@@ -258,7 +267,7 @@ describe("ProjectReel", () => {
|
||||
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", () => {
|
||||
it("moves to the next project and syncs the laptop video to the scroll; the phone is just a poster", () => {
|
||||
renderReel();
|
||||
readyVideos();
|
||||
|
||||
@@ -267,18 +276,20 @@ describe("ProjectReel", () => {
|
||||
scrollTo(t);
|
||||
|
||||
const layer = activeLayer();
|
||||
expect(layer).toHaveTextContent("Lavação 047");
|
||||
const lavacao = getShowcaseConfig("lavacao-047");
|
||||
if (!lavacao) throw new Error("config ausente");
|
||||
expect(layer.querySelector(".ds-reel-title")).toBeNull();
|
||||
expect(caption()).toHaveTextContent("Lavação 047");
|
||||
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);
|
||||
// o celular da moldura só tem o pôster: nenhum vídeo dentro dele
|
||||
expect(layer.querySelector(".ds-phone")).toBeInTheDocument();
|
||||
expect(layer.querySelector(".ds-phone video")).toBeNull();
|
||||
expect(
|
||||
(layer.querySelector(".ds-phone-screen") as HTMLElement).style
|
||||
.backgroundImage,
|
||||
).toContain("lavacao-047/mobile.webp");
|
||||
});
|
||||
|
||||
it("names the chapter on screen and counts them", () => {
|
||||
@@ -286,15 +297,14 @@ describe("ProjectReel", () => {
|
||||
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\/03$/,
|
||||
);
|
||||
expect(
|
||||
caption().querySelector(".ds-reel-chapter-count")?.textContent,
|
||||
).toMatch(/^0\d\/03$/);
|
||||
const labels = lavacao?.chapters.map((chapter) => chapter.label) ?? [];
|
||||
expect(labels).toContain(
|
||||
layer.querySelector(".ds-reel-chapter strong")?.textContent,
|
||||
caption().querySelector(".ds-reel-chapter strong")?.textContent,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -309,7 +319,7 @@ describe("ProjectReel", () => {
|
||||
expect(first.currentTime).toBe(0);
|
||||
|
||||
scrollTo(1);
|
||||
expect(activeLayer()).toHaveTextContent("AS Serviços");
|
||||
expect(caption()).toHaveTextContent("AS Serviços");
|
||||
const last = activeLayer().querySelector(
|
||||
".ds-reel-screen video",
|
||||
) as HTMLVideoElement;
|
||||
@@ -336,9 +346,8 @@ describe("ProjectReel", () => {
|
||||
expect(
|
||||
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
loaded.some((src) => src?.includes("sev-modaintima/scrub-mobile.mp4")),
|
||||
).toBe(true);
|
||||
// o celular da moldura é só pôster: o vídeo de celular nunca é baixado na vitrine fixa
|
||||
expect(loaded.some((src) => src?.includes("scrub-mobile"))).toBe(false);
|
||||
// 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);
|
||||
@@ -378,7 +387,7 @@ describe("ProjectReel", () => {
|
||||
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not download the phone video while the phone frame is not on screen", () => {
|
||||
it("never downloads the phone video, whether or not the phone frame is on screen (it is only a poster)", () => {
|
||||
phoneWidth = 0;
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
@@ -487,8 +496,7 @@ describe("ProjectReel", () => {
|
||||
|
||||
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;
|
||||
const actions = caption().querySelector(".ds-reel-actions") as HTMLElement;
|
||||
expect(
|
||||
within(actions).getByRole("link", { name: /Ver case/ }),
|
||||
).toHaveAttribute("href", "/cases/sev-modaintima");
|
||||
@@ -502,20 +510,17 @@ describe("ProjectReel", () => {
|
||||
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();
|
||||
scrollTo(1);
|
||||
expect(caption()).toHaveTextContent("AS Serviços");
|
||||
expect(
|
||||
within(asServicos as HTMLElement).queryByRole("link", {
|
||||
name: /Abrir site/,
|
||||
hidden: true,
|
||||
}),
|
||||
within(caption()).queryByRole("link", { name: /Abrir site/ }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the enlarged video from the pinned scene", () => {
|
||||
renderReel();
|
||||
fireEvent.click(
|
||||
within(activeLayer()).getByRole("button", { name: /Ampliar vídeo/ }),
|
||||
within(caption()).getByRole("button", { name: /Ampliar vídeo/ }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "SEV Moda Íntima" }),
|
||||
@@ -551,3 +556,156 @@ describe("ProjectReel", () => {
|
||||
expect(last.top).toBeLessThan(reelOffset(weights, 1, 0) * SCROLLABLE);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectReel: a troca de projeto não duplica nada na tela", () => {
|
||||
/** Move a rolagem para o meio do trecho do projeto e deixa passar só `ms`. */
|
||||
function moveTo(index: number, ms: number) {
|
||||
reelTop = HEADER - reelOffset(weights, index, 0.5) * SCROLLABLE;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(ms);
|
||||
});
|
||||
}
|
||||
|
||||
const titles = () =>
|
||||
[...document.querySelectorAll(".ds-reel-title")].map(
|
||||
(title) => title.textContent,
|
||||
);
|
||||
|
||||
it("has one caption for the whole pinned scene: title, counter and buttons exist once, outside the layers", () => {
|
||||
renderReel();
|
||||
|
||||
expect(document.querySelectorAll(".ds-reel-caption")).toHaveLength(1);
|
||||
expect(document.querySelectorAll(".ds-reel-title")).toHaveLength(1);
|
||||
expect(document.querySelectorAll(".ds-reel-chapter-count")).toHaveLength(1);
|
||||
expect(document.querySelectorAll(".ds-reel-actions")).toHaveLength(1);
|
||||
expect(document.querySelectorAll(".ds-reel-layer")).toHaveLength(5);
|
||||
for (const layer of document.querySelectorAll(".ds-reel-layer")) {
|
||||
expect(layer.closest(".ds-reel-caption")).toBeNull();
|
||||
expect(layer.querySelector(".ds-reel-caption")).toBeNull();
|
||||
expect(layer.querySelector("h3, button, a")).toBeNull();
|
||||
}
|
||||
// os aparelhos e a legenda dividem o mesmo bloco, a legenda abaixo
|
||||
const set = document.querySelector(".ds-reel-set") as HTMLElement;
|
||||
expect(set.querySelector(".ds-reel-devices")).toBeInTheDocument();
|
||||
expect(set.lastElementChild).toBe(caption());
|
||||
});
|
||||
|
||||
it("each layer holds only the devices: the laptop with its video and the phone", () => {
|
||||
renderReel();
|
||||
|
||||
for (const layer of document.querySelectorAll(".ds-reel-layer")) {
|
||||
expect(layer.querySelectorAll(".ds-bframe")).toHaveLength(1);
|
||||
expect(layer.querySelectorAll(".ds-phone")).toHaveLength(1);
|
||||
expect(layer.querySelectorAll(".ds-reel-screen video")).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("fades the old caption out first, with its old content and no second caption, then brings the new one in", () => {
|
||||
renderReel();
|
||||
expect(caption()).toHaveTextContent("SEV Moda Íntima");
|
||||
expect(caption()).not.toHaveAttribute("data-leaving");
|
||||
|
||||
// o projeto muda; só uns milissegundos depois (a legenda ainda está saindo)
|
||||
moveTo(1, 60);
|
||||
expect(activeLayer()).toHaveAttribute("data-state", "active");
|
||||
expect(activeLayer().querySelector(".ds-bframe-url")?.textContent).not.toBe(
|
||||
"sevmodaintima.com.br",
|
||||
);
|
||||
expect(caption()).toHaveAttribute("data-leaving");
|
||||
expect(titles()).toEqual(["SEV Moda Íntima"]);
|
||||
expect(document.querySelectorAll(".ds-reel-actions")).toHaveLength(1);
|
||||
|
||||
// passou o tempo de saída: o conteúdo novo entra, e a legenda volta
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(CAPTION_OUT_MS + 40);
|
||||
});
|
||||
expect(titles()).toEqual(["Lavação 047"]);
|
||||
expect(caption()).not.toHaveAttribute("data-leaving");
|
||||
});
|
||||
|
||||
it("leaves in 120 ms: before that the old caption is still there, right after it the new one", () => {
|
||||
expect(CAPTION_OUT_MS).toBe(120);
|
||||
renderReel();
|
||||
moveTo(1, 20);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(CAPTION_OUT_MS - 30);
|
||||
});
|
||||
expect(titles()).toEqual(["SEV Moda Íntima"]);
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(60);
|
||||
});
|
||||
expect(titles()).toEqual(["Lavação 047"]);
|
||||
});
|
||||
|
||||
it("at no moment of the change are two project titles (or two sets of buttons) on screen", () => {
|
||||
renderReel();
|
||||
const seen: number[] = [];
|
||||
for (const [index, ms] of [
|
||||
[1, 16],
|
||||
[1, 50],
|
||||
[1, 80],
|
||||
[2, 16],
|
||||
[2, 100],
|
||||
[2, 200],
|
||||
[3, 10],
|
||||
[0, 10],
|
||||
[0, 300],
|
||||
] as const) {
|
||||
moveTo(index, ms);
|
||||
seen.push(
|
||||
document.querySelectorAll(".ds-reel-title").length,
|
||||
document.querySelectorAll(".ds-reel-actions").length,
|
||||
document.querySelectorAll(".ds-reel-chapter-count").length,
|
||||
);
|
||||
}
|
||||
|
||||
expect(new Set(seen)).toEqual(new Set([1]));
|
||||
});
|
||||
|
||||
it("keeps coming back to the project in scene when the scroll goes back before the caption finished leaving", () => {
|
||||
renderReel();
|
||||
|
||||
moveTo(1, 40);
|
||||
moveTo(0, 40);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
expect(titles()).toEqual(["SEV Moda Íntima"]);
|
||||
expect(caption()).not.toHaveAttribute("data-leaving");
|
||||
});
|
||||
|
||||
it("changes the chapter inside the same project in place, without fading the caption", () => {
|
||||
renderReel();
|
||||
readyVideos();
|
||||
|
||||
scrollTo(reelOffset(weights, 0, 0.9)); // fim do primeiro projeto: outro trecho
|
||||
|
||||
expect(caption()).not.toHaveAttribute("data-leaving");
|
||||
expect(titles()).toEqual(["SEV Moda Íntima"]);
|
||||
expect(
|
||||
caption().querySelector(".ds-reel-chapter-count")?.textContent,
|
||||
).not.toBe("01/03");
|
||||
});
|
||||
|
||||
it("never moves the caption text: no inline transform, and the CSS only fades it", () => {
|
||||
renderReel();
|
||||
moveTo(1, 60);
|
||||
|
||||
expect(caption().getAttribute("style")).toBeNull();
|
||||
for (const element of caption().querySelectorAll("*"))
|
||||
expect((element as HTMLElement).style.transform).toBe("");
|
||||
|
||||
const css = fs.readFileSync(path.join(__dirname, "showcase.css"), "utf8");
|
||||
const rule = /\.ds-reel-caption \{[\s\S]*?\n\}/.exec(css)?.[0] ?? "";
|
||||
expect(rule).toContain("opacity");
|
||||
expect(rule).not.toMatch(/transform|translate/);
|
||||
expect(rule).toContain("0.12s");
|
||||
const layer = /\.ds-reel-layer \{[\s\S]*?\n\}/.exec(css)?.[0] ?? "";
|
||||
expect(layer).toContain("opacity");
|
||||
expect(layer).not.toMatch(/transform|translate/);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
type CSSProperties,
|
||||
type RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
chapterIndexAt,
|
||||
getScrubSource,
|
||||
getShowcaseAssets,
|
||||
mobileFractionAt,
|
||||
} from "@/lib/project-showcase";
|
||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||
import { PhoneFrame } from "./PhoneFrame";
|
||||
@@ -54,6 +54,9 @@ const headerHeight = () => {
|
||||
return headerCache.height;
|
||||
};
|
||||
|
||||
/** Saída da legenda (ms) antes de a do novo projeto entrar: curta, para a troca nunca mostrar duas legendas. */
|
||||
export const CAPTION_OUT_MS = 120;
|
||||
|
||||
/** Põe o vídeo no instante `fraction` (0 a 1) sem repetir buscas enquanto a anterior ainda não terminou. */
|
||||
function seekVideo(video: HTMLVideoElement | null, fraction: number) {
|
||||
if (!video || video.readyState < 1 || !video.duration) return;
|
||||
@@ -147,15 +150,49 @@ export function ProjectReel() {
|
||||
);
|
||||
}
|
||||
|
||||
const layerState = (index: number, active: number) => {
|
||||
if (index === active) return "active";
|
||||
return index < active ? "before" : "after";
|
||||
};
|
||||
|
||||
/** Estado do traço de capítulo: ainda por vir, o de agora ou já visto. */
|
||||
const tickState = (tick: number, current: number) => {
|
||||
if (tick > current) return "todo";
|
||||
return tick === current ? "now" : "done";
|
||||
};
|
||||
|
||||
/** Progresso (0 a 1) de cada vídeo quando o projeto `index` está na fração `local` do trecho dele. */
|
||||
function targetsAt(count: number, index: number, local: number) {
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
if (i < index) return 1;
|
||||
return i === index ? videoProgress(local) : 0;
|
||||
});
|
||||
}
|
||||
|
||||
/** Onde a rolagem está na vitrine: projeto em cena, fração dele e se ela está a ~1 tela da janela. Só lê o DOM. */
|
||||
function readReel(container: HTMLElement, pin: HTMLElement, weights: number[]) {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const scrollable = rect.height - pin.offsetHeight;
|
||||
const progress =
|
||||
scrollable > 0 ? (headerHeight() - rect.top) / scrollable : 0;
|
||||
const { index, local } = locateInReel(weights, progress);
|
||||
// a ~1 tela de distância (acima ou abaixo): é quando pôsteres e vídeo da vitrine começam a ser pedidos
|
||||
const near =
|
||||
rect.top < window.innerHeight && rect.bottom > -window.innerHeight;
|
||||
return { index, local, near };
|
||||
}
|
||||
|
||||
/**
|
||||
* Vitrine animada: o site de cada projeto rola dentro de um notebook (e de um celular ao lado) conforme a
|
||||
* página rola. A cena fica presa (position: sticky) durante a rolagem de todos os projetos; cada projeto tem
|
||||
* um trecho com uma pausa no começo e outra no fim. Capítulos nomeiam o que está na tela; notebook e celular
|
||||
* mostram o mesmo trecho do site ao mesmo tempo.
|
||||
* um trecho com uma pausa no começo e outra no fim. Capítulos nomeiam o que está na tela; o celular é só um
|
||||
* pôster da mesma tela (o vídeo de celular é do celular: aqui ele nem é baixado).
|
||||
*
|
||||
* Os aparelhos de cada projeto são uma camada (as camadas trocam por fade cruzado); a legenda (nome, trecho,
|
||||
* contador e botões) é UM bloco só, fora das camadas, que sai e entra sem nunca existir em dobro.
|
||||
*
|
||||
* Mídia: só o projeto em cena baixa vídeo (os vizinhos ficam no pôster), e só quando a vitrine está a ~1 tela
|
||||
* da janela; com "economizar dados" ninguém baixa vídeo. O vídeo de celular só é pedido se a moldura do
|
||||
* celular está à vista.
|
||||
* da janela; com "economizar dados" ninguém baixa vídeo.
|
||||
*/
|
||||
function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
const openProject = useShowcaseOpen();
|
||||
@@ -163,14 +200,12 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
const [chapter, setChapter] = useState(0);
|
||||
const [near, setNear] = useState(false);
|
||||
const [settled, setSettled] = useState(0);
|
||||
const [phoneVisible, setPhoneVisible] = useState(true);
|
||||
const saveData = useSaveData();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const pinRef = useRef<HTMLDivElement>(null);
|
||||
const indexRef = useRef<HTMLOListElement>(null);
|
||||
const desktopVideos = useRef<Array<HTMLVideoElement | null>>([]);
|
||||
const phoneVideos = useRef<Array<HTMLVideoElement | null>>([]);
|
||||
const bars = useRef<Array<HTMLElement | null>>([]);
|
||||
const live = useRef({
|
||||
target: [] as number[],
|
||||
@@ -180,7 +215,6 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
raf: 0,
|
||||
last: 0,
|
||||
near: false,
|
||||
phone: true,
|
||||
});
|
||||
|
||||
const weights = useMemo(
|
||||
@@ -224,12 +258,9 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
useEffect(() => {
|
||||
items.forEach((_, index) => {
|
||||
if (isAttached(index)) return;
|
||||
for (const video of [
|
||||
desktopVideos.current[index],
|
||||
phoneVideos.current[index],
|
||||
])
|
||||
if (video && video.networkState !== 0 && !video.getAttribute("src"))
|
||||
video.load();
|
||||
const video = desktopVideos.current[index];
|
||||
if (video && video.networkState !== 0 && !video.getAttribute("src"))
|
||||
video.load();
|
||||
});
|
||||
}, [items, isAttached]);
|
||||
|
||||
@@ -239,19 +270,14 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
const seconds = state.last ? (now - state.last) / 1000 : 0;
|
||||
state.last = now;
|
||||
let moving = false;
|
||||
items.forEach((item, index) => {
|
||||
items.forEach((_, index) => {
|
||||
if (Math.abs(index - state.active) > 1) return;
|
||||
const before = state.smooth[index] ?? 0;
|
||||
const next = easeToward(before, state.target[index] ?? 0, seconds);
|
||||
const target = state.target[index] ?? 0;
|
||||
const next = easeToward(state.smooth[index] ?? 0, target, seconds);
|
||||
state.smooth[index] = next;
|
||||
if (Math.abs(next - (state.target[index] ?? 0)) > 0.0004) moving = true;
|
||||
if (Math.abs(next - target) > 0.0004) moving = true;
|
||||
seekVideo(desktopVideos.current[index], next);
|
||||
seekVideo(
|
||||
phoneVideos.current[index],
|
||||
mobileFractionAt(item.config, next),
|
||||
);
|
||||
const bar = bars.current[index];
|
||||
if (bar) bar.style.setProperty("--p", next.toFixed(4));
|
||||
bars.current[index]?.style.setProperty("--p", next.toFixed(4));
|
||||
});
|
||||
const current = items[state.active];
|
||||
if (current) {
|
||||
@@ -270,16 +296,25 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
);
|
||||
|
||||
// um vídeo que acabou de carregar precisa ir ao ponto em que a rolagem já está
|
||||
const syncVideos = useCallback(
|
||||
const syncVideo = useCallback((index: number) => {
|
||||
const state = live.current;
|
||||
seekVideo(
|
||||
desktopVideos.current[index],
|
||||
state.smooth[index] ?? state.target[index] ?? 0,
|
||||
);
|
||||
}, []);
|
||||
|
||||
/** Troca o projeto em cena: o capítulo vem do ponto em que o vídeo dele já está. */
|
||||
const changeActive = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
const at = live.current.smooth[index] ?? live.current.target[index] ?? 0;
|
||||
seekVideo(desktopVideos.current[index], at);
|
||||
if (item)
|
||||
seekVideo(
|
||||
phoneVideos.current[index],
|
||||
mobileFractionAt(item.config, at),
|
||||
);
|
||||
const state = live.current;
|
||||
state.active = index;
|
||||
state.chapter = chapterIndexAt(
|
||||
items[index].config,
|
||||
state.smooth[index] ?? 0,
|
||||
);
|
||||
setActive(index);
|
||||
setChapter(state.chapter);
|
||||
},
|
||||
[items],
|
||||
);
|
||||
@@ -288,46 +323,20 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
const container = containerRef.current;
|
||||
const pin = pinRef.current;
|
||||
// escondida por CSS (tela baixa, por exemplo): sem altura, não há o que medir nem o que carregar
|
||||
if (!container || !pin || items.length === 0 || !container.offsetHeight)
|
||||
return;
|
||||
if (!container?.offsetHeight || !pin) return;
|
||||
const state = live.current;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const scrollable = rect.height - pin.offsetHeight;
|
||||
const progress =
|
||||
scrollable > 0 ? (headerHeight() - rect.top) / scrollable : 0;
|
||||
const { index, local } = locateInReel(weights, progress);
|
||||
state.target = items.map((_, i) =>
|
||||
i < index ? 1 : i === index ? videoProgress(local) : 0,
|
||||
);
|
||||
// a ~1 tela de distância (acima ou abaixo): é quando pôsteres e vídeo da vitrine começam a ser pedidos
|
||||
const near =
|
||||
rect.top < window.innerHeight && rect.bottom > -window.innerHeight;
|
||||
if (near !== state.near) {
|
||||
state.near = near;
|
||||
setNear(near);
|
||||
}
|
||||
// o celular da moldura só baixa vídeo se está à vista (tem tamanho na tela)
|
||||
const phone = container.querySelector<HTMLElement>(".ds-reel-phone");
|
||||
const phoneShown = Boolean(phone && phone.offsetWidth > 0);
|
||||
if (phoneShown !== state.phone) {
|
||||
state.phone = phoneShown;
|
||||
setPhoneVisible(phoneShown);
|
||||
}
|
||||
|
||||
if (index !== state.active) {
|
||||
state.active = index;
|
||||
state.chapter = chapterIndexAt(
|
||||
items[index].config,
|
||||
state.smooth[index] ?? 0,
|
||||
);
|
||||
setActive(index);
|
||||
setChapter(state.chapter);
|
||||
const { index, local, near: isNear } = readReel(container, pin, weights);
|
||||
state.target = targetsAt(items.length, index, local);
|
||||
if (isNear !== state.near) {
|
||||
state.near = isNear;
|
||||
setNear(isNear);
|
||||
}
|
||||
if (index !== state.active) changeActive(index);
|
||||
if (state.near && !state.raf) {
|
||||
state.last = 0;
|
||||
state.raf = requestAnimationFrame(frame);
|
||||
}
|
||||
}, [frame, items, weights]);
|
||||
}, [changeActive, frame, items.length, weights]);
|
||||
|
||||
useEffect(() => {
|
||||
let pending = 0;
|
||||
@@ -386,187 +395,41 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
<h2 className="sr-only">Projetos de clientes</h2>
|
||||
<div className="ds-reel-pin" ref={pinRef}>
|
||||
<div className="ds-reel-grid">
|
||||
<nav className="ds-reel-index" aria-label="Projetos da vitrine">
|
||||
<p className="ds-reel-hint">
|
||||
<span aria-hidden="true" className="ds-reel-hint-line" />
|
||||
Em movimento
|
||||
<span className="ds-reel-hint-sub">Role para avançar</span>
|
||||
</p>
|
||||
<ol ref={indexRef}>
|
||||
{items.map((item, index) => (
|
||||
<li key={item.config.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={index === active ? "true" : undefined}
|
||||
aria-label={item.project.title}
|
||||
onClick={() => goTo(index)}
|
||||
>
|
||||
{/* o número vem do CSS (data-n): o texto visível do botão fica igual ao nome acessível (WCAG 2.5.3) */}
|
||||
<span
|
||||
className="ds-reel-num"
|
||||
aria-hidden="true"
|
||||
data-n={String(index + 1).padStart(2, "0")}
|
||||
/>
|
||||
<span className="ds-reel-name">{item.project.title}</span>
|
||||
<i
|
||||
className="ds-reel-bar"
|
||||
aria-hidden="true"
|
||||
ref={(element) => {
|
||||
bars.current[index] = element;
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{index === active && (
|
||||
<ol
|
||||
className="ds-reel-chapters"
|
||||
aria-label={`Trechos de ${item.project.title}`}
|
||||
>
|
||||
{item.config.chapters.map((entry, i) => (
|
||||
<li key={entry.label}>
|
||||
<button
|
||||
type="button"
|
||||
aria-current={i === chapter ? "true" : undefined}
|
||||
onClick={() => goToChapter(index, i)}
|
||||
>
|
||||
<span aria-hidden="true">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{entry.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
<ReelIndex
|
||||
items={items}
|
||||
active={active}
|
||||
chapter={chapter}
|
||||
listRef={indexRef}
|
||||
bars={bars}
|
||||
onProject={goTo}
|
||||
onChapter={goToChapter}
|
||||
/>
|
||||
|
||||
<div className="ds-reel-stage">
|
||||
{items.map((item, index) => {
|
||||
const { config, project } = item;
|
||||
const isActive = index === active;
|
||||
const attach = isAttached(index);
|
||||
const posters = showsPoster(index);
|
||||
const desktop = getShowcaseAssets(config.id, "desktop");
|
||||
const phone = getShowcaseAssets(config.id, "mobile");
|
||||
return (
|
||||
<div
|
||||
key={config.id}
|
||||
className="ds-reel-layer"
|
||||
data-state={
|
||||
isActive ? "active" : index < active ? "before" : "after"
|
||||
}
|
||||
style={{ "--accent": config.accent } as CSSProperties}
|
||||
aria-hidden={isActive ? undefined : true}
|
||||
inert={!isActive}
|
||||
>
|
||||
<div className="ds-reel-set">
|
||||
<div className="ds-reel-devices">
|
||||
<BrowserFrame
|
||||
url={displayUrl(project)}
|
||||
className="ds-reel-laptop"
|
||||
>
|
||||
<div
|
||||
className="ds-reel-screen"
|
||||
style={
|
||||
posters
|
||||
? { backgroundImage: `url(${desktop.poster})` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<video
|
||||
ref={(element) => {
|
||||
desktopVideos.current[index] = element;
|
||||
}}
|
||||
src={
|
||||
attach
|
||||
? getScrubSource(config.id, "desktop")
|
||||
: undefined
|
||||
}
|
||||
poster={posters ? desktop.poster : undefined}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
onLoadedMetadata={() => syncVideos(index)}
|
||||
onLoadedData={(event) => prime(event.currentTarget)}
|
||||
/>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
<PhoneFrame
|
||||
className="ds-reel-phone"
|
||||
poster={posters ? phone.poster : undefined}
|
||||
>
|
||||
<video
|
||||
ref={(element) => {
|
||||
phoneVideos.current[index] = element;
|
||||
}}
|
||||
src={
|
||||
attach && phoneVisible
|
||||
? getScrubSource(config.id, "mobile")
|
||||
: undefined
|
||||
}
|
||||
poster={posters ? phone.poster : undefined}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
onLoadedMetadata={() => syncVideos(index)}
|
||||
onLoadedData={(event) => prime(event.currentTarget)}
|
||||
/>
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
<div className="ds-reel-set">
|
||||
<div className="ds-reel-devices">
|
||||
{items.map((item, index) => (
|
||||
<ReelLayer
|
||||
key={item.config.id}
|
||||
item={item}
|
||||
state={layerState(index, active)}
|
||||
poster={showsPoster(index)}
|
||||
attached={isAttached(index)}
|
||||
videoRef={(element) => {
|
||||
desktopVideos.current[index] = element;
|
||||
}}
|
||||
onVideoLoaded={() => syncVideo(index)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ds-reel-caption">
|
||||
<p className="ds-kicker">
|
||||
{project.category} · {project.status}
|
||||
</p>
|
||||
<h3 className="ds-reel-title">{project.title}</h3>
|
||||
{project.subtitle && (
|
||||
<p className="ds-reel-sub">{project.subtitle}</p>
|
||||
)}
|
||||
<div className="ds-reel-chapter">
|
||||
<span className="ds-reel-chapter-count">
|
||||
{String((isActive ? chapter : 0) + 1).padStart(
|
||||
2,
|
||||
"0",
|
||||
)}
|
||||
/{String(config.chapters.length).padStart(2, "0")}
|
||||
</span>
|
||||
<strong>
|
||||
{config.chapters[isActive ? chapter : 0].label}
|
||||
</strong>
|
||||
<ol className="ds-reel-ticks" aria-hidden="true">
|
||||
{config.chapters.map((entry, tick) => (
|
||||
<li
|
||||
key={entry.label}
|
||||
data-state={
|
||||
!isActive || tick > chapter
|
||||
? "todo"
|
||||
: tick === chapter
|
||||
? "now"
|
||||
: "done"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<ProjectActions
|
||||
item={item}
|
||||
onExpand={() => openProject(config.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<ReelCaption
|
||||
items={items}
|
||||
active={active}
|
||||
chapter={chapter}
|
||||
onExpand={openProject}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="sr-only" aria-live="polite">
|
||||
@@ -578,3 +441,217 @@ function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
/** Lista de projetos (e, no desktop, os trechos do projeto em cena): leva a rolagem ao ponto escolhido. */
|
||||
function ReelIndex({
|
||||
items,
|
||||
active,
|
||||
chapter,
|
||||
listRef,
|
||||
bars,
|
||||
onProject,
|
||||
onChapter,
|
||||
}: {
|
||||
items: ShowcaseItem[];
|
||||
active: number;
|
||||
chapter: number;
|
||||
listRef: RefObject<HTMLOListElement | null>;
|
||||
bars: RefObject<Array<HTMLElement | null>>;
|
||||
onProject: (index: number) => void;
|
||||
onChapter: (index: number, chapter: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<nav className="ds-reel-index" aria-label="Projetos da vitrine">
|
||||
<p className="ds-reel-hint">
|
||||
<span aria-hidden="true" className="ds-reel-hint-line" />
|
||||
Em movimento
|
||||
<span className="ds-reel-hint-sub">Role para avançar</span>
|
||||
</p>
|
||||
<ol ref={listRef}>
|
||||
{items.map((item, index) => (
|
||||
<li key={item.config.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="ds-reel-pick"
|
||||
aria-current={index === active ? "true" : undefined}
|
||||
aria-label={item.project.title}
|
||||
onClick={() => onProject(index)}
|
||||
>
|
||||
{/* o número vem do CSS (data-n): o texto visível do botão fica igual ao nome acessível (WCAG 2.5.3) */}
|
||||
<span
|
||||
className="ds-reel-num"
|
||||
aria-hidden="true"
|
||||
data-n={String(index + 1).padStart(2, "0")}
|
||||
/>
|
||||
<span className="ds-reel-name">{item.project.title}</span>
|
||||
<i
|
||||
className="ds-reel-bar"
|
||||
aria-hidden="true"
|
||||
ref={(element) => {
|
||||
bars.current[index] = element;
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{index === active && (
|
||||
<ol
|
||||
className="ds-reel-chapters"
|
||||
aria-label={`Trechos de ${item.project.title}`}
|
||||
>
|
||||
{item.config.chapters.map((entry, i) => (
|
||||
<li key={entry.label}>
|
||||
<button
|
||||
type="button"
|
||||
className="ds-reel-chapter-pick"
|
||||
aria-current={i === chapter ? "true" : undefined}
|
||||
onClick={() => onChapter(index, i)}
|
||||
>
|
||||
<span className="ds-reel-chapter-num" aria-hidden="true">
|
||||
{String(i + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{entry.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Os aparelhos de um projeto: notebook com o vídeo que acompanha a rolagem e celular só com o pôster. Camadas
|
||||
* empilhadas na mesma célula; quem não está em cena fica transparente, inerte e escondido dos leitores de tela.
|
||||
*/
|
||||
function ReelLayer({
|
||||
item,
|
||||
state,
|
||||
poster,
|
||||
attached,
|
||||
videoRef,
|
||||
onVideoLoaded,
|
||||
}: {
|
||||
item: ShowcaseItem;
|
||||
state: "active" | "before" | "after";
|
||||
/** O pôster já pode ser pedido (a vitrine está perto e este projeto é o da vez ou um vizinho). */
|
||||
poster: boolean;
|
||||
/** O vídeo de rolagem já pode ser baixado. */
|
||||
attached: boolean;
|
||||
videoRef: (element: HTMLVideoElement | null) => void;
|
||||
onVideoLoaded: () => void;
|
||||
}) {
|
||||
const { config, project } = item;
|
||||
const isActive = state === "active";
|
||||
const desktop = getShowcaseAssets(config.id, "desktop");
|
||||
const phone = getShowcaseAssets(config.id, "mobile");
|
||||
return (
|
||||
<div
|
||||
className="ds-reel-layer"
|
||||
data-state={state}
|
||||
style={{ "--accent": config.accent } as CSSProperties}
|
||||
aria-hidden={isActive ? undefined : true}
|
||||
inert={!isActive}
|
||||
>
|
||||
<BrowserFrame url={displayUrl(project)} className="ds-reel-laptop">
|
||||
<div
|
||||
className="ds-reel-screen"
|
||||
style={
|
||||
poster ? { backgroundImage: `url(${desktop.poster})` } : undefined
|
||||
}
|
||||
>
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={attached ? getScrubSource(config.id) : undefined}
|
||||
poster={poster ? desktop.poster : undefined}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disablePictureInPicture
|
||||
disableRemotePlayback
|
||||
onLoadedMetadata={onVideoLoaded}
|
||||
onLoadedData={(event) => prime(event.currentTarget)}
|
||||
/>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
<PhoneFrame
|
||||
className="ds-reel-phone"
|
||||
poster={poster ? phone.poster : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const pad = (value: number) => String(value).padStart(2, "0");
|
||||
|
||||
/**
|
||||
* Legenda do projeto em cena: um bloco só, fora das camadas. Na troca de projeto a legenda atual some em
|
||||
* CAPTION_OUT_MS e só então o conteúdo muda e ela volta (fade, sem deslocamento): nunca há título, contador ou
|
||||
* botões em dobro. Dentro do mesmo projeto, o trecho troca no lugar.
|
||||
*/
|
||||
function ReelCaption({
|
||||
items,
|
||||
active,
|
||||
chapter,
|
||||
onExpand,
|
||||
}: {
|
||||
items: ShowcaseItem[];
|
||||
active: number;
|
||||
chapter: number;
|
||||
onExpand: (id: string) => void;
|
||||
}) {
|
||||
const [shown, setShown] = useState({ index: active, chapter });
|
||||
const leaving = shown.index !== active;
|
||||
// o que vale quando a saída termina: o trecho continua mudando durante os 120 ms, e isso não reinicia a espera
|
||||
const latest = useRef({ active, chapter });
|
||||
useEffect(() => {
|
||||
latest.current = { active, chapter };
|
||||
});
|
||||
|
||||
// dentro do mesmo projeto o trecho troca no lugar
|
||||
useEffect(() => {
|
||||
if (shown.index === active && shown.chapter !== chapter)
|
||||
setShown({ index: active, chapter });
|
||||
}, [active, chapter, shown]);
|
||||
|
||||
// projeto novo: a legenda sai e, passado CAPTION_OUT_MS, o conteúdo muda
|
||||
useEffect(() => {
|
||||
if (shown.index === active) return;
|
||||
const timer = setTimeout(
|
||||
() =>
|
||||
setShown({
|
||||
index: latest.current.active,
|
||||
chapter: latest.current.chapter,
|
||||
}),
|
||||
CAPTION_OUT_MS,
|
||||
);
|
||||
return () => clearTimeout(timer);
|
||||
}, [active, shown.index]);
|
||||
|
||||
const item = items[shown.index];
|
||||
const { config, project } = item;
|
||||
return (
|
||||
<div className="ds-reel-caption" data-leaving={leaving ? "" : undefined}>
|
||||
<p className="ds-kicker">
|
||||
{project.category} · {project.status}
|
||||
</p>
|
||||
<h3 className="ds-reel-title">{project.title}</h3>
|
||||
{project.subtitle && <p className="ds-reel-sub">{project.subtitle}</p>}
|
||||
<div className="ds-reel-chapter">
|
||||
<span className="ds-reel-chapter-count">
|
||||
{pad(shown.chapter + 1)}/{pad(config.chapters.length)}
|
||||
</span>
|
||||
<strong>{config.chapters[shown.chapter].label}</strong>
|
||||
<ol className="ds-reel-ticks" aria-hidden="true">
|
||||
{config.chapters.map((entry, tick) => (
|
||||
<li key={entry.label} data-state={tickState(tick, shown.chapter)} />
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<ProjectActions item={item} onExpand={() => onExpand(config.id)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,557 +1,166 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import {
|
||||
buildShowcaseItems,
|
||||
type ShowcaseItem,
|
||||
ShowcaseProvider,
|
||||
useShowcaseCard,
|
||||
useShowcaseItems,
|
||||
useShowcaseOpen,
|
||||
} from "./ShowcaseProvider";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de ShowcaseProvider (complemento de ShowcaseProvider.test.tsx): register() sem
|
||||
* IntersectionObserver, limiar de visibilidade, quem devolve o mouse, desmontagem do card em destaque, foco
|
||||
* que sai para dentro ou para fora do card, aba oculta, pausa do usuário e a janela ampliada.
|
||||
* Testes de caminhos-base de ShowcaseProvider (complemento de ShowcaseProvider.test.tsx). O provedor só guarda a
|
||||
* lista da vitrine e qual projeto está ampliado: aqui, a janela aberta quando a lista muda, a troca de projeto
|
||||
* com a janela já aberta e a estabilidade do que ele entrega (lista e função de abrir) entre renderizações.
|
||||
* A janela ampliada é um dublê que só mostra o índice.
|
||||
*/
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
jest.mock("./ProjectLightbox", () => ({
|
||||
ProjectLightbox: ({
|
||||
index,
|
||||
items,
|
||||
onClose,
|
||||
onNavigate,
|
||||
}: {
|
||||
index: number;
|
||||
items: unknown[];
|
||||
onClose: () => void;
|
||||
onNavigate: (index: number) => void;
|
||||
}) => (
|
||||
<div role="dialog" aria-label="ampliado">
|
||||
aberto:{index} de {items.length}
|
||||
<button type="button" onClick={onClose}>
|
||||
fechar
|
||||
</button>
|
||||
<button type="button" onClick={() => onNavigate(0)}>
|
||||
ir para o primeiro
|
||||
</button>
|
||||
<button type="button" onClick={() => onNavigate(99)}>
|
||||
ir para um que não existe
|
||||
</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 report(target: Element, ratio: number) {
|
||||
act(() => {
|
||||
for (const observer of observers) {
|
||||
observer.callback(
|
||||
[
|
||||
{
|
||||
target,
|
||||
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, toggleAutoplay, userPaused } =
|
||||
useShowcaseCard(id);
|
||||
return (
|
||||
<article className="ds-card">
|
||||
<div ref={frameRef} data-testid={id} data-active={active} />
|
||||
<button type="button" onClick={open}>
|
||||
ampliar {id}
|
||||
</button>
|
||||
<button type="button" onClick={toggleAutoplay}>
|
||||
pausa {id} {String(userPaused)}
|
||||
</button>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const projects = getFeaturedProjects(null);
|
||||
const clientIds = ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"];
|
||||
const clients = projects.filter((project) => clientIds.includes(project.id));
|
||||
|
||||
function Cards({ ids }: { ids: string[] }) {
|
||||
function Opener({ id }: { id: string }) {
|
||||
const open = useShowcaseOpen();
|
||||
return (
|
||||
<ShowcaseProvider projects={projects}>
|
||||
{ids.map((id) => (
|
||||
<Card key={id} id={id} />
|
||||
))}
|
||||
</ShowcaseProvider>
|
||||
<button type="button" onClick={() => open(id)}>
|
||||
abrir {id}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function setup(ids = ["sev-modaintima", "lavacao-047"]) {
|
||||
const view = render(<Cards ids={ids} />);
|
||||
const frame = (id: string) => screen.getByTestId(id);
|
||||
// tela de 768px de altura no jsdom: o meio fica em 384
|
||||
if (ids.includes("sev-modaintima")) place(frame("sev-modaintima"), 100); // centro 250
|
||||
if (ids.includes("lavacao-047")) place(frame("lavacao-047"), 230); // centro 380
|
||||
return { view, frame };
|
||||
describe("ShowcaseProvider: janela ampliada e mudança da lista", () => {
|
||||
it("path: window open and its project leaves the list -> the window goes away", () => {
|
||||
const { rerender } = render(
|
||||
<ShowcaseProvider projects={clients}>
|
||||
<Opener id="sc-plus" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "abrir sc-plus" }));
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("aberto:2 de 4");
|
||||
|
||||
rerender(
|
||||
<ShowcaseProvider
|
||||
projects={clients.filter((project) => project.id !== "sc-plus")}
|
||||
>
|
||||
<Opener id="sc-plus" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: window open and another project is opened -> it moves to the new project, one window only", () => {
|
||||
render(
|
||||
<ShowcaseProvider projects={clients}>
|
||||
<Opener id="sev-modaintima" />
|
||||
<Opener id="as-servicos" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "abrir sev-modaintima" }),
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("aberto:0 de 4");
|
||||
fireEvent.click(screen.getByRole("button", { name: "abrir as-servicos" }));
|
||||
|
||||
expect(screen.getAllByRole("dialog")).toHaveLength(1);
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("aberto:3 de 4");
|
||||
});
|
||||
|
||||
it("path: a project outside the showcase is asked while another window is open -> no window is left", () => {
|
||||
render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<Opener id="sev-modaintima" />
|
||||
<Opener id="reddit-explorer" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "abrir sev-modaintima" }),
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "abrir reddit-explorer" }),
|
||||
);
|
||||
|
||||
// o id desconhecido não acha posição na lista: a janela não mostra projeto nenhum
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: closed window -> reopening the same project works again", () => {
|
||||
render(
|
||||
<ShowcaseProvider projects={clients}>
|
||||
<Opener id="lavacao-047" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "abrir lavacao-047" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "fechar" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "abrir lavacao-047" }));
|
||||
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("aberto:1 de 4");
|
||||
});
|
||||
});
|
||||
|
||||
type Captured = {
|
||||
items: ShowcaseItem[];
|
||||
open: (id: string) => void;
|
||||
};
|
||||
|
||||
function Probe({ onRender }: { onRender: (captured: Captured) => void }) {
|
||||
onRender({ items: useShowcaseItems(), open: useShowcaseOpen() });
|
||||
return null;
|
||||
}
|
||||
|
||||
const isActive = (element: Element) =>
|
||||
element.getAttribute("data-active") === "true";
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
reducedMotion = false;
|
||||
observers = [];
|
||||
global.IntersectionObserver =
|
||||
FakeIntersectionObserver as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
global.IntersectionObserver = OriginalObserver;
|
||||
Reflect.deleteProperty(document, "hidden");
|
||||
});
|
||||
|
||||
describe("ShowcaseProvider: IntersectionObserver e visibilidade", () => {
|
||||
it("path: browser without IntersectionObserver -> cards register fine but none ever plays", () => {
|
||||
// @ts-expect-error simula um navegador sem IntersectionObserver
|
||||
global.IntersectionObserver = undefined;
|
||||
|
||||
const { view, frame } = setup();
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(false);
|
||||
expect(isActive(frame("lavacao-047"))).toBe(false);
|
||||
expect(() => view.unmount()).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[0.32, false],
|
||||
[0.33, true],
|
||||
[1, true],
|
||||
])("path: %f of the card on screen -> visible: %s", (ratio, expected) => {
|
||||
const { frame } = setup(["sev-modaintima"]);
|
||||
|
||||
report(frame("sev-modaintima"), ratio);
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(expected);
|
||||
});
|
||||
|
||||
it("path: a visibility report about an element that is not a card -> ignored", () => {
|
||||
const { frame } = setup(["sev-modaintima"]);
|
||||
const stranger = document.createElement("div");
|
||||
report(frame("sev-modaintima"), 0.8);
|
||||
flush();
|
||||
|
||||
report(stranger, 1);
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the card leaves the screen -> it stops being the active one", () => {
|
||||
const { frame } = setup(["sev-modaintima"]);
|
||||
report(frame("sev-modaintima"), 0.8);
|
||||
flush();
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
|
||||
report(frame("sev-modaintima"), 0);
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(false);
|
||||
});
|
||||
|
||||
it("path: hidden tab -> nobody plays; back to visible -> the closest card plays again", () => {
|
||||
const { frame } = setup();
|
||||
report(frame("sev-modaintima"), 0.8);
|
||||
report(frame("lavacao-047"), 0.8);
|
||||
flush();
|
||||
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
flush();
|
||||
expect(isActive(frame("lavacao-047"))).toBe(false);
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(false);
|
||||
|
||||
Object.defineProperty(document, "hidden", {
|
||||
configurable: true,
|
||||
value: false,
|
||||
});
|
||||
act(() => {
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
flush();
|
||||
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShowcaseProvider: mouse e foco", () => {
|
||||
function hoverSetup() {
|
||||
const result = setup();
|
||||
report(result.frame("sev-modaintima"), 0.8);
|
||||
report(result.frame("lavacao-047"), 0.8);
|
||||
flush();
|
||||
return result;
|
||||
}
|
||||
|
||||
it("path: a pen counts as a pointer that hovers", () => {
|
||||
const { frame } = hoverSetup();
|
||||
|
||||
fireEvent.pointerEnter(
|
||||
frame("sev-modaintima").closest(".ds-card") as HTMLElement,
|
||||
{ pointerType: "pen" },
|
||||
describe("ShowcaseProvider: o que entrega a quem está dentro", () => {
|
||||
it("path: re-render with the same projects -> the same list and the same open function", () => {
|
||||
const seen: Captured[] = [];
|
||||
const tree = () => (
|
||||
<ShowcaseProvider projects={clients}>
|
||||
<Probe onRender={(captured) => seen.push(captured)} />
|
||||
</ShowcaseProvider>
|
||||
);
|
||||
flush();
|
||||
const { rerender } = render(tree());
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
rerender(tree());
|
||||
|
||||
expect(seen.length).toBeGreaterThan(1);
|
||||
expect(seen[seen.length - 1].items).toBe(seen[0].items);
|
||||
expect(seen[seen.length - 1].open).toBe(seen[0].open);
|
||||
});
|
||||
|
||||
it("path: another card's pointer leaves -> the hovered card keeps the video", () => {
|
||||
const { frame } = hoverSetup();
|
||||
const sev = frame("sev-modaintima").closest(".ds-card") as HTMLElement;
|
||||
const lavacao = frame("lavacao-047").closest(".ds-card") as HTMLElement;
|
||||
fireEvent.pointerEnter(sev, { pointerType: "mouse" });
|
||||
flush();
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
|
||||
fireEvent.pointerLeave(lavacao, { pointerType: "mouse" });
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the hovered card is not on screen -> the closest visible card plays instead", () => {
|
||||
const { frame } = hoverSetup();
|
||||
report(frame("sev-modaintima"), 0);
|
||||
fireEvent.pointerEnter(
|
||||
frame("sev-modaintima").closest(".ds-card") as HTMLElement,
|
||||
{ pointerType: "mouse" },
|
||||
it("path: a different list of projects -> a new list of items", () => {
|
||||
const seen: Captured[] = [];
|
||||
const probe = <Probe onRender={(captured) => seen.push(captured)} />;
|
||||
const { rerender } = render(
|
||||
<ShowcaseProvider projects={clients}>{probe}</ShowcaseProvider>,
|
||||
);
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(false);
|
||||
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||
});
|
||||
|
||||
it("path: focus moves to another element inside the same card -> hover is kept", () => {
|
||||
const { frame } = hoverSetup();
|
||||
const card = frame("sev-modaintima").closest(".ds-card") as HTMLElement;
|
||||
const inside = screen.getByRole("button", {
|
||||
name: /ampliar sev-modaintima/,
|
||||
});
|
||||
fireEvent.focusIn(inside);
|
||||
flush();
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
|
||||
fireEvent.focusOut(card, { relatedTarget: inside });
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"to another card",
|
||||
() => screen.getByRole("button", { name: /ampliar lavacao-047/ }),
|
||||
],
|
||||
["to nothing", () => null],
|
||||
])(
|
||||
"path: focus leaves the card %s -> hover is released",
|
||||
(_label, target) => {
|
||||
const { frame } = hoverSetup();
|
||||
const card = frame("sev-modaintima").closest(".ds-card") as HTMLElement;
|
||||
fireEvent.focusIn(
|
||||
screen.getByRole("button", { name: /ampliar sev-modaintima/ }),
|
||||
);
|
||||
flush();
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
|
||||
fireEvent.focusOut(card, { relatedTarget: target() });
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(false);
|
||||
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("path: the hovered card is removed from the page -> its hover is dropped and another card plays", () => {
|
||||
function Swap() {
|
||||
const [showFirst, setShowFirst] = useState(true);
|
||||
return (
|
||||
<ShowcaseProvider projects={projects}>
|
||||
{showFirst && <Card id="sev-modaintima" />}
|
||||
<Card id="lavacao-047" />
|
||||
<button type="button" onClick={() => setShowFirst(false)}>
|
||||
remover o primeiro
|
||||
</button>
|
||||
</ShowcaseProvider>
|
||||
);
|
||||
}
|
||||
render(<Swap />);
|
||||
const sev = screen.getByTestId("sev-modaintima");
|
||||
const lavacao = screen.getByTestId("lavacao-047");
|
||||
place(sev, 100);
|
||||
place(lavacao, 230);
|
||||
report(sev, 0.8);
|
||||
report(lavacao, 0.8);
|
||||
fireEvent.pointerEnter(sev.closest(".ds-card") as HTMLElement, {
|
||||
pointerType: "mouse",
|
||||
});
|
||||
flush();
|
||||
expect(isActive(sev)).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "remover o primeiro" }));
|
||||
flush();
|
||||
|
||||
expect(screen.queryByTestId("sev-modaintima")).toBeNull();
|
||||
expect(isActive(lavacao)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShowcaseProvider: pausa e janela ampliada", () => {
|
||||
it("path: the visitor pauses and resumes -> no card plays while paused", () => {
|
||||
const { frame } = setup(["lavacao-047"]);
|
||||
report(frame("lavacao-047"), 0.8);
|
||||
flush();
|
||||
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /pausa lavacao-047 false/ }),
|
||||
);
|
||||
flush();
|
||||
expect(isActive(frame("lavacao-047"))).toBe(false);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /pausa lavacao-047 true/ }),
|
||||
);
|
||||
flush();
|
||||
expect(isActive(frame("lavacao-047"))).toBe(true);
|
||||
});
|
||||
|
||||
it("path: opening a project that is not part of the showcase -> no window", () => {
|
||||
function OpenOutsider() {
|
||||
const { open } = useShowcaseCard("reddit-explorer");
|
||||
return (
|
||||
<button type="button" onClick={open}>
|
||||
ampliar reddit-explorer
|
||||
</button>
|
||||
);
|
||||
}
|
||||
render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<OpenOutsider />
|
||||
rerender(
|
||||
<ShowcaseProvider projects={clients.slice(0, 2)}>
|
||||
{probe}
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /ampliar reddit-explorer/ }),
|
||||
);
|
||||
flush();
|
||||
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: navigating in the window to a valid index -> changes project; to an invalid one -> closes", () => {
|
||||
setup();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /ampliar lavacao-047/ }),
|
||||
);
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("aberto:1");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "ir para o primeiro" }));
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("aberto:0");
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "ir para um que não existe" }),
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: reduced motion -> pausing and resuming never makes a card play", () => {
|
||||
reducedMotion = true;
|
||||
const { frame } = setup(["lavacao-047"]);
|
||||
report(frame("lavacao-047"), 1);
|
||||
flush();
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /pausa lavacao-047 false/ }),
|
||||
);
|
||||
flush();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: /pausa lavacao-047 true/ }),
|
||||
);
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("lavacao-047"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShowcaseProvider: trabalho por quadro", () => {
|
||||
it("path: no card registered -> scroll and resize schedule no frame", () => {
|
||||
const raf = jest.spyOn(window, "requestAnimationFrame");
|
||||
render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<p>sem cards</p>
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
flush();
|
||||
raf.mockClear();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
document.dispatchEvent(new Event("visibilitychange"));
|
||||
});
|
||||
flush();
|
||||
|
||||
expect(raf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: several scrolls inside one frame -> evaluated once", () => {
|
||||
const { frame } = setup(["lavacao-047"]);
|
||||
report(frame("lavacao-047"), 0.8);
|
||||
flush();
|
||||
const raf = jest.spyOn(window, "requestAnimationFrame");
|
||||
raf.mockClear();
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
expect(raf).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShowcaseProvider: ganchos fora do provedor e cartão sem .ds-card", () => {
|
||||
it("path: hooks used outside the provider -> empty list, and open/toggle do nothing", () => {
|
||||
function Outsider() {
|
||||
const items = useShowcaseItems();
|
||||
const open = useShowcaseOpen();
|
||||
const { toggleAutoplay, open: openCard, active } = useShowcaseCard("x");
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
open("sev-modaintima");
|
||||
openCard();
|
||||
toggleAutoplay();
|
||||
}}
|
||||
>
|
||||
itens:{items.length} ativo:{String(active)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
render(<Outsider />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "itens:0 ativo:false" });
|
||||
expect(() => fireEvent.click(button)).not.toThrow();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: a card whose frame has no .ds-card ancestor -> the frame itself listens for the pointer", () => {
|
||||
function Bare() {
|
||||
const { frameRef, active } = useShowcaseCard("lavacao-047");
|
||||
return <div ref={frameRef} data-testid="bare" data-active={active} />;
|
||||
}
|
||||
function Other() {
|
||||
const { frameRef, active } = useShowcaseCard("sev-modaintima");
|
||||
return <div ref={frameRef} data-testid="other" data-active={active} />;
|
||||
}
|
||||
render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<Other />
|
||||
<Bare />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
const bare = screen.getByTestId("bare");
|
||||
const other = screen.getByTestId("other");
|
||||
place(other, 230); // mais perto do meio da tela
|
||||
place(bare, 100);
|
||||
report(other, 0.8);
|
||||
report(bare, 0.8);
|
||||
flush();
|
||||
expect(isActive(other)).toBe(true);
|
||||
|
||||
fireEvent.pointerEnter(bare, { pointerType: "mouse" });
|
||||
flush();
|
||||
|
||||
expect(isActive(bare)).toBe(true);
|
||||
expect(isActive(other)).toBe(false);
|
||||
});
|
||||
|
||||
it("path: projects missing from the list -> only showcase projects that exist are built, in showcase order", () => {
|
||||
const only = projects.filter((project) =>
|
||||
["as-servicos", "sev-modaintima"].includes(project.id),
|
||||
);
|
||||
|
||||
const items = buildShowcaseItems(only);
|
||||
|
||||
expect(items.map((item) => item.config.id)).toEqual([
|
||||
"sev-modaintima",
|
||||
"as-servicos",
|
||||
]);
|
||||
expect(buildShowcaseItems([])).toEqual([]);
|
||||
expect(seen[0].items).toHaveLength(4);
|
||||
expect(seen[seen.length - 1].items).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -1,149 +1,52 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import {
|
||||
buildShowcaseItems,
|
||||
ShowcaseProvider,
|
||||
useShowcaseCard,
|
||||
useShowcaseItems,
|
||||
useShowcaseOpen,
|
||||
} from "./ShowcaseProvider";
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
/** A janela ampliada é testada em ProjectLightbox.test.tsx; aqui ela é um dublê que só mostra o índice e navega. */
|
||||
jest.mock("./ProjectLightbox", () => ({
|
||||
ProjectLightbox: ({
|
||||
index,
|
||||
onClose,
|
||||
onNavigate,
|
||||
}: {
|
||||
index: number;
|
||||
onClose: () => void;
|
||||
onNavigate: (index: number) => void;
|
||||
}) => (
|
||||
<div role="dialog" aria-label="ampliado">
|
||||
aberto:{index}
|
||||
<button type="button" onClick={onClose}>
|
||||
fechar
|
||||
</button>
|
||||
<button type="button" onClick={() => onNavigate(0)}>
|
||||
ir para o primeiro
|
||||
</button>
|
||||
<button type="button" onClick={() => onNavigate(99)}>
|
||||
ir para um que não existe
|
||||
</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);
|
||||
const clientIds = ["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"];
|
||||
const clients = projects.filter((project) => clientIds.includes(project.id));
|
||||
|
||||
function setup() {
|
||||
const view = render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<Card id="sev-modaintima" />
|
||||
<Card id="lavacao-047" />
|
||||
<Card id="nutriscan" />
|
||||
</ShowcaseProvider>,
|
||||
function Opener({ id }: { id: string }) {
|
||||
const open = useShowcaseOpen();
|
||||
return (
|
||||
<button type="button" onClick={() => open(id)}>
|
||||
abrir {id}
|
||||
</button>
|
||||
);
|
||||
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", () => {
|
||||
describe("buildShowcaseItems", () => {
|
||||
it("lists only projects that have a recorded showcase, in showcase order", () => {
|
||||
const items = buildShowcaseItems(projects);
|
||||
expect(items.map((item) => item.config.id)).toEqual([
|
||||
@@ -159,11 +62,6 @@ describe("ShowcaseProvider", () => {
|
||||
});
|
||||
|
||||
it("serves the reel with only the client projects the home passes in, in showcase order", () => {
|
||||
const clients = projects.filter((project) =>
|
||||
["as-servicos", "sc-plus", "lavacao-047", "sev-modaintima"].includes(
|
||||
project.id,
|
||||
),
|
||||
);
|
||||
expect(buildShowcaseItems(clients).map((item) => item.config.id)).toEqual([
|
||||
"sev-modaintima",
|
||||
"lavacao-047",
|
||||
@@ -172,160 +70,99 @@ describe("ShowcaseProvider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("works with no cards at all: no per-scroll work, and the enlarged window still opens and navigates", () => {
|
||||
const clients = projects.filter((project) =>
|
||||
["sev-modaintima", "lavacao-047", "sc-plus", "as-servicos"].includes(
|
||||
project.id,
|
||||
),
|
||||
it("ignores projects missing from the list and builds nothing from an empty one", () => {
|
||||
const only = projects.filter((project) =>
|
||||
["as-servicos", "sev-modaintima"].includes(project.id),
|
||||
);
|
||||
function Opener() {
|
||||
const open = useShowcaseOpen();
|
||||
return (
|
||||
<button type="button" onClick={() => open("sc-plus")}>
|
||||
abrir sc-plus
|
||||
</button>
|
||||
);
|
||||
|
||||
expect(buildShowcaseItems(only).map((item) => item.config.id)).toEqual([
|
||||
"sev-modaintima",
|
||||
"as-servicos",
|
||||
]);
|
||||
expect(buildShowcaseItems([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ShowcaseProvider", () => {
|
||||
it("shares the same list of items with whoever is inside it", () => {
|
||||
function Count() {
|
||||
return <p>itens:{useShowcaseItems().length}</p>;
|
||||
}
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
render(
|
||||
<ShowcaseProvider projects={clients}>
|
||||
<Opener />
|
||||
<Count />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
flush();
|
||||
frames.mockClear();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
flush();
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
|
||||
expect(screen.getByText("itens:4")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens the enlarged window on the project's position in the list, and closes it", () => {
|
||||
render(
|
||||
<ShowcaseProvider projects={clients}>
|
||||
<Opener id="sc-plus" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "abrir sc-plus" }));
|
||||
// posição 3 de 4 na lista que a home passou (o NutriScan não está nela)
|
||||
expect(screen.getByRole("dialog", { name: "ampliado" })).toHaveTextContent(
|
||||
"aberto:2",
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "fechar" }));
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
frames.mockRestore();
|
||||
});
|
||||
|
||||
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" }),
|
||||
it("does nothing when asked to open a project that is not in the showcase", () => {
|
||||
render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<Opener id="reddit-explorer" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
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" }),
|
||||
screen.getByRole("button", { name: "abrir reddit-explorer" }),
|
||||
);
|
||||
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,
|
||||
it("changes project when the window navigates to a valid index, and closes on an invalid one", () => {
|
||||
render(
|
||||
<ShowcaseProvider projects={clients}>
|
||||
<Opener id="lavacao-047" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "abrir lavacao-047" }));
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("aberto:1");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "ir para o primeiro" }));
|
||||
expect(screen.getByRole("dialog")).toHaveTextContent("aberto:0");
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "ir para um que não existe" }),
|
||||
);
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hooks used outside the provider", () => {
|
||||
it("give an empty list, and opening does nothing", () => {
|
||||
function Outsider() {
|
||||
const items = useShowcaseItems();
|
||||
const open = useShowcaseOpen();
|
||||
return (
|
||||
<button type="button" onClick={() => open("sev-modaintima")}>
|
||||
itens:{items.length}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
render(<Outsider />);
|
||||
|
||||
const button = screen.getByRole("button", { name: "itens:0" });
|
||||
expect(() => fireEvent.click(button)).not.toThrow();
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,17 +5,12 @@ import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import type { PortfolioProjectView } from "@/lib/portfolio-data";
|
||||
import { SHOWCASE, type ShowcaseConfig } from "@/lib/project-showcase";
|
||||
import { ProjectLightbox } from "./ProjectLightbox";
|
||||
import { pickActiveCard } from "./showcase-math";
|
||||
import "./showcase.css";
|
||||
|
||||
export interface ShowcaseItem {
|
||||
config: ShowcaseConfig;
|
||||
@@ -23,24 +18,11 @@ export interface ShowcaseItem {
|
||||
}
|
||||
|
||||
interface ShowcaseApi {
|
||||
register(id: string, element: HTMLElement): () => void;
|
||||
setHover(id: string, hovered: boolean): void;
|
||||
open(id: string): void;
|
||||
/** Pausa (ou retoma) os vídeos que tocam sozinhos nos cards: quem não quer movimento na página tem como desligar. */
|
||||
toggleAutoplay(): void;
|
||||
items: ShowcaseItem[];
|
||||
}
|
||||
|
||||
interface ActiveState {
|
||||
activeId: string | null;
|
||||
userPaused: boolean;
|
||||
}
|
||||
|
||||
const ApiContext = createContext<ShowcaseApi | null>(null);
|
||||
const ActiveContext = createContext<ActiveState>({
|
||||
activeId: null,
|
||||
userPaused: false,
|
||||
});
|
||||
|
||||
/** Só aparece como card com vídeo o projeto que existe na lista e tem vídeo gravado. */
|
||||
export function buildShowcaseItems(
|
||||
@@ -54,7 +36,7 @@ export function buildShowcaseItems(
|
||||
return items;
|
||||
}
|
||||
|
||||
/** Itens da vitrine para quem está dentro do provedor (a vitrine animada usa a mesma lista dos cards). */
|
||||
/** Itens da vitrine para quem está dentro do provedor. */
|
||||
export function useShowcaseItems() {
|
||||
return useContext(ApiContext)?.items ?? [];
|
||||
}
|
||||
@@ -66,8 +48,8 @@ export function useShowcaseOpen() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Um vídeo por vez nos cards: toca o card sob o mouse (ou com foco); sem mouse sobre nenhum, o mais perto do
|
||||
* centro da tela. Também guarda qual card está ampliado e mostra a janela ampliada.
|
||||
* Guarda os projetos da vitrine e qual deles está ampliado, e mostra a janela ampliada. A vitrine animada
|
||||
* (ProjectReel) e a lista do celular (ProjectList) leem a mesma lista de itens.
|
||||
*/
|
||||
export function ShowcaseProvider({
|
||||
projects,
|
||||
@@ -76,121 +58,10 @@ export function ShowcaseProvider({
|
||||
projects: PortfolioProjectView[];
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const reducedMotion = useReducedMotion();
|
||||
const items = useMemo(() => buildShowcaseItems(projects), [projects]);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [userPaused, setUserPaused] = useState(false);
|
||||
|
||||
const cards = useRef(
|
||||
new Map<string, { el: HTMLElement; visible: boolean }>(),
|
||||
);
|
||||
const hovered = useRef<string | null>(null);
|
||||
const paused = useRef(false);
|
||||
const queued = useRef(false);
|
||||
const frame = useRef(0);
|
||||
const observer = useRef<IntersectionObserver | null>(null);
|
||||
|
||||
const evaluate = useCallback(() => {
|
||||
queued.current = false;
|
||||
if (paused.current || document.hidden) {
|
||||
setActiveId(null);
|
||||
return;
|
||||
}
|
||||
const middle = window.innerHeight / 2;
|
||||
const candidates = [...cards.current].map(([id, { el, visible }]) => {
|
||||
// só quem está à vista entra na disputa: os outros nem têm o layout lido a cada quadro de rolagem
|
||||
if (!visible) return { id, visible, distance: Number.POSITIVE_INFINITY };
|
||||
const rect = el.getBoundingClientRect();
|
||||
return {
|
||||
id,
|
||||
visible,
|
||||
distance: Math.abs(rect.top + rect.height / 2 - middle),
|
||||
};
|
||||
});
|
||||
setActiveId(pickActiveCard(candidates, hovered.current));
|
||||
}, []);
|
||||
|
||||
// sem cards registrados (a home mostra só a vitrine animada) não há o que decidir a cada quadro de rolagem;
|
||||
// `force` vale para o que acabou de mudar a lista (um card saiu), que ainda precisa ser reavaliado
|
||||
const schedule = useCallback(
|
||||
(force = false) => {
|
||||
if (queued.current || (!force && cards.current.size === 0)) return;
|
||||
queued.current = true;
|
||||
frame.current = requestAnimationFrame(evaluate);
|
||||
},
|
||||
[evaluate],
|
||||
);
|
||||
|
||||
// com a janela ampliada aberta, ou com preferência por menos movimento, nenhum card toca
|
||||
useEffect(() => {
|
||||
paused.current = reducedMotion || openId !== null || userPaused;
|
||||
schedule(true);
|
||||
}, [schedule, openId, reducedMotion, userPaused]);
|
||||
|
||||
useEffect(() => {
|
||||
const onChange = () => schedule();
|
||||
window.addEventListener("scroll", onChange, { passive: true });
|
||||
window.addEventListener("resize", onChange);
|
||||
document.addEventListener("visibilitychange", onChange);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onChange);
|
||||
window.removeEventListener("resize", onChange);
|
||||
document.removeEventListener("visibilitychange", onChange);
|
||||
cancelAnimationFrame(frame.current);
|
||||
queued.current = false;
|
||||
observer.current?.disconnect();
|
||||
observer.current = null;
|
||||
};
|
||||
}, [schedule]);
|
||||
|
||||
const api = useMemo<ShowcaseApi>(
|
||||
() => ({
|
||||
items,
|
||||
register(id, element) {
|
||||
if (!observer.current && typeof IntersectionObserver !== "undefined") {
|
||||
observer.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
for (const entry of entries) {
|
||||
for (const card of cards.current.values()) {
|
||||
// um card só vale como "à vista" com pelo menos um terço dele na tela
|
||||
if (card.el === entry.target)
|
||||
card.visible = entry.intersectionRatio >= 0.33;
|
||||
}
|
||||
}
|
||||
schedule();
|
||||
},
|
||||
{ threshold: [0, 0.33, 0.66, 1] },
|
||||
);
|
||||
}
|
||||
cards.current.set(id, { el: element, visible: false });
|
||||
observer.current?.observe(element);
|
||||
return () => {
|
||||
observer.current?.unobserve(element);
|
||||
cards.current.delete(id);
|
||||
if (hovered.current === id) hovered.current = null;
|
||||
schedule(true);
|
||||
};
|
||||
},
|
||||
setHover(id, isHovered) {
|
||||
if (isHovered) hovered.current = id;
|
||||
else if (hovered.current === id) hovered.current = null;
|
||||
schedule();
|
||||
},
|
||||
open(id) {
|
||||
setOpenId(id);
|
||||
},
|
||||
toggleAutoplay() {
|
||||
setUserPaused((paused) => !paused);
|
||||
},
|
||||
}),
|
||||
[items, schedule],
|
||||
);
|
||||
|
||||
const activeState = useMemo(
|
||||
() => ({ activeId, userPaused }),
|
||||
[activeId, userPaused],
|
||||
);
|
||||
const api = useMemo<ShowcaseApi>(() => ({ items, open: setOpenId }), [items]);
|
||||
|
||||
const openIndex = openId
|
||||
? items.findIndex((item) => item.config.id === openId)
|
||||
@@ -198,60 +69,15 @@ export function ShowcaseProvider({
|
||||
|
||||
return (
|
||||
<ApiContext.Provider value={api}>
|
||||
<ActiveContext.Provider value={activeState}>
|
||||
{children}
|
||||
{openIndex >= 0 && (
|
||||
<ProjectLightbox
|
||||
items={items}
|
||||
index={openIndex}
|
||||
onClose={() => setOpenId(null)}
|
||||
onNavigate={(index) => setOpenId(items[index]?.config.id ?? null)}
|
||||
/>
|
||||
)}
|
||||
</ActiveContext.Provider>
|
||||
{children}
|
||||
{openIndex >= 0 && (
|
||||
<ProjectLightbox
|
||||
items={items}
|
||||
index={openIndex}
|
||||
onClose={() => setOpenId(null)}
|
||||
onNavigate={(index) => setOpenId(items[index]?.config.id ?? null)}
|
||||
/>
|
||||
)}
|
||||
</ApiContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/** Liga um card à vitrine: devolve a ref da moldura, se ele é o card que toca agora e como ampliá-lo. */
|
||||
export function useShowcaseCard(id: string) {
|
||||
const api = useContext(ApiContext);
|
||||
const { activeId, userPaused } = useContext(ActiveContext);
|
||||
const frameRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const element = frameRef.current;
|
||||
if (!api || !element) return;
|
||||
const unregister = api.register(id, element);
|
||||
const card = element.closest<HTMLElement>(".ds-card") ?? element;
|
||||
const enter = (event: PointerEvent) => {
|
||||
// no toque não existe "passar o mouse": só o mouse e a caneta mandam na vitrine
|
||||
if (event.pointerType !== "touch") api.setHover(id, true);
|
||||
};
|
||||
const leave = () => api.setHover(id, false);
|
||||
const focusIn = () => api.setHover(id, true);
|
||||
const focusOut = (event: FocusEvent) => {
|
||||
if (!card.contains(event.relatedTarget as Node | null))
|
||||
api.setHover(id, false);
|
||||
};
|
||||
card.addEventListener("pointerenter", enter);
|
||||
card.addEventListener("pointerleave", leave);
|
||||
card.addEventListener("focusin", focusIn);
|
||||
card.addEventListener("focusout", focusOut);
|
||||
return () => {
|
||||
unregister();
|
||||
card.removeEventListener("pointerenter", enter);
|
||||
card.removeEventListener("pointerleave", leave);
|
||||
card.removeEventListener("focusin", focusIn);
|
||||
card.removeEventListener("focusout", focusOut);
|
||||
};
|
||||
}, [api, id]);
|
||||
|
||||
return {
|
||||
frameRef,
|
||||
active: activeId === id,
|
||||
userPaused,
|
||||
toggleAutoplay: useCallback(() => api?.toggleAutoplay(), [api]),
|
||||
open: useCallback(() => api?.open(id), [api, id]),
|
||||
};
|
||||
}
|
||||
@@ -1,50 +1,10 @@
|
||||
import {
|
||||
easeToward,
|
||||
locateInReel,
|
||||
pickActiveCard,
|
||||
reelStarts,
|
||||
videoProgress,
|
||||
} from "./showcase-math";
|
||||
|
||||
const card = (id: string, visible: boolean, distance: number) => ({
|
||||
id,
|
||||
visible,
|
||||
distance,
|
||||
});
|
||||
|
||||
describe("pickActiveCard", () => {
|
||||
it("plays the visible card closest to the middle of the screen", () => {
|
||||
expect(
|
||||
pickActiveCard(
|
||||
[card("a", true, 300), card("b", true, 80), card("c", false, 5)],
|
||||
null,
|
||||
),
|
||||
).toBe("b");
|
||||
});
|
||||
|
||||
it("lets the hovered card win over a closer one, but only while it is visible", () => {
|
||||
const cards = [card("a", true, 10), card("b", true, 400)];
|
||||
expect(pickActiveCard(cards, "b")).toBe("b");
|
||||
expect(
|
||||
pickActiveCard([card("a", true, 10), card("b", false, 0)], "b"),
|
||||
).toBe("a");
|
||||
});
|
||||
|
||||
it("keeps the first card on a tie, so the left one of a row wins", () => {
|
||||
expect(
|
||||
pickActiveCard(
|
||||
[card("left", true, 120), card("right", true, 120.3)],
|
||||
null,
|
||||
),
|
||||
).toBe("left");
|
||||
});
|
||||
|
||||
it("plays nothing when no card is on screen", () => {
|
||||
expect(pickActiveCard([card("a", false, 0)], null)).toBeNull();
|
||||
expect(pickActiveCard([], null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("locateInReel", () => {
|
||||
const weights = [1, 2, 1];
|
||||
|
||||
|
||||
@@ -1,30 +1,5 @@
|
||||
/** Contas puras da vitrine (sem DOM), separadas para serem testadas uma a uma. */
|
||||
|
||||
export interface CardCandidate {
|
||||
id: string;
|
||||
visible: boolean;
|
||||
/** Distância do centro do card ao centro da tela, em pixels. */
|
||||
distance: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Qual card toca: o que está sob o mouse (se visível); senão o visível mais perto do centro da tela.
|
||||
* Empate fica com o primeiro da lista (ordem do DOM: esquerda antes de direita).
|
||||
*/
|
||||
export function pickActiveCard(
|
||||
candidates: CardCandidate[],
|
||||
hoveredId: string | null,
|
||||
): string | null {
|
||||
const visible = candidates.filter((candidate) => candidate.visible);
|
||||
if (hoveredId && visible.some((candidate) => candidate.id === hoveredId))
|
||||
return hoveredId;
|
||||
let best: CardCandidate | null = null;
|
||||
for (const candidate of visible) {
|
||||
if (!best || candidate.distance < best.distance - 0.5) best = candidate;
|
||||
}
|
||||
return best?.id ?? null;
|
||||
}
|
||||
|
||||
export interface ReelPosition {
|
||||
/** Projeto em cena. */
|
||||
index: number;
|
||||
|
||||
@@ -163,11 +163,6 @@
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: zoom-in;
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
.ds-frame-chip {
|
||||
@@ -231,11 +226,6 @@
|
||||
&:hover svg {
|
||||
background: rgba(5, 5, 5, 0.9);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
/* sem mouse não existe "passar por cima": o aviso fica sempre à vista */
|
||||
@@ -345,46 +335,40 @@
|
||||
& > ol::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
& > ol > li > button {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.25s ease,
|
||||
border-color 0.25s ease,
|
||||
background 0.25s ease;
|
||||
.ds-reel-pick {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.25s ease,
|
||||
border-color 0.25s ease,
|
||||
background 0.25s ease;
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* por dentro: a faixa rolável (overflow-x) cortaria o anel desenhado para fora */
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: -4px;
|
||||
}
|
||||
|
||||
&[aria-current="true"] {
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-current="true"] {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
|
||||
.ds-reel-hint {
|
||||
@@ -431,44 +415,42 @@
|
||||
list-style: none;
|
||||
margin: 6px 0 10px 16px;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
& button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
line-height: 1.25;
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
text-align: left;
|
||||
.ds-reel-chapter-pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
line-height: 1.25;
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
text-align: left;
|
||||
|
||||
& span {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-current="true"] {
|
||||
color: var(--slash);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
|
||||
& span {
|
||||
color: var(--slash);
|
||||
}
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-current="true"] {
|
||||
color: var(--slash);
|
||||
}
|
||||
}
|
||||
|
||||
.ds-reel-chapter-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.ds-reel-chapter-pick[aria-current="true"] .ds-reel-chapter-num {
|
||||
color: var(--slash);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@@ -478,7 +460,7 @@
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) and (pointer: coarse) {
|
||||
.ds-reel-chapters button {
|
||||
.ds-reel-chapter-pick {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
@@ -495,7 +477,7 @@
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ds-reel-index > ol > li > button[aria-current="true"] .ds-reel-bar {
|
||||
.ds-reel-pick[aria-current="true"] .ds-reel-bar {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@@ -516,7 +498,7 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.ds-reel-index > ol > li > button {
|
||||
.ds-reel-pick {
|
||||
width: 100%;
|
||||
gap: 14px;
|
||||
padding: 0 16px;
|
||||
@@ -525,35 +507,12 @@
|
||||
|
||||
.ds-reel-stage {
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
/* o palco mede a si mesmo: o conjunto cresce até o limite da largura ou da altura que sobrar */
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
.ds-reel-layer {
|
||||
--accent: var(--slash);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* só opacidade: quem não está em cena já é inert (sem foco, sem clique, fora da busca) e aria-hidden */
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transform: translateY(14px);
|
||||
transition:
|
||||
opacity 0.5s ease,
|
||||
transform 0.6s var(--ease-out);
|
||||
|
||||
&[data-state="before"] {
|
||||
transform: translateY(-14px);
|
||||
}
|
||||
|
||||
&[data-state="active"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
}
|
||||
min-height: 0;
|
||||
/* o palco mede a si mesmo: o conjunto cresce até o limite da largura ou da altura que sobrar */
|
||||
container-type: size;
|
||||
}
|
||||
|
||||
/* aparelhos + legenda, um bloco só: a legenda tem sempre a mesma largura e o mesmo canto esquerdo do notebook */
|
||||
@@ -568,9 +527,33 @@
|
||||
min-width: min(100%, 320px);
|
||||
}
|
||||
|
||||
/* as camadas de aparelhos (uma por projeto) ficam empilhadas na mesma célula: o bloco tem a altura de um par */
|
||||
.ds-reel-devices {
|
||||
position: relative;
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.ds-reel-set {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
/* a troca de projeto é um fade cruzado só dos aparelhos (a legenda é um bloco à parte, abaixo): sem deslocamento.
|
||||
Quem não está em cena já é inert (sem foco, sem clique, fora da busca) e aria-hidden. */
|
||||
.ds-reel-layer {
|
||||
--accent: var(--slash);
|
||||
position: relative;
|
||||
grid-area: 1 / 1;
|
||||
min-width: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.45s ease;
|
||||
|
||||
&[data-state="active"] {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* brilho na cor do site, atrás dos aparelhos */
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -587,10 +570,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.ds-reel-set {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.ds-reel-laptop {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -620,11 +599,20 @@
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
/* uma só legenda para o projeto em cena: na troca ela some depressa (120 ms, o mesmo CAPTION_OUT_MS de
|
||||
ProjectReel.tsx) e volta com o conteúdo novo; só opacidade, o texto nunca se desloca */
|
||||
.ds-reel-caption {
|
||||
flex: none;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
transition: opacity 0.2s ease-out;
|
||||
|
||||
&[data-leaving] {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.12s linear;
|
||||
}
|
||||
}
|
||||
|
||||
.ds-reel-title {
|
||||
@@ -761,11 +749,11 @@
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) and (max-height: 700px) and (pointer: fine) {
|
||||
.ds-reel-index > ol > li > button {
|
||||
.ds-reel-pick {
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.ds-reel-chapters button {
|
||||
.ds-reel-chapter-pick {
|
||||
min-height: 28px;
|
||||
}
|
||||
}
|
||||
@@ -882,8 +870,9 @@
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* um cartão mais baixo: sem subtítulo a legenda cabe e o celular da moldura ganha espaço */
|
||||
/* um cartão mais baixo: sem subtítulo a legenda cabe e o celular da moldura ganha espaço; texto corrido em 15 px */
|
||||
.ds-reel-item .ds-reel-sub {
|
||||
font-size: 15px;
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
@@ -998,75 +987,65 @@ html.ds-lb-open {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
|
||||
& button {
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
color 0.2s ease;
|
||||
.ds-lb-toggle button {
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.2s ease,
|
||||
color 0.2s ease;
|
||||
|
||||
/* a versão escolhida: fundo claro e sublinhado em lima (a lima só marca, não preenche o botão) */
|
||||
&[aria-pressed="true"] {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: inset 0 -2px 0 var(--slash);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: -3px;
|
||||
}
|
||||
/* a versão escolhida: fundo claro e sublinhado em lima (a lima só marca, não preenche o botão) */
|
||||
&[aria-pressed="true"] {
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: inset 0 -2px 0 var(--slash);
|
||||
}
|
||||
}
|
||||
|
||||
.ds-lb-nav {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
& button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease;
|
||||
.ds-lb-nav button {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease;
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover:not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
& svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
@media (hover: hover) {
|
||||
&:hover:not(:disabled) {
|
||||
border-color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.ds-lb-nav button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
|
||||
/* o palco mede a si mesmo: o aparelho cresce até o limite da largura ou da altura que sobrar */
|
||||
@@ -1128,11 +1107,6 @@ html.ds-lb-open {
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
& svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
@@ -1186,11 +1160,6 @@ html.ds-lb-open {
|
||||
border-radius: 0;
|
||||
background: var(--slash);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.ds-lb-chapters {
|
||||
@@ -1199,44 +1168,39 @@ html.ds-lb-open {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
& button {
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
white-space: nowrap;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
.ds-lb-chapters button {
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
white-space: nowrap;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
border-color 0.2s ease;
|
||||
|
||||
& span {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--slash);
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-current="true"] {
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: -4px;
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
&[aria-current="true"] {
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.ds-lb-chapters button span {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--slash);
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
@@ -1361,3 +1325,27 @@ html.ds-lb-open {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Foco ─────────────────────────── */
|
||||
|
||||
/* Anel de foco da vitrine (sempre sobre fundo escuro): lima. Em tudo o que fica dentro de um contêiner que corta
|
||||
o que passa da borda (a faixa rolável do índice, as telas, a lista de trechos) o anel fica por dentro; nos
|
||||
controles soltos, por fora. Sem o halo do anel duplo global (globals.css): ele é para fundos claros. */
|
||||
:is(
|
||||
.ds-frame-expand,
|
||||
.ds-frame-pause,
|
||||
.ds-reel-pick,
|
||||
.ds-reel-chapter-pick,
|
||||
.ds-lb-toggle button,
|
||||
.ds-lb-chapters button
|
||||
):focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: -4px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
:is(.ds-lb-nav button, .ds-lb-play, .ds-lb-range):focus-visible {
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: 2px;
|
||||
box-shadow: none;
|
||||
}
|
||||
Reference in new issue
Block a user