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,
|
useReducer,
|
||||||
Reducer,
|
Reducer,
|
||||||
Dispatch,
|
Dispatch,
|
||||||
|
RefObject,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
import { Key } from "../Keyboard";
|
import { Key } from "../Keyboard";
|
||||||
|
@ -63,7 +64,7 @@ const RovingTabIndexContext = createContext<IContext>({
|
||||||
});
|
});
|
||||||
RovingTabIndexContext.displayName = "RovingTabIndexContext";
|
RovingTabIndexContext.displayName = "RovingTabIndexContext";
|
||||||
|
|
||||||
enum Type {
|
export enum Type {
|
||||||
Register = "REGISTER",
|
Register = "REGISTER",
|
||||||
Unregister = "UNREGISTER",
|
Unregister = "UNREGISTER",
|
||||||
SetFocus = "SET_FOCUS",
|
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) {
|
switch (action.type) {
|
||||||
case Type.Register: {
|
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
|
// Our list of refs was empty, set activeRef to this first item
|
||||||
return {
|
state.activeRef = action.payload.ref;
|
||||||
...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
|
// update the refs list
|
||||||
return {
|
if (index < state.refs.length) {
|
||||||
...state,
|
state.refs.splice(index, 0, action.payload.ref);
|
||||||
refs: [
|
} else {
|
||||||
...state.refs.slice(0, newIndex),
|
state.refs.push(action.payload.ref);
|
||||||
action.payload.ref,
|
}
|
||||||
...state.refs.slice(newIndex),
|
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
|
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
|
// we just removed the active ref, need to replace it
|
||||||
// pick the ref which is now in the index the old ref was in
|
// pick the ref which is now in the index the old ref was in
|
||||||
const oldIndex = state.refs.findIndex(r => r === action.payload.ref);
|
const len = state.refs.length;
|
||||||
return {
|
state.activeRef = oldIndex >= len ? state.refs[len - 1] : state.refs[oldIndex];
|
||||||
...state,
|
|
||||||
activeRef: oldIndex >= refs.length ? refs[refs.length - 1] : refs[oldIndex],
|
|
||||||
refs,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// update the refs list
|
// update the refs list
|
||||||
return {
|
return { ...state };
|
||||||
...state,
|
|
||||||
refs,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
case Type.SetFocus: {
|
case Type.SetFocus: {
|
||||||
// update active ref
|
// update active ref
|
||||||
return {
|
state.activeRef = action.payload.ref;
|
||||||
...state,
|
return { ...state };
|
||||||
activeRef: action.payload.ref,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
@ -151,13 +146,40 @@ const reducer = (state: IState, action: IAction) => {
|
||||||
interface IProps {
|
interface IProps {
|
||||||
handleHomeEnd?: boolean;
|
handleHomeEnd?: boolean;
|
||||||
handleUpDown?: boolean;
|
handleUpDown?: boolean;
|
||||||
|
handleLeftRight?: boolean;
|
||||||
children(renderProps: {
|
children(renderProps: {
|
||||||
onKeyDownHandler(ev: React.KeyboardEvent);
|
onKeyDownHandler(ev: React.KeyboardEvent);
|
||||||
});
|
});
|
||||||
onKeyDown?(ev: React.KeyboardEvent, state: IState);
|
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, {
|
const [state, dispatch] = useReducer<Reducer<IState, IAction>>(reducer, {
|
||||||
activeRef: null,
|
activeRef: null,
|
||||||
refs: [],
|
refs: [],
|
||||||
|
@ -166,6 +188,13 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
|
||||||
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
|
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
|
||||||
|
|
||||||
const onKeyDownHandler = useCallback((ev) => {
|
const onKeyDownHandler = useCallback((ev) => {
|
||||||
|
if (onKeyDown) {
|
||||||
|
onKeyDown(ev, context.state);
|
||||||
|
if (ev.defaultPrevented) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let handled = false;
|
let handled = false;
|
||||||
// Don't interfere with input default keydown behaviour
|
// Don't interfere with input default keydown behaviour
|
||||||
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
|
if (ev.target.tagName !== "INPUT" && ev.target.tagName !== "TEXTAREA") {
|
||||||
|
@ -174,43 +203,37 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
|
||||||
case Key.HOME:
|
case Key.HOME:
|
||||||
if (handleHomeEnd) {
|
if (handleHomeEnd) {
|
||||||
handled = true;
|
handled = true;
|
||||||
// move focus to first item
|
// move focus to first (visible) item
|
||||||
if (context.state.refs.length > 0) {
|
findSiblingElement(context.state.refs, 0)?.current?.focus();
|
||||||
context.state.refs[0].current.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Key.END:
|
case Key.END:
|
||||||
if (handleHomeEnd) {
|
if (handleHomeEnd) {
|
||||||
handled = true;
|
handled = true;
|
||||||
// move focus to last item
|
// move focus to last (visible) item
|
||||||
if (context.state.refs.length > 0) {
|
findSiblingElement(context.state.refs, context.state.refs.length - 1, true)?.current?.focus();
|
||||||
context.state.refs[context.state.refs.length - 1].current.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Key.ARROW_UP:
|
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;
|
handled = true;
|
||||||
if (context.state.refs.length > 0) {
|
if (context.state.refs.length > 0) {
|
||||||
const idx = context.state.refs.indexOf(context.state.activeRef);
|
const idx = context.state.refs.indexOf(context.state.activeRef);
|
||||||
if (idx > 0) {
|
findSiblingElement(context.state.refs, idx - 1)?.current?.focus();
|
||||||
context.state.refs[idx - 1].current.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Key.ARROW_DOWN:
|
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;
|
handled = true;
|
||||||
if (context.state.refs.length > 0) {
|
if (context.state.refs.length > 0) {
|
||||||
const idx = context.state.refs.indexOf(context.state.activeRef);
|
const idx = context.state.refs.indexOf(context.state.activeRef);
|
||||||
if (idx < context.state.refs.length - 1) {
|
findSiblingElement(context.state.refs, idx + 1, true)?.current?.focus();
|
||||||
context.state.refs[idx + 1].current.focus();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
@ -220,10 +243,8 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({ children, handleHomeE
|
||||||
if (handled) {
|
if (handled) {
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
ev.stopPropagation();
|
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}>
|
return <RovingTabIndexContext.Provider value={context}>
|
||||||
{ children({ onKeyDownHandler }) }
|
{ children({ onKeyDownHandler }) }
|
||||||
|
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
||||||
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
|
||||||
import { IState, RovingTabIndexProvider } from "./RovingTabIndex";
|
import { RovingTabIndexProvider } from "./RovingTabIndex";
|
||||||
import { Key } from "../Keyboard";
|
import { Key } from "../Keyboard";
|
||||||
|
|
||||||
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
|
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
|
// https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar
|
||||||
// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
|
// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
|
||||||
const Toolbar: React.FC<IProps> = ({ children, ...props }) => {
|
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;
|
const target = ev.target as HTMLElement;
|
||||||
// Don't interfere with input default keydown behaviour
|
// Don't interfere with input default keydown behaviour
|
||||||
if (target.tagName === "INPUT") return;
|
if (target.tagName === "INPUT") return;
|
||||||
|
@ -42,15 +42,6 @@ const Toolbar: React.FC<IProps> = ({ children, ...props }) => {
|
||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
handled = false;
|
handled = false;
|
||||||
}
|
}
|
||||||
|
|
|
@ -249,6 +249,8 @@ export class ContextMenu extends React.PureComponent<IProps, IState> {
|
||||||
let handled = true;
|
let handled = true;
|
||||||
|
|
||||||
switch (ev.key) {
|
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.TAB:
|
||||||
case Key.ESCAPE:
|
case Key.ESCAPE:
|
||||||
case Key.ARROW_LEFT: // close on left and right arrows too for when it is a context menu on a <Toolbar />
|
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 SpaceStore, { UPDATE_SELECTED_SPACE } from "../../stores/SpaceStore";
|
||||||
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
|
import { getKeyBindingsManager, RoomListAction } from "../../KeyBindingsManager";
|
||||||
import UIStore from "../../stores/UIStore";
|
import UIStore from "../../stores/UIStore";
|
||||||
|
import { findSiblingElement, IState as IRovingTabIndexState } from "../../accessibility/RovingTabIndex";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
isMinimized: boolean;
|
isMinimized: boolean;
|
||||||
|
@ -51,19 +52,12 @@ interface IState {
|
||||||
activeSpace?: Room;
|
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")
|
@replaceableComponent("structures.LeftPanel")
|
||||||
export default class LeftPanel extends React.Component<IProps, IState> {
|
export default class LeftPanel extends React.Component<IProps, IState> {
|
||||||
private ref: React.RefObject<HTMLDivElement> = createRef();
|
private ref = createRef<HTMLDivElement>();
|
||||||
private listContainerRef: React.RefObject<HTMLDivElement> = createRef();
|
private listContainerRef = createRef<HTMLDivElement>();
|
||||||
|
private roomSearchRef = createRef<RoomSearch>();
|
||||||
|
private roomListRef = createRef<RoomList>();
|
||||||
private focusedElement = null;
|
private focusedElement = null;
|
||||||
private isDoingStickyHeaders = false;
|
private isDoingStickyHeaders = false;
|
||||||
|
|
||||||
|
@ -283,16 +277,25 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||||
this.focusedElement = null;
|
this.focusedElement = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
private onKeyDown = (ev: React.KeyboardEvent) => {
|
private onKeyDown = (ev: React.KeyboardEvent, state?: IRovingTabIndexState) => {
|
||||||
if (!this.focusedElement) return;
|
if (!this.focusedElement) return;
|
||||||
|
|
||||||
const action = getKeyBindingsManager().getRoomListAction(ev);
|
const action = getKeyBindingsManager().getRoomListAction(ev);
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case RoomListAction.NextRoom:
|
case RoomListAction.NextRoom:
|
||||||
|
if (!state) {
|
||||||
|
ev.stopPropagation();
|
||||||
|
ev.preventDefault();
|
||||||
|
this.roomListRef.current?.focus();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case RoomListAction.PrevRoom:
|
case RoomListAction.PrevRoom:
|
||||||
ev.stopPropagation();
|
if (state && state.activeRef === findSiblingElement(state.refs, 0)) {
|
||||||
ev.preventDefault();
|
ev.stopPropagation();
|
||||||
this.onMoveFocus(action === RoomListAction.PrevRoom);
|
ev.preventDefault();
|
||||||
|
this.roomSearchRef.current?.focus();
|
||||||
|
}
|
||||||
break;
|
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 {
|
private renderHeader(): React.ReactNode {
|
||||||
return (
|
return (
|
||||||
<div className="mx_LeftPanel_userHeader">
|
<div className="mx_LeftPanel_userHeader">
|
||||||
|
@ -388,7 +352,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||||
>
|
>
|
||||||
<RoomSearch
|
<RoomSearch
|
||||||
isMinimized={this.props.isMinimized}
|
isMinimized={this.props.isMinimized}
|
||||||
onKeyDown={this.onKeyDown}
|
ref={this.roomSearchRef}
|
||||||
onSelectRoom={this.selectRoom}
|
onSelectRoom={this.selectRoom}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
@ -417,6 +381,7 @@ export default class LeftPanel extends React.Component<IProps, IState> {
|
||||||
activeSpace={this.state.activeSpace}
|
activeSpace={this.state.activeSpace}
|
||||||
onResize={this.refreshStickyHeaders}
|
onResize={this.refreshStickyHeaders}
|
||||||
onListCollapse={this.refreshStickyHeaders}
|
onListCollapse={this.refreshStickyHeaders}
|
||||||
|
ref={this.roomListRef}
|
||||||
/>;
|
/>;
|
||||||
|
|
||||||
const containerClasses = classNames({
|
const containerClasses = classNames({
|
||||||
|
|
|
@ -32,7 +32,6 @@ import SpaceStore, { UPDATE_SELECTED_SPACE, UPDATE_TOP_LEVEL_SPACES } from "../.
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
isMinimized: boolean;
|
isMinimized: boolean;
|
||||||
onKeyDown(ev: React.KeyboardEvent): void;
|
|
||||||
/**
|
/**
|
||||||
* @returns true if a room has been selected and the search field should be cleared
|
* @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();
|
this.clearInput();
|
||||||
defaultDispatcher.fire(Action.FocusSendMessageComposer);
|
defaultDispatcher.fire(Action.FocusSendMessageComposer);
|
||||||
break;
|
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: {
|
case RoomListAction.SelectRoom: {
|
||||||
const shouldClear = this.props.onSelectRoom();
|
const shouldClear = this.props.onSelectRoom();
|
||||||
if (shouldClear) {
|
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 {
|
public render(): React.ReactNode {
|
||||||
const classes = classNames({
|
const classes = classNames({
|
||||||
'mx_RoomSearch': true,
|
'mx_RoomSearch': true,
|
||||||
|
|
|
@ -14,14 +14,14 @@ See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { ReactComponentElement } from "react";
|
import React, { createRef, ReactComponentElement } from "react";
|
||||||
import { Dispatcher } from "flux";
|
import { Dispatcher } from "flux";
|
||||||
import { Room } from "matrix-js-sdk/src/models/room";
|
import { Room } from "matrix-js-sdk/src/models/room";
|
||||||
import * as fbEmitter from "fbemitter";
|
import * as fbEmitter from "fbemitter";
|
||||||
import { EventType } from "matrix-js-sdk/src/@types/event";
|
import { EventType } from "matrix-js-sdk/src/@types/event";
|
||||||
|
|
||||||
import { _t, _td } from "../../../languageHandler";
|
import { _t, _td } from "../../../languageHandler";
|
||||||
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
|
import { RovingTabIndexProvider, IState as IRovingTabIndexState } from "../../../accessibility/RovingTabIndex";
|
||||||
import ResizeNotifier from "../../../utils/ResizeNotifier";
|
import ResizeNotifier from "../../../utils/ResizeNotifier";
|
||||||
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
|
import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore";
|
||||||
import RoomViewStore from "../../../stores/RoomViewStore";
|
import RoomViewStore from "../../../stores/RoomViewStore";
|
||||||
|
@ -54,7 +54,7 @@ import { UIComponent } from "../../../settings/UIFeature";
|
||||||
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
|
import { JoinRule } from "matrix-js-sdk/src/@types/partials";
|
||||||
|
|
||||||
interface IProps {
|
interface IProps {
|
||||||
onKeyDown: (ev: React.KeyboardEvent) => void;
|
onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void;
|
||||||
onFocus: (ev: React.FocusEvent) => void;
|
onFocus: (ev: React.FocusEvent) => void;
|
||||||
onBlur: (ev: React.FocusEvent) => void;
|
onBlur: (ev: React.FocusEvent) => void;
|
||||||
onResize: () => void;
|
onResize: () => void;
|
||||||
|
@ -249,6 +249,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
|
||||||
private dispatcherRef;
|
private dispatcherRef;
|
||||||
private customTagStoreRef;
|
private customTagStoreRef;
|
||||||
private roomStoreToken: fbEmitter.EventSubscription;
|
private roomStoreToken: fbEmitter.EventSubscription;
|
||||||
|
private treeRef = createRef<HTMLDivElement>();
|
||||||
|
|
||||||
constructor(props: IProps) {
|
constructor(props: IProps) {
|
||||||
super(props);
|
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() {
|
public render() {
|
||||||
const cli = MatrixClientPeg.get();
|
const cli = MatrixClientPeg.get();
|
||||||
const userId = cli.getUserId();
|
const userId = cli.getUserId();
|
||||||
|
@ -584,7 +591,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
|
||||||
|
|
||||||
const sublists = this.renderSublists();
|
const sublists = this.renderSublists();
|
||||||
return (
|
return (
|
||||||
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={this.props.onKeyDown}>
|
<RovingTabIndexProvider handleHomeEnd handleUpDown onKeyDown={this.props.onKeyDown}>
|
||||||
{ ({ onKeyDownHandler }) => (
|
{ ({ onKeyDownHandler }) => (
|
||||||
<div
|
<div
|
||||||
onFocus={this.props.onFocus}
|
onFocus={this.props.onFocus}
|
||||||
|
@ -593,6 +600,7 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
|
||||||
className="mx_RoomList"
|
className="mx_RoomList"
|
||||||
role="tree"
|
role="tree"
|
||||||
aria-label={_t("Rooms")}
|
aria-label={_t("Rooms")}
|
||||||
|
ref={this.treeRef}
|
||||||
>
|
>
|
||||||
{ sublists }
|
{ sublists }
|
||||||
{ explorePrompt }
|
{ explorePrompt }
|
||||||
|
|
|
@ -43,7 +43,6 @@ import SpaceStore, {
|
||||||
} from "../../../stores/SpaceStore";
|
} from "../../../stores/SpaceStore";
|
||||||
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
import AutoHideScrollbar from "../../structures/AutoHideScrollbar";
|
||||||
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
|
import { RovingTabIndexProvider } from "../../../accessibility/RovingTabIndex";
|
||||||
import { Key } from "../../../Keyboard";
|
|
||||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||||
import SpaceContextMenu from "../context_menus/SpaceContextMenu";
|
import SpaceContextMenu from "../context_menus/SpaceContextMenu";
|
||||||
import IconizedContextMenu, {
|
import IconizedContextMenu, {
|
||||||
|
@ -228,75 +227,12 @@ const SpacePanel = () => {
|
||||||
return () => UIStore.instance.stopTrackingElementDimensions("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 (
|
return (
|
||||||
<DragDropContext onDragEnd={result => {
|
<DragDropContext onDragEnd={result => {
|
||||||
if (!result.destination) return; // dropped outside the list
|
if (!result.destination) return; // dropped outside the list
|
||||||
SpaceStore.instance.moveRootSpace(result.source.index, result.destination.index);
|
SpaceStore.instance.moveRootSpace(result.source.index, result.destination.index);
|
||||||
}}>
|
}}>
|
||||||
<RovingTabIndexProvider handleHomeEnd={true} onKeyDown={onKeyDown}>
|
<RovingTabIndexProvider handleHomeEnd handleUpDown>
|
||||||
{ ({ onKeyDownHandler }) => (
|
{ ({ onKeyDownHandler }) => (
|
||||||
<ul
|
<ul
|
||||||
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
|
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