Improve tree screen reader accessibility (#2263)

Also:

* Allow opening tree context menu with Shift + F10 and Context Menu button
This commit is contained in:
Adomas Ven 2021-12-15 08:47:06 +02:00 committed by GitHub
parent fbef7c00dd
commit 0a8ba2bced
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 158 additions and 46 deletions

View file

@ -299,6 +299,22 @@ var CollectionTree = class CollectionTree extends LibraryTree {
cell.appendChild(icon);
cell.appendChild(label);
div.appendChild(cell);
// Accessibility
div.setAttribute('aria-level', depth+1);
if (!this.isContainerEmpty(index)) {
div.setAttribute('aria-expanded', this.isContainerOpen(index));
}
div.setAttribute('role', 'treeitem');
if (treeRow.isSeparator()) {
div.setAttribute('role', 'none');
}
let children = [];
for (let i = index + 1; ; i++) {
let row = this.getRow(i);
if (!row || treeRow.level >= row.level) break;
children.push(this.id + '-row-' + i);
}
// Drag-and-drop stuff
if (this.props.dragAndDrop) {
@ -341,11 +357,12 @@ var CollectionTree = class CollectionTree extends LibraryTree {
toggleOpenState: this.toggleOpenState,
getRowString: this.getRowString.bind(this),
onItemContextMenu: (e) => this.props.onContextMenu && this.props.onContextMenu(e),
onItemContextMenu: (...args) => this.props.onContextMenu && this.props.onContextMenu(...args),
onKeyDown: this.handleKeyDown,
onActivate: this.handleActivate,
role: 'tree',
label: Zotero.getString('pane.collections.title')
}
);
@ -2152,12 +2169,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return false;
}
if (isLibrary) {
var collections = Zotero.Collections.getByLibrary(libraryID);
}
else if (isCollection) {
var collections = Zotero.Collections.getByParent(treeRow.ref.id);
}
var collections = treeRow.getChildren();
if (isLibrary) {
var savedSearches = await Zotero.Searches.getAll(libraryID);

View file

@ -98,8 +98,8 @@ i('BulletBlueEmpty', "chrome://zotero/skin/bullet_blue_empty.png");
// TreeItems
i('TreeitemArtwork', 'chrome://zotero/skin/treeitem-artwork.png');
i('TreeitemAttachmentLink', 'chrome://zotero/skin/treeitem-attachment-link.png');
i('TreeitemAttachmentPdf', 'chrome://zotero/skin/treeitem-attachment-pdf.png');
i('TreeitemAttachmentPdfLink', 'chrome://zotero/skin/treeitem-attachment-pdf-link.png');
i('TreeitemAttachmentPDF', 'chrome://zotero/skin/treeitem-attachment-pdf.png');
i('TreeitemAttachmentPDFLink', 'chrome://zotero/skin/treeitem-attachment-pdf-link.png');
i('TreeitemAttachmentSnapshot', 'chrome://zotero/skin/treeitem-attachment-snapshot.png');
i('TreeitemAttachmentWebLink', 'chrome://zotero/skin/treeitem-attachment-web-link.png');
i('TreeitemAudioRecording', 'chrome://zotero/skin/treeitem-audioRecording.png');

View file

@ -325,6 +325,7 @@ class VirtualizedTable extends React.Component {
static defaultProps = {
label: '',
role: 'grid',
showHeader: false,
// Array of column objects like the ones in itemTreeColumns.js
@ -377,6 +378,7 @@ class VirtualizedTable extends React.Component {
alternatingRowColors: PropTypes.array,
// For screen-readers
label: PropTypes.string,
role: PropTypes.string,
showHeader: PropTypes.bool,
// Array of column objects like the ones in itemTreeColumns.js
@ -421,6 +423,8 @@ class VirtualizedTable extends React.Component {
// Enter, double-clicking
onActivate: PropTypes.func,
onFocus: PropTypes.func,
onItemContextMenu: PropTypes.func,
};
@ -575,6 +579,13 @@ class VirtualizedTable extends React.Component {
}
break;
}
if (e.key == 'ContextMenu' || (e.key == 'F10' && e.shiftKey)) {
let selectedElem = document.querySelector(`#${this._jsWindowID} [aria-selected=true]`);
let boundingRect = selectedElem.getBoundingClientRect();
this.props.onItemContextMenu(e, boundingRect.left + 50, boundingRect.bottom);
return;
}
if (this.props.getRowString && !(e.ctrlKey || e.metaKey) && e.key.length == 1) {
this._handleTyping(e.key);
@ -669,9 +680,7 @@ class VirtualizedTable extends React.Component {
if (!modifierClick && !this.selection.isSelected(index)) {
this._onSelection(index, false, false);
}
if (this.props.onItemContextMenu) {
this.props.onItemContextMenu(e);
}
this.props.onItemContextMenu(e, e.clientX, e.clientY);
}
// All modifier clicks handled in mouseUp per mozilla itemtree convention
if (!modifierClick && !this.selection.isSelected(index)) {
@ -768,7 +777,7 @@ class VirtualizedTable extends React.Component {
this.setState({ resizing: index });
let onResizeData = {};
const columns = this._getColumns().filter(col => !col.hidden);
const columns = this._getVisibleColumns();
for (let i = 0; i < columns.length; i++) {
let elem = event.target.parentNode.parentNode.children[i];
onResizeData[columns[i].dataKey] = elem.getBoundingClientRect().width;
@ -809,10 +818,14 @@ class VirtualizedTable extends React.Component {
return this._columns.getAsArray();
}
_getVisibleColumns() {
return this._getColumns().filter(col => !col.hidden);
}
_getResizeColumns(index) {
index = typeof index != "undefined" ? index : this.state.resizing;
let resizingColumn, aColumn, bColumn;
const columns = this._getColumns().filter(col => !col.hidden).sort((a, b) => a.ordinal - b.ordinal);
const columns = this._getVisibleColumns().sort((a, b) => a.ordinal - b.ordinal);
aColumn = resizingColumn = columns[index - 1];
bColumn = columns[index];
if (aColumn.fixedWidth) {
@ -868,7 +881,7 @@ class VirtualizedTable extends React.Component {
// If inserting before the column that was being dragged
// there is nothing to do
if (this.state.draggingColumn != index) {
const visibleColumns = this._getColumns().filter(col => !col.hidden);
const visibleColumns = this._getVisibleColumns();
const dragColumn = this._getColumns().findIndex(
col => col == visibleColumns[this.state.draggingColumn]);
// Insert as final column (before end of list)
@ -986,12 +999,20 @@ class VirtualizedTable extends React.Component {
}
node.style.height = this._rowHeight + 'px';
node.id = this.props.id + "-row-" + index;
node.setAttribute('role', 'row');
if (!node.hasAttribute('role')) {
node.setAttribute('role', 'row');
}
if (this.selection.isSelected(index)) {
node.setAttribute('aria-selected', true);
}
else {
node.removeAttribute('aria-selected');
}
return node;
}
_renderHeaderCells = () => {
return this._getColumns().filter(col => !col.hidden).map((column, index) => {
return this._getVisibleColumns().map((column, index) => {
let columnName = column.label;
if (column.label in Zotero.Intl.strings) {
columnName = this.props.intl.formatMessage({ id: column.label });
@ -1062,17 +1083,25 @@ class VirtualizedTable extends React.Component {
onKeyDown: this._onKeyDown,
onDragOver: this._onDragOver,
onDrop: e => this.props.onDrop && this.props.onDrop(e),
onFocus: e => this.props.onFocus && this.props.onFocus(e),
className: cx(["virtualized-table", { resizing: this.state.resizing }]),
id: this.props.id,
ref: ref => this._topDiv = ref,
tabIndex: 0,
role: "table",
role: this.props.role,
};
if (this.props.hide) {
props.style = { display: "none" };
}
if (this.props.label) {
props.label = this.props.label;
props['aria-label'] = this.props.label;
}
if (this.props.columns.length && this.props.showHeader) {
props['aria-multiselectable'] = this.props.multiSelect;
props['aria-colcount'] = this._getVisibleColumns().length;
}
if (this.props.role == 'treegrid') {
props['aria-readonly'] = true;
}
if (this.selection.count > 0) {
const elem = this._jsWindow && this._jsWindow.getElementByIndex(this.selection.focused);

View file

@ -823,6 +823,15 @@ var ItemTree = class ItemTree extends LibraryTree {
}
}
handleFocus = (event) => {
if (Zotero.locked) {
return false;
}
if (this.selection.count == 0) {
this.selection.select(0);
}
}
handleActivate = (event, indices) => {
// Ignore double-clicks in duplicates view on everything except attachments
let items = indices.map(index => this.getRow(index).ref);
@ -934,7 +943,6 @@ var ItemTree = class ItemTree extends LibraryTree {
renderItem: this._renderItem.bind(this),
hide: showMessage,
key: "virtualized-table",
label: Zotero.getString('pane.items.title'),
showHeader: true,
columns: this._getColumns(),
@ -960,8 +968,12 @@ var ItemTree = class ItemTree extends LibraryTree {
onDrop: e => this.props.dragAndDrop && this.onDrop(e, -1),
onKeyDown: this.handleKeyDown,
onActivate: this.handleActivate,
onFocus: this.handleFocus,
onItemContextMenu: e => this.props.onContextMenu(e),
onItemContextMenu: (...args) => this.props.onContextMenu(...args),
role: 'treegrid',
label: Zotero.getString('pane.items.title'),
}
);
}
@ -2531,18 +2543,44 @@ var ItemTree = class ItemTree extends LibraryTree {
const item = this.getRow(index).ref;
let retracted = "";
let retractedAriaLabel = "";
if (Zotero.Retractions.isRetracted(item)) {
retracted = getDOMElement('IconCross');
retracted.classList.add("retracted");
retractedAriaLabel = Zotero.getString('retraction.banner');
}
let tags = item.getColoredTags().map(x => this._getTagSwatch(x.tag, x.color));
let tagAriaLabel = '';
let tagSpans = '';
let coloredTags = item.getColoredTags();
if (coloredTags.length) {
tagSpans = coloredTags.map(x => this._getTagSwatch(x.tag, x.color));
tagAriaLabel = tagSpans.length == 1 ? Zotero.getString('searchConditions.tag') : Zotero.getString('itemFields.tags');
tagAriaLabel += ' ' + coloredTags.map(x => x.tag).join(', ') + '.';
}
let itemTypeAriaLabel;
try {
var itemType = Zotero.ItemTypes.getName(item.itemTypeID);
itemTypeAriaLabel = Zotero.getString(`itemTypes.${itemType}`) + '.';
}
catch (e) {
Zotero.debug('Error attempting to get a localized item type label for ' + itemType, 1);
Zotero.debug(e, 1);
}
let textWithFullStop = data;
if (!textWithFullStop.match(/\.$/)) {
textWithFullStop += '.';
}
let textSpanAriaLabel = [textWithFullStop, itemTypeAriaLabel, tagAriaLabel, retractedAriaLabel].join(' ');
let textSpan = document.createElementNS("http://www.w3.org/1999/xhtml", 'span');
textSpan.className = "cell-text";
textSpan.innerText = data;
textSpan.setAttribute('aria-label', textSpanAriaLabel);
span.append(twisty, icon, retracted, ...tags, textSpan);
span.append(twisty, icon, retracted, ...tagSpans, textSpan);
// Set depth indent
const depth = this.getLevel(index);
@ -2580,6 +2618,10 @@ var ItemTree = class ItemTree extends LibraryTree {
icon = getDOMElement('IconBulletBlueEmpty');
icon.classList.add('cell-icon');
}
if (icon.setAttribute) {
icon.setAttribute('aria-label', Zotero.getString('pane.item.attachments.has') + '.');
}
span.append(icon);
item.getBestAttachmentState()
@ -2607,8 +2649,21 @@ var ItemTree = class ItemTree extends LibraryTree {
return span;
}
_renderCell() {
return renderCell.apply(this, arguments);
_renderCell(index, data, column) {
if (column.primary) {
return this._renderPrimaryCell(index, data, column);
}
else if (column.dataKey === 'hasAttachment') {
return this._renderHasAttachmentCell(index, data, column);
}
let cell = renderCell.apply(this, arguments);
if (column.dataKey === 'numNotes' && data) {
cell.setAttribute('aria-label', Zotero.getString('pane.item.notes.count', data, data) + '.');
}
else if (column.dataKey === 'itemType') {
cell.setAttribute('aria-hidden', true);
}
return cell;
}
_renderItem(index, selection, oldDiv=null, columns) {
@ -2640,16 +2695,7 @@ var ItemTree = class ItemTree extends LibraryTree {
for (let column of columns) {
if (column.hidden) continue;
if (column.primary) {
div.appendChild(this._renderPrimaryCell(index, rowData[column.dataKey], column));
}
else if (column.dataKey === 'hasAttachment') {
div.appendChild(this._renderHasAttachmentCell(index, rowData[column.dataKey], column));
}
else {
div.appendChild(this._renderCell(index, rowData[column.dataKey], column));
}
div.appendChild(this._renderCell(index, rowData[column.dataKey], column));
}
if (!oldDiv) {
@ -2668,6 +2714,16 @@ var ItemTree = class ItemTree extends LibraryTree {
div.addEventListener('mouseup', this._handleRowMouseUpDown, { passive: true });
}
// Accessibility
div.setAttribute('role', 'row');
div.setAttribute('aria-level', this.getLevel(index) + 1);
if (!this.isContainerEmpty(index)) {
div.setAttribute('aria-expanded', this.isContainerOpen(index));
}
if (!rowData.contextRow) {
div.setAttribute('aria-disabled', true);
}
return div;
};
@ -2679,7 +2735,6 @@ var ItemTree = class ItemTree extends LibraryTree {
}
_handleSelectionChange = (selection, shouldDebounce) => {
// Update aria-activedescendant on the tree
if (this.collectionTreeRow.isDuplicates() && selection.count == 1 && this.duplicateMouseSelection) {
var itemID = this.getRow(selection.focused).ref.id;
var setItemIDs = this.collectionTreeRow.ref.getSetItemsByItemID(itemID);
@ -2691,6 +2746,8 @@ var ItemTree = class ItemTree extends LibraryTree {
this.tree.invalidateRow(this._rowMap[id]);
}
}
// Update aria-activedescendant on the tree
this.forceUpdate();
this.duplicateMouseSelection = false;
if (shouldDebounce) {
this._onSelectionChangeDebounced();
@ -3532,10 +3589,10 @@ var ItemTree = class ItemTree extends LibraryTree {
if (item.attachmentContentType == 'application/pdf' && item.isFileAttachment()) {
if (linkMode == Zotero.Attachments.LINK_MODE_LINKED_FILE) {
itemType += 'PdfLink';
itemType += 'PDFLink';
}
else {
itemType += 'Pdf';
itemType += 'PDF';
}
}
else if (linkMode == Zotero.Attachments.LINK_MODE_IMPORTED_FILE) {

View file

@ -245,6 +245,15 @@ Zotero.CollectionTreeRow.prototype.getName = function()
}
}
Zotero.CollectionTreeRow.prototype.getChildren = function () {
if (this.isLibrary(true)) {
return Zotero.Collections.getByLibrary(this.ref.libraryID);
}
else if (this.isCollection()) {
return Zotero.Collections.getByParent(this.ref.id);
}
}
Zotero.CollectionTreeRow.prototype.getItems = Zotero.Promise.coroutine(function* ()
{
switch (this.type) {

View file

@ -1084,7 +1084,7 @@ var ZoteroPane = new function()
persistColumns: true,
columnPicker: true,
onSelectionChange: selection => ZoteroPane.itemSelected(selection),
onContextMenu: event => ZoteroPane.onItemsContextMenuOpen(event),
onContextMenu: (...args) => ZoteroPane.onItemsContextMenuOpen(...args),
onActivate: (event, items) => ZoteroPane.onItemTreeActivate(event, items),
emptyMessage: Zotero.getString('pane.items.loading')
});
@ -1103,7 +1103,7 @@ var ZoteroPane = new function()
var collectionsTree = document.getElementById('zotero-collections-tree');
ZoteroPane.collectionsView = await CollectionTree.init(collectionsTree, {
onSelectionChange: prevSelection => ZoteroPane.onCollectionSelected(prevSelection),
onContextMenu: e => ZoteroPane.onCollectionsContextMenuOpen(e),
onContextMenu: (...args) => ZoteroPane.onCollectionsContextMenuOpen(...args),
dragAndDrop: true
});
}
@ -2297,21 +2297,24 @@ var ZoteroPane = new function()
/**
* Show context menu once it's ready
*/
this.onCollectionsContextMenuOpen = async function (event) {
this.onCollectionsContextMenuOpen = async function (event, x, y) {
await ZoteroPane.buildCollectionContextMenu();
x = x || event.clientX;
y = y || event.clientY;
document.getElementById('zotero-collectionmenu').openPopup(
null, null, event.clientX + 1, event.clientY + 1);
null, null, x + 1, y + 1);
};
/**
* Show context menu once it's ready
*/
this.onItemsContextMenuOpen = async function (event) {
await ZoteroPane.buildItemContextMenu()
this.onItemsContextMenuOpen = async function (event, x, y) {
await ZoteroPane.buildItemContextMenu();
x = x || event.clientX;
y = y || event.clientY;
document.getElementById('zotero-itemmenu').openPopup(
null, null, event.clientX + 1, event.clientY + 1, true, false, event
);
null, null, x + 1, y + 1);
};

View file

@ -402,6 +402,7 @@ pane.item.attachments.delete.confirm = Are you sure you want to delete this att
pane.item.attachments.count.zero = %S attachments:
pane.item.attachments.count.singular = %S attachment:
pane.item.attachments.count.plural = %S attachments:
pane.item.attachments.has = Has attachments
pane.item.attachments.select = Select a File
pane.item.attachments.PDF.installTools.title = PDF Tools Not Installed
pane.item.attachments.PDF.installTools.text = To use this feature, you must first install the PDF tools in the Search pane of the Zotero preferences.
@ -572,6 +573,7 @@ itemFields.issuingAuthority = Issuing Authority
itemFields.filingDate = Filing Date
itemFields.genre = Genre
itemFields.archive = Archive
itemFields.attachmentPDF = PDF Attachment
creatorTypes.author = Author
creatorTypes.contributor = Contributor