tag selector focus edits, fix windowing breakage (#3984)

* tag selector focus edits

- no tabstop on the tag selector scrollable area
- change tag selector's role from default "grid" to "group".
"grid" is not quite correct semantically and leads to
voiceover suggesting irrelevant commands.
- move all keyboard handling logic to tagSelectorList.jsx.
Tabbing through tag selector is now handled in ZoteroPane,
so the only logic left there is arrow navigation
between tags, and there's no reason to not have it
together with the tags list.
- a workaround to deal with focused tags when windowing
kicks in. When a tag is focused, record its index.
Each time tags are re-rendered, if the saved index is not
among rendered tags, refocus it, otherwise, move focus
to the tags list.
This commit is contained in:
abaevbog 2024-04-18 01:39:36 -04:00 committed by GitHub
parent 816aaca380
commit 2835d6fe83
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 100 additions and 43 deletions

View file

@ -46,6 +46,12 @@ class TagList extends React.PureComponent {
this.collectionRef = React.createRef(); this.collectionRef = React.createRef();
this.scrollToTopOnNextUpdate = false; this.scrollToTopOnNextUpdate = false;
this.prevTagCount = 0; this.prevTagCount = 0;
this.focusedTagIndex = null;
this.lastFocusedTagIndex = null;
this.resolveTagsRenderedPromise = null;
this.state = {
scrollToCell: null
};
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
@ -159,7 +165,7 @@ class TagList extends React.PureComponent {
const { onDragOver, onDragExit, onDrop } = this.props.dragObserver; const { onDragOver, onDragExit, onDrop } = this.props.dragObserver;
var className = 'tag-selector-item'; var className = 'tag-selector-item keyboard-clickable';
if (tag.selected) { if (tag.selected) {
className += ' selected'; className += ' selected';
} }
@ -177,10 +183,13 @@ class TagList extends React.PureComponent {
className, className,
onClick: ev => !tag.disabled && this.props.onSelect(tag.name, ev), onClick: ev => !tag.disabled && this.props.onSelect(tag.name, ev),
onContextMenu: ev => this.props.onTagContext(tag, ev), onContextMenu: ev => this.props.onTagContext(tag, ev),
onKeyDown: ev => this.props.onKeyDown(ev),
onDragOver, onDragOver,
onDragExit, onDragExit,
onDrop, onDrop,
onFocus: (_) => {
this.lastFocusedTagIndex = this.focusedTagIndex;
this.focusedTagIndex = index;
}
}; };
props.style = { props.style = {
@ -215,6 +224,84 @@ class TagList extends React.PureComponent {
); );
}; };
// Try to refocus a focused tag that was removed due to windowing
refocusTag() {
let tagsList = document.querySelector('.tag-selector-list');
let tagsNodes = [...tagsList.querySelectorAll(".tag-selector-item")];
let tagToFocus = this.props.tags[this.focusedTagIndex];
let nodeToFocus = tagsNodes.find(node => node.textContent == tagToFocus.name);
if (nodeToFocus) {
nodeToFocus.focus();
}
}
waitForSectionRender() {
return new Promise((resolve, _) => {
this.resolveTagsRenderedPromise = resolve;
});
}
handleSectionRendered = ({ indices }) => {
let tagsList = document.querySelector('.tag-selector-list');
// <Collection> sets role="grid" which is not semantically correct
tagsList.setAttribute("role", "group");
if (this.focusedTagIndex === null) return;
// If the focused tag does not changed, the scrollToCell won't change
// either, so the <Collection> won't scroll to the desired tag if we don't reset it.
// E.g. second arrowLeft keypress when first tag is focused won't scroll to it.
if (this.lastFocusedTagIndex === this.focusedTagIndex) {
this.setState({ scrollToCell: null });
}
// Check if the tag that is supposed to be focused is within the rendered tags range.
// If it is, make sure it is focused. If it is not - focus the tags list.
if (indices.includes(this.focusedTagIndex)) {
this.refocusTag();
if (this.resolveTagsRenderedPromise) {
this.resolveTagsRenderedPromise();
}
}
else {
tagsList.focus();
}
};
handleBlur = (event) => {
// If the focus leaves the tags list, clear the last focused tag index
let tagsList = document.querySelector('.tag-selector-list');
if (!tagsList.contains(event.relatedTarget)) {
this.focusedTagIndex = null;
this.lastFocusedTagIndex = null;
}
};
async handleKeyDown(e) {
if (!["ArrowRight", "ArrowLeft"].includes(e.key)) return;
// If the windowing kicks in, the node of the initially-focused tag may not
// exist, so first we may need to scroll to it.
if (!document.activeElement.classList.contains("tag-selector-item")) {
this.setState({ scrollToCell: this.focusedTagIndex });
// Even after the <Collection> re-renders, the new tag nodes may not be rendered yet.
// So we have to wait for handleSectionRendered to run before proceeding.
await this.waitForSectionRender();
}
// Sanity check to make sure that now a tag node is focused
if (!document.activeElement.classList.contains("tag-selector-item")) return;
// Handle arrow navigation
let nextTag = (node) => {
if (e.key == "ArrowRight") return node.nextElementSibling;
return node.previousElementSibling;
};
let nextOne = nextTag(document.activeElement);
// Skip disabled tags
while (nextOne && nextOne.classList.contains("disabled")) {
nextOne = nextTag(nextOne);
}
if (nextOne) {
nextOne.focus();
}
}
render() { render() {
Zotero.debug("Rendering tag list"); Zotero.debug("Rendering tag list");
const tagCount = this.props.tags.length; const tagCount = this.props.tags.length;
@ -252,12 +339,14 @@ class TagList extends React.PureComponent {
width={this.props.width} width={this.props.width}
height={this.props.height - filterBarHeight} height={this.props.height - filterBarHeight}
aria-label={document.querySelector("#zotero-tag-selector").getAttribute("label") || ""} aria-label={document.querySelector("#zotero-tag-selector").getAttribute("label") || ""}
onSectionRendered={this.handleSectionRendered}
scrollToCell={Number.isInteger(this.state.scrollToCell) ? this.state.scrollToCell : undefined}
/> />
); );
} }
return ( return (
<div className="tag-selector-list-container"> <div className="tag-selector-list-container" onBlur={this.handleBlur} onKeyDown={this.handleKeyDown.bind(this)}>
{tagList} {tagList}
</div> </div>
); );

View file

@ -580,34 +580,6 @@ Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
} }
} }
handleKeyDown = (e) => {
if (["ArrowRight", "ArrowLeft"].includes(e.key)) {
let nextTag = (node) => {
if (e.key == "ArrowRight") return node.nextElementSibling;
return node.previousElementSibling;
};
let nextOne = nextTag(e.target);
// Skip disabled tags
while (nextOne && nextOne.classList.contains("disabled")) {
nextOne = nextTag(nextOne);
}
if (nextOne) {
nextOne.focus();
}
}
else if (e.key == "Tab" && !e.shiftKey) {
this.focusTextbox();
e.preventDefault();
}
else if (e.key == "Tab" && e.shiftKey) {
document.querySelector('.tag-selector-list').focus();
e.preventDefault();
}
if ([" ", "Enter"].includes(e.key)) {
e.target.click();
}
}
handleSearch = (searchString) => { handleSearch = (searchString) => {
this.setState({searchString}); this.setState({searchString});
} }

View file

@ -419,9 +419,12 @@ var ZoteroPane = new function()
if (tagContainer.getAttribute('collapsed') == "true") { if (tagContainer.getAttribute('collapsed') == "true") {
return document.getElementById('zotero-tb-add'); return document.getElementById('zotero-tb-add');
} }
// If tag selector is collapsed, go to "New item" button, otherwise // If tag selector is collapsed, go to "New item" button, otherwise focus tag selector
// default to focusing on tag selector let firstNonDisabledTag = tagSelector.querySelector('.tag-selector-item:not(.disabled)');
return tagContainer.querySelector(".tag-selector-list"); if (firstNonDisabledTag) {
return firstNonDisabledTag;
}
return tagSelector.querySelector(".search-input");
}, },
Escape: clearCollectionSearch Escape: clearCollectionSearch
} }
@ -452,20 +455,14 @@ var ZoteroPane = new function()
}, },
'tag-selector-item': { 'tag-selector-item': {
Tab: () => tagSelector.querySelector(".search-input"), Tab: () => tagSelector.querySelector(".search-input"),
ShiftTab: () => tagSelector.querySelector(".tag-selector-list"), ShiftTab: () => document.getElementById("collection-tree"),
}, },
'tag-selector-actions': { 'tag-selector-actions': {
Tab: () => document.getElementById('zotero-tb-add'), Tab: () => document.getElementById('zotero-tb-add'),
ShiftTab: () => tagSelector.querySelector(".search-input") ShiftTab: () => tagSelector.querySelector(".search-input")
}, },
'tag-selector-list': { 'tag-selector-list': {
Tab: () => { Tab: () => tagSelector.querySelector(".search-input"),
let firstNonDisabledTag = tagSelector.querySelector('.tag-selector-item:not(.disabled)');
if (firstNonDisabledTag) {
return firstNonDisabledTag;
}
return tagSelector.querySelector(".search-input");
},
ShiftTab: () => document.getElementById("collection-tree"), ShiftTab: () => document.getElementById("collection-tree"),
} }
}; };

View file

@ -1534,7 +1534,6 @@ describe("ZoteroPane", function() {
"tag-selector-actions", "tag-selector-actions",
"search-input", "search-input",
"tag-selector-item", "tag-selector-item",
"tag-selector-list",
"collection-tree", "collection-tree",
"zotero-collections-search", "zotero-collections-search",
"zotero-tb-collection-add", "zotero-tb-collection-add",