Files
Evandro Fonseca Junior 6ac87824f3 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.
2026-10-14 17:01:37 -03:00

81 lines
2.7 KiB
TypeScript

"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,
}),
};
}