Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit a667677

Browse files
authored
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
1 parent 60286f6 commit a667677

File tree

8 files changed

+95
-50
lines changed

8 files changed

+95
-50
lines changed

src/accessibility/RovingTabIndex.tsx

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ export const reducer = (state: IState, action: IAction) => {
131131
}
132132

133133
case Type.SetFocus: {
134+
// if the ref doesn't change just return the same object reference to skip a re-render
135+
if (state.activeRef === action.payload.ref) return state;
134136
// update active ref
135137
state.activeRef = action.payload.ref;
136138
return { ...state };
@@ -194,6 +196,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
194196
}
195197

196198
let handled = false;
199+
let focusRef: RefObject<HTMLElement>;
197200
// Don't interfere with input default keydown behaviour
198201
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
199202
// check if we actually have any items
@@ -202,36 +205,38 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
202205
if (handleHomeEnd) {
203206
handled = true;
204207
// move focus to first (visible) item
205-
findSiblingElement(context.state.refs, 0)?.current?.focus();
208+
focusRef = findSiblingElement(context.state.refs, 0);
206209
}
207210
break;
208211

209212
case Key.END:
210213
if (handleHomeEnd) {
211214
handled = true;
212215
// move focus to last (visible) item
213-
findSiblingElement(context.state.refs, context.state.refs.length - 1, true)?.current?.focus();
216+
focusRef = findSiblingElement(context.state.refs, context.state.refs.length - 1, true);
214217
}
215218
break;
216219

217-
case Key.ARROW_UP:
220+
case Key.ARROW_DOWN:
218221
case Key.ARROW_RIGHT:
219-
if ((ev.key === Key.ARROW_UP && handleUpDown) || (ev.key === Key.ARROW_RIGHT && handleLeftRight)) {
222+
if ((ev.key === Key.ARROW_DOWN && handleUpDown) ||
223+
(ev.key === Key.ARROW_RIGHT && handleLeftRight)
224+
) {
220225
handled = true;
221226
if (context.state.refs.length > 0) {
222227
const idx = context.state.refs.indexOf(context.state.activeRef);
223-
findSiblingElement(context.state.refs, idx - 1)?.current?.focus();
228+
focusRef = findSiblingElement(context.state.refs, idx + 1);
224229
}
225230
}
226231
break;
227232

228-
case Key.ARROW_DOWN:
233+
case Key.ARROW_UP:
229234
case Key.ARROW_LEFT:
230-
if ((ev.key === Key.ARROW_DOWN && handleUpDown) || (ev.key === Key.ARROW_LEFT && handleLeftRight)) {
235+
if ((ev.key === Key.ARROW_UP && handleUpDown) || (ev.key === Key.ARROW_LEFT && handleLeftRight)) {
231236
handled = true;
232237
if (context.state.refs.length > 0) {
233238
const idx = context.state.refs.indexOf(context.state.activeRef);
234-
findSiblingElement(context.state.refs, idx + 1, true)?.current?.focus();
239+
focusRef = findSiblingElement(context.state.refs, idx - 1, true);
235240
}
236241
}
237242
break;
@@ -242,7 +247,18 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
242247
ev.preventDefault();
243248
ev.stopPropagation();
244249
}
245-
}, [context.state, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight]);
250+
251+
if (focusRef) {
252+
focusRef.current?.focus();
253+
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
254+
dispatch({
255+
type: Type.SetFocus,
256+
payload: {
257+
ref: focusRef,
258+
},
259+
});
260+
}
261+
}, [context, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight]);
246262

247263
return <RovingTabIndexContext.Provider value={context}>
248264
{ children({ onKeyDownHandler }) }
@@ -283,7 +299,7 @@ export const useRovingTabIndex = (inputRef?: Ref): [FocusHandler, boolean, Ref]
283299
type: Type.SetFocus,
284300
payload: { ref },
285301
});
286-
}, [ref, context]);
302+
}, []); // eslint-disable-line react-hooks/exhaustive-deps
287303

288304
const isActive = context.state.activeRef === ref;
289305
return [onFocus, isActive, ref];

src/accessibility/Toolbar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ const Toolbar: React.FC<IProps> = ({ children, ...props }) => {
5252
}
5353
};
5454

55-
return <RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
55+
return <RovingTabIndexProvider handleHomeEnd handleLeftRight onKeyDown={onKeyDown}>
5656
{ ({ onKeyDownHandler }) => <div {...props} onKeyDown={onKeyDownHandler} role="toolbar">
5757
{ children }
5858
</div> }

src/components/views/avatars/DecoratedRoomAvatar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import TextWithTooltip from "../elements/TextWithTooltip";
3131
import DMRoomMap from "../../../utils/DMRoomMap";
3232
import { replaceableComponent } from "../../../utils/replaceableComponent";
3333
import { IOOBData } from "../../../stores/ThreepidInviteStore";
34+
import TooltipTarget from "../elements/TooltipTarget";
3435

3536
interface IProps {
3637
room: Room;
@@ -39,6 +40,7 @@ interface IProps {
3940
forceCount?: boolean;
4041
oobData?: IOOBData;
4142
viewAvatarOnClick?: boolean;
43+
tooltipProps?: Omit<React.ComponentProps<typeof TooltipTarget>, "label" | "tooltipClassName" | "className">;
4244
}
4345

4446
interface IState {
@@ -189,6 +191,7 @@ export default class DecoratedRoomAvatar extends React.PureComponent<IProps, ISt
189191
if (this.state.icon !== Icon.None) {
190192
icon = <TextWithTooltip
191193
tooltip={tooltipText(this.state.icon)}
194+
tooltipProps={this.props.tooltipProps}
192195
class={`mx_DecoratedRoomAvatar_icon mx_DecoratedRoomAvatar_icon_${this.state.icon.toLowerCase()}`}
193196
/>;
194197
}

src/components/views/context_menus/ThreadListContextMenu.tsx

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,26 +14,34 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import React, { useCallback, useEffect, useState } from "react";
17+
import React, { RefObject, useCallback, useEffect } from "react";
1818
import { MatrixEvent } from "matrix-js-sdk/src";
1919

2020
import { ButtonEvent } from "../elements/AccessibleButton";
2121
import dis from '../../../dispatcher/dispatcher';
2222
import { Action } from "../../../dispatcher/actions";
2323
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
2424
import { copyPlaintext } from "../../../utils/strings";
25-
import { ChevronFace, ContextMenuTooltipButton } from "../../structures/ContextMenu";
25+
import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu";
2626
import { _t } from "../../../languageHandler";
2727
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
2828
import { WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
2929
import { MatrixClientPeg } from "../../../MatrixClientPeg";
30+
import { useRovingTabIndex } from "../../../accessibility/RovingTabIndex";
3031

3132
interface IProps {
3233
mxEvent: MatrixEvent;
3334
permalinkCreator: RoomPermalinkCreator;
3435
onMenuToggle?: (open: boolean) => void;
3536
}
3637

38+
interface IExtendedProps extends IProps {
39+
// Props for making the button into a roving one
40+
tabIndex?: number;
41+
inputRef?: RefObject<HTMLElement>;
42+
onFocus?(): void;
43+
}
44+
3745
const contextMenuBelow = (elementRect: DOMRect) => {
3846
// align the context menu's icons with the icon which opened the context menu
3947
const left = elementRect.left + window.pageXOffset + elementRect.width;
@@ -42,11 +50,27 @@ const contextMenuBelow = (elementRect: DOMRect) => {
4250
return { left, top, chevronFace };
4351
};
4452

45-
const ThreadListContextMenu: React.FC<IProps> = ({ mxEvent, permalinkCreator, onMenuToggle }) => {
46-
const [optionsPosition, setOptionsPosition] = useState(null);
47-
const closeThreadOptions = useCallback(() => {
48-
setOptionsPosition(null);
49-
}, []);
53+
export const RovingThreadListContextMenu: React.FC<IProps> = (props) => {
54+
const [onFocus, isActive, ref] = useRovingTabIndex();
55+
56+
return <ThreadListContextMenu
57+
{...props}
58+
onFocus={onFocus}
59+
tabIndex={isActive ? 0 : -1}
60+
inputRef={ref}
61+
/>;
62+
};
63+
64+
const ThreadListContextMenu: React.FC<IExtendedProps> = ({
65+
mxEvent,
66+
permalinkCreator,
67+
onMenuToggle,
68+
onFocus,
69+
inputRef,
70+
...props
71+
}) => {
72+
const [menuDisplayed, _ref, openMenu, closeThreadOptions] = useContextMenu();
73+
const button = inputRef ?? _ref; // prefer the ref we receive via props in case we are being controlled
5074

5175
const viewInRoom = useCallback((evt: ButtonEvent): void => {
5276
evt.preventDefault();
@@ -68,37 +92,31 @@ const ThreadListContextMenu: React.FC<IProps> = ({ mxEvent, permalinkCreator, on
6892
closeThreadOptions();
6993
}, [mxEvent, closeThreadOptions, permalinkCreator]);
7094

71-
const toggleOptionsMenu = useCallback((ev: ButtonEvent): void => {
72-
if (!!optionsPosition) {
73-
closeThreadOptions();
74-
} else {
75-
const position = ev.currentTarget.getBoundingClientRect();
76-
setOptionsPosition(position);
77-
}
78-
}, [closeThreadOptions, optionsPosition]);
79-
8095
useEffect(() => {
8196
if (onMenuToggle) {
82-
onMenuToggle(!!optionsPosition);
97+
onMenuToggle(menuDisplayed);
8398
}
84-
}, [optionsPosition, onMenuToggle]);
99+
onFocus?.();
100+
}, [menuDisplayed, onMenuToggle, onFocus]);
85101

86102
const isMainSplitTimelineShown = !WidgetLayoutStore.instance.hasMaximisedWidget(
87103
MatrixClientPeg.get().getRoom(mxEvent.getRoomId()),
88104
);
89105
return <React.Fragment>
90106
<ContextMenuTooltipButton
107+
{...props}
91108
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
92-
onClick={toggleOptionsMenu}
109+
onClick={openMenu}
93110
title={_t("Thread options")}
94-
isExpanded={!!optionsPosition}
111+
isExpanded={menuDisplayed}
112+
inputRef={button}
95113
/>
96-
{ !!optionsPosition && (<IconizedContextMenu
114+
{ menuDisplayed && (<IconizedContextMenu
97115
onFinished={closeThreadOptions}
98116
className="mx_RoomTile_contextMenu"
99117
compact
100118
rightAligned
101-
{...contextMenuBelow(optionsPosition)}
119+
{...contextMenuBelow(button.current.getBoundingClientRect())}
102120
>
103121
<IconizedContextMenuOptionList>
104122
{ isMainSplitTimelineShown &&

src/components/views/elements/TextWithTooltip.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ interface IProps {
2424
class?: string;
2525
tooltipClass?: string;
2626
tooltip: React.ReactNode;
27-
tooltipProps?: {};
27+
tooltipProps?: Omit<React.ComponentProps<typeof TooltipTarget>, "label" | "tooltipClassName" | "className">;
2828
onClick?: (ev?: React.MouseEvent) => void;
2929
}
3030

src/components/views/messages/MessageActionBar.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,13 @@ const OptionsButton: React.FC<IOptionsButtonProps> = ({
9191
<ContextMenuTooltipButton
9292
className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
9393
title={_t("Options")}
94-
onClick={openMenu}
94+
onClick={() => {
95+
openMenu();
96+
// when the context menu is opened directly, e.g. via mouse click, the onFocus handler which tracks
97+
// the element that is currently focused is skipped. So we want to call onFocus manually to keep the
98+
// position in the page even when someone is clicking around.
99+
onFocus();
100+
}}
95101
isExpanded={menuDisplayed}
96102
inputRef={ref}
97103
onFocus={onFocus}
@@ -127,7 +133,13 @@ const ReactButton: React.FC<IReactButtonProps> = ({ mxEvent, reactions, onFocusC
127133
<ContextMenuTooltipButton
128134
className="mx_MessageActionBar_maskButton mx_MessageActionBar_reactButton"
129135
title={_t("React")}
130-
onClick={openMenu}
136+
onClick={() => {
137+
openMenu();
138+
// when the context menu is opened directly, e.g. via mouse click, the onFocus handler which tracks
139+
// the element that is currently focused is skipped. So we want to call onFocus manually to keep the
140+
// position in the page even when someone is clicking around.
141+
onFocus();
142+
}}
131143
isExpanded={menuDisplayed}
132144
inputRef={ref}
133145
onFocus={onFocus}
@@ -196,10 +208,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
196208
};
197209

198210
private onFocusChange = (focused: boolean): void => {
199-
if (!this.props.onFocusChange) {
200-
return;
201-
}
202-
this.props.onFocusChange(focused);
211+
this.props.onFocusChange?.(focused);
203212
};
204213

205214
private onReplyClick = (ev: React.MouseEvent): void => {

src/components/views/rooms/EventTile.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ import { MediaEventHelper } from "../../../utils/MediaEventHelper";
6767
import Toolbar from '../../../accessibility/Toolbar';
6868
import { POLL_START_EVENT_TYPE } from '../../../polls/consts';
6969
import { RovingAccessibleTooltipButton } from '../../../accessibility/roving/RovingAccessibleTooltipButton';
70-
import ThreadListContextMenu from '../context_menus/ThreadListContextMenu';
70+
import { RovingThreadListContextMenu } from '../context_menus/ThreadListContextMenu';
7171
import { ThreadNotificationState } from '../../../stores/notifications/ThreadNotificationState';
7272
import { RoomNotificationStateStore } from '../../../stores/notifications/RoomNotificationStateStore';
7373
import { NotificationStateEvents } from '../../../stores/notifications/NotificationState';
@@ -1432,7 +1432,7 @@ export default class EventTile extends React.Component<IProps, IState> {
14321432
onClick={() => dispatchShowThreadEvent(this.props.mxEvent)}
14331433
key="thread"
14341434
/>
1435-
<ThreadListContextMenu
1435+
<RovingThreadListContextMenu
14361436
mxEvent={this.props.mxEvent}
14371437
permalinkCreator={this.props.permalinkCreator}
14381438
onMenuToggle={this.onActionBarFocusChange}

src/components/views/rooms/RoomTile.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -566,13 +566,6 @@ export default class RoomTile extends React.PureComponent<IProps, IState> {
566566
if (typeof name !== 'string') name = '';
567567
name = name.replace(":", ":\u200b"); // add a zero-width space to allow linewrapping after the colon
568568

569-
const roomAvatar = <DecoratedRoomAvatar
570-
room={this.props.room}
571-
avatarSize={32}
572-
displayBadge={this.props.isMinimized}
573-
oobData={({ avatarUrl: roomProfile.avatarMxc })}
574-
/>;
575-
576569
let badge: React.ReactNode;
577570
if (!this.props.isMinimized && this.notificationState) {
578571
// 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> {
663656
aria-selected={this.state.selected}
664657
aria-describedby={ariaDescribedBy}
665658
>
666-
{ roomAvatar }
659+
<DecoratedRoomAvatar
660+
room={this.props.room}
661+
avatarSize={32}
662+
displayBadge={this.props.isMinimized}
663+
oobData={({ avatarUrl: roomProfile.avatarMxc })}
664+
tooltipProps={{ tabIndex: isActive ? 0 : -1 }}
665+
/>
667666
{ nameContainer }
668667
{ badge }
669668
{ this.renderGeneralMenu() }

0 commit comments

Comments
 (0)