Skip to content

Commit bb4a7e9

Browse files
authored
Move RovingTabIndex to shared component and use it in ActionBarView (#33263)
* 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
1 parent 1a6b0e2 commit bb4a7e9

14 files changed

Lines changed: 1067 additions & 602 deletions

File tree

apps/web/src/accessibility/RovingTabIndex.tsx

Lines changed: 32 additions & 373 deletions
Large diffs are not rendered by default.

apps/web/src/accessibility/roving/RovingTabIndexWrapper.tsx

Lines changed: 1 addition & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,4 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
66
Please see LICENSE files in the repository root for full details.
77
*/
88

9-
import { type ReactElement, type RefCallback, type RefObject } from "react";
10-
11-
import type React from "react";
12-
import { useRovingTabIndex } from "../RovingTabIndex";
13-
import { type FocusHandler } from "./types";
14-
15-
interface IProps {
16-
inputRef?: RefObject<HTMLElement | null>;
17-
children(
18-
this: void,
19-
renderProps: {
20-
onFocus: FocusHandler;
21-
isActive: boolean;
22-
ref: RefCallback<HTMLElement>;
23-
},
24-
): ReactElement<any, any>;
25-
}
26-
27-
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
28-
export const RovingTabIndexWrapper: React.FC<IProps> = ({ children, inputRef }) => {
29-
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
30-
return children({ onFocus, isActive, ref });
31-
};
9+
export { RovingTabIndexWrapper } from "@element-hq/web-shared-components";

apps/web/src/accessibility/roving/types.ts

Lines changed: 0 additions & 9 deletions
This file was deleted.

apps/web/src/components/views/dialogs/ForwardDialog.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,9 @@ import { RoomContextDetails } from "../rooms/RoomContextDetails";
5050
import { filterBoolean } from "../../../utils/arrays";
5151
import {
5252
type IState,
53+
RovingStateActionType,
5354
RovingTabIndexContext,
5455
RovingTabIndexProvider,
55-
Type,
5656
useRovingTabIndex,
5757
} from "../../../accessibility/RovingTabIndex";
5858
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
@@ -368,7 +368,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
368368
const node = context.state.nodes[0];
369369
if (node) {
370370
context.dispatch({
371-
type: Type.SetFocus,
371+
type: RovingStateActionType.SetFocus,
372372
payload: { node },
373373
});
374374
node?.scrollIntoView?.({

apps/web/src/components/views/dialogs/spotlight/SpotlightDialog.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ import {
4444

4545
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
4646
import {
47-
findSiblingElement,
47+
findNextSiblingElement,
48+
RovingStateActionType,
4849
RovingTabIndexContext,
4950
RovingTabIndexProvider,
50-
Type,
5151
} from "../../../../accessibility/RovingTabIndex";
5252
import { mediaFromMxc } from "../../../../customisations/Media";
5353
import { Action } from "../../../../dispatcher/actions";
@@ -537,7 +537,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
537537
const node = rovingContext.state.nodes[0];
538538
if (node) {
539539
rovingContext.dispatch({
540-
type: Type.SetFocus,
540+
type: RovingStateActionType.SetFocus,
541541
payload: { node },
542542
});
543543
node?.scrollIntoView?.({
@@ -1181,7 +1181,10 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
11811181
}
11821182

11831183
const idx = nodes.indexOf(rovingContext.state.activeNode);
1184-
node = findSiblingElement(nodes, idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1));
1184+
node = findNextSiblingElement(
1185+
nodes,
1186+
idx + (accessibilityAction === KeyBindingAction.ArrowUp ? -1 : 1),
1187+
);
11851188
}
11861189
break;
11871190

@@ -1201,7 +1204,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
12011204

12021205
const nodes = rovingContext.state.nodes.filter(nodeIsForRecentlyViewed);
12031206
const idx = nodes.indexOf(rovingContext.state.activeNode);
1204-
node = findSiblingElement(
1207+
node = findNextSiblingElement(
12051208
nodes,
12061209
idx + (accessibilityAction === KeyBindingAction.ArrowLeft ? -1 : 1),
12071210
);
@@ -1211,7 +1214,7 @@ const SpotlightDialog: React.FC<IProps> = ({ initialText = "", initialFilter = n
12111214

12121215
if (node) {
12131216
rovingContext.dispatch({
1214-
type: Type.SetFocus,
1217+
type: RovingStateActionType.SetFocus,
12151218
payload: { node },
12161219
});
12171220
node?.scrollIntoView({

apps/web/src/components/views/emojipicker/EmojiPicker.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import {
2525
type IAction as RovingAction,
2626
type IState as RovingState,
2727
RovingTabIndexProvider,
28-
Type,
28+
RovingStateActionType,
2929
} from "../../../accessibility/RovingTabIndex";
3030
import { Key } from "../../../Keyboard";
3131
import { type ButtonEvent } from "../elements/AccessibleButton";
@@ -187,7 +187,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
187187
focusNode?.focus();
188188
}
189189
dispatch({
190-
type: Type.SetFocus,
190+
type: RovingStateActionType.SetFocus,
191191
payload: { node: focusNode },
192192
});
193193

@@ -212,7 +212,7 @@ class EmojiPicker extends React.Component<IProps, IState> {
212212
// Reset to first emoji when showing highlight for the first time (or after it was hidden)
213213
if (state.nodes.length > 0) {
214214
dispatch({
215-
type: Type.SetFocus,
215+
type: RovingStateActionType.SetFocus,
216216
payload: { node: state.nodes[0] },
217217
});
218218
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
Copyright 2026 Element Creations Ltd.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import React from "react";
9+
import { render } from "jest-matrix-react";
10+
import { RovingAction, type RovingTabIndexProviderProps } from "@element-hq/web-shared-components";
11+
12+
import * as KeyBindingsManagerModule from "../../../src/KeyBindingsManager";
13+
import { KeyBindingAction } from "../../../src/accessibility/KeyboardShortcuts";
14+
import { RovingTabIndexProvider } from "../../../src/accessibility/RovingTabIndex";
15+
16+
jest.mock("@element-hq/web-shared-components", () => {
17+
const actual = jest.requireActual("@element-hq/web-shared-components");
18+
const mockSharedRovingTabIndexProvider = jest.fn(({ children }: RovingTabIndexProviderProps) => {
19+
return <>{children({ onDragEndHandler: jest.fn(), onKeyDownHandler: jest.fn() })}</>;
20+
});
21+
22+
return {
23+
__mockSharedRovingTabIndexProvider: mockSharedRovingTabIndexProvider,
24+
...actual,
25+
RovingTabIndexProvider: mockSharedRovingTabIndexProvider,
26+
};
27+
});
28+
29+
const getMockSharedRovingTabIndexProvider = (): jest.Mock => {
30+
return jest.requireMock("@element-hq/web-shared-components").__mockSharedRovingTabIndexProvider as jest.Mock;
31+
};
32+
33+
const getInjectedGetAction = (): NonNullable<RovingTabIndexProviderProps["getAction"]> => {
34+
const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider();
35+
expect(mockSharedRovingTabIndexProvider).toHaveBeenCalled();
36+
const getAction = (mockSharedRovingTabIndexProvider.mock.calls.at(-1)![0] as RovingTabIndexProviderProps).getAction;
37+
expect(getAction).toBeDefined();
38+
return getAction!;
39+
};
40+
41+
describe("RovingTabIndex adapter", () => {
42+
beforeEach(() => {
43+
const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider();
44+
mockSharedRovingTabIndexProvider.mockClear();
45+
jest.restoreAllMocks();
46+
});
47+
48+
it.each([
49+
[KeyBindingAction.ArrowDown, RovingAction.ArrowDown],
50+
[KeyBindingAction.ArrowUp, RovingAction.ArrowUp],
51+
[KeyBindingAction.ArrowRight, RovingAction.ArrowRight],
52+
[KeyBindingAction.ArrowLeft, RovingAction.ArrowLeft],
53+
[KeyBindingAction.Home, RovingAction.Home],
54+
[KeyBindingAction.End, RovingAction.End],
55+
[KeyBindingAction.Tab, RovingAction.Tab],
56+
])("maps %s to %s", (accessibilityAction, expectedRovingAction) => {
57+
const manager = new KeyBindingsManagerModule.KeyBindingsManager();
58+
jest.spyOn(KeyBindingsManagerModule, "getKeyBindingsManager").mockReturnValue(manager);
59+
jest.spyOn(manager, "getAccessibilityAction").mockReturnValue(accessibilityAction);
60+
61+
render(<RovingTabIndexProvider>{() => null}</RovingTabIndexProvider>);
62+
63+
const getAction = getInjectedGetAction();
64+
expect(getAction({ key: "irrelevant" } as React.KeyboardEvent)).toBe(expectedRovingAction);
65+
});
66+
67+
it("returns undefined when there is no matching accessibility action", () => {
68+
const manager = new KeyBindingsManagerModule.KeyBindingsManager();
69+
jest.spyOn(KeyBindingsManagerModule, "getKeyBindingsManager").mockReturnValue(manager);
70+
jest.spyOn(manager, "getAccessibilityAction").mockReturnValue(undefined);
71+
72+
render(<RovingTabIndexProvider>{() => null}</RovingTabIndexProvider>);
73+
74+
const getAction = getInjectedGetAction();
75+
expect(getAction({ key: "x" } as React.KeyboardEvent)).toBeUndefined();
76+
});
77+
78+
it("forwards provider props to shared-components", () => {
79+
const onKeyDown = jest.fn();
80+
81+
render(
82+
<RovingTabIndexProvider handleHomeEnd handleLoop handleUpDown onKeyDown={onKeyDown} scrollIntoView>
83+
{() => null}
84+
</RovingTabIndexProvider>,
85+
);
86+
87+
const mockSharedRovingTabIndexProvider = getMockSharedRovingTabIndexProvider();
88+
const props = mockSharedRovingTabIndexProvider.mock.calls.at(-1)![0] as RovingTabIndexProviderProps;
89+
expect(props.handleHomeEnd).toBe(true);
90+
expect(props.handleLoop).toBe(true);
91+
expect(props.handleUpDown).toBe(true);
92+
expect(props.onKeyDown).toBe(onKeyDown);
93+
expect(props.scrollIntoView).toBe(true);
94+
expect(props.getAction).toEqual(expect.any(Function));
95+
});
96+
});

0 commit comments

Comments
 (0)