From 0ff54a71cc2e13bde4bf5885244aed5e4918cf6c Mon Sep 17 00:00:00 2001 From: Bogdan Abaev Date: Fri, 13 Sep 2024 14:43:25 -0700 Subject: [PATCH] reader context menu keyboard navigation - add tabstop to contextMenu, so navigation between buttons within it is handled by focusManager - when context menu is opened, focus the first button from contextMenu so that the next keypress interacts with menu items - when Escape keypress is being handled and contextmenu is open, just call reader.closeContextMenu to close it and let focus go back to previously focused element. - add keydown listener to context-menu to navigate it by typing characters on the keyboard. After something is typed, find buttons with text that begins with the input. If there is only one match, it is clicked. If there are multiple, the first one is focused. The input counter is reset after 3 seconds of not typing. Fixes: https://github.com/zotero/zotero/issues/4681 --- src/common/components/context-menu.js | 34 ++++++++++++++++++- .../components/sidebar/annotations-view.js | 6 +++- src/common/keyboard-manager.js | 5 +++ src/common/reader.js | 3 +- 4 files changed, 44 insertions(+), 4 deletions(-) diff --git a/src/common/components/context-menu.js b/src/common/components/context-menu.js index 00999186..6b0bf59d 100644 --- a/src/common/components/context-menu.js +++ b/src/common/components/context-menu.js @@ -109,6 +109,8 @@ function ContextMenu({ params, onClose }) { const [position, setPosition] = useState({ style: {} }); const [update, setUpdate] = useState(); const containerRef = useRef(); + const searchStringRef = useRef(''); + const searchTimeoutRef = useRef(null); useEffect(() => { setUpdate({}); @@ -147,6 +149,36 @@ function ContextMenu({ params, onClose }) { } } + function handleKeyDown(event) { + let { key } = event; + // Ignore non-characters + if (key.length !== 1 || !key.match(/\S/)) return; + + // Clear search string after 3 seconds of no typing + if (searchTimeoutRef.current) { + clearTimeout(searchTimeoutRef.current); + } + searchTimeoutRef.current = setTimeout(() => { + searchStringRef.current = ''; + }, 3000); + + // Keep track of what has been typed so far + searchStringRef.current += key.toLowerCase(); + + // Find all buttons with text that start with what has been typed + let menuOptions = [...document.querySelectorAll(".context-menu button:not([disabled])")]; + let candidates = menuOptions.filter(option => option.textContent.toLowerCase().startsWith(searchStringRef.current)); + + // If there is only one candidate, click it right away + if (candidates.length == 1) { + candidates[0].click(); + } + // If there are multiple - focus the first one + else if (candidates.length > 1) { + candidates[0].focus(); + } + } + function handleClick(event, item) { onClose(); event.preventDefault(); @@ -156,7 +188,7 @@ function ContextMenu({ params, onClose }) { return (
-
+
{params.itemGroups.map((items, i) => (
{items.map((item, i) => { diff --git a/src/common/components/sidebar/annotations-view.js b/src/common/components/sidebar/annotations-view.js index 9169abe6..34ec5271 100644 --- a/src/common/components/sidebar/annotations-view.js +++ b/src/common/components/sidebar/annotations-view.js @@ -175,7 +175,11 @@ const AnnotationsView = memo(React.forwardRef((props, ref) => { props.onUpdateAnnotations([annotation]); }, []); - function handlePointerDown() { + function handlePointerDown(event) { + // Clicking on the rendered content when a contextmenu is open will + // lead to pointerup event not firing and the annotation becoming not-selectable + // via keyboard until it is clicked. + if (event.target.classList.contains("context-menu-overlay")) return; pointerDownRef.current = true; } diff --git a/src/common/keyboard-manager.js b/src/common/keyboard-manager.js index 077fbd61..63dec15d 100644 --- a/src/common/keyboard-manager.js +++ b/src/common/keyboard-manager.js @@ -71,6 +71,11 @@ export class KeyboardManager { // Escape must be pressed alone. We basically want to prevent // Option-Escape (speak text on macOS) deselecting text if (key === 'Escape') { + // If context menu is opened, close it and let focus return to last active element + if (document.querySelector(".context-menu")) { + this._reader.closeContextMenu(); + return; + } this._reader._lastView.focus(); this._reader.abortPrint(); this._reader._updateState({ diff --git a/src/common/reader.js b/src/common/reader.js index 31ecc2fa..910fdc16 100644 --- a/src/common/reader.js +++ b/src/common/reader.js @@ -626,8 +626,7 @@ class Reader { this._onBringReaderToFront?.(true); this._updateState({ contextMenu: params }); setTimeout(() => { - window.focus(); - document.activeElement.blur(); + document.querySelector(".context-menu button:not([disabled])").focus(); }); }