Merge pull request #28452 from element-hq/midhun/fix-spotlight-1

This commit is contained in:
Michael Telatynski 2024-11-13 15:54:43 +00:00 committed by GitHub
commit 18ef975386
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 270 additions and 212 deletions

View file

@ -10,7 +10,6 @@ import React, {
createContext, createContext,
useCallback, useCallback,
useContext, useContext,
useEffect,
useMemo, useMemo,
useRef, useRef,
useReducer, useReducer,
@ -18,11 +17,12 @@ import React, {
Dispatch, Dispatch,
RefObject, RefObject,
ReactNode, ReactNode,
RefCallback,
} from "react"; } from "react";
import { getKeyBindingsManager } from "../KeyBindingsManager"; import { getKeyBindingsManager } from "../KeyBindingsManager";
import { KeyBindingAction } from "./KeyboardShortcuts"; import { KeyBindingAction } from "./KeyboardShortcuts";
import { FocusHandler, Ref } from "./roving/types"; import { FocusHandler } from "./roving/types";
/** /**
* Module to simplify implementing the Roving TabIndex accessibility technique * Module to simplify implementing the Roving TabIndex accessibility technique
@ -49,8 +49,8 @@ export function checkInputableElement(el: HTMLElement): boolean {
} }
export interface IState { export interface IState {
activeRef?: Ref; activeNode?: HTMLElement;
refs: Ref[]; nodes: HTMLElement[];
} }
export interface IContext { export interface IContext {
@ -60,7 +60,7 @@ export interface IContext {
export const RovingTabIndexContext = createContext<IContext>({ export const RovingTabIndexContext = createContext<IContext>({
state: { state: {
refs: [], // list of refs in DOM order nodes: [], // list of nodes in DOM order
}, },
dispatch: () => {}, dispatch: () => {},
}); });
@ -76,7 +76,7 @@ export enum Type {
export interface IAction { export interface IAction {
type: Exclude<Type, Type.Update>; type: Exclude<Type, Type.Update>;
payload: { payload: {
ref: Ref; node: HTMLElement;
}; };
} }
@ -87,12 +87,12 @@ interface UpdateAction {
type Action = IAction | UpdateAction; type Action = IAction | UpdateAction;
const refSorter = (a: Ref, b: Ref): number => { const nodeSorter = (a: HTMLElement, b: HTMLElement): number => {
if (a === b) { if (a === b) {
return 0; return 0;
} }
const position = a.current!.compareDocumentPosition(b.current!); const position = a.compareDocumentPosition(b);
if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) { if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
return -1; return -1;
@ -106,54 +106,56 @@ const refSorter = (a: Ref, b: Ref): number => {
export const reducer: Reducer<IState, Action> = (state: IState, action: Action) => { export const reducer: Reducer<IState, Action> = (state: IState, action: Action) => {
switch (action.type) { switch (action.type) {
case Type.Register: { case Type.Register: {
if (!state.activeRef) { if (!state.activeNode) {
// Our list of refs was empty, set activeRef to this first item // Our list of nodes was empty, set activeNode to this first item
state.activeRef = action.payload.ref; state.activeNode = action.payload.node;
} }
if (state.nodes.includes(action.payload.node)) return state;
// Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert // Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert
state.refs.push(action.payload.ref); state.nodes.push(action.payload.node);
state.refs.sort(refSorter); state.nodes.sort(nodeSorter);
return { ...state }; return { ...state };
} }
case Type.Unregister: { case Type.Unregister: {
const oldIndex = state.refs.findIndex((r) => r === action.payload.ref); const oldIndex = state.nodes.findIndex((r) => r === action.payload.node);
if (oldIndex === -1) { if (oldIndex === -1) {
return state; // already removed, this should not happen return state; // already removed, this should not happen
} }
if (state.refs.splice(oldIndex, 1)[0] === state.activeRef) { if (state.nodes.splice(oldIndex, 1)[0] === state.activeNode) {
// we just removed the active ref, need to replace it // we just removed the active node, need to replace it
// pick the ref closest to the index the old ref was in // pick the node closest to the index the old node was in
if (oldIndex >= state.refs.length) { if (oldIndex >= state.nodes.length) {
state.activeRef = findSiblingElement(state.refs, state.refs.length - 1, true); state.activeNode = findSiblingElement(state.nodes, state.nodes.length - 1, true);
} else { } else {
state.activeRef = state.activeNode =
findSiblingElement(state.refs, oldIndex) || findSiblingElement(state.refs, oldIndex, true); findSiblingElement(state.nodes, oldIndex) || findSiblingElement(state.nodes, oldIndex, true);
} }
if (document.activeElement === document.body) { if (document.activeElement === document.body) {
// if the focus got reverted to the body then the user was likely focused on the unmounted element // if the focus got reverted to the body then the user was likely focused on the unmounted element
setTimeout(() => state.activeRef?.current?.focus(), 0); setTimeout(() => state.activeNode?.focus(), 0);
} }
} }
// update the refs list // update the nodes list
return { ...state }; return { ...state };
} }
case Type.SetFocus: { case Type.SetFocus: {
// if the ref doesn't change just return the same object reference to skip a re-render // if the node doesn't change just return the same object reference to skip a re-render
if (state.activeRef === action.payload.ref) return state; if (state.activeNode === action.payload.node) return state;
// update active ref // update active node
state.activeRef = action.payload.ref; state.activeNode = action.payload.node;
return { ...state }; return { ...state };
} }
case Type.Update: { case Type.Update: {
state.refs.sort(refSorter); state.nodes.sort(nodeSorter);
return { ...state }; return { ...state };
} }
@ -174,28 +176,28 @@ interface IProps {
} }
export const findSiblingElement = ( export const findSiblingElement = (
refs: RefObject<HTMLElement>[], nodes: HTMLElement[],
startIndex: number, startIndex: number,
backwards = false, backwards = false,
loop = false, loop = false,
): RefObject<HTMLElement> | undefined => { ): HTMLElement | undefined => {
if (backwards) { if (backwards) {
for (let i = startIndex; i < refs.length && i >= 0; i--) { for (let i = startIndex; i < nodes.length && i >= 0; i--) {
if (refs[i].current?.offsetParent !== null) { if (nodes[i]?.offsetParent !== null) {
return refs[i]; return nodes[i];
} }
} }
if (loop) { if (loop) {
return findSiblingElement(refs.slice(startIndex + 1), refs.length - 1, true, false); return findSiblingElement(nodes.slice(startIndex + 1), nodes.length - 1, true, false);
} }
} else { } else {
for (let i = startIndex; i < refs.length && i >= 0; i++) { for (let i = startIndex; i < nodes.length && i >= 0; i++) {
if (refs[i].current?.offsetParent !== null) { if (nodes[i]?.offsetParent !== null) {
return refs[i]; return nodes[i];
} }
} }
if (loop) { if (loop) {
return findSiblingElement(refs.slice(0, startIndex), 0, false, false); return findSiblingElement(nodes.slice(0, startIndex), 0, false, false);
} }
} }
}; };
@ -211,7 +213,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
onKeyDown, onKeyDown,
}) => { }) => {
const [state, dispatch] = useReducer<Reducer<IState, Action>>(reducer, { const [state, dispatch] = useReducer<Reducer<IState, Action>>(reducer, {
refs: [], nodes: [],
}); });
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]); const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
@ -227,17 +229,17 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
let handled = false; let handled = false;
const action = getKeyBindingsManager().getAccessibilityAction(ev); const action = getKeyBindingsManager().getAccessibilityAction(ev);
let focusRef: RefObject<HTMLElement> | undefined; let focusNode: HTMLElement | undefined;
// Don't interfere with input default keydown behaviour // Don't interfere with input default keydown behaviour
// but allow people to move focus from it with Tab. // but allow people to move focus from it with Tab.
if (!handleInputFields && checkInputableElement(ev.target as HTMLElement)) { if (!handleInputFields && checkInputableElement(ev.target as HTMLElement)) {
switch (action) { switch (action) {
case KeyBindingAction.Tab: case KeyBindingAction.Tab:
handled = true; handled = true;
if (context.state.refs.length > 0) { if (context.state.nodes.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef!); const idx = context.state.nodes.indexOf(context.state.activeNode!);
focusRef = findSiblingElement( focusNode = findSiblingElement(
context.state.refs, context.state.nodes,
idx + (ev.shiftKey ? -1 : 1), idx + (ev.shiftKey ? -1 : 1),
ev.shiftKey, ev.shiftKey,
); );
@ -251,7 +253,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
if (handleHomeEnd) { if (handleHomeEnd) {
handled = true; handled = true;
// move focus to first (visible) item // move focus to first (visible) item
focusRef = findSiblingElement(context.state.refs, 0); focusNode = findSiblingElement(context.state.nodes, 0);
} }
break; break;
@ -259,7 +261,7 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
if (handleHomeEnd) { if (handleHomeEnd) {
handled = true; handled = true;
// move focus to last (visible) item // move focus to last (visible) item
focusRef = findSiblingElement(context.state.refs, context.state.refs.length - 1, true); focusNode = findSiblingElement(context.state.nodes, context.state.nodes.length - 1, true);
} }
break; break;
@ -270,9 +272,9 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
(action === KeyBindingAction.ArrowRight && handleLeftRight) (action === KeyBindingAction.ArrowRight && handleLeftRight)
) { ) {
handled = true; handled = true;
if (context.state.refs.length > 0) { if (context.state.nodes.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef!); const idx = context.state.nodes.indexOf(context.state.activeNode!);
focusRef = findSiblingElement(context.state.refs, idx + 1, false, handleLoop); focusNode = findSiblingElement(context.state.nodes, idx + 1, false, handleLoop);
} }
} }
break; break;
@ -284,9 +286,9 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
(action === KeyBindingAction.ArrowLeft && handleLeftRight) (action === KeyBindingAction.ArrowLeft && handleLeftRight)
) { ) {
handled = true; handled = true;
if (context.state.refs.length > 0) { if (context.state.nodes.length > 0) {
const idx = context.state.refs.indexOf(context.state.activeRef!); const idx = context.state.nodes.indexOf(context.state.activeNode!);
focusRef = findSiblingElement(context.state.refs, idx - 1, true, handleLoop); focusNode = findSiblingElement(context.state.nodes, idx - 1, true, handleLoop);
} }
} }
break; break;
@ -298,17 +300,17 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
ev.stopPropagation(); ev.stopPropagation();
} }
if (focusRef) { if (focusNode) {
focusRef.current?.focus(); focusNode?.focus();
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves // programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
dispatch({ dispatch({
type: Type.SetFocus, type: Type.SetFocus,
payload: { payload: {
ref: focusRef, node: focusNode,
}, },
}); });
if (scrollIntoView) { if (scrollIntoView) {
focusRef.current?.scrollIntoView(scrollIntoView); focusNode?.scrollIntoView(scrollIntoView);
} }
} }
}, },
@ -337,46 +339,61 @@ export const RovingTabIndexProvider: React.FC<IProps> = ({
); );
}; };
// Hook to register a roving tab index /**
// inputRef parameter specifies the ref to use * Hook to register a roving tab index.
// onFocus should be called when the index gained focus in any manner *
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}` * inputRef is an optional argument; when passed this ref points to the DOM element
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition * to which the callback ref is attached.
*
* Returns:
* onFocus should be called when the index gained focus in any manner.
* isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`.
* ref is a callback ref that should be passed to a DOM node which will be used for DOM compareDocumentPosition.
* nodeRef is a ref that points to the DOM element to which the ref mentioned above is attached.
*
* nodeRef = inputRef when inputRef argument is provided.
*/
export const useRovingTabIndex = <T extends HTMLElement>( export const useRovingTabIndex = <T extends HTMLElement>(
inputRef?: RefObject<T>, inputRef?: RefObject<T>,
): [FocusHandler, boolean, RefObject<T>] => { ): [FocusHandler, boolean, RefCallback<T>, RefObject<T | null>] => {
const context = useContext(RovingTabIndexContext); const context = useContext(RovingTabIndexContext);
let ref = useRef<T>(null);
let nodeRef = useRef<T | null>(null);
if (inputRef) { if (inputRef) {
// if we are given a ref, use it instead of ours // if we are given a ref, use it instead of ours
ref = inputRef; nodeRef = inputRef;
} }
// setup (after refs) const ref = useCallback((node: T | null) => {
useEffect(() => { if (node) {
context.dispatch({ nodeRef.current = node;
type: Type.Register, context.dispatch({
payload: { ref }, type: Type.Register,
}); payload: { node },
// teardown });
return () => { } else {
context.dispatch({ context.dispatch({
type: Type.Unregister, type: Type.Unregister,
payload: { ref }, payload: { node: nodeRef.current! },
}); });
}; nodeRef.current = null;
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
const onFocus = useCallback(() => { const onFocus = useCallback(() => {
if (!nodeRef.current) {
console.warn("useRovingTabIndex.onFocus called but the react ref does not point to any DOM element!");
return;
}
context.dispatch({ context.dispatch({
type: Type.SetFocus, type: Type.SetFocus,
payload: { ref }, payload: { node: nodeRef.current },
}); });
}, []); // eslint-disable-line react-hooks/exhaustive-deps }, []); // eslint-disable-line react-hooks/exhaustive-deps
const isActive = context.state.activeRef === ref; const isActive = context.state.activeNode === nodeRef.current;
return [onFocus, isActive, ref]; return [onFocus, isActive, ref, nodeRef];
}; };
// re-export the semantic helper components for simplicity // re-export the semantic helper components for simplicity

View file

@ -6,14 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React, { ReactElement } from "react"; import React, { ReactElement, RefCallback } from "react";
import { useRovingTabIndex } from "../RovingTabIndex"; import { useRovingTabIndex } from "../RovingTabIndex";
import { FocusHandler, Ref } from "./types"; import { FocusHandler, Ref } from "./types";
interface IProps { interface IProps {
inputRef?: Ref; inputRef?: Ref;
children(renderProps: { onFocus: FocusHandler; isActive: boolean; ref: Ref }): ReactElement<any, any>; children(renderProps: {
onFocus: FocusHandler;
isActive: boolean;
ref: RefCallback<HTMLElement>;
}): ReactElement<any, any>;
} }
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components. // Wrapper to allow use of useRovingTabIndex outside of React Functional Components.

View file

@ -114,7 +114,7 @@ const Tile: React.FC<ITileProps> = ({
(room.room_type === RoomType.Space ? _t("common|unnamed_space") : _t("common|unnamed_room")); (room.room_type === RoomType.Space ? _t("common|unnamed_space") : _t("common|unnamed_room"));
const [showChildren, toggleShowChildren] = useStateToggle(true); const [showChildren, toggleShowChildren] = useStateToggle(true);
const [onFocus, isActive, ref] = useRovingTabIndex(); const [onFocus, isActive, ref, nodeRef] = useRovingTabIndex();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const onPreviewClick = (ev: ButtonEvent): void => { const onPreviewClick = (ev: ButtonEvent): void => {
@ -288,7 +288,7 @@ const Tile: React.FC<ITileProps> = ({
case KeyBindingAction.ArrowLeft: case KeyBindingAction.ArrowLeft:
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
ref.current?.focus(); nodeRef.current?.focus();
break; break;
} }
}; };
@ -315,7 +315,7 @@ const Tile: React.FC<ITileProps> = ({
case KeyBindingAction.ArrowRight: case KeyBindingAction.ArrowRight:
handled = true; handled = true;
if (showChildren) { if (showChildren) {
const childSection = ref.current?.nextElementSibling; const childSection = nodeRef.current?.nextElementSibling;
childSection?.querySelector<HTMLDivElement>(".mx_SpaceHierarchy_roomTile")?.focus(); childSection?.querySelector<HTMLDivElement>(".mx_SpaceHierarchy_roomTile")?.focus();
} else { } else {
toggleShowChildren(); toggleShowChildren();
@ -790,7 +790,7 @@ const SpaceHierarchy: React.FC<IProps> = ({ space, initialText = "", showRoom, a
const onKeyDown = (ev: KeyboardEvent, state: IState): void => { const onKeyDown = (ev: KeyboardEvent, state: IState): void => {
const action = getKeyBindingsManager().getAccessibilityAction(ev); const action = getKeyBindingsManager().getAccessibilityAction(ev);
if (action === KeyBindingAction.ArrowDown && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) { if (action === KeyBindingAction.ArrowDown && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) {
state.refs[0]?.current?.focus(); state.nodes[0]?.focus();
} }
}; };

View file

@ -294,7 +294,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
const action = getKeyBindingsManager().getAccessibilityAction(ev); const action = getKeyBindingsManager().getAccessibilityAction(ev);
switch (action) { switch (action) {
case KeyBindingAction.Enter: { case KeyBindingAction.Enter: {
state.activeRef?.current?.querySelector<HTMLButtonElement>(".mx_ForwardList_sendButton")?.click(); state.activeNode?.querySelector<HTMLButtonElement>(".mx_ForwardList_sendButton")?.click();
break; break;
} }
@ -347,13 +347,13 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
onSearch={(query: string): void => { onSearch={(query: string): void => {
setQuery(query); setQuery(query);
setTimeout(() => { setTimeout(() => {
const ref = context.state.refs[0]; const node = context.state.nodes[0];
if (ref) { if (node) {
context.dispatch({ context.dispatch({
type: Type.SetFocus, type: Type.SetFocus,
payload: { ref }, payload: { node },
}); });
ref.current?.scrollIntoView?.({ node?.scrollIntoView?.({
block: "nearest", block: "nearest",
}); });
} }
@ -361,7 +361,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
}} }}
autoFocus={true} autoFocus={true}
onKeyDown={onKeyDownHandler} onKeyDown={onKeyDownHandler}
aria-activedescendant={context.state.activeRef?.current?.id} aria-activedescendant={context.state.activeNode?.id}
aria-owns="mx_ForwardDialog_resultsList" aria-owns="mx_ForwardDialog_resultsList"
/> />
)} )}

View file

@ -7,13 +7,12 @@ Please see LICENSE files in the repository root for full details.
*/ */
import classNames from "classnames"; import classNames from "classnames";
import React, { ReactNode, RefObject } from "react"; import React, { ReactNode } from "react";
import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex"; import { useRovingTabIndex } from "../../../../accessibility/RovingTabIndex";
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton"; import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
interface OptionProps { interface OptionProps {
inputRef?: RefObject<HTMLLIElement>;
endAdornment?: ReactNode; endAdornment?: ReactNode;
id?: string; id?: string;
className?: string; className?: string;
@ -21,8 +20,8 @@ interface OptionProps {
children?: ReactNode; children?: ReactNode;
} }
export const Option: React.FC<OptionProps> = ({ inputRef, children, endAdornment, className, ...props }) => { export const Option: React.FC<OptionProps> = ({ children, endAdornment, className, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLLIElement>(inputRef); const [onFocus, isActive, ref] = useRovingTabIndex<HTMLLIElement>();
return ( return (
<AccessibleButton <AccessibleButton
{...props} {...props}

View file

@ -20,7 +20,7 @@ import {
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types"; import { KnownMembership } from "matrix-js-sdk/src/types";
import { normalize } from "matrix-js-sdk/src/utils"; import { normalize } from "matrix-js-sdk/src/utils";
import React, { ChangeEvent, RefObject, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import React, { ChangeEvent, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import sanitizeHtml from "sanitize-html"; import sanitizeHtml from "sanitize-html";
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts"; import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
@ -90,8 +90,8 @@ interface IProps {
onFinished(): void; onFinished(): void;
} }
function refIsForRecentlyViewed(ref?: RefObject<HTMLElement>): boolean { function nodeIsForRecentlyViewed(node?: HTMLElement): boolean {
return ref?.current?.id?.startsWith("mx_SpotlightDialog_button_recentlyViewed_") === true; return node?.id?.startsWith("mx_SpotlightDialog_button_recentlyViewed_") === true;
} }
function getRoomTypes(filter: Filter | null): Set<RoomType | null> { function getRoomTypes(filter: Filter | null): Set<RoomType | null> {
@ -498,13 +498,13 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
}; };
useEffect(() => { useEffect(() => {
setTimeout(() => { setTimeout(() => {
const ref = rovingContext.state.refs[0]; const node = rovingContext.state.nodes[0];
if (ref) { if (node) {
rovingContext.dispatch({ rovingContext.dispatch({
type: Type.SetFocus, type: Type.SetFocus,
payload: { ref }, payload: { node },
}); });
ref.current?.scrollIntoView?.({ node?.scrollIntoView?.({
block: "nearest", block: "nearest",
}); });
} }
@ -1128,7 +1128,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
break; break;
} }
let ref: RefObject<HTMLElement> | undefined; let node: HTMLElement | undefined;
const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev); const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev);
switch (accessibilityAction) { switch (accessibilityAction) {
case KeyBindingAction.Escape: case KeyBindingAction.Escape:
@ -1141,20 +1141,20 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
if (rovingContext.state.activeRef && rovingContext.state.refs.length > 0) { if (rovingContext.state.activeNode && rovingContext.state.nodes.length > 0) {
let refs = rovingContext.state.refs; let nodes = rovingContext.state.nodes;
if (!query && !filter !== null) { if (!query && !filter !== null) {
// If the current selection is not in the recently viewed row then only include the // If the current selection is not in the recently viewed row then only include the
// first recently viewed so that is the target when the user is switching into recently viewed. // first recently viewed so that is the target when the user is switching into recently viewed.
const keptRecentlyViewedRef = refIsForRecentlyViewed(rovingContext.state.activeRef) const keptRecentlyViewedRef = nodeIsForRecentlyViewed(rovingContext.state.activeNode)
? rovingContext.state.activeRef ? rovingContext.state.activeNode
: refs.find(refIsForRecentlyViewed); : nodes.find(nodeIsForRecentlyViewed);
// exclude all other recently viewed items from the list so up/down arrows skip them // exclude all other recently viewed items from the list so up/down arrows skip them
refs = refs.filter((ref) => ref === keptRecentlyViewedRef || !refIsForRecentlyViewed(ref)); nodes = nodes.filter((ref) => ref === keptRecentlyViewedRef || !nodeIsForRecentlyViewed(ref));
} }
const idx = refs.indexOf(rovingContext.state.activeRef); const idx = nodes.indexOf(rovingContext.state.activeNode);
ref = findSiblingElement(refs, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1)); node = findSiblingElement(nodes, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1));
} }
break; break;
@ -1164,27 +1164,30 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
if ( if (
!query && !query &&
!filter !== null && !filter !== null &&
rovingContext.state.activeRef && rovingContext.state.activeNode &&
rovingContext.state.refs.length > 0 && rovingContext.state.nodes.length > 0 &&
refIsForRecentlyViewed(rovingContext.state.activeRef) nodeIsForRecentlyViewed(rovingContext.state.activeNode)
) { ) {
// we only intercept left/right arrows when the field is empty, and they'd do nothing anyway // we only intercept left/right arrows when the field is empty, and they'd do nothing anyway
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
const refs = rovingContext.state.refs.filter(refIsForRecentlyViewed); const nodes = rovingContext.state.nodes.filter(nodeIsForRecentlyViewed);
const idx = refs.indexOf(rovingContext.state.activeRef); const idx = nodes.indexOf(rovingContext.state.activeNode);
ref = findSiblingElement(refs, idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1)); node = findSiblingElement(
nodes,
idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1),
);
} }
break; break;
} }
if (ref) { if (node) {
rovingContext.dispatch({ rovingContext.dispatch({
type: Type.SetFocus, type: Type.SetFocus,
payload: { ref }, payload: { node },
}); });
ref.current?.scrollIntoView({ node?.scrollIntoView({
block: "nearest", block: "nearest",
}); });
} }
@ -1204,12 +1207,12 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
case KeyBindingAction.Enter: case KeyBindingAction.Enter:
ev.stopPropagation(); ev.stopPropagation();
ev.preventDefault(); ev.preventDefault();
rovingContext.state.activeRef?.current?.click(); rovingContext.state.activeNode?.click();
break; break;
} }
}; };
const activeDescendant = rovingContext.state.activeRef?.current?.id; const activeDescendant = rovingContext.state.activeNode?.id;
return ( return (
<> <>

View file

@ -12,6 +12,9 @@ import React, {
RefObject, RefObject,
createRef, createRef,
ComponentProps, ComponentProps,
MutableRefObject,
RefCallback,
Ref,
} from "react"; } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { debounce } from "lodash"; import { debounce } from "lodash";
@ -75,7 +78,7 @@ interface IProps {
export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> { export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
// The ref pass through to the input // The ref pass through to the input
inputRef?: RefObject<HTMLInputElement>; inputRef?: Ref<HTMLInputElement>;
// The element to create. Defaults to "input". // The element to create. Defaults to "input".
element: "input"; element: "input";
// The input's value. This is a controlled component, so the value is required. // The input's value. This is a controlled component, so the value is required.
@ -84,7 +87,7 @@ export interface IInputProps extends IProps, InputHTMLAttributes<HTMLInputElemen
interface ISelectProps extends IProps, SelectHTMLAttributes<HTMLSelectElement> { interface ISelectProps extends IProps, SelectHTMLAttributes<HTMLSelectElement> {
// The ref pass through to the select // The ref pass through to the select
inputRef?: RefObject<HTMLSelectElement>; inputRef?: Ref<HTMLSelectElement>;
// To define options for a select, use <Field><option ... /></Field> // To define options for a select, use <Field><option ... /></Field>
element: "select"; element: "select";
// The select's value. This is a controlled component, so the value is required. // The select's value. This is a controlled component, so the value is required.
@ -93,7 +96,7 @@ interface ISelectProps extends IProps, SelectHTMLAttributes<HTMLSelectElement> {
interface ITextareaProps extends IProps, TextareaHTMLAttributes<HTMLTextAreaElement> { interface ITextareaProps extends IProps, TextareaHTMLAttributes<HTMLTextAreaElement> {
// The ref pass through to the textarea // The ref pass through to the textarea
inputRef?: RefObject<HTMLTextAreaElement>; inputRef?: Ref<HTMLTextAreaElement>;
element: "textarea"; element: "textarea";
// The textarea's value. This is a controlled component, so the value is required. // The textarea's value. This is a controlled component, so the value is required.
value: string; value: string;
@ -101,7 +104,7 @@ interface ITextareaProps extends IProps, TextareaHTMLAttributes<HTMLTextAreaElem
export interface INativeOnChangeInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> { export interface INativeOnChangeInputProps extends IProps, InputHTMLAttributes<HTMLInputElement> {
// The ref pass through to the input // The ref pass through to the input
inputRef?: RefObject<HTMLInputElement>; inputRef?: Ref<HTMLInputElement>;
element: "input"; element: "input";
// The input's value. This is a controlled component, so the value is required. // The input's value. This is a controlled component, so the value is required.
value: string; value: string;
@ -118,7 +121,17 @@ interface IState {
export default class Field extends React.PureComponent<PropShapes, IState> { export default class Field extends React.PureComponent<PropShapes, IState> {
private readonly id: string; private readonly id: string;
private readonly _inputRef = createRef<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(); private readonly _inputRef: MutableRefObject<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null> =
createRef();
/**
* When props.inputRef is a callback ref, we will pass callbackRef to the DOM element.
* This is so that other methods here can still access the DOM element via this._inputRef.
*/
private readonly callbackRef: RefCallback<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> = (node) => {
this._inputRef.current = node;
(this.props.inputRef as RefCallback<unknown>)(node);
};
public static readonly defaultProps = { public static readonly defaultProps = {
element: "input", element: "input",
@ -230,7 +243,12 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
} }
private get inputRef(): RefObject<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> { private get inputRef(): RefObject<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> {
return this.props.inputRef ?? this._inputRef; const inputRef = this.props.inputRef;
if (typeof inputRef === "function") {
// This is a callback ref, so return _inputRef which will point to the actual DOM element.
return this._inputRef;
}
return (inputRef ?? this._inputRef) as RefObject<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>;
} }
private onTooltipOpenChange = (open: boolean): void => { private onTooltipOpenChange = (open: boolean): void => {
@ -284,7 +302,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
const inputProps_: React.HTMLAttributes<HTMLSelectElement | HTMLInputElement | HTMLTextAreaElement> & const inputProps_: React.HTMLAttributes<HTMLSelectElement | HTMLInputElement | HTMLTextAreaElement> &
React.ClassAttributes<HTMLSelectElement | HTMLInputElement | HTMLTextAreaElement> = { React.ClassAttributes<HTMLSelectElement | HTMLInputElement | HTMLTextAreaElement> = {
...inputProps, ...inputProps,
ref: this.inputRef, ref: typeof this.props.inputRef === "function" ? this.callbackRef : this.inputRef,
}; };
const fieldInput = React.createElement(this.props.element, inputProps_, children); const fieldInput = React.createElement(this.props.element, inputProps_, children);

View file

@ -6,7 +6,7 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import React, { Ref } from "react";
import { randomString } from "matrix-js-sdk/src/randomstring"; import { randomString } from "matrix-js-sdk/src/randomstring";
import classnames from "classnames"; import classnames from "classnames";
@ -16,7 +16,7 @@ export enum CheckboxStyle {
} }
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> { interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
inputRef?: React.RefObject<HTMLInputElement>; inputRef?: Ref<HTMLInputElement>;
kind?: CheckboxStyle; kind?: CheckboxStyle;
id?: string; id?: string;
} }

View file

@ -6,11 +6,11 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import React, { Ref } from "react";
import classnames from "classnames"; import classnames from "classnames";
interface IProps extends React.InputHTMLAttributes<HTMLInputElement> { interface IProps extends React.InputHTMLAttributes<HTMLInputElement> {
inputRef?: React.RefObject<HTMLInputElement>; inputRef?: Ref<HTMLInputElement>;
outlined?: boolean; outlined?: boolean;
// If true (default), the children will be contained within a <label> element // If true (default), the children will be contained within a <label> element
// If false, they'll be in a div. Putting interactive components that have labels // If false, they'll be in a div. Putting interactive components that have labels

View file

@ -28,7 +28,6 @@ import {
import { Key } from "../../../Keyboard"; import { Key } from "../../../Keyboard";
import { clamp } from "../../../utils/numbers"; import { clamp } from "../../../utils/numbers";
import { ButtonEvent } from "../elements/AccessibleButton"; import { ButtonEvent } from "../elements/AccessibleButton";
import { Ref } from "../../../accessibility/roving/types";
export const CATEGORY_HEADER_HEIGHT = 20; export const CATEGORY_HEADER_HEIGHT = 20;
export const EMOJI_HEIGHT = 35; export const EMOJI_HEIGHT = 35;
@ -154,47 +153,47 @@ class EmojiPicker extends React.Component<IProps, IState> {
}; };
private keyboardNavigation(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void { private keyboardNavigation(ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void {
const node = state.activeRef?.current; const node = state.activeNode;
const parent = node?.parentElement; const parent = node?.parentElement;
if (!parent || !state.activeRef) return; if (!parent || !state.activeNode) return;
const rowIndex = Array.from(parent.children).indexOf(node); const rowIndex = Array.from(parent.children).indexOf(node);
const refIndex = state.refs.indexOf(state.activeRef); const refIndex = state.nodes.indexOf(state.activeNode);
let focusRef: Ref | undefined; let focusNode: HTMLElement | undefined;
let newParent: HTMLElement | undefined; let newParent: HTMLElement | undefined;
switch (ev.key) { switch (ev.key) {
case Key.ARROW_LEFT: case Key.ARROW_LEFT:
focusRef = state.refs[refIndex - 1]; focusNode = state.nodes[refIndex - 1];
newParent = focusRef?.current?.parentElement ?? undefined; newParent = focusNode?.parentElement ?? undefined;
break; break;
case Key.ARROW_RIGHT: case Key.ARROW_RIGHT:
focusRef = state.refs[refIndex + 1]; focusNode = state.nodes[refIndex + 1];
newParent = focusRef?.current?.parentElement ?? undefined; newParent = focusNode?.parentElement ?? undefined;
break; break;
case Key.ARROW_UP: case Key.ARROW_UP:
case Key.ARROW_DOWN: { case Key.ARROW_DOWN: {
// For up/down we find the prev/next parent by inspecting the refs either side of our row // For up/down we find the prev/next parent by inspecting the refs either side of our row
const ref = const node =
ev.key === Key.ARROW_UP ev.key === Key.ARROW_UP
? state.refs[refIndex - rowIndex - 1] ? state.nodes[refIndex - rowIndex - 1]
: state.refs[refIndex - rowIndex + EMOJIS_PER_ROW]; : state.nodes[refIndex - rowIndex + EMOJIS_PER_ROW];
newParent = ref?.current?.parentElement ?? undefined; newParent = node?.parentElement ?? undefined;
const newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)]; const newTarget = newParent?.children[clamp(rowIndex, 0, newParent.children.length - 1)];
focusRef = state.refs.find((r) => r.current === newTarget); focusNode = state.nodes.find((r) => r === newTarget);
break; break;
} }
} }
if (focusRef) { if (focusNode) {
dispatch({ dispatch({
type: Type.SetFocus, type: Type.SetFocus,
payload: { ref: focusRef }, payload: { node: focusNode },
}); });
if (parent !== newParent) { if (parent !== newParent) {
focusRef.current?.scrollIntoView({ focusNode?.scrollIntoView({
behavior: "auto", behavior: "auto",
block: "center", block: "center",
inline: "center", inline: "center",
@ -207,10 +206,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
} }
private onKeyDown = (ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void => { private onKeyDown = (ev: React.KeyboardEvent, state: RovingState, dispatch: Dispatch<RovingAction>): void => {
if ( if (state.activeNode && [Key.ARROW_DOWN, Key.ARROW_RIGHT, Key.ARROW_LEFT, Key.ARROW_UP].includes(ev.key)) {
state.activeRef?.current &&
[Key.ARROW_DOWN, Key.ARROW_RIGHT, Key.ARROW_LEFT, Key.ARROW_UP].includes(ev.key)
) {
this.keyboardNavigation(ev, state, dispatch); this.keyboardNavigation(ev, state, dispatch);
} }
}; };

View file

@ -70,7 +70,7 @@ class Search extends React.PureComponent<IProps> {
onChange={(ev) => this.props.onChange(ev.target.value)} onChange={(ev) => this.props.onChange(ev.target.value)}
onKeyDown={this.onKeyDown} onKeyDown={this.onKeyDown}
ref={this.inputRef} ref={this.inputRef}
aria-activedescendant={this.context.state.activeRef?.current?.id} aria-activedescendant={this.context.state.activeNode?.id}
aria-controls="mx_EmojiPicker_body" aria-controls="mx_EmojiPicker_body"
aria-haspopup="grid" aria-haspopup="grid"
aria-autocomplete="list" aria-autocomplete="list"

View file

@ -23,7 +23,7 @@ const JumpToDatePicker: React.FC<IProps> = ({ ts, onDatePicked }: IProps) => {
const dateInputDefaultValue = formatDateForInput(date); const dateInputDefaultValue = formatDateForInput(date);
const [dateValue, setDateValue] = useState(dateInputDefaultValue); const [dateValue, setDateValue] = useState(dateInputDefaultValue);
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>(); const [onFocus, isActive, refCallback] = useRovingTabIndex<HTMLInputElement>();
const onDateValueInput = (ev: React.ChangeEvent<HTMLInputElement>): void => setDateValue(ev.target.value); const onDateValueInput = (ev: React.ChangeEvent<HTMLInputElement>): void => setDateValue(ev.target.value);
const onJumpToDateSubmit = (ev: FormEvent): void => { const onJumpToDateSubmit = (ev: FormEvent): void => {
@ -45,7 +45,7 @@ const JumpToDatePicker: React.FC<IProps> = ({ ts, onDatePicked }: IProps) => {
className="mx_JumpToDatePicker_datePicker" className="mx_JumpToDatePicker_datePicker"
label={_t("room|jump_to_date_prompt")} label={_t("room|jump_to_date_prompt")}
onFocus={onFocus} onFocus={onFocus}
inputRef={ref} inputRef={refCallback}
tabIndex={isActive ? 0 : -1} tabIndex={isActive ? 0 : -1}
/> />
<RovingAccessibleButton <RovingAccessibleButton

View file

@ -73,7 +73,7 @@ export const SpaceButton = <T extends keyof JSX.IntrinsicElements>({
...props ...props
}: ButtonProps<T>): JSX.Element => { }: ButtonProps<T>): JSX.Element => {
const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLElement>(innerRef); const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu<HTMLElement>(innerRef);
const [onFocus, isActive] = useRovingTabIndex(handle); const [onFocus, isActive, ref] = useRovingTabIndex(handle);
const tabIndex = isActive ? 0 : -1; const tabIndex = isActive ? 0 : -1;
const spaceKey = _spaceKey ?? space?.roomId; const spaceKey = _spaceKey ?? space?.roomId;
@ -144,7 +144,7 @@ export const SpaceButton = <T extends keyof JSX.IntrinsicElements>({
title={!isNarrow || menuDisplayed ? undefined : label} title={!isNarrow || menuDisplayed ? undefined : label}
onClick={onClick} onClick={onClick}
onContextMenu={openMenu} onContextMenu={openMenu}
ref={handle} ref={ref}
tabIndex={tabIndex} tabIndex={tabIndex}
onFocus={onFocus} onFocus={onFocus}
> >

View file

@ -28,6 +28,12 @@ const checkTabIndexes = (buttons: NodeListOf<HTMLElement>, expectations: number[
expect([...buttons].map((b) => b.tabIndex)).toStrictEqual(expectations); expect([...buttons].map((b) => b.tabIndex)).toStrictEqual(expectations);
}; };
const createButtonElement = (text: string): HTMLButtonElement => {
const button = document.createElement("button");
button.textContent = text;
return button;
};
// give the buttons keys for the fibre reconciler to not treat them all as the same // give the buttons keys for the fibre reconciler to not treat them all as the same
const button1 = <Button key={1}>a</Button>; const button1 = <Button key={1}>a</Button>;
const button2 = <Button key={2}>b</Button>; const button2 = <Button key={2}>b</Button>;
@ -114,6 +120,25 @@ describe("RovingTabIndex", () => {
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]); checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
}); });
it("RovingTabIndexProvider provides a ref to the dom element", () => {
const nodeRef = React.createRef<HTMLButtonElement>();
const MyButton = (props: HTMLAttributes<HTMLButtonElement>) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLButtonElement>(nodeRef);
return <button {...props} onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref} />;
};
const { container } = render(
<RovingTabIndexProvider>
{() => (
<React.Fragment>
<MyButton />
</React.Fragment>
)}
</RovingTabIndexProvider>,
);
// nodeRef should point to button
expect(nodeRef.current).toBe(container.querySelector("button"));
});
it("RovingTabIndexProvider works as expected with RovingTabIndexWrapper", () => { it("RovingTabIndexProvider works as expected with RovingTabIndexWrapper", () => {
const { container } = render( const { container } = render(
<RovingTabIndexProvider> <RovingTabIndexProvider>
@ -123,11 +148,7 @@ describe("RovingTabIndex", () => {
{button2} {button2}
<RovingTabIndexWrapper> <RovingTabIndexWrapper>
{({ onFocus, isActive, ref }) => ( {({ onFocus, isActive, ref }) => (
<button <button onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref}>
onFocus={onFocus}
tabIndex={isActive ? 0 : -1}
ref={ref as React.RefObject<HTMLButtonElement>}
>
. .
</button> </button>
)} )}
@ -147,75 +168,75 @@ describe("RovingTabIndex", () => {
describe("reducer functions as expected", () => { describe("reducer functions as expected", () => {
it("SetFocus works as expected", () => { it("SetFocus works as expected", () => {
const ref1 = React.createRef<HTMLElement>(); const node1 = createButtonElement("Button 1");
const ref2 = React.createRef<HTMLElement>(); const node2 = createButtonElement("Button 2");
expect( expect(
reducer( reducer(
{ {
activeRef: ref1, activeNode: node1,
refs: [ref1, ref2], nodes: [node1, node2],
}, },
{ {
type: Type.SetFocus, type: Type.SetFocus,
payload: { payload: {
ref: ref2, node: node2,
}, },
}, },
), ),
).toStrictEqual({ ).toStrictEqual({
activeRef: ref2, activeNode: node2,
refs: [ref1, ref2], nodes: [node1, node2],
}); });
}); });
it("Unregister works as expected", () => { it("Unregister works as expected", () => {
const ref1 = React.createRef<HTMLElement>(); const button1 = createButtonElement("Button 1");
const ref2 = React.createRef<HTMLElement>(); const button2 = createButtonElement("Button 2");
const ref3 = React.createRef<HTMLElement>(); const button3 = createButtonElement("Button 3");
const ref4 = React.createRef<HTMLElement>(); const button4 = createButtonElement("Button 4");
let state: IState = { let state: IState = {
refs: [ref1, ref2, ref3, ref4], nodes: [button1, button2, button3, button4],
}; };
state = reducer(state, { state = reducer(state, {
type: Type.Unregister, type: Type.Unregister,
payload: { payload: {
ref: ref2, node: button2,
}, },
}); });
expect(state).toStrictEqual({ expect(state).toStrictEqual({
refs: [ref1, ref3, ref4], nodes: [button1, button3, button4],
}); });
state = reducer(state, { state = reducer(state, {
type: Type.Unregister, type: Type.Unregister,
payload: { payload: {
ref: ref3, node: button3,
}, },
}); });
expect(state).toStrictEqual({ expect(state).toStrictEqual({
refs: [ref1, ref4], nodes: [button1, button4],
}); });
state = reducer(state, { state = reducer(state, {
type: Type.Unregister, type: Type.Unregister,
payload: { payload: {
ref: ref4, node: button4,
}, },
}); });
expect(state).toStrictEqual({ expect(state).toStrictEqual({
refs: [ref1], nodes: [button1],
}); });
state = reducer(state, { state = reducer(state, {
type: Type.Unregister, type: Type.Unregister,
payload: { payload: {
ref: ref1, node: button1,
}, },
}); });
expect(state).toStrictEqual({ expect(state).toStrictEqual({
refs: [], nodes: [],
}); });
}); });
@ -235,122 +256,122 @@ describe("RovingTabIndex", () => {
); );
let state: IState = { let state: IState = {
refs: [], nodes: [],
}; };
state = reducer(state, { state = reducer(state, {
type: Type.Register, type: Type.Register,
payload: { payload: {
ref: ref1, node: ref1.current!,
}, },
}); });
expect(state).toStrictEqual({ expect(state).toStrictEqual({
activeRef: ref1, activeNode: ref1.current,
refs: [ref1], nodes: [ref1.current],
}); });
state = reducer(state, { state = reducer(state, {
type: Type.Register, type: Type.Register,
payload: { payload: {
ref: ref2, node: ref2.current!,
}, },
}); });
expect(state).toStrictEqual({ expect(state).toStrictEqual({
activeRef: ref1, activeNode: ref1.current,
refs: [ref1, ref2], nodes: [ref1.current, ref2.current],
}); });
state = reducer(state, { state = reducer(state, {
type: Type.Register, type: Type.Register,
payload: { payload: {
ref: ref3, node: ref3.current!,
}, },
}); });
expect(state).toStrictEqual({ expect(state).toStrictEqual({
activeRef: ref1, activeNode: ref1.current,
refs: [ref1, ref2, ref3], nodes: [ref1.current, ref2.current, ref3.current],
}); });
state = reducer(state, { state = reducer(state, {
type: Type.Register, type: Type.Register,
payload: { payload: {
ref: ref4, node: ref4.current!,
}, },
}); });
expect(state).toStrictEqual({ expect(state).toStrictEqual({
activeRef: ref1, activeNode: ref1.current,
refs: [ref1, ref2, ref3, ref4], nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
}); });
// test that the automatic focus switch works for unmounting // test that the automatic focus switch works for unmounting
state = reducer(state, { state = reducer(state, {
type: Type.SetFocus, type: Type.SetFocus,
payload: { payload: {
ref: ref2, node: ref2.current!,
}, },
}); });
expect(state).toStrictEqual({ expect(state).toStrictEqual({
activeRef: ref2, activeNode: ref2.current,
refs: [ref1, ref2, ref3, ref4], nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
}); });
state = reducer(state, { state = reducer(state, {
type: Type.Unregister, type: Type.Unregister,
payload: { payload: {
ref: ref2, node: ref2.current!,
}, },
}); });
expect(state).toStrictEqual({ expect(state).toStrictEqual({
activeRef: ref3, activeNode: ref3.current,
refs: [ref1, ref3, ref4], nodes: [ref1.current, ref3.current, ref4.current],
}); });
// test that the insert into the middle works as expected // test that the insert into the middle works as expected
state = reducer(state, { state = reducer(state, {
type: Type.Register, type: Type.Register,
payload: { payload: {
ref: ref2, node: ref2.current!,
}, },
}); });
expect(state).toStrictEqual({ expect(state).toStrictEqual({
activeRef: ref3, activeNode: ref3.current,
refs: [ref1, ref2, ref3, ref4], nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
}); });
// test that insertion at the edges works // test that insertion at the edges works
state = reducer(state, { state = reducer(state, {
type: Type.Unregister, type: Type.Unregister,
payload: { payload: {
ref: ref1, node: ref1.current!,
}, },
}); });
state = reducer(state, { state = reducer(state, {
type: Type.Unregister, type: Type.Unregister,
payload: { payload: {
ref: ref4, node: ref4.current!,
}, },
}); });
expect(state).toStrictEqual({ expect(state).toStrictEqual({
activeRef: ref3, activeNode: ref3.current,
refs: [ref2, ref3], nodes: [ref2.current, ref3.current],
}); });
state = reducer(state, { state = reducer(state, {
type: Type.Register, type: Type.Register,
payload: { payload: {
ref: ref1, node: ref1.current!,
}, },
}); });
state = reducer(state, { state = reducer(state, {
type: Type.Register, type: Type.Register,
payload: { payload: {
ref: ref4, node: ref4.current!,
}, },
}); });
expect(state).toStrictEqual({ expect(state).toStrictEqual({
activeRef: ref3, activeNode: ref3.current,
refs: [ref1, ref2, ref3, ref4], nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
}); });
}); });
}); });