Fix accessibility regressions (#7336)

* Fix room list roving treeview

New TooltipTarget & TextWithTooltip were not roving-accessible

* Fix programmatic focus management in roving tab index not triggering onFocus handler

* Fix toolbar no longer handling left & right arrows

* Fix roving tab index focus tracking on interactive element like context menu trigger

* Fix thread list context menu roving

* add comment

* fix comment

* Fix handling vertical arrows in the wrong direction

* iterate PR

* delint

* tidy up
This commit is contained in:
Michael Telatynski 2021-12-14 14:27:35 +00:00 committed by GitHub
parent 60286f6170
commit a667677c57
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 103 additions and 58 deletions

View file

@ -131,6 +131,8 @@ export const reducer = (state: IState, action: IAction) => {
} }
case Type.SetFocus: { case Type.SetFocus: {
// if the ref doesn't change just return the same object reference to skip a re-render
if (state.activeRef === action.payload.ref) return state;
// update active ref // update active ref
state.activeRef = action.payload.ref; state.activeRef = action.payload.ref;
return { ...state }; return { ...state };
@ -194,6 +196,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
} }
let handled = false; let handled = false;
let focusRef: RefObject<HTMLElement>;
// Don't interfere with input default keydown behaviour // Don't interfere with input default keydown behaviour
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") { if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
// check if we actually have any items // check if we actually have any items
@ -202,7 +205,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
if (handleHomeEnd) { if (handleHomeEnd) {
handled = true; handled = true;
// move focus to first (visible) item // move focus to first (visible) item
findSiblingElement(context.state.refs, 0)?.current?.focus(); focusRef = findSiblingElement(context.state.refs, 0);
} }
break; break;
@ -210,28 +213,30 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
if (handleHomeEnd) { if (handleHomeEnd) {
handled = true; handled = true;
// move focus to last (visible) item // move focus to last (visible) item
findSiblingElement(context.state.refs, context.state.refs.length - 1, true)?.current?.focus(); focusRef = findSiblingElement(context.state.refs, context.state.refs.length - 1, true);
}
break;
case Key.ARROW_UP:
case Key.ARROW_RIGHT:
if ((ev.key === Key.ARROW_UP && handleUpDown) || (ev.key === Key.ARROW_RIGHT && handleLeftRight)) {
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef);
findSiblingElement(context.state.refs, idx - 1)?.current?.focus();
}
} }
break; break;
case Key.ARROW_DOWN: case Key.ARROW_DOWN:
case Key.ARROW_LEFT: case Key.ARROW_RIGHT:
if ((ev.key === Key.ARROW_DOWN && handleUpDown) || (ev.key === Key.ARROW_LEFT && handleLeftRight)) { if ((ev.key === Key.ARROW_DOWN && handleUpDown) ||
(ev.key === Key.ARROW_RIGHT && handleLeftRight)
) {
handled = true; handled = true;
if (context.state.refs.length > 0) { if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef); const idx = context.state.refs.indexOf(context.state.activeRef);
findSiblingElement(context.state.refs, idx + 1, true)?.current?.focus(); focusRef = findSiblingElement(context.state.refs, idx + 1);
}
}
break;
case Key.ARROW_UP:
case Key.ARROW_LEFT:
if ((ev.key === Key.ARROW_UP && handleUpDown) || (ev.key === Key.ARROW_LEFT && handleLeftRight)) {
handled = true;
if (context.state.refs.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef);
focusRef = findSiblingElement(context.state.refs, idx - 1, true);
} }
} }
break; break;
@ -242,7 +247,18 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
ev.preventDefault(); ev.preventDefault();
ev.stopPropagation(); ev.stopPropagation();
} }
}, [context.state, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight]);
if (focusRef) {
focusRef.current?.focus();
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
dispatch({
type: Type.SetFocus,
payload: {
ref: focusRef,
},
});
}
}, [context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight]);
return <RovingTabIndexContext.Provider value={context}> return <RovingTabIndexContext.Provider value={context}>
{ children({ onKeyDownHandler }) } { children({ onKeyDownHandler }) }
@ -283,7 +299,7 @@ export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref]
type: Type.SetFocus, type: Type.SetFocus,
payload: { ref }, payload: { ref },
}); });
}, [ref, context]); }, []); // eslint-disable-line react-hooks/exhaustive-deps
const isActive = context.state.activeRef === ref; const isActive = context.state.activeRef === ref;
return [onFocus, isActive, ref]; return [onFocus, isActive, ref];

View file

@ -52,7 +52,7 @@ const Toolbar: React.FC<IProps> = ({ children, ...props }) => {
} }
}; };
return <RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}> return <RovingTabIndexProvider handleHomeEnd handleLeftRight onKeyDown={onKeyDown}>
{ ({ onKeyDownHandler }) => <div {...props} onKeyDown={onKeyDownHandler} role="toolbar"> { ({ onKeyDownHandler }) => <div {...props} onKeyDown={onKeyDownHandler} role="toolbar">
{ children } { children }
</div> } </div> }

View file

@ -31,6 +31,7 @@ import TextWithTooltip from "../elements/TextWithTooltip";
import DMRoomMap from "../../../utils/DMRoomMap"; import DMRoomMap from "../../../utils/DMRoomMap";
import { replaceableComponent } from "../../../utils/replaceableComponent"; import { replaceableComponent } from "../../../utils/replaceableComponent";
import { IOOBData } from "../../../stores/ThreepidInviteStore"; import { IOOBData } from "../../../stores/ThreepidInviteStore";
import TooltipTarget from "../elements/TooltipTarget";
interface IProps { interface IProps {
room: Room; room: Room;
@ -39,6 +40,7 @@ interface IProps {
forceCount?: boolean; forceCount?: boolean;
oobData?: IOOBData; oobData?: IOOBData;
viewAvatarOnClick?: boolean; viewAvatarOnClick?: boolean;
tooltipProps?: Omit<React.ComponentProps<typeof TooltipTarget>, "label" | "tooltipClassName" | "className">;
} }
interface IState { interface IState {
@ -189,6 +191,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
if (this.state.icon !== Icon.None) { if (this.state.icon !== Icon.None) {
icon = <TextWithTooltip icon = <TextWithTooltip
tooltip={tooltipText(this.state.icon)} tooltip={tooltipText(this.state.icon)}
tooltipProps={this.props.tooltipProps}
class={`mx_DecoratedRoomAvatar_icon mx_DecoratedRoomAvatar_icon_${this.state.icon.toLowerCase()}`} class={`mx_DecoratedRoomAvatar_icon mx_DecoratedRoomAvatar_icon_${this.state.icon.toLowerCase()}`}
/>; />;
} }

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useCallback, useEffect, useState } from "react"; import React, { RefObject, useCallback, useEffect } from "react";
import { MatrixEvent } from "matrix-js-sdk/src"; import { MatrixEvent } from "matrix-js-sdk/src";
import { ButtonEvent } from "../elements/AccessibleButton"; import { ButtonEvent } from "../elements/AccessibleButton";
@ -22,11 +22,12 @@ import dis from '../../../dispatcher/dispatcher';
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { copyPlaintext } from "../../../utils/strings"; import { copyPlaintext } from "../../../utils/strings";
import { ChevronFace, ContextMenuTooltipButton } from "../../structures/ContextMenu"; import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu"; import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
interface IProps { interface IProps {
mxEvent: MatrixEvent; mxEvent: MatrixEvent;
@ -34,6 +35,13 @@ interface IProps {
onMenuToggle?: (open: boolean) => void; onMenuToggle?: (open: boolean) => void;
} }
interface IExtendedProps extends IProps {
// Props for making the button into a roving one
tabIndex?: number;
inputRef?: RefObject<HTMLElement>;
onFocus?(): void;
}
const contextMenuBelow = (elementRect: DOMRect) => { const contextMenuBelow = (elementRect: DOMRect) => {
// align the context menu's icons with the icon which opened the context menu // align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.pageXOffset + elementRect.width; const left = elementRect.left + window.pageXOffset + elementRect.width;
@ -42,11 +50,27 @@ const contextMenuBelow = (elementRect: DOMRect) => {
return { left, top, chevronFace }; return { left, top, chevronFace };
}; };
const ThreadListContextMenu: React.FC<IProps> = ({ mxEvent, permalinkCreator, onMenuToggle }) => { export const RovingThreadListContextMenu: React.FC<IProps> = (props) => {
const [optionsPosition, setOptionsPosition] = useState(null); const [onFocus, isActive, ref] = useRovingTabIndex();
const closeThreadOptions = useCallback(() => {
setOptionsPosition(null); return <ThreadListContextMenu
}, []); {...props}
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
inputRef={ref}
/>;
};
const ThreadListContextMenu: React.FC<IExtendedProps> = ({
mxEvent,
permalinkCreator,
onMenuToggle,
onFocus,
inputRef,
...props
}) => {
const [menuDisplayed, _ref, openMenu, closeThreadOptions] = useContextMenu();
const button = inputRef ?? _ref; // prefer the ref we receive via props in case we are being controlled
const viewInRoom = useCallback((evt: ButtonEvent): void => { const viewInRoom = useCallback((evt: ButtonEvent): void => {
evt.preventDefault(); evt.preventDefault();
@ -68,37 +92,31 @@ const ThreadListContextMenu: React.FC<IProps> = ({ mxEvent, permalinkCreator, on
closeThreadOptions(); closeThreadOptions();
}, [mxEvent, closeThreadOptions, permalinkCreator]); }, [mxEvent, closeThreadOptions, permalinkCreator]);
const toggleOptionsMenu = useCallback((ev: ButtonEvent): void => {
if (!!optionsPosition) {
closeThreadOptions();
} else {
const position = ev.currentTarget.getBoundingClientRect();
setOptionsPosition(position);
}
}, [closeThreadOptions, optionsPosition]);
useEffect(() => { useEffect(() => {
if (onMenuToggle) { if (onMenuToggle) {
onMenuToggle(!!optionsPosition); onMenuToggle(menuDisplayed);
} }
}, [optionsPosition, onMenuToggle]); onFocus?.();
}, [menuDisplayed, onMenuToggle, onFocus]);
const isMainSplitTimelineShown = !WidgetLayoutStore.instance.hasMaximisedWidget( const isMainSplitTimelineShown = !WidgetLayoutStore.instance.hasMaximisedWidget(
MatrixClientPeg.get().getRoom(mxEvent.getRoomId()), MatrixClientPeg.get().getRoom(mxEvent.getRoomId()),
); );
return <React.Fragment> return <React.Fragment>
<ContextMenuTooltipButton <ContextMenuTooltipButton
{...props}
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton" className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
onClick={toggleOptionsMenu} onClick={openMenu}
title={_t("Thread options")} title={_t("Thread options")}
isExpanded={!!optionsPosition} isExpanded={menuDisplayed}
inputRef={button}
/> />
{ !!optionsPosition && (<IconizedContextMenu { menuDisplayed && (<IconizedContextMenu
onFinished={closeThreadOptions} onFinished={closeThreadOptions}
className="mx_RoomTile_contextMenu" className="mx_RoomTile_contextMenu"
compact compact
rightAligned rightAligned
{...contextMenuBelow(optionsPosition)} {...contextMenuBelow(button.current.getBoundingClientRect())}
> >
<IconizedContextMenuOptionList> <IconizedContextMenuOptionList>
{ isMainSplitTimelineShown && { isMainSplitTimelineShown &&

View file

@ -24,7 +24,7 @@ interface IProps {
class?: string; class?: string;
tooltipClass?: string; tooltipClass?: string;
tooltip: React.ReactNode; tooltip: React.ReactNode;
tooltipProps?: {}; tooltipProps?: Omit<React.ComponentProps<typeof TooltipTarget>, "label" | "tooltipClassName" | "className">;
onClick?: (ev?: React.MouseEvent) => void; onClick?: (ev?: React.MouseEvent) => void;
} }

View file

@ -91,7 +91,13 @@ const OptionsButton: React.FC<IOptionsButtonProps> = ({
<ContextMenuTooltipButton <ContextMenuTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton" className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
title={_t("Options")} title={_t("Options")}
onClick={openMenu} onClick={() => {
openMenu();
// when the context menu is opened directly, e.g. via mouse click, the onFocus handler which tracks
// the element that is currently focused is skipped. So we want to call onFocus manually to keep the
// position in the page even when someone is clicking around.
onFocus();
}}
isExpanded={menuDisplayed} isExpanded={menuDisplayed}
inputRef={ref} inputRef={ref}
onFocus={onFocus} onFocus={onFocus}
@ -127,7 +133,13 @@ const ReactButton: React.FC<IReactButtonProps> = ({ mxEvent, reactions, onFocusC
<ContextMenuTooltipButton <ContextMenuTooltipButton
className="mx_MessageActionBar_maskButton mx_MessageActionBar_reactButton" className="mx_MessageActionBar_maskButton mx_MessageActionBar_reactButton"
title={_t("React")} title={_t("React")}
onClick={openMenu} onClick={() => {
openMenu();
// when the context menu is opened directly, e.g. via mouse click, the onFocus handler which tracks
// the element that is currently focused is skipped. So we want to call onFocus manually to keep the
// position in the page even when someone is clicking around.
onFocus();
}}
isExpanded={menuDisplayed} isExpanded={menuDisplayed}
inputRef={ref} inputRef={ref}
onFocus={onFocus} onFocus={onFocus}
@ -196,10 +208,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
}; };
private onFocusChange = (focused: boolean): void => { private onFocusChange = (focused: boolean): void => {
if (!this.props.onFocusChange) { this.props.onFocusChange?.(focused);
return;
}
this.props.onFocusChange(focused);
}; };
private onReplyClick = (ev: React.MouseEvent): void => { private onReplyClick = (ev: React.MouseEvent): void => {

View file

@ -67,7 +67,7 @@ import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import Toolbar from '../../../accessibility/Toolbar'; import Toolbar from '../../../accessibility/Toolbar';
import { POLL_START_EVENT_TYPE } from '../../../polls/consts'; import { POLL_START_EVENT_TYPE } from '../../../polls/consts';
import { RovingAccessibleTooltipButton } from '../../../accessibility/roving/RovingAccessibleTooltipButton'; import { RovingAccessibleTooltipButton } from '../../../accessibility/roving/RovingAccessibleTooltipButton';
import ThreadListContextMenu from '../context_menus/ThreadListContextMenu'; import { RovingThreadListContextMenu } from '../context_menus/ThreadListContextMenu';
import { ThreadNotificationState } from '../../../stores/notifications/ThreadNotificationState'; import { ThreadNotificationState } from '../../../stores/notifications/ThreadNotificationState';
import { RoomNotificationStateStore } from '../../../stores/notifications/RoomNotificationStateStore'; import { RoomNotificationStateStore } from '../../../stores/notifications/RoomNotificationStateStore';
import { NotificationStateEvents } from '../../../stores/notifications/NotificationState'; import { NotificationStateEvents } from '../../../stores/notifications/NotificationState';
@ -1432,7 +1432,7 @@ export default class EventTile extends React.Component<IProps, IState> {
onClick={() => dispatchShowThreadEvent(this.props.mxEvent)} onClick={() => dispatchShowThreadEvent(this.props.mxEvent)}
key="thread" key="thread"
/> />
<ThreadListContextMenu <RovingThreadListContextMenu
mxEvent={this.props.mxEvent} mxEvent={this.props.mxEvent}
permalinkCreator={this.props.permalinkCreator} permalinkCreator={this.props.permalinkCreator}
onMenuToggle={this.onActionBarFocusChange} onMenuToggle={this.onActionBarFocusChange}

View file

@ -566,13 +566,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
if (typeof name !== 'string') name = ''; if (typeof name !== 'string') name = '';
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
const roomAvatar = <DecoratedRoomAvatar
room={this.props.room}
avatarSize={32}
displayBadge={this.props.isMinimized}
oobData={({ avatarUrl: roomProfile.avatarMxc })}
/>;
let badge: React.ReactNode; let badge: React.ReactNode;
if (!this.props.isMinimized && this.notificationState) { if (!this.props.isMinimized && this.notificationState) {
// aria-hidden because we summarise the unread count/highlight status in a manual aria-label below // aria-hidden because we summarise the unread count/highlight status in a manual aria-label below
@ -663,7 +656,13 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
aria-selected={this.state.selected} aria-selected={this.state.selected}
aria-describedby={ariaDescribedBy} aria-describedby={ariaDescribedBy}
> >
{ roomAvatar } <DecoratedRoomAvatar
room={this.props.room}
avatarSize={32}
displayBadge={this.props.isMinimized}
oobData={({ avatarUrl: roomProfile.avatarMxc })}
tooltipProps={{ tabIndex: isActive ? 0 : -1 }}
/>
{ nameContainer } { nameContainer }
{ badge } { badge }
{ this.renderGeneralMenu() } { this.renderGeneralMenu() }