wrolpi/app/src/components/KeyboardShortcutsProvider.js
Roland 00408d515d
ui: fix the cypress failures, and two real defects behind them
All 43 cypress specs pass now (was 36 of 43).  Five of the seven failures were
stale selectors, two were real.

Real, and both mine:

  The search modal stopped focusing its input.  Mantine mounts modal content on
  a later tick, so the effect that focused it ran while the ref was still null;
  Semantic's modal mounted synchronously, which is why focusing there had been
  enough.  Fixed declaratively -- the field asks for focus itself, and SearchBox
  also sets `data-autofocus`, which is what Mantine's focus trap honours when it
  takes focus away from a plain autoFocus.

  Escape closed the suggestion list and the enclosing modal at once, so a user
  correcting a mistyped query lost the whole modal.  Escape now dismisses the
  suggestions and stops there; a second Escape, with the list closed, still
  closes the modal.

Stale selectors, all in the specs: Semantic's icon font (`i.search.icon`) is
SVG now, its `.ui.modal`/`.ui.dimmer` are the library's modal and overlay, and
one test asserted a button carried the classes `ui button secondary`.  That last
one is rewritten to check what a user can actually perceive -- that Edit is a
real link pointing at the edit route -- because framework class names say
nothing and break on every library change.

Two accessibility gaps the tests walked into:

  The nav's search control was an anchor with an onClick, no name and no
  keyboard path.  It now has a name, a role and Enter/Space activation, which is
  also what let the spec stop matching on icon-font classes.

  The search dialog had no accessible name at all; it has one now.

One `{force: true}` remains, with its reason recorded: Cypress judges that
field covered because it sits in a fixed modal under a sticky header, and I
verified by hand at Cypress's own viewport that it is the topmost element at
its centre, unobscured, and focused on open.

Also closed a hole in the ratchet that this uncovered.  A file could drop its
`semantic-ui-react` imports, leave the baseline, and still render Semantic
through Theme.tsx's wrappers -- which is exactly how a Semantic modal survived
in the search shortcut long after that file looked migrated.  A test now
forbids it, and the three files doing it (Calculators, KeyboardShortcutsProvider,
and Common.js reaching for theme names) import from the library or from
themes/names instead.
2026-08-08 18:04:01 -06:00

237 lines
8 KiB
JavaScript

import React, {useCallback, useEffect, useRef, useState} from "react";
import {useNavigate} from "react-router";
import {useHotkeys} from "react-hotkeys-hook";
import {KeyboardShortcutsContext} from "../contexts/KeyboardShortcutsContext";
import {Modal} from "./ui";
import {SearchResultsInput} from "./Common";
import {useSearchSuggestions} from "./Search";
import HelpModal from "./HelpModal";
// Define all keyboard shortcuts with their metadata
export const SHORTCUTS = [
// Search & Help
{keys: 'meta+k, ctrl+k', description: 'Open search', category: 'Search'},
{keys: '/', description: 'Open search', category: 'Search', enableOnFormTags: false},
{keys: 'escape', description: 'Close modal', category: 'General'},
{keys: 'shift+/', description: 'Show keyboard shortcuts', category: 'Help'},
// Navigation (g+ sequences)
{keys: 'g h', description: 'Go to Home', category: 'Navigation', path: '/'},
{keys: 'g v', description: 'Go to Videos', category: 'Navigation', path: '/videos'},
{keys: 'g a', description: 'Go to Archives', category: 'Navigation', path: '/archives'},
{keys: 'g m', description: 'Go to Map', category: 'Navigation', path: '/map'},
{keys: 'g z', description: 'Go to Zim', category: 'Navigation', path: '/zim'},
{keys: 'g f', description: 'Go to Files', category: 'Navigation', path: '/files'},
{keys: 'g s', description: 'Go to Settings', category: 'Navigation', path: '/admin'},
{keys: 'g i', description: 'Go to Inventory', category: 'Navigation', path: '/inventory'},
// Page Search
{keys: 'f', description: 'Focus search input', category: 'Page Search'},
// Video Player
{keys: 'k', description: 'Play/pause video', category: 'Video Player'},
{keys: 'j', description: 'Jump back 15 seconds', category: 'Video Player'},
{keys: 'l', description: 'Jump forward 30 seconds', category: 'Video Player'},
{keys: 'n', description: 'Navigate to newer video', category: 'Video Player'},
{keys: 'p', description: 'Navigate to older video', category: 'Video Player'},
{keys: 'f', description: 'Toggle fullscreen', category: 'Video Player'},
];
function SearchModal({open, onClose}) {
const {
suggestionsResults,
handleResultSelect,
resultRenderer,
loading,
searchStr,
setSearchStr,
} = useSearchSuggestions();
const inputRef = useRef();
const prevOpen = useRef(open);
const localHandleResultSelect = (i) => {
onClose();
handleResultSelect(i);
};
useEffect(() => {
if (prevOpen.current === true && !open) {
// User has closed the modal.
setSearchStr('');
}
prevOpen.current = open;
}, [open, setSearchStr]);
useEffect(() => {
/*
* Focus the search input when the modal opens.
*
* `autoFocus` on the input below is what actually does it: the modal mounts its
* content on a later tick, so this effect runs while `inputRef.current` is still
* null. Semantic's modal mounted synchronously, which is why focusing here used
* to be enough. The ref call is kept for the case where the modal is already
* mounted and merely re-opened.
*/
if (open && inputRef.current) {
inputRef.current.focus();
}
}, [open]);
if (!open) return null;
return (
<Modal open={open} onClose={onClose} centered={false} title='Search'>
<Modal.Content>
<SearchResultsInput
clearable
searchStr={searchStr}
onChange={setSearchStr}
onSubmit={setSearchStr}
size='large'
placeholder='Search everywhere...'
results={suggestionsResults}
handleResultSelect={localHandleResultSelect}
resultRenderer={resultRenderer}
loading={loading}
inputRef={inputRef}
autoFocus
/>
</Modal.Content>
</Modal>
);
}
export function KeyboardShortcutsProvider({children}) {
const navigate = useNavigate();
const [searchModalOpen, setSearchModalOpen] = useState(false);
const [helpModalOpen, setHelpModalOpen] = useState(false);
const openSearchModal = useCallback(() => {
setHelpModalOpen(false);
setSearchModalOpen(true);
}, []);
const closeSearchModal = useCallback(() => {
setSearchModalOpen(false);
}, []);
const openHelpModal = useCallback(() => {
setSearchModalOpen(false);
setHelpModalOpen(true);
}, []);
const closeHelpModal = useCallback(() => {
setHelpModalOpen(false);
}, []);
const closeAllModals = useCallback(() => {
setSearchModalOpen(false);
setHelpModalOpen(false);
}, []);
// Search shortcuts - Cmd/Ctrl+K works everywhere
useHotkeys('meta+k, ctrl+k', (e) => {
e.preventDefault();
openSearchModal();
}, {
enableOnFormTags: true,
preventDefault: true,
});
// / for search - only when not in an input field
// Use 'Slash' (the keyboard code) for cross-browser compatibility
useHotkeys('Slash', (e) => {
// Only trigger for / not ? (shift+/)
if (e.shiftKey) return;
e.preventDefault();
openSearchModal();
}, {
enableOnFormTags: false,
});
// Escape closes any modal - works in inputs too
useHotkeys('escape', () => {
closeAllModals();
}, {
enableOnFormTags: true,
});
// ? for help modal - use shift+Slash
useHotkeys('shift+Slash', (e) => {
e.preventDefault();
openHelpModal();
}, {
enableOnFormTags: false,
preventDefault: true,
});
// Navigation shortcuts (g+ sequences) - custom implementation
// react-hotkeys-hook doesn't handle sequences well, so we implement our own
const gPressedRef = useRef(false);
const gTimeoutRef = useRef(null);
const NAV_KEYS = {
'h': '/',
'v': '/videos',
'a': '/archives',
'm': '/map',
'z': '/zim',
'f': '/files',
's': '/admin',
'i': '/inventory',
};
useEffect(() => {
const handleKeyDown = (e) => {
// Don't trigger in input fields
const tagName = e.target.tagName.toLowerCase();
if (tagName === 'input' || tagName === 'textarea' || tagName === 'select' || e.target.isContentEditable) {
return;
}
if (e.key === 'g' && !e.metaKey && !e.ctrlKey && !e.altKey) {
gPressedRef.current = true;
// Clear after 1 second
if (gTimeoutRef.current) clearTimeout(gTimeoutRef.current);
gTimeoutRef.current = setTimeout(() => {
gPressedRef.current = false;
}, 1000);
return;
}
if (gPressedRef.current && NAV_KEYS[e.key]) {
e.preventDefault();
gPressedRef.current = false;
if (gTimeoutRef.current) clearTimeout(gTimeoutRef.current);
navigate(NAV_KEYS[e.key]);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
if (gTimeoutRef.current) clearTimeout(gTimeoutRef.current);
};
}, [navigate]);
const value = {
searchModalOpen,
setSearchModalOpen,
openSearchModal,
closeSearchModal,
helpModalOpen,
setHelpModalOpen,
openHelpModal,
closeHelpModal,
closeAllModals,
};
return (
<KeyboardShortcutsContext.Provider value={value}>
{children}
<SearchModal open={searchModalOpen} onClose={closeSearchModal}/>
<HelpModal open={helpModalOpen} onClose={closeHelpModal}/>
</KeyboardShortcutsContext.Provider>
);
}