itembox focus edits and refactoring (#4096)

- removed ztabindex logic from itemBox. It is no longer needed, adds
  unnecessary complexity and is likely at the root of multiple glitches
  if a plugin inserts an arbitrary row that does not have ztabindex set.
- if a creator row is deleted when the focus is inside of the row, focus
  another creator row to not loose the focus.
- more centralized button handling in `_ensureButtonsFocusable` and
  `_updateCreatorButtonsStatus`
- refactoring of hidden toolbarbuttons css so that the icons are still
  hidden and don't occupy space (if desired) but are still visible for
  screen readers, so they are focusable without JS changing their
  visibility (this with ztabindex removal fixes vpat 24)
- removed `escape_enter` event from `editable-text`. It was a workaround
  to know when itemBox should move focus back to itemTree. Unhandled
  Enter on an input or Escape should focus itemTree (or reader) from
  anywhere in the itemPane/contextPane (not just itemBox), so that logic
  is moved to itemDetails.js. To avoid conflicts with Shift-Enter, do
  not propagate that event outside of multiline editable-text. Fixes:
  #3896
- removed not necessary keyboard nav handling from itemDetails.js. It
  was only needed for mac, and is redundant if "Keyboard navigation"
  setting is on
- using `keydown` instead of `keypress` for itemDetails keyboard nav
  handling because `Enter` `keypress` does not seem to get out of
  `editable-text` but `keydown` does.
- old handleKeyPress from itemBox is no longer relevant for most
  elements, so it is removed and substituted with a dedicated handler
  just for creator row.
- moved the creator's paste handler into its own dedicated function
  from the autocomplete handler (which was confusing)
- special handling for `enter` and `escape` events on `editable-text`
  with autocomplete to not stop event propagation, so that the events
  can bubble and be handled in `itemDetails`. It avoids some cases of
  the focus being lost and returned to the `window`. It was unnecessary
  earlier due to `escape_enter` workaround but only within itemBox and
  only within itemPane.
- removed explicit tab navigation handling from `collapsible-section`
  header. Currently, it may get stuck when buttons are hidden (e.g. in
  the trash mode). It was only added to enable keyboard navigation on
  mac before special "Keyboard navigation" setting was discovered (it
  was never an issue on windows), so now it's easier to just let mozilla
  handle it.
- always use `getTitleField` to find and focus the proper title field in
  itemBox
- on shift-tab from the focused tab, just move focus to the first
  focusable element before the splitter without any special handling for
  attachments, notes and duplicates pane as before. It ensures a more
  consistent and predictable keyboard navigation, especially now that
  itemPane is fairly keyboard accessible.

Fixes: #4076
This commit is contained in:
Bogdan Abaev 2024-05-07 10:42:20 -04:00 committed by Dan Stillman
parent 1394381257
commit c6799bc3c2
8 changed files with 251 additions and 481 deletions

View file

@ -334,27 +334,6 @@
event.stopPropagation();
};
// Tab/Shift-Tab from section header through header buttons
if (event.key === "Tab") {
let nextBtn;
if (tgt.classList.contains("head") && event.shiftKey) {
return;
}
if (tgt.classList.contains("head")) {
nextBtn = this._head.querySelector("toolbarbutton");
}
else {
nextBtn = event.shiftKey ? tgt.previousElementSibling : tgt.nextElementSibling;
}
if (nextBtn?.tagName == "popupset") {
nextBtn = this._head;
}
if (nextBtn) {
nextBtn.focus();
stopEvent();
}
}
if (event.target.tagName === "toolbarbutton") {
// No actions on right/left on header buttons
if (["ArrowRight", "ArrowLeft"].includes(event.key)) {

View file

@ -193,6 +193,14 @@
input.addEventListener('mousedown', this._handleMouseDown);
input.addEventListener('dragover', this._handleDragOver);
input.addEventListener('drop', this._handleDrop);
if (autocompleteEnabled) {
// Even through this may run multiple times on editable-text, the listener
// is added only once because we pass the reference to the same exact function.
this.addEventListener('keydown', this._captureAutocompleteKeydown, true);
}
else {
this.removeEventListener('keydown', this._captureAutocompleteKeydown, true);
}
let focused = this.focused;
let selectionStart = this._input?.selectionStart;
@ -333,18 +341,37 @@
if (event.key === 'Enter') {
if (this.multiline === event.shiftKey) {
event.preventDefault();
this.dispatchEvent(new CustomEvent('escape_enter'));
this._input.blur();
}
// Do not let out shift-enter event on multiline, since it should never do
// anything but add a linebreak to textarea
if (this.multiline && !event.shiftKey) {
event.stopPropagation();
}
}
else if (event.key === 'Escape') {
this.dispatchEvent(new CustomEvent('escape_enter'));
let initialValue = this._input.dataset.initialValue ?? '';
this.setAttribute('value', initialValue);
this._input.value = initialValue;
this._input.blur();
}
};
_captureAutocompleteKeydown = (event) => {
// On Enter or Escape, mozilla stops propagation of the event which may interfere with out handling
// of the focus. E.g. the event should be allowed to reach itemDetails from itemBox so that focus
// can be moved to the itemTree or the reader.
// https://searchfox.org/mozilla-central/source/toolkit/content/widgets/autocomplete-input.js#564
// To avoid it, capture Enter and Escape keydown events and handle them without stopping propagation.
if (this._input.autocomplete !== "on" || !["Enter", "Escape"].includes(event.key)) return;
event.preventDefault();
if (event.key == "Enter") {
this._input.handleEnter();
}
else if (event.key == "Escape") {
this._input.mController.handleEscape();
}
};
_handleMouseDown = (event) => {
this.setAttribute("mousedown", true);

View file

@ -47,11 +47,8 @@
this._editableFields = [];
this._fieldAlternatives = {};
this._fieldOrder = [];
this._tabIndexMinCreators = 100;
this._tabIndexMaxFields = 0;
this._initialVisibleCreators = 5;
this._draggedCreator = false;
this._ztabindex = 0;
this._selectField = null;
}
@ -116,7 +113,6 @@
);
typeBox.setAttribute('typeid', typeID);
this._lastTabIndex = -1;
this.modifyCreator(index, fields);
if (this.saveOnEdit) {
await this.blurOpenField();
@ -226,6 +222,23 @@
() => Zotero.Utilities.Internal.copyTextToClipboard(this._linkMenu.dataset.link)
);
// Save the last focused element, so that the focus can go back to it if
// the table is refreshed
this._infoTable.addEventListener("focusin", (e) => {
let target = e.target.closest("[fieldname], [tabindex], [focusable]");
if (target?.id) {
this._selectField = target.id;
}
});
// If the focus leaves the itemBox, clear the last focused element
this._infoTable.addEventListener("focusout", (e) => {
let destination = e.relatedTarget;
if (!(destination && this._infoTable.contains(destination))) {
this._selectField = null;
}
});
this._notifierID = Zotero.Notifier.registerObserver(this, ['item'], 'itemBox');
Zotero.Prefs.registerObserver('fontSize', () => {
this._forceRenderAll();
@ -327,7 +340,6 @@
}
this._item = val;
this._lastTabIndex = null;
this.scrollToTop();
}
@ -457,14 +469,6 @@
if (id != this.item.id) {
continue;
}
let activeArea = this.getFocusedTextArea();
// Re-select currently active area after refresh.
if (activeArea) {
this._selectField = activeArea.getAttribute("fieldname");
}
if (document.activeElement == this.itemTypeMenu) {
this._selectField = "item-type-menu";
}
this._forceRenderAll();
break;
}
@ -484,8 +488,6 @@
if (this._isAlreadyRendered()) return;
// Init tab index to begin after all creator rows
this._ztabindex = this._tabIndexMinCreators * (this.item.numCreators() || 1);
delete this._linkMenu.dataset.link;
//
@ -498,13 +500,6 @@
// Item type menu
this.addItemTypeMenu();
this.updateItemTypeMenuSelection();
this.itemTypeMenu.disabled = !this.showTypeMenu;
// Re-focus item type menu if it was focused before refresh
if (this._selectField == "item-type-menu") {
Services.focus.setFocus(this.itemTypeMenu, Services.focus.FLAG_SHOWRING);
this._selectField = null;
this._lastTabIndex = null;
}
var fieldNames = [];
// Manual field order
@ -653,7 +648,6 @@
if (!(Zotero.ItemFields.isLong(fieldName) || Zotero.ItemFields.isMultiline(fieldName))) {
optionsButton.classList.add("no-display");
}
optionsButton.setAttribute("ztabindex", ++this._ztabindex);
optionsButton.setAttribute('data-l10n-id', "itembox-button-options");
// eslint-disable-next-line no-loop-func
let triggerPopup = (e) => {
@ -672,20 +666,14 @@
};
// Same popup triggered for right-click and options button click
optionsButton.addEventListener("click", triggerPopup);
optionsButton.addEventListener('keypress', event => this.handleKeyPress(event));
rowData.appendChild(optionsButton);
rowData.oncontextmenu = triggerPopup;
}
this.addDynamicRow(rowLabel, rowData);
if (fieldName && this._selectField == fieldName) {
valueElement.focus();
this._selectField = null;
}
// In field merge mode, add a button to switch field versions
else if (this.mode == 'fieldmerge' && typeof this._fieldAlternatives[fieldName] != 'undefined') {
if (this.mode == 'fieldmerge' && typeof this._fieldAlternatives[fieldName] != 'undefined') {
var button = document.createXULElement("toolbarbutton");
button.className = 'zotero-field-version-button zotero-clicky-merge';
button.setAttribute('type', 'menu');
@ -715,13 +703,11 @@
rowData.appendChild(button);
}
}
this._tabIndexMaxFields = this._ztabindex; // Save the last tab index
//
// Creators
//
this._ztabindex = 1; // Reset tab index to 1, since creators go before other fields
// Creator type menu
if (this.editable) {
while (this._creatorTypeMenu.hasChildNodes()) {
@ -778,11 +764,6 @@
for (let i = 0; i < max; i++) {
let data = this.item.getCreator(i);
this.addCreatorRow(data, data.creatorTypeID);
// Display "+" button on all but last row
if (i == max - 2) {
this.disableCreatorAddButtons();
}
}
if (this._draggedCreator) {
this._draggedCreator = false;
@ -802,8 +783,6 @@
// Additional creators not displayed
if (num > max) {
this.addMoreCreatorsRow(num - max);
this.disableCreatorAddButtons();
}
else {
// If we didn't start with creators truncated,
@ -815,20 +794,14 @@
if (this._addCreatorRow) {
this.addCreatorRow(false, this.item.getCreator(max - 1).creatorTypeID, true);
this._addCreatorRow = false;
this.disableCreatorAddButtons();
}
}
}
else if (this.editable && Zotero.CreatorTypes.itemTypeHasCreators(this.item.itemTypeID)) {
// Add default row
this.addCreatorRow(false, false, true, true);
this.disableCreatorAddButtons();
}
// Move to next or previous field if (shift-)tab was pressed
if (this._lastTabIndex && this._lastTabIndex != -1) {
this._focusNextField(this._lastTabIndex);
}
if (this._showCreatorTypeGuidance) {
let creatorTypeLabels = this.querySelectorAll(".creator-type-label");
@ -866,11 +839,16 @@
}
});
}
this._refreshed = true;
// Add tabindex=0 to all focusable element
this.querySelectorAll("[ztabindex]").forEach((node) => {
node.setAttribute("tabindex", 0);
});
this._ensureButtonsFocusable();
// Set focus on the last focused field
if (this._selectField) {
let refocusField = this.querySelector(`#${this._selectField}`);
if (refocusField) {
refocusField.focus();
}
}
// Make sure that any opened popup closes
this.querySelectorAll("menupopup").forEach((popup) => {
popup.hidePopup();
@ -896,24 +874,27 @@
else {
var menulist = document.createXULElement("menulist", { is: "menulist-item-types" });
menulist.id = "item-type-menu";
menulist.className = "zotero-clicky";
menulist.className = "zotero-clicky keyboard-clickable";
menulist.addEventListener('command', (event) => {
this.changeTypeTo(event.target.value, menulist);
});
menulist.addEventListener('focus', () => {
this.ensureElementIsVisible(menulist);
});
menulist.addEventListener('keypress', (event) => {
if (event.keyCode == event.DOM_VK_TAB) {
this.handleKeyPress(event);
// This is instead of setting disabled=true so that the menu is not excluded
// from tab navigation. For <input>s, we just set readonly=true but it is not
// a valid property for menulist.
menulist.addEventListener("popupshowing", (e) => {
if (!this._editable) {
e.preventDefault();
e.stopPropagation();
}
});
menulist.setAttribute("aria-labelledby", "itembox-field-itemType-label");
this.itemTypeMenu = menulist;
rowData.appendChild(menulist);
}
this.itemTypeMenu.setAttribute('ztabindex', '1');
this.itemTypeMenu.disabled = !this.showTypeMenu;
this.itemTypeMenu.setAttribute("aria-disabled", !this._editable);
row.appendChild(labelWrapper);
row.appendChild(rowData);
this._infoTable.appendChild(row);
@ -967,8 +948,10 @@
let labelWrapper = document.createElement('div');
let grippy = document.createXULElement('toolbarbutton');
labelWrapper.className = 'creator-type-label';
labelWrapper.className = 'creator-type-label keyboard-clickable';
labelWrapper.setAttribute("tabindex", 0);
grippy.className = "zotero-clicky zotero-clicky-grippy show-on-hover";
grippy.setAttribute('tabindex', -1);
rowLabel.appendChild(grippy);
if (this.editable) {
@ -989,7 +972,7 @@
labelWrapper.setAttribute('role', 'button');
labelWrapper.setAttribute('aria-describedby', 'creator-type-label-inner');
labelWrapper.setAttribute('ztabindex', ++this._ztabindex);
labelWrapper.setAttribute('id', `creator-${rowIndex}-label`);
// If not editable or only 1 creator row, hide grippy
if (!this.editable || this.item.numCreators() < 2) {
@ -1016,7 +999,6 @@
this.createValueElement(
lastName,
fieldName,
++this._ztabindex
)
);
@ -1026,7 +1008,6 @@
this.createValueElement(
firstName,
fieldName,
++this._ztabindex
)
);
firstNameElem.placeholder = this._defaultFirstName;
@ -1039,41 +1020,27 @@
// Minus (-) button
var removeButton = document.createXULElement('toolbarbutton');
removeButton.setAttribute("class", "zotero-clicky zotero-clicky-minus show-on-hover no-display");
removeButton.setAttribute('ztabindex', ++this._ztabindex);
removeButton.setAttribute('id', `creator-${rowIndex}-remove`);
removeButton.setAttribute('tooltiptext', Zotero.getString('general.delete'));
// If default first row, don't let user remove it
if (defaultRow || !this.editable) {
this.disableButton(removeButton);
}
else {
removeButton.addEventListener("click", () => {
this.removeCreator(rowIndex, rowData.parentNode);
});
}
removeButton.addEventListener("command", () => this.removeCreator(rowIndex, rowData.parentNode));
rowData.appendChild(removeButton);
// Plus (+) button
var addButton = document.createXULElement('toolbarbutton');
addButton.setAttribute("class", "zotero-clicky zotero-clicky-plus show-on-hover no-display");
addButton.setAttribute('ztabindex', ++this._ztabindex);
addButton.setAttribute('id', `creator-${rowIndex}-add`);
addButton.setAttribute('tooltiptext', Zotero.getString('general.create'));
// If row isn't saved, don't let user add more
if (unsaved || !this.editable) {
this.disableButton(addButton);
}
else {
this._enablePlusButton(addButton, typeID, fieldMode);
}
addButton.addEventListener("command", () => this.addCreatorRow(null, typeID, true));
rowData.appendChild(addButton);
// Options button that opens creator transform menu
let optionsButton = document.createXULElement("toolbarbutton");
if (!this.editable) {
optionsButton.style.visibility = "hidden";
this.disableButton(optionsButton);
optionsButton.disabled = true;
}
optionsButton.className = "zotero-clicky zotero-clicky-options show-on-hover no-display";
optionsButton.setAttribute('ztabindex', ++this._ztabindex);
optionsButton.setAttribute('id', `creator-${rowIndex}-options`);
optionsButton.setAttribute('data-l10n-id', "itembox-button-options");
let triggerPopup = (e) => {
document.popupNode = firstlast;
@ -1086,28 +1053,18 @@
rowData.appendChild(optionsButton);
if (this.editable) {
optionsButton.addEventListener("click", triggerPopup);
optionsButton.addEventListener("command", triggerPopup);
rowData.oncontextmenu = triggerPopup;
}
if (!this.preventFocus) {
for (const domEl of [labelWrapper, removeButton, addButton, optionsButton]) {
domEl.setAttribute('tabindex', '0');
domEl.addEventListener('keypress', this.handleKeyPress.bind(this));
domEl.addEventListener('focusin', this.updateLastFocused.bind(this));
}
}
this._creatorCount++;
if (!this.editable) {
removeButton.hidden = true;
addButton.hidden = true;
optionsButton.hidden = true;
}
let row = this.addDynamicRow(rowLabel, rowData, true);
this._updateCreatorButtonsStatus();
this._ensureButtonsFocusable();
/**
* Events handling creator drag-drop reordering
*/
@ -1173,6 +1130,8 @@
this.addAutocompleteToElement(lastNameElem);
this.addAutocompleteToElement(firstNameElem);
row.addEventListener("keydown", e => this.handleCreatorRowKeyDown(e));
lastNameElem.addEventListener("paste", e => this.handleCreatorPaste(e));
// Focus new rows
if (unsaved && !defaultRow) {
lastNameElem.focus();
@ -1186,7 +1145,7 @@
var rowData = document.createElement('div');
rowData.className = "meta-data";
rowData.id = 'more-creators-label';
rowData.setAttribute("ztabindex", ++this._ztabindex);
rowData.setAttribute("tabindex", 0);
rowData.addEventListener('click', () => {
this._displayAllCreators = true;
this._forceRenderAll();
@ -1248,10 +1207,6 @@
delete lastName.style.width;
delete lastName.style.maxWidth;
// Remove firstname field from tabindex
tab = parseInt(firstName.getAttribute('ztabindex'));
firstName.setAttribute('ztabindex', -1);
// Hide first name field and prepend to last name field
firstName.hidden = true;
@ -1264,13 +1219,6 @@
}
// Clear first name value after it was moved to the full name field
firstName.value = "";
// If one of the creator fields is open, leave it open after swap
let activeField = this.getFocusedTextArea();
if (activeField == firstName || activeField == lastName) {
this._lastTabIndex = parseInt(lastName.getAttribute('ztabindex'));
this._tabDirection = false;
}
}
// Switch to two-field mode
else {
@ -1278,9 +1226,6 @@
lastName.setAttribute('fieldMode', '0');
lastName.placeholder = this._defaultLastName;
// Add firstname field to tabindex
tab = parseInt(lastName.getAttribute('ztabindex'));
firstName.setAttribute('ztabindex', tab + 1);
if (!initial) {
// Move all but last word to first name field and show it
@ -1426,26 +1371,15 @@
return false;
}
disableButton(button) {
button.setAttribute('disabled', true);
button.setAttribute('onclick', false);
}
_enablePlusButton(button, creatorTypeID, _fieldMode) {
button.removeAttribute('disabled');
button.onclick = () => {
this.disableButton(button);
this.addCreatorRow(null, creatorTypeID, true);
};
}
disableCreatorAddButtons() {
// Disable the "+" button on all creator rows
var elems = this._infoTable.getElementsByClassName('zotero-clicky-plus');
for (let elem of elems) {
this.disableButton(elem);
}
// Toolbarbuttons required tabindex=0 to be properly focusable via tab
_ensureButtonsFocusable() {
this.querySelectorAll("toolbarbutton").forEach((btn) => {
if (!btn.getAttribute('tabindex')) {
btn.setAttribute("tabindex", 0);
}
});
}
createOpenLinkIcon(value) {
// In duplicates/trash mode return nothing
@ -1455,8 +1389,6 @@
let openLink = document.createXULElement("toolbarbutton");
openLink.className = "zotero-clicky zotero-clicky-open-link show-on-hover no-display";
openLink.addEventListener("click", event => ZoteroPane.loadURI(value, event));
openLink.addEventListener('keypress', event => this.handleKeyPress(event));
openLink.setAttribute("ztabindex", ++this._ztabindex);
openLink.setAttribute('data-l10n-id', "item-button-view-online");
return openLink;
}
@ -1485,23 +1417,15 @@
if (this._fieldIsClickable(fieldName)) {
valueElement.addEventListener("focus", e => this.showEditor(e.target));
valueElement.addEventListener("blur", e => this.hideEditor(e.target));
valueElement.addEventListener("escape_enter", (_) => {
setTimeout(() => {
document.getElementById('item-tree-main-default').focus();
});
});
}
else {
valueElement.setAttribute('readonly', true);
}
valueElement.setAttribute('ztabindex', ++this._ztabindex);
valueElement.setAttribute('id', `itembox-field-value-${fieldName}`);
valueElement.setAttribute('fieldname', fieldName);
valueElement.setAttribute('tight', true);
valueElement.addEventListener("focus", e => this.updateLastFocused(e));
valueElement.addEventListener("keypress", e => this.handleKeyPress(e));
switch (fieldName) {
case 'itemType':
valueElement.setAttribute('itemTypeID', valueText);
@ -1557,18 +1481,19 @@
return valueElement;
}
async removeCreator(index, labelToDelete) {
async removeCreator(index, creatorRow) {
// Move focus to another creator row
if (creatorRow.contains(document.activeElement)) {
let nextCreatorIndex = index ? index - 1 : 0;
this._selectField = `itembox-field-value-creator-${nextCreatorIndex}-lastName`;
}
// If unsaved row, just remove element
if (!this.item.hasCreatorAt(index)) {
labelToDelete.parentNode.removeChild(labelToDelete);
// Enable the "+" button on the previous row
var elems = this._infoTable.getElementsByClassName('zotero-clicky-plus');
var button = elems[elems.length - 1];
var creatorFields = this.getCreatorFields(button.closest('.meta-row'));
this._enablePlusButton(button, creatorFields.creatorTypeID, creatorFields.fieldMode);
creatorRow.remove();
this._creatorCount--;
this.querySelector(`#${this._selectField}`)?.focus();
this._updateCreatorButtonsStatus();
return;
}
await this.blurOpenField();
@ -1676,93 +1601,19 @@
// https://searchfox.org/mozilla-central/rev/2d678a843ceab81e43f7ffb83212197dc10e944a/toolkit/content/widgets/autocomplete-input.js#372
// https://searchfox.org/mozilla-central/rev/2d678a843ceab81e43f7ffb83212197dc10e944a/browser/components/search/content/searchbar.js#791
elem.onTextEntered = () => {
this.handleCreatorAutoCompleteSelect(elem, true);
this.handleCreatorAutoCompleteSelect(elem);
};
// Tab/Shift-Tab
elem.addEventListener('change', () => {
this.handleCreatorAutoCompleteSelect(elem, true);
this.handleCreatorAutoCompleteSelect(elem);
});
if (creatorField == 'lastName') {
elem.addEventListener('paste', (event) => {
let lastName = event.clipboardData.getData('text').trim();
// Handle \n\r and \n delimited entries and a single line containing a tab
var rawNameArray = lastName.split(/\r\n?|\n/);
if (rawNameArray.length > 1 || rawNameArray[0].includes('\t')) {
// Pasting multiple authors; first make sure we prevent normal paste behavior
event.preventDefault();
// Save tab direction and add creator flags since they are reset in the
// process of adding multiple authors
var tabDirectionBuffer = this._tabDirection;
var addCreatorRowBuffer = this._addCreatorRow;
var tabIndexBuffer = this._lastTabIndex;
this._tabDirection = false;
this._addCreatorRow = false;
// Filter out bad names
var nameArray = rawNameArray.filter(name => name);
// If not adding names at the end of the creator list, make new creator
// entries and then shift down existing creators.
var initNumCreators = this.item.numCreators();
var creatorsToShift = initNumCreators - creatorIndex;
if (creatorsToShift > 0) {
// Add extra creators with dummy values
for (let i = 0; i < nameArray.length; i++) {
this.modifyCreator(i + initNumCreators, {
firstName: '',
lastName: '',
fieldMode: 0,
creatorTypeID
});
}
// Shift existing creators
for (let i = initNumCreators - 1; i >= creatorIndex; i--) {
let shiftedCreatorData = this.item.getCreator(i);
this.item.setCreator(nameArray.length + i, shiftedCreatorData);
}
}
let currentIndex = creatorIndex;
let newCreator = { creatorTypeID };
// Add the creators in lastNameArray one at a time
for (let tempName of nameArray) {
// Check for tab to determine creator name format
newCreator.fieldMode = (tempName.indexOf('\t') == -1) ? 1 : 0;
if (newCreator.fieldMode == 0) {
newCreator.lastName = tempName.split('\t')[0];
newCreator.firstName = tempName.split('\t')[1];
}
else {
newCreator.lastName = tempName;
newCreator.firstName = '';
}
this.modifyCreator(currentIndex, newCreator);
currentIndex++;
}
this._tabDirection = tabDirectionBuffer;
this._addCreatorRow = (creatorsToShift == 0) ? addCreatorRowBuffer : false;
if (this._tabDirection == 1) {
this._lastTabIndex = tabIndexBuffer + 2 * (nameArray.length - 1);
if (newCreator.fieldMode == 0) {
this._lastTabIndex++;
}
}
if (this.saveOnEdit) {
this.item.saveTx();
}
}
});
}
}
elem.autocomplete = {
completeSelectedIndex: true,
ignoreBlurWhileSearching: false,
search: 'zotero',
searchParam: JSON.stringify(params)
searchParam: JSON.stringify(params),
popup: 'PopupAutoComplete',
};
}
@ -1771,7 +1622,7 @@
* Save a multiple-field selection for the creator autocomplete
* (e.g. "Shakespeare, William")
*/
handleCreatorAutoCompleteSelect(textbox, stayFocused) {
handleCreatorAutoCompleteSelect(textbox) {
let inputField = textbox.querySelector("input");
if (!inputField) {
return;
@ -1791,7 +1642,7 @@
if (!id) {
return;
}
var [creatorID, numFields] = id.split('-');
// If result uses two fields, save both
@ -1803,11 +1654,6 @@
var [_field, creatorIndex, creatorField]
= textbox.getAttribute('fieldname').split('-');
if (stayFocused) {
this._lastTabIndex = parseInt(textbox.getAttribute('ztabindex'));
this._tabDirection = false;
}
var creator = Zotero.Creators.get(creatorID);
var otherField = creatorField == 'lastName' ? 'firstName' : 'lastName';
@ -1844,93 +1690,107 @@
// Otherwise let the autocomplete popup handle matters
}
handleKeyPress(event) {
var target = event.target;
if ((event.key === 'ArrowDown' || event.key === 'ArrowUp' || event.key === ' ')
&& target.classList.contains('zotero-clicky')) {
event.preventDefault();
target.click();
// Handle Shift-Enter on creator input field
handleCreatorRowKeyDown(event) {
let target = event.target.closest("editable-text");
if (!target) return;
setTimeout(() => {
this._creatorTypeMenu.dispatchEvent(
new KeyboardEvent("keydown", { key: 'ArrowDown', keyCode: 40, charCode: 0 })
);
}, 0);
return;
if (event.key == "Enter" && event.shiftKey) {
event.stopPropagation();
console.log("Target ", target);
// Value has changed - focus empty creator row at the bottom
if (target.initialValue != target.value) {
this._addCreatorRow = true;
this.blurOpenField();
return;
}
// Value hasn't changed
Zotero.debug("Value hasn't changed");
let row = target.closest('.meta-row');
// Next row is a creator - focus that
let nextRow = row.nextSibling;
if (nextRow.querySelector(".creator-type-value")) {
nextRow.querySelector("editable-text").focus();
return;
}
// Next row is a "More creators" label - click that
let moreCreators = nextRow.querySelector("#more-creators-label");
if (moreCreators) {
moreCreators.click();
this._selectField = `itembox-field-value-creator-${this._creatorCount}-lastName`;
}
var creatorFields = this.getCreatorFields(row);
// Do nothing from the last empty row
if (creatorFields.lastName == "" && creatorFields.firstName == "") return;
this.addCreatorRow(false, creatorFields.creatorTypeID, true);
this._selectField = `itembox-field-value-creator-${this._creatorCount - 1}-lastName`;
}
}
let tree;
switch (event.key) {
case "Enter":
var valueField = target.closest("editable-text");
if (!valueField) {
return;
// Handle adding multiple creator rows via paste
handleCreatorPaste(event) {
let target = event.target.closest('editable-text');
var fieldName = target.getAttribute('fieldname');
let creatorTypeID = parseInt(
target.closest('.meta-row').querySelector('.meta-label').getAttribute('typeid')
);
var [field, creatorIndex, creatorField] = fieldName.split('-');
let lastName = event.clipboardData.getData('text').trim();
// Handle \n\r and \n delimited entries and a single line containing a tab
var rawNameArray = lastName.split(/\r\n?|\n/);
if (rawNameArray.length > 1 || rawNameArray[0].includes('\t')) {
// Pasting multiple authors; first make sure we prevent normal paste behavior
event.preventDefault();
// Filter out bad names
var nameArray = rawNameArray.filter(name => name);
// If not adding names at the end of the creator list, make new creator
// entries and then shift down existing creators.
var initNumCreators = this.item.numCreators();
var creatorsToShift = initNumCreators - creatorIndex;
if (creatorsToShift > 0) {
// Add extra creators with dummy values
for (let i = 0; i < nameArray.length; i++) {
this.modifyCreator(i + initNumCreators, {
firstName: '',
lastName: '',
fieldMode: 0,
creatorTypeID
});
}
var fieldname = valueField.getAttribute('fieldname');
// Use shift-enter as the save action for the larger fields
if (Zotero.ItemFields.isMultiline(fieldname) && !event.shiftKey) {
return;
// Shift existing creators
for (let i = initNumCreators - 1; i >= creatorIndex; i--) {
let shiftedCreatorData = this.item.getCreator(i);
this.item.setCreator(nameArray.length + i, shiftedCreatorData);
}
// Shift-enter adds new creator row
if (fieldname.indexOf('creator-') == 0 && event.shiftKey) {
// Value hasn't changed
if (valueField.initialValue == valueField.value) {
Zotero.debug("Value hasn't changed");
let row = target.closest('.meta-row');
// If + button is disabled, just focus next creator row
if (row.querySelector(".zotero-clicky-plus").disabled) {
let moreCreators = row.nextSibling.querySelector("#more-creators-label");
if (moreCreators) {
moreCreators.click();
}
else {
row.nextSibling.querySelector("editable-text").focus();
}
}
else {
var creatorFields = this.getCreatorFields(row);
this.addCreatorRow(false, creatorFields.creatorTypeID, true);
}
}
// Value has changed
else {
this._tabDirection = 1;
this._addCreatorRow = true;
this.blurOpenField();
}
}
return;
case "Escape":
// Return focus to items pane
tree = document.getElementById('item-tree-main-default');
if (tree) {
tree.focus();
}
return;
case "Tab":
this.updateLastFocused(event);
if (event.shiftKey) {
// Shift-tab from the item type
if (this._lastTabIndex === 1) {
return;
}
event.preventDefault();
event.stopPropagation();
this._focusNextField(this._lastTabIndex, true);
}
let currentIndex = creatorIndex;
let newCreator = { creatorTypeID };
// Add the creators in lastNameArray one at a time
for (let tempName of nameArray) {
// Check for tab to determine creator name format
newCreator.fieldMode = (tempName.indexOf('\t') == -1) ? 1 : 0;
if (newCreator.fieldMode == 0) {
newCreator.lastName = tempName.split('\t')[0];
newCreator.firstName = tempName.split('\t')[1];
}
else {
let focused = this._focusNextField(++this._lastTabIndex);
if (focused) {
event.preventDefault();
event.stopPropagation();
}
newCreator.lastName = tempName;
newCreator.firstName = '';
}
this.modifyCreator(currentIndex, newCreator);
currentIndex++;
}
// Select the last field added
this._selectField = `itembox-field-value-creator-${currentIndex}-lastName`;
this._addCreatorRow = (creatorsToShift == 0);
if (this.saveOnEdit) {
this.item.saveTx();
}
}
}
@ -1946,8 +1806,6 @@
Zotero.debug(`Hiding editor for ${textbox.getAttribute('fieldname')}`);
this._lastTabIndex = -1;
// Prevent autocomplete breakage in Firefox 3
if (textbox.mController) {
textbox.mController.input = null;
@ -2081,6 +1939,30 @@
}
}
// Make sure that irrelevant creators +/- buttons are disabled
_updateCreatorButtonsStatus() {
let creatorValues = [...this.querySelectorAll(".creator-type-value")];
let row;
for (let creatorValue of creatorValues) {
row = creatorValue.closest(".meta-row");
let { lastName, firstName } = this.getCreatorFields(row);
let isEmpty = lastName == "" && firstName == "";
let isNextRowCreator = row.nextSibling.querySelector(".creator-type-value");
let isDefaultEmptyRow = isEmpty && creatorValues.length == 1;
if (!this.editable) {
row.querySelector(".zotero-clicky-plus").hidden = true;
row.querySelector(".zotero-clicky-minus").hidden = true;
row.querySelector(".zotero-clicky-options").hidden = true;
return;
}
row.querySelector(".zotero-clicky-plus").disabled = isEmpty || isNextRowCreator;
row.querySelector(".zotero-clicky-minus").disabled = isDefaultEmptyRow;
}
}
getCreatorFields(row) {
var typeID = row.querySelector('[typeid]').getAttribute('typeid');
var [label1, label2] = row.querySelectorAll('editable-text');
@ -2188,7 +2070,6 @@
return;
}
this._draggedCreator = true;
this._lastTabIndex = null;
// Due to some kind of drag-drop API issue,
// after creator is dropped, the hover effect often stays at
// the row's old location. To workaround that, set noHover class to block all
@ -2283,9 +2164,7 @@
}
focusField(fieldName) {
let field = this.querySelector(`editable-text[fieldname="${fieldName}"]`);
if (!field) return false;
return this._focusNextField(field.getAttribute('ztabindex'));
this.querySelector(`editable-text[fieldname="${fieldName}"]`)?.focus();
}
getTitleField() {
@ -2301,84 +2180,6 @@
return null;
}
/**
* Advance the field focus forward or backward
*
* Note: We're basically replicating the built-in tabindex functionality,
* which doesn't work well with the weird label/textbox stuff we're doing.
* (The textbox being tabbed away from is deleted before the blur()
* completes, so it doesn't know where it's supposed to go next.)
*/
_focusNextField(tabindex, back) {
var box = this._infoTable;
tabindex = parseInt(tabindex);
// Get all fields with ztabindex attributes
var tabbableFields = box.querySelectorAll('*[ztabindex]:not([disabled=true])');
if (!tabbableFields.length) {
Zotero.debug("No tabbable fields found");
return false;
}
var next;
if (back) {
Zotero.debug('Looking for previous tabindex before ' + tabindex, 4);
for (let i = tabbableFields.length - 1; i >= 0; i--) {
let field = tabbableFields[i];
let tabIndexHere = parseInt(field.getAttribute('ztabindex'));
if (tabIndexHere !== -1 && tabIndexHere < tabindex) {
next = tabbableFields[i];
break;
}
}
}
else {
Zotero.debug('Looking for tabindex ' + tabindex, 4);
for (var pos = 0; pos < tabbableFields.length; pos++) {
let field = tabbableFields[pos];
let tabIndexHere = parseInt(field.getAttribute('ztabindex'));
if (tabIndexHere !== -1 && tabIndexHere >= tabindex) {
next = tabbableFields[pos];
break;
}
}
}
if (!next) {
Zotero.debug("Next field not found");
return false;
}
// Ensure the node is visible
next.style.visibility = "visible";
next.style.display = "block";
next.focus();
next.style.removeProperty("visibility");
next.style.removeProperty("display");
// 1) next.parentNode is always null for some reason
// 2) For some reason it's necessary to scroll to the next element when
// moving forward for the target element to be fully in view
let visElem;
if (!back && tabbableFields[pos + 1]) {
Zotero.debug("Scrolling to next field");
visElem = tabbableFields[pos + 1];
}
else {
visElem = next;
}
this.ensureElementIsVisible(visElem);
return true;
}
updateLastFocused(ev) {
let ztabindex = parseInt(ev.target.getAttribute('ztabindex'));
if (ztabindex) {
this._lastTabIndex = ztabindex;
}
}
async blurOpenField() {
var activeField = this.getFocusedTextArea();
if (!activeField) {

View file

@ -199,7 +199,7 @@
this._header = this.querySelector('#zotero-item-pane-header');
this._paneParent = this.querySelector('#zotero-view-item');
this._container.addEventListener("keypress", this._handleKeypress);
this._container.addEventListener("keydown", this._handleKeydown);
this._paneParent.addEventListener('scroll', this._handleContainerScroll);
this._paneHiddenOb = new MutationObserver(this._handlePaneStatus);
@ -225,7 +225,7 @@
}
destroy() {
this._container.removeEventListener("keypress", this._handleKeypress);
this._container.removeEventListener("keydown", this._handleKeydown);
this._paneParent.removeEventListener('scroll', this._handleContainerScroll);
this._paneHiddenOb.disconnect();
@ -535,7 +535,7 @@
};
// Keyboard navigation within the itemPane. Also handles contextPane keyboard nav
_handleKeypress = (event) => {
_handleKeydown = (event) => {
let stopEvent = () => {
event.preventDefault();
event.stopPropagation();
@ -552,21 +552,16 @@
stopEvent();
return;
}
// Tab tavigation between entries and buttons within library, related and notes boxes
if (event.key == "Tab" && event.target.closest(".box")) {
let next = null;
if (event.key == "Tab" && !event.shiftKey) {
next = event.target.nextElementSibling;
// On Escape/Enter on editable-text, return focus to the item tree or reader
if (event.key == "Escape" || (event.key == "Enter" && event.target.classList.contains('input'))) {
if (isLibraryTab) {
document.getElementById('item-tree-main-default').focus();
}
if (event.key == "Tab" && event.shiftKey) {
next = event.target.parentNode.previousElementSibling?.lastChild;
}
// Force the element to be visible before focusing
if (next) {
next.style.visibility = "visible";
next.focus();
next.style.removeProperty("visibility");
stopEvent();
else {
let reader = Zotero.Reader.getByTabID(Zotero_Tabs.selectedID);
if (reader) {
reader.focus();
}
}
}
};

View file

@ -327,6 +327,11 @@
if (!row.parentElement) {
return;
}
// Do not propagate event to itemDetails that would send focus to itemTree or reader
// because a new empty row will be created and focused in saveTag
if (row.getAttribute("isNew")) {
event.stopPropagation();
}
let blurOnly = false;
let focusField = false;
@ -348,13 +353,6 @@
if (focusField) {
focusField.focus();
}
// Return focus to items pane
else {
var tree = document.getElementById('zotero-items-tree');
if (tree) {
tree.focus();
}
}
}
};

View file

@ -768,31 +768,7 @@ var Zotero_Tabs = new function () {
Services.focus.MOVEFOCUS_BACKWARD, 0);
return;
}
// If no item is selected, focus items list.
if (ZoteroPane.itemPane.mode == "message") {
document.getElementById("item-tree-main-default").focus();
return;
}
let selected = ZoteroPane.getSelectedItems();
// If the selected collection row is duplicates, just focus on the
// itemTree until the merge pane is keyboard accessible
// If multiple items selected, focus on itemTree as well.
let collectionRow = ZoteroPane.collectionsView.selectedTreeRow;
if (collectionRow.isDuplicates() || selected.length !== 1) {
document.getElementById("item-tree-main-default").focus();
return;
}
// Special treatment for notes and attachments in itemPane
selected = selected[0];
if (selected.isNote()) {
document.getElementById("zotero-note-editor").focus();
return;
}
if (selected.isAttachment()) {
document.getElementById("attachment-note-editor").focus();
return;
}
// For regular items, focus the last field
// Focus the last field of itemPane
// We do that by moving focus backwards from the element following the pane, because Services.focus doesn't
// support MOVEFOCUS_LAST on subtrees
Services.focus.moveFocus(window, document.getElementById("zotero-context-splitter"),

View file

@ -2147,7 +2147,7 @@ var ZoteroPane = new function()
await original.saveTx({ skipDateModifiedUpdate: true });
await duplicate.saveTx();
document.getElementById('zotero-editpane-item-box').focusField('title');
ZoteroPane.itemPane.querySelector("item-box").getTitleField().focus();
return duplicate;
};

View file

@ -123,13 +123,6 @@
row-gap: 2px;
width: inherit;
.show-on-hover {
visibility: hidden;
&.no-display {
display: none;
}
}
.meta-row {
display: grid;
grid-template-columns: subgrid;
@ -139,14 +132,15 @@
display: none;
}
// On hover of the meta-row, reveal all hidden icons
// unless there's .noHover class which keeps everything hidden
&:not(.noHover):hover .show-on-hover,
&:focus-within .show-on-hover {
visibility: visible;
display: revert;
&:not(:hover):not(:focus-within) .show-on-hover,
&.noHover .show-on-hover {
clip-path: inset(50%);
&.no-display {
width: 0;
height: 0;
padding: 0;
}
}
.meta-data {
width: 0;
min-width: 100%;