Merge pull request #3844 from matrix-org/t3chguy/roving
Implement Roving Tab Index and Room List as TreeView
This commit is contained in:
commit
587ff6ad75
9 changed files with 508 additions and 122 deletions
|
@ -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,
|
.mx_RoomTile_menuDisplayed,
|
||||||
// or on keyboard focus of room tile
|
// 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_LeftPanel_container:not(.collapsed) .mx_RoomTile:hover {
|
||||||
.mx_RoomTile_menuButton {
|
.mx_RoomTile_menuButton {
|
||||||
display: block;
|
display: block;
|
||||||
|
|
234
src/accessibility/RovingTabIndex.js
Normal file
234
src/accessibility/RovingTabIndex.js
Normal 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});
|
||||||
|
};
|
||||||
|
|
|
@ -129,9 +129,6 @@ const LeftPanel = createReactClass({
|
||||||
if (!this.focusedElement) return;
|
if (!this.focusedElement) return;
|
||||||
|
|
||||||
switch (ev.key) {
|
switch (ev.key) {
|
||||||
case Key.TAB:
|
|
||||||
this._onMoveFocus(ev, ev.shiftKey);
|
|
||||||
break;
|
|
||||||
case Key.ARROW_UP:
|
case Key.ARROW_UP:
|
||||||
this._onMoveFocus(ev, true, true);
|
this._onMoveFocus(ev, true, true);
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -31,6 +31,7 @@ import PropTypes from 'prop-types';
|
||||||
import RoomTile from "../views/rooms/RoomTile";
|
import RoomTile from "../views/rooms/RoomTile";
|
||||||
import LazyRenderList from "../views/elements/LazyRenderList";
|
import LazyRenderList from "../views/elements/LazyRenderList";
|
||||||
import {_t} from "../../languageHandler";
|
import {_t} from "../../languageHandler";
|
||||||
|
import {RovingTabIndexWrapper} from "../../accessibility/RovingTabIndex";
|
||||||
|
|
||||||
// turn this on for drop & drag console debugging galore
|
// turn this on for drop & drag console debugging galore
|
||||||
const debug = false;
|
const debug = false;
|
||||||
|
@ -263,33 +264,6 @@ export default class RoomSubList extends React.PureComponent {
|
||||||
const subListNotifCount = subListNotifications.count;
|
const subListNotifCount = subListNotifications.count;
|
||||||
const subListNotifHighlight = subListNotifications.highlight;
|
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
|
// When collapsed, allow a long hover on the header to show user
|
||||||
// the full tag name and room count
|
// the full tag name and room count
|
||||||
let title;
|
let title;
|
||||||
|
@ -305,17 +279,6 @@ export default class RoomSubList extends React.PureComponent {
|
||||||
<IncomingCallBox className="mx_RoomSubList_incomingCall" incomingCall={this.props.incomingCall} />;
|
<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;
|
const len = this.props.list.length + this.props.extraTiles.length;
|
||||||
let chevron;
|
let chevron;
|
||||||
if (len) {
|
if (len) {
|
||||||
|
@ -327,25 +290,81 @@ export default class RoomSubList extends React.PureComponent {
|
||||||
chevron = (<div className={chevronClasses} />);
|
chevron = (<div className={chevronClasses} />);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <RovingTabIndexWrapper inputRef={this._headerButton}>
|
||||||
<div className="mx_RoomSubList_labelContainer" title={title} ref={this._header} onKeyDown={this.onHeaderKeyDown}>
|
{({onFocus, isActive, ref}) => {
|
||||||
<AccessibleButton
|
const tabIndex = isActive ? 0 : -1;
|
||||||
onClick={this.onClick}
|
|
||||||
className="mx_RoomSubList_label"
|
let badge;
|
||||||
tabIndex={0}
|
if (!this.props.collapsed) {
|
||||||
aria-expanded={!isCollapsed}
|
const badgeClasses = classNames({
|
||||||
inputRef={this._headerButton}
|
'mx_RoomSubList_badge': true,
|
||||||
role="treeitem"
|
'mx_RoomSubList_badgeHighlight': subListNotifHighlight,
|
||||||
aria-level="1"
|
});
|
||||||
>
|
// Wrap the contents in a div and apply styles to the child div so that the browser default outline works
|
||||||
{ chevron }
|
if (subListNotifCount > 0) {
|
||||||
<span>{this.props.label}</span>
|
badge = (
|
||||||
{ incomingCall }
|
<AccessibleButton
|
||||||
</AccessibleButton>
|
tabIndex={tabIndex}
|
||||||
{ badge }
|
className={badgeClasses}
|
||||||
{ addRoomButton }
|
onClick={this._onNotifBadgeClick}
|
||||||
</div>
|
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 = () => {
|
checkOverflow = () => {
|
||||||
|
|
|
@ -133,9 +133,11 @@ export default createReactClass({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const clearButton = (!this.state.blurred || this.state.searchTerm) ?
|
const clearButton = (!this.state.blurred || this.state.searchTerm) ?
|
||||||
(<AccessibleButton key="button"
|
(<AccessibleButton
|
||||||
className="mx_SearchBox_closeButton"
|
key="button"
|
||||||
onClick={ () => {this._clearSearch("button"); } }>
|
tabIndex={-1}
|
||||||
|
className="mx_SearchBox_closeButton"
|
||||||
|
onClick={ () => {this._clearSearch("button"); } }>
|
||||||
</AccessibleButton>) : undefined;
|
</AccessibleButton>) : undefined;
|
||||||
|
|
||||||
// show a shorter placeholder when blurred, if requested
|
// show a shorter placeholder when blurred, if requested
|
||||||
|
|
|
@ -26,6 +26,7 @@ import classNames from 'classnames';
|
||||||
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
import {MatrixClientPeg} from "../../../MatrixClientPeg";
|
||||||
import {ContextMenu, ContextMenuButton, toRightOf} from "../../structures/ContextMenu";
|
import {ContextMenu, ContextMenuButton, toRightOf} from "../../structures/ContextMenu";
|
||||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||||
|
import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex";
|
||||||
|
|
||||||
// XXX this class copies a lot from RoomTile.js
|
// XXX this class copies a lot from RoomTile.js
|
||||||
export default createReactClass({
|
export default createReactClass({
|
||||||
|
@ -137,16 +138,6 @@ export default createReactClass({
|
||||||
});
|
});
|
||||||
|
|
||||||
const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!';
|
const badgeContent = badgeEllipsis ? '\u00B7\u00B7\u00B7' : '!';
|
||||||
const badge = (
|
|
||||||
<ContextMenuButton
|
|
||||||
className={badgeClasses}
|
|
||||||
onClick={this.onContextMenuButtonClick}
|
|
||||||
label={_t("Options")}
|
|
||||||
isExpanded={isMenuDisplayed}
|
|
||||||
>
|
|
||||||
{ badgeContent }
|
|
||||||
</ContextMenuButton>
|
|
||||||
);
|
|
||||||
|
|
||||||
let tooltip;
|
let tooltip;
|
||||||
if (this.props.collapsed && this.state.hover) {
|
if (this.props.collapsed && this.state.hover) {
|
||||||
|
@ -171,22 +162,37 @@ export default createReactClass({
|
||||||
}
|
}
|
||||||
|
|
||||||
return <React.Fragment>
|
return <React.Fragment>
|
||||||
<AccessibleButton
|
<RovingTabIndexWrapper>
|
||||||
className={classes}
|
{({onFocus, isActive, ref}) =>
|
||||||
onClick={this.onClick}
|
<AccessibleButton
|
||||||
onMouseEnter={this.onMouseEnter}
|
onFocus={onFocus}
|
||||||
onMouseLeave={this.onMouseLeave}
|
tabIndex={isActive ? 0 : -1}
|
||||||
onContextMenu={this.onContextMenu}
|
inputRef={ref}
|
||||||
>
|
className={classes}
|
||||||
<div className="mx_RoomTile_avatar">
|
onClick={this.onClick}
|
||||||
{ av }
|
onMouseEnter={this.onMouseEnter}
|
||||||
</div>
|
onMouseLeave={this.onMouseLeave}
|
||||||
<div className="mx_RoomTile_nameContainer">
|
onContextMenu={this.onContextMenu}
|
||||||
{ label }
|
>
|
||||||
{ badge }
|
<div className="mx_RoomTile_avatar">
|
||||||
</div>
|
{ av }
|
||||||
{ tooltip }
|
</div>
|
||||||
</AccessibleButton>
|
<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 }
|
{ contextMenu }
|
||||||
</React.Fragment>;
|
</React.Fragment>;
|
||||||
|
|
|
@ -39,6 +39,7 @@ import * as sdk from "../../../index";
|
||||||
import * as Receipt from "../../../utils/Receipt";
|
import * as Receipt from "../../../utils/Receipt";
|
||||||
import {Resizer} from '../../../resizer';
|
import {Resizer} from '../../../resizer';
|
||||||
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
|
import {Layout, Distributor} from '../../../resizer/distributors/roomsublist2';
|
||||||
|
import {RovingTabIndexProvider} from "../../../accessibility/RovingTabIndex";
|
||||||
|
|
||||||
const HIDE_CONFERENCE_CHANS = true;
|
const HIDE_CONFERENCE_CHANS = true;
|
||||||
const STANDARD_TAGS_REGEX = /^(m\.(favourite|lowpriority|server_notice)|im\.vector\.fake\.(invite|recent|direct|archived))$/;
|
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}
|
onMouseMove={this.onMouseMove}
|
||||||
onMouseLeave={this.onMouseLeave}
|
onMouseLeave={this.onMouseLeave}
|
||||||
>
|
>
|
||||||
{ subListComponents }
|
<RovingTabIndexProvider handleHomeEnd={true}>
|
||||||
|
{ subListComponents }
|
||||||
|
</RovingTabIndexProvider>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
@ -32,6 +32,7 @@ import ActiveRoomObserver from '../../../ActiveRoomObserver';
|
||||||
import RoomViewStore from '../../../stores/RoomViewStore';
|
import RoomViewStore from '../../../stores/RoomViewStore';
|
||||||
import SettingsStore from "../../../settings/SettingsStore";
|
import SettingsStore from "../../../settings/SettingsStore";
|
||||||
import {_t} from "../../../languageHandler";
|
import {_t} from "../../../languageHandler";
|
||||||
|
import {RovingTabIndexWrapper} from "../../../accessibility/RovingTabIndex";
|
||||||
|
|
||||||
export default createReactClass({
|
export default createReactClass({
|
||||||
displayName: 'RoomTile',
|
displayName: 'RoomTile',
|
||||||
|
@ -432,36 +433,42 @@ export default createReactClass({
|
||||||
}
|
}
|
||||||
|
|
||||||
return <React.Fragment>
|
return <React.Fragment>
|
||||||
<AccessibleButton
|
<RovingTabIndexWrapper>
|
||||||
tabIndex="0"
|
{({onFocus, isActive, ref}) =>
|
||||||
className={classes}
|
<AccessibleButton
|
||||||
onClick={this.onClick}
|
onFocus={onFocus}
|
||||||
onMouseEnter={this.onMouseEnter}
|
tabIndex={isActive ? 0 : -1}
|
||||||
onMouseLeave={this.onMouseLeave}
|
inputRef={ref}
|
||||||
onContextMenu={this.onContextMenu}
|
className={classes}
|
||||||
aria-label={ariaLabel}
|
onClick={this.onClick}
|
||||||
aria-selected={this.state.selected}
|
onMouseEnter={this.onMouseEnter}
|
||||||
role="treeitem"
|
onMouseLeave={this.onMouseLeave}
|
||||||
>
|
onContextMenu={this.onContextMenu}
|
||||||
<div className={avatarClasses}>
|
aria-label={ariaLabel}
|
||||||
<div className="mx_RoomTile_avatar_container">
|
aria-selected={this.state.selected}
|
||||||
<RoomAvatar room={this.props.room} width={24} height={24} />
|
role="treeitem"
|
||||||
{ dmIndicator }
|
>
|
||||||
</div>
|
<div className={avatarClasses}>
|
||||||
</div>
|
<div className="mx_RoomTile_avatar_container">
|
||||||
{ privateIcon }
|
<RoomAvatar room={this.props.room} width={24} height={24} />
|
||||||
<div className="mx_RoomTile_nameContainer">
|
{ dmIndicator }
|
||||||
<div className="mx_RoomTile_labelContainer">
|
</div>
|
||||||
{ label }
|
</div>
|
||||||
{ subtextLabel }
|
{ privateIcon }
|
||||||
</div>
|
<div className="mx_RoomTile_nameContainer">
|
||||||
{ dmOnline }
|
<div className="mx_RoomTile_labelContainer">
|
||||||
{ contextMenuButton }
|
{ label }
|
||||||
{ badge }
|
{ subtextLabel }
|
||||||
</div>
|
</div>
|
||||||
{ /* { incomingCallBox } */ }
|
{ dmOnline }
|
||||||
{ tooltip }
|
{ contextMenuButton }
|
||||||
</AccessibleButton>
|
{ badge }
|
||||||
|
</div>
|
||||||
|
{ /* { incomingCallBox } */ }
|
||||||
|
{ tooltip }
|
||||||
|
</AccessibleButton>
|
||||||
|
}
|
||||||
|
</RovingTabIndexWrapper>
|
||||||
|
|
||||||
{ contextMenu }
|
{ contextMenu }
|
||||||
</React.Fragment>;
|
</React.Fragment>;
|
||||||
|
|
117
test/accessibility/RovingTabIndex-test.js
Normal file
117
test/accessibility/RovingTabIndex-test.js
Normal 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]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue