From 5252cf4c454c8790ea7f5d12eb7e8ac426aa57a7 Mon Sep 17 00:00:00 2001
From: Michael Telatynski <7t3chguy@gmail.com>
Date: Wed, 15 Jan 2020 02:44:22 +0000
Subject: [PATCH] Implement roving tab index context based magic thing and demo
on LeftPanel
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
---
res/css/views/rooms/_RoomTile.scss | 5 +-
src/components/structures/LeftPanel.js | 3 -
src/components/structures/RoomSubList.js | 36 +++-
.../views/groups/GroupInviteTile.js | 17 +-
src/components/views/rooms/RoomList.js | 5 +-
src/components/views/rooms/RoomTile.js | 8 +-
src/contexts/RovingTabIndexContext.js | 193 ++++++++++++++++++
7 files changed, 242 insertions(+), 25 deletions(-)
create mode 100644 src/contexts/RovingTabIndexContext.js
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/components/structures/LeftPanel.js b/src/components/structures/LeftPanel.js
index 796840a625..3444225d06 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 123ed7c4e1..915a952e79 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 {RovingTabIndex, RovingTabIndexGroup} from "../../contexts/RovingTabIndexContext";
// turn this on for drop & drag console debugging galore
const debug = false;
@@ -272,20 +273,32 @@ export default class RoomSubList extends React.PureComponent {
// 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 }
-
+
);
}
}
@@ -308,7 +321,9 @@ export default class RoomSubList extends React.PureComponent {
let addRoomButton;
if (this.props.onAddRoom) {
addRoomButton = (
- );
}
- return (
+ return
-
{this.props.label}
{ incomingCall }
-
+
{ badge }
{ addRoomButton }
- );
+ ;
}
checkOverflow = () => {
diff --git a/src/components/views/groups/GroupInviteTile.js b/src/components/views/groups/GroupInviteTile.js
index c0d0d9eafe..e7ccbdf40b 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 {RovingTabIndex, RovingTabIndexGroup} from "../../../contexts/RovingTabIndexContext";
// XXX this class copies a lot from RoomTile.js
export default createReactClass({
@@ -138,14 +139,16 @@ export default createReactClass({
const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!';
const badge = (
-
{ badgeContent }
-
+
);
let tooltip;
@@ -170,8 +173,10 @@ export default createReactClass({
);
}
- return
-
+
{ tooltip }
-
+
{ contextMenu }
- ;
+ ;
},
});
diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js
index 35a5ca9e66..277aedb65e 100644
--- a/src/components/views/rooms/RoomList.js
+++ b/src/components/views/rooms/RoomList.js
@@ -41,6 +41,7 @@ import ResizeHandle from '../elements/ResizeHandle';
import {Resizer} from '../../../resizer';
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
+import {RovingTabIndexContextWrapper} from "../../../contexts/RovingTabIndexContext";
const HIDE_CONFERENCE_CHANS = true;
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
const HOVER_MOVE_TIMEOUT = 1000;
@@ -788,7 +789,9 @@ module.exports = 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 4dbcc7ca03..6358564042 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 {RovingTabIndex} from "../../../contexts/RovingTabIndexContext";
module.exports = createReactClass({
displayName: 'RoomTile',
@@ -432,8 +433,9 @@ module.exports = createReactClass({
}
return
-
{ /* { incomingCallBox } */ }
{ tooltip }
-
+
{ contextMenu }
;
diff --git a/src/contexts/RovingTabIndexContext.js b/src/contexts/RovingTabIndexContext.js
new file mode 100644
index 0000000000..a571bd2eae
--- /dev/null
+++ b/src/contexts/RovingTabIndexContext.js
@@ -0,0 +1,193 @@
+/*
+ *
+ * Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
+ *
+ * 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";
+
+const DOCUMENT_POSITION_PRECEDING = 2;
+const ANY = Symbol();
+
+const RovingTabIndexContext = createContext({
+ state: {
+ activeRef: null,
+ refs: [],
+ },
+ 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) {
+ 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
+ }
+
+ 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
+ }
+
+ return {
+ ...state,
+ refs: [
+ ...state.refs.slice(0, newIndex),
+ action.payload.ref,
+ ...state.refs.slice(newIndex),
+ ],
+ };
+ }
+ case types.UNREGISTER: {
+ const refs = state.refs.filter(r => r !== action.payload.ref); // keep all other refs
+
+ 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
+ const oldIndex = state.refs.findIndex(r => r === action.payload.ref);
+ return {
+ ...state,
+ activeRef: oldIndex >= refs.length ? refs[refs.length - 1] : refs[oldIndex],
+ refs,
+ };
+ }
+
+ return {
+ ...state,
+ refs,
+ };
+ }
+ case types.SET_FOCUS: {
+ return {
+ ...state,
+ activeRef: action.payload.ref,
+ };
+ }
+ default:
+ return state;
+ }
+};
+
+export const RovingTabIndexContextWrapper = ({children}) => {
+ const [state, dispatch] = useReducer(reducer, {
+ activeRef: null,
+ refs: [],
+ });
+
+ const context = useMemo(() => ({state, dispatch}), [state]);
+
+ return
+ {children}
+ ;
+};
+
+export const useRovingTabIndex = () => {
+ const ref = useRef(null);
+ const context = useContext(RovingTabIndexContext);
+
+ // setup/teardown
+ // add ref to the context
+ useLayoutEffect(() => {
+ context.dispatch({
+ type: types.REGISTER,
+ payload: {ref},
+ });
+ 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 || context.state.activeRef === ANY;
+ return [onFocus, isActive, ref];
+};
+
+export const RovingTabIndexGroup = ({children}) => {
+ const [onFocus, isActive, ref] = useRovingTabIndex();
+
+ // fake reducer dispatch to catch SET_FOCUS calls and pass them to parent as a focus of the group
+ const dispatch = useCallback(({type}) => {
+ if (type === types.SET_FOCUS) {
+ onFocus();
+ }
+ }, [onFocus]);
+
+ const context = useMemo(() => ({
+ state: {activeRef: isActive ? ANY : undefined},
+ dispatch,
+ }), [isActive, dispatch]);
+
+ return
+
+ {children}
+
+
;
+};
+
+// Wraps a given element to attach it to the roving context, props onFocus and tabIndex overridden
+export const RovingTabIndex = ({component: E, useInputRef, ...props}) => {
+ const [onFocus, isActive, ref] = useRovingTabIndex();
+ const refProps = {};
+ if (useInputRef) {
+ refProps.inputRef = ref;
+ } else {
+ refProps.ref = ref;
+ }
+ return ;
+};
+RovingTabIndex.propTypes = {
+ component: PropTypes.elementType.isRequired,
+ useInputRef: PropTypes.bool, // whether to pass inputRef instead of ref like for AccessibleButton
+};
+