Make sidenav buttons keyboard accessible (#4235)

- Section buttons, Locate, and Notes in the sidenav are focusable
- itemPane section buttons are combined into one focusable
group. Those buttons by themselves don't mean anything in
the context of keyboard navigation as they just scroll
to the section in the itemPane. In fact, having info,
abstract, attachments, etc. focusable and announceable by
screen readers is just confusing. However, we do want the group
of those buttons to be focusable to switch back to
zotero-context-pane-item-deck from zotero-context-pane-notes-deck
if the notes button in the sidenav is pressed.
- sidenav can be reached by tabbing into it from the end of itemPane
or via shift-tab from the focused tab
- sidenav buttons can be navigated with up/down arrows
- notes list in the context pane can be activated via the button in the
sidenav and navigated via up/down arrows
- use command vs onclick listener for notes list context menus to work
with keyboard-triggered clicks as well
- focus itemPane when tabs are switched in sidenav
This commit is contained in:
abaevbog 2024-11-12 21:20:21 -08:00 committed by GitHub
parent d75d638337
commit e94789c8db
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 155 additions and 29 deletions

View file

@ -117,6 +117,7 @@
this.addEventListener('click', this._handleClick);
this.addEventListener('contextmenu', this._handleContextMenu);
this.addEventListener('keydown', this._handleKeyDown);
this.render();
}
@ -182,6 +183,17 @@
}));
}
};
// ArrowUp/Down navigation between notes
_handleKeyDown = (event) => {
if (event.target.tagName !== "note-row" && !event.target.classList.contains("more")) return;
if (event.key == "ArrowDown") {
event.target.nextElementSibling?.focus();
}
else if (event.key == "ArrowUp") {
event.target.previousElementSibling?.focus();
}
};
_handleAddNote = (event) => {
let eventName = event.target.closest('collapsible-section') == this._itemNotesSection

View file

@ -49,7 +49,7 @@
<html:div class="zotero-view-item-main">
<item-pane-header id="zotero-item-pane-header" />
<html:div id="zotero-view-item" class="zotero-view-item" tabindex="0">
<html:div id="zotero-view-item" class="zotero-view-item" tabindex="0" data-l10n-id="item-details-pane">
<info-box id="zotero-editpane-info-box" data-pane="info"/>
<abstract-box id="zotero-editpane-abstract" class="zotero-editpane-abstract" data-pane="abstract"/>
@ -555,13 +555,10 @@
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);
if (event.target.classList.contains("zotero-view-item") && event.key == "Tab" && !event.shiftKey && this.pinnedPane) {
let pane = this.getPane(this.pinnedPane);
pane.firstChild._head.focus();
stopEvent();
return;

View file

@ -28,9 +28,11 @@
{
class ItemPaneSidenav extends XULElementBase {
content = MozXULElement.parseXULToFragment(`
<html:div class="inherit-flex highlight-notes-inactive">
<html:div class="inherit-flex highlight-notes-inactive"
tabindex="0" role="tab" data-l10n-id="sidenav-main-btn-grouping">
<html:div class="pin-wrapper">
<toolbarbutton
id="sidenav-info-btn"
disabled="true"
data-l10n-id="sidenav-info"
data-pane="info"/>
@ -96,16 +98,19 @@
<html:div class="pin-wrapper highlight-notes-active">
<toolbarbutton
data-l10n-id="sidenav-notes"
data-pane="context-notes"/>
data-pane="context-notes"
tabindex="0"
role="tab"/>
</html:div>
<html:div class="divider"/>
<html:div class="pin-wrapper">
<toolbarbutton
tooltiptext="&zotero.toolbar.openURL.label;"
type="menu"
data-action="locate">
data-action="locate"
tabindex="0">
<menupopup/>
</toolbarbutton>
</html:div>
@ -210,7 +215,9 @@
}
this.addEventListener('click', this.handleButtonClick);
this.addEventListener('keydown', this.handleKeyDown);
this.addEventListener('focusin', this.handleFocusIn);
this.addEventListener('mousedown', this.handleMouseDown);
// Set up action toolbarbuttons
for (let toolbarbutton of this.querySelectorAll('toolbarbutton[data-action]')) {
let action = toolbarbutton.dataset.action;
@ -236,10 +243,14 @@
this.querySelector('.zotero-menuitem-unpin').addEventListener('command', () => {
this.pinnedPane = null;
});
this.setAttribute("role", "tablist");
}
destroy() {
this.removeEventListener('click', this.handleButtonClick);
this.removeEventListener('keydown', this.handleKeyDown);
this.removeEventListener('focusin', this.handleFocusIn);
this.removeEventListener('mousedown', this.handleMouseDown);
}
render() {
@ -265,7 +276,7 @@
continue;
}
toolbarbutton.setAttribute('aria-selected', !contextNotesPaneVisible && pane == pinnedPane);
toolbarbutton.closest("[role='tab']").setAttribute('aria-selected', !contextNotesPaneVisible);
// No need to set `hidden` here, since it's updated by ItemDetails#_handlePaneStatus
// Set .pinned on the container, for pin styling
toolbarbutton.parentElement.classList.toggle('pinned', pane == pinnedPane);
@ -374,6 +385,88 @@
}
}
handleKeyDown = (event) => {
if (event.key == "Tab" && !event.shiftKey) {
// Wrap focus around to the tab bar
Services.focus.moveFocus(window, document.getElementById("zotero-title-bar"), Services.focus.MOVEFOCUS_FORWARD, 0);
event.preventDefault();
}
if (event.key == "Tab" && event.shiftKey) {
// Return focus to item pane
Services.focus.moveFocus(window, this, Services.focus.MOVEFOCUS_BACKWARD, 0);
event.preventDefault();
}
if (["ArrowUp", "ArrowDown"].includes(event.key)) {
// Up/Down arrow navigation
let direction = event.key == "ArrowUp" ? Services.focus.MOVEFOCUS_BACKWARD : Services.focus.MOVEFOCUS_FORWARD;
let focused = Services.focus.moveFocus(window, event.target, direction, Services.focus.FLAG_BYKEY);
// If focus was moved outside of the sidenav (e.g. on arrowUp from the first button), bring it back
if (!this.contains(focused)) {
Services.focus.setFocus(event.target, Services.focus.FLAG_BYKEY);
}
event.preventDefault();
}
if (["ArrowRight", "ArrowLeft"].includes(event.key)) {
// Do nothing on arrow right/left
event.preventDefault();
}
if ([" ", "Enter"].includes(event.key)) {
// Only handles buttons that change which itemPane deck is visible
if (!(event.target == this._buttonContainer || event.target.closest(".highlight-notes-active"))) return;
// Click the first itemPane button in a group to switch from notes to item details pane
if (event.target === this._buttonContainer && this._contextNotesPaneVisible) {
let firstBtn = event.target.querySelector("toolbarbutton");
let clickEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
detail: 1
});
firstBtn.dispatchEvent(clickEvent);
}
setTimeout(() => {
// If notes are visible, tab into them
if (this._contextNotesPaneVisible) {
Services.focus.moveFocus(window, this.contextNotesPane, Services.focus.MOVEFOCUS_FORWARD, 0);
}
// Tab into the pinned section if it exists
else if (this.pinnedPane) {
Services.focus.moveFocus(window, this.container.getEnabledPane(this.pinnedPane),
Services.focus.MOVEFOCUS_FORWARD, 0);
}
// Otherwise, focus the top-level scrollable itemPane
else {
this._container.querySelector(".zotero-view-item").focus();
}
});
}
};
/**
* Help screen readers understand the index of focused tab in the sidenav.
* Sidenav has role="tablist", since it can switch between itemDetails and notesContext panes.
* However, it also has Locate (and potentially plugin) buttons. It confuses some screen readers
* and leads them to announce the index of tabs incorrectly. As a workaround, aria-hide all non-tabs
* when a tab is focused and hide tabs when a non-tab is focused.
*/
handleFocusIn = (event) => {
let focusedTab = event.target.getAttribute("role") == "tab";
for (let node of [...this.querySelectorAll("[tabindex]")]) {
let isTab = node.getAttribute("role") == "tab";
if (focusedTab) {
node.setAttribute("aria-hidden", !isTab);
}
else {
node.setAttribute("aria-hidden", isTab);
}
}
};
// Prevents focus from leaving the currently focused element and landing on the sidenav buttons
handleMouseDown = (event) => {
event.preventDefault();
};
handleButtonClick = (event) => {
let toolbarbutton = event.target;
let pane = toolbarbutton.dataset.pane;

View file

@ -60,6 +60,7 @@ import { getCSSItemTypeIcon } from 'components/icons';
this._noteContent = this.querySelector('.note-content');
this._noteDate = this.querySelector('.note-date');
this.tabIndex = 0;
this.classList.add("keyboard-clickable");
this.render();
}
@ -74,13 +75,16 @@ import { getCSSItemTypeIcon } from 'components/icons';
this._parentTitle.textContent = note.parentTitle;
this._noteTitle.hidden = false;
this._noteTitle.textContent = note.title;
this.setAttribute("aria-description", note.title);
}
else {
this.querySelector('.icon').replaceWith(getCSSItemTypeIcon('note'));
this._parentTitle.textContent = note.title;
this._noteTitle.hidden = true;
this._noteTitle.textContent = '';
this.setAttribute("aria-description", note.body);
}
this.setAttribute("aria-label", this._parentTitle.textContent);
this._noteContent.textContent = note.body;
this._noteContent.hidden = !note.body;
this._noteDate.textContent = note.date;

View file

@ -46,7 +46,7 @@
<vbox class="zotero-context-note-container context-note-standalone">
<vbox class="zotero-context-pane-editor-parent-line">
<html:div class="parent-title-container">
<toolbarbutton class="zotero-tb-note-return"></toolbarbutton>
<toolbarbutton class="zotero-tb-note-return" tabindex="0" data-l10n-id="context-notes-return-button"></toolbarbutton>
<html:div class="parent-title"></html:div>
</html:div>
</vbox>
@ -55,7 +55,7 @@
<vbox class="zotero-context-note-container context-note-child">
<vbox class="zotero-context-pane-editor-parent-line">
<html:div class="parent-title-container">
<toolbarbutton class="zotero-tb-note-return"></toolbarbutton>
<toolbarbutton class="zotero-tb-note-return" tabindex="0" data-l10n-id="context-notes-return-button"></toolbarbutton>
<html:div class="parent-title"></html:div>
</html:div>
</vbox>
@ -185,19 +185,23 @@
popup.openPopupAtScreen(screenX, screenY, true);
}
});
let addChildNotePopup = document.getElementById('context-pane-add-child-note-button-popup');
addChildNotePopup.addEventListener("command", (event) => {
this._handleAddChildNotePopupClick(event);
});
this.notesList.addEventListener('add-child', (event) => {
document.getElementById('context-pane-add-child-note').setAttribute('disabled', !this.editable);
document.getElementById('context-pane-add-child-note-from-annotations').setAttribute('disabled', !this.editable);
let popup = document.getElementById('context-pane-add-child-note-button-popup');
popup.onclick = this._handleAddChildNotePopupClick;
popup.openPopup(event.detail.button, 'after_end');
addChildNotePopup.openPopup(event.detail.button, 'after_end');
});
let addStandaloneNotePopup = document.getElementById('context-pane-add-standalone-note-button-popup');
addStandaloneNotePopup.addEventListener("command", (event) => {
this._handleAddStandaloneNotePopupClick(event);
});
this.notesList.addEventListener('add-standalone', (event) => {
document.getElementById('context-pane-add-standalone-note').setAttribute('disabled', !this.editable);
document.getElementById('context-pane-add-standalone-note-from-annotations').setAttribute('disabled', !this.editable);
let popup = document.getElementById('context-pane-add-standalone-note-button-popup');
popup.onclick = this._handleAddStandaloneNotePopupClick;
popup.openPopup(event.detail.button, 'after_end');
addStandaloneNotePopup.openPopup(event.detail.button, 'after_end');
});
}

View file

@ -798,19 +798,25 @@ var Zotero_Tabs = new function () {
popup.openPopupAtScreen(x, y, true);
};
// Used to move focus back to itemTree or contextPane from the tabs.
// Used to move focus back or sidenav from the tabs.
this.focusWrapAround = function () {
// Focus the last field of contextPane when reader is opened
// Focus the first focusable button of context pane sidenav when reader is opened
if (Zotero_Tabs.selectedIndex > 0) {
Services.focus.moveFocus(window, document.getElementById("zotero-context-pane-sidenav"),
Services.focus.MOVEFOCUS_BACKWARD, 0);
Services.focus.MOVEFOCUS_FORWARD, 0);
return;
}
// 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"),
Services.focus.MOVEFOCUS_BACKWARD, 0);
let itemSideNav = document.getElementById("zotero-view-item-sidenav");
if (itemSideNav.hidden) {
// If sidenav is hidden, focus the last focusable element of item pane
Services.focus.moveFocus(window, document.getElementById("zotero-context-splitter"),
Services.focus.MOVEFOCUS_BACKWARD, 0);
}
else {
// Focus the first focusable button of item pane sidenav
Services.focus.moveFocus(window, document.getElementById("zotero-view-item-sidenav"),
Services.focus.MOVEFOCUS_FORWARD, 0);
}
};
/**

View file

@ -25,6 +25,7 @@ general-help = Help
general-tag = Tag
general-done = Done
general-view-troubleshooting-instructions = View Troubleshooting Instructions
general-go-back = Go Back
citation-style-label = Citation Style:
language-label = Language:
@ -458,6 +459,7 @@ menu-ui-density-comfortable =
menu-ui-density-compact =
.label = Compact
pane-item-details = Item Details
pane-info = Info
pane-abstract = Abstract
pane-attachments = Attachments
@ -472,6 +474,8 @@ pane-attachment-annotations = Annotations
pane-header-attachment-associated =
.label = Rename associated file
item-details-pane =
.aria-label = { pane-item-details }
section-info =
.label = { pane-info }
section-abstract =
@ -546,6 +550,8 @@ sidenav-tags =
.tooltiptext = { pane-tags }
sidenav-related =
.tooltiptext = { pane-related }
sidenav-main-btn-grouping =
.aria-label = { pane-item-details }
pin-section =
.label = Pin Section
@ -568,6 +574,8 @@ tagselector-search =
context-notes-search =
.placeholder = Search Notes
context-notes-return-button =
.aria-label = { general-go-back }
new-collection-dialog =
.title = New Collection

View file

@ -5,6 +5,7 @@ note-row {
border-radius: 5px;
border: 1px solid var(--color-quinary-on-sidepane);
background: var(--material-background);
@include focus-ring;
&:active {
background-color: var(--accent-blue10);

View file

@ -1619,7 +1619,8 @@ describe("ZoteroPane", function() {
assert.equal(doc.activeElement.className, "tab selected");
doc.activeElement.dispatchEvent(shiftTab);
assert.equal(doc.activeElement.id, "item-tree-main-default");
// One of tab buttons in the sidenav
assert.equal(doc.activeElement.getAttribute("role"), "tab");
});
it("should tab across the zotero pane", async function () {