From 13571f9fd2c1f9de78bbcd5f0db5acf6932d9d31 Mon Sep 17 00:00:00 2001 From: abaevbog Date: Wed, 5 Jul 2023 03:17:07 -0400 Subject: [PATCH] making toolbar accessible to the keyboard - initial conversion (#3188) Port of d866a10a2b56e96631a1563572d3f2918f1f006a Fixes #3001 --- .../content/zotero-platform/unix/overlay.css | 3 + chrome/content/zotero/elements/itemBox.js | 13 + chrome/content/zotero/elements/relatedBox.js | 6 + chrome/content/zotero/elements/tagsBox.js | 10 +- chrome/content/zotero/lookup.js | 26 ++ .../content/zotero/xpcom/sync/syncRunner.js | 33 ++- chrome/content/zotero/zoteroPane.js | 269 +++++++++++++++++- chrome/content/zotero/zoteroPane.xhtml | 29 +- chrome/skin/default/zotero/overlay.css | 1 + 9 files changed, 364 insertions(+), 26 deletions(-) diff --git a/chrome/content/zotero-platform/unix/overlay.css b/chrome/content/zotero-platform/unix/overlay.css index 8b4b72e699..3ec2d80a36 100644 --- a/chrome/content/zotero-platform/unix/overlay.css +++ b/chrome/content/zotero-platform/unix/overlay.css @@ -53,3 +53,6 @@ tab { background-color: #ececec; border-top: 1px solid hsla(0, 0%, 0%, 0.2); } +.zotero-tb-button:focus { + border: 1px dotted #999; +} diff --git a/chrome/content/zotero/elements/itemBox.js b/chrome/content/zotero/elements/itemBox.js index 091fb7b804..46ae8b376f 100644 --- a/chrome/content/zotero/elements/itemBox.js +++ b/chrome/content/zotero/elements/itemBox.js @@ -2313,6 +2313,19 @@ focusFirstField() { this._focusNextField(1); } + + focusLastField() { + const tabbableFields = this.querySelectorAll('*[ztabindex]:not([disabled=true])'); + const last = tabbableFields[tabbableFields.length - 1]; + + if (last.classList.contains('zotero-focusable')) { + last.focus(); + } + // Fields need to be clicked + else { + last.click(); + } + } focusField(fieldName) { let field = this.querySelector(`[fieldname="${fieldName}"][ztabindex]`); diff --git a/chrome/content/zotero/elements/relatedBox.js b/chrome/content/zotero/elements/relatedBox.js index a02fba4fe1..7477b13925 100644 --- a/chrome/content/zotero/elements/relatedBox.js +++ b/chrome/content/zotero/elements/relatedBox.js @@ -253,6 +253,12 @@ _id(id) { return this.querySelector(`[id=${id}]`); } + + receiveKeyboardFocus(direction) { + this._id("addButton").focus(); + // TODO: the relatedbox is not currently keyboard accessible + // so we are ignoring the direction + } } customElements.define("related-box", RelatedBox); } diff --git a/chrome/content/zotero/elements/tagsBox.js b/chrome/content/zotero/elements/tagsBox.js index 05d96da633..5fa024708f 100644 --- a/chrome/content/zotero/elements/tagsBox.js +++ b/chrome/content/zotero/elements/tagsBox.js @@ -45,7 +45,7 @@
@@ -79,8 +79,8 @@ let content = document.importNode(this.content, true); this.append(content); - this._id('add').addEventListener('click', this._handleAddButtonClick); - this._id('add').addEventListener('keydown', this._handleAddButtonKeyDown); + this._id("tags-box-add-button").addEventListener('click', this._handleAddButtonClick); + this._id("tags-box-add-button").addEventListener('keydown', this._handleAddButtonKeyDown); this._id('tags-box').addEventListener('click', (event) => { if (event.target.id == 'tags-box') { this.blurOpenField(); @@ -228,7 +228,7 @@ // Cancel field focusing while we're updating this._reloading = true; - this._id('add').hidden = !this.editable; + this._id("tags-box-add-button").hidden = !this.editable; this._tagColors = Zotero.Tags.getColors(this.item.libraryID); @@ -889,7 +889,7 @@ else if (dir == -1) { if (tabindex == 1) { // Focus Add button - this._id('add').focus(); + this._id("tags-box-add-button").focus(); return false; } var nextIndex = tabindex - 1; diff --git a/chrome/content/zotero/lookup.js b/chrome/content/zotero/lookup.js index 9dfed64659..5efe31cea4 100644 --- a/chrome/content/zotero/lookup.js +++ b/chrome/content/zotero/lookup.js @@ -41,6 +41,7 @@ var Zotero_Lookup = new function () { * @param toggleProgress {function} - Callback to toggle progress on/off * @returns {Promise} */ + this._button = null; this.addItemsFromIdentifier = async function (textBox, childItem, toggleProgress) { var identifiers = Zotero.Utilities.extractIdentifiers(textBox.value); if (!identifiers.length) { @@ -134,9 +135,34 @@ 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); } + 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; + } + } + /** * Focuses the field diff --git a/chrome/content/zotero/xpcom/sync/syncRunner.js b/chrome/content/zotero/xpcom/sync/syncRunner.js index fa0ef0aa5c..01cdeb0b8e 100644 --- a/chrome/content/zotero/xpcom/sync/syncRunner.js +++ b/chrome/content/zotero/xpcom/sync/syncRunner.js @@ -1492,7 +1492,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.createXULElement('vbox'); var label = doc.createXULElement('label'); if (e.libraryID !== undefined) { @@ -1516,6 +1516,7 @@ Zotero.Sync.Runner_Module = function (options = {}) { if (e.dialogHeader) { let header = doc.createXULElement('description'); header.className = 'error-header'; + header.setAttribute("control", `zotero-sync-error-panel-button-${index}`); header.textContent = e.dialogHeader; content.appendChild(header); } @@ -1538,7 +1539,8 @@ Zotero.Sync.Runner_Module = function (options = {}) { // Make the text selectable desc.setAttribute('style', '-moz-user-select: text; cursor: text'); content.appendChild(desc); - + desc.setAttribute("control", `zotero-sync-error-panel-button-${index}`); + /*// If not an error and there's no explicit button text, don't show // button to report errors if (e.errorType != 'error' && e.dialogButtonText === undefined) { @@ -1557,12 +1559,24 @@ Zotero.Sync.Runner_Module = function (options = {}) { var buttonCallback = e.dialogButtonCallback; } + // eslint-disable-next-line no-inner-declarations + 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.createXULElement('button'); button.setAttribute('label', buttonText); - button.onclick = function () { - buttonCallback(); - panel.hidePopup(); - }; + button.setAttribute("id", `zotero-sync-error-panel-button-${index}`); + addEventHandlers(button, buttonCallback); buttons.appendChild(button); // Second button @@ -1571,11 +1585,10 @@ Zotero.Sync.Runner_Module = function (options = {}) { buttonCallback = e.dialogButton2Callback; let button2 = doc.createXULElement('button'); + button2.setAttribute("id", `zotero-sync-error-panel-button-${index}`); + button.removeAttribute("id"); button2.setAttribute('label', buttonText); - button2.onclick = () => { - buttonCallback(); - panel.hidePopup(); - }; + addEventHandlers(button2, buttonCallback); buttons.insertBefore(button2, button); } } diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js index 85166a0614..0d6fe02296 100644 --- a/chrome/content/zotero/zoteroPane.js +++ b/chrome/content/zotero/zoteroPane.js @@ -57,7 +57,10 @@ var ZoteroPane = new function() this.reportErrors = reportErrors; this.document = document; - + + const modifierIsNotShift = ev => ev.getModifierState("Meta") || ev.getModifierState("Alt") + || ev.getModifierState("Control") || ev.getModifierState("OS"); + var self = this, _loaded = false, _madeVisible = false, titlebarcolorState, titleState, observerService, @@ -129,14 +132,276 @@ var ZoteroPane = new function() // register an observer for Zotero reload observerService = Components.classes["@mozilla.org/observer-service;1"] - .getService(Components.interfaces.nsIObserverService); + .getService(Components.interfaces.nsIObserverService); observerService.addObserver(_reloadObserver, "zotero-reloaded", false); observerService.addObserver(_reloadObserver, "zotero-before-reload", false); this.addReloadListener(_loadPane); // 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")._searchModePopup.flattenedTreeParentNode.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; + } + } + + + observer.observe(document.getElementById("zotero-tb-sync-stop"), { + 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; + } + // manually move focus to search menu when Shift+Tabbing from the search-menu + if (event.key === 'Tab' && event.shiftKey + && !modifierIsNotShift(event) + && event.originalTarget?.tagName == "input") { + event.preventDefault(); + event.stopPropagation(); + document.getElementById("zotero-tb-search")._searchModePopup.flattenedTreeParentNode.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.openPopup(); + } + } + } + + /* 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.openPopup(); + } + } + + /* 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 diff --git a/chrome/content/zotero/zoteroPane.xhtml b/chrome/content/zotero/zoteroPane.xhtml index 627a2ec2ec..e91d7c4e0f 100644 --- a/chrome/content/zotero/zoteroPane.xhtml +++ b/chrome/content/zotero/zoteroPane.xhtml @@ -720,12 +720,13 @@ onkeypress="ZoteroPane_Local.handleKeyPress(event)" chromedir="&locale.dir;"> - + - + @@ -747,6 +748,7 @@ - - + @@ -804,6 +808,7 @@ - + - + @@ -845,15 +852,19 @@ -