mirror of
https://github.com/element-hq/element-web
synced 2024-11-27 03:36:07 +03:00
Improve RovingTabIndex & Room List filtering performance (#6987)
This commit is contained in:
parent
39e61c4fa3
commit
04c06b6aa8
9 changed files with 471 additions and 327 deletions
|
@ -24,6 +24,7 @@ import React, {
|
|||
useReducer,
|
||||
Reducer,
|
||||
Dispatch,
|
||||
RefObject,
|
||||
} from "react";
|
||||
|
||||
import { Key } from "../Keyboard";
|
||||
|
@ -63,7 +64,7 @@ const RovingTabIndexContext = createContext<IContext>({
|
|||
});
|
||||
RovingTabIndexContext.displayName = "RovingTabIndexContext";
|
||||
|
||||
enum Type {
|
||||
export enum Type {
|
||||
Register = "REGISTER",
|
||||
Unregister = "UNREGISTER",
|
||||
SetFocus = "SET_FOCUS",
|
||||
|
@ -76,73 +77,67 @@ interface IAction {
|
|||
};
|
||||
}
|
||||
|
||||
const reducer = (state: IState, action: IAction) => {
|
||||
export const reducer = (state: IState, action: IAction) => {
|
||||
switch (action.type) {
|
||||
case Type.Register: {
|
||||
if (state.refs.length === 0) {
|
||||
let left = 0;
|
||||
let right = state.refs.length - 1;
|
||||
let index = state.refs.length; // by default append to the end
|
||||
|
||||
// do a binary search to find the right slot
|
||||
while (left <= right) {
|
||||
index = Math.floor((left + right) / 2);
|
||||
const ref = state.refs[index];
|
||||
|
||||
if (ref === action.payload.ref) {
|
||||
return state; // already in refs, this should not happen
|
||||
}
|
||||
|
||||
if (action.payload.ref.current.compareDocumentPosition(ref.current) & DOCUMENT_POSITION_PRECEDING) {
|
||||
left = ++index;
|
||||
} else {
|
||||
right = index - 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!state.activeRef) {
|
||||
// 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
|
||||
state.activeRef = action.payload.ref;
|
||||
}
|
||||
|
||||
// update the refs list
|
||||
return {
|
||||
...state,
|
||||
refs: [
|
||||
...state.refs.slice(0, newIndex),
|
||||
action.payload.ref,
|
||||
...state.refs.slice(newIndex),
|
||||
],
|
||||
};
|
||||
if (index < state.refs.length) {
|
||||
state.refs.splice(index, 0, action.payload.ref);
|
||||
} else {
|
||||
state.refs.push(action.payload.ref);
|
||||
}
|
||||
return { ...state };
|
||||
}
|
||||
case Type.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) {
|
||||
case Type.Unregister: {
|
||||
const oldIndex = state.refs.findIndex(r => r === action.payload.ref);
|
||||
|
||||
if (oldIndex === -1) {
|
||||
return state; // already removed, this should not happen
|
||||
}
|
||||
|
||||
if (state.activeRef === action.payload.ref) {
|
||||
if (state.refs.splice(oldIndex, 1)[0] === state.activeRef) {
|
||||
// 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,
|
||||
};
|
||||
const len = state.refs.length;
|
||||
state.activeRef = oldIndex >= len ? state.refs[len - 1] : state.refs[oldIndex];
|
||||
}
|
||||
|
||||
// update the refs list
|
||||
return {
|
||||
...state,
|
||||
refs,
|
||||
};
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
case Type.SetFocus: {
|
||||
// update active ref
|
||||
return {
|
||||
...state,
|
||||
activeRef: action.payload.ref,
|
||||
};
|
||||
state.activeRef = action.payload.ref;
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
@ -151,13 +146,40 @@ const reducer = (state: IState, action: IAction) => {
|
|||
interface IProps {
|
||||
handleHomeEnd?: boolean;
|
||||
handleUpDown?: boolean;
|
||||
handleLeftRight?: boolean;
|
||||
children(renderProps: {
|
||||
onKeyDownHandler(ev: React.KeyboardEvent);
|
||||
});
|
||||
onKeyDown?(ev: React.KeyboardEvent, state: IState);
|
||||
}
|
||||
|
||||
export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeEnd, handleUpDown, onKeyDown }) => {
|
||||
export const findSiblingElement = (
|
||||
refs: RefObject<HTMLElement>[],
|
||||
startIndex: number,
|
||||
backwards = false,
|
||||
): RefObject<HTMLElement> => {
|
||||
if (backwards) {
|
||||
for (let i = startIndex; i < refs.length && i >= 0; i--) {
|
||||
if (refs[i].current.offsetParent !== null) {
|
||||
return refs[i];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for (let i = startIndex; i < refs.length && i >= 0; i++) {
|
||||
if (refs[i].current.offsetParent !== null) {
|
||||
return refs[i];
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||
children,
|
||||
handleHomeEnd,
|
||||
handleUpDown,
|
||||
handleLeftRight,
|
||||
onKeyDown,
|
||||
}) => {
|
||||
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
|
||||
activeRef: null,
|
||||
refs: [],
|
||||
|
@ -166,6 +188,13 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
|
|||
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
|
||||
|
||||
const onKeyDownHandler = useCallback((ev) => {
|
||||
if (onKeyDown) {
|
||||
onKeyDown(ev, context.state);
|
||||
if (ev.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let handled = false;
|
||||
// Don't interfere with input default keydown behaviour
|
||||
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
|
||||
|
@ -174,43 +203,37 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
|
|||
case Key.HOME:
|
||||
if (handleHomeEnd) {
|
||||
handled = true;
|
||||
// move focus to first item
|
||||
if (context.state.refs.length > 0) {
|
||||
context.state.refs[0].current.focus();
|
||||
}
|
||||
// move focus to first (visible) item
|
||||
findSiblingElement(context.state.refs, 0)?.current?.focus();
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.END:
|
||||
if (handleHomeEnd) {
|
||||
handled = true;
|
||||
// move focus to last item
|
||||
if (context.state.refs.length > 0) {
|
||||
context.state.refs[context.state.refs.length - 1].current.focus();
|
||||
}
|
||||
// move focus to last (visible) item
|
||||
findSiblingElement(context.state.refs, context.state.refs.length - 1, true)?.current?.focus();
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.ARROW_UP:
|
||||
if (handleUpDown) {
|
||||
case Key.ARROW_RIGHT:
|
||||
if ((ev.key === Key.ARROW_UP && handleUpDown) || (ev.key === Key.ARROW_RIGHT && handleLeftRight)) {
|
||||
handled = true;
|
||||
if (context.state.refs.length > 0) {
|
||||
const idx = context.state.refs.indexOf(context.state.activeRef);
|
||||
if (idx > 0) {
|
||||
context.state.refs[idx - 1].current.focus();
|
||||
}
|
||||
findSiblingElement(context.state.refs, idx - 1)?.current?.focus();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case Key.ARROW_DOWN:
|
||||
if (handleUpDown) {
|
||||
case Key.ARROW_LEFT:
|
||||
if ((ev.key === Key.ARROW_DOWN && handleUpDown) || (ev.key === Key.ARROW_LEFT && handleLeftRight)) {
|
||||
handled = true;
|
||||
if (context.state.refs.length > 0) {
|
||||
const idx = context.state.refs.indexOf(context.state.activeRef);
|
||||
if (idx < context.state.refs.length - 1) {
|
||||
context.state.refs[idx + 1].current.focus();
|
||||
}
|
||||
findSiblingElement(context.state.refs, idx + 1, true)?.current?.focus();
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
@ -220,10 +243,8 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
|
|||
if (handled) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
} else if (onKeyDown) {
|
||||
return onKeyDown(ev, context.state);
|
||||
}
|
||||
}, [context.state, onKeyDown, handleHomeEnd, handleUpDown]);
|
||||
}, [context.state, onKeyDown, handleHomeEnd, handleUpDown, handleLeftRight]);
|
||||
|
||||
return <RovingTabIndexContext.Provider value={context}>
|
||||
{ children({ onKeyDownHandler }) }
|
||||
|
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
|||
|
||||
import React from "react";
|
||||
|
||||
import { IState, RovingTabIndexProvider } from "./RovingTabIndex";
|
||||
import { RovingTabIndexProvider } from "./RovingTabIndex";
|
||||
import { Key } from "../Keyboard";
|
||||
|
||||
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
|
||||
|
@ -26,7 +26,7 @@ interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
|
|||
// https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar
|
||||
// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
|
||||
const Toolbar: React.FC<IProps> = ({ children, ...props }) => {
|
||||
const onKeyDown = (ev: React.KeyboardEvent, state: IState) => {
|
||||
const onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
const target = ev.target as HTMLElement;
|
||||
// Don't interfere with input default keydown behaviour
|
||||
if (target.tagName === "INPUT") return;
|
||||
|
@ -42,15 +42,6 @@ const Toolbar: React.FC<IProps> = ({ children, ...props }) => {
|
|||
}
|
||||
break;
|
||||
|
||||
case Key.ARROW_LEFT:
|
||||
case Key.ARROW_RIGHT:
|
||||
if (state.refs.length > 0) {
|
||||
const i = state.refs.findIndex(r => r === state.activeRef);
|
||||
const delta = ev.key === Key.ARROW_RIGHT ? 1 : -1;
|
||||
state.refs.slice((i + delta) % state.refs.length)[0].current.focus();
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
handled = false;
|
||||
}
|
||||
|
|
|
@ -249,6 +249,8 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
|||
let handled = true;
|
||||
|
||||
switch (ev.key) {
|
||||
// XXX: this is imitating roving behaviour, it should really use the RovingTabIndex utils
|
||||
// to inherit proper handling of unmount edge cases
|
||||
case Key.TAB:
|
||||
case Key.ESCAPE:
|
||||
case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a <Toolbar />
|
||||
|
|
|
@ -40,6 +40,7 @@ import { replaceableComponent } from "../../utils/replaceableComponent";
|
|||
import SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
|
||||
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
|
||||
import UIStore from "../../stores/UIStore";
|
||||
import { findSiblingElement, IState as IRovingTabIndexState } from "../../accessibility/RovingTabIndex";
|
||||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
|
@ -51,19 +52,12 @@ interface IState {
|
|||
activeSpace?: Room;
|
||||
}
|
||||
|
||||
// List of CSS classes which should be included in keyboard navigation within the room list
|
||||
const cssClasses = [
|
||||
"mx_RoomSearch_input",
|
||||
"mx_RoomSearch_minimizedHandle", // minimized <RoomSearch />
|
||||
"mx_RoomSublist_headerText",
|
||||
"mx_RoomTile",
|
||||
"mx_RoomSublist_showNButton",
|
||||
];
|
||||
|
||||
@replaceableComponent("structures.LeftPanel")
|
||||
export default class LeftPanel extends React.Component<IProps, IState> {
|
||||
private ref: React.RefObject<HTMLDivElement> = createRef();
|
||||
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
|
||||
private ref = createRef<HTMLDivElement>();
|
||||
private listContainerRef = createRef<HTMLDivElement>();
|
||||
private roomSearchRef = createRef<RoomSearch>();
|
||||
private roomListRef = createRef<RoomList>();
|
||||
private focusedElement = null;
|
||||
private isDoingStickyHeaders = false;
|
||||
|
||||
|
@ -283,16 +277,25 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
this.focusedElement = null;
|
||||
};
|
||||
|
||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
private onKeyDown = (ev: React.KeyboardEvent, state?: IRovingTabIndexState) => {
|
||||
if (!this.focusedElement) return;
|
||||
|
||||
const action = getKeyBindingsManager().getRoomListAction(ev);
|
||||
switch (action) {
|
||||
case RoomListAction.NextRoom:
|
||||
if (!state) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.roomListRef.current?.focus();
|
||||
}
|
||||
break;
|
||||
|
||||
case RoomListAction.PrevRoom:
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.onMoveFocus(action === RoomListAction.PrevRoom);
|
||||
if (state && state.activeRef === findSiblingElement(state.refs, 0)) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this.roomSearchRef.current?.focus();
|
||||
}
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
@ -305,45 +308,6 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
private onMoveFocus = (up: boolean) => {
|
||||
let element = this.focusedElement;
|
||||
|
||||
let descending = false; // are we currently descending or ascending through the DOM tree?
|
||||
let classes: DOMTokenList;
|
||||
|
||||
do {
|
||||
const child = up ? element.lastElementChild : element.firstElementChild;
|
||||
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
|
||||
|
||||
if (descending) {
|
||||
if (child) {
|
||||
element = child;
|
||||
} else if (sibling) {
|
||||
element = sibling;
|
||||
} else {
|
||||
descending = false;
|
||||
element = element.parentElement;
|
||||
}
|
||||
} else {
|
||||
if (sibling) {
|
||||
element = sibling;
|
||||
descending = true;
|
||||
} else {
|
||||
element = element.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
if (element) {
|
||||
classes = element.classList;
|
||||
}
|
||||
} while (element && (!cssClasses.some(c => classes.contains(c)) || element.offsetParent === null));
|
||||
|
||||
if (element) {
|
||||
element.focus();
|
||||
this.focusedElement = element;
|
||||
}
|
||||
};
|
||||
|
||||
private renderHeader(): React.ReactNode {
|
||||
return (
|
||||
<div className="mx_LeftPanel_userHeader">
|
||||
|
@ -388,7 +352,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
>
|
||||
<RoomSearch
|
||||
isMinimized={this.props.isMinimized}
|
||||
onKeyDown={this.onKeyDown}
|
||||
ref={this.roomSearchRef}
|
||||
onSelectRoom={this.selectRoom}
|
||||
/>
|
||||
|
||||
|
@ -417,6 +381,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
|||
activeSpace={this.state.activeSpace}
|
||||
onResize={this.refreshStickyHeaders}
|
||||
onListCollapse={this.refreshStickyHeaders}
|
||||
ref={this.roomListRef}
|
||||
/>;
|
||||
|
||||
const containerClasses = classNames({
|
||||
|
|
|
@ -32,7 +32,6 @@ import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../.
|
|||
|
||||
interface IProps {
|
||||
isMinimized: boolean;
|
||||
onKeyDown(ev: React.KeyboardEvent): void;
|
||||
/**
|
||||
* @returns true if a room has been selected and the search field should be cleared
|
||||
*/
|
||||
|
@ -133,11 +132,6 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
this.clearInput();
|
||||
defaultDispatcher.fire(Action.FocusSendMessageComposer);
|
||||
break;
|
||||
case RoomListAction.NextRoom:
|
||||
case RoomListAction.PrevRoom:
|
||||
// we don't handle these actions here put pass the event on to the interested party (LeftPanel)
|
||||
this.props.onKeyDown(ev);
|
||||
break;
|
||||
case RoomListAction.SelectRoom: {
|
||||
const shouldClear = this.props.onSelectRoom();
|
||||
if (shouldClear) {
|
||||
|
@ -151,6 +145,10 @@ export default class RoomSearch extends React.PureComponent<IProps, IState> {
|
|||
}
|
||||
};
|
||||
|
||||
public focus(): void {
|
||||
this.inputRef.current?.focus();
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const classes = classNames({
|
||||
'mx_RoomSearch': true,
|
||||
|
|
|
@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
|
|||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ReactComponentElement } from "react";
|
||||
import React, { createRef, ReactComponentElement } from "react";
|
||||
import { Dispatcher } from "flux";
|
||||
import { Room } from "matrix-js-sdk/src/models/room";
|
||||
import * as fbEmitter from "fbemitter";
|
||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||
|
||||
import { _t, _td } from "../../../languageHandler";
|
||||
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
|
||||
import { RovingTabIndexProvider, IState as IRovingTabIndexState } from "../../../accessibility/RovingTabIndex";
|
||||
import ResizeNotifier from "../../../utils/ResizeNotifier";
|
||||
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
|
||||
import RoomViewStore from "../../../stores/RoomViewStore";
|
||||
|
@ -54,7 +54,7 @@ import { UIComponent } from "../../../settings/UIFeature";
|
|||
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
|
||||
|
||||
interface IProps {
|
||||
onKeyDown: (ev: React.KeyboardEvent) => void;
|
||||
onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void;
|
||||
onFocus: (ev: React.FocusEvent) => void;
|
||||
onBlur: (ev: React.FocusEvent) => void;
|
||||
onResize: () => void;
|
||||
|
@ -249,6 +249,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
|
|||
private dispatcherRef;
|
||||
private customTagStoreRef;
|
||||
private roomStoreToken: fbEmitter.EventSubscription;
|
||||
private treeRef = createRef<HTMLDivElement>();
|
||||
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
|
@ -505,6 +506,12 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
|
|||
});
|
||||
}
|
||||
|
||||
public focus(): void {
|
||||
// focus the first focusable element in this aria treeview widget
|
||||
[...this.treeRef.current?.querySelectorAll<HTMLElement>('[role="treeitem"]')]
|
||||
.find(e => e.offsetParent !== null)?.focus();
|
||||
}
|
||||
|
||||
public render() {
|
||||
const cli = MatrixClientPeg.get();
|
||||
const userId = cli.getUserId();
|
||||
|
@ -584,7 +591,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
|
|||
|
||||
const sublists = this.renderSublists();
|
||||
return (
|
||||
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={this.props.onKeyDown}>
|
||||
<RovingTabIndexProvider handleHomeEnd handleUpDown onKeyDown={this.props.onKeyDown}>
|
||||
{ ({ onKeyDownHandler }) => (
|
||||
<div
|
||||
onFocus={this.props.onFocus}
|
||||
|
@ -593,6 +600,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
|
|||
className="mx_RoomList"
|
||||
role="tree"
|
||||
aria-label={_t("Rooms")}
|
||||
ref={this.treeRef}
|
||||
>
|
||||
{ sublists }
|
||||
{ explorePrompt }
|
||||
|
|
|
@ -43,7 +43,6 @@ import SpaceStore, {
|
|||
} from "../../../stores/SpaceStore";
|
||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
|
||||
import { Key } from "../../../Keyboard";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import SpaceContextMenu from "../context_menus/SpaceContextMenu";
|
||||
import IconizedContextMenu, {
|
||||
|
@ -228,75 +227,12 @@ const SpacePanel = () => {
|
|||
return () => UIStore.instance.stopTrackingElementDimensions("SpacePanel");
|
||||
}, []);
|
||||
|
||||
const onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
if (ev.defaultPrevented) return;
|
||||
|
||||
let handled = true;
|
||||
|
||||
switch (ev.key) {
|
||||
case Key.ARROW_UP:
|
||||
onMoveFocus(ev.target as Element, true);
|
||||
break;
|
||||
case Key.ARROW_DOWN:
|
||||
onMoveFocus(ev.target as Element, false);
|
||||
break;
|
||||
default:
|
||||
handled = false;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
// consume all other keys in context menu
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const onMoveFocus = (element: Element, up: boolean) => {
|
||||
let descending = false; // are we currently descending or ascending through the DOM tree?
|
||||
let classes: DOMTokenList;
|
||||
|
||||
do {
|
||||
const child = up ? element.lastElementChild : element.firstElementChild;
|
||||
const sibling = up ? element.previousElementSibling : element.nextElementSibling;
|
||||
|
||||
if (descending) {
|
||||
if (child) {
|
||||
element = child;
|
||||
} else if (sibling) {
|
||||
element = sibling;
|
||||
} else {
|
||||
descending = false;
|
||||
element = element.parentElement;
|
||||
}
|
||||
} else {
|
||||
if (sibling) {
|
||||
element = sibling;
|
||||
descending = true;
|
||||
} else {
|
||||
element = element.parentElement;
|
||||
}
|
||||
}
|
||||
|
||||
if (element) {
|
||||
if (element.classList.contains("mx_ContextualMenu")) { // we hit the top
|
||||
element = up ? element.lastElementChild : element.firstElementChild;
|
||||
descending = true;
|
||||
}
|
||||
classes = element.classList;
|
||||
}
|
||||
} while (element && !classes.contains("mx_SpaceButton"));
|
||||
|
||||
if (element) {
|
||||
(element as HTMLElement).focus();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DragDropContext onDragEnd={result => {
|
||||
if (!result.destination) return; // dropped outside the list
|
||||
SpaceStore.instance.moveRootSpace(result.source.index, result.destination.index);
|
||||
}}>
|
||||
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
|
||||
<RovingTabIndexProvider handleHomeEnd handleUpDown>
|
||||
{ ({ onKeyDownHandler }) => (
|
||||
<ul
|
||||
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
|
||||
|
|
|
@ -1,118 +0,0 @@
|
|||
/*
|
||||
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 '../skinned-sdk'; // Must be first for skinning to work
|
||||
import React from "react";
|
||||
import { mount } from "enzyme";
|
||||
|
||||
import {
|
||||
RovingTabIndexProvider,
|
||||
RovingTabIndexWrapper,
|
||||
useRovingTabIndex,
|
||||
} from "../../src/accessibility/RovingTabIndex";
|
||||
|
||||
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>
|
||||
{ () => <React.Fragment>
|
||||
{ button1 }
|
||||
{ button2 }
|
||||
{ button3 }
|
||||
</React.Fragment> }
|
||||
</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>
|
||||
{ () => <React.Fragment>
|
||||
{ button1 }
|
||||
{ button2 }
|
||||
<RovingTabIndexWrapper>
|
||||
{ ({ onFocus, isActive, ref }) =>
|
||||
<button onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref}>.</button>
|
||||
}
|
||||
</RovingTabIndexWrapper>
|
||||
</React.Fragment> }
|
||||
</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]);
|
||||
});
|
||||
});
|
||||
|
341
test/accessibility/RovingTabIndex-test.tsx
Normal file
341
test/accessibility/RovingTabIndex-test.tsx
Normal file
|
@ -0,0 +1,341 @@
|
|||
/*
|
||||
Copyright 2020 - 2021 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 '../skinned-sdk'; // Must be first for skinning to work
|
||||
import * as React from "react";
|
||||
import { mount, ReactWrapper } from "enzyme";
|
||||
|
||||
import {
|
||||
IState,
|
||||
reducer,
|
||||
RovingTabIndexProvider,
|
||||
RovingTabIndexWrapper,
|
||||
Type,
|
||||
useRovingTabIndex,
|
||||
} from "../../src/accessibility/RovingTabIndex";
|
||||
|
||||
const Button = (props) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex();
|
||||
return <button {...props} onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref} />;
|
||||
};
|
||||
|
||||
const checkTabIndexes = (buttons: ReactWrapper, expectations: number[]) => {
|
||||
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>
|
||||
{ () => <React.Fragment>
|
||||
{ button1 }
|
||||
{ button2 }
|
||||
{ button3 }
|
||||
</React.Fragment> }
|
||||
</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>
|
||||
{ () => <React.Fragment>
|
||||
{ button1 }
|
||||
{ button2 }
|
||||
<RovingTabIndexWrapper>
|
||||
{ ({ onFocus, isActive, ref }) =>
|
||||
<button onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref}>.</button>
|
||||
}
|
||||
</RovingTabIndexWrapper>
|
||||
</React.Fragment> }
|
||||
</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]);
|
||||
});
|
||||
|
||||
describe("reducer functions as expected", () => {
|
||||
it("SetFocus works as expected", () => {
|
||||
const ref1 = React.createRef<HTMLElement>();
|
||||
const ref2 = React.createRef<HTMLElement>();
|
||||
expect(reducer({
|
||||
activeRef: ref1,
|
||||
refs: [ref1, ref2],
|
||||
}, {
|
||||
type: Type.SetFocus,
|
||||
payload: {
|
||||
ref: ref2,
|
||||
},
|
||||
})).toStrictEqual({
|
||||
activeRef: ref2,
|
||||
refs: [ref1, ref2],
|
||||
});
|
||||
});
|
||||
|
||||
it("Unregister works as expected", () => {
|
||||
const ref1 = React.createRef<HTMLElement>();
|
||||
const ref2 = React.createRef<HTMLElement>();
|
||||
const ref3 = React.createRef<HTMLElement>();
|
||||
const ref4 = React.createRef<HTMLElement>();
|
||||
|
||||
let state: IState = {
|
||||
activeRef: null,
|
||||
refs: [ref1, ref2, ref3, ref4],
|
||||
};
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Unregister,
|
||||
payload: {
|
||||
ref: ref2,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeRef: null,
|
||||
refs: [ref1, ref3, ref4],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Unregister,
|
||||
payload: {
|
||||
ref: ref3,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeRef: null,
|
||||
refs: [ref1, ref4],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Unregister,
|
||||
payload: {
|
||||
ref: ref4,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeRef: null,
|
||||
refs: [ref1],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Unregister,
|
||||
payload: {
|
||||
ref: ref1,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeRef: null,
|
||||
refs: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("Register works as expected", () => {
|
||||
const ref1 = React.createRef<HTMLElement>();
|
||||
const ref2 = React.createRef<HTMLElement>();
|
||||
const ref3 = React.createRef<HTMLElement>();
|
||||
const ref4 = React.createRef<HTMLElement>();
|
||||
|
||||
mount(<React.Fragment>
|
||||
<span ref={ref1} />
|
||||
<span ref={ref2} />
|
||||
<span ref={ref3} />
|
||||
<span ref={ref4} />
|
||||
</React.Fragment>);
|
||||
|
||||
let state: IState = {
|
||||
activeRef: null,
|
||||
refs: [],
|
||||
};
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Register,
|
||||
payload: {
|
||||
ref: ref1,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeRef: ref1,
|
||||
refs: [ref1],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Register,
|
||||
payload: {
|
||||
ref: ref1,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeRef: ref1,
|
||||
refs: [ref1],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Register,
|
||||
payload: {
|
||||
ref: ref2,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeRef: ref1,
|
||||
refs: [ref1, ref2],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Register,
|
||||
payload: {
|
||||
ref: ref3,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeRef: ref1,
|
||||
refs: [ref1, ref2, ref3],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Register,
|
||||
payload: {
|
||||
ref: ref4,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeRef: ref1,
|
||||
refs: [ref1, ref2, ref3, ref4],
|
||||
});
|
||||
|
||||
// test that the automatic focus switch works for unmounting
|
||||
state = reducer(state, {
|
||||
type: Type.SetFocus,
|
||||
payload: {
|
||||
ref: ref2,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeRef: ref2,
|
||||
refs: [ref1, ref2, ref3, ref4],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Unregister,
|
||||
payload: {
|
||||
ref: ref2,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeRef: ref3,
|
||||
refs: [ref1, ref3, ref4],
|
||||
});
|
||||
|
||||
// test that the insert into the middle works as expected
|
||||
state = reducer(state, {
|
||||
type: Type.Register,
|
||||
payload: {
|
||||
ref: ref2,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeRef: ref3,
|
||||
refs: [ref1, ref2, ref3, ref4],
|
||||
});
|
||||
|
||||
// test that insertion at the edges works
|
||||
state = reducer(state, {
|
||||
type: Type.Unregister,
|
||||
payload: {
|
||||
ref: ref1,
|
||||
},
|
||||
});
|
||||
state = reducer(state, {
|
||||
type: Type.Unregister,
|
||||
payload: {
|
||||
ref: ref4,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeRef: ref3,
|
||||
refs: [ref2, ref3],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Register,
|
||||
payload: {
|
||||
ref: ref1,
|
||||
},
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Register,
|
||||
payload: {
|
||||
ref: ref4,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeRef: ref3,
|
||||
refs: [ref1, ref2, ref3, ref4],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
Loading…
Reference in a new issue