Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 3c9bd69

Browse files
akirkMidhunSureshR
andauthored
Accessibility: Add Landmark navigation (#12190)
Co-authored-by: R Midhun Suresh <[email protected]>
1 parent 4edf4e4 commit 3c9bd69

File tree

13 files changed

+550
-3
lines changed

13 files changed

+550
-3
lines changed
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
/*
2+
Copyright 2024 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { test, expect } from "../../element-web-test";
18+
import { Bot } from "../../pages/bot";
19+
20+
test.describe("Landmark navigation tests", () => {
21+
test.use({
22+
displayName: "Alice",
23+
});
24+
25+
test("without any rooms", async ({ page, homeserver, app, user }) => {
26+
/**
27+
* Without any rooms, there is no tile in the roomlist to be focused.
28+
* So the next landmark in the list should be focused instead.
29+
*/
30+
31+
// Pressing Control+F6 will first focus the space button
32+
await page.keyboard.press("ControlOrMeta+F6");
33+
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
34+
35+
// Pressing Control+F6 again will focus room search
36+
await page.keyboard.press("ControlOrMeta+F6");
37+
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
38+
39+
// Pressing Control+F6 again will focus the message composer
40+
await page.keyboard.press("ControlOrMeta+F6");
41+
await expect(page.locator(".mx_HomePage")).toBeFocused();
42+
43+
// Pressing Control+F6 again will bring focus back to the space button
44+
await page.keyboard.press("ControlOrMeta+F6");
45+
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
46+
47+
// Now go back in the same order
48+
await page.keyboard.press("ControlOrMeta+Shift+F6");
49+
await expect(page.locator(".mx_HomePage")).toBeFocused();
50+
51+
await page.keyboard.press("ControlOrMeta+Shift+F6");
52+
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
53+
54+
await page.keyboard.press("ControlOrMeta+Shift+F6");
55+
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
56+
});
57+
58+
test("with an open room", async ({ page, homeserver, app, user }) => {
59+
const bob = new Bot(page, homeserver, { displayName: "Bob" });
60+
await bob.prepareClient();
61+
62+
// create dm with bob
63+
await app.client.evaluate(
64+
async (cli, { bob }) => {
65+
const bobRoom = await cli.createRoom({ is_direct: true });
66+
await cli.invite(bobRoom.room_id, bob);
67+
},
68+
{
69+
bob: bob.credentials.userId,
70+
},
71+
);
72+
73+
await app.viewRoomByName("Bob");
74+
// confirm the room was loaded
75+
await expect(page.getByText("Bob joined the room")).toBeVisible();
76+
77+
// Pressing Control+F6 will first focus the space button
78+
await page.keyboard.press("ControlOrMeta+F6");
79+
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
80+
81+
// Pressing Control+F6 again will focus room search
82+
await page.keyboard.press("ControlOrMeta+F6");
83+
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
84+
85+
// Pressing Control+F6 again will focus the room tile in the room list
86+
await page.keyboard.press("ControlOrMeta+F6");
87+
await expect(page.locator(".mx_RoomTile_selected")).toBeFocused();
88+
89+
// Pressing Control+F6 again will focus the message composer
90+
await page.keyboard.press("ControlOrMeta+F6");
91+
await expect(page.locator(".mx_BasicMessageComposer_input")).toBeFocused();
92+
93+
// Pressing Control+F6 again will bring focus back to the space button
94+
await page.keyboard.press("ControlOrMeta+F6");
95+
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
96+
97+
// Now go back in the same order
98+
await page.keyboard.press("ControlOrMeta+Shift+F6");
99+
await expect(page.locator(".mx_BasicMessageComposer_input")).toBeFocused();
100+
101+
await page.keyboard.press("ControlOrMeta+Shift+F6");
102+
await expect(page.locator(".mx_RoomTile_selected")).toBeFocused();
103+
104+
await page.keyboard.press("ControlOrMeta+Shift+F6");
105+
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
106+
107+
await page.keyboard.press("ControlOrMeta+Shift+F6");
108+
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
109+
});
110+
111+
test("without an open room", async ({ page, homeserver, app, user }) => {
112+
const bob = new Bot(page, homeserver, { displayName: "Bob" });
113+
await bob.prepareClient();
114+
115+
// create a dm with bob
116+
await app.client.evaluate(
117+
async (cli, { bob }) => {
118+
const bobRoom = await cli.createRoom({ is_direct: true });
119+
await cli.invite(bobRoom.room_id, bob);
120+
},
121+
{
122+
bob: bob.credentials.userId,
123+
},
124+
);
125+
126+
await app.viewRoomByName("Bob");
127+
// confirm the room was loaded
128+
await expect(page.getByText("Bob joined the room")).toBeVisible();
129+
130+
// Close the room
131+
page.goto("/#/home");
132+
133+
// Pressing Control+F6 will first focus the space button
134+
await page.keyboard.press("ControlOrMeta+F6");
135+
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
136+
137+
// Pressing Control+F6 again will focus room search
138+
await page.keyboard.press("ControlOrMeta+F6");
139+
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
140+
141+
// Pressing Control+F6 again will focus the room tile in the room list
142+
await page.keyboard.press("ControlOrMeta+F6");
143+
await expect(page.locator(".mx_RoomTile")).toBeFocused();
144+
145+
// Pressing Control+F6 again will focus the home section
146+
await page.keyboard.press("ControlOrMeta+F6");
147+
await expect(page.locator(".mx_HomePage")).toBeFocused();
148+
149+
// Pressing Control+F6 will bring focus back to the space button
150+
await page.keyboard.press("ControlOrMeta+F6");
151+
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
152+
153+
// Now go back in same order
154+
await page.keyboard.press("ControlOrMeta+Shift+F6");
155+
await expect(page.locator(".mx_HomePage")).toBeFocused();
156+
157+
await page.keyboard.press("ControlOrMeta+Shift+F6");
158+
await expect(page.locator(".mx_RoomTile")).toBeFocused();
159+
160+
await page.keyboard.press("ControlOrMeta+Shift+F6");
161+
await expect(page.locator(".mx_RoomSearch")).toBeFocused();
162+
163+
await page.keyboard.press("ControlOrMeta+Shift+F6");
164+
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
165+
});
166+
});

src/@types/global.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,14 @@ declare global {
224224
readonly port: MessagePort;
225225
}
226226

227+
/**
228+
* In future, browsers will support focusVisible option.
229+
* See https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#focusvisible
230+
*/
231+
interface FocusOptions {
232+
focusVisible: boolean;
233+
}
234+
227235
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
228236
function registerProcessor(
229237
name: string,

src/Keyboard.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const Key = {
2929
ARROW_DOWN: "ArrowDown",
3030
ARROW_LEFT: "ArrowLeft",
3131
ARROW_RIGHT: "ArrowRight",
32+
F6: "F6",
3233
TAB: "Tab",
3334
ESCAPE: "Escape",
3435
ENTER: "Enter",
@@ -77,6 +78,7 @@ export const Key = {
7778
};
7879

7980
export const IS_MAC = navigator.platform.toUpperCase().includes("MAC");
81+
export const IS_ELECTRON = window.electron;
8082

8183
export function isOnlyCtrlOrCmdKeyEvent(ev: React.KeyboardEvent | KeyboardEvent): boolean {
8284
if (IS_MAC) {

src/accessibility/KeyboardShortcuts.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ limitations under the License.
1717
*/
1818

1919
import { _td, TranslationKey } from "../languageHandler";
20-
import { IS_MAC, Key } from "../Keyboard";
20+
import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard";
2121
import { IBaseSetting } from "../settings/Settings";
2222
import { KeyCombo } from "../KeyBindingsManager";
2323

@@ -129,6 +129,10 @@ export enum KeyBindingAction {
129129
PreviousVisitedRoomOrSpace = "KeyBinding.PreviousVisitedRoomOrSpace",
130130
/** Navigates forward */
131131
NextVisitedRoomOrSpace = "KeyBinding.NextVisitedRoomOrSpace",
132+
/** Navigates to the next Landmark */
133+
NextLandmark = "KeyBinding.nextLandmark",
134+
/** Navigates to the next Landmark */
135+
PreviousLandmark = "KeyBinding.previousLandmark",
132136

133137
/** Toggles microphone while on a call */
134138
ToggleMicInCall = "KeyBinding.toggleMicInCall",
@@ -291,6 +295,8 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
291295
KeyBindingAction.SwitchToSpaceByNumber,
292296
KeyBindingAction.PreviousVisitedRoomOrSpace,
293297
KeyBindingAction.NextVisitedRoomOrSpace,
298+
KeyBindingAction.NextLandmark,
299+
KeyBindingAction.PreviousLandmark,
294300
],
295301
},
296302
[CategoryName.AUTOCOMPLETE]: {
@@ -714,4 +720,19 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
714720
key: Key.COMMA,
715721
},
716722
},
723+
[KeyBindingAction.NextLandmark]: {
724+
default: {
725+
ctrlOrCmdKey: !IS_ELECTRON,
726+
key: Key.F6,
727+
},
728+
displayName: _td("keyboard|next_landmark"),
729+
},
730+
[KeyBindingAction.PreviousLandmark]: {
731+
default: {
732+
ctrlOrCmdKey: !IS_ELECTRON,
733+
key: Key.F6,
734+
shiftKey: true,
735+
},
736+
displayName: _td("keyboard|prev_landmark"),
737+
},
717738
};
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
/*
2+
* Copyright 2024 The Matrix.org Foundation C.I.C.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { TimelineRenderingType } from "../contexts/RoomContext";
18+
import { Action } from "../dispatcher/actions";
19+
import defaultDispatcher from "../dispatcher/dispatcher";
20+
21+
export const enum Landmark {
22+
// This is the space/home button in the left panel.
23+
ACTIVE_SPACE_BUTTON,
24+
// This is the room filter in the left panel.
25+
ROOM_SEARCH,
26+
// This is the currently opened room/first room in the room list in the left panel.
27+
ROOM_LIST,
28+
// This is the message composer within the room if available or it is the welcome screen shown when no room is selected
29+
MESSAGE_COMPOSER_OR_HOME,
30+
}
31+
32+
const ORDERED_LANDMARKS = [
33+
Landmark.ACTIVE_SPACE_BUTTON,
34+
Landmark.ROOM_SEARCH,
35+
Landmark.ROOM_LIST,
36+
Landmark.MESSAGE_COMPOSER_OR_HOME,
37+
];
38+
39+
/**
40+
* The landmarks are cycled through in the following order:
41+
* ACTIVE_SPACE_BUTTON <-> ROOM_SEARCH <-> ROOM_LIST <-> MESSAGE_COMPOSER/HOME <-> ACTIVE_SPACE_BUTTON
42+
*/
43+
export class LandmarkNavigation {
44+
/**
45+
* Get the next/previous landmark that must be focused from a given landmark
46+
* @param currentLandmark The current landmark
47+
* @param backwards If true, the landmark before currentLandmark in ORDERED_LANDMARKS is returned
48+
* @returns The next landmark to focus
49+
*/
50+
private static getLandmark(currentLandmark: Landmark, backwards = false): Landmark {
51+
const currentIndex = ORDERED_LANDMARKS.findIndex((l) => l === currentLandmark);
52+
const offset = backwards ? -1 : 1;
53+
const newLandmark = ORDERED_LANDMARKS.at((currentIndex + offset) % ORDERED_LANDMARKS.length)!;
54+
return newLandmark;
55+
}
56+
57+
/**
58+
* Focus the next landmark from a given landmark.
59+
* This method will skip over any missing landmarks.
60+
* @param currentLandmark The current landmark
61+
* @param backwards If true, search the next landmark to the left in ORDERED_LANDMARKS
62+
*/
63+
public static findAndFocusNextLandmark(currentLandmark: Landmark, backwards = false): void {
64+
let landmark = currentLandmark;
65+
let element: HTMLElement | null | undefined = null;
66+
while (element === null) {
67+
landmark = LandmarkNavigation.getLandmark(landmark, backwards);
68+
element = landmarkToDomElementMap[landmark]();
69+
}
70+
element?.focus({ focusVisible: true });
71+
}
72+
}
73+
74+
/**
75+
* The functions return:
76+
* - The DOM element of the landmark if it exists
77+
* - undefined if the DOM element exists but focus is given through an action
78+
* - null if the landmark does not exist
79+
*/
80+
const landmarkToDomElementMap: Record<Landmark, () => HTMLElement | null | undefined> = {
81+
[Landmark.ACTIVE_SPACE_BUTTON]: () => document.querySelector<HTMLElement>(".mx_SpaceButton_active"),
82+
83+
[Landmark.ROOM_SEARCH]: () => document.querySelector<HTMLElement>(".mx_RoomSearch"),
84+
[Landmark.ROOM_LIST]: () =>
85+
document.querySelector<HTMLElement>(".mx_RoomTile_selected") ||
86+
document.querySelector<HTMLElement>(".mx_RoomTile"),
87+
88+
[Landmark.MESSAGE_COMPOSER_OR_HOME]: () => {
89+
const isComposerOpen = !!document.querySelector(".mx_MessageComposer");
90+
if (isComposerOpen) {
91+
const inThread = !!document.activeElement?.closest(".mx_ThreadView");
92+
defaultDispatcher.dispatch(
93+
{
94+
action: Action.FocusSendMessageComposer,
95+
context: inThread ? TimelineRenderingType.Thread : TimelineRenderingType.Room,
96+
},
97+
true,
98+
);
99+
// Special case where the element does exist but we focus it through an action.
100+
return undefined;
101+
} else {
102+
return document.querySelector<HTMLElement>(".mx_HomePage");
103+
}
104+
},
105+
};

src/components/structures/LeftPanel.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButto
4444
import PosthogTrackers from "../../PosthogTrackers";
4545
import PageType from "../../PageTypes";
4646
import { UserOnboardingButton } from "../views/user-onboarding/UserOnboardingButton";
47+
import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation";
4748

4849
interface IProps {
4950
isMinimized: boolean;
@@ -308,6 +309,16 @@ export default class LeftPanel extends React.Component<IProps, IState> {
308309
}
309310
break;
310311
}
312+
313+
const navAction = getKeyBindingsManager().getNavigationAction(ev);
314+
if (navAction === KeyBindingAction.PreviousLandmark || navAction === KeyBindingAction.NextLandmark) {
315+
ev.stopPropagation();
316+
ev.preventDefault();
317+
LandmarkNavigation.findAndFocusNextLandmark(
318+
Landmark.ROOM_SEARCH,
319+
navAction === KeyBindingAction.PreviousLandmark,
320+
);
321+
}
311322
};
312323

313324
private renderBreadcrumbs(): React.ReactNode {

0 commit comments

Comments
 (0)