Refactor `ContextMenu` to use `RovingTabIndex` (more consistent keyboard navigation accessibility) (#7353)

Split off from https://github.com/matrix-org/matrix-react-sdk/pull/7339
pull/21833/head
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

@ -208,14 +208,51 @@ limitations under the License.
.mx_UserMenu_CustomStatusSection {
margin: 0 12px 8px;
.mx_UserMenu_CustomStatusSection_input {
.mx_UserMenu_CustomStatusSection_field {
position: relative;
display: flex;
> input {
&.mx_UserMenu_CustomStatusSection_field_hasQuery {
.mx_UserMenu_CustomStatusSection_clear {
display: block;
}
}
> .mx_UserMenu_CustomStatusSection_input {
border: 1px solid $accent;
border-radius: 8px;
width: 100%;
&:focus + .mx_UserMenu_CustomStatusSection_clear {
display: block;
}
}
> .mx_UserMenu_CustomStatusSection_clear {
display: none;
position: absolute;
top: 50%;
right: 0;
transform: translateY(-50%);
width: 16px;
height: 16px;
margin-right: 8px;
background-color: $quinary-content;
border-radius: 50%;
&::before {
content: "";
position: absolute;
width: inherit;
height: inherit;
mask-image: url('$(res)/img/feather-customised/x.svg');
mask-position: center;
mask-size: 12px;
mask-repeat: no-repeat;
background-color: $secondary-content;
}
}
}

View File

@ -43,6 +43,17 @@ import { FocusHandler, Ref } from "./roving/types";
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex
*/
// Check for form elements which utilize the arrow keys for native functions
// like many of the text input varieties.
//
// i.e. it's ok to press the down arrow on a radio button to move to the next
// radio. But it's not ok to press the down arrow on a <input type="text"> to
// move away because the down arrow should move the cursor to the end of the
// input.
export function checkInputableElement(el: HTMLElement): boolean {
return el.matches('input:not([type="radio"]):not([type="checkbox"]), textarea, select, [contenteditable=true]');
}
export interface IState {
activeRef: Ref;
refs: Ref[];
@ -187,7 +198,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
const onKeyDownHandler = useCallback((ev) => {
const onKeyDownHandler = useCallback((ev: React.KeyboardEvent) => {
if (onKeyDown) {
onKeyDown(ev, context.state);
if (ev.defaultPrevented) {
@ -198,7 +209,18 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
let handled = false;
let focusRef: RefObject<HTMLElement>;
// Don't interfere with input default keydown behaviour
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
// but allow people to move focus from it with Tab.
if (checkInputableElement(ev.target as HTMLElement)) {
switch (ev.key) {
case Key.TAB:
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef);
focusRef = findSiblingElement(context.state.refs, idx + (ev.shiftKey ? -1 : 1), ev.shiftKey);
}
break;
}
} else {
// check if we actually have any items
switch (ev.key) {
case Key.HOME:
@ -270,9 +292,11 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
// onFocus should be called when the index gained focus in any manner
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref] => {
export const useRovingTabIndex = <T extends HTMLElement>(
inputRef?: RefObject<T>,
): [FocusHandler, boolean, RefObject<T>] => {
const context = useContext(RovingTabIndexContext);
let ref = useRef<HTMLElement>(null);
let ref = useRef<T>(null);
if (inputRef) {
// if we are given a ref, use it instead of ours

View File

@ -18,10 +18,9 @@ limitations under the License.
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
import AccessibleTooltipButton from "../../components/views/elements/AccessibleTooltipButton";
import { RovingAccessibleButton, RovingAccessibleTooltipButton } from "../RovingTabIndex";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
interface IProps extends React.ComponentProps<typeof RovingAccessibleButton> {
label?: string;
tooltip?: string;
}
@ -31,15 +30,14 @@ export const MenuItem: React.FC<IProps> = ({ children, label, tooltip, ...props
const ariaLabel = props["aria-label"] || label;
if (tooltip) {
return <AccessibleTooltipButton {...props} role="menuitem" tabIndex={-1} aria-label={ariaLabel} title={tooltip}>
return <RovingAccessibleTooltipButton {...props} role="menuitem" aria-label={ariaLabel} title={tooltip}>
{ children }
</AccessibleTooltipButton>;
</RovingAccessibleTooltipButton>;
}
return (
<AccessibleButton {...props} role="menuitem" tabIndex={-1} aria-label={ariaLabel}>
<RovingAccessibleButton {...props} role="menuitem" aria-label={ariaLabel}>
{ children }
</AccessibleButton>
</RovingAccessibleButton>
);
};

View File

@ -18,9 +18,9 @@ limitations under the License.
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
import { RovingAccessibleButton } from "../RovingTabIndex";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
interface IProps extends React.ComponentProps<typeof RovingAccessibleButton> {
label?: string;
active: boolean;
}
@ -28,16 +28,15 @@ interface IProps extends React.ComponentProps<typeof AccessibleButton> {
// Semantic component for representing a role=menuitemcheckbox
export const MenuItemCheckbox: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
return (
<AccessibleButton
<RovingAccessibleButton
{...props}
role="menuitemcheckbox"
aria-checked={active}
aria-disabled={disabled}
disabled={disabled}
tabIndex={-1}
aria-label={label}
>
{ children }
</AccessibleButton>
</RovingAccessibleButton>
);
};

View File

@ -18,9 +18,9 @@ limitations under the License.
import React from "react";
import AccessibleButton from "../../components/views/elements/AccessibleButton";
import { RovingAccessibleButton } from "../RovingTabIndex";
interface IProps extends React.ComponentProps<typeof AccessibleButton> {
interface IProps extends React.ComponentProps<typeof RovingAccessibleButton> {
label?: string;
active: boolean;
}
@ -28,16 +28,15 @@ interface IProps extends React.ComponentProps<typeof AccessibleButton> {
// Semantic component for representing a role=menuitemradio
export const MenuItemRadio: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
return (
<AccessibleButton
<RovingAccessibleButton
{...props}
role="menuitemradio"
aria-checked={active}
aria-disabled={disabled}
disabled={disabled}
tabIndex={-1}
aria-label={label}
>
{ children }
</AccessibleButton>
</RovingAccessibleButton>
);
};

View File

@ -19,6 +19,7 @@ limitations under the License.
import React from "react";
import { Key } from "../../Keyboard";
import { useRovingTabIndex } from "../RovingTabIndex";
import StyledCheckbox from "../../components/views/elements/StyledCheckbox";
interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
@ -29,6 +30,8 @@ interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
// Semantic component for representing a styled role=menuitemcheckbox
export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === Key.ENTER || e.key === Key.SPACE) {
e.stopPropagation();
@ -52,11 +55,13 @@ export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onCh
<StyledCheckbox
{...props}
role="menuitemcheckbox"
tabIndex={-1}
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onFocus={onFocus}
inputRef={ref}
tabIndex={isActive ? 0 : -1}
>
{ children }
</StyledCheckbox>

View File

@ -19,6 +19,7 @@ limitations under the License.
import React from "react";
import { Key } from "../../Keyboard";
import { useRovingTabIndex } from "../RovingTabIndex";
import StyledRadioButton from "../../components/views/elements/StyledRadioButton";
interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
@ -29,6 +30,8 @@ interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
// Semantic component for representing a styled role=menuitemradio
export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === Key.ENTER || e.key === Key.SPACE) {
e.stopPropagation();
@ -52,11 +55,13 @@ export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChang
<StyledRadioButton
{...props}
role="menuitemradio"
tabIndex={-1}
aria-label={label}
onChange={onChange}
onKeyDown={onKeyDown}
onKeyUp={onKeyUp}
onFocus={onFocus}
inputRef={ref}
tabIndex={isActive ? 0 : -1}
>
{ children }
</StyledRadioButton>

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 }

View File

@ -26,6 +26,7 @@ export enum CheckboxStyle {
}
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
inputRef?: React.RefObject<HTMLInputElement>;
kind?: CheckboxStyle;
}
@ -48,7 +49,8 @@ export default class StyledCheckbox extends React.PureComponent<IProps, IState>
public render() {
/* eslint @typescript-eslint/no-unused-vars: ["error", { "ignoreRestSiblings": true }] */
const { children, className, kind = CheckboxStyle.Solid, ...otherProps } = this.props;
const { children, className, kind = CheckboxStyle.Solid, inputRef, ...otherProps } = this.props;
const newClassName = classnames(
"mx_Checkbox",
className,
@ -58,7 +60,13 @@ export default class StyledCheckbox extends React.PureComponent<IProps, IState>
},
);
return <span className={newClassName}>
<input id={this.id} {...otherProps} type="checkbox" />
<input
// Pass through the ref - used for keyboard shortcut access to some buttons
ref={inputRef}
id={this.id}
{...otherProps}
type="checkbox"
/>
<label htmlFor={this.id}>
{ /* Using the div to center the image */ }
<div className="mx_Checkbox_background">

View File

@ -20,6 +20,7 @@ import classnames from 'classnames';
import { replaceableComponent } from "../../../utils/replaceableComponent";
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
inputRef?: React.RefObject<HTMLInputElement>;
outlined?: boolean;
// If true (default), the children will be contained within a <label> element
// If false, they'll be in a div. Putting interactive components that have labels
@ -38,7 +39,7 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
};
public render() {
const { children, className, disabled, outlined, childrenInLabel, ...otherProps } = this.props;
const { children, className, disabled, outlined, childrenInLabel, inputRef, ...otherProps } = this.props;
const _className = classnames(
'mx_StyledRadioButton',
className,
@ -50,7 +51,13 @@ export default class StyledRadioButton extends React.PureComponent<IProps, IStat
});
const radioButton = <React.Fragment>
<input type='radio' disabled={disabled} {...otherProps} />
<input
// Pass through the ref - used for keyboard shortcut access to some buttons
ref={inputRef}
type='radio'
disabled={disabled}
{...otherProps}
/>
{ /* Used to render the radio button circle */ }
<div><div /></div>
</React.Fragment>;

View File

@ -402,6 +402,7 @@ export default class RoomSublist extends React.Component<IProps, IState> {
private onTagSortChanged = async (sort: SortAlgorithm) => {
await RoomListStore.instance.setTagSorting(this.props.tagId, sort);
this.forceUpdate();
};
private onMessagePreviewChanged = () => {

View File

@ -45,29 +45,55 @@ exports[`ThreadPanel Header expect that My filter for ThreadPanelHeader properly
`;
exports[`ThreadPanel Header expect that ThreadPanelHeader has the correct option selected in the context menu 1`] = `
<AccessibleButton
<RovingAccessibleButton
aria-checked={true}
className="mx_ThreadPanel_Header_FilterOptionItem"
element="div"
onClick={[Function]}
role="menuitemradio"
tabIndex={-1}
>
<div
<AccessibleButton
aria-checked={true}
className="mx_AccessibleButton mx_ThreadPanel_Header_FilterOptionItem"
className="mx_ThreadPanel_Header_FilterOptionItem"
element="div"
inputRef={
Object {
"current": <div
aria-checked="true"
class="mx_AccessibleButton mx_ThreadPanel_Header_FilterOptionItem"
role="menuitemradio"
tabindex="-1"
>
<span>
All threads
</span>
<span>
Shows all threads from current room
</span>
</div>,
}
}
onClick={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
onFocus={[Function]}
role="menuitemradio"
tabIndex={-1}
>
<span>
All threads
</span>
<span>
Shows all threads from current room
</span>
</div>
</AccessibleButton>
<div
aria-checked={true}
className="mx_AccessibleButton mx_ThreadPanel_Header_FilterOptionItem"
onClick={[Function]}
onFocus={[Function]}
onKeyDown={[Function]}
onKeyUp={[Function]}
role="menuitemradio"
tabIndex={-1}
>
<span>
All threads
</span>
<span>
Shows all threads from current room
</span>
</div>
</AccessibleButton>
</RovingAccessibleButton>
`;