Merge pull request #6311 from matrix-org/t3chguy/a11y/focus-lock-ctx-menu

This commit is contained in:
Michael Telatynski 2021-10-07 13:28:32 +01:00 committed by GitHub
commit 1b5aef4447
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 27 additions and 17 deletions

View file

@ -19,6 +19,7 @@ limitations under the License.
import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react"; import React, { CSSProperties, RefObject, SyntheticEvent, useRef, useState } from "react";
import ReactDOM from "react-dom"; import ReactDOM from "react-dom";
import classNames from "classnames"; import classNames from "classnames";
import FocusLock from "react-focus-lock";
import { Key } from "../../Keyboard"; import { Key } from "../../Keyboard";
import { Writeable } from "../../@types/common"; import { Writeable } from "../../@types/common";
@ -43,8 +44,6 @@ function getOrCreateContainer(): HTMLDivElement {
return container; return container;
} }
const ARIA_MENU_ITEM_ROLES = new Set(["menuitem", "menuitemcheckbox", "menuitemradio"]);
export interface IPosition { export interface IPosition {
top?: number; top?: number;
bottom?: number; bottom?: number;
@ -84,6 +83,10 @@ export interface IProps extends IPosition {
// it will be mounted to a container at the root of the DOM. // it will be mounted to a container at the root of the DOM.
mountAsChild?: boolean; mountAsChild?: boolean;
// If specified, contents will be wrapped in a FocusLock, this is only needed if the context menu is being rendered
// within an existing FocusLock e.g inside a modal.
focusLock?: boolean;
// Function to be called on menu close // Function to be called on menu close
onFinished(); onFinished();
// on resize callback // on resize callback
@ -99,7 +102,7 @@ interface IState {
// this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines. // this will allow the ContextMenu to manage its own focus using arrow keys as per the ARIA guidelines.
@replaceableComponent("structures.ContextMenu") @replaceableComponent("structures.ContextMenu")
export class ContextMenu extends React.PureComponent<IProps, IState> { export class ContextMenu extends React.PureComponent<IProps, IState> {
private initialFocus: HTMLElement; private readonly initialFocus: HTMLElement;
static defaultProps = { static defaultProps = {
hasBackground: true, hasBackground: true,
@ -108,6 +111,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
this.state = { this.state = {
contextMenuElem: null, contextMenuElem: null,
}; };
@ -121,14 +125,13 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
this.initialFocus.focus(); this.initialFocus.focus();
} }
private collectContextMenuRect = (element) => { private collectContextMenuRect = (element: HTMLDivElement) => {
// We don't need to clean up when unmounting, so ignore // We don't need to clean up when unmounting, so ignore
if (!element) return; if (!element) return;
let first = element.querySelector('[role^="menuitem"]'); const first = element.querySelector<HTMLElement>('[role^="menuitem"]')
if (!first) { || element.querySelector<HTMLElement>('[tab-index]');
first = element.querySelector('[tab-index]');
}
if (first) { if (first) {
first.focus(); first.focus();
} }
@ -205,7 +208,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
descending = true; descending = true;
} }
} }
} while (element && !ARIA_MENU_ITEM_ROLES.has(element.getAttribute("role"))); } while (element && !element.getAttribute("role")?.startsWith("menuitem"));
if (element) { if (element) {
(element as HTMLElement).focus(); (element as HTMLElement).focus();
@ -383,6 +386,17 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
); );
} }
let body = <>
{ chevron }
{ props.children }
</>;
if (props.focusLock) {
body = <FocusLock>
{ body }
</FocusLock>;
}
return ( return (
<div <div
className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)} className={classNames("mx_ContextualMenu_wrapper", this.props.wrapperClassName)}
@ -397,8 +411,7 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
ref={this.collectContextMenuRect} ref={this.collectContextMenuRect}
role={this.props.managed ? "menu" : undefined} role={this.props.managed ? "menu" : undefined}
> >
{ chevron } { body }
{ props.children }
</div> </div>
{ background } { background }
</div> </div>

View file

@ -268,7 +268,7 @@ const NetworkDropdown = ({ onOptionChange, protocols = {}, selectedServerName, s
}; };
const buttonRect = handle.current.getBoundingClientRect(); const buttonRect = handle.current.getBoundingClientRect();
content = <ContextMenu {...inPlaceOf(buttonRect)} onFinished={closeMenu}> content = <ContextMenu {...inPlaceOf(buttonRect)} onFinished={closeMenu} focusLock>
<div className="mx_NetworkDropdown_menu"> <div className="mx_NetworkDropdown_menu">
{ options } { options }
<MenuItem className="mx_NetworkDropdown_server_add" label={undefined} onClick={onClick}> <MenuItem className="mx_NetworkDropdown_server_add" label={undefined} onClick={onClick}>

View file

@ -17,9 +17,8 @@ limitations under the License.
import React, { ComponentProps, RefObject, SyntheticEvent, KeyboardEvent, useContext, useRef, useState } from "react"; import React, { ComponentProps, RefObject, SyntheticEvent, KeyboardEvent, useContext, useRef, useState } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { RoomType } from "matrix-js-sdk/src/@types/event"; import { RoomType } from "matrix-js-sdk/src/@types/event";
import FocusLock from "react-focus-lock";
import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests"; import { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests";
import { HistoryVisibility, Preset } from "matrix-js-sdk/src/@types/partials";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton";
@ -361,9 +360,7 @@ const SpaceCreateMenu = ({ onFinished }) => {
wrapperClassName="mx_SpaceCreateMenu_wrapper" wrapperClassName="mx_SpaceCreateMenu_wrapper"
managed={false} managed={false}
> >
<FocusLock returnFocus={true}>
{ body } { body }
</FocusLock>
</ContextMenu>; </ContextMenu>;
}; };