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.
81 lines
2.7 KiB
TypeScript
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,
|
|
}),
|
|
};
|
|
}
|