Accessibility: Add Landmark navigation (#12190)

Co-authored-by: R Midhun Suresh <hi@midhun.dev>
This commit is contained in:
Alex Kirk
2024-07-17 14:46:45 +01:00
committed by GitHub
co-authored by R Midhun Suresh
parent 4edf4e42cd
commit 3c9bd69d48
13 changed files with 550 additions and 3 deletions
+8
View File
@@ -224,6 +224,14 @@ declare global {
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
function registerProcessor(
name: string,
+2
View File
@@ -29,6 +29,7 @@ export const Key = {
ARROW_DOWN: "ArrowDown",
ARROW_LEFT: "ArrowLeft",
ARROW_RIGHT: "ArrowRight",
F6: "F6",
TAB: "Tab",
ESCAPE: "Escape",
ENTER: "Enter",
@@ -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) {
+22 -1
View File
@@ -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";
@@ -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",
@@ -291,6 +295,8 @@ export const CATEGORIES: Record<CategoryName, ICategory> = {
KeyBindingAction.SwitchToSpaceByNumber,
KeyBindingAction.PreviousVisitedRoomOrSpace,
KeyBindingAction.NextVisitedRoomOrSpace,
KeyBindingAction.NextLandmark,
KeyBindingAction.PreviousLandmark,
],
},
[CategoryName.AUTOCOMPLETE]: {
@@ -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
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");
}
},
};
+11
View File
@@ -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;
@@ -308,6 +309,16 @@ export default class LeftPanel extends React.Component<IProps, IState> {
}
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 {
@@ -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.
@@ -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",
@@ -51,6 +51,7 @@ import { _t } from "../../../languageHandler";
import { linkify } from "../../../linkify-matrix";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation";
// matches emoticons which follow the start of a line or whitespace
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 accessibilityAction = getKeyBindingsManager().getAccessibilityAction(event);
if (model.autoComplete?.hasCompletions()) {
+19 -1
View File
@@ -60,7 +60,10 @@ import IconizedContextMenu, {
import ExtraTile from "./ExtraTile";
import RoomSublist, { IAuxButtonProps } from "./RoomSublist";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import AccessibleButton from "../elements/AccessibleButton";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation";
interface IProps {
onKeyDown: (ev: React.KeyboardEvent, state: IRovingTabIndexState) => void;
@@ -652,7 +655,22 @@ export default class RoomList extends React.PureComponent<IProps, IState> {
<div
onFocus={this.props.onFocus}
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"
role="tree"
aria-label={_t("common|rooms")}
+19 -1
View File
@@ -67,10 +67,13 @@ import defaultDispatcher from "../../../dispatcher/dispatcher";
import { ActionPayload } from "../../../dispatcher/payloads";
import { Action } from "../../../dispatcher/actions";
import { NotificationState } from "../../../stores/notifications/NotificationState";
import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts";
import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { ThreadsActivityCentre } from "./threads-activity-centre/";
import AccessibleButton from "../elements/AccessibleButton";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation";
import { KeyboardShortcut } from "../settings/KeyboardShortcut";
const useSpaces = (): [Room[], MetaSpace[], Room[], SpaceKey] => {
@@ -383,7 +386,22 @@ const SpacePanel: React.FC = () => {
>
<nav
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}
aria-label={_t("common|spaces")}
>
+2
View File
@@ -1370,12 +1370,14 @@
"navigate_next_message_edit": "Navigate to next message to edit",
"navigate_prev_history": "Previous recently visited room or space",
"navigate_prev_message_edit": "Navigate to previous message to edit",
"next_landmark": "Go to next landmark",
"next_room": "Next room or DM",
"next_unread_room": "Next unread room or DM",
"number": "[number]",
"open_user_settings": "Open user settings",
"page_down": "Page Down",
"page_up": "Page Up",
"prev_landmark": "Go to previous landmark",
"prev_room": "Previous room or DM",
"prev_unread_room": "Previous unread room or DM",
"room_list_collapse_section": "Collapse room list section",