Improve tree screen reader accessibility (#2263)
Also: * Allow opening tree context menu with Shift + F10 and Context Menu button
This commit is contained in:
parent
fbef7c00dd
commit
0a8ba2bced
7 changed files with 158 additions and 46 deletions
|
@ -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);
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue