From d866a10a2b56e96631a1563572d3f2918f1f006a Mon Sep 17 00:00:00 2001 From: James Eschrich Date: Mon, 13 Feb 2023 23:28:53 -0500 Subject: [PATCH] Made toolbar accessible to keyboard (#2853) --- .../content/zotero-platform/unix/overlay.css | 4 + chrome/content/zotero/bindings/itembox.xml | 16 +- chrome/content/zotero/bindings/relatedbox.xml | 9 + .../zotero/components/itemPane/tagsBox.jsx | 2 +- chrome/content/zotero/lookup.js | 24 ++ .../content/zotero/xpcom/sync/syncRunner.js | 32 ++- chrome/content/zotero/zoteroPane.js | 253 +++++++++++++++++- chrome/content/zotero/zoteroPane.xul | 45 ++-- chrome/skin/default/zotero/overlay.css | 1 + 9 files changed, 355 insertions(+), 31 deletions(-) diff --git a/chrome/content/zotero-platform/unix/overlay.css b/chrome/content/zotero-platform/unix/overlay.css index 4fcd4cd2eb..58f05690e9 100644 --- a/chrome/content/zotero-platform/unix/overlay.css +++ b/chrome/content/zotero-platform/unix/overlay.css @@ -63,3 +63,7 @@ tab { background-color: #ececec; border-top: 1px solid hsla(0, 0%, 0%, 0.2); } + +.zotero-tb-button:focus { + border: 1px dotted #999; +} \ No newline at end of file diff --git a/chrome/content/zotero/bindings/itembox.xml b/chrome/content/zotero/bindings/itembox.xml index 17587cff5c..22c050d6e4 100644 --- a/chrome/content/zotero/bindings/itembox.xml +++ b/chrome/content/zotero/bindings/itembox.xml @@ -2344,7 +2344,21 @@ ]]> - + + + + diff --git a/chrome/content/zotero/bindings/relatedbox.xml b/chrome/content/zotero/bindings/relatedbox.xml index a58399d35f..0598bc9851 100644 --- a/chrome/content/zotero/bindings/relatedbox.xml +++ b/chrome/content/zotero/bindings/relatedbox.xml @@ -330,6 +330,15 @@ ]]> + + + + + diff --git a/chrome/content/zotero/components/itemPane/tagsBox.jsx b/chrome/content/zotero/components/itemPane/tagsBox.jsx index 8d5f0db0c5..2e64745c36 100644 --- a/chrome/content/zotero/components/itemPane/tagsBox.jsx +++ b/chrome/content/zotero/components/itemPane/tagsBox.jsx @@ -415,7 +415,7 @@ const TagsBox = React.forwardRef((props, ref) => {
{renderCount()}
- { props.editable &&
} + { props.editable &&
}
    diff --git a/chrome/content/zotero/lookup.js b/chrome/content/zotero/lookup.js index 4df73e88e4..60a563a1f1 100644 --- a/chrome/content/zotero/lookup.js +++ b/chrome/content/zotero/lookup.js @@ -28,6 +28,7 @@ * @namespace */ var Zotero_Lookup = new function () { + this._button = null; /** * Performs a lookup by DOI, PMID, or ISBN on the given textBox value * and adds any items it can. @@ -134,6 +135,10 @@ var Zotero_Lookup = new function () { this.showPanel = function (button) { var panel = document.getElementById('zotero-lookup-panel'); + this._button = button; + if (!button) { + button = document.getElementById("zotero-tb-lookup"); + } panel.openPopup(button, "after_start", 16, -2, false, false); } @@ -201,6 +206,25 @@ var Zotero_Lookup = new function () { } return true; } + + this.onFocusOut = function(event) { + /* + if the lookup popup was triggered by the lookup button, + we want to return there on focus out. So we check + (1) that we came from a button and (2) that + event.relatedTarget === null, i.e. that the user hasn't used + the mouse or keyboard to select something, and focus is leaving + the popup because the popup was hidden/dismissed. + */ + if (this._button && event.relatedTarget === null) { + event.preventDefault(); + event.stopPropagation(); + this._button.focus(); + this._button = null; + } else { + this._button = null; + } + } this.onInput = function (event, textbox) { diff --git a/chrome/content/zotero/xpcom/sync/syncRunner.js b/chrome/content/zotero/xpcom/sync/syncRunner.js index 6e8b78ca31..f18d90ee2a 100644 --- a/chrome/content/zotero/xpcom/sync/syncRunner.js +++ b/chrome/content/zotero/xpcom/sync/syncRunner.js @@ -1501,7 +1501,7 @@ Zotero.Sync.Runner_Module = function (options = {}) { panel.removeChild(panel.firstChild); } - for (let e of errors) { + for (let [index, e] of errors.entries()) { var box = doc.createElement('vbox'); var label = doc.createElement('label'); if (e.libraryID !== undefined) { @@ -1526,6 +1526,7 @@ Zotero.Sync.Runner_Module = function (options = {}) { let header = doc.createElement('description'); header.className = 'error-header'; header.textContent = e.dialogHeader; + header.setAttribute("control", `zotero-sync-error-panel-button-${index}`); content.appendChild(header); } @@ -1544,6 +1545,7 @@ Zotero.Sync.Runner_Module = function (options = {}) { var desc = doc.createElement('description'); desc.textContent = msg; + desc.setAttribute("control", `zotero-sync-error-panel-button-${index}`); // Make the text selectable desc.setAttribute('style', '-moz-user-select: text; cursor: text'); content.appendChild(desc); @@ -1565,13 +1567,26 @@ Zotero.Sync.Runner_Module = function (options = {}) { var buttonText = e.dialogButtonText; var buttonCallback = e.dialogButtonCallback; } + + function addEventHandlers(button, cb) { + button.addEventListener("click", () => { + cb(); + panel.hidePopup(); + }); + + button.addEventListener("keydown", (event) => { + if (event.key !== ' ' && event.key !== 'Enter') return; + cb(); + panel.hidePopup(); + }); + } + + let button = doc.createElement('button'); + button.setAttribute("id", `zotero-sync-error-panel-button-${index}`); button.setAttribute('label', buttonText); - button.onclick = function () { - buttonCallback(); - panel.hidePopup(); - }; + addEventHandlers(button, buttonCallback); buttons.appendChild(button); // Second button @@ -1581,10 +1596,9 @@ Zotero.Sync.Runner_Module = function (options = {}) { let button2 = doc.createElement('button'); button2.setAttribute('label', buttonText); - button2.onclick = () => { - buttonCallback(); - panel.hidePopup(); - }; + button2.setAttribute("id", `zotero-sync-error-panel-button-${index}`); + button.removeAttribute("id"); + addEventHandlers(button2, buttonCallback); buttons.insertBefore(button2, button); } } diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js index 709f7ad839..e6dbf65ca1 100644 --- a/chrome/content/zotero/zoteroPane.js +++ b/chrome/content/zotero/zoteroPane.js @@ -55,6 +55,9 @@ var ZoteroPane = new function() this.clearItemsPaneMessage = clearItemsPaneMessage; this.viewSelectedAttachment = viewSelectedAttachment; this.reportErrors = reportErrors; + + const modifierIsNotShift = ev => ev.getModifierState("Meta") || ev.getModifierState("Alt") || + ev.getModifierState("Control") || ev.getModifierState("OS"); this.document = document; @@ -136,8 +139,256 @@ var ZoteroPane = new function() // continue loading pane _loadPane(); + setUpToolbar(); }; - + + function setUpToolbar() { + // if the hidden property is ever set on a grandparent or more distant + // ancestor this will need to be updated + const isVisible = (b) => !b.hidden && !b.parentElement.hidden; + const isTbButton = (node) => node && node.tagName === "toolbarbutton"; + + function nextVisible(id, field = "after") { + let b = document.getElementById(id); + while (!isVisible(b)) { + const mapData = focusMap.get(b.id); + b = document.getElementById(mapData[field]); + } + return b; + } + + /* constants */ + const toolbar = this.document.getElementById("zotero-toolbar"); + + // assumes no toolbarbuttons are dynamically added, just hidden + // or revealed. If this changes, the observer will have to monitor + // changes to the childList for each hbox in the toolbar which might + // have dynamic children + const buttons = toolbar.getElementsByTagName("toolbarbutton"); + const focusMap = new Map(); + const zones = [ + { + get start() { return document.getElementById("zotero-tb-collection-add"); }, + focusBefore() { + // If no item is selected, focus items list. + const pane = document.getElementById("zotero-item-pane-content"); + if (pane.selectedIndex === "0") { + document.getElementById("item-tree-main-default").focus(); + } + else { + const tabBox = document.getElementById("zotero-view-tabbox"); + if (tabBox.selectedIndex === 0) { + const itembox = document.getElementById("zotero-editpane-item-box"); + itembox.focusLastField(); + } + else if (tabBox.selectedIndex === 1) { + const notes = document.getElementById("zotero-editpane-notes"); + const nodes = notes.querySelectorAll("button"); + const node = nodes[nodes.length - 1]; + node.focus(); + // TODO: the notes are currently inaccessible to the keyboard + } + else if (tabBox.selectedIndex === 2) { + const tagContainer = document.getElementById("tags-box-container"); + const tags = tagContainer.querySelectorAll("#tags-box-add-button,.zotero-clicky"); + const last = tags[tags.length - 1]; + if (last.id === "tags-box-add-button") { + last.focus(); + } + else { + last.click(); + } + } + else if (tabBox.selectedIndex === 3) { + const related = tabBox.querySelector("relatedbox"); + related.receiveKeyboardFocus("end"); + } + else { + throw new Error("The selectedIndex should always be between 1 and 4"); + } + } + }, + focusAfter() { + document.getElementById("zotero-tb-search-menu-button").focus(); + } + }, + { + get start() { return document.getElementById("zotero-tb-locate"); }, + focusBefore() { + document.getElementById("zotero-tb-search").focus(); + }, + focusAfter() { + document.getElementById("collection-tree").focus(); + } + } + ]; + + /* + observe buttons and containers for changes in the "hidden" + attribute + */ + const observer = new MutationObserver((mutations, _) => { + for (const mutation of mutations) { + if (mutation.target.hidden + && (document.activeElement === mutation.target + || mutation.target.contains(document.activeElement)) + ) { + const next = nextVisible(document.activeElement.id, "before"); + next.focus(); + } + } + }); + + /* + build a chain which connects all the s, + except for zotero-tb-locate and zotero-tb-advanced-search + which is where the chain breaks + */ + let prev = null; + let _zone = zones[0]; + for (const button of buttons) { + focusMap.set(button.id, { + before: prev, + after: null, + zone: _zone + }); + + /* observe each button for changes to "hidden" */ + observer.observe(button, { + attributes: true, + attributeFilter: ["hidden"] + }); + + if (focusMap.has(prev)) { + focusMap.get(prev).after = button.id; + } + + prev = button.id; + + // break the chain at zotero-tb-advanced-search + if (button.id === "zotero-tb-advanced-search") { + _zone = zones[1]; + prev = null; + } + } + + /* this container sets "hidden" to hide its children, so we have to observe it too */ + observer.observe(document.getElementById("zotero-tb-sync-progress-box"), { + attributes: true, + attributeFilter: ["hidden"] + }); + + // lookupButton and syncErrorButton show popup panels, and so need special treatment + const lookupButton = document.getElementById("zotero-tb-lookup"); + const syncErrorButton = document.getElementById("zotero-tb-sync-error"); + + /* buttons at the start of zones need tabindex=0 */ + for (const zone of zones) { + zone.start.setAttribute("tabindex", "0"); + } + + toolbar.addEventListener("keydown", (event) => { + // manually move focus when Shift+Tabbing from the search-menu-button + if (event.key === 'Tab' && event.shiftKey + && !modifierIsNotShift(event) + && event.originalTarget + && event.originalTarget.id == "zotero-tb-search-menu-button") { + event.preventDefault(); + event.stopPropagation(); + zones[0].start.focus(); + return; + } + + // only handle events on a + if (!isTbButton(event.target)) return; + + const mapData = focusMap.get(event.target.id); + + if (!Zotero.rtl && event.key === 'ArrowRight' + || Zotero.rtl && event.key === 'ArrowLeft') { + event.preventDefault(); + event.stopPropagation(); + if (mapData.after) { + nextVisible(mapData.after, "after").focus(); + } + return; + } + if (!Zotero.rtl && event.key === 'ArrowLeft' + || Zotero.rtl && event.key === 'ArrowRight') { + event.preventDefault(); + event.stopPropagation(); + + if (mapData.before) { + nextVisible(mapData.before, "before").focus(); + } + return; + } + + /* manually trigger on space and enter */ + if (event.key === ' ' || event.key === 'Enter') { + if (event.target.disabled) return; + + if (event.target === lookupButton) { + event.preventDefault(); + event.stopPropagation(); + Zotero_Lookup.showPanel(event.target); + } + else if (event.target === syncErrorButton) { + event.preventDefault(); + event.stopPropagation(); + syncErrorButton.dispatchEvent(new MouseEvent("click", { + target: event.target + })); + } + else if (event.target.getAttribute('type') === 'menu') { + event.preventDefault(); + event.stopPropagation(); + const popup = event.target.querySelector("menupopup"); + if (popup !== null && !event.target.disabled) { + popup.showPopup(); + } + } + } + /* activate menus and popups on ArrowDown and ArrowUp, otherwise prepare for a focus change */ + else if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + if (event.target === lookupButton && !event.target.disabled) { + event.preventDefault(); + event.stopPropagation(); + Zotero_Lookup.showPanel(event.target); + } + else if (event.target.getAttribute('type') === 'menu' && !event.target.disabled) { + event.preventDefault(); + event.stopPropagation(); + const popup = event.target.querySelector("menupopup"); + if (popup !== null && !event.target.disabled) { + popup.showPopup(); + } + } + /* prepare for a focus change */ + else if (event.key === 'ArrowDown') { + event.preventDefault(); + event.stopPropagation(); + mapData.zone.focusBefore(); + } + else if (event.key === 'ArrowUp') { + event.preventDefault(); + event.stopPropagation(); + mapData.zone.focusAfter(); + } + } + else if (event.key === 'Tab' && !modifierIsNotShift(event)) { + event.preventDefault(); + event.stopPropagation(); + if (event.shiftKey) { + mapData.zone.focusBefore(); + } + else { + mapData.zone.focusAfter(); + } + } + }); + } + /** * Called on window load or when pane has been reloaded after switching into or out of connector * mode diff --git a/chrome/content/zotero/zoteroPane.xul b/chrome/content/zotero/zoteroPane.xul index 0716fec257..830afd05ea 100644 --- a/chrome/content/zotero/zoteroPane.xul +++ b/chrome/content/zotero/zoteroPane.xul @@ -95,10 +95,10 @@ onkeypress="ZoteroPane_Local.handleKeyPress(event)" chromedir="&locale.dir;"> - + - - + + @@ -114,7 +114,7 @@ - @@ -127,13 +127,13 @@ - + + onpopuphidden="Zotero_Lookup.onHidden(event)" onfocusout="Zotero_Lookup.onFocusOut(event)"> - &zotero.lookup.description; + @@ -155,14 +155,14 @@ - - @@ -171,29 +171,31 @@ - + + oncommand="ZoteroPane_Local.search()" /> - + -