Skip to content

Commit

Permalink
reader context menu keyboard navigation
Browse files Browse the repository at this point in the history
- 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: zotero/zotero#4681
  • Loading branch information
abaevbog committed Sep 13, 2024
1 parent dbf4bef commit 0ff54a7
Show file tree
Hide file tree
Showing 4 changed files with 44 additions and 4 deletions.
34 changes: 33 additions & 1 deletion src/common/components/context-menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({});
Expand Down Expand Up @@ -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();
Expand All @@ -156,7 +188,7 @@ function ContextMenu({ params, onClose }) {

return (
<div className="context-menu-overlay" onPointerDown={handlePointerDown}>
<div ref={containerRef} className="context-menu" style={position.style}>
<div ref={containerRef} className="context-menu" style={position.style} data-tabstop={1} onKeyDown={handleKeyDown}>
{params.itemGroups.map((items, i) => (
<div key={i} className="group">
{items.map((item, i) => {
Expand Down
6 changes: 5 additions & 1 deletion src/common/components/sidebar/annotations-view.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
5 changes: 5 additions & 0 deletions src/common/keyboard-manager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
3 changes: 1 addition & 2 deletions src/common/reader.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
}

Expand Down

0 comments on commit 0ff54a7

Please sign in to comment.