Accessibility: Add Landmark navigation (#12190)

Co-authored-by: R Midhun Suresh <hi@midhun.dev>
This commit is contained in:
Alex Kirk 2024-07-17 15:46:45 +02:00 committed by GitHub
parent 4edf4e42cd
commit 3c9bd69d48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 550 additions and 3 deletions

View file

@ -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();
});
});

View file

@ -224,6 +224,14 @@ declare global {
readonly port: MessagePort; readonly port: MessagePort;
} }
/**
* In future, browsers will support focusVisible option.
* See https://developer.mozilla.org/en-US/docs/Web/API/HTMLElement/focus#focusvisible
*/
interface FocusOptions {
focusVisible: boolean;
}
// https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278 // https://github.com/microsoft/TypeScript/issues/28308#issuecomment-650802278
function registerProcessor( function registerProcessor(
name: string, name: string,

View file

@ -29,6 +29,7 @@ export const Key = {
ARROW_DOWN: "ArrowDown", ARROW_DOWN: "ArrowDown",
ARROW_LEFT: "ArrowLeft", ARROW_LEFT: "ArrowLeft",
ARROW_RIGHT: "ArrowRight", ARROW_RIGHT: "ArrowRight",
F6: "F6",
TAB: "Tab", TAB: "Tab",
ESCAPE: "Escape", ESCAPE: "Escape",
ENTER: "Enter", ENTER: "Enter",
@ -77,6 +78,7 @@ export const Key = {
}; };
export const IS_MAC = navigator.platform.toUpperCase().includes("MAC"); export const IS_MAC = navigator.platform.toUpperCase().includes("MAC");
export const IS_ELECTRON = window.electron;
export function isOnlyCtrlOrCmdKeyEvent(ev: React.KeyboardEvent | KeyboardEvent): boolean { export function isOnlyCtrlOrCmdKeyEvent(ev: React.KeyboardEvent | KeyboardEvent): boolean {
if (IS_MAC) { if (IS_MAC) {

View file

@ -17,7 +17,7 @@ limitations under the License.
*/ */
import { _td, TranslationKey } from "../languageHandler"; 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 { IBaseSetting } from "../settings/Settings";
import { KeyCombo } from "../KeyBindingsManager"; import { KeyCombo } from "../KeyBindingsManager";
@ -129,6 +129,10 @@ export enum KeyBindingAction {
PreviousVisitedRoomOrSpace = "KeyBinding.PreviousVisitedRoomOrSpace", PreviousVisitedRoomOrSpace = "KeyBinding.PreviousVisitedRoomOrSpace",
/** Navigates forward */ /** Navigates forward */
NextVisitedRoomOrSpace = "KeyBinding.NextVisitedRoomOrSpace", 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 */ /** Toggles microphone while on a call */
ToggleMicInCall = "KeyBinding.toggleMicInCall", ToggleMicInCall = "KeyBinding.toggleMicInCall",
@ -291,6 +295,8 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
KeyBindingAction.SwitchToSpaceByNumber, KeyBindingAction.SwitchToSpaceByNumber,
KeyBindingAction.PreviousVisitedRoomOrSpace, KeyBindingAction.PreviousVisitedRoomOrSpace,
KeyBindingAction.NextVisitedRoomOrSpace, KeyBindingAction.NextVisitedRoomOrSpace,
KeyBindingAction.NextLandmark,
KeyBindingAction.PreviousLandmark,
], ],
}, },
[CategoryName.AUTOCOMPLETE]: { [CategoryName.AUTOCOMPLETE]: {
@ -714,4 +720,19 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
key: Key.COMMA, 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"),
},
}; };

View file

@ -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({ focusVisible: true });
}
}
/**
* 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: Record<Landmark, () => HTMLElement | null | undefined> = {
[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");
}
},
};

View file

@ -44,6 +44,7 @@ import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButto
import PosthogTrackers from "../../PosthogTrackers"; import PosthogTrackers from "../../PosthogTrackers";
import PageType from "../../PageTypes"; import PageType from "../../PageTypes";
import { UserOnboardingButton } from "../views/user-onboarding/UserOnboardingButton"; import { UserOnboardingButton } from "../views/user-onboarding/UserOnboardingButton";
import { Landmark, LandmarkNavigation } from "../../accessibility/LandmarkNavigation";
interface IProps { interface IProps {
isMinimized: boolean; isMinimized: boolean;
@ -308,6 +309,16 @@ export default class LeftPanel extends React.Component<IProps, IState> {
} }
break; break;
} }
const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (navAction === KeyBindingAction.PreviousLandmark || navAction === KeyBindingAction.NextLandmark) {
ev.stopPropagation();
ev.preventDefault();
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.ROOM_SEARCH,
navAction === KeyBindingAction.PreviousLandmark,
);
}
}; };
private renderBreadcrumbs(): React.ReactNode { private renderBreadcrumbs(): React.ReactNode {

View file

@ -75,6 +75,7 @@ import { PipContainer } from "./PipContainer";
import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushRules"; import { monitorSyncedPushRules } from "../../utils/pushRules/monitorSyncedPushRules";
import { ConfigOptions } from "../../SdkConfig"; import { ConfigOptions } from "../../SdkConfig";
import { MatrixClientContextProvider } from "./MatrixClientContextProvider"; 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) // 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. // so each pinned message may trigger a request. Limit the number per room for sanity.
@ -470,6 +471,14 @@ class LoggedInView extends React.Component<IProps, IState> {
const navAction = getKeyBindingsManager().getNavigationAction(ev); const navAction = getKeyBindingsManager().getNavigationAction(ev);
switch (navAction) { switch (navAction) {
case KeyBindingAction.NextLandmark:
case KeyBindingAction.PreviousLandmark:
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.MESSAGE_COMPOSER_OR_HOME,
navAction === KeyBindingAction.PreviousLandmark,
);
handled = true;
break;
case KeyBindingAction.FilterRooms: case KeyBindingAction.FilterRooms:
dis.dispatch({ dis.dispatch({
action: "focus_room_filter", action: "focus_room_filter",

View file

@ -51,6 +51,7 @@ import { _t } from "../../../languageHandler";
import { linkify } from "../../../linkify-matrix"; import { linkify } from "../../../linkify-matrix";
import { SdkContextClass } from "../../../contexts/SDKContext"; import { SdkContextClass } from "../../../contexts/SDKContext";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation";
// matches emoticons which follow the start of a line or whitespace // matches emoticons which follow the start of a line or whitespace
const REGEX_EMOTICON_WHITESPACE = new RegExp("(?:^|\\s)(" + EMOTICON_REGEX.source + ")\\s|:^$"); const REGEX_EMOTICON_WHITESPACE = new RegExp("(?:^|\\s)(" + EMOTICON_REGEX.source + ")\\s|:^$");
@ -536,6 +537,16 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
} }
const navAction = getKeyBindingsManager().getNavigationAction(event);
if (navAction === KeyBindingAction.NextLandmark || navAction === KeyBindingAction.PreviousLandmark) {
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.MESSAGE_COMPOSER_OR_HOME,
navAction === KeyBindingAction.PreviousLandmark,
);
handled = true;
}
const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event); const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event);
const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(event); const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(event);
if (model.autoComplete?.hasCompletions()) { if (model.autoComplete?.hasCompletions()) {

View file

@ -60,7 +60,10 @@ import IconizedContextMenu, {
import ExtraTile from "./ExtraTile"; import ExtraTile from "./ExtraTile";
import RoomSublist, { IAuxButtonProps } from "./RoomSublist"; import RoomSublist, { IAuxButtonProps } from "./RoomSublist";
import { SdkContextClass } from "../../../contexts/SDKContext"; import { SdkContextClass } from "../../../contexts/SDKContext";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation";
interface IProps { interface IProps {
onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void; onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void;
@ -652,7 +655,22 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
<div <div
onFocus={this.props.onFocus} onFocus={this.props.onFocus}
onBlur={this.props.onBlur} onBlur={this.props.onBlur}
onKeyDown={onKeyDownHandler} onKeyDown={(ev) => {
const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (
navAction === KeyBindingAction.NextLandmark ||
navAction === KeyBindingAction.PreviousLandmark
) {
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.ROOM_LIST,
navAction === KeyBindingAction.PreviousLandmark,
);
ev.stopPropagation();
ev.preventDefault();
return;
}
onKeyDownHandler(ev);
}}
className="mx_RoomList" className="mx_RoomList"
role="tree" role="tree"
aria-label={_t("common|rooms")} aria-label={_t("common|rooms")}

View file

@ -67,10 +67,13 @@ import defaultDispatcher from "../../../dispatcher/dispatcher";
import { ActionPayload } from "../../../dispatcher/payloads"; import { ActionPayload } from "../../../dispatcher/payloads";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { NotificationState } from "../../../stores/notifications/NotificationState"; import { NotificationState } from "../../../stores/notifications/NotificationState";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature"; import { UIComponent } from "../../../settings/UIFeature";
import { ThreadsActivityCentre } from "./threads-activity-centre/"; import { ThreadsActivityCentre } from "./threads-activity-centre/";
import AccessibleButton from "../elements/AccessibleButton"; import AccessibleButton from "../elements/AccessibleButton";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation";
import { KeyboardShortcut } from "../settings/KeyboardShortcut"; import { KeyboardShortcut } from "../settings/KeyboardShortcut";
const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => { const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => {
@ -383,7 +386,22 @@ const SpacePanel: React.FC = () => {
> >
<nav <nav
className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })} className={classNames("mx_SpacePanel", { collapsed: isPanelCollapsed })}
onKeyDown={onKeyDownHandler} onKeyDown={(ev) => {
const navAction = getKeyBindingsManager().getNavigationAction(ev);
if (
navAction === KeyBindingAction.NextLandmark ||
navAction === KeyBindingAction.PreviousLandmark
) {
LandmarkNavigation.findAndFocusNextLandmark(
Landmark.ACTIVE_SPACE_BUTTON,
navAction === KeyBindingAction.PreviousLandmark,
);
ev.stopPropagation();
ev.preventDefault();
return;
}
onKeyDownHandler(ev);
}}
ref={ref} ref={ref}
aria-label={_t("common|spaces")} aria-label={_t("common|spaces")}
> >

View file

@ -1370,12 +1370,14 @@
"navigate_next_message_edit": "Navigate to next message to edit", "navigate_next_message_edit": "Navigate to next message to edit",
"navigate_prev_history": "Previous recently visited room or space", "navigate_prev_history": "Previous recently visited room or space",
"navigate_prev_message_edit": "Navigate to previous message to edit", "navigate_prev_message_edit": "Navigate to previous message to edit",
"next_landmark": "Go to next landmark",
"next_room": "Next room or DM", "next_room": "Next room or DM",
"next_unread_room": "Next unread room or DM", "next_unread_room": "Next unread room or DM",
"number": "[number]", "number": "[number]",
"open_user_settings": "Open user settings", "open_user_settings": "Open user settings",
"page_down": "Page Down", "page_down": "Page Down",
"page_up": "Page Up", "page_up": "Page Up",
"prev_landmark": "Go to previous landmark",
"prev_room": "Previous room or DM", "prev_room": "Previous room or DM",
"prev_unread_room": "Previous unread room or DM", "prev_unread_room": "Previous unread room or DM",
"room_list_collapse_section": "Collapse room list section", "room_list_collapse_section": "Collapse room list section",

View file

@ -0,0 +1,130 @@
/*
* 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 { render, screen, waitFor } from "@testing-library/react";
import React from "react";
import { Landmark, LandmarkNavigation } from "../../src/accessibility/LandmarkNavigation";
import defaultDispatcher from "../../src/dispatcher/dispatcher";
describe("KeyboardLandmarkUtils", () => {
it("Landmarks are cycled through correctly without an opened room", () => {
render(
<div>
<div tabIndex={0} className="mx_SpaceButton_active" data-testid="mx_SpaceButton_active">
SPACE_BUTTON
</div>
<div tabIndex={0} className="mx_RoomSearch" data-testid="mx_RoomSearch">
ROOM_SEARCH
</div>
<div tabIndex={0} className="mx_RoomTile" data-testid="mx_RoomTile">
ROOM_TILE
</div>
<div tabIndex={0} className="mx_HomePage" data-testid="mx_HomePage">
HOME_PAGE
</div>
</div>,
);
// ACTIVE_SPACE_BUTTON <-> ROOM_SEARCH <-> ROOM_LIST <-> HOME <-> ACTIVE_SPACE_BUTTON
// ACTIVE_SPACE_BUTTON -> ROOM_SEARCH
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ACTIVE_SPACE_BUTTON);
expect(screen.getByTestId("mx_RoomSearch")).toHaveFocus();
// ROOM_SEARCH -> ROOM_LIST
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_SEARCH);
expect(screen.getByTestId("mx_RoomTile")).toHaveFocus();
// ROOM_LIST -> HOME_PAGE
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_LIST);
expect(screen.getByTestId("mx_HomePage")).toHaveFocus();
// HOME_PAGE -> ACTIVE_SPACE_BUTTON
LandmarkNavigation.findAndFocusNextLandmark(Landmark.MESSAGE_COMPOSER_OR_HOME);
expect(screen.getByTestId("mx_SpaceButton_active")).toHaveFocus();
// HOME_PAGE <- ACTIVE_SPACE_BUTTON
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ACTIVE_SPACE_BUTTON, true);
expect(screen.getByTestId("mx_HomePage")).toHaveFocus();
// ROOM_LIST <- HOME_PAGE
LandmarkNavigation.findAndFocusNextLandmark(Landmark.MESSAGE_COMPOSER_OR_HOME, true);
expect(screen.getByTestId("mx_RoomTile")).toHaveFocus();
// ROOM_SEARCH <- ROOM_LIST
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_LIST, true);
expect(screen.getByTestId("mx_RoomSearch")).toHaveFocus();
// ACTIVE_SPACE_BUTTON <- ROOM_SEARCH
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_SEARCH, true);
expect(screen.getByTestId("mx_SpaceButton_active")).toHaveFocus();
});
it("Landmarks are cycled through correctly with an opened room", async () => {
const callback = jest.fn();
defaultDispatcher.register(callback);
render(
<div>
<div tabIndex={0} className="mx_SpaceButton_active" data-testid="mx_SpaceButton_active">
SPACE_BUTTON
</div>
<div tabIndex={0} className="mx_RoomSearch" data-testid="mx_RoomSearch">
ROOM_SEARCH
</div>
<div tabIndex={0} className="mx_RoomTile_selected" data-testid="mx_RoomTile_selected">
ROOM_TILE
</div>
<div tabIndex={0} className="mx_Room" data-testid="mx_Room">
ROOM
<div tabIndex={0} className="mx_MessageComposer">
COMPOSER
</div>
</div>
</div>,
);
// ACTIVE_SPACE_BUTTON <-> ROOM_SEARCH <-> ROOM_LIST <-> MESSAGE_COMPOSER <-> ACTIVE_SPACE_BUTTON
// ACTIVE_SPACE_BUTTON -> ROOM_SEARCH
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ACTIVE_SPACE_BUTTON);
expect(screen.getByTestId("mx_RoomSearch")).toHaveFocus();
// ROOM_SEARCH -> ROOM_LIST
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_SEARCH);
expect(screen.getByTestId("mx_RoomTile_selected")).toHaveFocus();
// ROOM_LIST -> MESSAGE_COMPOSER
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_LIST);
await waitFor(() => expect(callback).toHaveBeenCalledTimes(1));
// MESSAGE_COMPOSER -> ACTIVE_SPACE_BUTTON
LandmarkNavigation.findAndFocusNextLandmark(Landmark.MESSAGE_COMPOSER_OR_HOME);
expect(screen.getByTestId("mx_SpaceButton_active")).toHaveFocus();
// MESSAGE_COMPOSER <- ACTIVE_SPACE_BUTTON
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ACTIVE_SPACE_BUTTON, true);
await waitFor(() => expect(callback).toHaveBeenCalledTimes(2));
// ROOM_LIST <- MESSAGE_COMPOSER
LandmarkNavigation.findAndFocusNextLandmark(Landmark.MESSAGE_COMPOSER_OR_HOME, true);
expect(screen.getByTestId("mx_RoomTile_selected")).toHaveFocus();
// ROOM_SEARCH <- ROOM_LIST
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_LIST, true);
expect(screen.getByTestId("mx_RoomSearch")).toHaveFocus();
// ACTIVE_SPACE_BUTTON <- ROOM_SEARCH
LandmarkNavigation.findAndFocusNextLandmark(Landmark.ROOM_SEARCH, true);
expect(screen.getByTestId("mx_SpaceButton_active")).toHaveFocus();
});
});

View file

@ -987,6 +987,52 @@ exports[`KeyboardUserSettingsTab renders list of keyboard shortcuts 1`] = `
</kbd> </kbd>
</div> </div>
</li> </li>
<li
class="mx_KeyboardShortcut_shortcutRow"
>
Go to next landmark
<div
class="mx_KeyboardShortcut"
>
<kbd>
Ctrl
</kbd>
+
<kbd>
F6
</kbd>
</div>
</li>
<li
class="mx_KeyboardShortcut_shortcutRow"
>
Go to previous landmark
<div
class="mx_KeyboardShortcut"
>
<kbd>
Ctrl
</kbd>
+
<kbd>
Shift
</kbd>
+
<kbd>
F6
</kbd>
</div>
</li>
</ul> </ul>
</div> </div>
</div> </div>