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'},
|
2026-03-20 15:38:32 -06:00
|
|
|
{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'},
|
2026-02-23 17:29:09 -07:00
|
|
|
|
|
|
|
|
// 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',
|
2026-03-20 15:38:32 -06:00
|
|
|
'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>
|
|
|
|
|
);
|
|
|
|
|
}
|