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 +}; +