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:
Eric Eastwood 2021-12-17 11:08:56 -06:00 committed by GitHub
parent 6761ef9540
commit 9289c0c90f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 224 additions and 160 deletions

View file

@ -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>
);
}

View file

@ -74,7 +74,10 @@ import LegacyCommunityPreview from "./LegacyCommunityPreview";
// NB. this is just for server notices rather than pinned messages in general.
const MAX_PINNED_NOTICES_PER_ROOM = 2;
export function getInputableElement(el: HTMLElement): HTMLElement | null {
// Used to find the closest inputable thing. Because of how our composer works,
// your caret might be within a paragraph/font/div/whatever within the
// contenteditable rather than directly in something inputable.
function getInputableElement(el: HTMLElement): HTMLElement | null {
return el.closest("input, textarea, select, [contenteditable=true]");
}

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { createRef, useContext, useState } from "react";
import React, { createRef, useContext, useRef, useState } from "react";
import { Room } from "matrix-js-sdk/src/models/room";
import * as fbEmitter from "fbemitter";
import classNames from "classnames";
@ -33,13 +33,17 @@ import Modal from "../../Modal";
import LogoutDialog from "../views/dialogs/LogoutDialog";
import SettingsStore from "../../settings/SettingsStore";
import { findHighContrastTheme, getCustomTheme, isHighContrastTheme } from "../../theme";
import {
RovingAccessibleButton,
RovingAccessibleTooltipButton,
useRovingTabIndex,
} from "../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import SdkConfig from "../../SdkConfig";
import { getHomePageUrl } from "../../utils/pages";
import { OwnProfileStore } from "../../stores/OwnProfileStore";
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import BaseAvatar from '../views/avatars/BaseAvatar';
import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton";
import { SettingLevel } from "../../settings/SettingLevel";
import IconizedContextMenu, {
IconizedContextMenuCheckbox,
@ -61,30 +65,43 @@ const CustomStatusSection = () => {
const setStatus = cli.getUser(cli.getUserId()).unstable_statusMessage || "";
const [value, setValue] = useState(setStatus);
const ref = useRef<HTMLInputElement>(null);
const [onFocus, isActive] = useRovingTabIndex(ref);
const classes = classNames({
'mx_UserMenu_CustomStatusSection_field': true,
'mx_UserMenu_CustomStatusSection_field_hasQuery': value,
});
let details: JSX.Element;
if (value !== setStatus) {
details = <>
<p>{ _t("Your status will be shown to people you have a DM with.") }</p>
<AccessibleButton
<RovingAccessibleButton
onClick={() => cli._unstable_setStatusMessage(value)}
kind="primary_outline"
>
{ value ? _t("Set status") : _t("Clear status") }
</AccessibleButton>
</RovingAccessibleButton>
</>;
}
return <div className="mx_UserMenu_CustomStatusSection">
<div className="mx_UserMenu_CustomStatusSection_input">
return <form className="mx_UserMenu_CustomStatusSection">
<div className={classes}>
<input
type="text"
value={value}
className="mx_UserMenu_CustomStatusSection_input"
onChange={e => setValue(e.target.value)}
placeholder={_t("Set a new status")}
autoComplete="off"
onFocus={onFocus}
ref={ref}
tabIndex={isActive ? 0 : -1}
/>
<AccessibleButton
// The clear button is only for mouse users
tabIndex={-1}
title={_t("Clear")}
className="mx_UserMenu_CustomStatusSection_clear"
@ -93,7 +110,7 @@ const CustomStatusSection = () => {
</div>
{ details }
</div>;
</form>;
};
interface IProps {
@ -486,7 +503,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
</span>
</div>
<AccessibleTooltipButton
<RovingAccessibleTooltipButton
className="mx_UserMenu_contextMenu_themeButton"
onClick={this.onSwitchThemeClick}
title={this.state.isDarkTheme ? _t("Switch to light mode") : _t("Switch to dark mode")}
@ -496,7 +513,7 @@ export default class UserMenu extends React.Component<IProps, IState> {
alt={_t("Switch theme")}
width={16}
/>
</AccessibleTooltipButton>
</RovingAccessibleTooltipButton>
</div>
{ customStatusSection }
{ topSection }