perf(movimento): uma transição, 3D leve e vídeo certo por aparelho
Passagens com fade e 32 px, cena anterior termina em opacidade 0, sem visibility nem clip-path. 3D do hero como fundo ambiente, sob demanda e desligado em toque, aparelho fraco, saveData e movimento reduzido. Lenis fora do toque e salto direto em âncoras distantes. Vitrine só de clientes, até 3 capítulos, fixa só no desktop e carrossel no celular; vídeo só do projeto em cena com preload metadata. Rótulos mono a partir de 12 px e limão só no contato.
This commit is contained in:
1 parent
8d07b8c6bb
commit
c9a0b80656
48 files changed
+7210
-1241
No files matched your search
@@ -0,0 +1,141 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de displayUrl e BrowserFrame (complemento de BrowserFrame.test.tsx): cada condição
|
||||
* da escolha do endereço (demoUrl válido, caminho, www, porta, esquema, github e parentes, URL inválida,
|
||||
* título vazio) e as variações da moldura (cadeado, texto padrão, className).
|
||||
*/
|
||||
|
||||
describe("displayUrl", () => {
|
||||
it.each([
|
||||
["https://sevmodaintima.com.br", "sevmodaintima.com.br", true],
|
||||
["https://sevmodaintima.com.br/", "sevmodaintima.com.br", true],
|
||||
[
|
||||
"https://www.sevmodaintima.com.br/loja/",
|
||||
"sevmodaintima.com.br/loja",
|
||||
true,
|
||||
],
|
||||
["https://x.example.com/a/b/", "x.example.com/a/b", true],
|
||||
["https://x.example.com/a?q=1#topo", "x.example.com/a", true],
|
||||
["http://x.example.com", "x.example.com", false],
|
||||
["https://x.example.com:8443/app", "x.example.com:8443/app", true],
|
||||
["https://sub.www.example.com", "sub.www.example.com", true],
|
||||
["https://notgithub.com", "notgithub.com", true],
|
||||
["https://github.com.evil.example", "github.com.evil.example", true],
|
||||
["ftp://files.example.com/pasta", "files.example.com/pasta", false],
|
||||
])("path: demoUrl %s -> %s (secure: %s)", (demoUrl, text, secure) => {
|
||||
expect(displayUrl({ demoUrl, title: "Projeto" })).toEqual({ text, secure });
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://github.com/evandrodevbr/projeto",
|
||||
"https://GITHUB.COM/evandrodevbr",
|
||||
"https://gist.github.com/x",
|
||||
"https://www.github.com",
|
||||
])(
|
||||
"path: demoUrl %s points at GitHub -> falls back to the title",
|
||||
(demoUrl) => {
|
||||
expect(displayUrl({ demoUrl, title: "Projeto" })).toEqual({
|
||||
text: "Projeto",
|
||||
secure: false,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("path: demoUrl does not parse -> falls back to the title", () => {
|
||||
expect(displayUrl({ demoUrl: "isto nao e url", title: "Projeto" })).toEqual(
|
||||
{
|
||||
text: "Projeto",
|
||||
secure: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["null demoUrl", { demoUrl: null, title: "Projeto" }],
|
||||
["empty demoUrl", { demoUrl: "", title: "Projeto" }],
|
||||
["missing demoUrl", { title: "Projeto" }],
|
||||
])("path: %s with a title -> the title, never secure", (_label, project) => {
|
||||
expect(displayUrl(project)).toEqual({ text: "Projeto", secure: false });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["no fields", {}],
|
||||
["empty title", { title: "" }],
|
||||
["null title and no demo", { title: null, demoUrl: null }],
|
||||
["github demo and no title", { demoUrl: "https://github.com/x" }],
|
||||
["invalid demo and empty title", { demoUrl: "???", title: "" }],
|
||||
])("path: %s -> null", (_label, project) => {
|
||||
expect(displayUrl(project)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("BrowserFrame", () => {
|
||||
it("path: secure address -> lock icon plus the address text", () => {
|
||||
const { container } = render(
|
||||
<BrowserFrame url={{ text: "exemplo.com.br", secure: true }}>
|
||||
<p>tela</p>
|
||||
</BrowserFrame>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".ds-bframe-url svg")).not.toBeNull();
|
||||
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||
"exemplo.com.br",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: insecure address -> text without the lock", () => {
|
||||
const { container } = render(
|
||||
<BrowserFrame url={{ text: "Projeto", secure: false }}>
|
||||
<p>tela</p>
|
||||
</BrowserFrame>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".ds-bframe-url svg")).toBeNull();
|
||||
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||
"Projeto",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: no address -> neutral label and no lock", () => {
|
||||
const { container } = render(
|
||||
<BrowserFrame url={null}>
|
||||
<p>tela</p>
|
||||
</BrowserFrame>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".ds-bframe-url svg")).toBeNull();
|
||||
expect(container.querySelector(".ds-bframe-url")).toHaveTextContent(
|
||||
"pré-visualização",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: className and style given -> both reach the wrapper, with no stray space", () => {
|
||||
const { container } = render(
|
||||
<BrowserFrame
|
||||
url={null}
|
||||
className="ds-reel-laptop"
|
||||
style={{ width: "88%" }}
|
||||
>
|
||||
<p>tela</p>
|
||||
</BrowserFrame>,
|
||||
);
|
||||
|
||||
const frame = container.firstElementChild as HTMLElement;
|
||||
expect(frame.className).toBe("ds-bframe ds-reel-laptop");
|
||||
expect(frame.style.width).toBe("88%");
|
||||
});
|
||||
|
||||
it("path: no className -> the class has no trailing space", () => {
|
||||
const { container } = render(
|
||||
<BrowserFrame url={null}>
|
||||
<p>tela</p>
|
||||
</BrowserFrame>,
|
||||
);
|
||||
|
||||
expect((container.firstElementChild as HTMLElement).className).toBe(
|
||||
"ds-bframe",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -20,24 +20,39 @@ describe("displayUrl", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the code address when there is no live demo", () => {
|
||||
expect(
|
||||
displayUrl({ demoUrl: null, githubUrl: "https://github.com/user/repo" }),
|
||||
).toEqual({ text: "github.com/user/repo", secure: true });
|
||||
it("shows only the project name, never a code address, when there is no live demo", () => {
|
||||
expect(displayUrl({ title: "AS Serviços", demoUrl: null })).toEqual({
|
||||
text: "AS Serviços",
|
||||
secure: false,
|
||||
});
|
||||
expect(displayUrl({ title: "AS Serviços" })).toEqual({
|
||||
text: "AS Serviços",
|
||||
secure: false,
|
||||
});
|
||||
// um projeto completo, com o endereço do código ainda nos dados, não vaza para a barra
|
||||
const project = {
|
||||
title: "Twins Lab",
|
||||
demoUrl: "",
|
||||
githubUrl: "https://github.com/user/repo",
|
||||
};
|
||||
expect(displayUrl(project)?.text).toBe("Twins Lab");
|
||||
});
|
||||
|
||||
it("skips an address that does not parse and uses the next one", () => {
|
||||
expect(
|
||||
displayUrl({
|
||||
demoUrl: "isso não é uma url",
|
||||
githubUrl: "https://github.com/a/b",
|
||||
}),
|
||||
).toEqual({ text: "github.com/a/b", secure: true });
|
||||
it("falls back to the project name when the address does not parse or points at github.com", () => {
|
||||
const title = "Meu projeto";
|
||||
const name = { text: title, secure: false };
|
||||
expect(displayUrl({ title, demoUrl: "isso não é uma url" })).toEqual(name);
|
||||
expect(displayUrl({ title, demoUrl: "https://github.com/a/b" })).toEqual(
|
||||
name,
|
||||
);
|
||||
expect(displayUrl({ title, demoUrl: "https://www.github.com/a" })).toEqual(
|
||||
name,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null when there is nothing to show", () => {
|
||||
expect(displayUrl({})).toBeNull();
|
||||
expect(displayUrl({ demoUrl: "", githubUrl: null })).toBeNull();
|
||||
expect(displayUrl({ title: "", demoUrl: "" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import type { CSSProperties, ReactNode } from "react";
|
||||
|
||||
/** Endereço mostrado na barra: o do site publicado, ou o do código quando não há demonstração. */
|
||||
/**
|
||||
* Endereço mostrado na barra: o domínio real do site publicado (`demoUrl`). Sem demonstração, só o nome do
|
||||
* projeto: o endereço do código nunca aparece (nada de github.com na interface).
|
||||
*/
|
||||
export function displayUrl(project: {
|
||||
title?: string | null;
|
||||
demoUrl?: string | null;
|
||||
githubUrl?: string | null;
|
||||
}): { text: string; secure: boolean } | null {
|
||||
for (const raw of [project.demoUrl, project.githubUrl]) {
|
||||
if (!raw) continue;
|
||||
if (project.demoUrl) {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
|
||||
return {
|
||||
text: `${url.host.replace(/^www\./, "")}${path}`,
|
||||
secure: url.protocol === "https:",
|
||||
};
|
||||
const url = new URL(project.demoUrl);
|
||||
if (!/(^|\.)github\.com$/i.test(url.hostname)) {
|
||||
const path =
|
||||
url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
|
||||
return {
|
||||
text: `${url.host.replace(/^www\./, "")}${path}`,
|
||||
secure: url.protocol === "https:",
|
||||
};
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
return null;
|
||||
return project.title ? { text: project.title, secure: false } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import Link from "next/link";
|
||||
import type { ShowcaseItem } from "./ShowcaseProvider";
|
||||
|
||||
/** O site publicado abre em nova aba quando existe e não foi dado como fora do ar. */
|
||||
export function canOpenSite(project: ShowcaseItem["project"]) {
|
||||
return Boolean(project.demoUrl) && project.demoAvailability !== "unavailable";
|
||||
}
|
||||
|
||||
/**
|
||||
* Ações de um projeto na vitrine: "Ver case" (contorno) leva ao estudo de caso; "Ampliar vídeo" (contorno,
|
||||
* com ícone) abre a janela ampliada; "Abrir site" (link de texto, nova aba) só existe com `demoUrl`.
|
||||
* Nenhuma é verde-limão: o preenchido de destaque é só da ação de contato.
|
||||
*/
|
||||
export function ProjectActions({
|
||||
item,
|
||||
onExpand,
|
||||
}: {
|
||||
item: ShowcaseItem;
|
||||
/** Sem a janela ampliada (antes de o navegador montar a página) o botão nem aparece. */
|
||||
onExpand?: () => void;
|
||||
}) {
|
||||
const { project } = item;
|
||||
return (
|
||||
<div className="ds-reel-actions">
|
||||
<Link className="ds-link ds-link-muted" href={`/cases/${project.id}`}>
|
||||
Ver case
|
||||
<span className="sr-only"> de {project.title}</span>
|
||||
</Link>
|
||||
{onExpand && (
|
||||
<button
|
||||
type="button"
|
||||
className="ds-link ds-link-muted ds-reel-expand"
|
||||
onClick={onExpand}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
<path d="M9.5 2.5h4v4M6.5 13.5h-4v-4M13.5 2.5 9 7M2.5 13.5 7 9" />
|
||||
</svg>
|
||||
Ampliar vídeo
|
||||
<span className="sr-only"> de {project.title}</span>
|
||||
</button>
|
||||
)}
|
||||
{canOpenSite(project) && (
|
||||
<a
|
||||
className="ds-link ds-link-text"
|
||||
href={project.demoUrl ?? undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Abrir site
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
de {project.title} (abre em nova aba)
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -177,7 +177,7 @@ describe("ProjectLightbox", () => {
|
||||
);
|
||||
|
||||
expect(video.currentTime).toBeCloseTo(
|
||||
chapterFraction(lavacao, 2, "desktop") * lavacao.durations.desktop,
|
||||
chapterFraction(lavacao, 1, "desktop") * lavacao.durations.desktop,
|
||||
5,
|
||||
);
|
||||
expect(
|
||||
@@ -226,14 +226,72 @@ describe("ProjectLightbox", () => {
|
||||
expect(screen.getByRole("dialog")).toHaveAccessibleName("SEV Moda Íntima");
|
||||
});
|
||||
|
||||
it("offers the site and the code when they exist", () => {
|
||||
it("offers the case and, when the site exists, a text link to it in a new tab; never the code", () => {
|
||||
const dialog = setup("lavacao-047");
|
||||
expect(
|
||||
within(dialog).getByRole("link", { name: /Abrir projeto/ }),
|
||||
).toHaveAttribute("href", "https://landing-page-lavacao047.vercel.app");
|
||||
within(dialog).getByRole("link", { name: /Ver case/ }),
|
||||
).toHaveAttribute("href", "/cases/lavacao-047");
|
||||
const site = within(dialog).getByRole("link", { name: /Abrir site/ });
|
||||
expect(site).toHaveAttribute(
|
||||
"href",
|
||||
"https://landing-page-lavacao047.vercel.app",
|
||||
);
|
||||
expect(site).toHaveAttribute("target", "_blank");
|
||||
expect(site).toHaveClass("ds-link-text");
|
||||
expect(dialog.querySelector(".ds-link-accent")).toBeNull();
|
||||
expect(within(dialog).queryByRole("link", { name: /código/i })).toBeNull();
|
||||
expect(dialog.innerHTML).not.toContain("github.com");
|
||||
});
|
||||
|
||||
it("has no site link, and an address bar with only the project name, for a project without a live site", () => {
|
||||
const dialog = setup("as-servicos");
|
||||
expect(
|
||||
within(dialog).queryByRole("link", { name: /Ver codigo/ }),
|
||||
within(dialog).queryByRole("link", { name: /Abrir site/ }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
within(dialog).getByRole("link", { name: /Ver case/ }),
|
||||
).toHaveAttribute("href", "/cases/as-servicos");
|
||||
expect(dialog.querySelector(".ds-bframe-url")?.textContent).toBe(
|
||||
"AS Serviços",
|
||||
);
|
||||
});
|
||||
|
||||
it("opens on the phone version below 768 px", () => {
|
||||
const original = window.matchMedia;
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: query === "(max-width: 767px)",
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
try {
|
||||
const dialog = setup();
|
||||
expect(
|
||||
within(dialog).getByRole("button", { name: "Celular" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
expect(dialog.querySelector(".ds-lb-phone")).not.toBeNull();
|
||||
} finally {
|
||||
window.matchMedia = original;
|
||||
}
|
||||
});
|
||||
|
||||
it("does not autoplay, nor preload the whole file, with the data saver on", () => {
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: { saveData: true },
|
||||
});
|
||||
try {
|
||||
const dialog = setup();
|
||||
const video = dialog.querySelector("video") as HTMLVideoElement;
|
||||
expect(video).not.toHaveAttribute("autoplay");
|
||||
expect(video).toHaveAttribute("preload", "metadata");
|
||||
expect(HTMLMediaElement.prototype.play).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("closes with the close button, with the backdrop or with Esc, and unlocks the page", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type CSSProperties,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
|
||||
import { isMobileViewport, isSaveData } from "@/lib/device";
|
||||
import {
|
||||
chapterFraction,
|
||||
chapterIndexAt,
|
||||
@@ -56,12 +58,17 @@ export function ProjectLightbox({
|
||||
const pendingFraction = useRef<number | null>(null);
|
||||
const pressedOnBackdrop = useRef(false);
|
||||
|
||||
// celular abaixo de 768 px (lib/device.ts); a troca de aparelho continua à escolha do visitante
|
||||
const [device, setDevice] = useState<Device>(() =>
|
||||
window.matchMedia("(max-width: 639px)").matches ? "mobile" : "desktop",
|
||||
isMobileViewport() ? "mobile" : "desktop",
|
||||
);
|
||||
// o vídeo de cada aparelho/projeto nasce tocando só se o visitante não pausou e não pediu menos movimento
|
||||
// "economizar dados": o arquivo só chega quando o visitante pede (play), não antes
|
||||
const [saveData] = useState(isSaveData);
|
||||
const [autoplay, setAutoplay] = useState(
|
||||
() => !window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
||||
() =>
|
||||
!window.matchMedia("(prefers-reduced-motion: reduce)").matches &&
|
||||
!saveData,
|
||||
);
|
||||
const wantsToPlay = useRef(autoplay);
|
||||
const [playing, setPlaying] = useState(false);
|
||||
@@ -199,7 +206,6 @@ export function ProjectLightbox({
|
||||
};
|
||||
|
||||
const video = (
|
||||
// biome-ignore lint/a11y/useMediaCaption: demonstração sem áudio; a descrição está no título e nos capítulos
|
||||
<video
|
||||
key={`${config.id}-${device}`}
|
||||
ref={videoRef}
|
||||
@@ -208,7 +214,7 @@ export function ProjectLightbox({
|
||||
muted
|
||||
loop
|
||||
playsInline
|
||||
preload="auto"
|
||||
preload={saveData ? "metadata" : "auto"}
|
||||
poster={assets.poster}
|
||||
width={assets.width}
|
||||
height={assets.height}
|
||||
@@ -249,9 +255,9 @@ export function ProjectLightbox({
|
||||
{String(index + 1).padStart(2, "0")} /{" "}
|
||||
{String(items.length).padStart(2, "0")}
|
||||
</p>
|
||||
<h3 id={titleId} className="ds-lb-title">
|
||||
<h2 id={titleId} className="ds-lb-title">
|
||||
{project.title}
|
||||
</h3>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<fieldset className="ds-lb-toggle">
|
||||
@@ -371,25 +377,18 @@ export function ProjectLightbox({
|
||||
</ol>
|
||||
|
||||
<div className="ds-lb-links">
|
||||
<Link className="ds-link ds-link-muted" href={`/cases/${project.id}`}>
|
||||
Ver case
|
||||
<span className="sr-only"> de {project.title}</span>
|
||||
</Link>
|
||||
{canOpenSite && (
|
||||
<a
|
||||
className="ds-link ds-link-accent"
|
||||
className="ds-link ds-link-text"
|
||||
href={project.demoUrl ?? undefined}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Abrir projeto
|
||||
<span className="sr-only"> (abre em nova aba)</span>
|
||||
</a>
|
||||
)}
|
||||
{project.githubUrl && (
|
||||
<a
|
||||
className="ds-link ds-link-muted"
|
||||
href={project.githubUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Ver codigo
|
||||
Abrir site
|
||||
<span className="sr-only"> (abre em nova aba)</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import { ProjectList } from "./ProjectList";
|
||||
import { buildShowcaseItems, ShowcaseProvider } from "./ShowcaseProvider";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de ProjectList (e do ListItem dentro dela): sem aparelho (servidor), celular x desktop,
|
||||
* longe x perto da janela (pôster), com e sem subtítulo, rótulo "pendente" e o botão de ampliar.
|
||||
*/
|
||||
|
||||
jest.mock("./ProjectLightbox", () => ({
|
||||
ProjectLightbox: ({ index }: { index: number }) => (
|
||||
<div role="dialog" aria-label="ampliado">
|
||||
aberto:{index}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
type Callback = IntersectionObserverCallback;
|
||||
/** Cada observador criado, com o elemento que observa. */
|
||||
const observers: Array<{ callback: Callback; target?: Element }> = [];
|
||||
const Original = global.IntersectionObserver;
|
||||
|
||||
const projects = getFeaturedProjects(null);
|
||||
const items = buildShowcaseItems(projects);
|
||||
|
||||
beforeEach(() => {
|
||||
observers.length = 0;
|
||||
global.IntersectionObserver = class {
|
||||
private readonly record: { callback: Callback; target?: Element };
|
||||
constructor(callback: Callback) {
|
||||
this.record = { callback };
|
||||
observers.push(this.record);
|
||||
}
|
||||
observe(target: Element) {
|
||||
this.record.target = target;
|
||||
}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.IntersectionObserver = Original;
|
||||
});
|
||||
|
||||
function renderList(props: Partial<Parameters<typeof ProjectList>[0]> = {}) {
|
||||
return render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<ProjectList items={items} device="desktop" {...props} />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
function bringNear(target: Element) {
|
||||
act(() => {
|
||||
for (const observer of observers)
|
||||
if (observer.target === target)
|
||||
observer.callback(
|
||||
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||
{} as IntersectionObserver,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function bringAllNear(container: HTMLElement) {
|
||||
for (const item of container.querySelectorAll("li.ds-reel-item"))
|
||||
bringNear(item);
|
||||
}
|
||||
|
||||
describe("ProjectList", () => {
|
||||
it("path: device unknown (server / before mount) -> text and links only, no media and no expand button", () => {
|
||||
const { container } = renderList({ device: null });
|
||||
|
||||
expect(container.querySelectorAll("li")).toHaveLength(items.length);
|
||||
expect(container.querySelector("[data-device]")).toBeNull();
|
||||
expect(container.querySelector(".ds-reel-item-media")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /Ampliar vídeo/ })).toBeNull();
|
||||
expect(screen.getAllByRole("link", { name: /Ver case/ })).toHaveLength(
|
||||
items.length,
|
||||
);
|
||||
});
|
||||
|
||||
it("path: desktop -> browser frame, poster only once the item is near the window", () => {
|
||||
const { container } = renderList({ device: "desktop" });
|
||||
|
||||
expect(container.querySelectorAll(".ds-bframe")).toHaveLength(items.length);
|
||||
expect(container.querySelector(".ds-phone")).toBeNull();
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
|
||||
bringAllNear(container);
|
||||
|
||||
const images = [...container.querySelectorAll("img")];
|
||||
expect(images).toHaveLength(items.length);
|
||||
expect(images[0]).toHaveAttribute(
|
||||
"alt",
|
||||
`Captura de tela de ${items[0].project.title}`,
|
||||
);
|
||||
expect(decodeURIComponent(images[0].getAttribute("src") ?? "")).toContain(
|
||||
"desktop.webp",
|
||||
);
|
||||
expect(images[0]).toHaveAttribute("sizes", "50vw");
|
||||
});
|
||||
|
||||
it("path: mobile -> phone frame with the phone poster and the phone sizes", () => {
|
||||
const { container } = renderList({ device: "mobile" });
|
||||
bringAllNear(container);
|
||||
|
||||
expect(container.querySelectorAll(".ds-phone")).toHaveLength(items.length);
|
||||
expect(container.querySelector(".ds-bframe")).toBeNull();
|
||||
const image = container.querySelector("img") as HTMLImageElement;
|
||||
expect(decodeURIComponent(image.getAttribute("src") ?? "")).toContain(
|
||||
"mobile.webp",
|
||||
);
|
||||
expect(image).toHaveAttribute("sizes", "(max-width: 767px) 70vw");
|
||||
});
|
||||
|
||||
it("path: an item that is not near yet keeps its frame but no image", () => {
|
||||
const { container } = renderList({ device: "desktop" });
|
||||
bringNear(container.querySelector("li.ds-reel-item") as Element);
|
||||
|
||||
expect(container.querySelectorAll("img")).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".ds-bframe")).toHaveLength(items.length);
|
||||
});
|
||||
|
||||
it("path: project with a subtitle -> shown; without -> no subtitle paragraph", () => {
|
||||
const withSubtitle = items.map((item, index) => ({
|
||||
...item,
|
||||
project: { ...item.project, subtitle: index === 0 ? "Loja virtual" : "" },
|
||||
}));
|
||||
|
||||
const { container } = render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<ProjectList items={withSubtitle} device="desktop" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
|
||||
const subs = container.querySelectorAll(".ds-reel-sub");
|
||||
expect(subs).toHaveLength(1);
|
||||
expect(subs[0]).toHaveTextContent("Loja virtual");
|
||||
});
|
||||
|
||||
it("path: pending -> the section is marked so CSS can hide it; not pending -> unmarked", () => {
|
||||
const pending = renderList({ pending: true, device: null });
|
||||
expect(pending.container.querySelector("section")).toHaveAttribute(
|
||||
"data-pending",
|
||||
);
|
||||
pending.unmount();
|
||||
|
||||
const settled = renderList({ pending: false });
|
||||
expect(settled.container.querySelector("section")).not.toHaveAttribute(
|
||||
"data-pending",
|
||||
);
|
||||
});
|
||||
|
||||
it("path: pressing 'Ampliar vídeo' on an item -> opens the enlarged window on that project", () => {
|
||||
renderList({ device: "desktop" });
|
||||
const second = screen
|
||||
.getByRole("heading", { level: 3, name: items[1].project.title })
|
||||
.closest("li") as HTMLElement;
|
||||
|
||||
fireEvent.click(
|
||||
within(second).getByRole("button", { name: /Ampliar vídeo/ }),
|
||||
);
|
||||
|
||||
expect(screen.getByRole("dialog", { name: "ampliado" })).toHaveTextContent(
|
||||
"aberto:1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { type CSSProperties, useId, useRef } from "react";
|
||||
import { type Device, getShowcaseAssets } from "@/lib/project-showcase";
|
||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||
import { PhoneFrame } from "./PhoneFrame";
|
||||
import { ProjectActions } from "./ProjectActions";
|
||||
import { type ShowcaseItem, useShowcaseOpen } from "./ShowcaseProvider";
|
||||
import { useNearViewport } from "./useShowcaseMedia";
|
||||
|
||||
function ListItem({
|
||||
item,
|
||||
device,
|
||||
onExpand,
|
||||
}: {
|
||||
item: ShowcaseItem;
|
||||
/** `null` no servidor e até montar: o item mostra só o texto e os links. */
|
||||
device: Device | null;
|
||||
onExpand?: () => void;
|
||||
}) {
|
||||
const { config, project } = item;
|
||||
const ref = useRef<HTMLLIElement>(null);
|
||||
const near = useNearViewport(ref);
|
||||
const assets = device ? getShowcaseAssets(config.id, device) : null;
|
||||
// só pôster (nunca vídeo): a imagem entra quando o item chega a ~1 tela da janela
|
||||
const poster =
|
||||
assets && near ? (
|
||||
<Image
|
||||
src={assets.poster}
|
||||
alt={`Captura de tela de ${project.title}`}
|
||||
width={assets.width}
|
||||
height={assets.height}
|
||||
sizes={device === "mobile" ? "(max-width: 767px) 70vw" : "50vw"}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={ref}
|
||||
className="ds-reel-item"
|
||||
data-device={device ?? undefined}
|
||||
style={{ "--accent": config.accent } as CSSProperties}
|
||||
>
|
||||
{device && (
|
||||
<div className="ds-reel-item-media">
|
||||
{device === "mobile" ? (
|
||||
<PhoneFrame className="ds-reel-item-phone">{poster}</PhoneFrame>
|
||||
) : (
|
||||
<BrowserFrame url={displayUrl(project)}>
|
||||
<div className="ds-reel-item-screen">{poster}</div>
|
||||
</BrowserFrame>
|
||||
)}
|
||||
</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>}
|
||||
<ProjectActions item={item} onExpand={onExpand} />
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Projetos em lista simples: sem rolagem presa, sem vídeo que acompanha a rolagem. No celular (abaixo de 768 px)
|
||||
* é um carrossel por toque (rolagem horizontal com encaixe, o próximo projeto aparece pela borda); em telas
|
||||
* maiores, uma grade. Serve também a quem pede menos movimento e a janelas baixas demais para a vitrine fixa.
|
||||
*
|
||||
* É o que o servidor entrega (texto e links, sem mídia): sem JavaScript os projetos continuam todos ali, e
|
||||
* `pending` esconde a lista por CSS só onde a vitrine fixa vai assumir o lugar (largura e altura de desktop).
|
||||
* Pôster e "Ampliar vídeo" entram depois que o navegador monta; vídeo, só dentro da janela ampliada.
|
||||
*/
|
||||
export function ProjectList({
|
||||
items,
|
||||
device,
|
||||
pending = false,
|
||||
}: {
|
||||
items: ShowcaseItem[];
|
||||
device: Device | null;
|
||||
pending?: boolean;
|
||||
}) {
|
||||
const open = useShowcaseOpen();
|
||||
const headingId = useId();
|
||||
return (
|
||||
<section
|
||||
className="ds-reel-list"
|
||||
aria-labelledby={headingId}
|
||||
data-pending={pending ? "" : undefined}
|
||||
>
|
||||
<h2 id={headingId} className="sr-only">
|
||||
Projetos de clientes
|
||||
</h2>
|
||||
<ul className="ds-reel-items">
|
||||
{items.map((item) => (
|
||||
<ListItem
|
||||
key={item.config.id}
|
||||
item={item}
|
||||
device={device}
|
||||
onExpand={device ? () => open(item.config.id) : undefined}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
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";
|
||||
@@ -18,6 +20,16 @@ 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();
|
||||
@@ -43,6 +55,11 @@ afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
global.IntersectionObserver = Original;
|
||||
window.matchMedia = originalMatchMedia;
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
function renderCard() {
|
||||
@@ -156,6 +173,69 @@ describe("ProjectMedia", () => {
|
||||
).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}>
|
||||
|
||||
@@ -6,10 +6,13 @@ 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).
|
||||
* 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,
|
||||
@@ -18,15 +21,16 @@ export function ProjectMedia({
|
||||
id: string;
|
||||
title: string;
|
||||
demoUrl?: string | null;
|
||||
githubUrl?: string | null;
|
||||
};
|
||||
}) {
|
||||
const { frameRef, active, open, userPaused, toggleAutoplay } =
|
||||
useShowcaseCard(project.id);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const video = getProjectVideo(project.id);
|
||||
const device = useViewportDevice();
|
||||
const near = useNearViewport(frameRef);
|
||||
const video = device ? getProjectVideo(project.id, device) : undefined;
|
||||
const config = getShowcaseConfig(project.id);
|
||||
if (!video || !config) return null;
|
||||
if (!getProjectVideo(project.id) || !config) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -38,9 +42,20 @@ export function ProjectMedia({
|
||||
<BrowserFrame url={displayUrl(project)}>
|
||||
<div
|
||||
className="ds-frame-screen"
|
||||
style={{ backgroundImage: `url(${video.poster})` }}
|
||||
style={
|
||||
video && near
|
||||
? { backgroundImage: `url(${video.poster})` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ProjectVideo video={video} title={project.title} active={active} />
|
||||
{video && (
|
||||
<ProjectVideo
|
||||
video={video}
|
||||
title={project.title}
|
||||
active={active}
|
||||
showPoster={near}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="ds-frame-expand"
|
||||
|
||||
@@ -0,0 +1,675 @@
|
||||
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 } from "@/lib/project-showcase";
|
||||
import { ProjectReel } from "./ProjectReel";
|
||||
import { ShowcaseProvider } from "./ShowcaseProvider";
|
||||
import { reelOffset } from "./showcase-math";
|
||||
import { PINNED_QUERY } from "./useShowcaseMedia";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de ProjectReel (complemento de ProjectReel.test.tsx):
|
||||
* - qual versão da vitrine entra (fixa só com largura e altura de desktop e sem movimento reduzido; lista em
|
||||
* janela baixa; carrossel no celular; nada sem projetos) e a troca ao vivo quando a janela muda;
|
||||
* - measure(): vitrine escondida por CSS, trilho sem rolagem;
|
||||
* - a faixa de projetos que acompanha o ativo, a descarga de vídeos, o seek dos vídeos, o "prime" e a rolagem suave.
|
||||
*/
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => reducedMotion,
|
||||
}));
|
||||
|
||||
const HEADER = 60;
|
||||
let containerHeight = 7000;
|
||||
let pinHeight = 800;
|
||||
let reelTop = HEADER;
|
||||
let phoneWidth = 90;
|
||||
/** Rolagem horizontal da faixa de projetos (tablet e celular). */
|
||||
let stripScrollWidth = 0;
|
||||
let stripClientWidth = 0;
|
||||
let buttonOffsetLeft = 0;
|
||||
let mediaNetworkState = 0;
|
||||
|
||||
type Listener = () => void;
|
||||
const mediaState: Record<string, boolean> = {};
|
||||
const mediaListeners = new Map<string, Set<Listener>>();
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
|
||||
function mockViewport({
|
||||
phone = false,
|
||||
pinned = !phone,
|
||||
}: {
|
||||
phone?: boolean;
|
||||
pinned?: boolean;
|
||||
} = {}) {
|
||||
mediaState[MOBILE_QUERY] = phone;
|
||||
mediaState[PINNED_QUERY] = pinned;
|
||||
mediaListeners.clear();
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
get matches() {
|
||||
return Boolean(mediaState[query]);
|
||||
},
|
||||
media: query,
|
||||
addEventListener: (_type: string, listener: Listener) => {
|
||||
if (!mediaListeners.has(query)) mediaListeners.set(query, new Set());
|
||||
mediaListeners.get(query)?.add(listener);
|
||||
},
|
||||
removeEventListener: (_type: string, listener: Listener) => {
|
||||
mediaListeners.get(query)?.delete(listener);
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
function changeMedia(query: string, matches: boolean) {
|
||||
mediaState[query] = matches;
|
||||
act(() => {
|
||||
for (const listener of mediaListeners.get(query) ?? []) listener();
|
||||
});
|
||||
}
|
||||
|
||||
const projects = getFeaturedProjects(null);
|
||||
const WEIGHTS = [
|
||||
"sev-modaintima",
|
||||
"lavacao-047",
|
||||
"nutriscan",
|
||||
"sc-plus",
|
||||
"as-servicos",
|
||||
].map((id) => getShowcaseConfig(id)?.weight ?? 1);
|
||||
|
||||
const originalRect = Element.prototype.getBoundingClientRect;
|
||||
const descriptors = {
|
||||
offsetHeight: Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"offsetHeight",
|
||||
),
|
||||
offsetWidth: Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"offsetWidth",
|
||||
),
|
||||
offsetLeft: Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"offsetLeft",
|
||||
),
|
||||
scrollWidth: Object.getOwnPropertyDescriptor(
|
||||
Element.prototype,
|
||||
"scrollWidth",
|
||||
),
|
||||
clientWidth: Object.getOwnPropertyDescriptor(
|
||||
Element.prototype,
|
||||
"clientWidth",
|
||||
),
|
||||
networkState: Object.getOwnPropertyDescriptor(
|
||||
HTMLMediaElement.prototype,
|
||||
"networkState",
|
||||
),
|
||||
};
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
if (this.classList.contains("ds-reel")) return containerHeight;
|
||||
if (this.classList.contains("ds-reel-pin")) return pinHeight;
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return this.classList.contains("ds-reel-phone") ? phoneWidth : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetLeft", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return this.tagName === "BUTTON" ? buttonOffsetLeft : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(Element.prototype, "scrollWidth", {
|
||||
configurable: true,
|
||||
get(this: Element) {
|
||||
return this.matches(".ds-reel-index ol") ? stripScrollWidth : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(Element.prototype, "clientWidth", {
|
||||
configurable: true,
|
||||
get(this: Element) {
|
||||
return this.matches(".ds-reel-index ol") ? stripClientWidth : 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLMediaElement.prototype, "networkState", {
|
||||
configurable: true,
|
||||
get: () => mediaNetworkState,
|
||||
});
|
||||
HTMLDialogElement.prototype.showModal = function showModal(
|
||||
this: HTMLDialogElement,
|
||||
) {
|
||||
this.setAttribute("open", "");
|
||||
};
|
||||
HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) {
|
||||
this.removeAttribute("open");
|
||||
this.dispatchEvent(new Event("close"));
|
||||
};
|
||||
Element.prototype.getBoundingClientRect = function rect(this: Element) {
|
||||
if (this.classList.contains("ds-reel"))
|
||||
return {
|
||||
top: reelTop,
|
||||
bottom: reelTop + containerHeight,
|
||||
height: containerHeight,
|
||||
left: 0,
|
||||
right: 1000,
|
||||
width: 1000,
|
||||
x: 0,
|
||||
y: reelTop,
|
||||
} as DOMRect;
|
||||
return originalRect.call(this);
|
||||
};
|
||||
window.scrollTo = jest.fn();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
Element.prototype.getBoundingClientRect = originalRect;
|
||||
for (const [key, target] of [
|
||||
["offsetHeight", HTMLElement.prototype],
|
||||
["offsetWidth", HTMLElement.prototype],
|
||||
["offsetLeft", HTMLElement.prototype],
|
||||
["scrollWidth", Element.prototype],
|
||||
["clientWidth", Element.prototype],
|
||||
["networkState", HTMLMediaElement.prototype],
|
||||
] as const) {
|
||||
const original = descriptors[key];
|
||||
if (original) Object.defineProperty(target, key, original);
|
||||
else Reflect.deleteProperty(target, key);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
reducedMotion = false;
|
||||
containerHeight = 7000;
|
||||
pinHeight = 800;
|
||||
reelTop = HEADER;
|
||||
phoneWidth = 90;
|
||||
stripScrollWidth = 0;
|
||||
stripClientWidth = 0;
|
||||
buttonOffsetLeft = 0;
|
||||
mediaNetworkState = 0;
|
||||
mockViewport();
|
||||
(window.scrollTo as jest.Mock).mockClear();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
jest.spyOn(HTMLMediaElement.prototype, "load").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
window.matchMedia = originalMatchMedia;
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
function renderReel(list = projects) {
|
||||
const view = render(
|
||||
<ShowcaseProvider projects={list}>
|
||||
<ProjectReel />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(50);
|
||||
});
|
||||
return view;
|
||||
}
|
||||
|
||||
function scrollTo(progress: number) {
|
||||
const scrollable = containerHeight - pinHeight;
|
||||
reelTop = HEADER - progress * scrollable;
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
jest.advanceTimersByTime(1500);
|
||||
});
|
||||
}
|
||||
|
||||
const reelEl = () => document.querySelector(".ds-reel");
|
||||
const listEl = () =>
|
||||
screen.queryByRole("region", { name: "Projetos de clientes" });
|
||||
const activeLayer = () =>
|
||||
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
|
||||
|
||||
describe("ProjectReel: qual versão entra", () => {
|
||||
it("path: no projects in the showcase -> renders nothing at all", () => {
|
||||
const { container } = renderReel([]);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("path: wide and tall window, motion allowed -> the pinned reel, not the list", () => {
|
||||
renderReel();
|
||||
|
||||
expect(reelEl()).not.toBeNull();
|
||||
expect(listEl()).toBeNull();
|
||||
});
|
||||
|
||||
it("path: wide but short window (below 561 px of height) -> desktop list, nothing pinned", () => {
|
||||
mockViewport({ phone: false, pinned: false });
|
||||
|
||||
renderReel();
|
||||
|
||||
expect(reelEl()).toBeNull();
|
||||
const list = listEl() as HTMLElement;
|
||||
expect(list).not.toBeNull();
|
||||
expect(list.querySelectorAll("li[data-device='desktop']")).toHaveLength(5);
|
||||
expect(list.querySelector(".ds-phone")).toBeNull();
|
||||
expect(list).not.toHaveAttribute("data-pending");
|
||||
});
|
||||
|
||||
it("path: phone -> carousel list with the phone version of every item", () => {
|
||||
mockViewport({ phone: true });
|
||||
|
||||
renderReel();
|
||||
|
||||
expect(reelEl()).toBeNull();
|
||||
expect(
|
||||
(listEl() as HTMLElement).querySelectorAll("li[data-device='mobile']"),
|
||||
).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("path: phone width and reduced motion at once -> still the list", () => {
|
||||
mockViewport({ phone: true });
|
||||
reducedMotion = true;
|
||||
|
||||
renderReel();
|
||||
|
||||
expect(reelEl()).toBeNull();
|
||||
expect(listEl()).not.toBeNull();
|
||||
});
|
||||
|
||||
it("path: server render -> the list marked as pending plus the placeholder, with no media", () => {
|
||||
const html = renderToString(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<ProjectReel />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
|
||||
expect(html).toContain("data-pending");
|
||||
expect(html).toContain("ds-reel-placeholder");
|
||||
expect(html).not.toContain("<video");
|
||||
expect(html).not.toContain("<img");
|
||||
expect(html).not.toContain('data-device="');
|
||||
});
|
||||
|
||||
it("path: the window shrinks below the pinned size -> the reel gives way to the list, and back", () => {
|
||||
renderReel();
|
||||
expect(reelEl()).not.toBeNull();
|
||||
|
||||
changeMedia(PINNED_QUERY, false);
|
||||
expect(reelEl()).toBeNull();
|
||||
expect(listEl()).not.toBeNull();
|
||||
|
||||
changeMedia(PINNED_QUERY, true);
|
||||
expect(reelEl()).not.toBeNull();
|
||||
expect(listEl()).toBeNull();
|
||||
});
|
||||
|
||||
it("path: the window crosses the phone breakpoint while the list shows -> the list switches versions", () => {
|
||||
mockViewport({ phone: false, pinned: false });
|
||||
renderReel();
|
||||
expect(
|
||||
(listEl() as HTMLElement).querySelectorAll("li[data-device='desktop']"),
|
||||
).toHaveLength(5);
|
||||
|
||||
changeMedia(MOBILE_QUERY, true);
|
||||
|
||||
expect(
|
||||
(listEl() as HTMLElement).querySelectorAll("li[data-device='mobile']"),
|
||||
).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectReel: measure()", () => {
|
||||
it("path: the reel is hidden by CSS (height 0) -> nothing is measured or downloaded (and it is when visible)", () => {
|
||||
const attachedSources = () =>
|
||||
[...document.querySelectorAll(".ds-reel-layer video")].filter((video) =>
|
||||
video.getAttribute("src"),
|
||||
);
|
||||
containerHeight = 0;
|
||||
const hidden = renderReel();
|
||||
scrollTo(0.5);
|
||||
expect(attachedSources()).toHaveLength(0);
|
||||
hidden.unmount();
|
||||
|
||||
containerHeight = 7000;
|
||||
renderReel();
|
||||
scrollTo(0.5);
|
||||
expect(attachedSources().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: the pinned scene is as tall as the track (nothing to scroll) -> stays on the first project", () => {
|
||||
containerHeight = pinHeight;
|
||||
renderReel();
|
||||
|
||||
scrollTo(0.9);
|
||||
|
||||
expect(activeLayer()).toHaveTextContent("SEV Moda Íntima");
|
||||
});
|
||||
|
||||
it("path: scrolled to a later project -> that project becomes the active layer", () => {
|
||||
renderReel();
|
||||
|
||||
scrollTo(reelOffset(WEIGHTS, 3, 0.5));
|
||||
|
||||
expect(
|
||||
document.querySelector('.ds-reel-index [aria-current="true"]'),
|
||||
).toHaveAttribute("aria-label", "SC Plus Aquicultura");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectReel: faixa de projetos que acompanha o ativo", () => {
|
||||
it("path: strip wider than its box -> scrolls the active button into view (minus 16 px)", () => {
|
||||
stripScrollWidth = 900;
|
||||
stripClientWidth = 300;
|
||||
buttonOffsetLeft = 140;
|
||||
const scrollToStrip = jest.fn();
|
||||
HTMLOListElement.prototype.scrollTo = scrollToStrip;
|
||||
try {
|
||||
renderReel();
|
||||
|
||||
expect(scrollToStrip).toHaveBeenCalledWith({
|
||||
left: 124,
|
||||
behavior: "smooth",
|
||||
});
|
||||
} finally {
|
||||
Reflect.deleteProperty(HTMLOListElement.prototype, "scrollTo");
|
||||
}
|
||||
});
|
||||
|
||||
it("path: the active button sits near the start -> never scrolls to a negative position", () => {
|
||||
stripScrollWidth = 900;
|
||||
stripClientWidth = 300;
|
||||
buttonOffsetLeft = 4;
|
||||
const scrollToStrip = jest.fn();
|
||||
HTMLOListElement.prototype.scrollTo = scrollToStrip;
|
||||
try {
|
||||
renderReel();
|
||||
|
||||
expect(scrollToStrip).toHaveBeenCalledWith({
|
||||
left: 0,
|
||||
behavior: "smooth",
|
||||
});
|
||||
} finally {
|
||||
Reflect.deleteProperty(HTMLOListElement.prototype, "scrollTo");
|
||||
}
|
||||
});
|
||||
|
||||
it("path: strip fits in its box -> no scrolling", () => {
|
||||
stripScrollWidth = 300;
|
||||
stripClientWidth = 300;
|
||||
const scrollToStrip = jest.fn();
|
||||
HTMLOListElement.prototype.scrollTo = scrollToStrip;
|
||||
try {
|
||||
renderReel();
|
||||
|
||||
expect(scrollToStrip).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
Reflect.deleteProperty(HTMLOListElement.prototype, "scrollTo");
|
||||
}
|
||||
});
|
||||
|
||||
it("path: the browser has no Element.scrollTo -> no error and no scrolling", () => {
|
||||
stripScrollWidth = 900;
|
||||
stripClientWidth = 300;
|
||||
|
||||
expect(() => renderReel()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectReel: vídeos", () => {
|
||||
const videos = () => [
|
||||
...document.querySelectorAll<HTMLVideoElement>(".ds-reel-layer video"),
|
||||
];
|
||||
|
||||
function ready(
|
||||
video: HTMLVideoElement,
|
||||
{ readyState = 4, duration = 30, seeking = false } = {},
|
||||
) {
|
||||
Object.defineProperty(video, "readyState", {
|
||||
configurable: true,
|
||||
value: readyState,
|
||||
});
|
||||
Object.defineProperty(video, "duration", {
|
||||
configurable: true,
|
||||
value: duration,
|
||||
});
|
||||
Object.defineProperty(video, "seeking", {
|
||||
configurable: true,
|
||||
value: seeking,
|
||||
});
|
||||
let time = 0;
|
||||
Object.defineProperty(video, "currentTime", {
|
||||
configurable: true,
|
||||
get: () => time,
|
||||
set: (value: number) => {
|
||||
time = value;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("path: videos that hold a file reference but are not attached -> released with load()", () => {
|
||||
mediaNetworkState = 1;
|
||||
reelTop = 6000; // longe da vitrine: ninguém fica anexado
|
||||
|
||||
renderReel();
|
||||
|
||||
expect(HTMLMediaElement.prototype.load).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: videos that never loaded anything (network empty) -> nothing to release", () => {
|
||||
mediaNetworkState = 0;
|
||||
reelTop = 6000;
|
||||
|
||||
renderReel();
|
||||
|
||||
expect(HTMLMediaElement.prototype.load).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: the attached project keeps its file while the others are released", () => {
|
||||
mediaNetworkState = 1;
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
jest.mocked(HTMLMediaElement.prototype.load).mockClear();
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
const attached = videos().filter((video) => video.getAttribute("src"));
|
||||
expect(attached.length).toBeGreaterThan(0);
|
||||
// quem soltou foi pedido a recarregar; quem ficou com src não foi tocado
|
||||
const loaded = jest.mocked(HTMLMediaElement.prototype.load).mock
|
||||
.instances as unknown as HTMLMediaElement[];
|
||||
for (const video of attached) expect(loaded).not.toContain(video);
|
||||
});
|
||||
|
||||
it("path: a video that is still seeking -> its time is left alone", () => {
|
||||
renderReel();
|
||||
for (const video of videos()) ready(video, { seeking: true });
|
||||
|
||||
scrollTo(reelOffset(WEIGHTS, 0, 0.5));
|
||||
|
||||
expect(videos()[0].currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it("path: a video whose metadata has not loaded (readyState 0) or has no duration -> never sought", () => {
|
||||
renderReel();
|
||||
const [first, second] = videos();
|
||||
ready(first, { readyState: 0 });
|
||||
ready(second, { duration: 0 });
|
||||
|
||||
scrollTo(reelOffset(WEIGHTS, 0, 0.5));
|
||||
|
||||
expect(first.currentTime).toBe(0);
|
||||
expect(second.currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it("path: a video that is ready and idle -> sought to the scroll position", () => {
|
||||
renderReel();
|
||||
for (const video of videos()) ready(video);
|
||||
|
||||
scrollTo(reelOffset(WEIGHTS, 0, 0.5));
|
||||
|
||||
expect(videos()[0].currentTime).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("path: first frame decoded -> primed once (play then pause) even if loadeddata fires twice", async () => {
|
||||
renderReel();
|
||||
const video = videos()[0];
|
||||
|
||||
fireEvent(video, new Event("loadeddata"));
|
||||
fireEvent(video, new Event("loadeddata"));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(HTMLMediaElement.prototype.play).toHaveBeenCalledTimes(1);
|
||||
expect(HTMLMediaElement.prototype.pause).toHaveBeenCalledTimes(1);
|
||||
expect(video.dataset.primed).toBe("true");
|
||||
});
|
||||
|
||||
it("path: priming is refused by the browser -> swallowed, no pause", async () => {
|
||||
jest
|
||||
.mocked(HTMLMediaElement.prototype.play)
|
||||
.mockRejectedValue(new DOMException("blocked", "NotAllowedError"));
|
||||
renderReel();
|
||||
|
||||
fireEvent(videos()[0], new Event("loadeddata"));
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(HTMLMediaElement.prototype.pause).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("path: metadata loads before any scroll work (reel hidden by CSS, nothing measured) -> sought to the start, no error", () => {
|
||||
containerHeight = 0;
|
||||
renderReel();
|
||||
const video = videos()[0];
|
||||
ready(video);
|
||||
|
||||
expect(() => fireEvent(video, new Event("loadedmetadata"))).not.toThrow();
|
||||
|
||||
expect(video.currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it("path: metadata loads when the scroll target is known but no frame ran yet (reel far away) -> uses the target", () => {
|
||||
reelTop = 6000;
|
||||
renderReel();
|
||||
const video = videos()[0];
|
||||
ready(video);
|
||||
|
||||
fireEvent(video, new Event("loadedmetadata"));
|
||||
|
||||
expect(video.currentTime).toBe(0);
|
||||
});
|
||||
|
||||
it("path: metadata loads after the scroll already moved -> the video jumps to where the scroll is", () => {
|
||||
renderReel();
|
||||
scrollTo(reelOffset(WEIGHTS, 0, 0.5));
|
||||
const video = videos()[0];
|
||||
ready(video);
|
||||
|
||||
fireEvent(video, new Event("loadedmetadata"));
|
||||
|
||||
expect(video.currentTime).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectReel: rolagem suave até um projeto", () => {
|
||||
function pressProject(name: string) {
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole("navigation", { name: "Projetos da vitrine" }),
|
||||
).getByRole("button", { name }),
|
||||
);
|
||||
}
|
||||
|
||||
it("path: pressing a project -> scrolls in animation frames and ends exactly on the target", () => {
|
||||
renderReel();
|
||||
|
||||
pressProject("SC Plus Aquicultura");
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
const calls = (window.scrollTo as jest.Mock).mock.calls;
|
||||
expect(calls.length).toBeGreaterThan(2);
|
||||
for (const [options] of calls) expect(options.behavior).toBe("instant");
|
||||
const tops = calls.map(([options]) => options.top as number);
|
||||
expect(tops).toEqual([...tops].sort((a, b) => a - b));
|
||||
expect(tops[tops.length - 1]).toBeGreaterThan(tops[0]);
|
||||
});
|
||||
|
||||
it("path: the wheel during the scroll -> the animation stops where it is", () => {
|
||||
renderReel();
|
||||
pressProject("SC Plus Aquicultura");
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("wheel"));
|
||||
});
|
||||
const stoppedAt = (window.scrollTo as jest.Mock).mock.calls.length;
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect((window.scrollTo as jest.Mock).mock.calls).toHaveLength(stoppedAt);
|
||||
});
|
||||
|
||||
it("path: touching the screen during the scroll -> the animation stops too", () => {
|
||||
renderReel();
|
||||
pressProject("SC Plus Aquicultura");
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("touchstart"));
|
||||
});
|
||||
const stoppedAt = (window.scrollTo as jest.Mock).mock.calls.length;
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
expect((window.scrollTo as jest.Mock).mock.calls).toHaveLength(stoppedAt);
|
||||
});
|
||||
|
||||
it("path: a second press while the first scroll runs -> the first is cancelled, only the new target is reached", () => {
|
||||
renderReel();
|
||||
pressProject("AS Serviços");
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(100);
|
||||
});
|
||||
(window.scrollTo as jest.Mock).mockClear();
|
||||
|
||||
pressProject("Lavação 047");
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(2000);
|
||||
});
|
||||
|
||||
const tops = (window.scrollTo as jest.Mock).mock.calls.map(
|
||||
([options]) => options.top as number,
|
||||
);
|
||||
const finalTop = tops[tops.length - 1];
|
||||
// o destino da Lavação (2º projeto) fica bem antes do da AS Serviços (último)
|
||||
expect(finalTop).toBeLessThan(reelTopFor(4));
|
||||
});
|
||||
|
||||
function reelTopFor(index: number) {
|
||||
const scrollable = containerHeight - pinHeight;
|
||||
return reelOffset(WEIGHTS, index, 0) * scrollable;
|
||||
}
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { MOBILE_QUERY } from "@/lib/device";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import { getShowcaseConfig, mobileFractionAt } from "@/lib/project-showcase";
|
||||
import { ProjectReel } from "./ProjectReel";
|
||||
import { ShowcaseProvider } from "./ShowcaseProvider";
|
||||
import { reelOffset, videoProgress } from "./showcase-math";
|
||||
import { PINNED_QUERY } from "./useShowcaseMedia";
|
||||
|
||||
let reducedMotion = false;
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
@@ -16,6 +18,21 @@ const CONTAINER = 7000;
|
||||
const SCROLLABLE = CONTAINER - PIN;
|
||||
const HEADER = 60;
|
||||
let reelTop = HEADER;
|
||||
/** largura do celular da moldura: 0 simula uma moldura escondida por CSS */
|
||||
let phoneWidth = 90;
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
|
||||
/** Tela de desktop por padrão (cabe a vitrine fixa); `phone` simula um celular. */
|
||||
function mockViewport({ phone = false }: { phone?: boolean } = {}) {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches:
|
||||
(query === MOBILE_QUERY && phone) || (query === PINNED_QUERY && !phone),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
}
|
||||
|
||||
const projects = getFeaturedProjects(null);
|
||||
const weights = [
|
||||
@@ -31,6 +48,10 @@ const originalOffsetHeight = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"offsetHeight",
|
||||
);
|
||||
const originalOffsetWidth = Object.getOwnPropertyDescriptor(
|
||||
HTMLElement.prototype,
|
||||
"offsetWidth",
|
||||
);
|
||||
|
||||
beforeAll(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
|
||||
@@ -41,6 +62,22 @@ beforeAll(() => {
|
||||
return 0;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetWidth", {
|
||||
configurable: true,
|
||||
get(this: HTMLElement) {
|
||||
return this.classList.contains("ds-reel-phone") ? phoneWidth : 0;
|
||||
},
|
||||
});
|
||||
// jsdom ainda não implementa o <dialog> modal
|
||||
HTMLDialogElement.prototype.showModal = function showModal(
|
||||
this: HTMLDialogElement,
|
||||
) {
|
||||
this.setAttribute("open", "");
|
||||
};
|
||||
HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) {
|
||||
this.removeAttribute("open");
|
||||
this.dispatchEvent(new Event("close"));
|
||||
};
|
||||
Element.prototype.getBoundingClientRect = function rect(this: Element) {
|
||||
if (this.classList.contains("ds-reel"))
|
||||
return {
|
||||
@@ -66,12 +103,20 @@ afterAll(() => {
|
||||
"offsetHeight",
|
||||
originalOffsetHeight,
|
||||
);
|
||||
if (originalOffsetWidth)
|
||||
Object.defineProperty(
|
||||
HTMLElement.prototype,
|
||||
"offsetWidth",
|
||||
originalOffsetWidth,
|
||||
);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
reducedMotion = false;
|
||||
reelTop = HEADER;
|
||||
phoneWidth = 90;
|
||||
mockViewport();
|
||||
(window.scrollTo as jest.Mock).mockClear();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "play").mockResolvedValue();
|
||||
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
@@ -80,6 +125,11 @@ beforeEach(() => {
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
jest.restoreAllMocks();
|
||||
window.matchMedia = originalMatchMedia;
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
function renderReel() {
|
||||
@@ -130,12 +180,51 @@ const activeLayer = () =>
|
||||
document.querySelector('.ds-reel-layer[data-state="active"]') as HTMLElement;
|
||||
|
||||
describe("ProjectReel", () => {
|
||||
it("renders nothing for users who prefer reduced motion", () => {
|
||||
it("falls back to a plain project list, nothing pinned, for users who prefer reduced motion", () => {
|
||||
reducedMotion = true;
|
||||
renderReel();
|
||||
expect(
|
||||
screen.queryByRole("region", { name: /Vitrine animada/ }),
|
||||
).toBeNull();
|
||||
expect(document.querySelector(".ds-reel")).toBeNull();
|
||||
const list = screen.getByRole("region", { name: "Projetos de clientes" });
|
||||
expect(within(list).getAllByRole("heading", { level: 3 })).toHaveLength(5);
|
||||
expect(document.querySelector("video")).toBeNull();
|
||||
});
|
||||
|
||||
it("is a touch carousel of posters, with nothing pinned and no video, on a phone", () => {
|
||||
mockViewport({ phone: true });
|
||||
renderReel();
|
||||
|
||||
expect(document.querySelector(".ds-reel")).toBeNull();
|
||||
const list = screen.getByRole("region", { name: "Projetos de clientes" });
|
||||
expect(list).not.toHaveAttribute("data-pending");
|
||||
expect(document.querySelector("video")).toBeNull();
|
||||
// o item chega a ~1 tela da janela (o IntersectionObserver do jest.setup avisa): só então vem o pôster
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(50);
|
||||
});
|
||||
const posters = [...list.querySelectorAll("img")].map((image) =>
|
||||
decodeURIComponent(image.getAttribute("src") ?? ""),
|
||||
);
|
||||
expect(posters.length).toBeGreaterThan(0);
|
||||
for (const poster of posters) expect(poster).toContain("mobile.webp");
|
||||
expect(list.querySelectorAll(".ds-phone")).toHaveLength(5);
|
||||
expect(list.querySelector(".ds-bframe")).toBeNull();
|
||||
});
|
||||
|
||||
it("still opens the enlarged video from the list, without any card in the page", () => {
|
||||
mockViewport({ phone: true });
|
||||
renderReel();
|
||||
const item = screen
|
||||
.getByRole("heading", { level: 3, name: "SEV Moda Íntima" })
|
||||
.closest("li") as HTMLElement;
|
||||
fireEvent.click(
|
||||
within(item).getByRole("button", { name: /Ampliar vídeo/ }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "SEV Moda Íntima" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists every project by name, starting on the first with its chapters", () => {
|
||||
@@ -201,7 +290,7 @@ describe("ProjectReel", () => {
|
||||
const lavacao = getShowcaseConfig("lavacao-047");
|
||||
expect(lavacao).toBeDefined();
|
||||
expect(layer.querySelector(".ds-reel-chapter-count")?.textContent).toMatch(
|
||||
/^0\d\/06$/,
|
||||
/^0\d\/03$/,
|
||||
);
|
||||
const labels = lavacao?.chapters.map((chapter) => chapter.label) ?? [];
|
||||
expect(labels).toContain(
|
||||
@@ -237,7 +326,7 @@ describe("ProjectReel", () => {
|
||||
expect(sources().every((src) => src === null)).toBe(true);
|
||||
});
|
||||
|
||||
it("downloads only the project on screen and its neighbours, and lets go of the rest", () => {
|
||||
it("downloads video only for the project on screen (neighbours stay on the poster), and lets go of the rest", () => {
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
act(() => {
|
||||
@@ -248,10 +337,14 @@ describe("ProjectReel", () => {
|
||||
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
|
||||
).toBe(true);
|
||||
expect(
|
||||
loaded.some((src) => src?.includes("lavacao-047/scrub-mobile.mp4")),
|
||||
loaded.some((src) => src?.includes("sev-modaintima/scrub-mobile.mp4")),
|
||||
).toBe(true);
|
||||
// o vizinho (Lavação) e os demais ficam sem nenhum vídeo
|
||||
expect(loaded.some((src) => src?.includes("lavacao-047"))).toBe(false);
|
||||
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(false);
|
||||
expect(loaded.some((src) => src?.includes("as-servicos"))).toBe(false);
|
||||
for (const video of document.querySelectorAll(".ds-reel-layer video"))
|
||||
expect(video).toHaveAttribute("preload", "metadata");
|
||||
|
||||
// vai para o último projeto: o primeiro sai da janela e solta o arquivo
|
||||
scrollTo(1);
|
||||
@@ -281,34 +374,38 @@ describe("ProjectReel", () => {
|
||||
});
|
||||
const loaded = sources().filter(Boolean);
|
||||
expect(loaded.some((src) => src?.includes("sc-plus"))).toBe(true);
|
||||
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(true); // vizinho do projeto onde parou
|
||||
expect(loaded.some((src) => src?.includes("nutriscan"))).toBe(false); // vizinho: só o pôster
|
||||
expect(loaded.some((src) => src?.includes("sev-modaintima"))).toBe(false);
|
||||
});
|
||||
|
||||
it("does not download the hidden laptop video on a phone-sized screen", () => {
|
||||
const original = window.matchMedia;
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: query.includes("max-width: 639px"),
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
try {
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
const loaded = sources().filter(Boolean);
|
||||
expect(loaded.some((src) => src?.includes("scrub-mobile.mp4"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(loaded.some((src) => src?.includes("scrub-desktop.mp4"))).toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
window.matchMedia = original;
|
||||
}
|
||||
it("does not download the phone video while the phone frame is not on screen", () => {
|
||||
phoneWidth = 0;
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
const loaded = sources().filter(Boolean);
|
||||
expect(
|
||||
loaded.some((src) => src?.includes("sev-modaintima/scrub-desktop.mp4")),
|
||||
).toBe(true);
|
||||
expect(loaded.some((src) => src?.includes("scrub-mobile.mp4"))).toBe(false);
|
||||
});
|
||||
|
||||
it("asks for posters only near the reel, and only for the project on screen and its neighbours", () => {
|
||||
reelTop = 6000;
|
||||
renderReel();
|
||||
const background = (index: number) =>
|
||||
(document.querySelectorAll(".ds-reel-screen")[index] as HTMLElement).style
|
||||
.backgroundImage;
|
||||
expect(background(0)).toBe("");
|
||||
expect(document.querySelectorAll("video[poster]")).toHaveLength(0);
|
||||
|
||||
scrollTo(0); // chegou a menos de uma tela
|
||||
expect(background(0)).toContain("sev-modaintima/desktop.webp");
|
||||
expect(background(1)).toContain("lavacao-047/desktop.webp");
|
||||
expect(background(3)).toBe("");
|
||||
expect(background(4)).toBe("");
|
||||
});
|
||||
|
||||
it("respects the data saver: no scroll video is downloaded", () => {
|
||||
@@ -316,31 +413,113 @@ describe("ProjectReel", () => {
|
||||
configurable: true,
|
||||
value: { saveData: true },
|
||||
});
|
||||
try {
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
expect(sources().every((src) => src === null)).toBe(true);
|
||||
} finally {
|
||||
Object.defineProperty(navigator, "connection", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
expect(sources().every((src) => src === null)).toBe(true);
|
||||
// o pôster continua lá
|
||||
expect(
|
||||
(document.querySelectorAll(".ds-reel-screen")[0] as HTMLElement).style
|
||||
.backgroundImage,
|
||||
).toContain("desktop.webp");
|
||||
});
|
||||
|
||||
it("holds the page height before it mounts, so a jump to #contact lands in the right place", () => {
|
||||
it("gives the server the project list (text and links, no media) and holds the pinned height before mounting", () => {
|
||||
const html = renderToString(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<ProjectReel />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
// lugar reservado para a vitrine fixa: a página não muda de altura quando ela aparece (salto para #contact)
|
||||
expect(html).toContain("ds-reel-placeholder");
|
||||
expect(html).toContain("--reel-sum");
|
||||
expect(html).toContain('aria-hidden="true"');
|
||||
expect(html).not.toContain("<video");
|
||||
expect(html).not.toContain("/videos/");
|
||||
// sem JavaScript os projetos continuam ali, com o link do case
|
||||
expect(html).toContain("ds-reel-list");
|
||||
expect(html).toContain("data-pending");
|
||||
expect(html).toContain("SEV Moda Íntima");
|
||||
expect(html).toContain('href="/cases/lavacao-047"');
|
||||
expect(html).not.toContain("Ampliar vídeo");
|
||||
});
|
||||
|
||||
it("has an h2 before the project h3, in the pinned scene and in the list", () => {
|
||||
const { unmount } = renderReel();
|
||||
const region = screen.getByRole("region", { name: /Vitrine animada/ });
|
||||
const order = [...region.querySelectorAll("h2, h3")].map(
|
||||
(heading) => heading.tagName,
|
||||
);
|
||||
expect(order[0]).toBe("H2");
|
||||
expect(order).toContain("H3");
|
||||
unmount();
|
||||
|
||||
mockViewport({ phone: true });
|
||||
renderReel();
|
||||
const list = screen.getByRole("region", { name: "Projetos de clientes" });
|
||||
expect(list.querySelector("h2, h3")?.tagName).toBe("H2");
|
||||
});
|
||||
|
||||
it("limits each project to three chapters, in the index and in the caption", () => {
|
||||
renderReel();
|
||||
for (const layer of document.querySelectorAll(".ds-reel-layer"))
|
||||
expect(
|
||||
layer.querySelectorAll(".ds-reel-ticks li").length,
|
||||
).toBeLessThanOrEqual(3);
|
||||
const chapters = screen.getByRole("list", {
|
||||
name: "Trechos de SEV Moda Íntima",
|
||||
});
|
||||
expect(within(chapters).getAllByRole("button")).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("shows the real domain in the address bar, or just the project name, never github.com", () => {
|
||||
renderReel();
|
||||
const bars = [...document.querySelectorAll(".ds-bframe-url")].map(
|
||||
(bar) => bar.textContent,
|
||||
);
|
||||
expect(bars).toContain("sevmodaintima.com.br");
|
||||
expect(bars).toContain("scplus.evandro.dev.br");
|
||||
expect(bars).toContain("AS Serviços");
|
||||
expect(document.body.innerHTML).not.toContain("github.com");
|
||||
});
|
||||
|
||||
it("offers Ver case (outline), Abrir site (text link, only with a live site) and Ampliar vídeo, none of them lime", () => {
|
||||
renderReel();
|
||||
const layer = activeLayer();
|
||||
const actions = layer.querySelector(".ds-reel-actions") as HTMLElement;
|
||||
expect(
|
||||
within(actions).getByRole("link", { name: /Ver case/ }),
|
||||
).toHaveAttribute("href", "/cases/sev-modaintima");
|
||||
const site = within(actions).getByRole("link", { name: /Abrir site/ });
|
||||
expect(site).toHaveAttribute("href", "https://sevmodaintima.com.br");
|
||||
expect(site).toHaveAttribute("target", "_blank");
|
||||
expect(site).toHaveClass("ds-link-text");
|
||||
expect(
|
||||
within(actions).getByRole("button", { name: /Ampliar vídeo/ }),
|
||||
).toHaveClass("ds-link-muted");
|
||||
expect(document.querySelector(".ds-reel .ds-link-accent")).toBeNull();
|
||||
|
||||
// sem demoUrl não há "Abrir site"
|
||||
const asServicos = [...document.querySelectorAll(".ds-reel-layer")].at(-1);
|
||||
expect(asServicos).toBeDefined();
|
||||
expect(
|
||||
within(asServicos as HTMLElement).queryByRole("link", {
|
||||
name: /Abrir site/,
|
||||
hidden: true,
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("opens the enlarged video from the pinned scene", () => {
|
||||
renderReel();
|
||||
fireEvent.click(
|
||||
within(activeLayer()).getByRole("button", { name: /Ampliar vídeo/ }),
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "SEV Moda Íntima" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("scrolls to the start of a project when its button is pressed", () => {
|
||||
|
||||
@@ -18,7 +18,13 @@ import {
|
||||
} from "@/lib/project-showcase";
|
||||
import { BrowserFrame, displayUrl } from "./BrowserFrame";
|
||||
import { PhoneFrame } from "./PhoneFrame";
|
||||
import { useShowcaseItems, useShowcaseOpen } from "./ShowcaseProvider";
|
||||
import { ProjectActions } from "./ProjectActions";
|
||||
import { ProjectList } from "./ProjectList";
|
||||
import {
|
||||
type ShowcaseItem,
|
||||
useShowcaseItems,
|
||||
useShowcaseOpen,
|
||||
} from "./ShowcaseProvider";
|
||||
import {
|
||||
easeToward,
|
||||
locateInReel,
|
||||
@@ -26,6 +32,11 @@ import {
|
||||
reelOffset,
|
||||
videoProgress,
|
||||
} from "./showcase-math";
|
||||
import {
|
||||
usePinnedViewport,
|
||||
useSaveData,
|
||||
useViewportDevice,
|
||||
} from "./useShowcaseMedia";
|
||||
|
||||
// a altura do cabeçalho só muda com a largura da janela (tokens em :root): ler o estilo a cada quadro de rolagem é caro
|
||||
let headerCache = { width: -1, height: 60 };
|
||||
@@ -43,25 +54,6 @@ const headerHeight = () => {
|
||||
return headerCache.height;
|
||||
};
|
||||
|
||||
/** Verdadeiro enquanto a tela é estreita a ponto de a vitrine mostrar só o celular (o notebook fica escondido). */
|
||||
function usePhoneOnly() {
|
||||
const [phoneOnly, setPhoneOnly] = useState(false);
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") return;
|
||||
const query = window.matchMedia("(max-width: 639px)");
|
||||
const update = () => setPhoneOnly(query.matches);
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
return phoneOnly;
|
||||
}
|
||||
|
||||
/** "Economizar dados" ligado: a vitrine fica nos posters, sem baixar os vídeos de rolagem. */
|
||||
const saveDataOn = () =>
|
||||
(navigator as Navigator & { connection?: { saveData?: boolean } }).connection
|
||||
?.saveData === true;
|
||||
|
||||
/** 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;
|
||||
@@ -111,25 +103,68 @@ function scrollToSmoothly(top: number) {
|
||||
id = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
/**
|
||||
* Projetos da home. Em tablet e desktop (768 px de largura e 561 px de altura, no mínimo), a vitrine animada:
|
||||
* o site de cada projeto rola dentro de um notebook (e de um celular ao lado) conforme a página rola, com a
|
||||
* cena presa (position: sticky) durante a rolagem de todos os projetos. No celular, com prefers-reduced-motion
|
||||
* e em janelas baixas, nada fica preso: é a lista de projetos (carrossel por toque no celular), só com pôsteres.
|
||||
* O servidor entrega a lista (texto e links); o navegador troca pela vitrine fixa quando ela cabe, e até lá
|
||||
* um bloco da mesma altura segura o lugar dela. Funciona sozinha dentro de `ShowcaseProvider`: não depende
|
||||
* dos cards da grade.
|
||||
*/
|
||||
export function ProjectReel() {
|
||||
const items = useShowcaseItems();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const wide = usePinnedViewport();
|
||||
const device = useViewportDevice();
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const mounted = wide !== null && device !== null;
|
||||
if (mounted && wide && !reducedMotion) return <PinnedReel items={items} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProjectList items={items} device={device} pending={!mounted} />
|
||||
{!mounted && (
|
||||
// antes de montar, um bloco da mesma altura segura o lugar da vitrine fixa: a página não muda de
|
||||
// tamanho quando ela aparece (o salto para #contact, por exemplo, cai no lugar certo); o CSS só o
|
||||
// mostra onde a vitrine fixa vai existir (e a lista, nesses casos, fica escondida)
|
||||
<div
|
||||
className="ds-reel ds-reel-placeholder"
|
||||
aria-hidden="true"
|
||||
style={
|
||||
{
|
||||
"--reel-sum": items.reduce(
|
||||
(sum, item) => sum + item.config.weight,
|
||||
0,
|
||||
),
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Só existe com JavaScript e sem prefers-reduced-motion; antes disso, e nesses casos, os cards fazem o papel.
|
||||
* 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.
|
||||
*/
|
||||
export function ProjectReel() {
|
||||
const items = useShowcaseItems();
|
||||
function PinnedReel({ items }: { items: ShowcaseItem[] }) {
|
||||
const openProject = useShowcaseOpen();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [active, setActive] = useState(0);
|
||||
const [chapter, setChapter] = useState(0);
|
||||
const [near, setNear] = useState(false);
|
||||
const [settled, setSettled] = useState(0);
|
||||
const [saveData, setSaveData] = useState(false);
|
||||
const phoneOnly = usePhoneOnly();
|
||||
const [phoneVisible, setPhoneVisible] = useState(true);
|
||||
const saveData = useSaveData();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const pinRef = useRef<HTMLDivElement>(null);
|
||||
@@ -145,6 +180,7 @@ export function ProjectReel() {
|
||||
raf: 0,
|
||||
last: 0,
|
||||
near: false,
|
||||
phone: true,
|
||||
});
|
||||
|
||||
const weights = useMemo(
|
||||
@@ -153,13 +189,8 @@ export function ProjectReel() {
|
||||
);
|
||||
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
|
||||
|
||||
useEffect(() => {
|
||||
setSaveData(saveDataOn());
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// só o projeto em cena e os vizinhos baixam vídeo; e só depois que a rolagem parou ali (passar voando por
|
||||
// um projeto no clique do índice não pode baixar todos os do caminho)
|
||||
// só o projeto em cena baixa vídeo (os vizinhos ficam no pôster); e só depois que a rolagem parou ali (passar
|
||||
// voando por um projeto no clique do índice não pode baixar todos os do caminho)
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setSettled(active), 300);
|
||||
return () => clearTimeout(timer);
|
||||
@@ -183,9 +214,11 @@ export function ProjectReel() {
|
||||
}, [active]);
|
||||
|
||||
const isAttached = useCallback(
|
||||
(index: number) => near && !saveData && Math.abs(index - settled) <= 1,
|
||||
(index: number) => near && !saveData && index === settled,
|
||||
[near, saveData, settled],
|
||||
);
|
||||
// o pôster de quem está em cena e dos vizinhos já fica pronto para a troca; os outros esperam chegar perto
|
||||
const showsPoster = (index: number) => near && Math.abs(index - active) <= 1;
|
||||
|
||||
// quem saiu da janela solta o arquivo (sem `src` o elemento fica vazio e libera a memória)
|
||||
useEffect(() => {
|
||||
@@ -266,12 +299,20 @@ export function ProjectReel() {
|
||||
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 * 2 && rect.bottom > -window.innerHeight;
|
||||
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;
|
||||
@@ -289,7 +330,6 @@ export function ProjectReel() {
|
||||
}, [frame, items, weights]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted || reducedMotion || items.length === 0) return;
|
||||
let pending = 0;
|
||||
const schedule = () => {
|
||||
if (!pending)
|
||||
@@ -309,7 +349,7 @@ export function ProjectReel() {
|
||||
cancelAnimationFrame(state.raf);
|
||||
state.raf = 0;
|
||||
};
|
||||
}, [mounted, reducedMotion, items.length, measure]);
|
||||
}, [measure]);
|
||||
|
||||
/** Rola até o ponto da vitrine em que o projeto `index` está em `local` (0 a 1) do trecho dele. */
|
||||
const scrollToReel = (index: number, local: number) => {
|
||||
@@ -334,20 +374,6 @@ export function ProjectReel() {
|
||||
scrollToReel(index, REEL_HOLD + fraction * (1 - REEL_HOLD * 2));
|
||||
};
|
||||
|
||||
if (items.length === 0 || (mounted && reducedMotion)) return null;
|
||||
|
||||
// antes de montar, um bloco da mesma altura segura o lugar: a página não muda de tamanho quando a vitrine
|
||||
// aparece (o salto para #contact, por exemplo, cai no lugar certo); o CSS o esconde sem JavaScript, com
|
||||
// prefers-reduced-motion e em telas baixas
|
||||
if (!mounted)
|
||||
return (
|
||||
<div
|
||||
className="ds-reel ds-reel-placeholder"
|
||||
aria-hidden="true"
|
||||
style={{ "--reel-sum": weightSum } as CSSProperties}
|
||||
/>
|
||||
);
|
||||
|
||||
const current = items[active];
|
||||
|
||||
return (
|
||||
@@ -357,6 +383,7 @@ export function ProjectReel() {
|
||||
style={{ "--reel-sum": weightSum } as CSSProperties}
|
||||
ref={containerRef}
|
||||
>
|
||||
<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">
|
||||
@@ -374,9 +401,12 @@ export function ProjectReel() {
|
||||
aria-label={item.project.title}
|
||||
onClick={() => goTo(index)}
|
||||
>
|
||||
<span className="ds-reel-num">
|
||||
{String(index + 1).padStart(2, "0")}
|
||||
</span>
|
||||
{/* 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"
|
||||
@@ -417,6 +447,7 @@ export function ProjectReel() {
|
||||
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 (
|
||||
@@ -438,22 +469,25 @@ export function ProjectReel() {
|
||||
>
|
||||
<div
|
||||
className="ds-reel-screen"
|
||||
style={{ backgroundImage: `url(${desktop.poster})` }}
|
||||
style={
|
||||
posters
|
||||
? { backgroundImage: `url(${desktop.poster})` }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: vídeo decorativo sem áudio */}
|
||||
<video
|
||||
ref={(element) => {
|
||||
desktopVideos.current[index] = element;
|
||||
}}
|
||||
src={
|
||||
attach && !phoneOnly
|
||||
attach
|
||||
? getScrubSource(config.id, "desktop")
|
||||
: undefined
|
||||
}
|
||||
poster={desktop.poster}
|
||||
poster={posters ? desktop.poster : undefined}
|
||||
muted
|
||||
playsInline
|
||||
preload={attach && !phoneOnly ? "auto" : "none"}
|
||||
preload="metadata"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disablePictureInPicture
|
||||
@@ -465,22 +499,21 @@ export function ProjectReel() {
|
||||
</BrowserFrame>
|
||||
<PhoneFrame
|
||||
className="ds-reel-phone"
|
||||
poster={phone.poster}
|
||||
poster={posters ? phone.poster : undefined}
|
||||
>
|
||||
{/* biome-ignore lint/a11y/useMediaCaption: vídeo decorativo sem áudio */}
|
||||
<video
|
||||
ref={(element) => {
|
||||
phoneVideos.current[index] = element;
|
||||
}}
|
||||
src={
|
||||
attach
|
||||
attach && phoneVisible
|
||||
? getScrubSource(config.id, "mobile")
|
||||
: undefined
|
||||
}
|
||||
poster={phone.poster}
|
||||
poster={posters ? phone.poster : undefined}
|
||||
muted
|
||||
playsInline
|
||||
preload={attach ? "auto" : "none"}
|
||||
preload="metadata"
|
||||
tabIndex={-1}
|
||||
aria-hidden="true"
|
||||
disablePictureInPicture
|
||||
@@ -525,31 +558,10 @@ export function ProjectReel() {
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<div className="ds-reel-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="ds-link ds-link-accent"
|
||||
onClick={() => openProject(config.id)}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
Ampliar vídeo
|
||||
</button>
|
||||
{project.demoUrl &&
|
||||
project.demoAvailability !== "unavailable" && (
|
||||
<a
|
||||
className="ds-link ds-link-muted"
|
||||
href={project.demoUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
Abrir projeto
|
||||
<span className="sr-only">
|
||||
{" "}
|
||||
(abre em nova aba)
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<ProjectActions
|
||||
item={item}
|
||||
onExpand={() => openProject(config.id)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { useState } from "react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import {
|
||||
buildShowcaseItems,
|
||||
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.
|
||||
*/
|
||||
|
||||
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);
|
||||
|
||||
function Cards({ ids }: { ids: string[] }) {
|
||||
return (
|
||||
<ShowcaseProvider projects={projects}>
|
||||
{ids.map((id) => (
|
||||
<Card key={id} id={id} />
|
||||
))}
|
||||
</ShowcaseProvider>
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
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" },
|
||||
);
|
||||
flush();
|
||||
|
||||
expect(isActive(frame("sev-modaintima"))).toBe(true);
|
||||
});
|
||||
|
||||
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" },
|
||||
);
|
||||
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 />
|
||||
</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([]);
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
buildShowcaseItems,
|
||||
ShowcaseProvider,
|
||||
useShowcaseCard,
|
||||
useShowcaseOpen,
|
||||
} from "./ShowcaseProvider";
|
||||
|
||||
let reducedMotion = false;
|
||||
@@ -157,6 +158,59 @@ describe("ShowcaseProvider", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
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",
|
||||
"sc-plus",
|
||||
"as-servicos",
|
||||
]);
|
||||
});
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
function Opener() {
|
||||
const open = useShowcaseOpen();
|
||||
return (
|
||||
<button type="button" onClick={() => open("sc-plus")}>
|
||||
abrir sc-plus
|
||||
</button>
|
||||
);
|
||||
}
|
||||
const frames = jest.spyOn(window, "requestAnimationFrame");
|
||||
render(
|
||||
<ShowcaseProvider projects={clients}>
|
||||
<Opener />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
flush();
|
||||
frames.mockClear();
|
||||
act(() => {
|
||||
window.dispatchEvent(new Event("scroll"));
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
flush();
|
||||
expect(frames).not.toHaveBeenCalled();
|
||||
|
||||
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();
|
||||
|
||||
@@ -111,26 +111,32 @@ export function ShowcaseProvider({
|
||||
setActiveId(pickActiveCard(candidates, hovered.current));
|
||||
}, []);
|
||||
|
||||
const schedule = useCallback(() => {
|
||||
if (queued.current) return;
|
||||
queued.current = true;
|
||||
frame.current = requestAnimationFrame(evaluate);
|
||||
}, [evaluate]);
|
||||
// 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();
|
||||
schedule(true);
|
||||
}, [schedule, openId, reducedMotion, userPaused]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener("scroll", schedule, { passive: true });
|
||||
window.addEventListener("resize", schedule);
|
||||
document.addEventListener("visibilitychange", schedule);
|
||||
const onChange = () => schedule();
|
||||
window.addEventListener("scroll", onChange, { passive: true });
|
||||
window.addEventListener("resize", onChange);
|
||||
document.addEventListener("visibilitychange", onChange);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", schedule);
|
||||
window.removeEventListener("resize", schedule);
|
||||
document.removeEventListener("visibilitychange", schedule);
|
||||
window.removeEventListener("scroll", onChange);
|
||||
window.removeEventListener("resize", onChange);
|
||||
document.removeEventListener("visibilitychange", onChange);
|
||||
cancelAnimationFrame(frame.current);
|
||||
queued.current = false;
|
||||
observer.current?.disconnect();
|
||||
@@ -163,7 +169,7 @@ export function ShowcaseProvider({
|
||||
observer.current?.unobserve(element);
|
||||
cards.current.delete(id);
|
||||
if (hovered.current === id) hovered.current = null;
|
||||
schedule();
|
||||
schedule(true);
|
||||
};
|
||||
},
|
||||
setHover(id, isHovered) {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/*
|
||||
* Vitrine dos projetos: moldura de navegador, capa dos cards, celular, vitrine animada (.ds-reel) e vídeo
|
||||
* ampliado (.ds-lb). Tudo pensado para a seção escura de Projetos: cantos retos, rótulos em mono, lima como
|
||||
* única cor de ação e a cor do site de cada projeto (--accent) só no brilho ao redor do vídeo.
|
||||
* Vitrine dos projetos: moldura de navegador, capa dos cards, celular, vitrine animada (.ds-reel), lista de
|
||||
* projetos (.ds-reel-list) e vídeo ampliado (.ds-lb). Tudo pensado para a seção escura de Projetos: cantos
|
||||
* retos, rótulos curtos em mono (12 px no mínimo), texto de botão em sans 15 px, lima só em detalhes (nenhum
|
||||
* botão preenchido de lima aqui: isso é da ação de contato) e a cor do site de cada projeto (--accent) só no
|
||||
* brilho ao redor do vídeo. Nenhum efeito (grão, partículas, escurecimento) fica por cima das telas.
|
||||
*/
|
||||
|
||||
/* ─────────────────────────── Moldura de navegador ─────────────────────────── */
|
||||
@@ -50,9 +52,10 @@
|
||||
margin-inline: auto;
|
||||
padding: 0 9px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
|
||||
@@ -81,8 +84,7 @@
|
||||
}
|
||||
|
||||
.ds-bframe-url {
|
||||
height: 18px;
|
||||
font-size: 9px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,14 +177,14 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink);
|
||||
background: var(--slash);
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: #fff;
|
||||
background: rgba(10, 10, 10, 0.82);
|
||||
border: 1px solid rgba(255, 255, 255, 0.4);
|
||||
opacity: 0;
|
||||
transform: translateY(6px);
|
||||
transition:
|
||||
@@ -281,7 +283,8 @@
|
||||
border-radius: 24px;
|
||||
background: #0a0a0a center top / cover no-repeat;
|
||||
|
||||
& > video {
|
||||
& > video,
|
||||
& > img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
@@ -350,11 +353,11 @@
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 0 14px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
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;
|
||||
@@ -378,8 +381,8 @@
|
||||
|
||||
&[aria-current="true"] {
|
||||
color: #fff;
|
||||
border-color: rgba(204, 255, 0, 0.55);
|
||||
background: rgba(204, 255, 0, 0.07);
|
||||
border-color: rgba(255, 255, 255, 0.6);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -391,8 +394,8 @@
|
||||
gap: 6px 10px;
|
||||
margin-bottom: var(--sp-5);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--slash);
|
||||
}
|
||||
@@ -409,7 +412,13 @@
|
||||
}
|
||||
|
||||
.ds-reel-num {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--slash);
|
||||
|
||||
&::before {
|
||||
content: attr(data-n);
|
||||
}
|
||||
}
|
||||
|
||||
.ds-reel-name {
|
||||
@@ -432,13 +441,15 @@
|
||||
padding: 0 12px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -526,13 +537,13 @@
|
||||
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;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translateY(14px);
|
||||
transition:
|
||||
opacity 0.5s ease,
|
||||
transform 0.6s var(--ease-out),
|
||||
visibility 0s linear 0.5s;
|
||||
transform 0.6s var(--ease-out);
|
||||
|
||||
&[data-state="before"] {
|
||||
transform: translateY(-14px);
|
||||
@@ -540,9 +551,8 @@
|
||||
|
||||
&[data-state="active"] {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: none;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -610,46 +620,6 @@
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
/* celular: o celular é o aparelho principal (o notebook some) e a legenda ocupa a largura toda */
|
||||
@media (max-width: 639px) {
|
||||
.ds-reel-grid {
|
||||
/* respiro para o botão fixo de preferências de analytics, que ocupa o canto de baixo */
|
||||
padding-bottom: 68px;
|
||||
}
|
||||
|
||||
.ds-reel-layer {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.ds-reel-set {
|
||||
--caption-h: 12.5rem;
|
||||
width: min(100cqw, 460px);
|
||||
height: 100%;
|
||||
margin-inline: auto;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.ds-reel-devices {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ds-reel-laptop {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ds-reel-phone {
|
||||
position: relative;
|
||||
inset: auto;
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.ds-reel-caption {
|
||||
flex: none;
|
||||
display: grid;
|
||||
@@ -692,8 +662,8 @@
|
||||
|
||||
.ds-reel-chapter-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--slash);
|
||||
}
|
||||
|
||||
@@ -718,25 +688,71 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Ver case e Ampliar vídeo em contorno; Abrir site é link de texto. Nada em lima: o preenchido é do contato. */
|
||||
.ds-reel-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
gap: var(--sp-2) var(--sp-3);
|
||||
margin-top: var(--sp-2);
|
||||
|
||||
& .ds-link.ds-link-muted {
|
||||
padding: 8px 16px;
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.38);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
& .ds-link.ds-link-muted:hover {
|
||||
border-color: #fff;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
& .ds-link.ds-link-text {
|
||||
color: rgba(255, 255, 255, 0.84);
|
||||
}
|
||||
}
|
||||
|
||||
.ds-reel-actions .ds-link-muted {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
color: #fff;
|
||||
.ds-reel-expand {
|
||||
cursor: pointer;
|
||||
|
||||
& svg {
|
||||
flex: none;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
}
|
||||
|
||||
/* tela baixa (celular deitado, janela de desktop curta): a vitrine não cabe, e os cards já mostram os vídeos */
|
||||
/* tela baixa (celular deitado, janela de desktop curta): a vitrine fixa não cabe, e a lista de projetos entra no lugar */
|
||||
@media (max-height: 560px) {
|
||||
.ds-reel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* antes de o navegador montar a página, só onde a vitrine fixa vai existir: o bloco reservado aparece e a lista
|
||||
(que o servidor entregou, com o texto e os links) fica escondida; sem JavaScript, a lista é quem aparece */
|
||||
.ds-reel-placeholder {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (min-height: 561px) and (
|
||||
prefers-reduced-motion: no-preference
|
||||
) and (scripting: enabled) {
|
||||
.ds-reel-placeholder {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ds-reel-list[data-pending] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* desktop com pouca altura: a lista de projetos e os trechos têm de caber na altura do pin */
|
||||
@media (min-width: 1024px) and (max-height: 700px) {
|
||||
.ds-reel-hint {
|
||||
@@ -765,32 +781,125 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* celular: o subtítulo sai para o aparelho ter mais tela, e o contador "02/05" já faz o papel dos tiques */
|
||||
@media (max-width: 639px) {
|
||||
.ds-reel-sub,
|
||||
.ds-reel-ticks {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* celular baixo demais: o aparelho ficaria uma cápsula ilegível */
|
||||
@media (max-width: 639px) and (max-height: 600px) {
|
||||
.ds-reel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ds-reel {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* sem JavaScript a vitrine nunca monta: o espaço reservado por ela some */
|
||||
@media (scripting: none) {
|
||||
.ds-reel-placeholder {
|
||||
/* ─────────────────────────── Lista de projetos ─────────────────────────── */
|
||||
|
||||
/*
|
||||
* Sem rolagem presa: celular, movimento reduzido e janelas baixas. No celular é um carrossel por toque
|
||||
* (rolagem horizontal com encaixe; o card seguinte aparece pela borda); acima de 768 px, uma grade.
|
||||
*/
|
||||
.ds-reel-list {
|
||||
margin-top: var(--sp-10);
|
||||
}
|
||||
|
||||
.ds-reel-items {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr));
|
||||
gap: var(--sp-8) var(--sp-6);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.ds-reel-item {
|
||||
--accent: var(--slash);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--sp-5);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ds-reel-item-media {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -4% 0;
|
||||
z-index: -1;
|
||||
background: radial-gradient(
|
||||
55% 60% at 50% 50%,
|
||||
color-mix(in srgb, var(--accent) 36%, transparent),
|
||||
transparent 72%
|
||||
);
|
||||
filter: blur(36px);
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ds-reel-item-screen {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a;
|
||||
|
||||
& > img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
object-position: top;
|
||||
}
|
||||
}
|
||||
|
||||
.ds-reel-item-phone {
|
||||
width: min(62%, 240px);
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.ds-reel-list {
|
||||
margin-top: var(--sp-8);
|
||||
}
|
||||
|
||||
.ds-reel-items {
|
||||
display: flex;
|
||||
gap: var(--sp-4);
|
||||
overflow-x: auto;
|
||||
overscroll-behavior-x: contain;
|
||||
scroll-snap-type: x mandatory;
|
||||
scroll-padding-inline: var(--sp-5);
|
||||
scrollbar-width: none;
|
||||
/* o card seguinte aparece pela borda: é o convite para deslizar */
|
||||
margin-inline: calc(var(--sp-5) * -1);
|
||||
padding: 2px var(--sp-5) var(--sp-4);
|
||||
}
|
||||
|
||||
.ds-reel-items::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.ds-reel-item {
|
||||
flex: 0 0 min(82%, 340px);
|
||||
scroll-snap-align: start;
|
||||
padding: var(--sp-5) var(--sp-4);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
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 */
|
||||
.ds-reel-item .ds-reel-sub {
|
||||
-webkit-line-clamp: 3;
|
||||
}
|
||||
}
|
||||
|
||||
/* sem JavaScript o carrossel vira lista vertical: nada fica escondido atrás de uma rolagem lateral */
|
||||
@media (scripting: none) {
|
||||
.ds-reel-items {
|
||||
display: grid;
|
||||
overflow: visible;
|
||||
margin-inline: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ds-reel-item {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─────────────────────────── Vídeo ampliado ─────────────────────────── */
|
||||
@@ -870,8 +979,8 @@ html.ds-lb-open {
|
||||
|
||||
.ds-lb-count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.14em;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--slash);
|
||||
}
|
||||
|
||||
@@ -893,11 +1002,10 @@ html.ds-lb-open {
|
||||
& button {
|
||||
min-height: 44px;
|
||||
padding: 0 16px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
@@ -905,9 +1013,11 @@ html.ds-lb-open {
|
||||
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: var(--ink);
|
||||
background: var(--slash);
|
||||
color: #fff;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
box-shadow: inset 0 -2px 0 var(--slash);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -1007,13 +1117,19 @@ html.ds-lb-open {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
color: var(--ink);
|
||||
background: var(--slash);
|
||||
border: 0;
|
||||
color: #fff;
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
cursor: pointer;
|
||||
|
||||
@media (hover: hover) {
|
||||
&:hover {
|
||||
border-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid #fff;
|
||||
outline: 2px solid var(--slash);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@@ -1028,7 +1144,7 @@ html.ds-lb-open {
|
||||
flex: none;
|
||||
min-width: 72px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
@@ -1085,14 +1201,12 @@ html.ds-lb-open {
|
||||
list-style: none;
|
||||
|
||||
& button {
|
||||
min-height: 36px;
|
||||
padding: 0 12px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
min-height: 40px;
|
||||
padding: 0 14px;
|
||||
font-family: var(--font-display);
|
||||
font-size: 15px;
|
||||
white-space: nowrap;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
background: transparent;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
cursor: pointer;
|
||||
@@ -1101,6 +1215,8 @@ html.ds-lb-open {
|
||||
border-color 0.2s ease;
|
||||
|
||||
& span {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: var(--slash);
|
||||
}
|
||||
|
||||
@@ -1113,7 +1229,7 @@ html.ds-lb-open {
|
||||
|
||||
&[aria-current="true"] {
|
||||
color: #fff;
|
||||
border-color: var(--slash);
|
||||
border-color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
@@ -1132,11 +1248,18 @@ html.ds-lb-open {
|
||||
.ds-lb-links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--sp-3);
|
||||
align-items: center;
|
||||
gap: var(--sp-2) var(--sp-3);
|
||||
|
||||
& .ds-link-muted {
|
||||
& .ds-link.ds-link-muted {
|
||||
padding: 8px 16px;
|
||||
color: #fff;
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
border-color: rgba(255, 255, 255, 0.38);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
& .ds-link.ds-link-text {
|
||||
color: rgba(255, 255, 255, 0.84);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { act, render, renderHook } from "@testing-library/react";
|
||||
import { type RefObject, useRef } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
import { MOBILE_QUERY } from "@/lib/device";
|
||||
import {
|
||||
NEAR_MARGIN,
|
||||
PINNED_QUERY,
|
||||
useNearViewport,
|
||||
usePinnedViewport,
|
||||
useSaveData,
|
||||
useViewportDevice,
|
||||
} from "./useShowcaseMedia";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base dos hooks de mídia da vitrine: aparelho (celular x desktop x sem matchMedia),
|
||||
* vitrine fixa cabe ou não, "economizar dados" e a proximidade com a janela (com e sem IntersectionObserver).
|
||||
*/
|
||||
|
||||
const originalMatchMedia = window.matchMedia;
|
||||
const OriginalObserver = global.IntersectionObserver;
|
||||
|
||||
type MediaListener = () => void;
|
||||
type FakeQuery = {
|
||||
query: string;
|
||||
matches: boolean;
|
||||
listeners: Set<MediaListener>;
|
||||
};
|
||||
let queries: FakeQuery[] = [];
|
||||
|
||||
/** matchMedia falso: `matching` diz quais consultas valem; cada chamada devolve um objeto que guarda os ouvintes. */
|
||||
function mockMedia(matching: string[]) {
|
||||
queries = [];
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => {
|
||||
const fake: FakeQuery = {
|
||||
query,
|
||||
matches: matching.includes(query),
|
||||
listeners: new Set(),
|
||||
};
|
||||
queries.push(fake);
|
||||
return {
|
||||
get matches() {
|
||||
return fake.matches;
|
||||
},
|
||||
media: query,
|
||||
addEventListener: (_type: string, listener: MediaListener) =>
|
||||
fake.listeners.add(listener),
|
||||
removeEventListener: (_type: string, listener: MediaListener) =>
|
||||
fake.listeners.delete(listener),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mockNavigator(values: Record<string, unknown>) {
|
||||
for (const [key, value] of Object.entries(values))
|
||||
Object.defineProperty(navigator, key, { configurable: true, value });
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
window.matchMedia = originalMatchMedia;
|
||||
global.IntersectionObserver = OriginalObserver;
|
||||
mockNavigator({ connection: undefined });
|
||||
});
|
||||
|
||||
describe("useViewportDevice", () => {
|
||||
it("path: server render (no effect yet) -> null, so no media file is chosen", () => {
|
||||
function Probe() {
|
||||
return <span>{String(useViewportDevice())}</span>;
|
||||
}
|
||||
|
||||
expect(renderToString(<Probe />)).toContain("null");
|
||||
});
|
||||
|
||||
it("path: browser without matchMedia -> desktop", () => {
|
||||
// @ts-expect-error simula um navegador sem matchMedia
|
||||
window.matchMedia = undefined;
|
||||
|
||||
const { result } = renderHook(() => useViewportDevice());
|
||||
|
||||
expect(result.current).toBe("desktop");
|
||||
});
|
||||
|
||||
it("path: phone width matches -> mobile", () => {
|
||||
mockMedia([MOBILE_QUERY]);
|
||||
|
||||
const { result } = renderHook(() => useViewportDevice());
|
||||
|
||||
expect(result.current).toBe("mobile");
|
||||
expect(queries.map((q) => q.query)).toEqual([MOBILE_QUERY]);
|
||||
});
|
||||
|
||||
it("path: phone width does not match -> desktop", () => {
|
||||
mockMedia([]);
|
||||
|
||||
const { result } = renderHook(() => useViewportDevice());
|
||||
|
||||
expect(result.current).toBe("desktop");
|
||||
});
|
||||
|
||||
it("path: the window is resized across the breakpoint -> follows both ways", () => {
|
||||
mockMedia([]);
|
||||
const { result } = renderHook(() => useViewportDevice());
|
||||
expect(result.current).toBe("desktop");
|
||||
|
||||
act(() => {
|
||||
queries[0].matches = true;
|
||||
for (const listener of queries[0].listeners) listener();
|
||||
});
|
||||
expect(result.current).toBe("mobile");
|
||||
|
||||
act(() => {
|
||||
queries[0].matches = false;
|
||||
for (const listener of queries[0].listeners) listener();
|
||||
});
|
||||
expect(result.current).toBe("desktop");
|
||||
});
|
||||
|
||||
it("path: unmount -> the media listener is removed", () => {
|
||||
mockMedia([]);
|
||||
const { unmount } = renderHook(() => useViewportDevice());
|
||||
expect(queries[0].listeners.size).toBe(1);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(queries[0].listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usePinnedViewport", () => {
|
||||
it("path: server render -> null until the browser mounts", () => {
|
||||
function Probe() {
|
||||
return <span>{String(usePinnedViewport())}</span>;
|
||||
}
|
||||
|
||||
expect(renderToString(<Probe />)).toContain("null");
|
||||
});
|
||||
|
||||
it("path: browser without matchMedia -> the pinned reel is allowed", () => {
|
||||
// @ts-expect-error simula um navegador sem matchMedia
|
||||
window.matchMedia = undefined;
|
||||
|
||||
const { result } = renderHook(() => usePinnedViewport());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the screen is wide and tall enough -> true, using the shared query", () => {
|
||||
mockMedia([PINNED_QUERY]);
|
||||
|
||||
const { result } = renderHook(() => usePinnedViewport());
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
expect(queries.map((q) => q.query)).toEqual([PINNED_QUERY]);
|
||||
expect(PINNED_QUERY).toBe("(min-width: 768px) and (min-height: 561px)");
|
||||
});
|
||||
|
||||
it("path: the screen is too small (phone or short window) -> false", () => {
|
||||
mockMedia([]);
|
||||
|
||||
const { result } = renderHook(() => usePinnedViewport());
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it("path: the window changes size -> follows, and unmount removes the listener", () => {
|
||||
mockMedia([]);
|
||||
const { result, unmount } = renderHook(() => usePinnedViewport());
|
||||
expect(result.current).toBe(false);
|
||||
|
||||
act(() => {
|
||||
queries[0].matches = true;
|
||||
for (const listener of queries[0].listeners) listener();
|
||||
});
|
||||
expect(result.current).toBe(true);
|
||||
|
||||
unmount();
|
||||
expect(queries[0].listeners.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useSaveData", () => {
|
||||
it.each([
|
||||
["saveData on", { saveData: true }, true],
|
||||
["saveData off", { saveData: false }, false],
|
||||
["no connection info", undefined, false],
|
||||
])("path: %s -> %s", (_label, connection, expected) => {
|
||||
mockNavigator({ connection });
|
||||
|
||||
const { result } = renderHook(() => useSaveData());
|
||||
|
||||
expect(result.current).toBe(expected);
|
||||
});
|
||||
|
||||
it("path: server render -> false (the lightest assumption is not 'save data')", () => {
|
||||
mockNavigator({ connection: { saveData: true } });
|
||||
function Probe() {
|
||||
return <span>{String(useSaveData())}</span>;
|
||||
}
|
||||
|
||||
expect(renderToString(<Probe />)).toContain("false");
|
||||
});
|
||||
});
|
||||
|
||||
describe("useNearViewport", () => {
|
||||
type Entry = { isIntersecting: boolean };
|
||||
let callbacks: Array<(entries: Entry[]) => void> = [];
|
||||
let observers: Array<{
|
||||
observe: jest.Mock;
|
||||
disconnect: jest.Mock;
|
||||
options?: IntersectionObserverInit;
|
||||
}> = [];
|
||||
|
||||
beforeEach(() => {
|
||||
callbacks = [];
|
||||
observers = [];
|
||||
global.IntersectionObserver = class {
|
||||
observe = jest.fn();
|
||||
disconnect = jest.fn();
|
||||
constructor(
|
||||
callback: (entries: Entry[]) => void,
|
||||
options?: IntersectionObserverInit,
|
||||
) {
|
||||
callbacks.push(callback);
|
||||
observers.push({
|
||||
observe: this.observe,
|
||||
disconnect: this.disconnect,
|
||||
options,
|
||||
});
|
||||
}
|
||||
} as unknown as typeof IntersectionObserver;
|
||||
});
|
||||
|
||||
function Probe({ attach = true }: { attach?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const near = useNearViewport(ref as RefObject<Element | null>);
|
||||
return (
|
||||
<div>
|
||||
{attach && <div ref={ref} data-testid="target" />}
|
||||
<output>{String(near)}</output>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
it("path: element far from the window -> false, observing it with a one-screen margin", () => {
|
||||
const view = render(<Probe />);
|
||||
|
||||
expect(view.container.querySelector("output")).toHaveTextContent("false");
|
||||
expect(observers).toHaveLength(1);
|
||||
expect(observers[0].options).toEqual({ rootMargin: NEAR_MARGIN });
|
||||
expect(observers[0].observe).toHaveBeenCalledWith(
|
||||
view.getByTestId("target"),
|
||||
);
|
||||
});
|
||||
|
||||
it("path: the observer reports nothing intersecting -> still false", () => {
|
||||
const view = render(<Probe />);
|
||||
|
||||
act(() => callbacks[0]([{ isIntersecting: false }]));
|
||||
|
||||
expect(view.container.querySelector("output")).toHaveTextContent("false");
|
||||
});
|
||||
|
||||
it("path: the element gets within a screen -> true, and it stays true when it leaves again", () => {
|
||||
const view = render(<Probe />);
|
||||
|
||||
act(() =>
|
||||
callbacks[0]([{ isIntersecting: false }, { isIntersecting: true }]),
|
||||
);
|
||||
expect(view.container.querySelector("output")).toHaveTextContent("true");
|
||||
|
||||
act(() => callbacks[0]([{ isIntersecting: false }]));
|
||||
expect(view.container.querySelector("output")).toHaveTextContent("true");
|
||||
});
|
||||
|
||||
it("path: no ref target -> no observer is created and the answer stays false", () => {
|
||||
const view = render(<Probe attach={false} />);
|
||||
|
||||
expect(observers).toHaveLength(0);
|
||||
expect(view.container.querySelector("output")).toHaveTextContent("false");
|
||||
});
|
||||
|
||||
it("path: browser without IntersectionObserver -> assumes near so the poster still loads", () => {
|
||||
// @ts-expect-error simula um navegador sem IntersectionObserver
|
||||
global.IntersectionObserver = undefined;
|
||||
|
||||
const view = render(<Probe />);
|
||||
|
||||
expect(view.container.querySelector("output")).toHaveTextContent("true");
|
||||
});
|
||||
|
||||
it("path: unmount -> the observer is disconnected", () => {
|
||||
const view = render(<Probe />);
|
||||
|
||||
view.unmount();
|
||||
|
||||
expect(observers[0].disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { type RefObject, useEffect, useState } from "react";
|
||||
import { isSaveData, MOBILE_QUERY } from "@/lib/device";
|
||||
import type { Device } from "@/lib/project-showcase";
|
||||
|
||||
/**
|
||||
* Versão de vídeo e pôster que vale para o aparelho: "mobile" abaixo de 768 px, "desktop" acima. É `null`
|
||||
* até a página montar no navegador (no servidor nenhum arquivo de mídia é escolhido: só o espaço da capa).
|
||||
*/
|
||||
export function useViewportDevice(): Device | null {
|
||||
const [device, setDevice] = useState<Device | null>(null);
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") {
|
||||
setDevice("desktop");
|
||||
return;
|
||||
}
|
||||
const query = window.matchMedia(MOBILE_QUERY);
|
||||
const update = () => setDevice(query.matches ? "mobile" : "desktop");
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
return device;
|
||||
}
|
||||
|
||||
/** Telas em que a vitrine fixa cabe: tablet ou desktop, com altura para o notebook e a legenda (showcase.css usa a mesma conta). */
|
||||
export const PINNED_QUERY = "(min-width: 768px) and (min-height: 561px)";
|
||||
|
||||
/** A vitrine fixa cabe nesta tela? `null` até montar no navegador. */
|
||||
export function usePinnedViewport(): boolean | null {
|
||||
const [pinned, setPinned] = useState<boolean | null>(null);
|
||||
useEffect(() => {
|
||||
if (typeof window.matchMedia !== "function") {
|
||||
setPinned(true);
|
||||
return;
|
||||
}
|
||||
const query = window.matchMedia(PINNED_QUERY);
|
||||
const update = () => setPinned(query.matches);
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
return pinned;
|
||||
}
|
||||
|
||||
/** "Economizar dados" ligado (lido no navegador, depois de montar): só pôsteres, nenhum vídeo baixado. */
|
||||
export function useSaveData(): boolean {
|
||||
const [saveData, setSaveData] = useState(false);
|
||||
useEffect(() => {
|
||||
setSaveData(isSaveData());
|
||||
}, []);
|
||||
return saveData;
|
||||
}
|
||||
|
||||
/** Margem em volta da janela dentro da qual uma seção conta como "perto": uma tela acima e abaixo. */
|
||||
export const NEAR_MARGIN = "100% 0px";
|
||||
|
||||
/**
|
||||
* Verdadeiro quando o elemento chegou a até ~1 tela da janela: só então o pôster dele é pedido. Depois de
|
||||
* perto, continua verdadeiro (o pôster já baixado não some e volta).
|
||||
*/
|
||||
export function useNearViewport(ref: RefObject<Element | null>): boolean {
|
||||
const [near, setNear] = useState(false);
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
if (typeof IntersectionObserver === "undefined") {
|
||||
setNear(true);
|
||||
return;
|
||||
}
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) setNear(true);
|
||||
},
|
||||
{ rootMargin: NEAR_MARGIN },
|
||||
);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, [ref]);
|
||||
return near;
|
||||
}
|
||||
Reference in new issue
Block a user