From 7367c73a37a795f1baf3e8be0f33da51ca97dbd7 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 7 Jan 2020 10:50:02 +0000 Subject: [PATCH 01/10] Searchbox Enter is to clear, tabbing to clear button doesn't work, remove it Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/structures/SearchBox.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/structures/SearchBox.js b/src/components/structures/SearchBox.js index 9090152de8..6bf7c754f0 100644 --- a/src/components/structures/SearchBox.js +++ b/src/components/structures/SearchBox.js @@ -133,9 +133,11 @@ module.exports = 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 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 02/10] 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 +}; + From dedf1eab315347cd85ea45ed3d6a85d60f542104 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Wed, 15 Jan 2020 11:37:14 +0000 Subject: [PATCH 03/10] Iterate to get rid of the magic group and just provide a generic functional render wrapper Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/structures/RoomSubList.js | 149 +++++++++--------- .../views/groups/GroupInviteTile.js | 67 ++++---- src/components/views/rooms/RoomTile.js | 69 ++++---- src/contexts/RovingTabIndexContext.js | 81 +++++----- 4 files changed, 184 insertions(+), 182 deletions(-) diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js index 915a952e79..98e69f6edb 100644 --- a/src/components/structures/RoomSubList.js +++ b/src/components/structures/RoomSubList.js @@ -31,7 +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"; +import {RovingTabIndexWrapper} from "../../contexts/RovingTabIndexContext"; // turn this on for drop & drag console debugging galore const debug = false; @@ -264,45 +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; @@ -318,19 +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) { @@ -342,26 +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/views/groups/GroupInviteTile.js b/src/components/views/groups/GroupInviteTile.js index e7ccbdf40b..70baeb1e78 100644 --- a/src/components/views/groups/GroupInviteTile.js +++ b/src/components/views/groups/GroupInviteTile.js @@ -26,7 +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"; +import {RovingTabIndexWrapper} from "../../../contexts/RovingTabIndexContext"; // XXX this class copies a lot from RoomTile.js export default createReactClass({ @@ -138,18 +138,6 @@ export default createReactClass({ }); const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!'; - const badge = ( - - { badgeContent } - - ); let tooltip; if (this.props.collapsed && this.state.hover) { @@ -173,27 +161,40 @@ export default createReactClass({ ); } - return - -
- { av } -
-
- { label } - { badge } -
- { tooltip } -
+ return + + {({onFocus, isActive, ref}) => + +
+ { av } +
+
+ { label } + + { badgeContent } + +
+ { tooltip } +
+ } +
{ contextMenu } -
; + ; }, }); diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index 6358564042..001baf0b96 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -32,7 +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"; +import {RovingTabIndexWrapper} from "../../../contexts/RovingTabIndexContext"; module.exports = createReactClass({ displayName: 'RoomTile', @@ -433,37 +433,42 @@ module.exports = 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/src/contexts/RovingTabIndexContext.js b/src/contexts/RovingTabIndexContext.js index a571bd2eae..f5001d28cc 100644 --- a/src/contexts/RovingTabIndexContext.js +++ b/src/contexts/RovingTabIndexContext.js @@ -25,11 +25,9 @@ import React, { 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: { @@ -119,15 +117,42 @@ export const RovingTabIndexContextWrapper = ({children}) => { const context = useMemo(() => ({state, dispatch}), [state]); - return - {children} - ; + const onKeyDown = useCallback((ev) => { + if (state.refs.length <= 0) return; + + let handled = true; + switch (ev.key) { + case Key.HOME: + setImmediate(() => state.refs[0].current.focus()); + break; + case Key.END: + state.refs[state.refs.length - 1].current.focus(); + break; + default: + handled = false; + } + + if (handled) { + ev.preventDefault(); + ev.stopPropagation(); + } + }, [state]); + + return
+ + {children} + +
; }; -export const useRovingTabIndex = () => { - const ref = useRef(null); +export const useRovingTabIndex = (inputRef) => { + let ref = useRef(null); const context = useContext(RovingTabIndexContext); + if (inputRef) { + ref = inputRef; + } + // setup/teardown // add ref to the context useLayoutEffect(() => { @@ -149,45 +174,13 @@ export const useRovingTabIndex = () => { payload: {ref}, }); }, [ref, context]); - const isActive = context.state.activeRef === ref || context.state.activeRef === ANY; + + const isActive = context.state.activeRef === ref; 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 +export const RovingTabIndexWrapper = ({children, inputRef}) => { + const [onFocus, isActive, ref] = useRovingTabIndex(inputRef); + return children({onFocus, isActive, ref}); }; From 2b37fe76242fe1ae328d435abb8d82ca454ec13d Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 16 Jan 2020 00:40:08 +0000 Subject: [PATCH 04/10] do some renaming Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/rooms/RoomList.js | 6 +++--- src/contexts/RovingTabIndexContext.js | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 45ff940c22..4441b4d539 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -39,7 +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 {RovingTabIndexContextWrapper} from "../../../contexts/RovingTabIndexContext"; +import {RovingTabIndexContextProvider} from "../../../contexts/RovingTabIndexContext"; const HIDE_CONFERENCE_CHANS = true; const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/; @@ -788,9 +788,9 @@ export default createReactClass({ onMouseMove={this.onMouseMove} onMouseLeave={this.onMouseLeave} > - + { subListComponents } - +
); }, diff --git a/src/contexts/RovingTabIndexContext.js b/src/contexts/RovingTabIndexContext.js index f5001d28cc..182a7f5504 100644 --- a/src/contexts/RovingTabIndexContext.js +++ b/src/contexts/RovingTabIndexContext.js @@ -109,7 +109,7 @@ const reducer = (state, action) => { } }; -export const RovingTabIndexContextWrapper = ({children}) => { +export const RovingTabIndexContextProvider = ({children}) => { const [state, dispatch] = useReducer(reducer, { activeRef: null, refs: [], From 8c1fdf4cabfb0984f5d34d4656bc3e48d345d7d8 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 16 Jan 2020 01:25:44 +0000 Subject: [PATCH 05/10] tidy up Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/contexts/RovingTabIndexContext.js | 78 ++++++++++++++++++--------- 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/src/contexts/RovingTabIndexContext.js b/src/contexts/RovingTabIndexContext.js index 182a7f5504..2e8439d2a4 100644 --- a/src/contexts/RovingTabIndexContext.js +++ b/src/contexts/RovingTabIndexContext.js @@ -1,20 +1,18 @@ /* - * - * 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. - * / - */ +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, @@ -27,12 +25,25 @@ import React, { } from "react"; 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: [], + refs: [], // list of refs in DOM order }, dispatch: () => {}, }); @@ -49,6 +60,7 @@ 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, @@ -60,6 +72,7 @@ const reducer = (state, action) => { 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; }); @@ -68,6 +81,7 @@ const reducer = (state, action) => { newIndex = state.refs.length; // append to the end } + // update the refs list return { ...state, refs: [ @@ -78,13 +92,16 @@ const reducer = (state, action) => { }; } case types.UNREGISTER: { - const refs = state.refs.filter(r => r !== action.payload.ref); // keep all other refs + // 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 + 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, @@ -93,12 +110,14 @@ const reducer = (state, action) => { }; } + // update the refs list return { ...state, refs, }; } case types.SET_FOCUS: { + // update active ref return { ...state, activeRef: action.payload.ref, @@ -115,17 +134,18 @@ export const RovingTabIndexContextProvider = ({children}) => { refs: [], }); - const context = useMemo(() => ({state, dispatch}), [state]); - const onKeyDown = useCallback((ev) => { + // check if we actually have any items if (state.refs.length <= 0) return; let handled = true; switch (ev.key) { case Key.HOME: + // move focus to first item setImmediate(() => state.refs[0].current.focus()); break; case Key.END: + // move focus to last item state.refs[state.refs.length - 1].current.focus(); break; default: @@ -138,6 +158,9 @@ export const RovingTabIndexContextProvider = ({children}) => { } }, [state]); + const context = useMemo(() => ({state, dispatch}), [state]); + + // wrap in a div with key-down handling for HOME/END keys return
{children} @@ -145,21 +168,27 @@ export const RovingTabIndexContextProvider = ({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) => { - let ref = useRef(null); const context = useContext(RovingTabIndexContext); + let ref = useRef(null); if (inputRef) { + // if we are given a ref, use it instead of ours ref = inputRef; } - // setup/teardown - // add ref to the context + // setup (after refs) useLayoutEffect(() => { context.dispatch({ type: types.REGISTER, payload: {ref}, }); + // teardown return () => { context.dispatch({ type: types.UNREGISTER, @@ -179,6 +208,7 @@ export const useRovingTabIndex = (inputRef) => { 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}); From 781db63fa639e286622542bc7cba4e29c3c17e5f Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 16 Jan 2020 01:35:42 +0000 Subject: [PATCH 06/10] split out home/end handling into a helper as not all roving-tab-index widgets want it Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/components/views/rooms/RoomList.js | 6 ++++-- src/contexts/RovingTabIndexContext.js | 26 ++++++++++++++++---------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 4441b4d539..21cd1ed719 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -39,7 +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 {RovingTabIndexContextProvider} from "../../../contexts/RovingTabIndexContext"; +import {RovingTabIndexContextProvider, RovingTabIndexHomeEndHelper} from "../../../contexts/RovingTabIndexContext"; const HIDE_CONFERENCE_CHANS = true; const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/; @@ -789,7 +789,9 @@ export default createReactClass({ onMouseLeave={this.onMouseLeave} > - { subListComponents } + + { subListComponents } + ); diff --git a/src/contexts/RovingTabIndexContext.js b/src/contexts/RovingTabIndexContext.js index 2e8439d2a4..55ac19e40f 100644 --- a/src/contexts/RovingTabIndexContext.js +++ b/src/contexts/RovingTabIndexContext.js @@ -134,19 +134,30 @@ export const RovingTabIndexContextProvider = ({children}) => { refs: [], }); + const context = useMemo(() => ({state, dispatch}), [state]); + + return + {children} + ; +}; + +// Helper to handle Home/End to jump to first/last roving-tab-index for widgets such as treeview +export const RovingTabIndexHomeEndHelper = ({children}) => { + const context = useContext(RovingTabIndexContext); + const onKeyDown = useCallback((ev) => { // check if we actually have any items - if (state.refs.length <= 0) return; + if (context.state.refs.length <= 0) return; let handled = true; switch (ev.key) { case Key.HOME: // move focus to first item - setImmediate(() => state.refs[0].current.focus()); + setImmediate(() => context.state.refs[0].current.focus()); break; case Key.END: // move focus to last item - state.refs[state.refs.length - 1].current.focus(); + context.state.refs[context.state.refs.length - 1].current.focus(); break; default: handled = false; @@ -156,15 +167,10 @@ export const RovingTabIndexContextProvider = ({children}) => { ev.preventDefault(); ev.stopPropagation(); } - }, [state]); + }, [context.state]); - const context = useMemo(() => ({state, dispatch}), [state]); - - // wrap in a div with key-down handling for HOME/END keys return
- - {children} - + { children }
; }; From 2230b7732a703215c49a356697a7acd9cd969383 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 16 Jan 2020 01:45:16 +0000 Subject: [PATCH 07/10] rearrange Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- .../RovingTabIndex.js} | 2 +- src/components/structures/RoomSubList.js | 2 +- src/components/views/groups/GroupInviteTile.js | 2 +- src/components/views/rooms/RoomList.js | 6 +++--- src/components/views/rooms/RoomTile.js | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) rename src/{contexts/RovingTabIndexContext.js => accessibility/RovingTabIndex.js} (99%) diff --git a/src/contexts/RovingTabIndexContext.js b/src/accessibility/RovingTabIndex.js similarity index 99% rename from src/contexts/RovingTabIndexContext.js rename to src/accessibility/RovingTabIndex.js index 55ac19e40f..ad2051f1f1 100644 --- a/src/contexts/RovingTabIndexContext.js +++ b/src/accessibility/RovingTabIndex.js @@ -128,7 +128,7 @@ const reducer = (state, action) => { } }; -export const RovingTabIndexContextProvider = ({children}) => { +export const RovingTabIndexProvider = ({children}) => { const [state, dispatch] = useReducer(reducer, { activeRef: null, refs: [], diff --git a/src/components/structures/RoomSubList.js b/src/components/structures/RoomSubList.js index 775b6b69ce..2d41abf902 100644 --- a/src/components/structures/RoomSubList.js +++ b/src/components/structures/RoomSubList.js @@ -31,7 +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 "../../contexts/RovingTabIndexContext"; +import {RovingTabIndexWrapper} from "../../accessibility/RovingTabIndex"; // turn this on for drop & drag console debugging galore const debug = false; diff --git a/src/components/views/groups/GroupInviteTile.js b/src/components/views/groups/GroupInviteTile.js index 488c1e20cf..3b15c6ff41 100644 --- a/src/components/views/groups/GroupInviteTile.js +++ b/src/components/views/groups/GroupInviteTile.js @@ -26,7 +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 "../../../contexts/RovingTabIndexContext"; +import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex"; // XXX this class copies a lot from RoomTile.js export default createReactClass({ diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index 21cd1ed719..a137a36c60 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -39,7 +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 {RovingTabIndexContextProvider, RovingTabIndexHomeEndHelper} from "../../../contexts/RovingTabIndexContext"; +import {RovingTabIndexProvider, RovingTabIndexHomeEndHelper} from "../../../accessibility/RovingTabIndex"; const HIDE_CONFERENCE_CHANS = true; const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/; @@ -788,11 +788,11 @@ export default createReactClass({ onMouseMove={this.onMouseMove} onMouseLeave={this.onMouseLeave} > - + { subListComponents } - + ); }, diff --git a/src/components/views/rooms/RoomTile.js b/src/components/views/rooms/RoomTile.js index 8701c3d287..3b13001225 100644 --- a/src/components/views/rooms/RoomTile.js +++ b/src/components/views/rooms/RoomTile.js @@ -32,7 +32,7 @@ import ActiveRoomObserver from '../../../ActiveRoomObserver'; import RoomViewStore from '../../../stores/RoomViewStore'; import SettingsStore from "../../../settings/SettingsStore"; import {_t} from "../../../languageHandler"; -import {RovingTabIndexWrapper} from "../../../contexts/RovingTabIndexContext"; +import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex"; export default createReactClass({ displayName: 'RoomTile', From 4504d9b790c1fdbaa2ab2545cf9c7de2a601f6c8 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 16 Jan 2020 03:15:52 +0000 Subject: [PATCH 08/10] add tests Signed-off-by: Michael Telatynski <7t3chguy@gmail.com> --- src/accessibility/RovingTabIndex.js | 2 +- test/accessibility/RovingTabIndex-test.js | 117 ++++++++++++++++++++++ 2 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 test/accessibility/RovingTabIndex-test.js diff --git a/src/accessibility/RovingTabIndex.js b/src/accessibility/RovingTabIndex.js index ad2051f1f1..85aa133aa4 100644 --- a/src/accessibility/RovingTabIndex.js +++ b/src/accessibility/RovingTabIndex.js @@ -153,7 +153,7 @@ export const RovingTabIndexHomeEndHelper = ({children}) => { switch (ev.key) { case Key.HOME: // move focus to first item - setImmediate(() => context.state.refs[0].current.focus()); + context.state.refs[0].current.focus(); break; case Key.END: // move focus to last item 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]); + }); +}); + + From 0bcfe5819fd033040429aba0941d450b5477e0ee Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 20 Jan 2020 20:31:36 +0000 Subject: [PATCH 09/10] Integrate handleHomeEnd --- src/accessibility/RovingTabIndex.js | 18 +++++++++++++++--- src/components/views/rooms/RoomList.js | 8 +++----- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/src/accessibility/RovingTabIndex.js b/src/accessibility/RovingTabIndex.js index 85aa133aa4..2445a47e35 100644 --- a/src/accessibility/RovingTabIndex.js +++ b/src/accessibility/RovingTabIndex.js @@ -23,6 +23,7 @@ import React, { useRef, useReducer, } from "react"; +import PropTypes from "prop-types"; import {Key} from "../Keyboard"; /** @@ -128,7 +129,7 @@ const reducer = (state, action) => { } }; -export const RovingTabIndexProvider = ({children}) => { +export const RovingTabIndexProvider = ({children, handleHomeEnd}) => { const [state, dispatch] = useReducer(reducer, { activeRef: null, refs: [], @@ -136,13 +137,24 @@ export const RovingTabIndexProvider = ({children}) => { const context = useMemo(() => ({state, dispatch}), [state]); + if (handleHomeEnd) { + return + + { children } + + + } + return - {children} + { 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 RovingTabIndexHomeEndHelper = ({children}) => { +export const HomeEndHelper = ({children}) => { const context = useContext(RovingTabIndexContext); const onKeyDown = useCallback((ev) => { diff --git a/src/components/views/rooms/RoomList.js b/src/components/views/rooms/RoomList.js index a137a36c60..bd563b2f28 100644 --- a/src/components/views/rooms/RoomList.js +++ b/src/components/views/rooms/RoomList.js @@ -39,7 +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, RovingTabIndexHomeEndHelper} from "../../../accessibility/RovingTabIndex"; +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))$/; @@ -788,10 +788,8 @@ export default createReactClass({ onMouseMove={this.onMouseMove} onMouseLeave={this.onMouseLeave} > - - - { subListComponents } - + + { subListComponents } ); From be6a3821215b699d683cd7a628e583bdaa68d792 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Mon, 20 Jan 2020 20:46:12 +0000 Subject: [PATCH 10/10] delint --- src/accessibility/RovingTabIndex.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/accessibility/RovingTabIndex.js b/src/accessibility/RovingTabIndex.js index 2445a47e35..8924815f23 100644 --- a/src/accessibility/RovingTabIndex.js +++ b/src/accessibility/RovingTabIndex.js @@ -142,7 +142,7 @@ export const RovingTabIndexProvider = ({children, handleHomeEnd}) => { { children } - + ; } return