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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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]");
|
||||
}
|
||||
|
||||
|
|
|
@ -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 }
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue