Refactor ContextMenu
to use RovingTabIndex
(more consistent keyboard navigation accessibility) (#7353)
Split off from https://github.com/matrix-org/matrix-react-sdk/pull/7339
This commit is contained in:
parent
6761ef9540
commit
9289c0c90f
14 changed files with 224 additions and 160 deletions
|
@ -25,7 +25,7 @@ import { Key } from "../../Keyboard";
|
|||
import { Writeable } from "../../@types/common";
|
||||
import { replaceableComponent } from "../../utils/replaceableComponent";
|
||||
import UIStore from "../../stores/UIStore";
|
||||
import { getInputableElement } from "./LoggedInView";
|
||||
import { checkInputableElement, RovingTabIndexProvider } from "../../accessibility/RovingTabIndex";
|
||||
|
||||
// Shamelessly ripped off Modal.js. There's probably a better way
|
||||
// of doing reusable widgets like dialog boxes & menus where we go and
|
||||
|
@ -180,108 +180,39 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
if (this.props.onFinished) this.props.onFinished();
|
||||
};
|
||||
|
||||
private onMoveFocus = (element: Element, up: boolean) => {
|
||||
let descending = false; // are we currently descending or ascending through the DOM tree?
|
||||
|
||||
do {
|
||||
const child = up ? element.lastElementChild : element.firstElementChild;
|
||||
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
|
||||
|
||||
if (descending) {
|
||||
if (child) {
|
||||
element = child;
|
||||
} else if (sibling) {
|
||||
element = sibling;
|
||||
} else {
|
||||
descending = false;
|
||||
element = element.parentElement;
|
||||
}
|
||||
} else {
|
||||
if (sibling) {
|
||||
element = sibling;
|
||||
descending = true;
|
||||
} else {
|
||||
element = element.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
if (element) {
|
||||
if (element.classList.contains("mx_ContextualMenu")) { // we hit the top
|
||||
element = up ? element.lastElementChild : element.firstElementChild;
|
||||
descending = true;
|
||||
}
|
||||
}
|
||||
} while (element && !element.getAttribute("role")?.startsWith("menuitem"));
|
||||
|
||||
if (element) {
|
||||
(element as HTMLElement).focus();
|
||||
}
|
||||
};
|
||||
|
||||
private onMoveFocusHomeEnd = (element: Element, up: boolean) => {
|
||||
let results = element.querySelectorAll('[role^="menuitem"]');
|
||||
if (!results) {
|
||||
results = element.querySelectorAll('[tab-index]');
|
||||
}
|
||||
if (results && results.length) {
|
||||
if (up) {
|
||||
(results[0] as HTMLElement).focus();
|
||||
} else {
|
||||
(results[results.length - 1] as HTMLElement).focus();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onClick = (ev: React.MouseEvent) => {
|
||||
// Don't allow clicks to escape the context menu wrapper
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
// We now only handle closing the ContextMenu in this keyDown handler.
|
||||
// All of the item/option navigation is delegated to RovingTabIndex.
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
// don't let keyboard handling escape the context menu
|
||||
ev.stopPropagation();
|
||||
|
||||
// If someone is managing their own focus, we will only exit for them with Escape.
|
||||
// They are probably using props.focusLock along with this option as well.
|
||||
if (!this.props.managed) {
|
||||
if (ev.key === Key.ESCAPE) {
|
||||
this.props.onFinished();
|
||||
ev.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// only handle escape when in an input field
|
||||
if (ev.key !== Key.ESCAPE && getInputableElement(ev.target as HTMLElement)) return;
|
||||
|
||||
let handled = true;
|
||||
|
||||
switch (ev.key) {
|
||||
// XXX: this is imitating roving behaviour, it should really use the RovingTabIndex utils
|
||||
// to inherit proper handling of unmount edge cases
|
||||
case Key.TAB:
|
||||
case Key.ESCAPE:
|
||||
case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a <Toolbar />
|
||||
case Key.ARROW_RIGHT:
|
||||
this.props.onFinished();
|
||||
break;
|
||||
case Key.ARROW_UP:
|
||||
this.onMoveFocus(ev.target as Element, true);
|
||||
break;
|
||||
case Key.ARROW_DOWN:
|
||||
this.onMoveFocus(ev.target as Element, false);
|
||||
break;
|
||||
case Key.HOME:
|
||||
this.onMoveFocusHomeEnd(this.state.contextMenuElem, true);
|
||||
break;
|
||||
case Key.END:
|
||||
this.onMoveFocusHomeEnd(this.state.contextMenuElem, false);
|
||||
break;
|
||||
default:
|
||||
handled = false;
|
||||
// When an <input> is focused, only handle the Escape key
|
||||
if (checkInputableElement(ev.target as HTMLElement) && ev.key !== Key.ESCAPE) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
// consume all other keys in context menu
|
||||
ev.preventDefault();
|
||||
if (
|
||||
ev.key === Key.ESCAPE ||
|
||||
// You can only navigate the ContextMenu by arrow keys and Home/End (see RovingTabIndex).
|
||||
// Tabbing to the next section of the page, will close the ContextMenu.
|
||||
ev.key === Key.TAB ||
|
||||
// When someone moves left or right along a <Toolbar /> (like the
|
||||
// MessageActionBar), we should close any ContextMenu that is open.
|
||||
ev.key === Key.ARROW_LEFT ||
|
||||
ev.key === Key.ARROW_RIGHT
|
||||
) {
|
||||
this.props.onFinished();
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -408,23 +339,27 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)}
|
||||
style={{ ...position, ...wrapperStyle }}
|
||||
onKeyDown={this.onKeyDown}
|
||||
onClick={this.onClick}
|
||||
onContextMenu={this.onContextMenuPreventBubbling}
|
||||
>
|
||||
{ background }
|
||||
<div
|
||||
className={menuClasses}
|
||||
style={menuStyle}
|
||||
ref={this.collectContextMenuRect}
|
||||
role={this.props.managed ? "menu" : undefined}
|
||||
>
|
||||
{ body }
|
||||
</div>
|
||||
</div>
|
||||
<RovingTabIndexProvider handleHomeEnd handleUpDown onKeyDown={this.onKeyDown}>
|
||||
{ ({ onKeyDownHandler }) => (
|
||||
<div
|
||||
className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)}
|
||||
style={{ ...position, ...wrapperStyle }}
|
||||
onClick={this.onClick}
|
||||
onKeyDown={onKeyDownHandler}
|
||||
onContextMenu={this.onContextMenuPreventBubbling}
|
||||
>
|
||||
{ background }
|
||||
<div
|
||||
className={menuClasses}
|
||||
style={menuStyle}
|
||||
ref={this.collectContextMenuRect}
|
||||
role={this.props.managed ? "menu" : undefined}
|
||||
>
|
||||
{ body }
|
||||
</div>
|
||||
</div>
|
||||
) }
|
||||
</RovingTabIndexProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue