Files
readest/apps/readest-app/src/app/reader/hooks/useSpatialNavigation.ts
T
Huang Xin f361698e05 feat(android): add D-pad navigation for Android TV remote controller (#3745)
* 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>
2026-04-04 19:50:34 +02:00

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]);
}