Merge pull request #3844 from matrix-org/t3chguy/roving

Implement Roving Tab Index and Room List as TreeView
This commit is contained in:
Michael Telatynski 2020-01-21 17:36:30 +00:00 committed by GitHub
commit 587ff6ad75
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 508 additions and 122 deletions

View file

@ -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;

View file

@ -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 <RovingTabIndexContext.Provider value={context}>
<HomeEndHelper>
{ children }
</HomeEndHelper>
</RovingTabIndexContext.Provider>;
}
return <RovingTabIndexContext.Provider value={context}>
{ children }
</RovingTabIndexContext.Provider>;
};
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 <div onKeyDown={onKeyDown}>
{ children }
</div>;
};
// 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});
};

View file

@ -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;

View file

@ -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 = (
<AccessibleButton className={badgeClasses} onClick={this._onNotifBadgeClick} aria-label={_t("Jump to first unread room.")}>
<div>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>
</AccessibleButton>
);
} else if (this.props.isInvite && this.props.list.length) {
// no notifications but highlight anyway because this is an invite badge
badge = (
<AccessibleButton className={badgeClasses} onClick={this._onInviteBadgeClick} aria-label={_t("Jump to first invite.")}>
<div>
{ this.props.list.length }
</div>
</AccessibleButton>
);
}
}
// 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 {
<IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />;
}
let addRoomButton;
if (this.props.onAddRoom) {
addRoomButton = (
<AccessibleTooltipButton
onClick={this.onAddRoom}
className="mx_RoomSubList_addRoom"
title={this.props.addRoomLabel || _t("Add room")}
/>
);
}
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 = (<div className={chevronClasses} />);
}
return (
<div className="mx_RoomSubList_labelContainer" title={title} ref={this._header} onKeyDown={this.onHeaderKeyDown}>
<AccessibleButton
onClick={this.onClick}
className="mx_RoomSubList_label"
tabIndex={0}
aria-expanded={!isCollapsed}
inputRef={this._headerButton}
role="treeitem"
aria-level="1"
>
{ chevron }
<span>{this.props.label}</span>
{ incomingCall }
</AccessibleButton>
{ badge }
{ addRoomButton }
</div>
);
return <RovingTabIndexWrapper inputRef={this._headerButton}>
{({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 = (
<AccessibleButton
tabIndex={tabIndex}
className={badgeClasses}
onClick={this._onNotifBadgeClick}
aria-label={_t("Jump to first unread room.")}
>
<div>
{ FormattingUtils.formatCount(subListNotifCount) }
</div>
</AccessibleButton>
);
} else if (this.props.isInvite && this.props.list.length) {
// no notifications but highlight anyway because this is an invite badge
badge = (
<AccessibleButton
tabIndex={tabIndex}
className={badgeClasses}
onClick={this._onInviteBadgeClick}
aria-label={_t("Jump to first invite.")}
>
<div>
{ this.props.list.length }
</div>
</AccessibleButton>
);
}
}
let addRoomButton;
if (this.props.onAddRoom) {
addRoomButton = (
<AccessibleTooltipButton
tabIndex={tabIndex}
onClick={this.onAddRoom}
className="mx_RoomSubList_addRoom"
title={this.props.addRoomLabel || _t("Add room")}
/>
);
}
return (
<div className="mx_RoomSubList_labelContainer" title={title} ref={this._header} onKeyDown={this.onHeaderKeyDown}>
<AccessibleButton
onFocus={onFocus}
tabIndex={tabIndex}
inputRef={ref}
onClick={this.onClick}
className="mx_RoomSubList_label"
aria-expanded={!isCollapsed}
role="treeitem"
aria-level="1"
>
{ chevron }
<span>{this.props.label}</span>
{ incomingCall }
</AccessibleButton>
{ badge }
{ addRoomButton }
</div>
);
} }
</RovingTabIndexWrapper>;
}
checkOverflow = () => {

View file

@ -133,9 +133,11 @@ export default createReactClass({
return null;
}
const clearButton = (!this.state.blurred || this.state.searchTerm) ?
(<AccessibleButton key="button"
className="mx_SearchBox_closeButton"
onClick={ () => {this._clearSearch("button"); } }>
(<AccessibleButton
key="button"
tabIndex={-1}
className="mx_SearchBox_closeButton"
onClick={ () => {this._clearSearch("button"); } }>
</AccessibleButton>) : undefined;
// show a shorter placeholder when blurred, if requested

View file

@ -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 = (
<ContextMenuButton
className={badgeClasses}
onClick={this.onContextMenuButtonClick}
label={_t("Options")}
isExpanded={isMenuDisplayed}
>
{ badgeContent }
</ContextMenuButton>
);
let tooltip;
if (this.props.collapsed && this.state.hover) {
@ -171,22 +162,37 @@ export default createReactClass({
}
return <React.Fragment>
<AccessibleButton
className={classes}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
onContextMenu={this.onContextMenu}
>
<div className="mx_RoomTile_avatar">
{ av }
</div>
<div className="mx_RoomTile_nameContainer">
{ label }
{ badge }
</div>
{ tooltip }
</AccessibleButton>
<RovingTabIndexWrapper>
{({onFocus, isActive, ref}) =>
<AccessibleButton
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
inputRef={ref}
className={classes}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
onContextMenu={this.onContextMenu}
>
<div className="mx_RoomTile_avatar">
{ av }
</div>
<div className="mx_RoomTile_nameContainer">
{ label }
<ContextMenuButton
className={badgeClasses}
onClick={this.onContextMenuButtonClick}
label={_t("Options")}
isExpanded={isMenuDisplayed}
tabIndex={isActive ? 0 : -1}
>
{ badgeContent }
</ContextMenuButton>
</div>
{ tooltip }
</AccessibleButton>
}
</RovingTabIndexWrapper>
{ contextMenu }
</React.Fragment>;

View file

@ -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 }
<RovingTabIndexProvider handleHomeEnd={true}>
{ subListComponents }
</RovingTabIndexProvider>
</div>
);
},

View file

@ -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 <React.Fragment>
<AccessibleButton
tabIndex="0"
className={classes}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
onContextMenu={this.onContextMenu}
aria-label={ariaLabel}
aria-selected={this.state.selected}
role="treeitem"
>
<div className={avatarClasses}>
<div className="mx_RoomTile_avatar_container">
<RoomAvatar room={this.props.room} width={24} height={24} />
{ dmIndicator }
</div>
</div>
{ privateIcon }
<div className="mx_RoomTile_nameContainer">
<div className="mx_RoomTile_labelContainer">
{ label }
{ subtextLabel }
</div>
{ dmOnline }
{ contextMenuButton }
{ badge }
</div>
{ /* { incomingCallBox } */ }
{ tooltip }
</AccessibleButton>
<RovingTabIndexWrapper>
{({onFocus, isActive, ref}) =>
<AccessibleButton
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
inputRef={ref}
className={classes}
onClick={this.onClick}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
onContextMenu={this.onContextMenu}
aria-label={ariaLabel}
aria-selected={this.state.selected}
role="treeitem"
>
<div className={avatarClasses}>
<div className="mx_RoomTile_avatar_container">
<RoomAvatar room={this.props.room} width={24} height={24} />
{ dmIndicator }
</div>
</div>
{ privateIcon }
<div className="mx_RoomTile_nameContainer">
<div className="mx_RoomTile_labelContainer">
{ label }
{ subtextLabel }
</div>
{ dmOnline }
{ contextMenuButton }
{ badge }
</div>
{ /* { incomingCallBox } */ }
{ tooltip }
</AccessibleButton>
}
</RovingTabIndexWrapper>
{ contextMenu }
</React.Fragment>;

View file

@ -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 <button {...props} onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref} />;
};
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 = <Button key={1}>a</Button>;
const button2 = <Button key={2}>b</Button>;
const button3 = <Button key={3}>c</Button>;
const button4 = <Button key={4}>d</Button>;
describe("RovingTabIndex", () => {
it("RovingTabIndexProvider renders children as expected", () => {
const wrapper = mount(<RovingTabIndexProvider>
<div><span>Test</span></div>
</RovingTabIndexProvider>);
expect(wrapper.text()).toBe("Test");
expect(wrapper.html()).toBe('<div><span>Test</span></div>');
});
it("RovingTabIndexProvider works as expected with useRovingTabIndex", () => {
const wrapper = mount(<RovingTabIndexProvider>
{ button1 }
{ button2 }
{ button3 }
</RovingTabIndexProvider>);
// 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(<RovingTabIndexProvider>
{ button1 }
{ button2 }
<RovingTabIndexWrapper>
{({onFocus, isActive, ref}) =>
<button onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref}>.</button>
}
</RovingTabIndexWrapper>
</RovingTabIndexProvider>);
// 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]);
});
});