Move RovingTabIndex to shared component and use it in ActionBarView (#33263)
Build / Build on ubuntu-24.04 (push) Failing after 42s
Build and Deploy develop / Build & Deploy develop.element.io (push) Has been skipped
Deploy documentation / GitHub Pages (push) Failing after 35s
Deploy documentation / deploy (push) Has been skipped
Publish shared component storybook / Build storybook (push) Failing after 3m4s
Publish shared component storybook / Publish storybook (push) Has been skipped
Shared Component Visual Tests / Run Visual Tests (push) Failing after 49s
Static Analysis / Docs (push) Failing after 38s
Static Analysis / ESLint (push) Failing after 28s
Static Analysis / Analyse Dead Code (push) Failing after 44s
Static Analysis / Prettier (push) Failing after 36s
Static Analysis / Style Lint (push) Failing after 38s
Static Analysis / Typescript Syntax Check (push) Failing after 38s
Static Analysis / Workflow Lint (push) Failing after 42s
Static Analysis / Rethemendex Check (push) Failing after 41s
Static Analysis / Zizmor Github Actions lint (push) Failing after 37s
Static Analysis / i18n Check (Element Desktop) (push) Failing after 0s
Static Analysis / i18n Check (Shared Components) (push) Failing after 0s
Static Analysis / i18n Check (Element Web) (push) Failing after 0s
Static Analysis / Static Analysis (push) Successful in 1s
Build / Build on macos-14 (push) Has been cancelled
Build / Build on windows-2022 (push) Has been cancelled
Build / Build on ubuntu-24.04 (push) Failing after 42s
Build and Deploy develop / Build & Deploy develop.element.io (push) Has been skipped
Deploy documentation / GitHub Pages (push) Failing after 35s
Deploy documentation / deploy (push) Has been skipped
Publish shared component storybook / Build storybook (push) Failing after 3m4s
Publish shared component storybook / Publish storybook (push) Has been skipped
Shared Component Visual Tests / Run Visual Tests (push) Failing after 49s
Static Analysis / Docs (push) Failing after 38s
Static Analysis / ESLint (push) Failing after 28s
Static Analysis / Analyse Dead Code (push) Failing after 44s
Static Analysis / Prettier (push) Failing after 36s
Static Analysis / Style Lint (push) Failing after 38s
Static Analysis / Typescript Syntax Check (push) Failing after 38s
Static Analysis / Workflow Lint (push) Failing after 42s
Static Analysis / Rethemendex Check (push) Failing after 41s
Static Analysis / Zizmor Github Actions lint (push) Failing after 37s
Static Analysis / i18n Check (Element Desktop) (push) Failing after 0s
Static Analysis / i18n Check (Shared Components) (push) Failing after 0s
Static Analysis / i18n Check (Element Web) (push) Failing after 0s
Static Analysis / Static Analysis (push) Successful in 1s
Build / Build on macos-14 (push) Has been cancelled
Build / Build on windows-2022 (push) Has been cancelled
* Create a new shared component and a wrapper in app/web * Move unit tests and add new for better coverage * Refactor ActionBarView to use the RovingTabIndexProvider * Clean up the interface and adjust callers * Added documentation and renamed type for better readabililty * Reverting the clean up of IContext * Fix Sonar issues * More Sonar issus fixed
This commit is contained in:
@@ -6,23 +6,21 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useReducer,
|
||||
type Reducer,
|
||||
type Dispatch,
|
||||
type RefObject,
|
||||
type ReactNode,
|
||||
type RefCallback,
|
||||
} from "react";
|
||||
import React from "react";
|
||||
import {
|
||||
RovingAction,
|
||||
RovingTabIndexProvider as SharedRovingTabIndexProvider,
|
||||
type RovingTabIndexProviderProps,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
import { getKeyBindingsManager } from "../KeyBindingsManager";
|
||||
import { KeyBindingAction } from "./KeyboardShortcuts";
|
||||
import { type FocusHandler } from "./roving/types";
|
||||
|
||||
export { findNextSiblingElement, RovingTabIndexContext } from "@element-hq/web-shared-components";
|
||||
export { checkInputableElement } from "@element-hq/web-shared-components";
|
||||
export { RovingStateActionType } from "@element-hq/web-shared-components";
|
||||
export { useRovingTabIndex } from "@element-hq/web-shared-components";
|
||||
export type { IAction, IState } from "@element-hq/web-shared-components";
|
||||
|
||||
/**
|
||||
* Module to simplify implementing the Roving TabIndex accessibility technique
|
||||
@@ -37,370 +35,31 @@ import { type FocusHandler } from "./roving/types";
|
||||
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex
|
||||
*/
|
||||
|
||||
// Check for form elements which utilize the arrow keys for native functions
|
||||
// like many of the text input varieties.
|
||||
//
|
||||
// i.e. it's ok to press the down arrow on a radio button to move to the next
|
||||
// radio. But it's not ok to press the down arrow on a <input type="text"> to
|
||||
// move away because the down arrow should move the cursor to the end of the
|
||||
// input.
|
||||
export function checkInputableElement(el: HTMLElement): boolean {
|
||||
return el.matches('input:not([type="radio"]):not([type="checkbox"]), textarea, select, [contenteditable=true]');
|
||||
}
|
||||
|
||||
export interface IState {
|
||||
activeNode?: HTMLElement;
|
||||
nodes: HTMLElement[];
|
||||
}
|
||||
|
||||
export interface IContext {
|
||||
state: IState;
|
||||
dispatch: Dispatch<IAction>;
|
||||
}
|
||||
|
||||
export const RovingTabIndexContext = createContext<IContext>({
|
||||
state: {
|
||||
nodes: [], // list of nodes in DOM order
|
||||
},
|
||||
dispatch: () => {},
|
||||
});
|
||||
RovingTabIndexContext.displayName = "RovingTabIndexContext";
|
||||
|
||||
export enum Type {
|
||||
Register = "REGISTER",
|
||||
Unregister = "UNREGISTER",
|
||||
SetFocus = "SET_FOCUS",
|
||||
Update = "UPDATE",
|
||||
}
|
||||
|
||||
export interface IAction {
|
||||
type: Exclude<Type, Type.Update>;
|
||||
payload: {
|
||||
node: HTMLElement;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateAction {
|
||||
type: Type.Update;
|
||||
payload?: undefined;
|
||||
}
|
||||
|
||||
type Action = IAction | UpdateAction;
|
||||
|
||||
const nodeSorter = (a: HTMLElement, b: HTMLElement): number => {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const position = a.compareDocumentPosition(b);
|
||||
|
||||
if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
|
||||
return -1;
|
||||
} else if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const reducer: Reducer<IState, Action> = (state: IState, action: Action) => {
|
||||
switch (action.type) {
|
||||
case Type.Register: {
|
||||
if (!state.activeNode) {
|
||||
// Our list of nodes was empty, set activeNode to this first item
|
||||
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
|
||||
state.nodes.push(action.payload.node);
|
||||
state.nodes.sort(nodeSorter);
|
||||
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
case Type.Unregister: {
|
||||
const oldIndex = state.nodes.findIndex((r) => r === action.payload.node);
|
||||
|
||||
if (oldIndex === -1) {
|
||||
return state; // already removed, this should not happen
|
||||
}
|
||||
|
||||
if (state.nodes.splice(oldIndex, 1)[0] === state.activeNode) {
|
||||
// we just removed the active node, need to replace it
|
||||
// pick the node closest to the index the old node was in
|
||||
if (oldIndex >= state.nodes.length) {
|
||||
state.activeNode = findSiblingElement(state.nodes, state.nodes.length - 1, true);
|
||||
} else {
|
||||
state.activeNode =
|
||||
findSiblingElement(state.nodes, oldIndex) || findSiblingElement(state.nodes, oldIndex, true);
|
||||
}
|
||||
if (document.activeElement === document.body) {
|
||||
// if the focus got reverted to the body then the user was likely focused on the unmounted element
|
||||
setTimeout(() => state.activeNode?.focus(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// update the nodes list
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
case Type.SetFocus: {
|
||||
// if the node doesn't change just return the same object reference to skip a re-render
|
||||
if (state.activeNode === action.payload.node) return state;
|
||||
// update active node
|
||||
state.activeNode = action.payload.node;
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
case Type.Update: {
|
||||
state.nodes.sort(nodeSorter);
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
const getWebRovingAction = (ev: React.KeyboardEvent): RovingAction | undefined => {
|
||||
switch (getKeyBindingsManager().getAccessibilityAction(ev)) {
|
||||
case KeyBindingAction.Home:
|
||||
return RovingAction.Home;
|
||||
case KeyBindingAction.End:
|
||||
return RovingAction.End;
|
||||
case KeyBindingAction.ArrowLeft:
|
||||
return RovingAction.ArrowLeft;
|
||||
case KeyBindingAction.ArrowUp:
|
||||
return RovingAction.ArrowUp;
|
||||
case KeyBindingAction.ArrowRight:
|
||||
return RovingAction.ArrowRight;
|
||||
case KeyBindingAction.ArrowDown:
|
||||
return RovingAction.ArrowDown;
|
||||
case KeyBindingAction.Tab:
|
||||
return RovingAction.Tab;
|
||||
default:
|
||||
return state;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
handleLoop?: boolean;
|
||||
handleHomeEnd?: boolean;
|
||||
handleUpDown?: boolean;
|
||||
handleLeftRight?: boolean;
|
||||
handleInputFields?: boolean;
|
||||
scrollIntoView?: boolean | ScrollIntoViewOptions;
|
||||
children(
|
||||
this: void,
|
||||
renderProps: {
|
||||
onKeyDownHandler(this: void, ev: React.KeyboardEvent): void;
|
||||
onDragEndHandler(this: void): void;
|
||||
},
|
||||
): ReactNode;
|
||||
onKeyDown?(this: void, ev: React.KeyboardEvent, state: IState, dispatch: Dispatch<IAction>): void;
|
||||
}
|
||||
type IProps = Omit<RovingTabIndexProviderProps, "getAction">;
|
||||
|
||||
export const findSiblingElement = (
|
||||
nodes: HTMLElement[],
|
||||
startIndex: number,
|
||||
backwards = false,
|
||||
loop = false,
|
||||
): HTMLElement | undefined => {
|
||||
if (backwards) {
|
||||
for (let i = startIndex; i < nodes.length && i >= 0; i--) {
|
||||
if (nodes[i]?.offsetParent !== null) {
|
||||
return nodes[i];
|
||||
}
|
||||
}
|
||||
if (loop) {
|
||||
return findSiblingElement(nodes.slice(startIndex + 1), nodes.length - 1, true, false);
|
||||
}
|
||||
} else {
|
||||
for (let i = startIndex; i < nodes.length && i >= 0; i++) {
|
||||
if (nodes[i]?.offsetParent !== null) {
|
||||
return nodes[i];
|
||||
}
|
||||
}
|
||||
if (loop) {
|
||||
return findSiblingElement(nodes.slice(0, startIndex), 0, false, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||
children,
|
||||
handleHomeEnd,
|
||||
handleUpDown,
|
||||
handleLeftRight,
|
||||
handleLoop,
|
||||
handleInputFields,
|
||||
scrollIntoView,
|
||||
onKeyDown,
|
||||
}) => {
|
||||
const [state, dispatch] = useReducer<IState, [Action]>(reducer, {
|
||||
nodes: [],
|
||||
});
|
||||
|
||||
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
|
||||
|
||||
const onKeyDownHandler = useCallback(
|
||||
(ev: React.KeyboardEvent) => {
|
||||
if (onKeyDown) {
|
||||
onKeyDown(ev, context.state, context.dispatch);
|
||||
if (ev.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let handled = false;
|
||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||
let focusNode: HTMLElement | undefined;
|
||||
// Don't interfere with input default keydown behaviour
|
||||
// but allow people to move focus from it with Tab.
|
||||
if (!handleInputFields && checkInputableElement(ev.target as HTMLElement)) {
|
||||
switch (action) {
|
||||
case KeyBindingAction.Tab:
|
||||
handled = true;
|
||||
if (context.state.nodes.length > 0) {
|
||||
const idx = context.state.nodes.indexOf(context.state.activeNode!);
|
||||
focusNode = findSiblingElement(
|
||||
context.state.nodes,
|
||||
idx + (ev.shiftKey ? -1 : 1),
|
||||
ev.shiftKey,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// check if we actually have any items
|
||||
switch (action) {
|
||||
case KeyBindingAction.Home:
|
||||
if (handleHomeEnd) {
|
||||
handled = true;
|
||||
// move focus to first (visible) item
|
||||
focusNode = findSiblingElement(context.state.nodes, 0);
|
||||
}
|
||||
break;
|
||||
|
||||
case KeyBindingAction.End:
|
||||
if (handleHomeEnd) {
|
||||
handled = true;
|
||||
// move focus to last (visible) item
|
||||
focusNode = findSiblingElement(context.state.nodes, context.state.nodes.length - 1, true);
|
||||
}
|
||||
break;
|
||||
|
||||
case KeyBindingAction.ArrowDown:
|
||||
case KeyBindingAction.ArrowRight:
|
||||
if (
|
||||
(action === KeyBindingAction.ArrowDown && handleUpDown) ||
|
||||
(action === KeyBindingAction.ArrowRight && handleLeftRight)
|
||||
) {
|
||||
handled = true;
|
||||
if (context.state.nodes.length > 0) {
|
||||
const idx = context.state.nodes.indexOf(context.state.activeNode!);
|
||||
focusNode = findSiblingElement(context.state.nodes, idx + 1, false, handleLoop);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case KeyBindingAction.ArrowUp:
|
||||
case KeyBindingAction.ArrowLeft:
|
||||
if (
|
||||
(action === KeyBindingAction.ArrowUp && handleUpDown) ||
|
||||
(action === KeyBindingAction.ArrowLeft && handleLeftRight)
|
||||
) {
|
||||
handled = true;
|
||||
if (context.state.nodes.length > 0) {
|
||||
const idx = context.state.nodes.indexOf(context.state.activeNode!);
|
||||
focusNode = findSiblingElement(context.state.nodes, idx - 1, true, handleLoop);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
if (focusNode) {
|
||||
focusNode?.focus();
|
||||
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
|
||||
dispatch({
|
||||
type: Type.SetFocus,
|
||||
payload: {
|
||||
node: focusNode,
|
||||
},
|
||||
});
|
||||
if (scrollIntoView) {
|
||||
focusNode?.scrollIntoView(scrollIntoView);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
context,
|
||||
onKeyDown,
|
||||
handleHomeEnd,
|
||||
handleUpDown,
|
||||
handleLeftRight,
|
||||
handleLoop,
|
||||
handleInputFields,
|
||||
scrollIntoView,
|
||||
],
|
||||
);
|
||||
|
||||
const onDragEndHandler = useCallback(() => {
|
||||
dispatch({
|
||||
type: Type.Update,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RovingTabIndexContext.Provider value={context}>
|
||||
{children({ onKeyDownHandler, onDragEndHandler })}
|
||||
</RovingTabIndexContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to register a roving tab index.
|
||||
*
|
||||
* inputRef is an optional argument; when passed this ref points to the DOM element
|
||||
* 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>(
|
||||
inputRef?: RefObject<T | null>,
|
||||
): [FocusHandler, boolean, RefCallback<T>, RefObject<T | null>] => {
|
||||
const context = useContext(RovingTabIndexContext);
|
||||
|
||||
let nodeRef = useRef<T | null>(null);
|
||||
|
||||
if (inputRef) {
|
||||
// if we are given a ref, use it instead of ours
|
||||
nodeRef = inputRef;
|
||||
}
|
||||
|
||||
const ref = useCallback((node: T | null) => {
|
||||
if (node) {
|
||||
nodeRef.current = node;
|
||||
context.dispatch({
|
||||
type: Type.Register,
|
||||
payload: { node },
|
||||
});
|
||||
} else {
|
||||
context.dispatch({
|
||||
type: Type.Unregister,
|
||||
payload: { node: nodeRef.current! },
|
||||
});
|
||||
nodeRef.current = null;
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
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({
|
||||
type: Type.SetFocus,
|
||||
payload: { node: nodeRef.current },
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// eslint-disable-next-line react-compiler/react-compiler
|
||||
const isActive = context.state.activeNode === nodeRef.current;
|
||||
return [onFocus, isActive, ref, nodeRef];
|
||||
export const RovingTabIndexProvider: React.FC<IProps> = (props) => {
|
||||
return <SharedRovingTabIndexProvider {...props} getAction={getWebRovingAction} />;
|
||||
};
|
||||
|
||||
// re-export the semantic helper components for simplicity
|
||||
|
||||
@@ -6,26 +6,4 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type ReactElement, type RefCallback, type RefObject } from "react";
|
||||
|
||||
import type React from "react";
|
||||
import { useRovingTabIndex } from "../RovingTabIndex";
|
||||
import { type FocusHandler } from "./types";
|
||||
|
||||
interface IProps {
|
||||
inputRef?: RefObject<HTMLElement | null>;
|
||||
children(
|
||||
this: void,
|
||||
renderProps: {
|
||||
onFocus: FocusHandler;
|
||||
isActive: boolean;
|
||||
ref: RefCallback<HTMLElement>;
|
||||
},
|
||||
): ReactElement<any, any>;
|
||||
}
|
||||
|
||||
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
|
||||
export const RovingTabIndexWrapper: React.FC<IProps> = ({ children, inputRef }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return children({ onFocus, isActive, ref });
|
||||
};
|
||||
export { RovingTabIndexWrapper } from "@element-hq/web-shared-components";
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export type FocusHandler = () => void;
|
||||
@@ -50,9 +50,9 @@ import { RoomContextDetails } from "../rooms/RoomContextDetails";
|
||||
import { filterBoolean } from "../../../utils/arrays";
|
||||
import {
|
||||
type IState,
|
||||
RovingStateActionType,
|
||||
RovingTabIndexContext,
|
||||
RovingTabIndexProvider,
|
||||
Type,
|
||||
useRovingTabIndex,
|
||||
} from "../../../accessibility/RovingTabIndex";
|
||||
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
|
||||
@@ -368,7 +368,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
|
||||
const node = context.state.nodes[0];
|
||||
if (node) {
|
||||
context.dispatch({
|
||||
type: Type.SetFocus,
|
||||
type: RovingStateActionType.SetFocus,
|
||||
payload: { node },
|
||||
});
|
||||
node?.scrollIntoView?.({
|
||||
|
||||
@@ -44,10 +44,10 @@ import {
|
||||
|
||||
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
|
||||
import {
|
||||
findSiblingElement,
|
||||
findNextSiblingElement,
|
||||
RovingStateActionType,
|
||||
RovingTabIndexContext,
|
||||
RovingTabIndexProvider,
|
||||
Type,
|
||||
} from "../../../../accessibility/RovingTabIndex";
|
||||
import { mediaFromMxc } from "../../../../customisations/Media";
|
||||
import { Action } from "../../../../dispatcher/actions";
|
||||
@@ -537,7 +537,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||
const node = rovingContext.state.nodes[0];
|
||||
if (node) {
|
||||
rovingContext.dispatch({
|
||||
type: Type.SetFocus,
|
||||
type: RovingStateActionType.SetFocus,
|
||||
payload: { node },
|
||||
});
|
||||
node?.scrollIntoView?.({
|
||||
@@ -1181,7 +1181,10 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||
}
|
||||
|
||||
const idx = nodes.indexOf(rovingContext.state.activeNode);
|
||||
node = findSiblingElement(nodes, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1));
|
||||
node = findNextSiblingElement(
|
||||
nodes,
|
||||
idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -1201,7 +1204,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||
|
||||
const nodes = rovingContext.state.nodes.filter(nodeIsForRecentlyViewed);
|
||||
const idx = nodes.indexOf(rovingContext.state.activeNode);
|
||||
node = findSiblingElement(
|
||||
node = findNextSiblingElement(
|
||||
nodes,
|
||||
idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1),
|
||||
);
|
||||
@@ -1211,7 +1214,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
|
||||
|
||||
if (node) {
|
||||
rovingContext.dispatch({
|
||||
type: Type.SetFocus,
|
||||
type: RovingStateActionType.SetFocus,
|
||||
payload: { node },
|
||||
});
|
||||
node?.scrollIntoView({
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
type IAction as RovingAction,
|
||||
type IState as RovingState,
|
||||
RovingTabIndexProvider,
|
||||
Type,
|
||||
RovingStateActionType,
|
||||
} from "../../../accessibility/RovingTabIndex";
|
||||
import { Key } from "../../../Keyboard";
|
||||
import { type ButtonEvent } from "../elements/AccessibleButton";
|
||||
@@ -187,7 +187,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||
focusNode?.focus();
|
||||
}
|
||||
dispatch({
|
||||
type: Type.SetFocus,
|
||||
type: RovingStateActionType.SetFocus,
|
||||
payload: { node: focusNode },
|
||||
});
|
||||
|
||||
@@ -212,7 +212,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
|
||||
// Reset to first emoji when showing highlight for the first time (or after it was hidden)
|
||||
if (state.nodes.length > 0) {
|
||||
dispatch({
|
||||
type: Type.SetFocus,
|
||||
type: RovingStateActionType.SetFocus,
|
||||
payload: { node: state.nodes[0] },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type HTMLAttributes } from "react";
|
||||
import { act, render } from "jest-matrix-react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import {
|
||||
type IState,
|
||||
reducer,
|
||||
RovingTabIndexProvider,
|
||||
RovingTabIndexWrapper,
|
||||
Type,
|
||||
useRovingTabIndex,
|
||||
} from "../../../src/accessibility/RovingTabIndex";
|
||||
|
||||
const Button = (props: HTMLAttributes<HTMLButtonElement>) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLButtonElement>();
|
||||
return <button {...props} onFocus={onFocus} tabIndex={isActive ? 0 : -1} ref={ref} />;
|
||||
};
|
||||
|
||||
const checkTabIndexes = (buttons: NodeListOf<HTMLElement>, expectations: number[]) => {
|
||||
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
|
||||
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>;
|
||||
|
||||
// mock offsetParent
|
||||
Object.defineProperty(HTMLElement.prototype, "offsetParent", {
|
||||
get() {
|
||||
return this.parentNode;
|
||||
},
|
||||
});
|
||||
|
||||
describe("RovingTabIndex", () => {
|
||||
it("RovingTabIndexProvider renders children as expected", () => {
|
||||
const { container } = render(
|
||||
<RovingTabIndexProvider>
|
||||
{() => (
|
||||
<div>
|
||||
<span>Test</span>
|
||||
</div>
|
||||
)}
|
||||
</RovingTabIndexProvider>,
|
||||
);
|
||||
expect(container.textContent).toBe("Test");
|
||||
expect(container.innerHTML).toBe("<div><span>Test</span></div>");
|
||||
});
|
||||
|
||||
it("RovingTabIndexProvider works as expected with useRovingTabIndex", () => {
|
||||
const { container, rerender } = render(
|
||||
<RovingTabIndexProvider>
|
||||
{() => (
|
||||
<React.Fragment>
|
||||
{button1}
|
||||
{button2}
|
||||
{button3}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</RovingTabIndexProvider>,
|
||||
);
|
||||
|
||||
// should begin with 0th being active
|
||||
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
|
||||
|
||||
// focus on 2nd button and test it is the only active one
|
||||
act(() => container.querySelectorAll("button")[2].focus());
|
||||
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
|
||||
|
||||
// focus on 1st button and test it is the only active one
|
||||
act(() => container.querySelectorAll("button")[1].focus());
|
||||
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
|
||||
|
||||
// check that the active button does not change even on an explicit blur event
|
||||
act(() => container.querySelectorAll("button")[1].blur());
|
||||
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
|
||||
|
||||
// update the children, it should remain on the same button
|
||||
rerender(
|
||||
<RovingTabIndexProvider>
|
||||
{() => (
|
||||
<React.Fragment>
|
||||
{button1}
|
||||
{button4}
|
||||
{button2}
|
||||
{button3}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</RovingTabIndexProvider>,
|
||||
);
|
||||
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0, -1]);
|
||||
|
||||
// update the children, remove the active button, it should move to the next one
|
||||
rerender(
|
||||
<RovingTabIndexProvider>
|
||||
{() => (
|
||||
<React.Fragment>
|
||||
{button1}
|
||||
{button4}
|
||||
{button3}
|
||||
</React.Fragment>
|
||||
)}
|
||||
</RovingTabIndexProvider>,
|
||||
);
|
||||
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", () => {
|
||||
const { container } = render(
|
||||
<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(container.querySelectorAll("button"), [0, -1, -1]);
|
||||
|
||||
// focus on 2nd button and test it is the only active one
|
||||
act(() => container.querySelectorAll("button")[2].focus());
|
||||
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
|
||||
});
|
||||
|
||||
describe("reducer functions as expected", () => {
|
||||
it("SetFocus works as expected", () => {
|
||||
const node1 = createButtonElement("Button 1");
|
||||
const node2 = createButtonElement("Button 2");
|
||||
expect(
|
||||
reducer(
|
||||
{
|
||||
activeNode: node1,
|
||||
nodes: [node1, node2],
|
||||
},
|
||||
{
|
||||
type: Type.SetFocus,
|
||||
payload: {
|
||||
node: node2,
|
||||
},
|
||||
},
|
||||
),
|
||||
).toStrictEqual({
|
||||
activeNode: node2,
|
||||
nodes: [node1, node2],
|
||||
});
|
||||
});
|
||||
|
||||
it("Unregister works as expected", () => {
|
||||
const button1 = createButtonElement("Button 1");
|
||||
const button2 = createButtonElement("Button 2");
|
||||
const button3 = createButtonElement("Button 3");
|
||||
const button4 = createButtonElement("Button 4");
|
||||
|
||||
let state: IState = {
|
||||
nodes: [button1, button2, button3, button4],
|
||||
};
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Unregister,
|
||||
payload: {
|
||||
node: button2,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
nodes: [button1, button3, button4],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Unregister,
|
||||
payload: {
|
||||
node: button3,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
nodes: [button1, button4],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Unregister,
|
||||
payload: {
|
||||
node: button4,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
nodes: [button1],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Unregister,
|
||||
payload: {
|
||||
node: button1,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
nodes: [],
|
||||
});
|
||||
});
|
||||
|
||||
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>();
|
||||
|
||||
render(
|
||||
<React.Fragment>
|
||||
<span ref={ref1} />
|
||||
<span ref={ref2} />
|
||||
<span ref={ref3} />
|
||||
<span ref={ref4} />
|
||||
</React.Fragment>,
|
||||
);
|
||||
|
||||
let state: IState = {
|
||||
nodes: [],
|
||||
};
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Register,
|
||||
payload: {
|
||||
node: ref1.current!,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeNode: ref1.current,
|
||||
nodes: [ref1.current],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Register,
|
||||
payload: {
|
||||
node: ref2.current!,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeNode: ref1.current,
|
||||
nodes: [ref1.current, ref2.current],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Register,
|
||||
payload: {
|
||||
node: ref3.current!,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeNode: ref1.current,
|
||||
nodes: [ref1.current, ref2.current, ref3.current],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Register,
|
||||
payload: {
|
||||
node: ref4.current!,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeNode: ref1.current,
|
||||
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
|
||||
});
|
||||
|
||||
// test that the automatic focus switch works for unmounting
|
||||
state = reducer(state, {
|
||||
type: Type.SetFocus,
|
||||
payload: {
|
||||
node: ref2.current!,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeNode: ref2.current,
|
||||
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Unregister,
|
||||
payload: {
|
||||
node: ref2.current!,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeNode: ref3.current,
|
||||
nodes: [ref1.current, ref3.current, ref4.current],
|
||||
});
|
||||
|
||||
// test that the insert into the middle works as expected
|
||||
state = reducer(state, {
|
||||
type: Type.Register,
|
||||
payload: {
|
||||
node: ref2.current!,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeNode: ref3.current,
|
||||
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
|
||||
});
|
||||
|
||||
// test that insertion at the edges works
|
||||
state = reducer(state, {
|
||||
type: Type.Unregister,
|
||||
payload: {
|
||||
node: ref1.current!,
|
||||
},
|
||||
});
|
||||
state = reducer(state, {
|
||||
type: Type.Unregister,
|
||||
payload: {
|
||||
node: ref4.current!,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeNode: ref3.current,
|
||||
nodes: [ref2.current, ref3.current],
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Register,
|
||||
payload: {
|
||||
node: ref1.current!,
|
||||
},
|
||||
});
|
||||
|
||||
state = reducer(state, {
|
||||
type: Type.Register,
|
||||
payload: {
|
||||
node: ref4.current!,
|
||||
},
|
||||
});
|
||||
expect(state).toStrictEqual({
|
||||
activeNode: ref3.current,
|
||||
nodes: [ref1.current, ref2.current, ref3.current, ref4.current],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("handles arrow keys", () => {
|
||||
it("should handle up/down arrow keys work when handleUpDown=true", async () => {
|
||||
const { container } = render(
|
||||
<RovingTabIndexProvider handleUpDown>
|
||||
{({ onKeyDownHandler }) => (
|
||||
<div onKeyDown={onKeyDownHandler}>
|
||||
{button1}
|
||||
{button2}
|
||||
{button3}
|
||||
</div>
|
||||
)}
|
||||
</RovingTabIndexProvider>,
|
||||
);
|
||||
|
||||
act(() => container.querySelectorAll("button")[0].focus());
|
||||
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
|
||||
|
||||
await userEvent.keyboard("[ArrowDown]");
|
||||
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
|
||||
|
||||
await userEvent.keyboard("[ArrowDown]");
|
||||
checkTabIndexes(container.querySelectorAll("button"), [-1, -1, 0]);
|
||||
|
||||
await userEvent.keyboard("[ArrowUp]");
|
||||
checkTabIndexes(container.querySelectorAll("button"), [-1, 0, -1]);
|
||||
|
||||
await userEvent.keyboard("[ArrowUp]");
|
||||
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
|
||||
|
||||
// Does not loop without
|
||||
await userEvent.keyboard("[ArrowUp]");
|
||||
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
|
||||
});
|
||||
|
||||
it("should call scrollIntoView if specified", async () => {
|
||||
const { container } = render(
|
||||
<RovingTabIndexProvider handleUpDown scrollIntoView>
|
||||
{({ onKeyDownHandler }) => (
|
||||
<div onKeyDown={onKeyDownHandler}>
|
||||
{button1}
|
||||
{button2}
|
||||
{button3}
|
||||
</div>
|
||||
)}
|
||||
</RovingTabIndexProvider>,
|
||||
);
|
||||
|
||||
act(() => container.querySelectorAll("button")[0].focus());
|
||||
checkTabIndexes(container.querySelectorAll("button"), [0, -1, -1]);
|
||||
|
||||
const button = container.querySelectorAll("button")[1];
|
||||
const mock = jest.spyOn(button, "scrollIntoView");
|
||||
await userEvent.keyboard("[ArrowDown]");
|
||||
expect(mock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render } from "jest-matrix-react";
|
||||
import { RovingAction, type RovingTabIndexProviderProps } from "@element-hq/web-shared-components";
|
||||
|
||||
import * as KeyBindingsManagerModule from "../../../src/KeyBindingsManager";
|
||||
import { KeyBindingAction } from "../../../src/accessibility/KeyboardShortcuts";
|
||||
import { RovingTabIndexProvider } from "../../../src/accessibility/RovingTabIndex";
|
||||
|
||||
jest.mock("@element-hq/web-shared-components", () => {
|
||||
const actual = jest.requireActual("@element-hq/web-shared-components");
|
||||
const mockSharedRovingTabIndexProvider = jest.fn(({ children }: RovingTabIndexProviderProps) => {
|
||||
return <>{children({ onDragEndHandler: jest.fn(), onKeyDownHandler: jest.fn() })}</>;
|
||||
});
|
||||
|
||||
return {
|
||||
__mockSharedRovingTabIndexProvider: mockSharedRovingTabIndexProvider,
|
||||
...actual,
|
||||
RovingTabIndexProvider: mockSharedRovingTabIndexProvider,
|
||||
};
|
||||
});
|
||||
|
||||
const getMockSharedRovingTabIndexProvider = (): jest.Mock => {
|
||||
return jest.requireMock("@element-hq/web-shared-components").__mockSharedRovingTabIndexProvider as jest.Mock;
|
||||
};
|
||||
|
||||
const getInjectedGetAction = (): NonNullable<RovingTabIndexProviderProps["getAction"]> => {
|
||||
const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider();
|
||||
expect(mockSharedRovingTabIndexProvider).toHaveBeenCalled();
|
||||
const getAction = (mockSharedRovingTabIndexProvider.mock.calls.at(-1)![0] as RovingTabIndexProviderProps).getAction;
|
||||
expect(getAction).toBeDefined();
|
||||
return getAction!;
|
||||
};
|
||||
|
||||
describe("RovingTabIndex adapter", () => {
|
||||
beforeEach(() => {
|
||||
const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider();
|
||||
mockSharedRovingTabIndexProvider.mockClear();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[KeyBindingAction.ArrowDown, RovingAction.ArrowDown],
|
||||
[KeyBindingAction.ArrowUp, RovingAction.ArrowUp],
|
||||
[KeyBindingAction.ArrowRight, RovingAction.ArrowRight],
|
||||
[KeyBindingAction.ArrowLeft, RovingAction.ArrowLeft],
|
||||
[KeyBindingAction.Home, RovingAction.Home],
|
||||
[KeyBindingAction.End, RovingAction.End],
|
||||
[KeyBindingAction.Tab, RovingAction.Tab],
|
||||
])("maps %s to %s", (accessibilityAction, expectedRovingAction) => {
|
||||
const manager = new KeyBindingsManagerModule.KeyBindingsManager();
|
||||
jest.spyOn(KeyBindingsManagerModule, "getKeyBindingsManager").mockReturnValue(manager);
|
||||
jest.spyOn(manager, "getAccessibilityAction").mockReturnValue(accessibilityAction);
|
||||
|
||||
render(<RovingTabIndexProvider>{() => null}</RovingTabIndexProvider>);
|
||||
|
||||
const getAction = getInjectedGetAction();
|
||||
expect(getAction({ key: "irrelevant" } as React.KeyboardEvent)).toBe(expectedRovingAction);
|
||||
});
|
||||
|
||||
it("returns undefined when there is no matching accessibility action", () => {
|
||||
const manager = new KeyBindingsManagerModule.KeyBindingsManager();
|
||||
jest.spyOn(KeyBindingsManagerModule, "getKeyBindingsManager").mockReturnValue(manager);
|
||||
jest.spyOn(manager, "getAccessibilityAction").mockReturnValue(undefined);
|
||||
|
||||
render(<RovingTabIndexProvider>{() => null}</RovingTabIndexProvider>);
|
||||
|
||||
const getAction = getInjectedGetAction();
|
||||
expect(getAction({ key: "x" } as React.KeyboardEvent)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("forwards provider props to shared-components", () => {
|
||||
const onKeyDown = jest.fn();
|
||||
|
||||
render(
|
||||
<RovingTabIndexProvider handleHomeEnd handleLoop handleUpDown onKeyDown={onKeyDown} scrollIntoView>
|
||||
{() => null}
|
||||
</RovingTabIndexProvider>,
|
||||
);
|
||||
|
||||
const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider();
|
||||
const props = mockSharedRovingTabIndexProvider.mock.calls.at(-1)![0] as RovingTabIndexProviderProps;
|
||||
expect(props.handleHomeEnd).toBe(true);
|
||||
expect(props.handleLoop).toBe(true);
|
||||
expect(props.handleUpDown).toBe(true);
|
||||
expect(props.onKeyDown).toBe(onKeyDown);
|
||||
expect(props.scrollIntoView).toBe(true);
|
||||
expect(props.getAction).toEqual(expect.any(Function));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user