diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 388d67d9f3..3e52f9fe2a 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -47,7 +47,7 @@ const DOCUMENT_POSITION_PRECEDING = 2; type Ref = RefObject; -interface IState { +export interface IState { activeRef: Ref; refs: Ref[]; } @@ -156,7 +156,7 @@ interface IProps { children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent); }); - onKeyDown?(ev: React.KeyboardEvent); + onKeyDown?(ev: React.KeyboardEvent, state: IState); } export const RovingTabIndexProvider: React.FC = ({children, handleHomeEnd, onKeyDown}) => { @@ -193,7 +193,7 @@ export const RovingTabIndexProvider: React.FC = ({children, handleHomeEn ev.preventDefault(); ev.stopPropagation(); } else if (onKeyDown) { - return onKeyDown(ev); + return onKeyDown(ev, state); } }, [context.state, onKeyDown, handleHomeEnd]); diff --git a/src/accessibility/Toolbar.tsx b/src/accessibility/Toolbar.tsx new file mode 100644 index 0000000000..0e968461a8 --- /dev/null +++ b/src/accessibility/Toolbar.tsx @@ -0,0 +1,69 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import React from "react"; + +import {IState, RovingTabIndexProvider} from "./RovingTabIndex"; +import {Key} from "../Keyboard"; + +interface IProps extends Omit, "onKeyDown"> { +} + +// This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines. +// https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar +// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref` +const Toolbar: React.FC = ({children, ...props}) => { + const onKeyDown = (ev: React.KeyboardEvent, state: IState) => { + const target = ev.target as HTMLElement; + let handled = true; + + switch (ev.key) { + case Key.ARROW_UP: + case Key.ARROW_DOWN: + if (target.hasAttribute('aria-haspopup')) { + target.click(); + } + break; + + case Key.ARROW_LEFT: + case Key.ARROW_RIGHT: + if (state.refs.length > 0) { + const i = state.refs.findIndex(r => r === state.activeRef); + const delta = ev.key === Key.ARROW_RIGHT ? 1 : -1; + state.refs.slice((i + delta) % state.refs.length)[0].current.focus(); + } + break; + + // HOME and END are handled by RovingTabIndexProvider + + default: + handled = false; + } + + if (handled) { + ev.preventDefault(); + ev.stopPropagation(); + } + }; + + return + {({onKeyDownHandler}) =>
+ { children } +
} +
; +}; + +export default Toolbar; diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index cb1349da4b..62964c5799 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -233,6 +233,9 @@ export class ContextMenu extends React.PureComponent { switch (ev.key) { case Key.TAB: case Key.ESCAPE: + // close on left and right arrows too for when it is a context menu on a + case Key.ARROW_LEFT: + case Key.ARROW_RIGHT: this.props.onFinished(); break; case Key.ARROW_UP: diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index 95eb37b588..7959ad8a93 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -25,9 +25,12 @@ import dis from '../../../dispatcher/dispatcher'; import {aboveLeftOf, ContextMenu, ContextMenuButton, useContextMenu} from '../../structures/ContextMenu'; import { isContentActionable, canEditContent } from '../../../utils/EventUtils'; import RoomContext from "../../../contexts/RoomContext"; +import Toolbar from "../../../accessibility/Toolbar"; +import {RovingAccessibleButton, useRovingTabIndex} from "../../../accessibility/RovingTabIndex"; const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFocusChange}) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); + const [onFocus, isActive, ref] = useRovingTabIndex(button); useEffect(() => { onFocusChange(menuDisplayed); }, [onFocusChange, menuDisplayed]); @@ -57,7 +60,9 @@ const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFo label={_t("Options")} onClick={openMenu} isExpanded={menuDisplayed} - inputRef={button} + inputRef={ref} + onFocus={onFocus} + tabIndex={isActive ? 0 : -1} /> { contextMenu } @@ -66,6 +71,7 @@ const OptionsButton = ({mxEvent, getTile, getReplyThread, permalinkCreator, onFo const ReactButton = ({mxEvent, reactions, onFocusChange}) => { const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu(); + const [onFocus, isActive, ref] = useRovingTabIndex(button); useEffect(() => { onFocusChange(menuDisplayed); }, [onFocusChange, menuDisplayed]); @@ -85,7 +91,9 @@ const ReactButton = ({mxEvent, reactions, onFocusChange}) => { label={_t("React")} onClick={openMenu} isExpanded={menuDisplayed} - inputRef={button} + inputRef={ref} + onFocus={onFocus} + tabIndex={isActive ? 0 : -1} /> { contextMenu } @@ -148,8 +156,6 @@ export default class MessageActionBar extends React.PureComponent { }; render() { - const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - let reactButton; let replyButton; let editButton; @@ -161,7 +167,7 @@ export default class MessageActionBar extends React.PureComponent { ); } if (this.context.canReply) { - replyButton = + return {reactButton} {replyButton} {editButton} @@ -188,6 +194,6 @@ export default class MessageActionBar extends React.PureComponent { permalinkCreator={this.props.permalinkCreator} onFocusChange={this.onFocusChange} /> - ; + ; } }