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;">
-
+
-
+
-
+
@@ -845,15 +852,19 @@
-
+
-
+
-