wrolpi/app/src/components/KeyboardShortcutsProvider.js

238 lines
8 KiB
JavaScript
Raw Normal View History

2025-12-26 18:59:13 -07:00
import React, {useCallback, useEffect, useRef, useState} from "react";
import {useNavigate} from "react-router";
import {useHotkeys} from "react-hotkeys-hook";
import {KeyboardShortcutsContext} from "../contexts/KeyboardShortcutsContext";
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-07-30 12:53:19 -06:00
import {Modal} from "./ui";
2025-12-26 18:59:13 -07:00
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'},
2025-12-26 18:59:13 -07:00
{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'},
2026-03-27 17:59:36 -06:00
{keys: 'f', description: 'Toggle fullscreen', category: 'Video Player'},
2025-12-26 18:59:13 -07:00
];
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(() => {
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-07-30 12:53:19 -06:00
/*
* 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.
*/
2025-12-26 18:59:13 -07:00
if (open && inputRef.current) {
inputRef.current.focus();
}
}, [open]);
if (!open) return null;
return (
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-07-30 12:53:19 -06:00
<Modal open={open} onClose={onClose} centered={false} title='Search'>
2025-12-26 18:59:13 -07:00
<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}
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-07-30 12:53:19 -06:00
autoFocus
2025-12-26 18:59:13 -07:00
/>
</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',
2025-12-26 18:59:13 -07:00
'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>
);
}