"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 = { 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) { 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(`[${ITEM_ATTRIBUTE}]`) : null; if (!current) return; const items = Array.from( event.currentTarget.querySelectorAll(`[${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:")); * */ export function useRovingTabindex( fallback: string, isValid: (key: string) => boolean, ) { const [stop, setStop] = useState(null); const tabStop = stop !== null && isValid(stop) ? stop : fallback; return { groupProps: { onKeyDown: moveRovingFocus, onFocus(event: FocusEvent) { 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, }), }; }