diff --git a/chrome/content/zotero/collectionTree.jsx b/chrome/content/zotero/collectionTree.jsx index 6075aa211d..abb3ef5e52 100644 --- a/chrome/content/zotero/collectionTree.jsx +++ b/chrome/content/zotero/collectionTree.jsx @@ -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); diff --git a/chrome/content/zotero/components/icons.jsx b/chrome/content/zotero/components/icons.jsx index 74ed61292a..b6c1118eca 100644 --- a/chrome/content/zotero/components/icons.jsx +++ b/chrome/content/zotero/components/icons.jsx @@ -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'); diff --git a/chrome/content/zotero/components/virtualized-table.jsx b/chrome/content/zotero/components/virtualized-table.jsx index 0b9f75816c..4964c22215 100644 --- a/chrome/content/zotero/components/virtualized-table.jsx +++ b/chrome/content/zotero/components/virtualized-table.jsx @@ -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); diff --git a/chrome/content/zotero/itemTree.jsx b/chrome/content/zotero/itemTree.jsx index bc4907cecb..d9154e0e8c 100644 --- a/chrome/content/zotero/itemTree.jsx +++ b/chrome/content/zotero/itemTree.jsx @@ -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) { diff --git a/chrome/content/zotero/xpcom/collectionTreeRow.js b/chrome/content/zotero/xpcom/collectionTreeRow.js index e3bf0cdb95..5747a0e0a9 100644 --- a/chrome/content/zotero/xpcom/collectionTreeRow.js +++ b/chrome/content/zotero/xpcom/collectionTreeRow.js @@ -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) { diff --git a/chrome/content/zotero/zoteroPane.js b/chrome/content/zotero/zoteroPane.js index 36b06f77b6..4453d14939 100644 --- a/chrome/content/zotero/zoteroPane.js +++ b/chrome/content/zotero/zoteroPane.js @@ -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); }; diff --git a/chrome/locale/en-US/zotero/zotero.properties b/chrome/locale/en-US/zotero/zotero.properties index e271c2cac5..143530f973 100644 --- a/chrome/locale/en-US/zotero/zotero.properties +++ b/chrome/locale/en-US/zotero/zotero.properties @@ -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