Accessibility: Add Landmark navigation (#12190)
Co-authored-by: R Midhun Suresh <hi@midhun.dev>
This commit is contained in:
parent
4edf4e42cd
commit
3c9bd69d48
13 changed files with 550 additions and 3 deletions
166
playwright/e2e/accessibility/keyboard-navigation.spec.ts
Normal file
166
playwright/e2e/accessibility/keyboard-navigation.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
8
src/@types/global.d.ts
vendored
8
src/@types/global.d.ts
vendored
|
@ -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,
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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"),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
105
src/accessibility/LandmarkNavigation.ts
Normal file
105
src/accessibility/LandmarkNavigation.ts
Normal 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");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
|
@ -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 {
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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()) {
|
||||||
|
|
|
@ -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")}
|
||||||
|
|
|
@ -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")}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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",
|
||||||
|
|
130
test/accessibility/LandmarkNavigation-test.tsx
Normal file
130
test/accessibility/LandmarkNavigation-test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue