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:
1 parent
f8545cd442
commit
6ac87824f3
68 files changed
+5735
-3300
No files matched your search
@@ -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"));
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: ({
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user