forked from akai/readest
f361698e05
* feat(android): add D-pad navigation for Android TV remote controller Add basic D-pad/arrow key navigation support for Android TV Bluetooth remote controllers, covering the full flow: library grid browsing, reader page turning, toolbar interaction, and TTS toggle. Library: - Custom spatial navigation hook for grid D-pad navigation - Arrow keys move between BookshelfItem elements with auto column detection - ArrowDown from header enters the bookshelf grid Reader: - Enter key toggles header/footer toolbar visibility - Left/Right navigate between toolbar buttons, Up/Down moves between header and footer bars - Back button dismisses toolbar (with blur) before sidebar/library - Focus-probe technique for reliable button visibility detection across fixed/hidden containers Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: only auto-focus toolbar buttons on keyboard activation, not mouse hover Track pointer activity to distinguish keyboard vs mouse toolbar activation. When the toolbar appears due to mouse hover, skip auto-focus to prevent unwanted focus outlines on desktop. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: track keyboard events instead of pointer events for auto-focus guard Pointer events from iframes don't bubble to the main document, so tracking pointermove/pointerdown missed hover interactions over book content. Track keydown instead — auto-focus only when a recent keyboard event (Enter key) triggered the toolbar, not mouse hover. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
114 lines
3.7 KiB
TypeScript
114 lines
3.7 KiB
TypeScript
import { useEffect, useRef } from 'react';
|
|
|
|
// Track last keyboard activity to distinguish keyboard vs mouse activation.
|
|
// Pointer events from iframes don't bubble to the main document, but keyboard
|
|
// events always reach here via useShortcuts on window.
|
|
let lastKeyboardTime = 0;
|
|
/** @internal Reset for testing only */
|
|
export function _resetLastKeyboardTime() {
|
|
lastKeyboardTime = 0;
|
|
}
|
|
if (typeof document !== 'undefined') {
|
|
document.addEventListener(
|
|
'keydown',
|
|
() => {
|
|
lastKeyboardTime = Date.now();
|
|
},
|
|
true,
|
|
);
|
|
}
|
|
|
|
function getButtons(container: HTMLElement): HTMLButtonElement[] {
|
|
return Array.from(container.querySelectorAll<HTMLButtonElement>('button:not([disabled])'));
|
|
}
|
|
|
|
function getFocusableButtons(container: HTMLElement): HTMLButtonElement[] {
|
|
// Try each button to see if it can actually receive focus.
|
|
// This correctly handles all hiding methods (display:none, visibility:hidden,
|
|
// fixed positioning where offsetParent is null, etc.)
|
|
const prev = document.activeElement as HTMLElement | null;
|
|
const focusable: HTMLButtonElement[] = [];
|
|
for (const btn of getButtons(container)) {
|
|
btn.focus();
|
|
if (document.activeElement === btn) {
|
|
focusable.push(btn);
|
|
}
|
|
}
|
|
prev?.focus();
|
|
return focusable;
|
|
}
|
|
|
|
function focusFirstButton(container: HTMLElement) {
|
|
for (const btn of getButtons(container)) {
|
|
btn.focus();
|
|
if (document.activeElement === btn) return;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Arrow key navigation for toolbar containers (header bar, footer bar).
|
|
* Left/Right navigate between buttons within the toolbar.
|
|
* Up/Down move focus between the header bar and footer bar.
|
|
* Auto-focuses the first button when the toolbar becomes visible.
|
|
*/
|
|
export function useSpatialNavigation(
|
|
containerRef: React.RefObject<HTMLElement | null>,
|
|
isVisible: boolean,
|
|
) {
|
|
// Auto-focus first button when toolbar becomes visible via keyboard (not mouse hover)
|
|
const wasKeyboardActivated = useRef(false);
|
|
useEffect(() => {
|
|
if (isVisible) {
|
|
// If recent keyboard activity, this was keyboard-activated (Enter key)
|
|
wasKeyboardActivated.current = Date.now() - lastKeyboardTime < 200;
|
|
}
|
|
}, [isVisible]);
|
|
|
|
useEffect(() => {
|
|
if (!isVisible || !wasKeyboardActivated.current) return;
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
const timer = setTimeout(() => focusFirstButton(container), 100);
|
|
return () => clearTimeout(timer);
|
|
}, [isVisible, containerRef]);
|
|
|
|
useEffect(() => {
|
|
if (!isVisible) return;
|
|
const container = containerRef.current;
|
|
if (!container) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
const buttons = getFocusableButtons(container);
|
|
if (buttons.length === 0) return;
|
|
|
|
const currentIndex = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
|
if (currentIndex === -1) return;
|
|
|
|
const targetIndex = e.key === 'ArrowRight' ? currentIndex + 1 : currentIndex - 1;
|
|
|
|
if (targetIndex >= 0 && targetIndex < buttons.length) {
|
|
buttons[targetIndex]?.focus();
|
|
}
|
|
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
const target =
|
|
e.key === 'ArrowDown'
|
|
? document.querySelector<HTMLElement>('.footer-bar')
|
|
: document.querySelector<HTMLElement>('.header-bar');
|
|
|
|
if (target) {
|
|
focusFirstButton(target);
|
|
e.stopPropagation();
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
};
|
|
|
|
container.addEventListener('keydown', handleKeyDown);
|
|
return () => container.removeEventListener('keydown', handleKeyDown);
|
|
}, [isVisible, containerRef]);
|
|
}
|