mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
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.
237 lines
8 KiB
JavaScript
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>
|
|
);
|
|
}
|