perf(movimento): sem tempo morto, vídeos regravados e 3D sem React Three Fiber

Legenda do mergulho até Projetos cobrir a cena; transição de página sem blur e com fundo escuro; foco no conteúdo ao trocar de página; listas com marcador e dois pesos de título; vídeos da SEV e da SC Plus regravados sem quadros brancos; scrub a 12-15 fps (-30%); vitrine com uma parada de Tab e setas, contador no celular e CSS sob demanda; âncoras num módulo só (fragment-nav); FramePulse com setInterval; CSS morto removido.
This commit is contained in:
Evandro Fonseca Junior committed 2026-10-14 17:01:37 -03:00
1 parent f8545cd442
commit 6ac87824f3
68 files changed
+5735 -3300

No files matched your search

+621
View File
@@ -0,0 +1,621 @@
"use client";
import {
type CSSProperties,
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
chapterFraction,
chapterIndexAt,
getScrubSource,
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 {
CAPTION_OUT_MS,
easeToward,
locateInReel,
REEL_HOLD,
reelOffset,
videoProgress,
} from "./showcase-math";
import { useRovingTabindex } from "./useRovingTabindex";
import { useSaveData } from "./useShowcaseMedia";
import "./pinned-reel.css";
// 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 };
const headerHeight = () => {
if (headerCache.width !== window.innerWidth)
headerCache = {
width: window.innerWidth,
height:
Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
"--site-header-height",
),
) || 60,
};
return headerCache.height;
};
/** 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;
const target = Math.min(
video.duration - 0.001,
Math.max(0, fraction * video.duration),
);
if (!video.seeking && Math.abs(video.currentTime - target) > 1 / 48)
video.currentTime = target;
}
/** Alguns navegadores móveis só desenham o primeiro quadro de um vídeo que já tocou uma vez. */
function prime(video: HTMLVideoElement) {
if (video.dataset.primed) return;
video.dataset.primed = "true";
video
.play()
.then(() => video.pause())
.catch(() => {});
}
let stopScroll = () => {};
/** Rola até `top` em menos de um segundo; qualquer gesto do usuário interrompe. */
function scrollToSmoothly(top: number) {
stopScroll();
const from = window.scrollY;
const distance = top - from;
const duration = Math.min(900, 380 + Math.abs(distance) * 0.12);
const started = performance.now();
let id = 0;
const stop = () => {
cancelAnimationFrame(id);
window.removeEventListener("wheel", stop);
window.removeEventListener("touchstart", stop);
};
stopScroll = stop;
window.addEventListener("wheel", stop, { passive: true });
window.addEventListener("touchstart", stop, { passive: true });
const step = (now: number) => {
const t = Math.min(1, (now - started) / duration);
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
// "instant": o CSS global tem scroll-behavior: smooth e cada quadro já é um passo da animação
window.scrollTo({ top: from + distance * eased, behavior: "instant" });
if (t < 1) id = requestAnimationFrame(step);
else stop();
};
id = requestAnimationFrame(step);
}
const layerState = (index: number, active: number) => {
if (index === active) return "active";
return index < active ? "before" : "after";
};
/** Estado do traço de capítulo: ainda por vir, o de agora ou já visto. */
const tickState = (tick: number, current: number) => {
if (tick > current) return "todo";
return tick === current ? "now" : "done";
};
/** Progresso (0 a 1) de cada vídeo quando o projeto `index` está na fração `local` do trecho dele. */
function targetsAt(count: number, index: number, local: number) {
return Array.from({ length: count }, (_, i) => {
if (i < index) return 1;
return i === index ? videoProgress(local) : 0;
});
}
/** Onde a rolagem está na vitrine: projeto em cena, fração dele e se ela está a ~1 tela da janela. Só lê o DOM. */
function readReel(container: HTMLElement, pin: HTMLElement, weights: number[]) {
const rect = container.getBoundingClientRect();
const scrollable = rect.height - pin.offsetHeight;
const progress =
scrollable > 0 ? (headerHeight() - rect.top) / scrollable : 0;
const { index, local } = locateInReel(weights, progress);
// a ~1 tela de distância (acima ou abaixo): é quando pôsteres e vídeo da vitrine começam a ser pedidos
const near =
rect.top < window.innerHeight && rect.bottom > -window.innerHeight;
return { index, local, near };
}
/**
* Vitrine animada: o site de cada projeto rola dentro de um notebook (e de um celular ao lado) conforme a
* página rola. A cena fica presa (position: sticky) durante a rolagem de todos os projetos; cada projeto tem
* um trecho com uma pausa no começo e outra no fim. Capítulos nomeiam o que está na tela; o celular é só um
* pôster da mesma tela (o vídeo de celular é do celular: aqui ele nem é baixado).
*
* Os aparelhos de cada projeto são uma camada (as camadas trocam por fade cruzado); a legenda (nome, trecho,
* contador e botões) é UM bloco só, fora das camadas, que sai e entra sem nunca existir em dobro.
*
* Mídia: só o projeto em cena baixa vídeo (os vizinhos ficam no pôster), e só quando a vitrine está a ~1 tela
* da janela; com "economizar dados" ninguém baixa vídeo.
*/
export default function PinnedReel({ items }: { items: ShowcaseItem[] }) {
const openProject = useShowcaseOpen();
const [active, setActive] = useState(0);
const [chapter, setChapter] = useState(0);
const [near, setNear] = useState(false);
const [settled, setSettled] = useState(0);
const saveData = useSaveData();
const containerRef = useRef<HTMLDivElement>(null);
const pinRef = useRef<HTMLDivElement>(null);
const indexRef = useRef<HTMLOListElement>(null);
const desktopVideos = useRef<Array<HTMLVideoElement | null>>([]);
const bars = useRef<Array<HTMLElement | null>>([]);
const live = useRef({
target: [] as number[],
smooth: [] as number[],
active: 0,
chapter: 0,
raf: 0,
last: 0,
near: false,
});
const weights = useMemo(
() => items.map((item) => item.config.weight),
[items],
);
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
// 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);
}, [active]);
// na faixa horizontal (tablet e celular) o projeto em cena precisa continuar à vista
useEffect(() => {
const list = indexRef.current;
const button = list?.children[active]?.querySelector("button");
if (
!list ||
!button ||
list.scrollWidth <= list.clientWidth ||
typeof list.scrollTo !== "function"
)
return;
list.scrollTo({
left: Math.max(0, button.offsetLeft - 16),
behavior: "smooth",
});
}, [active]);
const isAttached = useCallback(
(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(() => {
items.forEach((_, index) => {
if (isAttached(index)) return;
const video = desktopVideos.current[index];
if (video && video.networkState !== 0 && !video.getAttribute("src"))
video.load();
});
}, [items, isAttached]);
const frame = useCallback(
(now: number) => {
const state = live.current;
const seconds = state.last ? (now - state.last) / 1000 : 0;
state.last = now;
let moving = false;
items.forEach((_, index) => {
if (Math.abs(index - state.active) > 1) return;
const target = state.target[index] ?? 0;
const next = easeToward(state.smooth[index] ?? 0, target, seconds);
state.smooth[index] = next;
if (Math.abs(next - target) > 0.0004) moving = true;
seekVideo(desktopVideos.current[index], next);
bars.current[index]?.style.setProperty("--p", next.toFixed(4));
});
const current = items[state.active];
if (current) {
const index = chapterIndexAt(
current.config,
state.smooth[state.active] ?? 0,
);
if (index !== state.chapter) {
state.chapter = index;
setChapter(index);
}
}
state.raf = moving ? requestAnimationFrame(frame) : 0;
},
[items],
);
// um vídeo que acabou de carregar precisa ir ao ponto em que a rolagem já está
const syncVideo = useCallback((index: number) => {
const state = live.current;
seekVideo(
desktopVideos.current[index],
state.smooth[index] ?? state.target[index] ?? 0,
);
}, []);
/** Troca o projeto em cena: o capítulo vem do ponto em que o vídeo dele já está. */
const changeActive = useCallback(
(index: number) => {
const state = live.current;
state.active = index;
state.chapter = chapterIndexAt(
items[index].config,
state.smooth[index] ?? 0,
);
setActive(index);
setChapter(state.chapter);
},
[items],
);
const measure = useCallback(() => {
const container = containerRef.current;
const pin = pinRef.current;
// escondida por CSS (tela baixa, por exemplo): sem altura, não há o que medir nem o que carregar
if (!container?.offsetHeight || !pin) return;
const state = live.current;
const { index, local, near: isNear } = readReel(container, pin, weights);
state.target = targetsAt(items.length, index, local);
if (isNear !== state.near) {
state.near = isNear;
setNear(isNear);
}
if (index !== state.active) changeActive(index);
if (state.near && !state.raf) {
state.last = 0;
state.raf = requestAnimationFrame(frame);
}
}, [changeActive, frame, items.length, weights]);
useEffect(() => {
let pending = 0;
const schedule = () => {
if (!pending)
pending = requestAnimationFrame(() => {
pending = 0;
measure();
});
};
schedule();
window.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
const state = live.current;
return () => {
window.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule);
cancelAnimationFrame(pending);
cancelAnimationFrame(state.raf);
state.raf = 0;
};
}, [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) => {
const container = containerRef.current;
const pin = pinRef.current;
if (!container || !pin) return;
const scrollable = container.offsetHeight - pin.offsetHeight;
const top = container.getBoundingClientRect().top + window.scrollY;
// 2px além: garante que a conta caia dentro do trecho
scrollToSmoothly(
top - headerHeight() + reelOffset(weights, index, local) * scrollable + 2,
);
};
const goTo = (index: number) => scrollToReel(index, 0);
const goToChapter = (index: number, chapterIndex: number) => {
const fraction = chapterFraction(
items[index].config,
chapterIndex,
"desktop",
);
scrollToReel(index, REEL_HOLD + fraction * (1 - REEL_HOLD * 2));
};
const current = items[active];
return (
<section
className="ds-reel"
aria-label="Vitrine animada dos projetos"
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">
<ReelIndex
items={items}
active={active}
chapter={chapter}
listRef={indexRef}
bars={bars}
onProject={goTo}
onChapter={goToChapter}
/>
<div className="ds-reel-stage">
<div className="ds-reel-set">
<div className="ds-reel-devices">
{items.map((item, index) => (
<ReelLayer
key={item.config.id}
item={item}
state={layerState(index, active)}
poster={showsPoster(index)}
attached={isAttached(index)}
videoRef={(element) => {
desktopVideos.current[index] = element;
}}
onVideoLoaded={() => syncVideo(index)}
/>
))}
</div>
<ReelCaption
items={items}
active={active}
chapter={chapter}
onExpand={openProject}
/>
</div>
</div>
</div>
<p className="sr-only" aria-live="polite">
{current
? `${current.project.title}: ${current.config.chapters[chapter]?.label ?? ""}`
: ""}
</p>
</div>
</section>
);
}
/**
* Lista de projetos (e, no desktop, os trechos do projeto em cena): leva a rolagem ao ponto escolhido. É UMA parada
* no Tab (roving tabindex): as setas e Home/End movem o foco entre os projetos e os trechos, Enter e espaço
* escolhem. Sem isto eram um Tab por projeto e por trecho (7) antes de chegar aos botões da legenda.
*/
function ReelIndex({
items,
active,
chapter,
listRef,
bars,
onProject,
onChapter,
}: {
items: ShowcaseItem[];
active: number;
chapter: number;
listRef: RefObject<HTMLOListElement | null>;
bars: RefObject<Array<HTMLElement | null>>;
onProject: (index: number) => void;
onChapter: (index: number, chapter: number) => void;
}) {
const { groupProps, itemProps } = useRovingTabindex(
`p:${active}`,
// os trechos são sempre os do projeto em cena: os de um projeto que já saiu de cena deixam de ser a parada
(key) => key.startsWith("p:") || key.startsWith(`c:${active}:`),
);
return (
<nav
className="ds-reel-index"
aria-label="Projetos da vitrine"
{...groupProps}
>
<p className="ds-reel-hint">
<span aria-hidden="true" className="ds-reel-hint-line" />
Em movimento
<span className="ds-reel-hint-sub">Role para avançar</span>
<span className="sr-only">
Use as setas para escolher o projeto e o trecho.
</span>
</p>
<ol ref={listRef}>
{items.map((item, index) => (
<li key={item.config.id}>
<button
type="button"
className="ds-reel-pick"
aria-current={index === active ? "true" : undefined}
aria-label={item.project.title}
onClick={() => onProject(index)}
{...itemProps(`p:${index}`)}
>
{/* o número vem do CSS (data-n): o texto visível do botão fica igual ao nome acessível (WCAG 2.5.3) */}
<span
className="ds-reel-num"
aria-hidden="true"
data-n={String(index + 1).padStart(2, "0")}
/>
<span className="ds-reel-name">{item.project.title}</span>
<i
className="ds-reel-bar"
aria-hidden="true"
ref={(element) => {
bars.current[index] = element;
}}
/>
</button>
{index === active && (
<ol
className="ds-reel-chapters"
aria-label={`Trechos de ${item.project.title}`}
>
{item.config.chapters.map((entry, i) => (
<li key={entry.label}>
<button
type="button"
className="ds-reel-chapter-pick"
aria-current={i === chapter ? "true" : undefined}
onClick={() => onChapter(index, i)}
{...itemProps(`c:${index}:${i}`)}
>
<span className="ds-reel-chapter-num" aria-hidden="true">
{String(i + 1).padStart(2, "0")}
</span>
{entry.label}
</button>
</li>
))}
</ol>
)}
</li>
))}
</ol>
</nav>
);
}
/**
* Os aparelhos de um projeto: notebook com o vídeo que acompanha a rolagem e celular só com o pôster. Camadas
* empilhadas na mesma célula; quem não está em cena fica transparente, inerte e escondido dos leitores de tela.
*/
function ReelLayer({
item,
state,
poster,
attached,
videoRef,
onVideoLoaded,
}: {
item: ShowcaseItem;
state: "active" | "before" | "after";
/** O pôster já pode ser pedido (a vitrine está perto e este projeto é o da vez ou um vizinho). */
poster: boolean;
/** O vídeo de rolagem já pode ser baixado. */
attached: boolean;
videoRef: (element: HTMLVideoElement | null) => void;
onVideoLoaded: () => void;
}) {
const { config, project } = item;
const isActive = state === "active";
const desktop = getShowcaseAssets(config.id, "desktop");
const phone = getShowcaseAssets(config.id, "mobile");
return (
<div
className="ds-reel-layer"
data-state={state}
style={{ "--accent": config.accent } as CSSProperties}
aria-hidden={isActive ? undefined : true}
inert={!isActive}
>
<BrowserFrame url={displayUrl(project)} className="ds-reel-laptop">
<div
className="ds-reel-screen"
style={
poster ? { backgroundImage: `url(${desktop.poster})` } : undefined
}
>
<video
ref={videoRef}
src={attached ? getScrubSource(config.id) : undefined}
poster={poster ? desktop.poster : undefined}
muted
playsInline
preload="metadata"
tabIndex={-1}
aria-hidden="true"
disablePictureInPicture
disableRemotePlayback
onLoadedMetadata={onVideoLoaded}
onLoadedData={(event) => prime(event.currentTarget)}
/>
</div>
</BrowserFrame>
<PhoneFrame
className="ds-reel-phone"
poster={poster ? phone.poster : undefined}
/>
</div>
);
}
const pad = (value: number) => String(value).padStart(2, "0");
/**
* Legenda do projeto em cena: um bloco só, fora das camadas. Na troca de projeto a legenda atual some em
* CAPTION_OUT_MS e só então o conteúdo muda e ela volta (fade, sem deslocamento): nunca há título, contador ou
* botões em dobro. Dentro do mesmo projeto, o trecho troca no lugar.
*/
function ReelCaption({
items,
active,
chapter,
onExpand,
}: {
items: ShowcaseItem[];
active: number;
chapter: number;
onExpand: (id: string) => void;
}) {
const [shown, setShown] = useState({ index: active, chapter });
const leaving = shown.index !== active;
// o que vale quando a saída termina: o trecho continua mudando durante os 120 ms, e isso não reinicia a espera
const latest = useRef({ active, chapter });
useEffect(() => {
latest.current = { active, chapter };
});
// dentro do mesmo projeto o trecho troca no lugar
useEffect(() => {
if (shown.index === active && shown.chapter !== chapter)
setShown({ index: active, chapter });
}, [active, chapter, shown]);
// projeto novo: a legenda sai e, passado CAPTION_OUT_MS, o conteúdo muda
useEffect(() => {
if (shown.index === active) return;
const timer = setTimeout(
() =>
setShown({
index: latest.current.active,
chapter: latest.current.chapter,
}),
CAPTION_OUT_MS,
);
return () => clearTimeout(timer);
}, [active, shown.index]);
const item = items[shown.index];
const { config, project } = item;
return (
<div className="ds-reel-caption" data-leaving={leaving ? "" : undefined}>
<p className="ds-kicker">
{project.category} · {project.status}
</p>
<h3 className="ds-reel-title">{project.title}</h3>
{project.subtitle && <p className="ds-reel-sub">{project.subtitle}</p>}
<div className="ds-reel-chapter">
<span className="ds-reel-chapter-count">
{pad(shown.chapter + 1)}/{pad(config.chapters.length)}
</span>
<strong>{config.chapters[shown.chapter].label}</strong>
<ol className="ds-reel-ticks" aria-hidden="true">
{config.chapters.map((entry, tick) => (
<li key={entry.label} data-state={tickState(tick, shown.chapter)} />
))}
</ol>
</div>
<ProjectActions item={item} onExpand={() => onExpand(config.id)} />
</div>
);
}
@@ -0,0 +1,10 @@
"use client";
import { lazy } from "react";
/**
* A vitrine fixa (código, vídeos e o CSS dela) só é baixada onde ela cabe: no celular, com movimento reduzido e em
* janelas baixas, a lista é tudo o que há, e nada disso viaja. Fica num módulo próprio para os testes poderem
* trocá-lo por um componente síncrono.
*/
export const PinnedReelLazy = lazy(() => import("./PinnedReel"));
+2 -1
View File
@@ -36,7 +36,8 @@ export function ProjectActions({
<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
{/* no celular o botão é só o ícone (CSS): o rótulo continua para o leitor de tela */}
<span className="ds-reel-expand-label">Ampliar vídeo</span>
<span className="sr-only"> de {project.title}</span>
</button>
)}
@@ -16,6 +16,11 @@ import {
import { ShowcaseProvider, useShowcaseOpen } from "./ShowcaseProvider";
let reducedMotion = false;
// a janela ampliada chega sob demanda (React.lazy): aqui entra já, para o teste não esperar
jest.mock("./ProjectLightboxLazy", () => ({
ProjectLightboxLazy: jest.requireActual("./ProjectLightbox").ProjectLightbox,
}));
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
}));
+1 -1
View File
@@ -26,6 +26,7 @@ import {
import { BrowserFrame, displayUrl } from "./BrowserFrame";
import { PhoneFrame } from "./PhoneFrame";
import type { ShowcaseItem } from "./ShowcaseProvider";
import "./lightbox.css";
const formatTime = (seconds: number) => {
const whole = Math.max(0, Math.floor(seconds));
@@ -300,7 +301,6 @@ export function ProjectLightbox({
<button
ref={closeRef}
type="button"
className="ds-lb-close"
onClick={() => dialogRef.current?.close()}
aria-label="Fechar"
>
@@ -0,0 +1,13 @@
"use client";
import { lazy } from "react";
/**
* A janela ampliada (e o CSS dela) só é baixada na primeira vez que alguém amplia um projeto. Fica num módulo
* próprio para os testes poderem trocá-lo por um componente síncrono.
*/
export const ProjectLightboxLazy = lazy(() =>
import("./ProjectLightbox").then((module) => ({
default: module.ProjectLightbox,
})),
);
@@ -1,8 +1,17 @@
import { act, fireEvent, render, screen, within } from "@testing-library/react";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import { ProjectList } from "./ProjectList";
import { carouselIndex, ProjectList } from "./ProjectList";
import { buildShowcaseItems, ShowcaseProvider } from "./ShowcaseProvider";
// o pacote da vitrine fixa e o da janela ampliada chegam sob demanda (React.lazy): aqui entram já, para o teste não esperar
jest.mock("./PinnedReelLazy", () => ({
PinnedReelLazy: jest.requireActual("./PinnedReel").default,
}));
jest.mock("./ProjectLightboxLazy", () => ({
// o ProjectLightbox deste teste é o dublê logo abaixo
ProjectLightboxLazy: jest.requireMock("./ProjectLightbox").ProjectLightbox,
}));
/**
* 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.
@@ -171,3 +180,150 @@ describe("ProjectList", () => {
);
});
});
describe("ProjectList: carrossel do celular", () => {
/** Dá ao carrossel as medidas que o jsdom não tem: largura de cada cartão, passo e rolagem. */
function lay(
list: HTMLElement,
{ scrollLeft = 0, card = 300, gap = 16 } = {},
) {
const cards = Array.from(list.children) as HTMLElement[];
Object.defineProperty(list, "clientWidth", {
configurable: true,
value: 360,
});
Object.defineProperty(list, "scrollWidth", {
configurable: true,
value: cards.length * (card + gap),
});
list.scrollLeft = scrollLeft;
cards.forEach((element, index) => {
element.getBoundingClientRect = () =>
({ left: index * (card + gap) - scrollLeft }) as DOMRect;
});
}
const list = (container: HTMLElement) =>
container.querySelector(".ds-reel-items") as HTMLElement;
const count = (container: HTMLElement) =>
container.querySelector(".ds-reel-count");
it("path: on the phone, shows '1/N' and one dot per project (decorative: the list already says how many there are)", () => {
const { container } = renderList({ device: "mobile" });
const counter = count(container) as HTMLElement;
expect(counter).toHaveAttribute("aria-hidden", "true");
expect(counter.textContent).toBe(`1/${items.length}`);
expect(counter.querySelectorAll("i")).toHaveLength(items.length);
expect(counter.querySelectorAll("i[data-on]")).toHaveLength(1);
});
it("path: no counter on the desktop grid, before mount, or with one project only", () => {
expect(count(renderList({ device: "desktop" }).container)).toBeNull();
expect(count(renderList({ device: null }).container)).toBeNull();
expect(
count(
renderList({ device: "mobile", items: items.slice(0, 1) }).container,
),
).toBeNull();
});
it("path: scrolling the strip moves the counter and the dot (one animation frame at a time)", () => {
jest.useFakeTimers();
try {
const { container } = renderList({ device: "mobile" });
const strip = list(container);
lay(strip, { scrollLeft: 316 * 2 });
act(() => {
strip.dispatchEvent(new Event("scroll"));
strip.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(40);
});
expect((count(container) as HTMLElement).textContent).toBe(
`3/${items.length}`,
);
const dots = [...(count(container) as HTMLElement).querySelectorAll("i")];
expect(dots.findIndex((dot) => dot.hasAttribute("data-on"))).toBe(2);
} finally {
jest.useRealTimers();
}
});
it("path: the strip scrolled to its end -> the last project, even if its start never reaches the edge", () => {
jest.useFakeTimers();
try {
const { container } = renderList({ device: "mobile" });
const strip = list(container);
lay(strip);
// fim da rolagem: scrollLeft + clientWidth = scrollWidth
strip.scrollLeft = strip.scrollWidth - strip.clientWidth;
act(() => {
strip.dispatchEvent(new Event("scroll"));
jest.advanceTimersByTime(40);
});
expect((count(container) as HTMLElement).textContent).toBe(
`${items.length}/${items.length}`,
);
} finally {
jest.useRealTimers();
}
});
it("carouselIndex: 0 without horizontal scroll, never past the ends, and tolerant of a degenerate stride", () => {
const strip = document.createElement("ul");
for (let i = 0; i < 4; i++) strip.append(document.createElement("li"));
// sem rolagem para o lado (grade das telas maiores)
Object.defineProperty(strip, "scrollWidth", {
configurable: true,
value: 300,
});
Object.defineProperty(strip, "clientWidth", {
configurable: true,
value: 300,
});
expect(carouselIndex(strip)).toBe(0);
lay(strip, { scrollLeft: 316 });
expect(carouselIndex(strip)).toBe(1);
lay(strip, { scrollLeft: 316 * 10 });
expect(carouselIndex(strip)).toBe(3);
// cartões sobrepostos (passo zero): não divide por zero
for (const child of Array.from(strip.children))
(child as HTMLElement).getBoundingClientRect = () =>
({ left: 0 }) as DOMRect;
strip.scrollLeft = 10;
Object.defineProperty(strip, "scrollWidth", {
configurable: true,
value: 2000,
});
Object.defineProperty(strip, "clientWidth", {
configurable: true,
value: 360,
});
expect(carouselIndex(strip)).toBe(0);
// um cartão só
const single = document.createElement("ul");
single.append(document.createElement("li"));
expect(carouselIndex(single)).toBe(0);
});
it("path: 'Ampliar vídeo' keeps its accessible name (visually hidden on the phone, by CSS) and 'Ver case' is the first action", () => {
const { container } = renderList({ device: "mobile" });
const first = container.querySelector(".ds-reel-actions") as HTMLElement;
const names = [...first.querySelectorAll("a, button")].map((element) =>
element.textContent?.replace(/\s+/g, " ").trim(),
);
expect(names[0]).toMatch(/^Ver case/);
expect(
within(first).getByRole("button", { name: /^Ampliar vídeo de / }),
).toBeInTheDocument();
expect(first.querySelector(".ds-reel-expand-label")?.textContent).toBe(
"Ampliar vídeo",
);
});
});
+76 -2
View File
@@ -1,13 +1,69 @@
"use client";
import Image from "next/image";
import { type CSSProperties, useId, useRef } from "react";
import {
type CSSProperties,
type RefObject,
useEffect,
useId,
useRef,
useState,
} 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";
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
* rolagem, o último, que talvez não alcance o começo). Só lê a posição da rolagem; sem rolagem horizontal fica em 0.
*/
export function carouselIndex(list: HTMLElement) {
const cards = Array.from(list.children) as HTMLElement[];
if (cards.length < 2 || list.scrollWidth <= list.clientWidth + 1) return 0;
if (list.scrollLeft + list.clientWidth >= list.scrollWidth - 2)
return cards.length - 1;
const stride =
cards[1].getBoundingClientRect().left -
cards[0].getBoundingClientRect().left;
if (stride <= 0) return 0;
return Math.min(
cards.length - 1,
Math.max(0, Math.round(list.scrollLeft / stride)),
);
}
/** Acompanha a rolagem horizontal da faixa e devolve o índice do cartão à frente (em um quadro de animação por vez). */
function useCarouselIndex(
ref: RefObject<HTMLElement | null>,
enabled: boolean,
) {
const [index, setIndex] = useState(0);
useEffect(() => {
const list = ref.current;
if (!enabled || !list) return;
let frame = 0;
const read = () => {
frame = 0;
setIndex(carouselIndex(list));
};
const onScroll = () => {
if (!frame) frame = requestAnimationFrame(read);
};
read();
list.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
return () => {
cancelAnimationFrame(frame);
list.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onScroll);
};
}, [ref, enabled]);
return index;
}
function ListItem({
item,
@@ -85,6 +141,10 @@ export function ProjectList({
}) {
const open = useShowcaseOpen();
const headingId = useId();
const listRef = useRef<HTMLUListElement>(null);
// a contagem só existe no celular (o carrossel por toque); a grade das telas maiores não rola para o lado
const carousel = device === "mobile";
const current = useCarouselIndex(listRef, carousel);
return (
<section
className="ds-reel-list"
@@ -94,7 +154,7 @@ export function ProjectList({
<h2 id={headingId} className="sr-only">
Projetos de clientes
</h2>
<ul className="ds-reel-items">
<ul className="ds-reel-items" ref={listRef}>
{items.map((item) => (
<ListItem
key={item.config.id}
@@ -104,6 +164,20 @@ export function ProjectList({
/>
))}
</ul>
{carousel && items.length > 1 && (
// quem lê com leitor de tela já tem a lista (com o número de itens): a contagem é só visual
<p className="ds-reel-count" aria-hidden="true">
<span className="ds-reel-dots">
{items.map((item, index) => (
<i
key={item.config.id}
data-on={index === current ? "" : undefined}
/>
))}
</span>
{current + 1}/{items.length}
</p>
)}
</section>
);
}
@@ -8,6 +8,14 @@ import { ShowcaseProvider } from "./ShowcaseProvider";
import { reelOffset } from "./showcase-math";
import { PINNED_QUERY } from "./useShowcaseMedia";
// o pacote da vitrine fixa e o da janela ampliada chegam sob demanda (React.lazy): aqui entram já, para o teste não esperar
jest.mock("./PinnedReelLazy", () => ({
PinnedReelLazy: jest.requireActual("./PinnedReel").default,
}));
jest.mock("./ProjectLightboxLazy", () => ({
ProjectLightboxLazy: jest.requireActual("./ProjectLightbox").ProjectLightbox,
}));
/**
* 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
@@ -741,3 +749,46 @@ describe("ProjectReel: rolagem suave até um projeto", () => {
return reelOffset(WEIGHTS, index, 0) * scrollable;
}
});
describe("ProjectReel: altura do cabeçalho descontada na rolagem até um projeto", () => {
function pressAndLand(name: string) {
(window.scrollTo as jest.Mock).mockClear();
const view = renderReel();
fireEvent.click(
within(
screen.getByRole("navigation", { name: "Projetos da vitrine" }),
).getByRole("button", { name }),
);
act(() => {
jest.advanceTimersByTime(2000);
});
const calls = (window.scrollTo as jest.Mock).mock.calls;
view.unmount();
return calls[calls.length - 1][0].top as number;
}
function setWidth(width: number) {
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: width,
});
}
afterEach(() => {
document.documentElement.style.removeProperty("--site-header-height");
});
it("path: no --site-header-height -> 60 px; set later at the same window width -> still the cached value; a new width -> read again", () => {
setWidth(1111);
const withoutVariable = pressAndLand("SC Plus Aquicultura");
document.documentElement.style.setProperty("--site-header-height", "80px");
const sameWidth = pressAndLand("SC Plus Aquicultura");
expect(sameWidth).toBe(withoutVariable);
setWidth(1112);
const newWidth = pressAndLand("SC Plus Aquicultura");
// o cabeçalho de 80 px deixa 20 px a menos de rolagem do que o de 60 px
expect(newWidth).toBe(withoutVariable - 20);
});
});
+93 -4
View File
@@ -5,11 +5,19 @@ 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 { CAPTION_OUT_MS, ProjectReel } from "./ProjectReel";
import { ProjectReel } from "./ProjectReel";
import { ShowcaseProvider } from "./ShowcaseProvider";
import { reelOffset, videoProgress } from "./showcase-math";
import { CAPTION_OUT_MS, reelOffset, videoProgress } from "./showcase-math";
import { PINNED_QUERY } from "./useShowcaseMedia";
// o pacote da vitrine fixa e o da janela ampliada chegam sob demanda (React.lazy): aqui entram já, para o teste não esperar
jest.mock("./PinnedReelLazy", () => ({
PinnedReelLazy: jest.requireActual("./PinnedReel").default,
}));
jest.mock("./ProjectLightboxLazy", () => ({
ProjectLightboxLazy: jest.requireActual("./ProjectLightbox").ProjectLightbox,
}));
let reducedMotion = false;
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => reducedMotion,
@@ -699,13 +707,94 @@ describe("ProjectReel: a troca de projeto não duplica nada na tela", () => {
for (const element of caption().querySelectorAll("*"))
expect((element as HTMLElement).style.transform).toBe("");
const css = fs.readFileSync(path.join(__dirname, "showcase.css"), "utf8");
const css = ["reel-list.css", "pinned-reel.css"]
.map((file) => fs.readFileSync(path.join(__dirname, file), "utf8"))
.join("\n");
const rule = /\.ds-reel-caption \{[\s\S]*?\n\}/.exec(css)?.[0] ?? "";
expect(rule).toContain("opacity");
expect(rule).not.toMatch(/transform|translate/);
expect(rule).toContain("0.12s");
const layer = /\.ds-reel-layer \{[\s\S]*?\n\}/.exec(css)?.[0] ?? "";
expect(layer).toContain("opacity");
expect(layer).not.toMatch(/transform|translate/);
// comportamento: a legenda sai (atributo, sem estilo inline) e volta com o conteúdo novo sem nunca se deslocar
expect(caption()).toHaveAttribute("data-leaving");
act(() => {
jest.advanceTimersByTime(CAPTION_OUT_MS + 40);
});
expect(caption()).not.toHaveAttribute("data-leaving");
expect(caption().getAttribute("style")).toBeNull();
for (const element of caption().querySelectorAll("*"))
expect((element as HTMLElement).style.transform).toBe("");
});
});
describe("ProjectReel: teclado do índice (roving tabindex)", () => {
const index = () =>
screen.getByRole("navigation", { name: "Projetos da vitrine" });
const stops = () =>
within(index())
.getAllByRole("button")
.filter((button) => button.getAttribute("tabindex") === "0");
const focusOn = (element: HTMLElement) =>
act(() => {
element.focus();
});
it("makes the whole index ONE tab stop (the current project), however many projects and chapters it lists", () => {
renderReel();
const buttons = within(index()).getAllByRole("button");
// 5 projetos e os 3 trechos do projeto em cena: 8 botões, uma parada
expect(buttons).toHaveLength(5 + 3);
expect(stops()).toHaveLength(1);
expect(stops()[0]).toHaveAttribute("aria-current", "true");
expect(stops()[0]).toHaveAccessibleName("SEV Moda Íntima");
});
it("walks projects and the chapters of the current one with the arrows, in the order they appear", () => {
renderReel();
const names = () =>
within(index())
.getAllByRole("button")
.map((button) => button.textContent?.replace(/\s+/g, " ").trim());
focusOn(stops()[0]);
const seen: Array<string | null | undefined> = [];
for (let i = 0; i < 7; i++) {
fireEvent.keyDown(document.activeElement as Element, {
key: "ArrowDown",
});
seen.push(
document.activeElement?.textContent?.replace(/\s+/g, " ").trim(),
);
}
// depois do projeto em cena vêm os trechos dele, e então os outros projetos
expect(seen.slice(0, 3)).toEqual(names().slice(1, 4));
// as setas não passam da última parada
fireEvent.keyDown(document.activeElement as Element, { key: "End" });
const last = document.activeElement;
fireEvent.keyDown(last as Element, { key: "ArrowDown" });
expect(document.activeElement).toBe(last);
});
it("moves the tab stop to where the focus went, and Enter picks the project like a click", () => {
renderReel();
focusOn(stops()[0]);
fireEvent.keyDown(document.activeElement as Element, { key: "End" });
const target = document.activeElement as HTMLElement;
expect(stops()).toEqual([target]);
// o botão do último projeto leva a rolagem até ele (o teclado ativa o clique nativo do botão)
fireEvent.click(target);
act(() => {
jest.advanceTimersByTime(100);
});
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);
});
});
+31 -625
View File
@@ -1,109 +1,32 @@
"use client";
import {
type CSSProperties,
type RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { type CSSProperties, Suspense } from "react";
import { useReducedMotion } from "@/app/hooks/useReducedMotion";
import {
chapterFraction,
chapterIndexAt,
getScrubSource,
getShowcaseAssets,
} from "@/lib/project-showcase";
import { BrowserFrame, displayUrl } from "./BrowserFrame";
import { PhoneFrame } from "./PhoneFrame";
import { ProjectActions } from "./ProjectActions";
import { PinnedReelLazy } from "./PinnedReelLazy";
import { ProjectList } from "./ProjectList";
import {
type ShowcaseItem,
useShowcaseItems,
useShowcaseOpen,
} from "./ShowcaseProvider";
import {
easeToward,
locateInReel,
REEL_HOLD,
reelOffset,
videoProgress,
} from "./showcase-math";
import {
usePinnedViewport,
useSaveData,
useViewportDevice,
} from "./useShowcaseMedia";
import { type ShowcaseItem, useShowcaseItems } from "./ShowcaseProvider";
import { usePinnedViewport, useViewportDevice } from "./useShowcaseMedia";
import "./reel-list.css";
// 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 };
const headerHeight = () => {
if (headerCache.width !== window.innerWidth)
headerCache = {
width: window.innerWidth,
height:
Number.parseFloat(
getComputedStyle(document.documentElement).getPropertyValue(
"--site-header-height",
/**
* O bloco que segura o lugar da vitrine fixa, com a mesma altura dela: 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.
*/
function ReelPlaceholder({ items }: { items: ShowcaseItem[] }) {
return (
<div
className="ds-reel ds-reel-placeholder"
aria-hidden="true"
style={
{
"--reel-sum": items.reduce(
(sum, item) => sum + item.config.weight,
0,
),
) || 60,
};
return headerCache.height;
};
/** Saída da legenda (ms) antes de a do novo projeto entrar: curta, para a troca nunca mostrar duas legendas. */
export const CAPTION_OUT_MS = 120;
/** Põe o vídeo no instante `fraction` (0 a 1) sem repetir buscas enquanto a anterior ainda não terminou. */
function seekVideo(video: HTMLVideoElement | null, fraction: number) {
if (!video || video.readyState < 1 || !video.duration) return;
const target = Math.min(
video.duration - 0.001,
Math.max(0, fraction * video.duration),
} as CSSProperties
}
/>
);
if (!video.seeking && Math.abs(video.currentTime - target) > 1 / 48)
video.currentTime = target;
}
/** Alguns navegadores móveis só desenham o primeiro quadro de um vídeo que já tocou uma vez. */
function prime(video: HTMLVideoElement) {
if (video.dataset.primed) return;
video.dataset.primed = "true";
video
.play()
.then(() => video.pause())
.catch(() => {});
}
let stopScroll = () => {};
/** Rola até `top` em menos de um segundo; qualquer gesto do usuário interrompe. */
function scrollToSmoothly(top: number) {
stopScroll();
const from = window.scrollY;
const distance = top - from;
const duration = Math.min(900, 380 + Math.abs(distance) * 0.12);
const started = performance.now();
let id = 0;
const stop = () => {
cancelAnimationFrame(id);
window.removeEventListener("wheel", stop);
window.removeEventListener("touchstart", stop);
};
stopScroll = stop;
window.addEventListener("wheel", stop, { passive: true });
window.addEventListener("touchstart", stop, { passive: true });
const step = (now: number) => {
const t = Math.min(1, (now - started) / duration);
const eased = t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
// "instant": o CSS global tem scroll-behavior: smooth e cada quadro já é um passo da animação
window.scrollTo({ top: from + distance * eased, behavior: "instant" });
if (t < 1) id = requestAnimationFrame(step);
else stop();
};
id = requestAnimationFrame(step);
}
/**
@@ -112,8 +35,8 @@ function scrollToSmoothly(top: number) {
* 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.
* (e enquanto o pacote dela chega) um bloco da mesma altura segura o lugar. Funciona sozinha dentro de
* `ShowcaseProvider`: não depende dos cards da grade.
*/
export function ProjectReel() {
const items = useShowcaseItems();
@@ -124,534 +47,17 @@ export function ProjectReel() {
if (items.length === 0) return null;
const mounted = wide !== null && device !== null;
if (mounted && wide && !reducedMotion) return <PinnedReel items={items} />;
if (mounted && wide && !reducedMotion)
return (
<Suspense fallback={<ReelPlaceholder items={items} />}>
<PinnedReelLazy items={items} />
</Suspense>
);
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
}
/>
)}
{!mounted && <ReelPlaceholder items={items} />}
</>
);
}
const layerState = (index: number, active: number) => {
if (index === active) return "active";
return index < active ? "before" : "after";
};
/** Estado do traço de capítulo: ainda por vir, o de agora ou já visto. */
const tickState = (tick: number, current: number) => {
if (tick > current) return "todo";
return tick === current ? "now" : "done";
};
/** Progresso (0 a 1) de cada vídeo quando o projeto `index` está na fração `local` do trecho dele. */
function targetsAt(count: number, index: number, local: number) {
return Array.from({ length: count }, (_, i) => {
if (i < index) return 1;
return i === index ? videoProgress(local) : 0;
});
}
/** Onde a rolagem está na vitrine: projeto em cena, fração dele e se ela está a ~1 tela da janela. Só lê o DOM. */
function readReel(container: HTMLElement, pin: HTMLElement, weights: number[]) {
const rect = container.getBoundingClientRect();
const scrollable = rect.height - pin.offsetHeight;
const progress =
scrollable > 0 ? (headerHeight() - rect.top) / scrollable : 0;
const { index, local } = locateInReel(weights, progress);
// a ~1 tela de distância (acima ou abaixo): é quando pôsteres e vídeo da vitrine começam a ser pedidos
const near =
rect.top < window.innerHeight && rect.bottom > -window.innerHeight;
return { index, local, near };
}
/**
* Vitrine animada: o site de cada projeto rola dentro de um notebook (e de um celular ao lado) conforme a
* página rola. A cena fica presa (position: sticky) durante a rolagem de todos os projetos; cada projeto tem
* um trecho com uma pausa no começo e outra no fim. Capítulos nomeiam o que está na tela; o celular é só um
* pôster da mesma tela (o vídeo de celular é do celular: aqui ele nem é baixado).
*
* Os aparelhos de cada projeto são uma camada (as camadas trocam por fade cruzado); a legenda (nome, trecho,
* contador e botões) é UM bloco só, fora das camadas, que sai e entra sem nunca existir em dobro.
*
* Mídia: só o projeto em cena baixa vídeo (os vizinhos ficam no pôster), e só quando a vitrine está a ~1 tela
* da janela; com "economizar dados" ninguém baixa vídeo.
*/
function PinnedReel({ items }: { items: ShowcaseItem[] }) {
const openProject = useShowcaseOpen();
const [active, setActive] = useState(0);
const [chapter, setChapter] = useState(0);
const [near, setNear] = useState(false);
const [settled, setSettled] = useState(0);
const saveData = useSaveData();
const containerRef = useRef<HTMLDivElement>(null);
const pinRef = useRef<HTMLDivElement>(null);
const indexRef = useRef<HTMLOListElement>(null);
const desktopVideos = useRef<Array<HTMLVideoElement | null>>([]);
const bars = useRef<Array<HTMLElement | null>>([]);
const live = useRef({
target: [] as number[],
smooth: [] as number[],
active: 0,
chapter: 0,
raf: 0,
last: 0,
near: false,
});
const weights = useMemo(
() => items.map((item) => item.config.weight),
[items],
);
const weightSum = weights.reduce((sum, weight) => sum + weight, 0);
// 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);
}, [active]);
// na faixa horizontal (tablet e celular) o projeto em cena precisa continuar à vista
useEffect(() => {
const list = indexRef.current;
const button = list?.children[active]?.querySelector("button");
if (
!list ||
!button ||
list.scrollWidth <= list.clientWidth ||
typeof list.scrollTo !== "function"
)
return;
list.scrollTo({
left: Math.max(0, button.offsetLeft - 16),
behavior: "smooth",
});
}, [active]);
const isAttached = useCallback(
(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(() => {
items.forEach((_, index) => {
if (isAttached(index)) return;
const video = desktopVideos.current[index];
if (video && video.networkState !== 0 && !video.getAttribute("src"))
video.load();
});
}, [items, isAttached]);
const frame = useCallback(
(now: number) => {
const state = live.current;
const seconds = state.last ? (now - state.last) / 1000 : 0;
state.last = now;
let moving = false;
items.forEach((_, index) => {
if (Math.abs(index - state.active) > 1) return;
const target = state.target[index] ?? 0;
const next = easeToward(state.smooth[index] ?? 0, target, seconds);
state.smooth[index] = next;
if (Math.abs(next - target) > 0.0004) moving = true;
seekVideo(desktopVideos.current[index], next);
bars.current[index]?.style.setProperty("--p", next.toFixed(4));
});
const current = items[state.active];
if (current) {
const index = chapterIndexAt(
current.config,
state.smooth[state.active] ?? 0,
);
if (index !== state.chapter) {
state.chapter = index;
setChapter(index);
}
}
state.raf = moving ? requestAnimationFrame(frame) : 0;
},
[items],
);
// um vídeo que acabou de carregar precisa ir ao ponto em que a rolagem já está
const syncVideo = useCallback((index: number) => {
const state = live.current;
seekVideo(
desktopVideos.current[index],
state.smooth[index] ?? state.target[index] ?? 0,
);
}, []);
/** Troca o projeto em cena: o capítulo vem do ponto em que o vídeo dele já está. */
const changeActive = useCallback(
(index: number) => {
const state = live.current;
state.active = index;
state.chapter = chapterIndexAt(
items[index].config,
state.smooth[index] ?? 0,
);
setActive(index);
setChapter(state.chapter);
},
[items],
);
const measure = useCallback(() => {
const container = containerRef.current;
const pin = pinRef.current;
// escondida por CSS (tela baixa, por exemplo): sem altura, não há o que medir nem o que carregar
if (!container?.offsetHeight || !pin) return;
const state = live.current;
const { index, local, near: isNear } = readReel(container, pin, weights);
state.target = targetsAt(items.length, index, local);
if (isNear !== state.near) {
state.near = isNear;
setNear(isNear);
}
if (index !== state.active) changeActive(index);
if (state.near && !state.raf) {
state.last = 0;
state.raf = requestAnimationFrame(frame);
}
}, [changeActive, frame, items.length, weights]);
useEffect(() => {
let pending = 0;
const schedule = () => {
if (!pending)
pending = requestAnimationFrame(() => {
pending = 0;
measure();
});
};
schedule();
window.addEventListener("scroll", schedule, { passive: true });
window.addEventListener("resize", schedule);
const state = live.current;
return () => {
window.removeEventListener("scroll", schedule);
window.removeEventListener("resize", schedule);
cancelAnimationFrame(pending);
cancelAnimationFrame(state.raf);
state.raf = 0;
};
}, [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) => {
const container = containerRef.current;
const pin = pinRef.current;
if (!container || !pin) return;
const scrollable = container.offsetHeight - pin.offsetHeight;
const top = container.getBoundingClientRect().top + window.scrollY;
// 2px além: garante que a conta caia dentro do trecho
scrollToSmoothly(
top - headerHeight() + reelOffset(weights, index, local) * scrollable + 2,
);
};
const goTo = (index: number) => scrollToReel(index, 0);
const goToChapter = (index: number, chapterIndex: number) => {
const fraction = chapterFraction(
items[index].config,
chapterIndex,
"desktop",
);
scrollToReel(index, REEL_HOLD + fraction * (1 - REEL_HOLD * 2));
};
const current = items[active];
return (
<section
className="ds-reel"
aria-label="Vitrine animada dos projetos"
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">
<ReelIndex
items={items}
active={active}
chapter={chapter}
listRef={indexRef}
bars={bars}
onProject={goTo}
onChapter={goToChapter}
/>
<div className="ds-reel-stage">
<div className="ds-reel-set">
<div className="ds-reel-devices">
{items.map((item, index) => (
<ReelLayer
key={item.config.id}
item={item}
state={layerState(index, active)}
poster={showsPoster(index)}
attached={isAttached(index)}
videoRef={(element) => {
desktopVideos.current[index] = element;
}}
onVideoLoaded={() => syncVideo(index)}
/>
))}
</div>
<ReelCaption
items={items}
active={active}
chapter={chapter}
onExpand={openProject}
/>
</div>
</div>
</div>
<p className="sr-only" aria-live="polite">
{current
? `${current.project.title}: ${current.config.chapters[chapter]?.label ?? ""}`
: ""}
</p>
</div>
</section>
);
}
/** Lista de projetos (e, no desktop, os trechos do projeto em cena): leva a rolagem ao ponto escolhido. */
function ReelIndex({
items,
active,
chapter,
listRef,
bars,
onProject,
onChapter,
}: {
items: ShowcaseItem[];
active: number;
chapter: number;
listRef: RefObject<HTMLOListElement | null>;
bars: RefObject<Array<HTMLElement | null>>;
onProject: (index: number) => void;
onChapter: (index: number, chapter: number) => void;
}) {
return (
<nav className="ds-reel-index" aria-label="Projetos da vitrine">
<p className="ds-reel-hint">
<span aria-hidden="true" className="ds-reel-hint-line" />
Em movimento
<span className="ds-reel-hint-sub">Role para avançar</span>
</p>
<ol ref={listRef}>
{items.map((item, index) => (
<li key={item.config.id}>
<button
type="button"
className="ds-reel-pick"
aria-current={index === active ? "true" : undefined}
aria-label={item.project.title}
onClick={() => onProject(index)}
>
{/* o número vem do CSS (data-n): o texto visível do botão fica igual ao nome acessível (WCAG 2.5.3) */}
<span
className="ds-reel-num"
aria-hidden="true"
data-n={String(index + 1).padStart(2, "0")}
/>
<span className="ds-reel-name">{item.project.title}</span>
<i
className="ds-reel-bar"
aria-hidden="true"
ref={(element) => {
bars.current[index] = element;
}}
/>
</button>
{index === active && (
<ol
className="ds-reel-chapters"
aria-label={`Trechos de ${item.project.title}`}
>
{item.config.chapters.map((entry, i) => (
<li key={entry.label}>
<button
type="button"
className="ds-reel-chapter-pick"
aria-current={i === chapter ? "true" : undefined}
onClick={() => onChapter(index, i)}
>
<span className="ds-reel-chapter-num" aria-hidden="true">
{String(i + 1).padStart(2, "0")}
</span>
{entry.label}
</button>
</li>
))}
</ol>
)}
</li>
))}
</ol>
</nav>
);
}
/**
* Os aparelhos de um projeto: notebook com o vídeo que acompanha a rolagem e celular só com o pôster. Camadas
* empilhadas na mesma célula; quem não está em cena fica transparente, inerte e escondido dos leitores de tela.
*/
function ReelLayer({
item,
state,
poster,
attached,
videoRef,
onVideoLoaded,
}: {
item: ShowcaseItem;
state: "active" | "before" | "after";
/** O pôster já pode ser pedido (a vitrine está perto e este projeto é o da vez ou um vizinho). */
poster: boolean;
/** O vídeo de rolagem já pode ser baixado. */
attached: boolean;
videoRef: (element: HTMLVideoElement | null) => void;
onVideoLoaded: () => void;
}) {
const { config, project } = item;
const isActive = state === "active";
const desktop = getShowcaseAssets(config.id, "desktop");
const phone = getShowcaseAssets(config.id, "mobile");
return (
<div
className="ds-reel-layer"
data-state={state}
style={{ "--accent": config.accent } as CSSProperties}
aria-hidden={isActive ? undefined : true}
inert={!isActive}
>
<BrowserFrame url={displayUrl(project)} className="ds-reel-laptop">
<div
className="ds-reel-screen"
style={
poster ? { backgroundImage: `url(${desktop.poster})` } : undefined
}
>
<video
ref={videoRef}
src={attached ? getScrubSource(config.id) : undefined}
poster={poster ? desktop.poster : undefined}
muted
playsInline
preload="metadata"
tabIndex={-1}
aria-hidden="true"
disablePictureInPicture
disableRemotePlayback
onLoadedMetadata={onVideoLoaded}
onLoadedData={(event) => prime(event.currentTarget)}
/>
</div>
</BrowserFrame>
<PhoneFrame
className="ds-reel-phone"
poster={poster ? phone.poster : undefined}
/>
</div>
);
}
const pad = (value: number) => String(value).padStart(2, "0");
/**
* Legenda do projeto em cena: um bloco só, fora das camadas. Na troca de projeto a legenda atual some em
* CAPTION_OUT_MS e só então o conteúdo muda e ela volta (fade, sem deslocamento): nunca há título, contador ou
* botões em dobro. Dentro do mesmo projeto, o trecho troca no lugar.
*/
function ReelCaption({
items,
active,
chapter,
onExpand,
}: {
items: ShowcaseItem[];
active: number;
chapter: number;
onExpand: (id: string) => void;
}) {
const [shown, setShown] = useState({ index: active, chapter });
const leaving = shown.index !== active;
// o que vale quando a saída termina: o trecho continua mudando durante os 120 ms, e isso não reinicia a espera
const latest = useRef({ active, chapter });
useEffect(() => {
latest.current = { active, chapter };
});
// dentro do mesmo projeto o trecho troca no lugar
useEffect(() => {
if (shown.index === active && shown.chapter !== chapter)
setShown({ index: active, chapter });
}, [active, chapter, shown]);
// projeto novo: a legenda sai e, passado CAPTION_OUT_MS, o conteúdo muda
useEffect(() => {
if (shown.index === active) return;
const timer = setTimeout(
() =>
setShown({
index: latest.current.active,
chapter: latest.current.chapter,
}),
CAPTION_OUT_MS,
);
return () => clearTimeout(timer);
}, [active, shown.index]);
const item = items[shown.index];
const { config, project } = item;
return (
<div className="ds-reel-caption" data-leaving={leaving ? "" : undefined}>
<p className="ds-kicker">
{project.category} · {project.status}
</p>
<h3 className="ds-reel-title">{project.title}</h3>
{project.subtitle && <p className="ds-reel-sub">{project.subtitle}</p>}
<div className="ds-reel-chapter">
<span className="ds-reel-chapter-count">
{pad(shown.chapter + 1)}/{pad(config.chapters.length)}
</span>
<strong>{config.chapters[shown.chapter].label}</strong>
<ol className="ds-reel-ticks" aria-hidden="true">
{config.chapters.map((entry, tick) => (
<li key={entry.label} data-state={tickState(tick, shown.chapter)} />
))}
</ol>
</div>
<ProjectActions item={item} onExpand={() => onExpand(config.id)} />
</div>
);
}
@@ -7,6 +7,11 @@ import {
useShowcaseOpen,
} from "./ShowcaseProvider";
// a janela ampliada chega sob demanda (React.lazy): aqui entra já (o dublê logo abaixo), para o teste não esperar
jest.mock("./ProjectLightboxLazy", () => ({
ProjectLightboxLazy: jest.requireMock("./ProjectLightbox").ProjectLightbox,
}));
/**
* Testes de caminhos-base de ShowcaseProvider (complemento de ShowcaseProvider.test.tsx). O provedor só guarda a
* lista da vitrine e qual projeto está ampliado: aqui, a janela aberta quando a lista muda, a troca de projeto
@@ -7,6 +7,11 @@ import {
useShowcaseOpen,
} from "./ShowcaseProvider";
// a janela ampliada chega sob demanda (React.lazy): aqui entra já (o dublê logo abaixo), para o teste não esperar
jest.mock("./ProjectLightboxLazy", () => ({
ProjectLightboxLazy: jest.requireMock("./ProjectLightbox").ProjectLightbox,
}));
/** A janela ampliada é testada em ProjectLightbox.test.tsx; aqui ela é um dublê que só mostra o índice e navega. */
jest.mock("./ProjectLightbox", () => ({
ProjectLightbox: ({
+10 -7
View File
@@ -3,6 +3,7 @@
import {
createContext,
type ReactNode,
Suspense,
useCallback,
useContext,
useMemo,
@@ -10,7 +11,7 @@ import {
} from "react";
import type { PortfolioProjectView } from "@/lib/portfolio-data";
import { SHOWCASE, type ShowcaseConfig } from "@/lib/project-showcase";
import { ProjectLightbox } from "./ProjectLightbox";
import { ProjectLightboxLazy } from "./ProjectLightboxLazy";
export interface ShowcaseItem {
config: ShowcaseConfig;
@@ -71,12 +72,14 @@ export function ShowcaseProvider({
<ApiContext.Provider value={api}>
{children}
{openIndex >= 0 && (
<ProjectLightbox
items={items}
index={openIndex}
onClose={() => setOpenId(null)}
onNavigate={(index) => setOpenId(items[index]?.config.id ?? null)}
/>
<Suspense fallback={null}>
<ProjectLightboxLazy
items={items}
index={openIndex}
onClose={() => setOpenId(null)}
onNavigate={(index) => setOpenId(items[index]?.config.id ?? null)}
/>
</Suspense>
)}
</ApiContext.Provider>
);
@@ -0,0 +1,149 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { MOBILE_QUERY } from "@/lib/device";
import { getFeaturedProjects } from "@/lib/portfolio-data";
import { ProjectReel } from "./ProjectReel";
import { ShowcaseProvider, useShowcaseOpen } from "./ShowcaseProvider";
import { PINNED_QUERY } from "./useShowcaseMedia";
/*
* A vitrine fixa e a janela ampliada (código e CSS) chegam sob demanda: o módulo só é carregado quando alguém precisa
* dele. Aqui os módulos pesados são trocados por dublês que contam quantas vezes foram carregados, e o React.lazy é o
* de verdade (os outros testes de showcase o trocam por componentes síncronos). A fábrica de cada dublê roda uma vez
* por arquivo, na primeira importação: os testes seguem esta ordem (celular, janela ampliada, desktop) e a contagem
* acumula de um para o outro.
*/
const mockLoads = { pinned: 0, lightbox: 0 };
jest.mock("./PinnedReel", () => {
mockLoads.pinned += 1;
return {
__esModule: true,
default: ({ items }: { items: unknown[] }) => (
<div data-testid="pinned-reel">{items.length} projetos fixos</div>
),
};
});
jest.mock("./ProjectLightbox", () => {
mockLoads.lightbox += 1;
return {
ProjectLightbox: ({ index }: { index: number }) => (
<div role="dialog" aria-label="ampliado">
aberto:{index}
</div>
),
};
});
jest.mock("@/app/hooks/useReducedMotion", () => ({
useReducedMotion: () => false,
}));
const projects = getFeaturedProjects(null);
const originalMatchMedia = window.matchMedia;
function mockViewport({ phone }: { phone: boolean }) {
window.matchMedia = jest.fn().mockImplementation((query: string) => ({
matches:
(query === MOBILE_QUERY && phone) || (query === PINNED_QUERY && !phone),
media: query,
addEventListener() {},
removeEventListener() {},
}));
}
afterEach(() => {
window.matchMedia = originalMatchMedia;
});
function Opener() {
const open = useShowcaseOpen();
return (
<button type="button" onClick={() => open("sev-modaintima")}>
ampliar
</button>
);
}
describe("carregamento sob demanda", () => {
it("1. on a phone nothing heavy is loaded: the list is all there is, and the window waits for a click", async () => {
mockViewport({ phone: true });
render(
<ShowcaseProvider projects={projects}>
<ProjectReel />
</ShowcaseProvider>,
);
await act(async () => {
await Promise.resolve();
});
expect(mockLoads).toEqual({ pinned: 0, lightbox: 0 });
expect(screen.queryByTestId("pinned-reel")).toBeNull();
expect(
screen.getByRole("region", { name: "Projetos de clientes" }),
).toBeInTheDocument();
});
it("2. the enlarged window is loaded when a project is enlarged, and only then", async () => {
mockViewport({ phone: true });
render(
<ShowcaseProvider projects={projects}>
<Opener />
</ShowcaseProvider>,
);
expect(mockLoads.lightbox).toBe(0);
fireEvent.click(screen.getByRole("button", { name: "ampliar" }));
expect(
await screen.findByRole("dialog", { name: "ampliado" }),
).toBeInTheDocument();
expect(mockLoads.lightbox).toBe(1);
expect(mockLoads.pinned).toBe(0);
});
it("3. the pinned reel is loaded where it fits, holding the place with a block of the same height until it arrives", async () => {
mockViewport({ phone: false });
const { container } = render(
<ShowcaseProvider projects={projects}>
<ProjectReel />
</ShowcaseProvider>,
);
// enquanto o pacote chega: o bloco reservado (com a conta da altura), não uma página que encolhe
const hold = container.querySelector(".ds-reel-placeholder");
expect(hold).not.toBeNull();
expect(hold).toHaveAttribute("aria-hidden", "true");
expect((hold as HTMLElement).style.getPropertyValue("--reel-sum")).not.toBe(
"",
);
expect(screen.queryByTestId("pinned-reel")).toBeNull();
expect(await screen.findByTestId("pinned-reel")).toBeInTheDocument();
expect(mockLoads.pinned).toBe(1);
expect(container.querySelector(".ds-reel-placeholder")).toBeNull();
});
it("4. the page code never imports the heavy modules by name: only the *Lazy files do, with import()", () => {
const fs = require("node:fs") as typeof import("node:fs");
const path = require("node:path") as typeof import("node:path");
const read = (file: string) =>
fs.readFileSync(path.join(__dirname, file), "utf8");
expect(read("ShowcaseProvider.tsx")).not.toMatch(
/from "\.\/ProjectLightbox"/,
);
expect(read("ProjectReel.tsx")).not.toMatch(/from "\.\/PinnedReel"/);
expect(read("ProjectLightboxLazy.tsx")).toMatch(
/import\("\.\/ProjectLightbox"\)/,
);
expect(read("PinnedReelLazy.tsx")).toMatch(/import\("\.\/PinnedReel"\)/);
// 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"/);
expect(
fs.readFileSync(path.join(__dirname, "../../layout.tsx"), "utf8"),
).not.toMatch(/pinned-reel\.css|lightbox\.css|reel-list\.css/);
});
});
+453
View File
@@ -0,0 +1,453 @@
/*
* Vídeo ampliado (ProjectLightbox): janela modal com a troca desktop/celular, capítulos e controles próprios.
* Carrega sob demanda, na primeira vez que alguém amplia um projeto.
*/
html.ds-lb-open {
overflow: hidden;
/* a barra de rolagem clássica some junto: devolve o espaço dela para a página não pular */
padding-right: var(--ds-scrollbar, 0px);
}
.ds-lb {
--accent: var(--slash);
color-scheme: dark;
position: fixed;
inset: 0;
width: 100%;
height: 100%;
max-width: none;
max-height: none;
margin: 0;
padding: clamp(12px, 3vw, 32px);
border: 0;
color: #fff;
background: rgba(5, 5, 5, 0.88);
backdrop-filter: blur(8px);
overscroll-behavior: contain;
&[open] {
display: flex;
align-items: center;
justify-content: center;
animation: ds-lb-in 0.28s var(--ease-out);
}
&::backdrop {
background: transparent;
}
}
@keyframes ds-lb-in {
from {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.ds-lb[open] {
animation: none;
}
}
.ds-lb-panel {
display: flex;
flex-direction: column;
gap: var(--sp-4);
width: min(1180px, 100%);
height: min(100%, 900px);
min-height: 0;
padding: clamp(14px, 2.4vw, 24px);
overflow: auto;
background: #0c0c0c;
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow: 0 0 120px -30px color-mix(in srgb, var(--accent) 55%, transparent);
}
.ds-lb-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--sp-3) var(--sp-4);
}
.ds-lb-heading {
flex: 1 1 200px;
min-width: 0;
}
.ds-lb-count {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.08em;
color: var(--slash);
}
.ds-lb-title {
font-family: var(--font-display);
font-size: clamp(20px, 2.6vw, 30px);
font-weight: var(--fw-card);
line-height: 1.05;
letter-spacing: -0.03em;
}
.ds-lb-toggle {
display: inline-flex;
min-width: 0;
margin: 0;
padding: 0;
border: 1px solid rgba(255, 255, 255, 0.16);
}
.ds-lb-toggle button {
min-height: 44px;
padding: 0 16px;
font-family: var(--font-display);
font-size: 15px;
font-weight: 500;
color: rgba(255, 255, 255, 0.72);
background: transparent;
border: 0;
cursor: pointer;
transition:
background 0.2s ease,
color 0.2s ease;
/* a versão escolhida: fundo claro e sublinhado em lima (a lima só marca, não preenche o botão) */
&[aria-pressed="true"] {
color: #fff;
background: rgba(255, 255, 255, 0.12);
box-shadow: inset 0 -2px 0 var(--slash);
}
}
.ds-lb-nav {
display: flex;
gap: 6px;
}
.ds-lb-nav button {
display: grid;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
color: #fff;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.16);
cursor: pointer;
transition: border-color 0.2s ease;
@media (hover: hover) {
&:hover:not(:disabled) {
border-color: rgba(255, 255, 255, 0.5);
}
}
&:disabled {
opacity: 0.3;
cursor: default;
}
}
/* o palco mede a si mesmo: o aparelho cresce até o limite da largura ou da altura que sobrar */
.ds-lb-stage {
flex: 1 1 0;
min-height: 200px;
display: flex;
align-items: center;
justify-content: center;
container-type: size;
}
.ds-lb-frame {
width: min(100cqw, calc((100cqh - 30px) * 1.6));
}
.ds-lb-screen {
position: relative;
aspect-ratio: 16 / 10;
overflow: hidden;
background: #0a0a0a center top / cover no-repeat;
}
.ds-lb-video {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
}
.ds-lb-phone {
height: min(100cqh, calc(100cqw * 866 / 390));
}
.ds-lb-controls {
display: flex;
align-items: center;
gap: var(--sp-3);
}
.ds-lb-play {
display: grid;
flex: none;
place-items: center;
width: 44px;
height: 44px;
padding: 0;
color: #fff;
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.5);
cursor: pointer;
@media (hover: hover) {
&:hover {
border-color: #fff;
}
}
& svg {
width: 16px;
height: 16px;
fill: currentColor;
}
}
.ds-lb-nav button svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
}
.ds-lb-time {
flex: none;
min-width: 72px;
font-family: var(--font-mono);
font-size: 12px;
font-variant-numeric: tabular-nums;
color: rgba(255, 255, 255, 0.7);
}
.ds-lb-range {
flex: 1 1 auto;
min-width: 0;
height: 44px;
margin: 0;
background: transparent;
appearance: none;
-webkit-appearance: none;
cursor: pointer;
&::-webkit-slider-runnable-track {
height: 3px;
background: rgba(255, 255, 255, 0.22);
}
&::-moz-range-track {
height: 3px;
background: rgba(255, 255, 255, 0.22);
}
&::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
margin-top: -5.5px;
border: 0;
border-radius: 0;
background: var(--slash);
}
&::-moz-range-thumb {
width: 14px;
height: 14px;
border: 0;
border-radius: 0;
background: var(--slash);
}
}
.ds-lb-chapters {
display: flex;
flex: none; /* é filha de coluna flex: sem isto a fileira rolável encolhe a quase nada em celular baixo */
flex-wrap: wrap;
gap: 8px;
list-style: none;
}
.ds-lb-chapters button {
min-height: 40px;
padding: 0 14px;
font-family: var(--font-display);
font-size: 15px;
white-space: nowrap;
color: rgba(255, 255, 255, 0.76);
background: transparent;
border: 1px solid rgba(255, 255, 255, 0.14);
cursor: pointer;
transition:
color 0.2s ease,
border-color 0.2s ease;
@media (hover: hover) {
&:hover {
color: #fff;
border-color: rgba(255, 255, 255, 0.4);
}
}
&[aria-current="true"] {
color: #fff;
border-color: rgba(255, 255, 255, 0.7);
}
}
.ds-lb-chapters button span {
font-family: var(--font-mono);
font-size: 12px;
color: var(--slash);
}
@media (pointer: coarse) {
.ds-lb-chapters button {
min-height: 44px;
}
}
.ds-lb-links {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--sp-2) var(--sp-3);
& .ds-link.ds-link-muted {
padding: 8px 16px;
color: #fff;
border-color: rgba(255, 255, 255, 0.38);
background: transparent;
}
& .ds-link.ds-link-text {
color: rgba(255, 255, 255, 0.84);
}
}
@media (max-width: 639px) {
.ds-lb {
padding: 0;
}
.ds-lb-panel {
width: 100%;
height: 100%;
border: 0;
}
.ds-lb-chapters {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
padding-bottom: 2px;
}
.ds-lb-chapters::-webkit-scrollbar {
display: none;
}
.ds-lb-time {
display: none;
}
}
/* telas muito estreitas: nome, versão e setas não cabem na mesma linha; o alternador de versão ocupa uma só */
@media (max-width: 389px) {
.ds-lb-panel {
gap: var(--sp-3);
}
.ds-lb-heading {
flex-basis: 0;
}
.ds-lb-toggle {
order: 3;
flex: 1 1 100%;
}
.ds-lb-toggle button {
flex: 1;
}
}
/* celular deitado e janela de desktop curta: vídeo grande ao lado, controles em coluna (empilhado, o vídeo ficava com 200px de altura) */
@media ((max-height: 520px) and (min-width: 640px)) or (
(max-height: 700px) and (min-width: 900px)
) {
.ds-lb {
padding: 0;
}
.ds-lb-panel {
display: grid;
grid-template-columns: minmax(260px, 320px) minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr) auto;
grid-template-areas:
"head stage"
"ctrl stage"
"chap stage"
"links stage";
gap: var(--sp-3) var(--sp-4);
width: 100%;
height: 100%;
border: 0;
}
.ds-lb-head {
grid-area: head;
}
.ds-lb-controls {
grid-area: ctrl;
}
.ds-lb-chapters {
grid-area: chap;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: none;
}
.ds-lb-chapters::-webkit-scrollbar {
display: none;
}
.ds-lb-links {
grid-area: links;
}
.ds-lb-stage {
grid-area: stage;
min-height: 0;
}
}
/* ─────────────────────────── Foco ─────────────────────────── */
/* Lima, por dentro, nos botões que ficam dentro de contêineres que cortam o que passa da borda; por fora, nos soltos.
Sem o halo do anel duplo global (globals.css): ele é para fundos claros. */
:is(.ds-lb-toggle button, .ds-lb-chapters button):focus-visible {
outline: 2px solid var(--slash);
outline-offset: -4px;
box-shadow: none;
}
:is(.ds-lb-nav button, .ds-lb-play, .ds-lb-range):focus-visible {
outline: 2px solid var(--slash);
outline-offset: 2px;
box-shadow: none;
}
+382
View File
@@ -0,0 +1,382 @@
/*
* Vitrine animada (ProjectReel mostra a fixa quando ela cabe): o site de cada projeto rola dentro de um notebook (e de
* um celular ao lado) conforme a página rola. Carrega sob demanda, junto com PinnedReel; o casco `.ds-reel`, a legenda
* e os botões vêm de reel-list.css.
*/
.ds-reel-pin {
position: sticky;
top: var(--site-header-height);
height: var(--pin-h);
overflow: clip;
}
.ds-reel-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto minmax(0, 1fr);
gap: var(--sp-4);
height: 100%;
padding-block: var(--sp-4);
}
@media (min-width: 1024px) {
.ds-reel-grid {
grid-template-columns: minmax(190px, 250px) minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
gap: var(--sp-10);
padding-block: var(--sp-6);
}
}
/* lista de projetos: faixa rolável na horizontal em telas pequenas, coluna no desktop */
.ds-reel-index {
min-width: 0;
& > ol {
display: flex;
gap: 8px;
list-style: none;
overflow-x: auto;
scrollbar-width: none;
}
& > ol::-webkit-scrollbar {
display: none;
}
}
.ds-reel-pick {
position: relative;
display: flex;
align-items: center;
gap: 10px;
min-height: 44px;
padding: 0 14px;
font-family: var(--font-display);
font-size: 15px;
font-weight: 500;
white-space: nowrap;
color: rgba(255, 255, 255, 0.72);
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.12);
cursor: pointer;
transition:
color 0.25s ease,
border-color 0.25s ease,
background 0.25s ease;
@media (hover: hover) {
&:hover {
color: #fff;
border-color: rgba(255, 255, 255, 0.3);
}
}
&[aria-current="true"] {
color: #fff;
border-color: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.08);
}
}
.ds-reel-hint {
display: none;
align-items: center;
flex-wrap: wrap;
gap: 6px 10px;
margin-bottom: var(--sp-5);
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--slash);
}
.ds-reel-hint-line {
width: 24px;
height: 1px;
background: var(--slash);
}
.ds-reel-hint-sub {
width: 100%;
color: rgba(255, 255, 255, 0.5);
}
.ds-reel-num {
font-family: var(--font-mono);
font-size: 12px;
color: var(--slash);
&::before {
content: attr(data-n);
}
}
.ds-reel-name {
display: none;
}
/* trechos do projeto em cena: só no desktop, onde sobra coluna; cada um leva ao ponto do vídeo */
.ds-reel-chapters {
display: none;
list-style: none;
margin: 6px 0 10px 16px;
border-left: 1px solid rgba(255, 255, 255, 0.12);
}
.ds-reel-chapter-pick {
display: flex;
align-items: center;
width: 100%;
min-height: 32px;
gap: 10px;
padding: 0 12px;
border: 0;
background: transparent;
font-family: var(--font-display);
font-size: 15px;
line-height: 1.25;
color: rgba(255, 255, 255, 0.66);
text-align: left;
@media (hover: hover) {
&:hover {
color: #fff;
}
}
&[aria-current="true"] {
color: var(--slash);
}
}
.ds-reel-chapter-num {
font-family: var(--font-mono);
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
}
.ds-reel-chapter-pick[aria-current="true"] .ds-reel-chapter-num {
color: var(--slash);
}
@media (min-width: 1024px) {
.ds-reel-chapters {
display: grid;
}
}
@media (min-width: 1024px) and (pointer: coarse) {
.ds-reel-chapter-pick {
min-height: 44px;
}
}
.ds-reel-bar {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 2px;
background: var(--slash);
transform: scaleX(var(--p, 0));
transform-origin: left;
opacity: 0;
}
.ds-reel-pick[aria-current="true"] .ds-reel-bar {
opacity: 1;
}
@media (min-width: 640px) {
.ds-reel-name {
display: inline;
}
}
@media (min-width: 1024px) {
.ds-reel-hint {
display: flex;
}
.ds-reel-index > ol {
flex-direction: column;
gap: 6px;
overflow: visible;
}
.ds-reel-pick {
width: 100%;
gap: 14px;
padding: 0 16px;
}
}
.ds-reel-stage {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
/* o palco mede a si mesmo: o conjunto cresce até o limite da largura ou da altura que sobrar */
container-type: size;
}
/* aparelhos + legenda, um bloco só: a legenda tem sempre a mesma largura e o mesmo canto esquerdo do notebook */
.ds-reel-set {
--caption-h: 13.75rem; /* reserva da legenda: título, trecho atual e botões */
--gap: var(--sp-6);
display: flex;
flex-direction: column;
gap: var(--gap);
/* cabe na largura (com folga para o celular sobrepor) e na altura (legenda + barra de 30px + tela 16:10) */
width: min(90cqw, calc((100cqh - var(--caption-h) - var(--gap) - 30px) * 1.6));
min-width: min(100%, 320px);
}
/* as camadas de aparelhos (uma por projeto) ficam empilhadas na mesma célula: o bloco tem a altura de um par */
.ds-reel-devices {
position: relative;
display: grid;
}
.ds-reel-set {
isolation: isolate;
}
/* a troca de projeto é um fade cruzado só dos aparelhos (a legenda é um bloco à parte, abaixo): sem deslocamento.
Quem não está em cena já é inert (sem foco, sem clique, fora da busca) e aria-hidden. */
.ds-reel-layer {
--accent: var(--slash);
position: relative;
grid-area: 1 / 1;
min-width: 0;
opacity: 0;
pointer-events: none;
transition: opacity 0.45s ease;
&[data-state="active"] {
opacity: 1;
pointer-events: auto;
}
/* brilho na cor do site, atrás dos aparelhos */
&::before {
content: "";
position: absolute;
inset: -4% 0;
z-index: -1;
background: radial-gradient(
55% 60% at 50% 50%,
color-mix(in srgb, var(--accent) 42%, transparent),
transparent 72%
);
opacity: 0.45;
pointer-events: none;
}
}
.ds-reel-laptop {
width: 100%;
}
.ds-reel-screen {
position: relative;
aspect-ratio: 16 / 10;
overflow: hidden;
background: #0a0a0a center top / cover no-repeat;
& > video {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
object-fit: cover;
object-position: top;
}
}
.ds-reel-phone {
position: absolute;
right: -5%;
bottom: -3%;
width: 22%;
min-width: 88px;
}
.ds-reel-chapter {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px 12px;
margin-top: 4px;
font-size: 15px;
color: #fff;
& strong {
font-weight: 600;
}
}
.ds-reel-chapter-count {
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.06em;
color: var(--slash);
}
.ds-reel-ticks {
display: flex;
gap: 4px;
list-style: none;
& li {
width: 18px;
height: 3px;
background: rgba(255, 255, 255, 0.18);
transition: background 0.3s ease;
}
& li[data-state="done"] {
background: rgba(204, 255, 0, 0.5);
}
& li[data-state="now"] {
background: var(--slash);
}
}
/* 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 {
display: none;
}
}
@media (min-width: 1024px) and (max-height: 700px) and (pointer: fine) {
.ds-reel-pick {
min-height: 38px;
}
.ds-reel-chapter-pick {
min-height: 28px;
}
}
/* janela curta: a reserva da legenda encolhe e o notebook ganha tela */
@media (min-width: 640px) and (max-height: 700px) {
.ds-reel-set {
--caption-h: 10.5rem;
}
}
/* Anel de foco do índice (sempre sobre fundo escuro): lima, por dentro, porque a faixa rolável e a lista de trechos
cortam o que passa da borda. Sem o halo do anel duplo global (globals.css): ele é para fundos claros. */
:is(.ds-reel-pick, .ds-reel-chapter-pick):focus-visible {
outline: 2px solid var(--slash);
outline-offset: -4px;
box-shadow: none;
}
+324
View File
@@ -0,0 +1,324 @@
/*
* 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
* 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
* (--accent) só no brilho ao redor do vídeo.
*/
/* ─────────────────────────── Casco da vitrine fixa ─────────────────────────── */
.ds-reel {
--pin-h: calc(100svh - var(--site-header-height));
position: relative;
margin-top: var(--sp-16);
/* a cena fica presa durante (soma dos pesos) alturas de tela de rolagem */
height: calc(var(--pin-h) + var(--reel-sum, 1) * 100svh);
}
/* 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;
}
}
/* ─────────────────────────── Legenda e botões de um projeto ─────────────────────────── */
/* uma só legenda para o projeto em cena: na troca ela some depressa (120 ms, o mesmo CAPTION_OUT_MS de
ProjectReel.tsx) e volta com o conteúdo novo; só opacidade, o texto nunca se desloca */
.ds-reel-caption {
flex: none;
display: grid;
gap: 6px;
min-width: 0;
transition: opacity 0.2s ease-out;
&[data-leaving] {
opacity: 0;
pointer-events: none;
transition: opacity 0.12s linear;
}
}
.ds-reel-title {
font-family: var(--font-display);
font-size: clamp(22px, 3vw, 38px);
font-weight: var(--fw-card);
line-height: 1.02;
letter-spacing: -0.03em;
color: #fff;
}
.ds-reel-sub {
font-size: 15px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.68);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* 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;
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-expand {
cursor: pointer;
& svg {
flex: none;
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.6;
}
}
/* janela curta: sem o subtítulo a legenda cabe em menos altura */
@media (min-width: 640px) and (max-height: 700px) {
.ds-reel-sub {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ds-reel {
display: none;
}
}
/* ─────────────────────────── 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%
);
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; texto corrido em 15 px */
.ds-reel-item .ds-reel-sub {
font-size: 15px;
-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;
}
}
/* ─────────────────────────── Carrossel do celular: contagem e botões ─────────────────────────── */
.ds-reel-count {
display: none;
}
@media (max-width: 767px) {
/* "1/4" e pontos logo abaixo do carrossel: quantos há e em qual se está */
.ds-reel-count {
display: flex;
align-items: center;
justify-content: center;
gap: var(--sp-3);
margin-top: var(--sp-1);
font-family: var(--font-mono);
font-size: 12px;
letter-spacing: 0.08em;
color: rgba(255, 255, 255, 0.72);
}
.ds-reel-dots {
display: flex;
gap: 6px;
& i {
width: 6px;
height: 6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.24);
transition: background 0.2s ease;
}
& i[data-on] {
background: var(--slash);
}
}
/* botões alinhados em todos os cartões: "Ver case" em largura total, "Ampliar" só com o ícone (44 px, o alvo de
toque mínimo) ao lado, e "Abrir site" numa linha só dele */
.ds-reel-item .ds-reel-actions {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: var(--sp-2) 0;
margin-top: var(--sp-3);
& .ds-link.ds-link-muted:not(.ds-reel-expand) {
width: 100%;
}
& .ds-reel-expand {
width: 44px;
margin-left: var(--sp-2);
padding: 0;
}
& .ds-link-text {
grid-column: 1 / -1;
justify-self: start;
}
}
/* o rótulo sai da vista mas não do leitor de tela (mesma técnica do .sr-only) */
.ds-reel-item .ds-reel-expand-label {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
}
+3
View File
@@ -64,3 +64,6 @@ export function easeToward(
const factor = 1 - Math.exp(-Math.max(0, deltaSeconds) / tau);
return current + (target - current) * factor;
}
/** Saída da legenda (ms) antes de a do novo projeto entrar: curta, para a troca nunca mostrar duas legendas. */
export const CAPTION_OUT_MS = 120;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,166 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import type { KeyboardEvent } from "react";
import { moveRovingFocus, useRovingTabindex } from "./useRovingTabindex";
/**
* Testes de caminhos-base de moveRovingFocus e useRovingTabindex (complemento de useRovingTabindex.test.tsx):
* cada tecla de gesto, cada modificador sozinho, grupo de um item só, o item atual escondido por CSS, um alvo que
* não é elemento, o foco em algo do grupo que não é item e a parada que deixa de valer.
*/
const KEYS = ["p:0", "p:1", "p:2"];
function Group({
count = 3,
validBelow = 99,
}: {
count?: number;
/** Chaves `p:<n>` com n a partir daqui deixam de valer (o item some do grupo). */
validBelow?: number;
}) {
const { groupProps, itemProps } = useRovingTabindex("p:0", (key) => {
const [, index] = key.split(":");
return Number(index) < validBelow;
});
return (
<nav aria-label="grupo" {...groupProps}>
{KEYS.slice(0, count).map((key) => (
<button key={key} type="button" {...itemProps(key)}>
{`item ${key.slice(2)}`}
</button>
))}
<a href="#fora">link comum</a>
</nav>
);
}
const item = (index: number) =>
screen.getByRole("button", { name: `item ${index}` });
describe("moveRovingFocus: um gesto por tecla", () => {
it.each([
["ArrowDown", 2],
["ArrowRight", 2],
["ArrowUp", 0],
["ArrowLeft", 0],
["Home", 0],
["End", 2],
])(
"path: %s from the middle item -> focus on item %i, the default is cancelled",
(key, expected) => {
render(<Group />);
item(1).focus();
expect(fireEvent.keyDown(item(1), { key })).toBe(false);
expect(item(expected)).toHaveFocus();
},
);
it.each(["altKey", "ctrlKey", "metaKey", "shiftKey"] as const)(
"path: ArrowDown with %s alone -> left to the browser (not cancelled, focus stays)",
(modifier) => {
render(<Group />);
item(0).focus();
expect(
fireEvent.keyDown(item(0), { key: "ArrowDown", [modifier]: true }),
).toBe(true);
expect(item(0)).toHaveFocus();
},
);
it("path: a group with one item only -> every gesture stays on it (and is still cancelled)", () => {
render(<Group count={1} />);
item(0).focus();
for (const key of ["ArrowDown", "ArrowUp", "Home", "End"]) {
expect(fireEvent.keyDown(item(0), { key })).toBe(false);
expect(item(0)).toHaveFocus();
}
});
});
describe("moveRovingFocus: quando o gesto não é do grupo", () => {
it("path: the current item is itself hidden by CSS -> it is not among the shown ones, so the key is left alone", () => {
const original = HTMLElement.prototype.checkVisibility;
HTMLElement.prototype.checkVisibility = function check(this: HTMLElement) {
return this.textContent !== "item 1";
};
try {
render(<Group />);
item(1).focus();
expect(fireEvent.keyDown(item(1), { key: "ArrowDown" })).toBe(true);
expect(item(1)).toHaveFocus();
} finally {
HTMLElement.prototype.checkVisibility = original;
}
});
it("path: the browser has no checkVisibility -> every item counts as shown", () => {
const original = HTMLElement.prototype.checkVisibility;
Reflect.deleteProperty(HTMLElement.prototype, "checkVisibility");
try {
render(<Group />);
item(0).focus();
expect(fireEvent.keyDown(item(0), { key: "End" })).toBe(false);
expect(item(2)).toHaveFocus();
} finally {
HTMLElement.prototype.checkVisibility = original;
}
});
it("path: the key target is not an element (the document) -> nothing happens", () => {
const preventDefault = jest.fn();
const group = document.createElement("div");
moveRovingFocus({
key: "ArrowDown",
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
target: document,
currentTarget: group,
preventDefault,
} as unknown as KeyboardEvent<HTMLElement>);
expect(preventDefault).not.toHaveBeenCalled();
});
it("path: a key that is not a gesture (a letter) -> left alone", () => {
render(<Group />);
item(0).focus();
expect(fireEvent.keyDown(item(0), { key: "a" })).toBe(true);
});
});
describe("useRovingTabindex: a parada do Tab", () => {
it("path: focus on something of the group that is not an item -> the tab stop does not move", () => {
render(<Group />);
act(() => item(2).focus());
expect(item(2)).toHaveAttribute("tabindex", "0");
act(() => screen.getByRole("link", { name: "link comum" }).focus());
expect(item(2)).toHaveAttribute("tabindex", "0");
expect(item(0)).toHaveAttribute("tabindex", "-1");
});
it("path: the remembered stop stops being valid (that item left the group) -> back to the fallback", () => {
const { rerender } = render(<Group />);
act(() => item(2).focus());
expect(item(2)).toHaveAttribute("tabindex", "0");
rerender(<Group count={2} validBelow={2} />);
expect(item(0)).toHaveAttribute("tabindex", "0");
expect(item(1)).toHaveAttribute("tabindex", "-1");
});
});
@@ -0,0 +1,144 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { useRovingTabindex } from "./useRovingTabindex";
function Group({ hidden = false }: { hidden?: boolean }) {
const [current, setCurrent] = useState(0);
const { groupProps, itemProps } = useRovingTabindex(`p:${current}`, (key) =>
key.startsWith("p:"),
);
return (
<>
<button type="button">antes</button>
<nav aria-label="grupo" {...groupProps}>
{["a", "b", "c"].map((name, index) => (
<button
key={name}
type="button"
onClick={() => setCurrent(index)}
{...itemProps(`p:${index}`)}
>
{name}
</button>
))}
{hidden && (
<button type="button" {...itemProps("p:9")} hidden>
escondido
</button>
)}
</nav>
<button type="button">depois</button>
</>
);
}
const tabIndexes = () =>
["a", "b", "c"].map((name) =>
screen.getByRole("button", { name }).getAttribute("tabindex"),
);
describe("useRovingTabindex", () => {
it("makes the group ONE tab stop: only the current item has tabindex 0", () => {
render(<Group />);
expect(tabIndexes()).toEqual(["0", "-1", "-1"]);
// fora do grupo, os botões comuns seguem como estão
expect(screen.getByRole("button", { name: "antes" })).not.toHaveAttribute(
"tabindex",
);
});
it("moves the focus with the arrows (and Home/End), stopping at the ends", () => {
render(<Group />);
const [a, b, c] = ["a", "b", "c"].map((name) =>
screen.getByRole("button", { name }),
);
a.focus();
fireEvent.keyDown(a, { key: "ArrowDown" });
expect(b).toHaveFocus();
fireEvent.keyDown(b, { key: "ArrowRight" });
expect(c).toHaveFocus();
fireEvent.keyDown(c, { key: "ArrowDown" });
expect(c).toHaveFocus();
fireEvent.keyDown(c, { key: "ArrowUp" });
expect(b).toHaveFocus();
fireEvent.keyDown(b, { key: "ArrowLeft" });
expect(a).toHaveFocus();
fireEvent.keyDown(a, { key: "ArrowUp" });
expect(a).toHaveFocus();
fireEvent.keyDown(a, { key: "End" });
expect(c).toHaveFocus();
fireEvent.keyDown(c, { key: "Home" });
expect(a).toHaveFocus();
});
it("keeps the tab stop where the focus last was, so Shift+Tab and Tab come back to the same item", () => {
render(<Group />);
const b = screen.getByRole("button", { name: "b" });
screen.getByRole("button", { name: "a" }).focus();
fireEvent.keyDown(screen.getByRole("button", { name: "a" }), {
key: "ArrowDown",
});
expect(b).toHaveFocus();
expect(tabIndexes()).toEqual(["-1", "0", "-1"]);
});
it("follows the current item while nothing has been focused yet", () => {
const { rerender } = render(<Group />);
fireEvent.click(screen.getByRole("button", { name: "c" }));
rerender(<Group />);
expect(tabIndexes()).toEqual(["-1", "-1", "0"]);
});
it("leaves Tab, Enter, space and any arrow with a modifier alone (no preventDefault)", () => {
render(<Group />);
const a = screen.getByRole("button", { name: "a" });
a.focus();
for (const init of [
{ key: "Tab" },
{ key: "Enter" },
{ key: " " },
{ key: "ArrowDown", altKey: true },
{ key: "ArrowDown", ctrlKey: true },
{ key: "ArrowDown", metaKey: true },
{ key: "ArrowDown", shiftKey: true },
])
expect(fireEvent.keyDown(a, init)).toBe(true);
expect(a).toHaveFocus();
});
it("cancels the page scroll of an arrow that moved the focus", () => {
render(<Group />);
const a = screen.getByRole("button", { name: "a" });
a.focus();
expect(fireEvent.keyDown(a, { key: "ArrowDown" })).toBe(false);
expect(
fireEvent.keyDown(screen.getByRole("button", { name: "b" }), {
key: "Home",
}),
).toBe(false);
});
it("skips items that CSS hides (they cannot take the focus)", () => {
const original = HTMLElement.prototype.checkVisibility;
HTMLElement.prototype.checkVisibility = function check(this: HTMLElement) {
return !this.hidden;
};
try {
render(<Group hidden />);
const c = screen.getByRole("button", { name: "c" });
c.focus();
fireEvent.keyDown(c, { key: "ArrowDown" });
expect(c).toHaveFocus();
fireEvent.keyDown(c, { key: "End" });
expect(c).toHaveFocus();
} finally {
HTMLElement.prototype.checkVisibility = original;
}
});
it("ignores keys pressed on something that is not one of the items", () => {
render(<Group />);
const nav = screen.getByRole("navigation", { name: "grupo" });
expect(fireEvent.keyDown(nav, { key: "ArrowDown" })).toBe(true);
});
});
@@ -0,0 +1,80 @@
"use client";
import { type FocusEvent, type KeyboardEvent, useState } from "react";
/** Atributo que marca os itens do grupo; o valor é a chave do item. */
const ITEM_ATTRIBUTE = "data-roving";
type Step = "next" | "previous" | "first" | "last";
const STEPS: Record<string, Step> = {
ArrowDown: "next",
ArrowRight: "next",
ArrowUp: "previous",
ArrowLeft: "previous",
Home: "first",
End: "last",
};
function targetIndex(step: Step, at: number, count: number) {
if (step === "first") return 0;
if (step === "last") return count - 1;
return Math.min(count - 1, Math.max(0, at + (step === "next" ? 1 : -1)));
}
/** O item aparece na tela? (um item dentro de um bloco escondido por CSS não recebe foco). */
const isShown = (element: HTMLElement) => element.checkVisibility?.() ?? true;
/**
* Teclado de um grupo de itens com roving tabindex: as setas (e Home/End) movem o foco entre os itens marcados com
* `data-roving`, na ordem do documento, sem passar das pontas. Quem não é um desses gestos segue o caminho normal.
*/
export function moveRovingFocus(event: KeyboardEvent<HTMLElement>) {
const step = STEPS[event.key];
if (!step || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey)
return;
const current =
event.target instanceof Element
? event.target.closest<HTMLElement>(`[${ITEM_ATTRIBUTE}]`)
: null;
if (!current) return;
const items = Array.from(
event.currentTarget.querySelectorAll<HTMLElement>(`[${ITEM_ATTRIBUTE}]`),
).filter(isShown);
const at = items.indexOf(current);
if (at < 0) return;
event.preventDefault();
items[targetIndex(step, at, items.length)]?.focus();
}
/**
* Um grupo de controles que ocupa uma parada só no Tab (roving tabindex): só o item da vez tem tabindex 0 e os
* demais -1; as setas movem o foco dentro do grupo. A parada é o último item que recebeu foco, enquanto ele existir
* (`isValid`); senão, `fallback` (o item atual do grupo).
*
* @example
* const { groupProps, itemProps } = useRovingTabindex("p:0", (key) => key.startsWith("p:"));
* <nav {...groupProps}><button {...itemProps("p:0")}>…</button></nav>
*/
export function useRovingTabindex(
fallback: string,
isValid: (key: string) => boolean,
) {
const [stop, setStop] = useState<string | null>(null);
const tabStop = stop !== null && isValid(stop) ? stop : fallback;
return {
groupProps: {
onKeyDown: moveRovingFocus,
onFocus(event: FocusEvent<HTMLElement>) {
const key = event.target
.closest(`[${ITEM_ATTRIBUTE}]`)
?.getAttribute(ITEM_ATTRIBUTE);
if (key) setStop(key);
},
},
itemProps: (key: string) => ({
[ITEM_ATTRIBUTE]: key,
tabIndex: key === tabStop ? 0 : -1,
}),
};
}