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

Accessibility: Add Landmark navigation #12190

Merged
merged 39 commits into from
Jul 17, 2024
Merged
Show file tree
Hide file tree
Changes from 34 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
98dd07c
Override aria-label for whole tile
akirk Dec 1, 2023
6e6696e
Message navigation
akirk Dec 12, 2023
b777dbd
Change hotkeys to use cmd/ctrl
akirk Dec 12, 2023
3bb1bb4
Add landmark navigation
akirk Dec 8, 2023
45cd0da
No cmd in Electron
akirk Dec 8, 2023
fd93d03
Fallback when no room is selected
akirk Dec 8, 2023
a954941
Review fixes
akirk Dec 12, 2023
1eac6ff
Handle invisible events better
akirk Dec 12, 2023
36048c1
Fix lint errors
akirk Mar 8, 2024
4c2c31f
Lint fixes, remove stray code from other PR
akirk Mar 8, 2024
645dc4c
Remove message navigation code
akirk Mar 8, 2024
f55ba53
Remove more stray code
akirk Mar 8, 2024
9bdc225
lint fixes
akirk Mar 8, 2024
e34baa5
fix room list landmark
akirk Mar 8, 2024
5ebd292
lint fixes
akirk Mar 8, 2024
d9d10d2
Remove copied function
akirk Mar 8, 2024
af40c89
update comment
akirk Mar 8, 2024
aee0c5f
lint
akirk Mar 8, 2024
87ce6d2
Update keyboard user settings snapshot
akirk Mar 8, 2024
87492e3
Merge branch 'develop' into add-landmark-navigation
MidhunSureshR May 14, 2024
e3ebda6
Fix lint
MidhunSureshR May 14, 2024
7fba563
Move code to a single file
MidhunSureshR May 15, 2024
f3b7f12
Merge branch 'develop' into add-landmark-navigation
MidhunSureshR May 15, 2024
7eb4dc6
Add jest test
MidhunSureshR May 15, 2024
9608b75
Merge branch 'develop' into add-landmark-navigation
MidhunSureshR Jul 14, 2024
d043ae9
Use a circular array for storing the order of landmarks
MidhunSureshR Jul 14, 2024
e38849a
Fix test
MidhunSureshR Jul 14, 2024
7edb9d4
Rename test
MidhunSureshR Jul 16, 2024
b493ac2
Change implementation
MidhunSureshR Jul 16, 2024
1396862
Add playwright test
MidhunSureshR Jul 16, 2024
477f58e
Add more tests
MidhunSureshR Jul 16, 2024
6c8e191
Fix comments and name
MidhunSureshR Jul 16, 2024
b2ce73b
Replacee method with Array.at
MidhunSureshR Jul 16, 2024
ea4fea8
Make changes from review
MidhunSureshR Jul 16, 2024
0961cae
Fix case; landmarkToDOMElementMap -> landmarkToDomElementMap
MidhunSureshR Jul 16, 2024
114cc77
Add stricter check
MidhunSureshR Jul 16, 2024
1d8ab2a
Add type to map
MidhunSureshR Jul 16, 2024
9f29735
Pass focusVisible option to focus call
MidhunSureshR Jul 17, 2024
d1085b7
Move type to global.d.ts
MidhunSureshR Jul 17, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions playwright/e2e/accessibility/keyboard-navigation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

import { test, expect } from "../../element-web-test";
import { Bot } from "../../pages/bot";

test.describe("Landmark navigation tests", () => {
test.use({
displayName: "Alice",
});

test("without any rooms", async ({ page, homeserver, app, user }) => {
/**
* Without any rooms, there is no tile in the roomlist to be focused.
* So the next landmark in the list should be focused instead.
*/

// Pressing Control+F6 will first focus the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();

// Pressing Control+F6 again will focus room search
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();

// Pressing Control+F6 again will focus the message composer
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_HomePage")).toBeFocused();

// Pressing Control+F6 again will bring focus back to the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();

// Now go back in the same order
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_HomePage")).toBeFocused();

await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();

await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
});

test("with an open room", async ({ page, homeserver, app, user }) => {
const bob = new Bot(page, homeserver, { displayName: "Bob" });
await bob.prepareClient();

// create dm with bob
await app.client.evaluate(
async (cli, { bob }) => {
const bobRoom = await cli.createRoom({ is_direct: true });
await cli.invite(bobRoom.room_id, bob);
},
{
bob: bob.credentials.userId,
},
);

await app.viewRoomByName("Bob");
// confirm the room was loaded
await expect(page.getByText("Bob joined the room")).toBeVisible();

// Pressing Control+F6 will first focus the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();

// Pressing Control+F6 again will focus room search
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();

// Pressing Control+F6 again will focus the room tile in the room list
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomTile_selected")).toBeFocused();

// Pressing Control+F6 again will focus the message composer
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_BasicMessageComposer_input")).toBeFocused();

// Pressing Control+F6 again will bring focus back to the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();

// Now go back in the same order
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_BasicMessageComposer_input")).toBeFocused();

await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomTile_selected")).toBeFocused();

await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();

await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
});

test("without an open room", async ({ page, homeserver, app, user }) => {
const bob = new Bot(page, homeserver, { displayName: "Bob" });
await bob.prepareClient();

// create a dm with bob
await app.client.evaluate(
async (cli, { bob }) => {
const bobRoom = await cli.createRoom({ is_direct: true });
await cli.invite(bobRoom.room_id, bob);
},
{
bob: bob.credentials.userId,
},
);

await app.viewRoomByName("Bob");
// confirm the room was loaded
await expect(page.getByText("Bob joined the room")).toBeVisible();

// Close the room
page.goto("/#/home");

// Pressing Control+F6 will first focus the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();

// Pressing Control+F6 again will focus room search
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();

// Pressing Control+F6 again will focus the room tile in the room list
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_RoomTile")).toBeFocused();

// Pressing Control+F6 again will focus the home section
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_HomePage")).toBeFocused();

// Pressing Control+F6 will bring focus back to the space button
await page.keyboard.press("ControlOrMeta+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();

// Now go back in same order
await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_HomePage")).toBeFocused();

await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomTile")).toBeFocused();

await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_RoomSearch")).toBeFocused();

await page.keyboard.press("ControlOrMeta+Shift+F6");
await expect(page.locator(".mx_SpaceButton_active")).toBeFocused();
});
});
2 changes: 2 additions & 0 deletions src/Keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const Key = {
ARROW_DOWN: "ArrowDown",
ARROW_LEFT: "ArrowLeft",
ARROW_RIGHT: "ArrowRight",
F6: "F6",
TAB: "Tab",
ESCAPE: "Escape",
ENTER: "Enter",
Expand Down Expand Up @@ -77,6 +78,7 @@ export const Key = {
};

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

export function isOnlyCtrlOrCmdKeyEvent(ev: React.KeyboardEvent | KeyboardEvent): boolean {
if (IS_MAC) {
Expand Down
23 changes: 22 additions & 1 deletion src/accessibility/KeyboardShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ limitations under the License.
*/

import { _td, TranslationKey } from "../languageHandler";
import { IS_MAC, Key } from "../Keyboard";
import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard";
import { IBaseSetting } from "../settings/Settings";
import { KeyCombo } from "../KeyBindingsManager";

Expand Down Expand Up @@ -129,6 +129,10 @@ export enum KeyBindingAction {
PreviousVisitedRoomOrSpace = "KeyBinding.PreviousVisitedRoomOrSpace",
/** Navigates forward */
NextVisitedRoomOrSpace = "KeyBinding.NextVisitedRoomOrSpace",
/** Navigates to the next Landmark */
NextLandmark = "KeyBinding.nextLandmark",
/** Navigates to the next Landmark */
PreviousLandmark = "KeyBinding.previousLandmark",

/** Toggles microphone while on a call */
ToggleMicInCall = "KeyBinding.toggleMicInCall",
Expand Down Expand Up @@ -291,6 +295,8 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
KeyBindingAction.SwitchToSpaceByNumber,
KeyBindingAction.PreviousVisitedRoomOrSpace,
KeyBindingAction.NextVisitedRoomOrSpace,
KeyBindingAction.NextLandmark,
KeyBindingAction.PreviousLandmark,
],
},
[CategoryName.AUTOCOMPLETE]: {
Expand Down Expand Up @@ -714,4 +720,19 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
key: Key.COMMA,
},
},
[KeyBindingAction.NextLandmark]: {
default: {
ctrlOrCmdKey: !IS_ELECTRON,
key: Key.F6,
},
displayName: _td("keyboard|next_landmark"),
},
[KeyBindingAction.PreviousLandmark]: {
default: {
ctrlOrCmdKey: !IS_ELECTRON,
key: Key.F6,
shiftKey: true,
},
displayName: _td("keyboard|prev_landmark"),
},
};
105 changes: 105 additions & 0 deletions src/accessibility/LandmarkNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import { TimelineRenderingType } from "../contexts/RoomContext";
import { Action } from "../dispatcher/actions";
import defaultDispatcher from "../dispatcher/dispatcher";

export const enum Landmark {
// This is the space/home button in the left panel.
ACTIVE_SPACE_BUTTON,
// This is the room filter in the left panel.
ROOM_SEARCH,
// This is the currently opened room/first room in the room list in the left panel.
ROOM_LIST,
// This is the message composer within the room if available or it is the welcome screen shown when no room is selected
MESSAGE_COMPOSER_OR_HOME,
}

const ORDERED_LANDMARKS = [
Landmark.ACTIVE_SPACE_BUTTON,
Landmark.ROOM_SEARCH,
Landmark.ROOM_LIST,
Landmark.MESSAGE_COMPOSER_OR_HOME,
];

/**
* The landmarks are cycled through in the following order:
* ACTIVE_SPACE_BUTTON <-> ROOM_SEARCH <-> ROOM_LIST <-> MESSAGE_COMPOSER/HOME <-> ACTIVE_SPACE_BUTTON
*/
export class LandmarkNavigation {
/**
* Get the next/previous landmark that must be focused from a given landmark
* @param currentLandmark The current landmark
* @param backwards If true, the landmark before currentLandmark in ORDERED_LANDMARKS is returned
* @returns The next landmark to focus
*/
private static getLandmark(currentLandmark: Landmark, backwards = false): Landmark {
const currentIndex = ORDERED_LANDMARKS.findIndex((l) => l === currentLandmark);
const offset = backwards ? -1 : 1;
const newLandmark = ORDERED_LANDMARKS.at((currentIndex + offset) % ORDERED_LANDMARKS.length)!;
return newLandmark;
}

/**
* Focus the next landmark from a given landmark.
* This method will skip over any missing landmarks.
* @param currentLandmark The current landmark
* @param backwards If true, search the next landmark to the left in ORDERED_LANDMARKS
*/
public static findAndFocusNextLandmark(currentLandmark: Landmark, backwards = false): void {
let landmark = currentLandmark;
let element: HTMLElement | null | undefined = null;
while (element === null) {
landmark = LandmarkNavigation.getLandmark(landmark, backwards);
element = landmarkToDOMElementMap[landmark]();
}
element?.focus();
}
}

/**
* The functions return:
* - The DOM element of the landmark if it exists
* - undefined if the DOM element exists but focus is given through an action
* - null if the landmark does not exist
*/
const landmarkToDOMElementMap = {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const landmarkToDOMElementMap = {
const landmarkToDomElementMap = {

Sorry being pedantic: https://www.approxion.com/capital-offenses-how-to-handle-abbreviations-in-camelcase/

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const landmarkToDOMElementMap = {
const landmarkToDOMElementMap: Record<Landmark, () => Element | null | undefined> = {

this type will prevent us forgetting to add an entry if we add to the enum

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Landmark.ACTIVE_SPACE_BUTTON]: () => document.querySelector<HTMLElement>(".mx_SpaceButton_active"),

[Landmark.ROOM_SEARCH]: () => document.querySelector<HTMLElement>(".mx_RoomSearch"),
[Landmark.ROOM_LIST]: () =>
document.querySelector<HTMLElement>(".mx_RoomTile_selected") ||
document.querySelector<HTMLElement>(".mx_RoomTile"),

[Landmark.MESSAGE_COMPOSER_OR_HOME]: () => {
const isComposerOpen = !!document.querySelector(".mx_MessageComposer");
if (isComposerOpen) {
const inThread = !!document.activeElement?.closest(".mx_ThreadView");
defaultDispatcher.dispatch(
{
action: Action.FocusSendMessageComposer,
context: inThread ? TimelineRenderingType.Thread : TimelineRenderingType.Room,
},
true,
);
// Special case where the element does exist but we focus it through an action.
return undefined;
} else {
return document.querySelector<HTMLElement>(".mx_HomePage");
}
},
};
11 changes: 11 additions & 0 deletions src/components/structures/LeftPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButto
import PosthogTrackers from "../../PosthogTrackers";
import PageType from "../../PageTypes";
import { UserOnboardingButton } from "../views/user-onboarding/UserOnboardingButton";
import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation";

interface IProps {
isMinimized: boolean;
Expand Down Expand Up @@ -308,6 +309,16 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
break;
}

const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (navAction) {
ev.stopPropagation();
ev.preventDefault();
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.ROOM_SEARCH,
navAction === KeyBindingAction.PreviousLandmark,
);
t3chguy marked this conversation as resolved.
Show resolved Hide resolved
}
};

private renderBreadcrumbs(): React.ReactNode {
Expand Down
9 changes: 9 additions & 0 deletions src/components/structures/LoggedInView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ import { PipContainer } from "./PipContainer";
import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushRules";
import { ConfigOptions } from "../../SdkConfig";
import { MatrixClientContextProvider } from "./MatrixClientContextProvider";
import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation";

// We need to fetch each pinned message individually (if we don't already have it)
// so each pinned message may trigger a request. Limit the number per room for sanity.
Expand Down Expand Up @@ -470,6 +471,14 @@ class LoggedInView extends React.Component<IProps, IState> {

const navAction = getKeyBindingsManager().getNavigationAction(ev);
switch (navAction) {
case KeyBindingAction.NextLandmark:
case KeyBindingAction.PreviousLandmark:
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.MESSAGE_COMPOSER_OR_HOME,
navAction === KeyBindingAction.PreviousLandmark,
);
handled = true;
break;
case KeyBindingAction.FilterRooms:
dis.dispatch({
action: "focus_room_filter",
Expand Down
Loading
Loading