Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 | 176x 176x 176x 176x 176x 176x 103x 103x 88x 1596x 176x 3x 3x 3x 3x 3x 10x 32x 10x 176x 87x 87x 72x 1432x 72x 1432x 176x 116x 12x 176x 16x 16x 16x 16x 16x 5x 5x 5x 16x 2x 2x 2x 3x 3x 3x 3x 3x 3x 3x 2x 2x 2x 2x 2x 2x 3x 3x 3x 2x 2x 3x 1x 176x 3x 3x 3x 4x 4x 3x 3x 3x 3x 3x 3x 3x 3x | import { useCallback, useEffect, useRef, type KeyboardEvent } from "react";
/**
* Roving-tabindex keyboard/D-pad navigation for the on-screen keyboard.
*
* Exactly one key is tabbable at a time; Arrow keys move focus (2D, geometry
* aware), Home/End jump to the first/last key, and Escape blurs the keyboard
* (and invokes `onEscape`, e.g. to close a floating/docked keyboard).
* `Space`/`Enter` fall through to the native `<button>` activation.
*
* Keys must render with `tabIndex={-1}` and a `data-vk-key` attribute; this hook
* promotes the appropriate one to `tabIndex={0}` imperatively (React never
* rewrites the constant `-1` prop, so the assignment sticks).
*
* @param resetKey - Identity that, when it changes, re-normalizes the roving
* tabindex (pass the current layout, gated on open, so it runs on mount /
* key-set change / each open).
* @param onEscape - Called when Escape is pressed inside the keyboard. Return
* `true` if it handled the key (e.g. closed the keyboard) so the event is not
* also bubbled to an ancestor (a parent modal, etc.).
* @param rtl - When true, ArrowLeft/ArrowRight are mirrored so the arrows always
* move to the visually-adjacent key (rows render reversed under `dir="rtl"`).
*/
export function useRovingFocus<T extends HTMLElement = HTMLDivElement>(
resetKey?: unknown,
onEscape?: () => boolean | void,
rtl = false
) {
const containerRef = useRef<T>(null);
const onEscapeRef = useRef(onEscape);
onEscapeRef.current = onEscape;
const rtlRef = useRef(rtl);
rtlRef.current = rtl;
const getKeys = useCallback((): HTMLButtonElement[] => {
const root = containerRef.current;
if (!root) return [];
return Array.from(
root.querySelectorAll<HTMLButtonElement>("button[data-vk-key]")
).filter((el) => !el.disabled);
}, []);
// The keys grouped by their DOM row, for structural up/down navigation
// (geometry-free, so it works during SSR/tests without layout).
const getGrid = useCallback((): HTMLButtonElement[][] => {
const root = containerRef.current;
Iif (!root) return [];
const rows = root.querySelectorAll<HTMLElement>("[data-vk-row]");
const rowEls = rows.length > 0 ? Array.from(rows) : Array.from(root.children);
return rowEls
.map((row) =>
Array.from(
row.querySelectorAll<HTMLButtonElement>("button[data-vk-key]")
).filter((el) => !el.disabled)
)
.filter((keys) => keys.length > 0);
}, []);
// Keep exactly one key tabbable (the focused one, else the first). Scoped to
// `resetKey` (the current layout identity) so it runs on mount and when the
// key set changes — not on every keystroke.
useEffect(() => {
const keys = getKeys();
if (keys.length === 0) return;
const active = document.activeElement;
const focused = keys.find((k) => k === active);
const chosen = focused ?? keys[0];
for (const k of keys) k.tabIndex = k === chosen ? 0 : -1;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [resetKey, getKeys]);
const focusKey = useCallback((keys: HTMLButtonElement[], target: HTMLButtonElement) => {
for (const k of keys) k.tabIndex = k === target ? 0 : -1;
target.focus();
}, []);
const onKeyDown = useCallback(
(event: KeyboardEvent<T>) => {
const keys = getKeys();
Iif (keys.length === 0) return;
const current =
(document.activeElement as HTMLButtonElement | null) ?? keys[0];
const currentIndex = keys.indexOf(current);
// Move `delta` logical steps (with wrap) and focus the result.
const moveBy = (delta: number) => {
const base = currentIndex < 0 ? 0 : currentIndex;
const i = (base + delta + keys.length) % keys.length;
focusKey(keys, keys[i]);
};
switch (event.key) {
case "ArrowLeft": {
event.preventDefault();
// In RTL the visually-left key is the NEXT logical key.
moveBy(rtlRef.current ? 1 : -1);
return;
}
case "ArrowRight": {
event.preventDefault();
moveBy(rtlRef.current ? -1 : 1);
return;
}
case "ArrowDown":
case "ArrowUp": {
event.preventDefault();
const target = findVertical(
getGrid(),
current,
event.key === "ArrowDown"
);
Eif (target) focusKey(keys, target);
return;
}
case "Home": {
event.preventDefault();
focusKey(keys, keys[0]);
return;
}
case "End": {
event.preventDefault();
focusKey(keys, keys[keys.length - 1]);
return;
}
case "Escape": {
current.blur();
const handled = onEscapeRef.current?.();
if (handled) {
// The keyboard consumed Escape (closed) — don't also bubble it to an
// ancestor (e.g. a parent modal/dialog).
event.preventDefault();
event.stopPropagation();
}
return;
}
default:
return;
}
},
[getKeys, focusKey]
);
return { containerRef, onKeyDown };
}
/**
* Move focus to the key in the adjacent row that sits at the same column index
* (clamped to that row's length). Structural — no geometry — so it behaves
* predictably across ragged rows and works without a layout engine.
*/
function findVertical(
grid: HTMLButtonElement[][],
current: HTMLButtonElement,
down: boolean
): HTMLButtonElement | null {
let rowIndex = -1;
let colIndex = -1;
for (let r = 0; r < grid.length; r++) {
const c = grid[r].indexOf(current);
if (c !== -1) {
rowIndex = r;
colIndex = c;
break;
}
}
Iif (rowIndex === -1) return null;
const nextRow = down ? rowIndex + 1 : rowIndex - 1;
const target = grid[nextRow];
Iif (!target || target.length === 0) return null;
return target[Math.min(colIndex, target.length - 1)];
}
|