From 04c06b6aa868f0ffab38b8cff2c5308d88189735 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Tue, 26 Oct 2021 12:16:50 +0100 Subject: [PATCH] Improve RovingTabIndex & Room List filtering performance (#6987) --- src/accessibility/RovingTabIndex.tsx | 157 ++++++---- src/accessibility/Toolbar.tsx | 13 +- src/components/structures/ContextMenu.tsx | 2 + src/components/structures/LeftPanel.tsx | 75 ++--- src/components/structures/RoomSearch.tsx | 10 +- src/components/views/rooms/RoomList.tsx | 16 +- src/components/views/spaces/SpacePanel.tsx | 66 +--- test/accessibility/RovingTabIndex-test.js | 118 ------- test/accessibility/RovingTabIndex-test.tsx | 341 +++++++++++++++++++++ 9 files changed, 471 insertions(+), 327 deletions(-) delete mode 100644 test/accessibility/RovingTabIndex-test.js create mode 100644 test/accessibility/RovingTabIndex-test.tsx diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 68e10049fd..8c49a4d6ae 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -24,6 +24,7 @@ import React, { useReducer, Reducer, Dispatch, + RefObject, } from "react"; import { Key } from "../Keyboard"; @@ -63,7 +64,7 @@ const RovingTabIndexContext = createContext({ }); 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 = ({ children, handleHomeEnd, handleUpDown, onKeyDown }) => { +export const findSiblingElement = ( + refs: RefObject[], + startIndex: number, + backwards = false, +): RefObject => { + 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 = ({ + children, + handleHomeEnd, + handleUpDown, + handleLeftRight, + onKeyDown, +}) => { const [state, dispatch] = useReducer>(reducer, { activeRef: null, refs: [], @@ -166,6 +188,13 @@ export const RovingTabIndexProvider: React.FC = ({ children, handleHomeE const context = useMemo(() => ({ 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 = ({ 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 = ({ 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 { children({ onKeyDownHandler }) } diff --git a/src/accessibility/Toolbar.tsx b/src/accessibility/Toolbar.tsx index 90538760bb..6e99c7f1fa 100644 --- a/src/accessibility/Toolbar.tsx +++ b/src/accessibility/Toolbar.tsx @@ -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, "onKeyDown"> { @@ -26,7 +26,7 @@ interface IProps extends Omit, "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 = ({ 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 = ({ 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; } diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 4250b5925b..aec1cf9dbf 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -249,6 +249,8 @@ export class ContextMenu extends React.PureComponent { 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 diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index 9a2ebd45e2..f12b4cbcf5 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -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 - "mx_RoomSublist_headerText", - "mx_RoomTile", - "mx_RoomSublist_showNButton", -]; - @replaceableComponent("structures.LeftPanel") export default class LeftPanel extends React.Component { - private ref: React.RefObject = createRef(); - private listContainerRef: React.RefObject = createRef(); + private ref = createRef(); + private listContainerRef = createRef(); + private roomSearchRef = createRef(); + private roomListRef = createRef(); private focusedElement = null; private isDoingStickyHeaders = false; @@ -283,16 +277,25 @@ export default class LeftPanel extends React.Component { 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 { } }; - 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 (
@@ -388,7 +352,7 @@ export default class LeftPanel extends React.Component { > @@ -417,6 +381,7 @@ export default class LeftPanel extends React.Component { activeSpace={this.state.activeSpace} onResize={this.refreshStickyHeaders} onListCollapse={this.refreshStickyHeaders} + ref={this.roomListRef} />; const containerClasses = classNames({ diff --git a/src/components/structures/RoomSearch.tsx b/src/components/structures/RoomSearch.tsx index 9acfb7bb8e..1a1cf46023 100644 --- a/src/components/structures/RoomSearch.tsx +++ b/src/components/structures/RoomSearch.tsx @@ -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 { 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 { } }; + public focus(): void { + this.inputRef.current?.focus(); + } + public render(): React.ReactNode { const classes = classNames({ 'mx_RoomSearch': true, diff --git a/src/components/views/rooms/RoomList.tsx b/src/components/views/rooms/RoomList.tsx index 90250f2d77..5db4213a4a 100644 --- a/src/components/views/rooms/RoomList.tsx +++ b/src/components/views/rooms/RoomList.tsx @@ -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 { private dispatcherRef; private customTagStoreRef; private roomStoreToken: fbEmitter.EventSubscription; + private treeRef = createRef(); constructor(props: IProps) { super(props); @@ -505,6 +506,12 @@ export default class RoomList extends React.PureComponent { }); } + public focus(): void { + // focus the first focusable element in this aria treeview widget + [...this.treeRef.current?.querySelectorAll('[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 { const sublists = this.renderSublists(); return ( - + { ({ onKeyDownHandler }) => (
{ className="mx_RoomList" role="tree" aria-label={_t("Rooms")} + ref={this.treeRef} > { sublists } { explorePrompt } diff --git a/src/components/views/spaces/SpacePanel.tsx b/src/components/views/spaces/SpacePanel.tsx index 67055d7418..f61ebdf0f7 100644 --- a/src/components/views/spaces/SpacePanel.tsx +++ b/src/components/views/spaces/SpacePanel.tsx @@ -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 ( { if (!result.destination) return; // dropped outside the list SpaceStore.instance.moveRootSpace(result.source.index, result.destination.index); }}> - + { ({ onKeyDownHandler }) => (
    { - const [onFocus, isActive, ref] = useRovingTabIndex(); - return ; -const button2 = ; -const button3 = ; -const button4 = ; - -describe("RovingTabIndex", () => { - it("RovingTabIndexProvider renders children as expected", () => { - const wrapper = mount( - { () =>
    Test
    } -
    ); - expect(wrapper.text()).toBe("Test"); - expect(wrapper.html()).toBe('
    Test
    '); - }); - - it("RovingTabIndexProvider works as expected with useRovingTabIndex", () => { - const wrapper = mount( - { () => - { button1 } - { button2 } - { button3 } - } - ); - - // should begin with 0th being active - checkTabIndexes(wrapper.find("button"), [0, -1, -1]); - - // focus on 2nd button and test it is the only active one - wrapper.find("button").at(2).simulate("focus"); - wrapper.update(); - checkTabIndexes(wrapper.find("button"), [-1, -1, 0]); - - // focus on 1st button and test it is the only active one - wrapper.find("button").at(1).simulate("focus"); - wrapper.update(); - checkTabIndexes(wrapper.find("button"), [-1, 0, -1]); - - // check that the active button does not change even on an explicit blur event - wrapper.find("button").at(1).simulate("blur"); - wrapper.update(); - checkTabIndexes(wrapper.find("button"), [-1, 0, -1]); - - // update the children, it should remain on the same button - wrapper.setProps({ - children: () => [button1, button4, button2, button3], - }); - wrapper.update(); - checkTabIndexes(wrapper.find("button"), [-1, -1, 0, -1]); - - // update the children, remove the active button, it should move to the next one - wrapper.setProps({ - children: () => [button1, button4, button3], - }); - wrapper.update(); - checkTabIndexes(wrapper.find("button"), [-1, -1, 0]); - }); - - it("RovingTabIndexProvider works as expected with RovingTabIndexWrapper", () => { - const wrapper = mount( - { () => - { button1 } - { button2 } - - { ({ onFocus, isActive, ref }) => - - } - - } - ); - - // should begin with 0th being active - checkTabIndexes(wrapper.find("button"), [0, -1, -1]); - - // focus on 2nd button and test it is the only active one - wrapper.find("button").at(2).simulate("focus"); - wrapper.update(); - checkTabIndexes(wrapper.find("button"), [-1, -1, 0]); - }); -}); - diff --git a/test/accessibility/RovingTabIndex-test.tsx b/test/accessibility/RovingTabIndex-test.tsx new file mode 100644 index 0000000000..7c08a676a9 --- /dev/null +++ b/test/accessibility/RovingTabIndex-test.tsx @@ -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 ; +const button2 = ; +const button3 = ; +const button4 = ; + +describe("RovingTabIndex", () => { + it("RovingTabIndexProvider renders children as expected", () => { + const wrapper = mount( + { () =>
    Test
    } +
    ); + expect(wrapper.text()).toBe("Test"); + expect(wrapper.html()).toBe('
    Test
    '); + }); + + it("RovingTabIndexProvider works as expected with useRovingTabIndex", () => { + const wrapper = mount( + { () => + { button1 } + { button2 } + { button3 } + } + ); + + // should begin with 0th being active + checkTabIndexes(wrapper.find("button"), [0, -1, -1]); + + // focus on 2nd button and test it is the only active one + wrapper.find("button").at(2).simulate("focus"); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, -1, 0]); + + // focus on 1st button and test it is the only active one + wrapper.find("button").at(1).simulate("focus"); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, 0, -1]); + + // check that the active button does not change even on an explicit blur event + wrapper.find("button").at(1).simulate("blur"); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, 0, -1]); + + // update the children, it should remain on the same button + wrapper.setProps({ + children: () => [button1, button4, button2, button3], + }); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, -1, 0, -1]); + + // update the children, remove the active button, it should move to the next one + wrapper.setProps({ + children: () => [button1, button4, button3], + }); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, -1, 0]); + }); + + it("RovingTabIndexProvider works as expected with RovingTabIndexWrapper", () => { + const wrapper = mount( + { () => + { button1 } + { button2 } + + { ({ onFocus, isActive, ref }) => + + } + + } + ); + + // should begin with 0th being active + checkTabIndexes(wrapper.find("button"), [0, -1, -1]); + + // focus on 2nd button and test it is the only active one + wrapper.find("button").at(2).simulate("focus"); + wrapper.update(); + checkTabIndexes(wrapper.find("button"), [-1, -1, 0]); + }); + + describe("reducer functions as expected", () => { + it("SetFocus works as expected", () => { + const ref1 = React.createRef(); + const ref2 = React.createRef(); + 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(); + const ref2 = React.createRef(); + const ref3 = React.createRef(); + const ref4 = React.createRef(); + + 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(); + const ref2 = React.createRef(); + const ref3 = React.createRef(); + const ref4 = React.createRef(); + + mount( + + + + + ); + + 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], + }); + }); + }); +}); +