Add Go menu for better keyboard navigation

Adds a top-level Go menu to jump to common UI elements without the mouse.
Each entry refreshes lazily on popup show, and items reflect
what's visible for the current tab.

- Tabs Menu, Locate: available in any tab type. Locate expands the
context pane first in reader/note tabs.
- Library, Quick Search: library tab only. Display the shortcut
configured by the respective pref.
- Item-pane sections (Info, Abstract, Tags, etc.): listed dynamically
for library, reader, and note tabs. Selecting one opens its
collapsible section and focuses its header. Sections added later
(built-in or via plugins) are picked up automatically.
- Item Info submenu: in library/reader tabs with a regular item
visible, display an additional menu with item's fields.
This commit is contained in:
Bogdan Abaev 2026-04-27 16:52:53 -07:00
parent 7ca8242a3e
commit c06fdf9e0a
11 changed files with 395 additions and 8 deletions

View file

@ -61,6 +61,15 @@ var ZoteroContextPane = new function () {
}
});
Object.defineProperty(this, 'itemDetails', {
get: () => {
if (_contextPaneInner.mode === 'notes') {
return this.activeEditor?.querySelector('links-box');
}
return document.getElementById('zotero-context-pane-item-deck').selectedPanel;
}
});
this.focus = () => {
return _contextPaneInner.handleFocus();
};

View file

@ -112,6 +112,11 @@
}
}
focusSection() {
this.open = true;
this._abstractField?.focus({ focusVisible: true });
}
render() {
if (!this.item) return;
if (this._isAlreadyRendered()) return;

View file

@ -2558,7 +2558,12 @@
}
focusField(fieldName) {
this.querySelector(`editable-text[fieldname="${fieldName}"]`)?.focus();
this.open = true;
if (fieldName === 'itemType') {
this.querySelector('#item-type-menu').focus({ focusVisible: true });
return;
}
this.querySelector(`editable-text[fieldname="${fieldName}"]`).focus();
}
_saveFieldFocus() {

View file

@ -111,6 +111,17 @@
this.setAttribute("view-type", type);
}
get itemDetails() {
if (this.mode === 'note') {
return this._noteEditor?.querySelector('links-box');
}
if (this.mode === 'item') {
return this._itemDetails;
}
return null;
}
get collapsed() {
return isPaneCollapsed(this);
}

View file

@ -85,6 +85,12 @@ class ItemPaneSectionElementBase extends XULElementBase {
}
}
focusSection() {
if (!this._section) return;
this._section.open = true;
this._section._head?.focus({ focusVisible: true });
}
get collapsible() {
return this._section.collapsible;
}

View file

@ -408,9 +408,9 @@
<html:div id="parent-label" class="label" hidden="true"/>
<html:div id="parent-value" class="value zotero-clicky" hidden="true"/>
-->
<tags-box id="tags"/>
<libraries-collections-box id="libraries-collections"/>
<related-box id="related"/>
<tags-box id="tags" data-pane="tags"/>
<libraries-collections-box id="libraries-collections" data-pane="libraries-collections"/>
<related-box id="related" data-pane="related"/>
`, ['chrome://zotero/locale/zotero.dtd']);
}
@ -433,6 +433,10 @@
this.destroy();
}
get item() {
return this._item;
}
set item(val) {
this._item = val;
this._id('related').item = this._item;
@ -447,6 +451,15 @@
this.refresh();
}
// Mirror relevant methods from itemDetails so that Go menu generation treats LinksBox the same way
getEnabledPanes() {
return Array.from(this.querySelectorAll(':scope > [data-pane]:not([hidden])'));
}
getEnabledPane(id) {
return this.querySelector(`:scope > [data-pane="${CSS.escape(id)}"]:not([hidden])`);
}
set mode(val) {
this._mode = val;
this._id('related').editable = val == "edit";

View file

@ -101,6 +101,10 @@ const ZoteroStandalone = new function () {
.setAttribute('key', Zotero.Keys.getKeyForCommand('copySelectedItemsToClipboard'));
document.getElementById('key_showTabsMenu')
.setAttribute('key', Zotero.Keys.getKeyForCommand('showTabsMenu'));
document.getElementById('key_library')
.setAttribute('key', Zotero.Keys.getKeyForCommand('library'));
document.getElementById('key_quicksearch')
.setAttribute('key', Zotero.Keys.getKeyForCommand('quicksearch'));
// Force menu to update with shortcut key at startup -- as of fx128, this is necessary
// to get the shortcut to reliably appear for the menu item without switching tabs
document.getElementById('show-tabs-menu').hidden = true;
@ -390,6 +394,8 @@ const ZoteroStandalone = new function () {
this.onGoMenuOpen = function (event) {
if (event.target !== event.currentTarget) return;
var keyBack = document.getElementById('key_back');
var keyForward = document.getElementById('key_forward');
@ -423,10 +429,135 @@ const ZoteroStandalone = new function () {
this.updateMenuItemEnabled('go-menuitem-forward', reader.canNavigateForward);
}
this._rebuildGoMenuSections(event.target);
this.onUpdateCustomMenus(event, 'go');
};
this._rebuildGoMenuSections = function (popup) {
let beforeSep = document.getElementById('go-menu-sep-sections-before');
let afterSep = document.getElementById('go-menu-sep-sections-after');
// Clear previously inserted dynamic entries
popup.querySelectorAll('.go-menu-section-dynamic').forEach(el => el.remove());
let itemDetails = Zotero_Tabs.currentItemPane?.itemDetails;
let hasSections = false;
for (let pane of (itemDetails?.getEnabledPanes() || [])) {
let paneID = pane.dataset.pane;
let section = pane.querySelector('collapsible-section');
if (!section) continue;
let label = Zotero.ftl.formatValueSync(`pane-${paneID}`)
|| section.getAttribute('label')
|| paneID;
let element;
if (paneID === 'info') {
element = document.createXULElement('menu');
element.setAttribute('label', label);
let submenu = document.createXULElement('menupopup');
element.appendChild(submenu);
this._populateItemInfoFields(submenu, itemDetails.item);
if (!submenu.hasChildNodes()) {
element.setAttribute('disabled', true);
}
}
else {
element = document.createXULElement('menuitem');
element.setAttribute('label', label);
element.addEventListener('command', () => {
Zotero_Tabs.currentItemPane.collapsed = false;
itemDetails.getEnabledPane(paneID).focusSection();
});
}
element.classList.add('go-menu-section-dynamic');
popup.insertBefore(element, afterSep);
hasSections = true;
}
// Only unhide separators the current tab type allows
let type = Zotero_Tabs.selectedType;
let beforeApplies = beforeSep.classList.contains(`menu-type-${type}`);
let afterApplies = afterSep.classList.contains(`menu-type-${type}`);
beforeSep.hidden = !(hasSections && beforeApplies);
afterSep.hidden = !(hasSections && afterApplies);
};
this._populateItemInfoFields = function (popup, item) {
let focusItemInfoField = function (fieldName) {
let pane = Zotero_Tabs.currentItemPane;
if (!pane) return;
pane.collapsed = false;
pane.itemDetails?.querySelector('info-box')?.focusField(fieldName);
};
let itemTypeMenuItem = document.createXULElement('menuitem');
itemTypeMenuItem.setAttribute('label', Zotero.getString('zotero.items.itemType'));
itemTypeMenuItem.addEventListener('command', () => focusItemInfoField('itemType'));
popup.appendChild(itemTypeMenuItem);
let fieldIDs = Zotero.ItemFields.getItemTypeFields(item.getField('itemTypeID'));
let titleFieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(item.itemTypeID, 'title');
for (let fieldID of fieldIDs) {
let fieldName = Zotero.ItemFields.getName(fieldID);
// Skip abstract - it has its own section
if (fieldName === "abstractNote") continue;
let menuitem = document.createXULElement('menuitem');
menuitem.setAttribute('label', Zotero.ItemFields.getLocalizedString(fieldID));
menuitem.addEventListener('command', () => focusItemInfoField(fieldName));
popup.appendChild(menuitem);
if (fieldID === titleFieldID) {
let firstCreator = item.getCreators()[0];
let creatorTypeID;
if (firstCreator) {
creatorTypeID = firstCreator.creatorTypeID;
}
else if (item.library.editable
&& Zotero.CreatorTypes.itemTypeHasCreators(item.itemTypeID)) {
creatorTypeID = Zotero.CreatorTypes.getPrimaryIDForType(item.itemTypeID);
}
if (creatorTypeID) {
let creatorMenuItem = document.createXULElement('menuitem');
creatorMenuItem.setAttribute('label',
Zotero.CreatorTypes.getLocalizedString(creatorTypeID));
creatorMenuItem.addEventListener('command', () => focusItemInfoField('creator-0-lastName'));
popup.appendChild(creatorMenuItem);
}
}
}
};
this.onGoMenuCommand = function (action) {
let target;
if (action === 'locate') {
let sidenavID = "zotero-view-item-sidenav";
// in non-library tabs the sidenav is hidden if context pane is collapsed
if (Zotero_Tabs.hasContextPane(Zotero_Tabs.selectedType)) {
ZoteroContextPane.collapsed = false;
sidenavID = "zotero-context-pane-sidenav";
}
target = document.querySelector(`#${sidenavID} toolbarbutton[data-action="locate"]`);
}
else {
let targetIDs = {
tabs: 'zotero-tb-tabs-menu',
library: 'collection-tree',
'quick-search': 'zotero-tb-search-textbox',
};
target = document.getElementById(targetIDs[action]);
}
if (target) {
target.focus({ focusVisible: true });
}
};
this.onViewMenuOpen = function (event) {
// PDF Reader
var reader = Zotero.Reader.getByTabID(Zotero_Tabs.selectedID);

View file

@ -92,6 +92,18 @@ var Zotero_Tabs = new function () {
return this._hasContextPaneTypes.includes(type);
};
Object.defineProperty(this, 'currentItemPane', {
get: () => {
if (this.selectedType === 'library') {
return ZoteroPane.itemPane;
}
if (this.hasContextPane(this.selectedType)) {
return ZoteroContextPane;
}
return null;
}
});
this.hasNoteContext = function (type) {
return this._hasNoteContextTypes.includes(type);
};

View file

@ -181,6 +181,10 @@
<key id="key_showTabsMenu"
command="cmd_zotero_showTabsMenu"
modifiers="accel"/>
<key id="key_library"
modifiers="accel shift"/>
<key id="key_quicksearch"
modifiers="accel shift"/>
</keyset>
<keyset id="editMenuKeys">
@ -711,13 +715,43 @@
</menupopup>
</menu>
<menu
<menu
id="go-menu"
class="menu-type-reader"
class="menu-type-reader menu-type-library menu-type-note"
label="&goMenu.label;"
accesskey="&goMenu.accesskey;">
<menupopup id="menu_goPopup"
onpopupshowing="ZoteroStandalone.onGoMenuOpen(event)">
<menuitem
id="go-menuitem-tabs"
class="menu-type-library menu-type-reader menu-type-note"
data-l10n-id="menu-go-tabs"
oncommand="ZoteroStandalone.onGoMenuCommand('tabs')"
/>
<menuitem
id="go-menuitem-library"
class="menu-type-library"
data-l10n-id="menu-go-library"
key="key_library"
oncommand="ZoteroStandalone.onGoMenuCommand('library')"
/>
<menuitem
id="go-menuitem-quick-search"
class="menu-type-library"
data-l10n-id="menu-go-quick-search"
key="key_quicksearch"
oncommand="ZoteroStandalone.onGoMenuCommand('quick-search')"
/>
<menuitem
id="go-menuitem-locate"
class="menu-type-library menu-type-reader menu-type-note"
data-l10n-id="menu-go-locate"
oncommand="ZoteroStandalone.onGoMenuCommand('locate')"
/>
<menuseparator id="go-menu-sep-sections-before"
class="menu-type-library menu-type-reader menu-type-note"/>
<menuseparator id="go-menu-sep-sections-after"
class="menu-type-reader"/>
<menuitem
id="go-menuitem-first-page"
class="menu-type-reader pdf epub"

View file

@ -150,6 +150,15 @@ menu-view-note-tab-font-size =
menu-show-tabs-menu =
.label = Show Tabs Menu
menu-go-tabs =
.label = Tabs Menu
menu-go-library =
.label = Library
menu-go-quick-search =
.label = Quick Search
menu-go-locate =
.label = Locate
menu-edit-copy-annotation =
.label = { $count ->
[one] Copy Annotation

View file

@ -1944,4 +1944,156 @@ describe("ZoteroPane", function () {
assert.includeMembers(topLevelCollections, [collectionChild]);
});
});
describe("Go menu", function () {
let goPopup;
before(function () {
goPopup = doc.getElementById('menu_goPopup');
});
beforeEach(async function () {
// Switch back to library tab if a previous test opened a reader/note tab
if (win.Zotero_Tabs.selectedID !== 'zotero-pane') {
let promise = waitForNotifierEvent("select", "tab");
win.Zotero_Tabs.select('zotero-pane');
await promise;
}
win.Zotero_Tabs.closeAll();
await selectLibrary(win);
// Clear lingering item selection from a previous test
if (zp.itemsView.selection.count > 0) {
let promise = zp.itemsView.waitForSelect();
zp.itemsView.selection.clearSelection();
await promise;
}
});
function openGoMenu() {
goPopup.dispatchEvent(new MouseEvent('popupshowing'));
}
function visibleStaticItemIds() {
return [...goPopup.children]
.filter(el => !el.classList.contains('go-menu-section-dynamic')
&& !el.hidden
&& ['menuitem', 'menu'].includes(el.tagName.toLowerCase()))
.map(el => el.id);
}
function dynamicSectionLabels() {
return [...goPopup.querySelectorAll('.go-menu-section-dynamic')]
.map(el => el.getAttribute('label'));
}
it("should show only Tabs Menu, Library, Quick Search, and Locate in library tab with no selection", function () {
openGoMenu();
assert.sameMembers(visibleStaticItemIds(), [
'go-menuitem-tabs',
'go-menuitem-library',
'go-menuitem-quick-search',
'go-menuitem-locate'
]);
assert.lengthOf(dynamicSectionLabels(), 0);
});
it("should list item-pane sections when an item is selected in library tab", async function () {
let item = await createDataObject('item', { itemType: 'book' });
await select(win, item);
openGoMenu();
let labels = dynamicSectionLabels();
assert.include(labels, 'Info');
assert.include(labels, 'Abstract');
assert.include(labels, 'Attachments');
assert.include(labels, 'Notes');
assert.include(labels, 'Libraries and Collections');
assert.include(labels, 'Tags');
assert.include(labels, 'Related');
// Selecting a section should focus its collapsible-section header
let tagsMenuItem = [...goPopup.querySelectorAll('.go-menu-section-dynamic')]
.find(el => el.getAttribute('label') === 'Tags');
let focusPromise = waitForDOMEvent(win.ZoteroPane.itemPane.itemDetails, 'focusin');
tagsMenuItem.dispatchEvent(new MouseEvent('command'));
await focusPromise;
assert.equal(doc.activeElement.textContent, "0 Tags");
assert.isTrue(doc.activeElement.classList.contains("head"));
});
it("should render the Item Info entry as a submenu of item fields", async function () {
let item = await createDataObject('item', { itemType: 'book' });
await select(win, item);
openGoMenu();
let info = [...goPopup.querySelectorAll('.go-menu-section-dynamic')]
.find(el => el.getAttribute('label') === 'Info');
assert.ok(info, "Info entry should exist");
assert.equal(info.tagName.toLowerCase(), 'menu');
let submenu = info.querySelector('menupopup');
let fieldLabels = [...submenu.children].map(el => el.getAttribute('label'));
assert.include(fieldLabels, "Item Type");
assert.include(fieldLabels, "Author");
let fieldIDs = Zotero.ItemFields.getItemTypeFields(item.itemTypeID);
for (let fieldID of fieldIDs) {
let fieldName = Zotero.ItemFields.getName(fieldID);
if (fieldName === "abstractNote") continue;
let label = Zotero.ItemFields.getLocalizedString(fieldID);
assert.include(fieldLabels, label, `Item Info submenu should include "${label}"`);
}
// Selecting Title should focus the title editable-text in the info-box
let titleFieldID = Zotero.ItemFields.getFieldIDFromTypeAndBase(item.itemTypeID, 'title');
let titleLabel = Zotero.ItemFields.getLocalizedString(titleFieldID);
let titleMenuItem = [...submenu.children].find(el => el.getAttribute('label') === titleLabel);
let infoBox = win.ZoteroPane.itemPane.itemDetails.querySelector('info-box');
let focusPromise = waitForDOMEvent(infoBox, 'focusin');
titleMenuItem.dispatchEvent(new MouseEvent('command'));
await focusPromise;
assert.equal(doc.activeElement.closest('editable-text').id, 'itembox-field-value-title');
});
it("should list Tags, Related, and Libraries and Collections when a note is selected in library tab", async function () {
let note = new Zotero.Item('note');
note.setNote('test');
await note.saveTx();
await select(win, note);
openGoMenu();
let labels = dynamicSectionLabels();
assert.include(labels, 'Tags');
assert.include(labels, 'Related');
assert.include(labels, 'Libraries and Collections');
assert.notInclude(labels, 'Info');
assert.notInclude(labels, 'Abstract');
});
it("should list attachment sections for a standalone PDF in a reader tab", async function () {
let attachment = await importPDFAttachment();
let tabPromise = waitForNotifierEvent("select", "tab");
let reader = await Zotero.Reader.open(attachment.itemID);
await reader._initPromise;
await tabPromise;
openGoMenu();
let labels = dynamicSectionLabels();
assert.include(labels, 'Attachment Info');
assert.include(labels, 'Libraries and Collections');
assert.include(labels, 'Tags');
assert.include(labels, 'Related');
});
it("should list Note Info, Libraries and Collections, Tags, and Related for a note tab", async function () {
let note = new Zotero.Item('note');
note.setNote('test note');
await note.saveTx();
await Zotero.Notes.open(note.id);
await waitForCallback(
() => win.Zotero_Tabs.currentItemPane?.itemDetails?.item?.id === note.id,
50, 5
);
openGoMenu();
let labels = dynamicSectionLabels();
assert.include(labels, 'Note Info');
assert.include(labels, 'Libraries and Collections');
assert.include(labels, 'Tags');
assert.include(labels, 'Related');
});
});
})