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:
parent
d75d638337
commit
e94789c8db
9 changed files with 155 additions and 29 deletions
|
@ -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
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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 () {
|
||||
|
|
Loading…
Add table
Reference in a new issue