Skip to content
Open
409 changes: 36 additions & 373 deletions apps/web/src/accessibility/RovingTabIndex.tsx

Large diffs are not rendered by default.

24 changes: 1 addition & 23 deletions apps/web/src/accessibility/roving/RovingTabIndexWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
9 changes: 0 additions & 9 deletions apps/web/src/accessibility/roving/types.ts

This file was deleted.

4 changes: 2 additions & 2 deletions apps/web/src/components/views/dialogs/ForwardDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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?.({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ import {
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
import {
findSiblingElement,
RovingStateActionType,
RovingTabIndexContext,
RovingTabIndexProvider,
Type,
} from "../../../../accessibility/RovingTabIndex";
import { mediaFromMxc } from "../../../../customisations/Media";
import { Action } from "../../../../dispatcher/actions";
Expand Down Expand Up @@ -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?.({
Expand Down Expand Up @@ -1211,7 +1211,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n

if (node) {
rovingContext.dispatch({
type: Type.SetFocus,
type: RovingStateActionType.SetFocus,
payload: { node },
});
node?.scrollIntoView({
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/views/emojipicker/EmojiPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -187,7 +187,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
focusNode?.focus();
}
dispatch({
type: Type.SetFocus,
type: RovingStateActionType.SetFocus,
payload: { node: focusNode },
});

Expand All @@ -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] },
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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));
});
});
Loading
Loading