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 = (
-
-
-
- );
- } 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 (
-
- );
+ 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 = (
+
+
+
+ );
+ } else if (this.props.isInvite && this.props.list.length) {
+ // no notifications but highlight anyway because this is an invite badge
+ 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 checkTabIndexes = (buttons, expectations) => {
+ expect(buttons.length).toBe(expectations.length);
+ for (let i = 0; i < buttons.length; i++) {
+ expect(buttons.at(i).prop("tabIndex")).toBe(expectations[i]);
+ }
+};
+
+// give the buttons keys for the fibre reconciler to not treat them all as the same
+const button1 = ;
+const button2 = ;
+const button3 = ;
+const button4 = ;
+
+describe("RovingTabIndex", () => {
+ it("RovingTabIndexProvider renders children as expected", () => {
+ const wrapper = mount(
+
');
+ });
+
+ 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]);
+ });
+});
+
+