diff --git a/res/css/views/rooms/_RoomTile.scss b/res/css/views/rooms/_RoomTile.scss index cb1137bb2f..db2c09f6f1 100644 --- a/res/css/views/rooms/_RoomTile.scss +++ b/res/css/views/rooms/_RoomTile.scss @@ -142,10 +142,11 @@ limitations under the License. } } -// toggle menuButton and badge on hover/menu displayed +// toggle menuButton and badge on menu displayed .mx_RoomTile_menuDisplayed, // or on keyboard focus of room tile -.mx_RoomTile.focus-visible:focus-within, +.mx_LeftPanel_container:not(.collapsed) .mx_RoomTile:focus-within, +// or on pointer hover .mx_LeftPanel_container:not(.collapsed) .mx_RoomTile:hover { .mx_RoomTile_menuButton { display: block; diff --git a/src/accessibility/RovingTabIndex.js b/src/accessibility/RovingTabIndex.js new file mode 100644 index 0000000000..8924815f23 --- /dev/null +++ b/src/accessibility/RovingTabIndex.js @@ -0,0 +1,234 @@ +/* +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, { + createContext, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, + useReducer, +} from "react"; +import PropTypes from "prop-types"; +import {Key} from "../Keyboard"; + +/** + * Module to simplify implementing the Roving TabIndex accessibility technique + * + * Wrap the Widget in an RovingTabIndexContextProvider + * and then for all buttons make use of useRovingTabIndex or RovingTabIndexWrapper. + * The code will keep track of which tabIndex was most recently focused and expose that information as `isActive` which + * can then be used to only set the tabIndex to 0 as expected by the roving tabindex technique. + * When the active button gets unmounted the closest button will be chosen as expected. + * Initially the first button to mount will be given active state. + * + * https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex + */ + +const DOCUMENT_POSITION_PRECEDING = 2; + +const RovingTabIndexContext = createContext({ + state: { + activeRef: null, + refs: [], // list of refs in DOM order + }, + dispatch: () => {}, +}); +RovingTabIndexContext.displayName = "RovingTabIndexContext"; + +// TODO use a TypeScript type here +const types = { + REGISTER: "REGISTER", + UNREGISTER: "UNREGISTER", + SET_FOCUS: "SET_FOCUS", +}; + +const reducer = (state, action) => { + switch (action.type) { + case types.REGISTER: { + if (state.refs.length === 0) { + // Our list of refs was empty, set activeRef to this first item + return { + ...state, + activeRef: action.payload.ref, + refs: [action.payload.ref], + }; + } + + if (state.refs.includes(action.payload.ref)) { + return state; // already in refs, this should not happen + } + + // find the index of the first ref which is not preceding this one in DOM order + let newIndex = state.refs.findIndex(ref => { + return ref.current.compareDocumentPosition(action.payload.ref.current) & DOCUMENT_POSITION_PRECEDING; + }); + + if (newIndex < 0) { + newIndex = state.refs.length; // append to the end + } + + // update the refs list + return { + ...state, + refs: [ + ...state.refs.slice(0, newIndex), + action.payload.ref, + ...state.refs.slice(newIndex), + ], + }; + } + case types.UNREGISTER: { + // filter out the ref which we are removing + const refs = state.refs.filter(r => r !== action.payload.ref); + + if (refs.length === state.refs.length) { + return state; // already removed, this should not happen + } + + if (state.activeRef === action.payload.ref) { + // we just removed the active ref, need to replace it + // pick the ref which is now in the index the old ref was in + const oldIndex = state.refs.findIndex(r => r === action.payload.ref); + return { + ...state, + activeRef: oldIndex >= refs.length ? refs[refs.length - 1] : refs[oldIndex], + refs, + }; + } + + // update the refs list + return { + ...state, + refs, + }; + } + case types.SET_FOCUS: { + // update active ref + return { + ...state, + activeRef: action.payload.ref, + }; + } + default: + return state; + } +}; + +export const RovingTabIndexProvider = ({children, handleHomeEnd}) => { + const [state, dispatch] = useReducer(reducer, { + activeRef: null, + refs: [], + }); + + const context = useMemo(() => ({state, dispatch}), [state]); + + if (handleHomeEnd) { + return + + { children } + + ; + } + + return + { children } + ; +}; +RovingTabIndexProvider.propTypes = { + handleHomeEnd: PropTypes.bool, +}; + +// Helper to handle Home/End to jump to first/last roving-tab-index for widgets such as treeview +export const HomeEndHelper = ({children}) => { + const context = useContext(RovingTabIndexContext); + + const onKeyDown = useCallback((ev) => { + // check if we actually have any items + if (context.state.refs.length <= 0) return; + + let handled = true; + switch (ev.key) { + case Key.HOME: + // move focus to first item + context.state.refs[0].current.focus(); + break; + case Key.END: + // move focus to last item + context.state.refs[context.state.refs.length - 1].current.focus(); + break; + default: + handled = false; + } + + if (handled) { + ev.preventDefault(); + ev.stopPropagation(); + } + }, [context.state]); + + return
+ { children } +
; +}; + +// Hook to register a roving tab index +// inputRef parameter specifies the ref to use +// 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) => { + const context = useContext(RovingTabIndexContext); + let ref = useRef(null); + + if (inputRef) { + // if we are given a ref, use it instead of ours + ref = inputRef; + } + + // setup (after refs) + useLayoutEffect(() => { + context.dispatch({ + type: types.REGISTER, + payload: {ref}, + }); + // teardown + return () => { + context.dispatch({ + type: types.UNREGISTER, + payload: {ref}, + }); + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + const onFocus = useCallback(() => { + context.dispatch({ + type: types.SET_FOCUS, + payload: {ref}, + }); + }, [ref, context]); + + const isActive = context.state.activeRef === ref; + return [onFocus, isActive, ref]; +}; + +// Wrapper to allow use of useRovingTabIndex outside of React Functional Components. +export const RovingTabIndexWrapper = ({children, inputRef}) => { + const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); + return children({onFocus, isActive, ref}); +}; + diff --git a/src/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js index 8a7d10e5b5..f5e0bca67e 100644 --- a/src/components/structures/LeftPanel.js +++ b/src/components/structures/LeftPanel.js @@ -129,9 +129,6 @@ const LeftPanel = createReactClass({ if (!this.focusedElement) return; switch (ev.key) { - case Key.TAB: - this._onMoveFocus(ev, ev.shiftKey); - break; case Key.ARROW_UP: this._onMoveFocus(ev, true, true); break; diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js index c2a644287d..2d41abf902 100644 --- a/src/components/structures/RoomSubList.js +++ b/src/components/structures/RoomSubList.js @@ -31,6 +31,7 @@ import PropTypes from 'prop-types'; import RoomTile from "../views/rooms/RoomTile"; import LazyRenderList from "../views/elements/LazyRenderList"; import {_t} from "../../languageHandler"; +import {RovingTabIndexWrapper} from "../../accessibility/RovingTabIndex"; // turn this on for drop & drag console debugging galore const debug = false; @@ -263,33 +264,6 @@ export default class RoomSubList extends React.PureComponent { const subListNotifCount = subListNotifications.count; const subListNotifHighlight = subListNotifications.highlight; - let badge; - if (!this.props.collapsed) { - const badgeClasses = classNames({ - 'mx_RoomSubList_badge': true, - 'mx_RoomSubList_badgeHighlight': subListNotifHighlight, - }); - // Wrap the contents in a div and apply styles to the child div so that the browser default outline works - if (subListNotifCount > 0) { - badge = ( - -
- { FormattingUtils.formatCount(subListNotifCount) } -
-
- ); - } else if (this.props.isInvite && this.props.list.length) { - // no notifications but highlight anyway because this is an invite badge - badge = ( - -
- { this.props.list.length } -
-
- ); - } - } - // When collapsed, allow a long hover on the header to show user // the full tag name and room count let title; @@ -305,17 +279,6 @@ export default class RoomSubList extends React.PureComponent { ; } - let addRoomButton; - if (this.props.onAddRoom) { - addRoomButton = ( - - ); - } - const len = this.props.list.length + this.props.extraTiles.length; let chevron; if (len) { @@ -327,25 +290,81 @@ export default class RoomSubList extends React.PureComponent { chevron = (
); } - return ( -
- - { chevron } - {this.props.label} - { incomingCall } - - { badge } - { addRoomButton } -
- ); + return + {({onFocus, isActive, ref}) => { + const tabIndex = isActive ? 0 : -1; + + let badge; + if (!this.props.collapsed) { + const badgeClasses = classNames({ + 'mx_RoomSubList_badge': true, + 'mx_RoomSubList_badgeHighlight': subListNotifHighlight, + }); + // Wrap the contents in a div and apply styles to the child div so that the browser default outline works + if (subListNotifCount > 0) { + badge = ( + +
+ { FormattingUtils.formatCount(subListNotifCount) } +
+
+ ); + } else if (this.props.isInvite && this.props.list.length) { + // no notifications but highlight anyway because this is an invite badge + badge = ( + +
+ { this.props.list.length } +
+
+ ); + } + } + + let addRoomButton; + if (this.props.onAddRoom) { + addRoomButton = ( + + ); + } + + return ( +
+ + { chevron } + {this.props.label} + { incomingCall } + + { badge } + { addRoomButton } +
+ ); + } } +
; } checkOverflow = () => { diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js index 3be2f65dc5..873efb64c2 100644 --- a/src/components/structures/SearchBox.js +++ b/src/components/structures/SearchBox.js @@ -133,9 +133,11 @@ export default createReactClass({ return null; } const clearButton = (!this.state.blurred || this.state.searchTerm) ? - ( {this._clearSearch("button"); } }> + ( {this._clearSearch("button"); } }> ) : undefined; // show a shorter placeholder when blurred, if requested diff --git a/src/components/views/groups/GroupInviteTile.js b/src/components/views/groups/GroupInviteTile.js index 808cf17d55..3b15c6ff41 100644 --- a/src/components/views/groups/GroupInviteTile.js +++ b/src/components/views/groups/GroupInviteTile.js @@ -26,6 +26,7 @@ import classNames from 'classnames'; import {MatrixClientPeg} from "../../../MatrixClientPeg"; import {ContextMenu, ContextMenuButton, toRightOf} from "../../structures/ContextMenu"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; +import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex"; // XXX this class copies a lot from RoomTile.js export default createReactClass({ @@ -137,16 +138,6 @@ export default createReactClass({ }); const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!'; - const badge = ( - - { badgeContent } - - ); let tooltip; if (this.props.collapsed && this.state.hover) { @@ -171,22 +162,37 @@ export default createReactClass({ } return - -
- { av } -
-
- { label } - { badge } -
- { tooltip } -
+ + {({onFocus, isActive, ref}) => + +
+ { av } +
+
+ { label } + + { badgeContent } + +
+ { tooltip } +
+ } +
{ contextMenu }
; diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 5c12b027a4..bd563b2f28 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -39,6 +39,7 @@ import * as sdk from "../../../index"; import * as Receipt from "../../../utils/Receipt"; import {Resizer} from '../../../resizer'; import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2'; +import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex"; const HIDE_CONFERENCE_CHANS = true; const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/; @@ -787,7 +788,9 @@ export default createReactClass({ onMouseMove={this.onMouseMove} onMouseLeave={this.onMouseLeave} > - { subListComponents } + + { subListComponents } +
); }, diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index 8c67be3b2a..3b13001225 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -32,6 +32,7 @@ import ActiveRoomObserver from '../../../ActiveRoomObserver'; import RoomViewStore from '../../../stores/RoomViewStore'; import SettingsStore from "../../../settings/SettingsStore"; import {_t} from "../../../languageHandler"; +import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex"; export default createReactClass({ displayName: 'RoomTile', @@ -432,36 +433,42 @@ export default createReactClass({ } return - -
-
- - { dmIndicator } -
-
- { privateIcon } -
-
- { label } - { subtextLabel } -
- { dmOnline } - { contextMenuButton } - { badge } -
- { /* { incomingCallBox } */ } - { tooltip } -
+ + {({onFocus, isActive, ref}) => + +
+
+ + { dmIndicator } +
+
+ { privateIcon } +
+
+ { label } + { subtextLabel } +
+ { dmOnline } + { contextMenuButton } + { badge } +
+ { /* { incomingCallBox } */ } + { tooltip } +
+ } +
{ contextMenu }
; diff --git a/test/accessibility/RovingTabIndex-test.js b/test/accessibility/RovingTabIndex-test.js new file mode 100644 index 0000000000..2b55d1420c --- /dev/null +++ b/test/accessibility/RovingTabIndex-test.js @@ -0,0 +1,117 @@ +/* +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 Adapter from "enzyme-adapter-react-16"; +import { configure, mount } from "enzyme"; + +import { + RovingTabIndexProvider, + RovingTabIndexWrapper, + useRovingTabIndex, +} from "../../src/accessibility/RovingTabIndex"; + +configure({ adapter: new Adapter() }); + +const Button = (props) => { + const [onFocus, isActive, ref] = useRovingTabIndex(); + return ; +const button2 = ; +const button3 = ; +const button4 = ; + +describe("RovingTabIndex", () => { + it("RovingTabIndexProvider renders children as expected", () => { + const wrapper = mount( +
Test
+
); + expect(wrapper.text()).toBe("Test"); + expect(wrapper.html()).toBe('
Test
'); + }); + + it("RovingTabIndexProvider works as expected with useRovingTabIndex", () => { + const wrapper = mount( + { button1 } + { button2 } + { button3 } + ); + + // should begin with 0th being active + checkTabIndexes(wrapper.find("button"), [0, -1, -1]); + + // focus on 2nd button and test it is the only active one + wrapper.find("button").at(2).simulate("focus"); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, -1, 0]); + + // focus on 1st button and test it is the only active one + wrapper.find("button").at(1).simulate("focus"); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, 0, -1]); + + // check that the active button does not change even on an explicit blur event + wrapper.find("button").at(1).simulate("blur"); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, 0, -1]); + + // update the children, it should remain on the same button + wrapper.setProps({ + children: [button1, button4, button2, button3], + }); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, -1, 0, -1]); + + // update the children, remove the active button, it should move to the next one + wrapper.setProps({ + children: [button1, button4, button3], + }); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, -1, 0]); + }); + + it("RovingTabIndexProvider works as expected with RovingTabIndexWrapper", () => { + const wrapper = mount( + { button1 } + { button2 } + + {({onFocus, isActive, ref}) => + + } + + ); + + // should begin with 0th being active + checkTabIndexes(wrapper.find("button"), [0, -1, -1]); + + // focus on 2nd button and test it is the only active one + wrapper.find("button").at(2).simulate("focus"); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, -1, 0]); + }); +}); + +