feat(FN-1471): add global keyboard shortcuts hook to TUI

- Create useGlobalShortcuts hook for centralized keyboard shortcut handling at app root
- Add FocusGuardRef module-level ref for tracking text input focus state
- Implement HelpOverlay component displaying available keyboard shortcuts
- Add Ctrl+C (emergency exit), q (quit), ?/h (help toggle), 1-5 (screen switch) shortcuts
- Focus guard prevents shortcuts when text input is focused (except Ctrl+C)
- Update ScreenRouter with controlled/uncontrolled mode support
- Wire global shortcuts into demo app with help overlay integration
- Add comprehensive tests for useGlobalShortcuts and HelpOverlay
- Update README with global shortcuts documentation and usage examples
This commit is contained in:
gsxdsm
2026-04-09 21:19:20 -07:00
parent 591e9d8b01
commit a8f4c3d447
7 changed files with 957 additions and 27 deletions

View File

@@ -48,6 +48,21 @@ export interface ScreenComponentProps {
* Props for the ScreenRouter component.
*/
export interface ScreenRouterProps {
/**
* Initial screen to display on mount.
* @default "board"
*/
initialScreen?: ScreenId;
/**
* Callback invoked when the user navigates to a different screen.
* Use this to sync with external state (e.g., global shortcuts).
*/
onScreenChange?: (screenId: ScreenId) => void;
/**
* Externally controlled active screen.
* When provided, the router uses this instead of internal state.
*/
activeScreen?: ScreenId;
/**
* Render function for each screen.
* Receives the screen ID and should return the screen component.
@@ -79,21 +94,32 @@ export interface ScreenRouterProps {
* </ScreenRouter>
* ```
*/
export function ScreenRouter({ children }: ScreenRouterProps): React.ReactNode {
const [activeScreen, setActiveScreen] = useState<ScreenId>("board");
export function ScreenRouter({ children, initialScreen = "board", onScreenChange, activeScreen: externalActiveScreen }: ScreenRouterProps): React.ReactNode {
// Use external state if provided, otherwise use internal state
const [internalActiveScreen, setInternalActiveScreen] = useState<ScreenId>(initialScreen);
const activeScreen = externalActiveScreen ?? internalActiveScreen;
// Navigate to a specific screen by index
const navigateToIndex = useCallback((index: number) => {
const normalizedIndex = ((index % SCREENS.length) + SCREENS.length) % SCREENS.length;
setActiveScreen(SCREENS[normalizedIndex].id);
}, []);
const newScreen = SCREENS[normalizedIndex].id;
// Only update internal state if not externally controlled
if (externalActiveScreen === undefined) {
setInternalActiveScreen(newScreen);
}
onScreenChange?.(newScreen);
}, [externalActiveScreen, onScreenChange]);
// Handle keyboard input
useInput((input, key) => {
// Number keys 1-5 for direct selection
const num = parseInt(input, 10);
if (num >= 1 && num <= SCREENS.length) {
setActiveScreen(SCREENS[num - 1].id);
const newScreen = SCREENS[num - 1].id;
if (externalActiveScreen === undefined) {
setInternalActiveScreen(newScreen);
}
onScreenChange?.(newScreen);
return;
}