fix(navegacao): foco em âncora de outra página, vitrine acessível e 3D mais leve
Foco cai no destino ao chegar de outra página; âncoras ignoram ?tipo=; movimento reduzido salta em todos os cliques; cronômetro da janela ampliada; degradê no topo de Projetos e legendas do mergulho com intervalos testados; vitrine acima da barra de cookies e com 2 paradas por projeto; reel-list.css no global; 3D montado em tarefas curtas, limitado a ~30 qps e estático com WebGL por software; pôsteres da vitrine de 330 para 158 KB; SC Plus regravado; scripts sem funções acima de complexidade 10.
This commit is contained in:
1 parent
9e2d65a926
commit
204ebd5fa5
49 files changed
+2197
-606
No files matched your search
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type RefObject,
|
||||
@@ -47,6 +48,16 @@ const headerHeight = () => {
|
||||
return headerCache.height;
|
||||
};
|
||||
|
||||
/**
|
||||
* Larguras (CSS) em que os pôsteres aparecem, para o next/image pedir o arquivo do tamanho certo (um WebP/AVIF de
|
||||
* ~1080 px no notebook e de ~256 px no celular) e não o original de 1440 px: o notebook mede ~58vw (até ~1100 px nas
|
||||
* telas largas; 90vw abaixo de 1024 px, com a vitrine em coluna) e o celular da moldura, 22% dele.
|
||||
*/
|
||||
const LAPTOP_POSTER_SIZES =
|
||||
"(min-width: 1900px) 1100px, (min-width: 1024px) 58vw, 90vw";
|
||||
const PHONE_POSTER_SIZES =
|
||||
"(min-width: 1900px) 240px, (min-width: 1024px) 13vw, 20vw";
|
||||
|
||||
/** 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;
|
||||
@@ -519,16 +530,22 @@ function ReelLayer({
|
||||
inert={!isActive}
|
||||
>
|
||||
<BrowserFrame url={displayUrl(project)} className="ds-reel-laptop">
|
||||
<div
|
||||
className="ds-reel-screen"
|
||||
style={
|
||||
poster ? { backgroundImage: `url(${desktop.poster})` } : undefined
|
||||
}
|
||||
>
|
||||
<div className="ds-reel-screen">
|
||||
{/* o pôster é a imagem de baixo (uma só, otimizada): o vídeo, por cima, cobre-a ao carregar */}
|
||||
{poster && (
|
||||
<Image
|
||||
src={desktop.poster}
|
||||
alt=""
|
||||
width={desktop.width}
|
||||
height={desktop.height}
|
||||
sizes={LAPTOP_POSTER_SIZES}
|
||||
loading="eager"
|
||||
fetchPriority="low"
|
||||
/>
|
||||
)}
|
||||
<video
|
||||
ref={videoRef}
|
||||
src={attached ? getScrubSource(config.id) : undefined}
|
||||
poster={poster ? desktop.poster : undefined}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
@@ -541,10 +558,19 @@ function ReelLayer({
|
||||
/>
|
||||
</div>
|
||||
</BrowserFrame>
|
||||
<PhoneFrame
|
||||
className="ds-reel-phone"
|
||||
poster={poster ? phone.poster : undefined}
|
||||
/>
|
||||
<PhoneFrame className="ds-reel-phone">
|
||||
{poster && (
|
||||
<Image
|
||||
src={phone.poster}
|
||||
alt=""
|
||||
width={phone.width}
|
||||
height={phone.height}
|
||||
sizes={PHONE_POSTER_SIZES}
|
||||
loading="eager"
|
||||
fetchPriority="low"
|
||||
/>
|
||||
)}
|
||||
</PhoneFrame>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -615,7 +641,11 @@ function ReelCaption({
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
<ProjectActions item={item} onExpand={() => onExpand(config.id)} />
|
||||
<ProjectActions
|
||||
item={item}
|
||||
onExpand={() => onExpand(config.id)}
|
||||
grouped
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import { canOpenSite, ProjectActions } from "./ProjectActions";
|
||||
import { buildShowcaseItems, type ShowcaseItem } from "./ShowcaseProvider";
|
||||
|
||||
/**
|
||||
* Testes de caminhos-base de ProjectActions (CC 7) e canOpenSite (CC 2).
|
||||
* Sem `grouped` (a lista): "Ver case", "Ampliar vídeo" (só com onExpand) e "Abrir site" (só com site no ar) são três
|
||||
* paradas do Tab cada uma. Com `grouped` (a vitrine fixa): uma barra de ferramentas e UMA parada, o roving tabindex:
|
||||
* a parada é "Ver case" até o foco passar a outra ação que ainda exista; as setas e Home/End movem o foco.
|
||||
* Combinações: grouped x (com | sem onExpand) x (com | sem site).
|
||||
*/
|
||||
|
||||
jest.mock("next/link", () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
children,
|
||||
prefetch: _prefetch,
|
||||
...props
|
||||
}: { children?: React.ReactNode; prefetch?: unknown } & Record<
|
||||
string,
|
||||
unknown
|
||||
>) => <a {...props}>{children}</a>,
|
||||
}));
|
||||
|
||||
const base = buildShowcaseItems(getFeaturedProjects(null)).find(
|
||||
({ project }) => project.demoUrl,
|
||||
) as ShowcaseItem;
|
||||
|
||||
function item(overrides: Partial<ShowcaseItem["project"]>): ShowcaseItem {
|
||||
return { ...base, project: { ...base.project, ...overrides } };
|
||||
}
|
||||
|
||||
const withSite = item({
|
||||
demoUrl: "https://exemplo.com.br",
|
||||
demoAvailability: "available",
|
||||
});
|
||||
const withoutSite = item({ demoUrl: null, demoAvailability: "unknown" });
|
||||
const siteDown = item({
|
||||
demoUrl: "https://exemplo.com.br",
|
||||
demoAvailability: "unavailable",
|
||||
});
|
||||
|
||||
const link = (name: RegExp) => screen.getByRole("link", { name });
|
||||
const tabindexes = () =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>("[data-roving]")).map(
|
||||
(element) => `${element.getAttribute("data-roving")}:${element.tabIndex}`,
|
||||
);
|
||||
const press = (key: string) =>
|
||||
fireEvent.keyDown(document.activeElement as Element, { key });
|
||||
|
||||
describe("canOpenSite", () => {
|
||||
it.each([
|
||||
["site no ar", withSite, true],
|
||||
["sem endereço", withoutSite, false],
|
||||
["endereço dado como fora do ar", siteDown, false],
|
||||
])("path: %s -> %s", (_name, project, expected) => {
|
||||
expect(canOpenSite(project.project)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectActions sem grouped (a lista)", () => {
|
||||
it("path: expand and a live site -> three actions, no toolbar, no roving attributes (each is its own Tab stop)", () => {
|
||||
const { container } = render(
|
||||
<ProjectActions item={withSite} onExpand={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(link(/Ver case/)).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: /Ampliar/ })).toBeVisible();
|
||||
expect(link(/Abrir site/)).toHaveAttribute("target", "_blank");
|
||||
expect(screen.queryByRole("toolbar")).toBeNull();
|
||||
expect(container.querySelector("[data-roving]")).toBeNull();
|
||||
expect(container.querySelector("[tabindex]")).toBeNull();
|
||||
});
|
||||
|
||||
it("path: no onExpand (before the page mounts) and no site -> only 'Ver case'", () => {
|
||||
render(<ProjectActions item={withoutSite} />);
|
||||
|
||||
expect(link(/Ver case/)).toBeVisible();
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: /Abrir site/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("path: a site declared down -> no 'Abrir site' even with an address", () => {
|
||||
render(<ProjectActions item={siteDown} onExpand={jest.fn()} />);
|
||||
|
||||
expect(screen.queryByRole("link", { name: /Abrir site/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("path: the expand button runs onExpand", () => {
|
||||
const onExpand = jest.fn();
|
||||
render(<ProjectActions item={withSite} onExpand={onExpand} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Ampliar/ }));
|
||||
|
||||
expect(onExpand).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProjectActions com grouped (a vitrine fixa)", () => {
|
||||
it("path: all three actions -> one toolbar named after the project, one Tab stop ('Ver case'), the others at -1", () => {
|
||||
render(<ProjectActions item={withSite} onExpand={jest.fn()} grouped />);
|
||||
|
||||
expect(
|
||||
screen.getByRole("toolbar", {
|
||||
name: `Ações de ${withSite.project.title}`,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(tabindexes()).toEqual(["case:0", "expand:-1", "site:-1"]);
|
||||
});
|
||||
|
||||
it("path: the arrows walk the three actions in order and stop at the ends; Home and End jump; Tab is left alone", () => {
|
||||
render(<ProjectActions item={withSite} onExpand={jest.fn()} grouped />);
|
||||
const expand = screen.getByRole("button", { name: /Ampliar/ });
|
||||
|
||||
act(() => link(/Ver case/).focus());
|
||||
press("ArrowRight");
|
||||
expect(expand).toHaveFocus();
|
||||
press("ArrowRight");
|
||||
expect(link(/Abrir site/)).toHaveFocus();
|
||||
press("ArrowRight");
|
||||
expect(link(/Abrir site/)).toHaveFocus();
|
||||
press("Home");
|
||||
expect(link(/Ver case/)).toHaveFocus();
|
||||
press("End");
|
||||
expect(link(/Abrir site/)).toHaveFocus();
|
||||
// o Tab sai do grupo em vez de andar dentro dele: não é cancelado
|
||||
expect(press("Tab")).toBe(true);
|
||||
});
|
||||
|
||||
it("path: the Tab stop follows the last focused action, so Shift+Tab and Tab come back to it", () => {
|
||||
render(<ProjectActions item={withSite} onExpand={jest.fn()} grouped />);
|
||||
|
||||
act(() => link(/Abrir site/).focus());
|
||||
|
||||
expect(tabindexes()).toEqual(["case:-1", "expand:-1", "site:0"]);
|
||||
});
|
||||
|
||||
it("path: no site -> the toolbar has two actions and the arrows never reach a missing 'Abrir site'", () => {
|
||||
render(<ProjectActions item={withoutSite} onExpand={jest.fn()} grouped />);
|
||||
|
||||
expect(tabindexes()).toEqual(["case:0", "expand:-1"]);
|
||||
act(() => screen.getByRole("button", { name: /Ampliar/ }).focus());
|
||||
press("ArrowRight");
|
||||
expect(screen.getByRole("button", { name: /Ampliar/ })).toHaveFocus();
|
||||
});
|
||||
|
||||
it("path: no onExpand and no site -> a toolbar of one action, which is the Tab stop", () => {
|
||||
render(<ProjectActions item={withoutSite} grouped />);
|
||||
|
||||
expect(tabindexes()).toEqual(["case:0"]);
|
||||
expect(screen.getByRole("toolbar")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("path: the stop was on 'Abrir site' and the next render has no site -> back to 'Ver case', never a missing stop", () => {
|
||||
const { rerender } = render(
|
||||
<ProjectActions item={withSite} onExpand={jest.fn()} grouped />,
|
||||
);
|
||||
act(() => link(/Abrir site/).focus());
|
||||
expect(tabindexes()).toContain("site:0");
|
||||
|
||||
rerender(
|
||||
<ProjectActions item={withoutSite} onExpand={jest.fn()} grouped />,
|
||||
);
|
||||
|
||||
expect(tabindexes()).toEqual(["case:0", "expand:-1"]);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import type { ShowcaseItem } from "./ShowcaseProvider";
|
||||
import { useRovingTabindex } from "./useRovingTabindex";
|
||||
|
||||
/** O site publicado abre em nova aba quando existe e não foi dado como fora do ar. */
|
||||
export function canOpenSite(project: ShowcaseItem["project"]) {
|
||||
@@ -10,19 +13,49 @@ export function canOpenSite(project: ShowcaseItem["project"]) {
|
||||
* 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.
|
||||
*
|
||||
* Com `grouped`, as ações são UMA parada no Tab (barra de ferramentas com roving tabindex: as setas e Home/End movem o
|
||||
* foco entre elas): na vitrine fixa só o projeto em cena existe, e sem isto eram três paradas a mais por projeto.
|
||||
*/
|
||||
export function ProjectActions({
|
||||
item,
|
||||
onExpand,
|
||||
grouped = false,
|
||||
}: {
|
||||
item: ShowcaseItem;
|
||||
/** Sem a janela ampliada (antes de o navegador montar a página) o botão nem aparece. */
|
||||
onExpand?: () => void;
|
||||
/** As ações viram uma parada só no Tab (a vitrine fixa); na lista, cada uma é a sua. */
|
||||
grouped?: boolean;
|
||||
}) {
|
||||
const { project } = item;
|
||||
const site = canOpenSite(project);
|
||||
// só as ações que existem podem ser a parada do grupo (um projeto sem site não tem "Abrir site")
|
||||
const present = [
|
||||
"case",
|
||||
...(onExpand ? ["expand"] : []),
|
||||
...(site ? ["site"] : []),
|
||||
];
|
||||
const { groupProps, itemProps } = useRovingTabindex("case", (key) =>
|
||||
present.includes(key),
|
||||
);
|
||||
const roving = (key: string) => (grouped ? itemProps(key) : {});
|
||||
return (
|
||||
<div className="ds-reel-actions">
|
||||
<Link className="ds-link ds-link-muted" href={`/cases/${project.id}`}>
|
||||
<div
|
||||
className="ds-reel-actions"
|
||||
{...(grouped
|
||||
? {
|
||||
role: "toolbar",
|
||||
"aria-label": `Ações de ${project.title}`,
|
||||
...groupProps,
|
||||
}
|
||||
: {})}
|
||||
>
|
||||
<Link
|
||||
className="ds-link ds-link-muted"
|
||||
href={`/cases/${project.id}`}
|
||||
{...roving("case")}
|
||||
>
|
||||
Ver case
|
||||
<span className="sr-only"> de {project.title}</span>
|
||||
</Link>
|
||||
@@ -32,6 +65,7 @@ export function ProjectActions({
|
||||
className="ds-link ds-link-muted ds-reel-expand"
|
||||
onClick={onExpand}
|
||||
aria-haspopup="dialog"
|
||||
{...roving("expand")}
|
||||
>
|
||||
<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" />
|
||||
@@ -41,12 +75,13 @@ export function ProjectActions({
|
||||
<span className="sr-only"> de {project.title}</span>
|
||||
</button>
|
||||
)}
|
||||
{canOpenSite(project) && (
|
||||
{site && (
|
||||
<a
|
||||
className="ds-link ds-link-text"
|
||||
href={project.demoUrl ?? undefined}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
{...roving("site")}
|
||||
>
|
||||
Abrir site
|
||||
<span className="sr-only">
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { getFeaturedProjects } from "@/lib/portfolio-data";
|
||||
import { getShowcaseConfig, mobileFractionAt } from "@/lib/project-showcase";
|
||||
import { ShowcaseProvider, useShowcaseOpen } from "./ShowcaseProvider";
|
||||
|
||||
/**
|
||||
* Caminhos-base de readMetadata (CC 3) e do efeito de `readyState` no ProjectLightbox (complemento de
|
||||
* ProjectLightbox.test.tsx): o vídeo que já chega com os metadados (cache, preload) não dispara `loadedmetadata`
|
||||
* depois de montado, então o efeito lê a duração e aplica o ponto pendente da troca de aparelho.
|
||||
* O que o teste principal já cobre: pronto na abertura sem ponto pendente; troca de aparelho com o evento.
|
||||
* Aqui: troca de aparelho com o vídeo novo JÁ pronto (o ponto pendente é aplicado sem evento algum), troca de
|
||||
* projeto com o vídeo novo já pronto (sem ponto pendente, o vídeo começa do zero), e vídeo ainda sem metadados.
|
||||
*/
|
||||
|
||||
jest.mock("./ProjectLightboxLazy", () => ({
|
||||
ProjectLightboxLazy: jest.requireActual("./ProjectLightbox").ProjectLightbox,
|
||||
}));
|
||||
jest.mock("@/app/hooks/useReducedMotion", () => ({
|
||||
useReducedMotion: () => false,
|
||||
}));
|
||||
|
||||
const DURATION = 30;
|
||||
const lavacao = getShowcaseConfig("lavacao-047");
|
||||
const projects = getFeaturedProjects(null);
|
||||
/** O ponto de cada <video> (jsdom não tem reprodução). */
|
||||
const positions = new WeakMap<object, number>();
|
||||
let readyState = 0;
|
||||
|
||||
function Card({ id }: { id: string }) {
|
||||
const openProject = useShowcaseOpen();
|
||||
return (
|
||||
<button type="button" onClick={() => openProject(id)}>
|
||||
ampliar {id}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function open(id = "lavacao-047") {
|
||||
render(
|
||||
<ShowcaseProvider projects={projects}>
|
||||
<Card id="sev-modaintima" />
|
||||
<Card id="lavacao-047" />
|
||||
<Card id="nutriscan" />
|
||||
<Card id="as-servicos" />
|
||||
</ShowcaseProvider>,
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: `ampliar ${id}` }));
|
||||
return screen.getByRole("dialog");
|
||||
}
|
||||
|
||||
const video = (dialog: HTMLElement) =>
|
||||
dialog.querySelector("video") as HTMLVideoElement;
|
||||
|
||||
beforeAll(() => {
|
||||
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
}));
|
||||
HTMLDialogElement.prototype.showModal = function showModal(
|
||||
this: HTMLDialogElement,
|
||||
) {
|
||||
this.setAttribute("open", "");
|
||||
};
|
||||
HTMLDialogElement.prototype.close = function close(this: HTMLDialogElement) {
|
||||
this.removeAttribute("open");
|
||||
this.dispatchEvent(new Event("close"));
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
readyState = 0;
|
||||
jest
|
||||
.spyOn(HTMLMediaElement.prototype, "readyState", "get")
|
||||
.mockImplementation(() => readyState);
|
||||
jest
|
||||
.spyOn(HTMLMediaElement.prototype, "duration", "get")
|
||||
.mockImplementation(() => DURATION);
|
||||
jest
|
||||
.spyOn(HTMLMediaElement.prototype, "currentTime", "get")
|
||||
.mockImplementation(function currentTime(this: HTMLMediaElement) {
|
||||
return positions.get(this) ?? 0;
|
||||
});
|
||||
jest
|
||||
.spyOn(HTMLMediaElement.prototype, "currentTime", "set")
|
||||
.mockImplementation(function currentTime(
|
||||
this: HTMLMediaElement,
|
||||
value: number,
|
||||
) {
|
||||
positions.set(this, value);
|
||||
});
|
||||
jest
|
||||
.spyOn(HTMLMediaElement.prototype, "play")
|
||||
.mockImplementation(() => Promise.resolve());
|
||||
jest.spyOn(HTMLMediaElement.prototype, "pause").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
document.documentElement.classList.remove("ds-lb-open");
|
||||
});
|
||||
|
||||
describe("ProjectLightbox: vídeo que já chega com os metadados", () => {
|
||||
it("path: switching device and the new video is already ready -> the pending point is applied without any loadedmetadata event", () => {
|
||||
if (!lavacao) throw new Error("config ausente");
|
||||
const dialog = open();
|
||||
const desktop = video(dialog);
|
||||
positions.set(desktop, DURATION * 0.5);
|
||||
|
||||
readyState = 1;
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
|
||||
|
||||
const phone = video(dialog);
|
||||
expect(phone).not.toBe(desktop);
|
||||
expect(positions.get(phone)).toBeCloseTo(
|
||||
mobileFractionAt(lavacao, 0.5) * DURATION,
|
||||
5,
|
||||
);
|
||||
expect(dialog.querySelector(".ds-lb-time")?.textContent).toMatch(
|
||||
/ \/ 0:30$/,
|
||||
);
|
||||
});
|
||||
|
||||
it("path: switching device, the new video is NOT ready yet -> nothing is applied until loadedmetadata, then the point is applied once", () => {
|
||||
if (!lavacao) throw new Error("config ausente");
|
||||
const dialog = open();
|
||||
positions.set(video(dialog), DURATION * 0.5);
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Celular" }));
|
||||
const phone = video(dialog);
|
||||
expect(positions.has(phone)).toBe(false);
|
||||
|
||||
act(() => {
|
||||
phone.dispatchEvent(new Event("loadedmetadata"));
|
||||
});
|
||||
const applied = positions.get(phone);
|
||||
expect(applied).toBeCloseTo(mobileFractionAt(lavacao, 0.5) * DURATION, 5);
|
||||
|
||||
// um segundo loadedmetadata (o vídeo recarrega) não puxa o ponto de volta: o pendente já foi consumido
|
||||
positions.set(phone, 7);
|
||||
act(() => {
|
||||
phone.dispatchEvent(new Event("loadedmetadata"));
|
||||
});
|
||||
expect(positions.get(phone)).toBe(7);
|
||||
});
|
||||
|
||||
it("path: moving to another project whose video is already ready -> it starts from zero, with the duration shown", () => {
|
||||
const dialog = open("lavacao-047");
|
||||
positions.set(video(dialog), DURATION * 0.5);
|
||||
|
||||
readyState = 1;
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: /próximo/i }));
|
||||
|
||||
expect(positions.has(video(dialog))).toBe(false);
|
||||
expect(dialog.querySelector(".ds-lb-time")?.textContent).toMatch(
|
||||
/^0:00 \/ 0:30$/,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -204,6 +204,50 @@ describe("ProjectLightbox", () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the duration when the video already has its metadata at opening (loadedmetadata never fires again)", () => {
|
||||
jest
|
||||
.spyOn(HTMLMediaElement.prototype, "readyState", "get")
|
||||
.mockReturnValue(1);
|
||||
jest
|
||||
.spyOn(HTMLMediaElement.prototype, "duration", "get")
|
||||
.mockReturnValue(75);
|
||||
|
||||
const dialog = setup();
|
||||
|
||||
expect(dialog.querySelector(".ds-lb-time")?.textContent).toBe(
|
||||
"0:00 / 1:15",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps 0:00 / 0:00 until the metadata is there, then reads the duration from the playback tick", () => {
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb) => {
|
||||
frames.push(cb);
|
||||
return frames.length;
|
||||
});
|
||||
const dialog = setup();
|
||||
expect(dialog.querySelector(".ds-lb-time")?.textContent).toBe(
|
||||
"0:00 / 0:00",
|
||||
);
|
||||
|
||||
const video = dialog.querySelector("video") as HTMLVideoElement;
|
||||
Object.defineProperty(video, "duration", {
|
||||
configurable: true,
|
||||
value: 42,
|
||||
});
|
||||
// sem evento algum: quem lê é o marcador, quadro a quadro, enquanto o vídeo toca
|
||||
act(() => {
|
||||
video.dispatchEvent(new Event("play"));
|
||||
});
|
||||
act(() => {
|
||||
for (const frame of frames.splice(0)) frame(0);
|
||||
});
|
||||
|
||||
expect(dialog.querySelector(".ds-lb-time")?.textContent).toBe(
|
||||
"0:00 / 0:42",
|
||||
);
|
||||
});
|
||||
|
||||
it("seeks with the range slider", () => {
|
||||
const dialog = setup();
|
||||
const video = loadVideo(dialog, 30);
|
||||
|
||||
@@ -76,6 +76,23 @@ export function ProjectLightbox({
|
||||
const [fraction, setFraction] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
|
||||
// lê a duração e aplica o ponto pendente da troca de aparelho; vale para o `loadedmetadata` e para o vídeo que já
|
||||
// chegou com os metadados (cache, preload): nesse caso o evento passou antes de alguém ouvir e não volta
|
||||
const readMetadata = useCallback(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
setDuration(video.duration);
|
||||
if (pendingFraction.current !== null) {
|
||||
video.currentTime = pendingFraction.current * video.duration;
|
||||
pendingFraction.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: o <video> é outro elemento a cada projeto/aparelho (key)
|
||||
useEffect(() => {
|
||||
if ((videoRef.current?.readyState ?? 0) >= 1) readMetadata();
|
||||
}, [config.id, device, readMetadata]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!reducedMotion) return;
|
||||
wantsToPlay.current = false;
|
||||
@@ -108,8 +125,10 @@ export function ProjectLightbox({
|
||||
let id = 0;
|
||||
const tick = () => {
|
||||
const current = videoRef.current;
|
||||
if (current?.duration)
|
||||
if (current?.duration) {
|
||||
setDuration(current.duration);
|
||||
setFraction(current.currentTime / current.duration);
|
||||
}
|
||||
id = requestAnimationFrame(tick);
|
||||
};
|
||||
id = requestAnimationFrame(tick);
|
||||
@@ -157,16 +176,6 @@ export function ProjectLightbox({
|
||||
onNavigate(next);
|
||||
};
|
||||
|
||||
const handleMetadata = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
setDuration(video.duration);
|
||||
if (pendingFraction.current !== null) {
|
||||
video.currentTime = pendingFraction.current * video.duration;
|
||||
pendingFraction.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleTime = () => {
|
||||
const video = videoRef.current;
|
||||
if (video?.duration) setFraction(video.currentTime / video.duration);
|
||||
@@ -219,7 +228,7 @@ export function ProjectLightbox({
|
||||
poster={assets.poster}
|
||||
width={assets.width}
|
||||
height={assets.height}
|
||||
onLoadedMetadata={handleMetadata}
|
||||
onLoadedMetadata={readMetadata}
|
||||
onPlay={() => setPlaying(true)}
|
||||
onPause={() => setPlaying(false)}
|
||||
onTimeUpdate={handleTime}
|
||||
|
||||
@@ -124,7 +124,8 @@ describe("ProjectList", () => {
|
||||
expect(decodeURIComponent(image.getAttribute("src") ?? "")).toContain(
|
||||
"mobile.webp",
|
||||
);
|
||||
expect(image).toHaveAttribute("sizes", "(max-width: 767px) 70vw");
|
||||
// o celular da moldura mede ~160 px no carrossel: o pôster de 390 px não viaja inteiro
|
||||
expect(image).toHaveAttribute("sizes", "160px");
|
||||
});
|
||||
|
||||
it("path: an item that is not near yet keeps its frame but no image", () => {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { PhoneFrame } from "./PhoneFrame";
|
||||
import { ProjectActions } from "./ProjectActions";
|
||||
import { type ShowcaseItem, useShowcaseOpen } from "./ShowcaseProvider";
|
||||
import { useNearViewport } from "./useShowcaseMedia";
|
||||
import "./reel-list.css";
|
||||
|
||||
/**
|
||||
* Qual cartão do carrossel está à frente: o índice do cartão cujo início está mais perto do começo da faixa (no fim da
|
||||
@@ -87,7 +86,8 @@ function ListItem({
|
||||
alt={`Captura de tela de ${project.title}`}
|
||||
width={assets.width}
|
||||
height={assets.height}
|
||||
sizes={device === "mobile" ? "(max-width: 767px) 70vw" : "50vw"}
|
||||
// o celular da moldura mede ~160 px no carrossel (o pôster de 390 px viria inteiro para 160 px de tela)
|
||||
sizes={device === "mobile" ? "160px" : "50vw"}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
|
||||
@@ -44,6 +44,10 @@ function mockViewport({ phone = false }: { phone?: boolean } = {}) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** O arquivo do pôster que a imagem dentro de `element` pede (decodificado); "" sem imagem. */
|
||||
const posterIn = (element: Element | null | undefined) =>
|
||||
decodeURIComponent(element?.querySelector("img")?.getAttribute("src") ?? "");
|
||||
|
||||
const projects = getFeaturedProjects(null);
|
||||
const weights = [
|
||||
"sev-modaintima",
|
||||
@@ -294,10 +298,9 @@ describe("ProjectReel", () => {
|
||||
// o celular da moldura só tem o pôster: nenhum vídeo dentro dele
|
||||
expect(layer.querySelector(".ds-phone")).toBeInTheDocument();
|
||||
expect(layer.querySelector(".ds-phone video")).toBeNull();
|
||||
expect(
|
||||
(layer.querySelector(".ds-phone-screen") as HTMLElement).style
|
||||
.backgroundImage,
|
||||
).toContain("lavacao-047/mobile.webp");
|
||||
expect(posterIn(layer.querySelector(".ds-phone-screen"))).toContain(
|
||||
"lavacao-047/mobile.webp",
|
||||
);
|
||||
});
|
||||
|
||||
it("names the chapter on screen and counts them", () => {
|
||||
@@ -412,17 +415,48 @@ describe("ProjectReel", () => {
|
||||
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("");
|
||||
const poster = (index: number) =>
|
||||
posterIn(document.querySelectorAll(".ds-reel-screen")[index]);
|
||||
expect(poster(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("");
|
||||
expect(poster(0)).toContain("sev-modaintima/desktop.webp");
|
||||
expect(poster(1)).toContain("lavacao-047/desktop.webp");
|
||||
expect(poster(3)).toBe("");
|
||||
expect(poster(4)).toBe("");
|
||||
});
|
||||
|
||||
it("downloads each poster once, as an optimized image sized for where it shows, never the 1440 px original twice", () => {
|
||||
renderReel();
|
||||
scrollTo(0);
|
||||
|
||||
// nenhum pôster vai por imagem de fundo nem pelo atributo do vídeo (cada um era uma requisição do arquivo original)
|
||||
for (const screen of document.querySelectorAll<HTMLElement>(
|
||||
".ds-reel-screen, .ds-phone-screen",
|
||||
))
|
||||
expect(screen.style.backgroundImage).toBe("");
|
||||
expect(document.querySelectorAll("video[poster]")).toHaveLength(0);
|
||||
const posters = [...document.querySelectorAll(".ds-reel img")];
|
||||
// o do projeto em cena e o do vizinho, no notebook e no celular
|
||||
expect(posters).toHaveLength(4);
|
||||
for (const image of posters) {
|
||||
// otimizado (next/image: o arquivo é redimensionado pelo servidor) e com o tamanho em que aparece
|
||||
expect(image.getAttribute("src")).toContain("/_next/image");
|
||||
expect(image.getAttribute("sizes")).toBeTruthy();
|
||||
expect(image).toHaveAttribute("alt", "");
|
||||
}
|
||||
const laptops = posters.filter((image) => image.closest(".ds-reel-screen"));
|
||||
const phones = posters.filter((image) => image.closest(".ds-phone-screen"));
|
||||
expect(laptops).toHaveLength(2);
|
||||
expect(phones).toHaveLength(2);
|
||||
for (const image of laptops)
|
||||
expect(image.getAttribute("sizes")).toContain("58vw");
|
||||
for (const image of phones)
|
||||
expect(image.getAttribute("sizes")).toContain("13vw");
|
||||
// cada pôster é uma URL só (não há dois pedidos do mesmo arquivo)
|
||||
const urls = posters.map((image) => image.getAttribute("src"));
|
||||
expect(new Set(urls).size).toBe(urls.length);
|
||||
});
|
||||
|
||||
it("respects the data saver: no scroll video is downloaded", () => {
|
||||
@@ -437,10 +471,9 @@ describe("ProjectReel", () => {
|
||||
});
|
||||
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");
|
||||
expect(posterIn(document.querySelectorAll(".ds-reel-screen")[0])).toContain(
|
||||
"desktop.webp",
|
||||
);
|
||||
});
|
||||
|
||||
it("gives the server the project list (text and links, no media) and holds the pinned height before mounting", () => {
|
||||
@@ -790,11 +823,81 @@ describe("ProjectReel: teclado do índice (roving tabindex)", () => {
|
||||
expect(window.scrollTo).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the caption actions as their own tab stops (Ver case, Ampliar vídeo, Abrir site)", () => {
|
||||
renderReel();
|
||||
const actions = [...caption().querySelectorAll("a, button")].filter(
|
||||
(element) => element.getAttribute("tabindex") !== "-1",
|
||||
);
|
||||
expect(actions.length).toBeGreaterThanOrEqual(2);
|
||||
describe("the caption actions", () => {
|
||||
const tabStopsAll = (root: Element) => [
|
||||
...root.querySelectorAll<HTMLElement>("a[href], button"),
|
||||
];
|
||||
const tabStops = (root: Element) =>
|
||||
tabStopsAll(root).filter(
|
||||
(element) => element.getAttribute("tabindex") !== "-1",
|
||||
);
|
||||
|
||||
it("are ONE tab stop (Ver case, Ampliar vídeo and Abrir site as a toolbar), so a project costs two stops with the index", () => {
|
||||
renderReel();
|
||||
const toolbar = within(caption()).getByRole("toolbar", {
|
||||
name: "Ações de SEV Moda Íntima",
|
||||
});
|
||||
|
||||
expect(tabStopsAll(toolbar)).toHaveLength(3);
|
||||
expect(tabStops(toolbar)).toHaveLength(1);
|
||||
expect(tabStops(toolbar)[0]).toHaveAccessibleName(/Ver case/);
|
||||
// o índice (uma parada) + as ações (uma parada): a vitrine inteira custa duas paradas, não oito
|
||||
const reel = document.querySelector(".ds-reel") as HTMLElement;
|
||||
const stops = tabStops(reel).filter(
|
||||
(element) => !element.closest("[inert]"),
|
||||
);
|
||||
expect(stops).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keep Ampliar vídeo and Abrir site reachable with the arrows, and Enter on Ampliar opens it", () => {
|
||||
renderReel();
|
||||
const toolbar = within(caption()).getByRole("toolbar");
|
||||
act(() => {
|
||||
tabStops(toolbar)[0].focus();
|
||||
});
|
||||
|
||||
fireEvent.keyDown(document.activeElement as Element, {
|
||||
key: "ArrowRight",
|
||||
});
|
||||
expect(document.activeElement).toHaveAccessibleName(/Ampliar vídeo/);
|
||||
// a parada do Tab foi para onde o foco está
|
||||
expect(tabStops(toolbar)).toEqual([document.activeElement]);
|
||||
|
||||
fireEvent.keyDown(document.activeElement as Element, {
|
||||
key: "ArrowRight",
|
||||
});
|
||||
expect(document.activeElement).toHaveAccessibleName(/Abrir site/);
|
||||
fireEvent.keyDown(document.activeElement as Element, { key: "End" });
|
||||
expect(document.activeElement).toHaveAccessibleName(/Abrir site/);
|
||||
fireEvent.keyDown(document.activeElement as Element, { key: "Home" });
|
||||
expect(document.activeElement).toHaveAccessibleName(/Ver case/);
|
||||
|
||||
fireEvent.keyDown(document.activeElement as Element, {
|
||||
key: "ArrowRight",
|
||||
});
|
||||
fireEvent.click(document.activeElement as Element);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "SEV Moda Íntima" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("follow a project without a site: two actions, and the stop never lands on the missing one", () => {
|
||||
renderReel();
|
||||
const toolbar = () => within(caption()).getByRole("toolbar");
|
||||
act(() => {
|
||||
tabStops(toolbar())[0].focus();
|
||||
});
|
||||
fireEvent.keyDown(document.activeElement as Element, { key: "End" });
|
||||
expect(document.activeElement).toHaveAccessibleName(/Abrir site/);
|
||||
|
||||
scrollTo(1); // AS Serviços: sem site
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(CAPTION_OUT_MS + 40);
|
||||
});
|
||||
|
||||
expect(caption()).toHaveTextContent("AS Serviços");
|
||||
expect(tabStopsAll(toolbar())).toHaveLength(2);
|
||||
expect(tabStops(toolbar())).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import { PinnedReelLazy } from "./PinnedReelLazy";
|
||||
import { ProjectList } from "./ProjectList";
|
||||
import { type ShowcaseItem, useShowcaseItems } from "./ShowcaseProvider";
|
||||
import { usePinnedViewport, useViewportDevice } from "./useShowcaseMedia";
|
||||
import "./reel-list.css";
|
||||
|
||||
/**
|
||||
* O bloco que segura o lugar da vitrine fixa, com a mesma altura dela: a página não muda de tamanho quando ela
|
||||
|
||||
@@ -142,8 +142,18 @@ describe("carregamento sob demanda", () => {
|
||||
// e o CSS de cada um viaja com o componente dele, não com a página
|
||||
expect(read("PinnedReel.tsx")).toMatch(/import "\.\/pinned-reel\.css"/);
|
||||
expect(read("ProjectLightbox.tsx")).toMatch(/import "\.\/lightbox\.css"/);
|
||||
// só as linhas de import contam: o comentário do layout pode citar os arquivos pelo nome
|
||||
expect(
|
||||
fs.readFileSync(path.join(__dirname, "../../layout.tsx"), "utf8"),
|
||||
).not.toMatch(/pinned-reel\.css|lightbox\.css|reel-list\.css/);
|
||||
).not.toMatch(
|
||||
/^\s*import\s+["'][^"']*(pinned-reel|lightbox|reel-list)\.css["']/m,
|
||||
);
|
||||
// a lista de projetos e o casco da vitrine (reel-list.css) vêm dentro do global: a home não carrega uma terceira
|
||||
// folha bloqueante, e nenhum componente a importa
|
||||
expect(read("ProjectReel.tsx")).not.toMatch(/reel-list\.css/);
|
||||
expect(read("ProjectList.tsx")).not.toMatch(/reel-list\.css/);
|
||||
expect(
|
||||
fs.readFileSync(path.join(__dirname, "../../globals.css"), "utf8"),
|
||||
).toMatch(/@import "\.\/components\/showcase\/reel-list\.css"/);
|
||||
});
|
||||
});
|
||||
@@ -220,6 +220,9 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 0;
|
||||
/* a barra de cookies (fixa na base da janela, na primeira visita) não cobre a legenda e os botões: o palco perde a
|
||||
altura dela (--cookie-bar-height, publicada por GoogleAnalytics enquanto a barra está aberta) */
|
||||
margin-bottom: var(--cookie-bar-height, 0px);
|
||||
/* o palco mede a si mesmo: o conjunto cresce até o limite da largura ou da altura que sobrar */
|
||||
container-type: size;
|
||||
}
|
||||
@@ -286,8 +289,10 @@
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 10;
|
||||
overflow: hidden;
|
||||
background: #0a0a0a center top / cover no-repeat;
|
||||
background: #0a0a0a;
|
||||
|
||||
/* o pôster (imagem otimizada) fica embaixo; o vídeo, depois dele no DOM, cobre-o ao carregar */
|
||||
& > img,
|
||||
& > video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/*
|
||||
* Lista de projetos e o que a vitrine fixa divide com ela (legenda, título, botões), mais o casco `.ds-reel` e o
|
||||
* bloco que segura o lugar da vitrine antes de o navegador montar a página. Carrega com ProjectReel e ProjectList
|
||||
* (a home). A vitrine fixa (pinned-reel.css) e a janela ampliada (lightbox.css) vêm sob demanda, com seus
|
||||
* bloco que segura o lugar da vitrine antes de o navegador montar a página. Vem dentro do global (app/globals.css o
|
||||
* importa), para a home não carregar uma terceira folha bloqueante; nenhum componente a importa. A vitrine fixa
|
||||
* (pinned-reel.css) e a janela ampliada (lightbox.css) vêm sob demanda, com seus
|
||||
* componentes; as molduras de navegador e de celular, que o hero e os cases também usam, ficam em showcase.css
|
||||
* (global). 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
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
height: 20px;
|
||||
margin-inline: auto;
|
||||
padding: 0 9px;
|
||||
font-family: var(--font-mono);
|
||||
/* na barra de endereço do hero (primeira dobra): monoespacada do sistema, sem baixar a Geist Mono na carga */
|
||||
font-family: var(--font-mono-system);
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
Reference in new issue
Block a user