itempane keyboard navigation

- right/left arrows on section header expand/collapse the sections
- space/enter on section header toggle section collapsed/expanded
- tab from the header goes through the header's buttons and then
tabs into the section if that's opened
- arrow up/down on the header jumps to the previous/next header
- space/enter on clickable elements simulate a click
- if there is a pinned section, tab from the title will focus its header
This commit is contained in:
abaevbog 2024-01-24 03:01:03 -05:00 committed by Dan Stillman
parent 580d1df086
commit ed8e3f142b
14 changed files with 228 additions and 23 deletions

View file

@ -80,10 +80,12 @@ var ZoteroContextPane = new function () {
window.addEventListener('resize', _update);
Zotero.Reader.onChangeSidebarWidth = _updatePaneWidth;
Zotero.Reader.onToggleSidebar = _updatePaneWidth;
_contextPaneInner.addEventListener("keypress", ZoteroItemPane.handleKeypress);
};
this.destroy = function () {
window.removeEventListener('resize', _update);
_contextPaneInner.removeEventListener("keypress", ZoteroItemPane.handleKeypress);
Zotero.Notifier.unregisterObserver(this._notifierID);
Zotero.Reader.onChangeSidebarWidth = () => {};
Zotero.Reader.onToggleSidebar = () => {};

View file

@ -32,8 +32,8 @@
content = MozXULElement.parseXULToFragment(`
<collapsible-section data-l10n-id="section-attachment-info" data-pane="attachment-info">
<html:div class="body">
<attachment-preview id="attachment-preview"/>
<label id="url" crop="end"
<attachment-preview id="attachment-preview" tabindex="0"/>
<label id="url" crop="end" tabindex="0"
ondragstart="let dt = event.dataTransfer; dt.setData('text/x-moz-url', this.value); dt.setData('text/uri-list', this.value); dt.setData('text/plain', this.value);"/>
<html:div class="metadata-table">
<html:div id="fileNameRow" class="meta-row">
@ -56,7 +56,7 @@
<html:div class="meta-label"><html:label id="index-status-label" class="key" data-l10n-id="attachment-info-index"/></html:div>
<html:div class="meta-data">
<html:label id="index-status"/>
<toolbarbutton id="reindex" oncommand="this.hidden = true; setTimeout(function () { ZoteroPane_Local.reindexItem(); }, 50)"/>
<toolbarbutton id="reindex" tabindex="0" oncommand="this.hidden = true; setTimeout(function () { ZoteroPane_Local.reindexItem(); }, 50)"/>
</html:div>
</html:div>
</html:div>
@ -228,6 +228,34 @@
this._preview.render();
}
});
// Work around the reindex toolbarbutton not wanting to properly receive focus on tab.
// Make <image> focusable. On focus of the image, bounce the focus to the toolbarbutton.
// Temporarily remove tabindex from the <image> so that the focus can move past the
// reindex button
let reindexButton = this._id("indexStatusRow").querySelector(".meta-data toolbarbutton");
if (reindexButton) {
reindexButton.addEventListener("focusin", function (e) {
if (e.target.tagName == "image") {
reindexButton.focus();
reindexButton.querySelector("image").removeAttribute("tabindex");
}
});
reindexButton.addEventListener("blur", function (_) {
setTimeout(() => {
if (document.activeElement !== reindexButton) {
reindexButton.querySelector("image").setAttribute("tabindex", "0");
}
});
});
}
// Prevents the button from getting stuck in active state
reindexButton.addEventListener("keydown", (e) => {
if (e.key == " ") {
e.preventDefault();
reindexButton.click();
}
});
}
destroy() {
@ -281,7 +309,7 @@
ZoteroPane_Local.loadURI(this.value, event);
}
};
urlField.className = 'zotero-text-link';
urlField.className = 'zotero-text-link keyboard-clickable';
}
else {
urlField.className = '';
@ -381,6 +409,13 @@
indexStatusRow.hidden = true;
}
// Make the image of the reindex toolbarbutton focusable because for some reason the
// actual toolbarbutton does not receive focus on tab
let reindexButton = indexStatusRow.querySelector("toolbarbutton");
if (document.activeElement !== reindexButton) {
reindexButton.querySelector("image").setAttribute("tabindex", "0");
}
this.initAttachmentNoteEditor();
if (this.displayButton) {

View file

@ -181,6 +181,8 @@
this.addEventListener("mouseenter", this.updateGoto);
this.addEventListener("dragstart", this._handleDragStart);
this.addEventListener("dragend", this._handleDragEnd);
this.addEventListener("focusin", this._handleFocusIn);
this.addEventListener("keypress", this._handleKeypress);
this.setAttribute("data-preview-type", "unknown");
}
@ -192,6 +194,8 @@
this.removeEventListener("mouseenter", this.updateGoto);
this.removeEventListener("dragstart", this._handleDragStart);
this.removeEventListener("dragend", this._handleDragEnd);
this.removeEventListener("focusin", this._handleFocusIn);
this.removeEventListener("keypress", this._handleKeypress);
}
async render() {
@ -279,6 +283,36 @@
this._id("next").disabled = !this._reader?.canGoto("next");
}
_handleFocusIn() {
this.focus();
}
_handleKeypress(e) {
let stopEvent = false;
// Space or enter open attachment
if ([" ", "Enter"].includes(e.key)) {
this.openAttachment(e);
stopEvent = true;
}
// Hacky way to preventing the focus from going into the actual reader where it can
// get stuck. On tab from the preview, try to find the next element and focus it.
else if (e.key == "Tab" && !e.shiftKey) {
let toFocus = this.nextElementSibling.querySelector('[tabindex="0"]');
if (!toFocus && this.nextElementSibling.getAttribute("tabindex") == "0") {
toFocus = this.nextElementSibling;
}
if (toFocus) {
toFocus.focus();
stopEvent = true;
}
}
if (stopEvent) {
e.stopPropagation();
e.preventDefault();
}
}
async _renderReader() {
this.setPreviewStatus("loading");
// This only need to be awaited during first load

View file

@ -31,7 +31,7 @@ import { getCSSItemTypeIcon } from 'components/icons';
class AttachmentRow extends XULElementBase {
content = MozXULElement.parseXULToFragment(`
<html:div class="head">
<html:div class="clicky-item attachment-btn">
<html:div class="clicky-item attachment-btn keyboard-clickable" tabindex="0">
<html:span class="icon"/>
<html:div class="label"/>
</html:div>

View file

@ -30,7 +30,7 @@
content = MozXULElement.parseXULToFragment(`
<collapsible-section data-l10n-id="section-attachments" data-pane="attachments" extra-buttons="add">
<html:div class="body">
<attachment-preview/>
<attachment-preview tabindex="0"/>
<html:div class="attachments-container"></html:div>
</html:div>
</collapsible-section>

View file

@ -66,8 +66,10 @@
cancelable: false
});
if (!newOpen && this.ownerDocument?.activeElement && this.contains(this.ownerDocument?.activeElement)) {
this.ownerDocument.activeElement.blur();
// Blur the focus if it's within the body (not the header) of the section on collapse
let focused = this.ownerDocument?.activeElement;
if (!newOpen && focused && this.lastChild.contains(focused)) {
focused.blur();
}
this._saveOpenState();
@ -114,11 +116,12 @@
throw new Error('data-pane is required');
}
this.tabIndex = 0;
this._head = document.createElement('div');
this._head.role = 'button';
this._head.className = 'head';
this._head.setAttribute("tabindex", "0");
this._head.addEventListener('mousedown', this._mouseDown);
this._head.addEventListener('click', this._handleClick);
this._head.addEventListener('keydown', this._handleKeyDown);
this._head.addEventListener('contextmenu', this._handleContextMenu);
@ -136,6 +139,7 @@
let twisty = document.createXULElement('toolbarbutton');
twisty.className = 'twisty';
twisty.setAttribute("tabindex", "0");
this._head.append(twisty);
this._buildExtraButtons();
@ -237,6 +241,7 @@
if (!buttonType) continue;
let button = document.createXULElement('toolbarbutton');
button.classList.add(buttonType, 'section-custom-button');
button.setAttribute("tabindex", "0");
button.addEventListener('command', (event) => {
this.dispatchEvent(new CustomEvent(buttonType, {
...event,
@ -251,6 +256,7 @@
destroy() {
this._head.removeEventListener('click', this._handleClick);
this._head.removeEventListener('mousedown', this._mouseDown);
this._head.removeEventListener('keydown', this._handleKeyDown);
this._head.removeEventListener('contextmenu', this._handleContextMenu);
@ -284,12 +290,78 @@
if (event.target.closest('.section-custom-button, menupopup')) return;
this.open = !this.open;
};
// Prevent moving focus to the header on click
_mouseDown = (event) => {
event.preventDefault();
};
_handleKeyDown = (event) => {
if (event.target.closest('.section-custom-button')) return;
if (event.key === 'Enter' || event.key === ' ') {
this.open = !this.open;
let tgt = event.target;
let stopEvent = () => {
event.preventDefault();
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)) {
stopEvent();
return;
}
// Let itemPane.js listener handle space or Enter clicks
if ([" ", "Enter"].includes(event.key)) {
return;
}
}
// Space/Enter toggle section open/closed.
// ArrowLeft/ArrowRight on actual header will close/open
if (["ArrowLeft", "ArrowRight", " ", "Enter"].includes(event.key)) {
stopEvent();
this.open = ([" ", "Enter"].includes(event.key)) ? !this.open : (event.key == "ArrowRight");
event.target.focus();
}
if (["ArrowUp", "ArrowDown"].includes(event.key)) {
let up = event.key == "ArrowUp";
// Arrow up from a button focuses the header
if (up && this._head !== tgt) {
this._head.focus();
stopEvent();
return;
}
// ArrowUp focuses the header of the previous section, ArrowDown - of the next one
let box = this.parentNode;
let nextBox;
nextBox = up ? box.previousElementSibling : box.nextElementSibling;
while (nextBox && nextBox.hidden) {
nextBox = up ? nextBox.previousElementSibling : nextBox.nextElementSibling;
}
let nextSection = nextBox?.querySelector("collapsible-section");
if (nextSection) {
nextSection._head.focus();
stopEvent();
}
}
};

View file

@ -1914,16 +1914,15 @@
return;
}
event.preventDefault();
event.stopPropagation();
this._focusNextField(this._lastTabIndex, true);
}
else {
event.preventDefault();
// If on the last field, return focus to item tree
if (this._lastTabIndex == this._tabIndexMaxFields) {
document.getElementById('item-tree-main-default')?.focus();
return;
let focused = this._focusNextField(++this._lastTabIndex);
if (focused) {
event.preventDefault();
event.stopPropagation();
}
this._focusNextField(++this._lastTabIndex);
}
}
}

View file

@ -116,7 +116,8 @@ import { getCSSIcon } from 'components/icons';
row.classList.toggle('context', isContext);
let box = document.createElement('div');
box.classList.add('box');
box.className = 'box keyboard_clickable';
box.setAttribute("tabindex", "0");
let iconName;
if (obj instanceof Zotero.Group) {
@ -141,6 +142,7 @@ import { getCSSIcon } from 'components/icons';
if (this._mode == 'edit' && obj instanceof Zotero.Collection && !isContext) {
let remove = document.createXULElement('toolbarbutton');
remove.className = 'zotero-clicky zotero-clicky-minus';
remove.setAttribute("tabindex", "0");
remove.addEventListener('command', () => {
if (Services.prompt.confirm(
window,

View file

@ -122,7 +122,8 @@ import { getCSSItemTypeIcon } from 'components/icons';
let box = document.createElement('div');
box.addEventListener('click', () => this._handleShowItem(id));
box.className = 'box';
box.className = 'box keyboard-clickable';
box.setAttribute("tabindex", 0);
box.append(icon, label);
row.append(box);
@ -131,6 +132,7 @@ import { getCSSItemTypeIcon } from 'components/icons';
let remove = document.createXULElement("toolbarbutton");
remove.addEventListener('command', () => this._handleRemove(id));
remove.className = 'zotero-clicky zotero-clicky-minus';
remove.setAttribute("tabindex", "0");
row.append(remove);
}

View file

@ -132,7 +132,8 @@ import { getCSSItemTypeIcon } from 'components/icons';
let box = document.createElement('div');
box.addEventListener('click', () => this._handleShowItem(id));
box.className = 'box';
box.setAttribute("tabindex", "0");
box.className = 'box keyboard-clickable';
box.appendChild(icon);
box.appendChild(label);
row.append(box);
@ -141,6 +142,7 @@ import { getCSSItemTypeIcon } from 'components/icons';
let remove = document.createXULElement("toolbarbutton");
remove.addEventListener('command', () => this._handleRemove(id));
remove.className = 'zotero-clicky zotero-clicky-minus';
remove.setAttribute("tabindex", "0");
row.append(remove);
}

View file

@ -54,6 +54,8 @@ var ZoteroItemPane = new function() {
_deck = document.getElementById('zotero-item-pane-content');
this._unregisterID = Zotero.Notifier.registerObserver(this, ['item'], 'itemPane');
_container.addEventListener("keypress", this.handleKeypress);
};
@ -166,6 +168,49 @@ var ZoteroItemPane = new function() {
document.getElementById('zotero-item-pane-content').selectedIndex = 2;
};
// Keyboard navigation within the itemPane. Also handles contextPane keyboard nav
this.handleKeypress = function (event) {
let stopEvent = () => {
event.preventDefault();
event.stopPropagation();
};
let isLibraryTab = Zotero_Tabs.selectedIndex == 0;
let sidenav = document.getElementById(
isLibraryTab ? 'zotero-view-item-sidenav' : 'zotero-context-pane-sidenav'
);
// Tab from the scrollable area focuses the pinned pane if it exists
if (event.target.classList.contains("zotero-view-item") && event.key == "Tab" && !event.shiftKey && sidenav.pinnedPane) {
let pane = sidenav.getPane(sidenav.pinnedPane);
pane.firstChild._head.focus();
stopEvent();
return;
}
// Space or Enter on a button or 'keyboard-clickable' triggers a click
if ([" ", "Enter"].includes(event.key)
&& (event.target.tagName == "toolbarbutton"
|| event.target.classList.contains("keyboard-clickable"))) {
event.target.click();
stopEvent();
}
// 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;
}
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();
}
}
};
/**

View file

@ -36,8 +36,10 @@ attachment-box {
width: 22px;
padding: 1px;
margin-left: 4px;
color: var(--fill-secondary);
@include svgicon-menu("sync", "universal", "20");
&:not([disabled='true']) {
color: var(--fill-secondary);
}
}
}

View file

@ -14,6 +14,7 @@ attachment-preview {
--preview-width: 400;
--preview-height: calc(min(var(--preview-width) / var(--width-height-ratio), var(--max-height)));
max-height: var(--max-height);
@include focus-ring;
&[hidden] {
display: none;

View file

@ -3,12 +3,16 @@ collapsible-section {
flex-direction: column;
gap: 2px;
padding-block: 4px;
--width-focus-border: 2px;
--radius-focus-border: 5px;
:not(:last-child) > & {
border-bottom: 1px solid var(--fill-quinary);
}
& > .head {
@include focus-ring;
@include comfortable {
padding-block: 2px;
}
@ -102,4 +106,9 @@ collapsible-section {
&.disable-transitions * {
transition: none !important;
}
toolbarbutton, .box, .keyboard-clickable {
@include focus-ring;
}
}