diff --git a/src/KeyBindingsDefaults.ts b/src/KeyBindingsDefaults.ts index 65b198d69e..a41680848d 100644 --- a/src/KeyBindingsDefaults.ts +++ b/src/KeyBindingsDefaults.ts @@ -149,16 +149,11 @@ const roomBindings = (): KeyBinding[] => { }; const navigationBindings = (): KeyBinding[] => { - const bindings = getBindingsByCategory(CategoryName.NAVIGATION); + return getBindingsByCategory(CategoryName.NAVIGATION); +}; - bindings.push({ - action: KeyBindingAction.CloseDialogOrContextMenu, - keyCombo: { - key: Key.ESCAPE, - }, - }); - - return bindings; +const accessibilityBindings = (): KeyBinding[] => { + return getBindingsByCategory(CategoryName.ACCESSIBILITY); }; const callBindings = (): KeyBinding[] => { @@ -177,6 +172,7 @@ export const defaultBindingsProvider: IKeyBindingsProvider = { getRoomListBindings: roomListBindings, getRoomBindings: roomBindings, getNavigationBindings: navigationBindings, + getAccessibilityBindings: accessibilityBindings, getCallBindings: callBindings, getLabsBindings: labsBindings, }; diff --git a/src/KeyBindingsManager.ts b/src/KeyBindingsManager.ts index 5f14fbaef1..7a79a69ce8 100644 --- a/src/KeyBindingsManager.ts +++ b/src/KeyBindingsManager.ts @@ -155,6 +155,10 @@ export class KeyBindingsManager { return this.getAction(this.bindingsProviders.map(it => it.getNavigationBindings), ev); } + getAccessibilityAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined { + return this.getAction(this.bindingsProviders.map(it => it.getAccessibilityBindings), ev); + } + getCallAction(ev: KeyboardEvent | React.KeyboardEvent): KeyBindingAction | undefined { return this.getAction(this.bindingsProviders.map(it => it.getCallBindings), ev); } diff --git a/src/accessibility/KeyboardShortcuts.ts b/src/accessibility/KeyboardShortcuts.ts index 7db10078d9..9ac4552ace 100644 --- a/src/accessibility/KeyboardShortcuts.ts +++ b/src/accessibility/KeyboardShortcuts.ts @@ -130,10 +130,20 @@ export enum KeyBindingAction { /** Toggles webcam while on a call */ ToggleWebcamInCall = "KeyBinding.toggleWebcamInCall", - /** Closes a dialog or a context menu */ - CloseDialogOrContextMenu = "KeyBinding.closeDialogOrContextMenu", - /** Clicks the selected button */ - ActivateSelectedButton = "KeyBinding.activateSelectedButton", + /** Accessibility actions */ + Escape = "KeyBinding.escape", + Enter = "KeyBinding.enter", + Space = "KeyBinding.space", + Backspace = "KeyBinding.backspace", + Delete = "KeyBinding.delete", + Home = "KeyBinding.home", + End = "KeyBinding.end", + ArrowLeft = "KeyBinding.arrowLeft", + ArrowUp = "KeyBinding.arrowUp", + ArrowRight = "KeyBinding.arrowRight", + ArrowDown = "KeyBinding.arrowDown", + Tab = "KeyBinding.tab", + Comma = "KeyBinding.comma", /** Toggle visibility of hidden events */ ToggleHiddenEventVisibility = 'KeyBinding.toggleHiddenEventVisibility', @@ -156,13 +166,14 @@ type IKeyboardShortcuts = { }; export interface ICategory { - categoryLabel: string; + categoryLabel?: string; // TODO: We should figure out what to do with the keyboard shortcuts that are not handled by KeybindingManager settingNames: (KeyBindingAction)[]; } export enum CategoryName { NAVIGATION = "Navigation", + ACCESSIBILITY = "Accessibility", CALLS = "Calls", COMPOSER = "Composer", ROOM_LIST = "Room List", @@ -245,12 +256,26 @@ export const CATEGORIES: Record = { KeyBindingAction.NextRoom, KeyBindingAction.PrevRoom, ], + }, [CategoryName.ACCESSIBILITY]: { + categoryLabel: _td("Accessibility"), + settingNames: [ + KeyBindingAction.Escape, + KeyBindingAction.Enter, + KeyBindingAction.Space, + KeyBindingAction.Backspace, + KeyBindingAction.Delete, + KeyBindingAction.Home, + KeyBindingAction.End, + KeyBindingAction.ArrowLeft, + KeyBindingAction.ArrowUp, + KeyBindingAction.ArrowRight, + KeyBindingAction.ArrowDown, + KeyBindingAction.Comma, + ], }, [CategoryName.NAVIGATION]: { categoryLabel: _td("Navigation"), settingNames: [ KeyBindingAction.ToggleUserMenu, - KeyBindingAction.CloseDialogOrContextMenu, - KeyBindingAction.ActivateSelectedButton, KeyBindingAction.ToggleRoomSidePanel, KeyBindingAction.ToggleSpacePanel, KeyBindingAction.ShowKeyboardSettings, @@ -611,6 +636,68 @@ export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = { }, displayName: _td("Open user settings"), }, + [KeyBindingAction.Escape]: { + default: { + key: Key.ESCAPE, + }, + displayName: _td("Close dialog or context menu"), + }, + [KeyBindingAction.Enter]: { + default: { + key: Key.ENTER, + }, + displayName: _td("Activate selected button"), + }, + [KeyBindingAction.Space]: { + default: { + key: Key.SPACE, + }, + }, + [KeyBindingAction.Backspace]: { + default: { + key: Key.BACKSPACE, + }, + }, + [KeyBindingAction.Delete]: { + default: { + key: Key.DELETE, + }, + }, + [KeyBindingAction.Home]: { + default: { + key: Key.HOME, + }, + }, + [KeyBindingAction.End]: { + default: { + key: Key.END, + }, + }, + [KeyBindingAction.ArrowLeft]: { + default: { + key: Key.ARROW_LEFT, + }, + }, + [KeyBindingAction.ArrowUp]: { + default: { + key: Key.ARROW_UP, + }, + }, + [KeyBindingAction.ArrowRight]: { + default: { + key: Key.ARROW_RIGHT, + }, + }, + [KeyBindingAction.ArrowDown]: { + default: { + key: Key.ARROW_DOWN, + }, + }, + [KeyBindingAction.Comma]: { + default: { + key: Key.COMMA, + }, + }, }; // XXX: These have to be manually mirrored in KeyBindingDefaults @@ -651,18 +738,6 @@ const getNonCustomizableShortcuts = (): IKeyboardShortcuts => { }, displayName: _td("Search (must be enabled)"), }, - [KeyBindingAction.CloseDialogOrContextMenu]: { - default: { - key: Key.ESCAPE, - }, - displayName: _td("Close dialog or context menu"), - }, - [KeyBindingAction.ActivateSelectedButton]: { - default: { - key: Key.ENTER, - }, - displayName: _td("Activate selected button"), - }, }; if (PlatformPeg.get().overrideBrowserShortcuts()) { diff --git a/src/accessibility/RovingTabIndex.tsx b/src/accessibility/RovingTabIndex.tsx index 842b4edce0..ea6699d367 100644 --- a/src/accessibility/RovingTabIndex.tsx +++ b/src/accessibility/RovingTabIndex.tsx @@ -27,7 +27,8 @@ import React, { RefObject, } from "react"; -import { Key } from "../Keyboard"; +import { getKeyBindingsManager } from "../KeyBindingsManager"; +import { KeyBindingAction } from "./KeyboardShortcuts"; import { FocusHandler, Ref } from "./roving/types"; /** @@ -207,12 +208,13 @@ export const RovingTabIndexProvider: React.FC = ({ } let handled = false; + const action = getKeyBindingsManager().getAccessibilityAction(ev); let focusRef: RefObject; // Don't interfere with input default keydown behaviour // but allow people to move focus from it with Tab. if (checkInputableElement(ev.target as HTMLElement)) { - switch (ev.key) { - case Key.TAB: + switch (action) { + case KeyBindingAction.Tab: handled = true; if (context.state.refs.length > 0) { const idx = context.state.refs.indexOf(context.state.activeRef); @@ -222,8 +224,8 @@ export const RovingTabIndexProvider: React.FC = ({ } } else { // check if we actually have any items - switch (ev.key) { - case Key.HOME: + switch (action) { + case KeyBindingAction.Home: if (handleHomeEnd) { handled = true; // move focus to first (visible) item @@ -231,7 +233,7 @@ export const RovingTabIndexProvider: React.FC = ({ } break; - case Key.END: + case KeyBindingAction.End: if (handleHomeEnd) { handled = true; // move focus to last (visible) item @@ -239,10 +241,10 @@ export const RovingTabIndexProvider: React.FC = ({ } break; - case Key.ARROW_DOWN: - case Key.ARROW_RIGHT: - if ((ev.key === Key.ARROW_DOWN && handleUpDown) || - (ev.key === Key.ARROW_RIGHT && handleLeftRight) + case KeyBindingAction.ArrowDown: + case KeyBindingAction.ArrowRight: + if ((action === KeyBindingAction.ArrowDown && handleUpDown) || + (action === KeyBindingAction.ArrowRight && handleLeftRight) ) { handled = true; if (context.state.refs.length > 0) { @@ -252,9 +254,11 @@ export const RovingTabIndexProvider: React.FC = ({ } break; - case Key.ARROW_UP: - case Key.ARROW_LEFT: - if ((ev.key === Key.ARROW_UP && handleUpDown) || (ev.key === Key.ARROW_LEFT && handleLeftRight)) { + case KeyBindingAction.ArrowUp: + case KeyBindingAction.ArrowLeft: + if ((action === KeyBindingAction.ArrowUp && handleUpDown) || + (action === KeyBindingAction.ArrowLeft && handleLeftRight) + ) { handled = true; if (context.state.refs.length > 0) { const idx = context.state.refs.indexOf(context.state.activeRef); diff --git a/src/accessibility/Toolbar.tsx b/src/accessibility/Toolbar.tsx index c0f2b56748..73d44e22a4 100644 --- a/src/accessibility/Toolbar.tsx +++ b/src/accessibility/Toolbar.tsx @@ -17,7 +17,8 @@ limitations under the License. import React from "react"; import { RovingTabIndexProvider } from "./RovingTabIndex"; -import { Key } from "../Keyboard"; +import { getKeyBindingsManager } from "../KeyBindingsManager"; +import { KeyBindingAction } from "./KeyboardShortcuts"; interface IProps extends Omit, "onKeyDown"> { } @@ -34,9 +35,10 @@ const Toolbar: React.FC = ({ children, ...props }) => { let handled = true; // HOME and END are handled by RovingTabIndexProvider - switch (ev.key) { - case Key.ARROW_UP: - case Key.ARROW_DOWN: + const action = getKeyBindingsManager().getAccessibilityAction(ev); + switch (action) { + case KeyBindingAction.ArrowUp: + case KeyBindingAction.ArrowDown: if (target.hasAttribute('aria-haspopup')) { target.click(); } diff --git a/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx index 7349646f2b..182428df00 100644 --- a/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx +++ b/src/accessibility/context_menu/StyledMenuItemCheckbox.tsx @@ -18,14 +18,15 @@ limitations under the License. import React from "react"; -import { Key } from "../../Keyboard"; import { useRovingTabIndex } from "../RovingTabIndex"; import StyledCheckbox from "../../components/views/elements/StyledCheckbox"; +import { KeyBindingAction } from "../KeyboardShortcuts"; +import { getKeyBindingsManager } from "../../KeyBindingsManager"; interface IProps extends React.ComponentProps { label?: string; onChange(); // we handle keyup/down ourselves so lose the ChangeEvent - onClose(): void; // gets called after onChange on Key.ENTER + onClose(): void; // gets called after onChange on KeyBindingAction.ActivateSelectedButton } // Semantic component for representing a styled role=menuitemcheckbox @@ -33,22 +34,37 @@ export const StyledMenuItemCheckbox: React.FC = ({ children, label, onCh const [onFocus, isActive, ref] = useRovingTabIndex(); const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === Key.ENTER || e.key === Key.SPACE) { + let handled = true; + const action = getKeyBindingsManager().getAccessibilityAction(e); + + switch (action) { + case KeyBindingAction.Space: + onChange(); + break; + case KeyBindingAction.Enter: + onChange(); + // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 + onClose(); + break; + default: + handled = false; + } + + if (handled) { e.stopPropagation(); e.preventDefault(); - onChange(); - // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 - if (e.key === Key.ENTER) { - onClose(); - } } }; const onKeyUp = (e: React.KeyboardEvent) => { - // prevent the input default handler as we handle it on keydown to match - // https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html - if (e.key === Key.SPACE || e.key === Key.ENTER) { - e.stopPropagation(); - e.preventDefault(); + const action = getKeyBindingsManager().getAccessibilityAction(e); + switch (action) { + case KeyBindingAction.Space: + case KeyBindingAction.Enter: + // prevent the input default handler as we handle it on keydown to match + // https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html + e.stopPropagation(); + e.preventDefault(); + break; } }; return ( diff --git a/src/accessibility/context_menu/StyledMenuItemRadio.tsx b/src/accessibility/context_menu/StyledMenuItemRadio.tsx index 0ce7f3d6f6..99440977f1 100644 --- a/src/accessibility/context_menu/StyledMenuItemRadio.tsx +++ b/src/accessibility/context_menu/StyledMenuItemRadio.tsx @@ -18,14 +18,15 @@ limitations under the License. import React from "react"; -import { Key } from "../../Keyboard"; import { useRovingTabIndex } from "../RovingTabIndex"; import StyledRadioButton from "../../components/views/elements/StyledRadioButton"; +import { KeyBindingAction } from "../KeyboardShortcuts"; +import { getKeyBindingsManager } from "../../KeyBindingsManager"; interface IProps extends React.ComponentProps { label?: string; onChange(); // we handle keyup/down ourselves so lose the ChangeEvent - onClose(): void; // gets called after onChange on Key.ENTER + onClose(): void; // gets called after onChange on KeyBindingAction.Enter } // Semantic component for representing a styled role=menuitemradio @@ -33,22 +34,37 @@ export const StyledMenuItemRadio: React.FC = ({ children, label, onChang const [onFocus, isActive, ref] = useRovingTabIndex(); const onKeyDown = (e: React.KeyboardEvent) => { - if (e.key === Key.ENTER || e.key === Key.SPACE) { + let handled = true; + const action = getKeyBindingsManager().getAccessibilityAction(e); + + switch (action) { + case KeyBindingAction.Space: + onChange(); + break; + case KeyBindingAction.Enter: + onChange(); + // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 + onClose(); + break; + default: + handled = false; + } + + if (handled) { e.stopPropagation(); e.preventDefault(); - onChange(); - // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 - if (e.key === Key.ENTER) { - onClose(); - } } }; const onKeyUp = (e: React.KeyboardEvent) => { - // prevent the input default handler as we handle it on keydown to match - // https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html - if (e.key === Key.SPACE || e.key === Key.ENTER) { - e.stopPropagation(); - e.preventDefault(); + const action = getKeyBindingsManager().getAccessibilityAction(e); + switch (action) { + case KeyBindingAction.Enter: + case KeyBindingAction.Space: + // prevent the input default handler as we handle it on keydown to match + // https://www.w3.org/TR/wai-aria-practices/examples/menubar/menubar-2/menubar-2.html + e.stopPropagation(); + e.preventDefault(); + break; } }; return ( diff --git a/src/components/structures/ContextMenu.tsx b/src/components/structures/ContextMenu.tsx index 1730ec2396..27e9240b20 100644 --- a/src/components/structures/ContextMenu.tsx +++ b/src/components/structures/ContextMenu.tsx @@ -21,11 +21,12 @@ import ReactDOM from "react-dom"; import classNames from "classnames"; import FocusLock from "react-focus-lock"; -import { Key } from "../../Keyboard"; import { Writeable } from "../../@types/common"; import { replaceableComponent } from "../../utils/replaceableComponent"; import UIStore from "../../stores/UIStore"; import { checkInputableElement, RovingTabIndexProvider } from "../../accessibility/RovingTabIndex"; +import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; +import { getKeyBindingsManager } from "../../KeyBindingsManager"; // Shamelessly ripped off Modal.js. There's probably a better way // of doing reusable widgets like dialog boxes & menus where we go and @@ -191,30 +192,32 @@ export default class ContextMenu extends React.PureComponent { private onKeyDown = (ev: React.KeyboardEvent) => { ev.stopPropagation(); // prevent keyboard propagating out of the context menu, we're focus-locked + const action = getKeyBindingsManager().getAccessibilityAction(ev); + // If someone is managing their own focus, we will only exit for them with Escape. // They are probably using props.focusLock along with this option as well. if (!this.props.managed) { - if (ev.key === Key.ESCAPE) { + if (action === KeyBindingAction.Escape) { this.props.onFinished(); } return; } // When an is focused, only handle the Escape key - if (checkInputableElement(ev.target as HTMLElement) && ev.key !== Key.ESCAPE) { + if (checkInputableElement(ev.target as HTMLElement) && action !== KeyBindingAction.Escape) { return; } - if ( - ev.key === Key.ESCAPE || + if ([ + KeyBindingAction.Escape, // You can only navigate the ContextMenu by arrow keys and Home/End (see RovingTabIndex). // Tabbing to the next section of the page, will close the ContextMenu. - ev.key === Key.TAB || + KeyBindingAction.Tab, // When someone moves left or right along a (like the // MessageActionBar), we should close any ContextMenu that is open. - ev.key === Key.ARROW_LEFT || - ev.key === Key.ARROW_RIGHT - ) { + KeyBindingAction.ArrowLeft, + KeyBindingAction.ArrowRight, + ].includes(action)) { this.props.onFinished(); } }; diff --git a/src/components/structures/LeftPanel.tsx b/src/components/structures/LeftPanel.tsx index eb2a861251..5315b8f1cd 100644 --- a/src/components/structures/LeftPanel.tsx +++ b/src/components/structures/LeftPanel.tsx @@ -35,7 +35,6 @@ import { getKeyBindingsManager } from "../../KeyBindingsManager"; import UIStore from "../../stores/UIStore"; import { findSiblingElement, IState as IRovingTabIndexState } from "../../accessibility/RovingTabIndex"; import RoomListHeader from "../views/rooms/RoomListHeader"; -import { Key } from "../../Keyboard"; import RecentlyViewedButton from "../views/rooms/RecentlyViewedButton"; import { BreadcrumbsStore } from "../../stores/BreadcrumbsStore"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../stores/room-list/RoomListStore"; @@ -316,12 +315,15 @@ export default class LeftPanel extends React.Component { private onRoomListKeydown = (ev: React.KeyboardEvent) => { if (ev.altKey || ev.ctrlKey || ev.metaKey) return; if (SettingsStore.getValue("feature_spotlight")) return; + + const action = getKeyBindingsManager().getAccessibilityAction(ev); + // we cannot handle Space as that is an activation key for all focusable elements in this widget if (ev.key.length === 1) { ev.preventDefault(); ev.stopPropagation(); this.roomSearchRef.current?.appendChar(ev.key); - } else if (ev.key === Key.BACKSPACE) { + } else if (action === KeyBindingAction.Backspace) { ev.preventDefault(); ev.stopPropagation(); this.roomSearchRef.current?.backspace(); diff --git a/src/components/structures/LeftPanelWidget.tsx b/src/components/structures/LeftPanelWidget.tsx index 470b4e733d..a73d08d29f 100644 --- a/src/components/structures/LeftPanelWidget.tsx +++ b/src/components/structures/LeftPanelWidget.tsx @@ -20,7 +20,6 @@ import classNames from "classnames"; import AccessibleButton from "../views/elements/AccessibleButton"; import { useRovingTabIndex } from "../../accessibility/RovingTabIndex"; -import { Key } from "../../Keyboard"; import { useLocalStorageState } from "../../hooks/useLocalStorageState"; import MatrixClientContext from "../../contexts/MatrixClientContext"; import WidgetUtils, { IWidgetEvent } from "../../utils/WidgetUtils"; @@ -28,6 +27,8 @@ import { useAccountData } from "../../hooks/useAccountData"; import AppTile from "../views/elements/AppTile"; import { useSettingValue } from "../../hooks/useSettings"; import UIStore from "../../stores/UIStore"; +import { getKeyBindingsManager } from "../../KeyBindingsManager"; +import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; const MIN_HEIGHT = 100; const MAX_HEIGHT = 500; // or 50% of the window height @@ -91,16 +92,16 @@ const LeftPanelWidget: React.FC = () => { onFocus={onFocus} className="mx_LeftPanelWidget_headerContainer" onKeyDown={(ev: React.KeyboardEvent) => { - switch (ev.key) { - case Key.ARROW_LEFT: + const action = getKeyBindingsManager().getAccessibilityAction(ev); + switch (action) { + case KeyBindingAction.ArrowLeft: ev.stopPropagation(); setExpanded(false); break; - case Key.ARROW_RIGHT: { + case KeyBindingAction.ArrowRight: ev.stopPropagation(); setExpanded(true); break; - } } }} > diff --git a/src/components/structures/SearchBox.tsx b/src/components/structures/SearchBox.tsx index 86058a957a..5789e5adf1 100644 --- a/src/components/structures/SearchBox.tsx +++ b/src/components/structures/SearchBox.tsx @@ -19,9 +19,10 @@ import React, { createRef, HTMLProps } from 'react'; import { throttle } from 'lodash'; import classNames from 'classnames'; -import { Key } from '../../Keyboard'; import AccessibleButton from '../../components/views/elements/AccessibleButton'; import { replaceableComponent } from "../../utils/replaceableComponent"; +import { getKeyBindingsManager } from "../../KeyBindingsManager"; +import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; interface IProps extends HTMLProps { onSearch?: (query: string) => void; @@ -66,8 +67,9 @@ export default class SearchBox extends React.Component { }, 200, { trailing: true, leading: true }); private onKeyDown = (ev: React.KeyboardEvent): void => { - switch (ev.key) { - case Key.ESCAPE: + const action = getKeyBindingsManager().getAccessibilityAction(ev); + switch (action) { + case KeyBindingAction.Escape: this.clearSearch("keyboard"); break; } diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index fb17fc6cc6..65da850e8e 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -54,7 +54,6 @@ import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import { linkifyElement } from "../../HtmlUtils"; import { useDispatcher } from "../../hooks/useDispatcher"; import { Action } from "../../dispatcher/actions"; -import { Key } from "../../Keyboard"; import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex"; import { getDisplayAliasForRoom } from "./RoomDirectory"; import MatrixClientContext from "../../contexts/MatrixClientContext"; @@ -64,6 +63,8 @@ import { awaitRoomDownSync } from "../../utils/RoomUpgrade"; import RoomViewStore from "../../stores/RoomViewStore"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; import { JoinRoomReadyPayload } from "../../dispatcher/payloads/JoinRoomReadyPayload"; +import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; +import { getKeyBindingsManager } from "../../KeyBindingsManager"; interface IProps { space: Room; @@ -247,10 +248,13 @@ const Tile: React.FC = ({ if (showChildren) { const onChildrenKeyDown = (e) => { - if (e.key === Key.ARROW_LEFT) { - e.preventDefault(); - e.stopPropagation(); - ref.current?.focus(); + const action = getKeyBindingsManager().getAccessibilityAction(e); + switch (action) { + case KeyBindingAction.ArrowLeft: + e.preventDefault(); + e.stopPropagation(); + ref.current?.focus(); + break; } }; @@ -266,15 +270,16 @@ const Tile: React.FC = ({ onKeyDown = (e) => { let handled = false; - switch (e.key) { - case Key.ARROW_LEFT: + const action = getKeyBindingsManager().getAccessibilityAction(e); + switch (action) { + case KeyBindingAction.ArrowLeft: if (showChildren) { handled = true; toggleShowChildren(); } break; - case Key.ARROW_RIGHT: + case KeyBindingAction.ArrowRight: handled = true; if (showChildren) { const childSection = ref.current?.nextElementSibling; @@ -700,7 +705,11 @@ const SpaceHierarchy = ({ } const onKeyDown = (ev: KeyboardEvent, state: IState): void => { - if (ev.key === Key.ARROW_DOWN && ev.currentTarget.classList.contains("mx_SpaceHierarchy_search")) { + const action = getKeyBindingsManager().getAccessibilityAction(ev); + if ( + action === KeyBindingAction.ArrowDown && + ev.currentTarget.classList.contains("mx_SpaceHierarchy_search") + ) { state.refs[0]?.current?.focus(); } }; diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index 22609bc629..35818629bf 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -37,7 +37,6 @@ import UserActivity from "../../UserActivity"; import Modal from "../../Modal"; import dis from "../../dispatcher/dispatcher"; import { Action } from '../../dispatcher/actions'; -import { Key } from '../../Keyboard'; import Timer from '../../utils/Timer'; import shouldHideEvent from '../../shouldHideEvent'; import { haveTileForEvent } from "../views/rooms/EventTile"; @@ -54,6 +53,8 @@ import EditorStateTransfer from '../../utils/EditorStateTransfer'; import ErrorDialog from '../views/dialogs/ErrorDialog'; import CallEventGrouper, { buildCallEventGroupers } from "./CallEventGrouper"; import { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"; +import { getKeyBindingsManager } from "../../KeyBindingsManager"; +import { KeyBindingAction } from "../../accessibility/KeyboardShortcuts"; const PAGINATE_SIZE = 20; const INITIAL_SIZE = 20; @@ -1086,11 +1087,12 @@ class TimelinePanel extends React.Component { * We pass it down to the scroll panel. */ public handleScrollKey = ev => { - if (!this.messagePanel.current) { return; } + if (!this.messagePanel.current) return; // jump to the live timeline on ctrl-end, rather than the end of the // timeline window. - if (ev.ctrlKey && !ev.shiftKey && !ev.altKey && !ev.metaKey && ev.key === Key.END) { + const action = getKeyBindingsManager().getRoomAction(ev); + if (action === KeyBindingAction.JumpToLatestMessage) { this.jumpToLiveTimeline(); } else { this.messagePanel.current.handleScrollKey(ev); diff --git a/src/components/views/audio_messages/AudioPlayer.tsx b/src/components/views/audio_messages/AudioPlayer.tsx index 64ef971567..84b96632f1 100644 --- a/src/components/views/audio_messages/AudioPlayer.tsx +++ b/src/components/views/audio_messages/AudioPlayer.tsx @@ -20,11 +20,12 @@ import PlayPauseButton from "./PlayPauseButton"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { formatBytes } from "../../../utils/FormattingUtils"; import DurationClock from "./DurationClock"; -import { Key } from "../../../Keyboard"; import { _t } from "../../../languageHandler"; import SeekBar from "./SeekBar"; import PlaybackClock from "./PlaybackClock"; import AudioPlayerBase from "./AudioPlayerBase"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; @replaceableComponent("views.audio_messages.AudioPlayer") export default class AudioPlayer extends AudioPlayerBase { @@ -32,18 +33,29 @@ export default class AudioPlayer extends AudioPlayerBase { private seekRef: RefObject = createRef(); private onKeyDown = (ev: React.KeyboardEvent) => { + let handled = true; + const action = getKeyBindingsManager().getAccessibilityAction(ev); + + switch (action) { + case KeyBindingAction.Space: + this.playPauseRef.current?.toggleState(); + break; + case KeyBindingAction.ArrowLeft: + this.seekRef.current?.left(); + break; + case KeyBindingAction.ArrowRight: + this.seekRef.current?.right(); + break; + default: + handled = false; + break; + } + // stopPropagation() prevents the FocusComposer catch-all from triggering, // but we need to do it on key down instead of press (even though the user // interaction is typically on press). - if (ev.key === Key.SPACE) { + if (handled) { ev.stopPropagation(); - this.playPauseRef.current?.toggleState(); - } else if (ev.key === Key.ARROW_LEFT) { - ev.stopPropagation(); - this.seekRef.current?.left(); - } else if (ev.key === Key.ARROW_RIGHT) { - ev.stopPropagation(); - this.seekRef.current?.right(); } }; diff --git a/src/components/views/context_menus/RoomContextMenu.tsx b/src/components/views/context_menus/RoomContextMenu.tsx index cc7a567f59..d0bc235a9d 100644 --- a/src/components/views/context_menus/RoomContextMenu.tsx +++ b/src/components/views/context_menus/RoomContextMenu.tsx @@ -31,7 +31,6 @@ import { DefaultTagID, TagID } from "../../../stores/room-list/models"; import RoomListStore, { LISTS_UPDATE_EVENT } from "../../../stores/room-list/RoomListStore"; import dis from "../../../dispatcher/dispatcher"; import RoomListActions from "../../../actions/RoomListActions"; -import { Key } from "../../../Keyboard"; import { EchoChamber } from "../../../stores/local-echo/EchoChamber"; import { RoomNotifState } from "../../../RoomNotifs"; import Modal from "../../../Modal"; @@ -47,6 +46,8 @@ import DMRoomMap from "../../../utils/DMRoomMap"; import { Action } from "../../../dispatcher/actions"; import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; interface IProps extends IContextMenuProps { room: Room; @@ -267,9 +268,12 @@ const RoomContextMenu = ({ room, onFinished, ...props }: IProps) => { logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`); } - if ((ev as React.KeyboardEvent).key === Key.ENTER) { - // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 - onFinished(); + const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent); + switch (action) { + case KeyBindingAction.Enter: + // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 + onFinished(); + break; } }; diff --git a/src/components/views/dialogs/AddressPickerDialog.tsx b/src/components/views/dialogs/AddressPickerDialog.tsx index 52db395a00..ad925e4407 100644 --- a/src/components/views/dialogs/AddressPickerDialog.tsx +++ b/src/components/views/dialogs/AddressPickerDialog.tsx @@ -30,7 +30,6 @@ import * as Email from '../../../email'; import IdentityAuthClient from '../../../IdentityAuthClient'; import { getDefaultIdentityServerUrl, useDefaultIdentityServer } from '../../../utils/IdentityServerUtils'; import { abbreviateUrl } from '../../../utils/UrlUtils'; -import { Key } from "../../../Keyboard"; import { Action } from "../../../dispatcher/actions"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import AddressSelector from '../elements/AddressSelector'; @@ -38,6 +37,8 @@ import AddressTile from '../elements/AddressTile'; import BaseDialog from "./BaseDialog"; import DialogButtons from "../elements/DialogButtons"; import AccessibleButton from '../elements/AccessibleButton'; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; const TRUNCATE_QUERY_LIST = 40; const QUERY_USER_DIRECTORY_DEBOUNCE_MS = 200; @@ -167,40 +168,38 @@ export default class AddressPickerDialog extends React.Component private onKeyDown = (e: React.KeyboardEvent): void => { const textInput = this.textinput.current ? this.textinput.current.value : undefined; + let handled = true; + const action = getKeyBindingsManager().getAccessibilityAction(e); - if (e.key === Key.ESCAPE) { - e.stopPropagation(); - e.preventDefault(); + if (action === KeyBindingAction.Escape) { this.props.onFinished(false); - } else if (e.key === Key.ARROW_UP) { - e.stopPropagation(); - e.preventDefault(); - if (this.addressSelector.current) this.addressSelector.current.moveSelectionUp(); - } else if (e.key === Key.ARROW_DOWN) { - e.stopPropagation(); - e.preventDefault(); - if (this.addressSelector.current) this.addressSelector.current.moveSelectionDown(); - } else if (this.state.suggestedList.length > 0 && [Key.COMMA, Key.ENTER, Key.TAB].includes(e.key)) { - e.stopPropagation(); - e.preventDefault(); - if (this.addressSelector.current) this.addressSelector.current.chooseSelection(); - } else if (textInput.length === 0 && this.state.selectedList.length && e.key === Key.BACKSPACE) { - e.stopPropagation(); - e.preventDefault(); + } else if (e.key === KeyBindingAction.ArrowUp) { + this.addressSelector.current?.moveSelectionUp(); + } else if (e.key === KeyBindingAction.ArrowDown) { + this.addressSelector.current?.moveSelectionDown(); + } else if ( + [KeyBindingAction.Comma, KeyBindingAction.Enter, KeyBindingAction.Tab].includes(action) && + this.state.suggestedList.length > 0 + ) { + this.addressSelector.current?.chooseSelection(); + } else if (textInput.length === 0 && this.state.selectedList.length && action === KeyBindingAction.Backspace) { this.onDismissed(this.state.selectedList.length - 1)(); - } else if (e.key === Key.ENTER) { - e.stopPropagation(); - e.preventDefault(); + } else if (e.key === KeyBindingAction.Enter) { if (textInput === '') { // if there's nothing in the input box, submit the form this.onButtonClick(); } else { this.addAddressesToList([textInput]); } - } else if (textInput && (e.key === Key.COMMA || e.key === Key.TAB)) { + } else if (textInput && [KeyBindingAction.Comma, KeyBindingAction.Tab].includes(action)) { + this.addAddressesToList([textInput]); + } else { + handled = false; + } + + if (handled) { e.stopPropagation(); e.preventDefault(); - this.addAddressesToList([textInput]); } }; diff --git a/src/components/views/dialogs/BaseDialog.tsx b/src/components/views/dialogs/BaseDialog.tsx index 544207151d..519e38601c 100644 --- a/src/components/views/dialogs/BaseDialog.tsx +++ b/src/components/views/dialogs/BaseDialog.tsx @@ -21,7 +21,6 @@ import FocusLock from 'react-focus-lock'; import classNames from 'classnames'; import { MatrixClient } from "matrix-js-sdk/src/client"; -import { Key } from '../../../Keyboard'; import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; import { _t } from "../../../languageHandler"; @@ -30,6 +29,8 @@ import { replaceableComponent } from "../../../utils/replaceableComponent"; import Heading from '../typography/Heading'; import { IDialogProps } from "./IDialogProps"; import { PosthogScreenTracker, ScreenName } from "../../../PosthogTrackers"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; interface IProps extends IDialogProps { // Whether the dialog should have a 'close' button that will @@ -99,10 +100,16 @@ export default class BaseDialog extends React.Component { if (this.props.onKeyDown) { this.props.onKeyDown(e); } - if (this.props.hasCancel && e.key === Key.ESCAPE) { - e.stopPropagation(); - e.preventDefault(); - this.props.onFinished(false); + + const action = getKeyBindingsManager().getAccessibilityAction(e); + switch (action) { + case KeyBindingAction.Escape: + if (!this.props.hasCancel) break; + + e.stopPropagation(); + e.preventDefault(); + this.props.onFinished(false); + break; } }; diff --git a/src/components/views/dialogs/CreateRoomDialog.tsx b/src/components/views/dialogs/CreateRoomDialog.tsx index d5610835da..a594a64ca3 100644 --- a/src/components/views/dialogs/CreateRoomDialog.tsx +++ b/src/components/views/dialogs/CreateRoomDialog.tsx @@ -23,7 +23,6 @@ import SdkConfig from '../../../SdkConfig'; import withValidation, { IFieldState } from '../elements/Validation'; import { _t } from '../../../languageHandler'; import { MatrixClientPeg } from '../../../MatrixClientPeg'; -import { Key } from "../../../Keyboard"; import { IOpts, privateShouldBeEncrypted } from "../../../createRoom"; import { CommunityPrototypeStore } from "../../../stores/CommunityPrototypeStore"; import { replaceableComponent } from "../../../utils/replaceableComponent"; @@ -34,6 +33,8 @@ import DialogButtons from "../elements/DialogButtons"; import BaseDialog from "../dialogs/BaseDialog"; import SpaceStore from "../../../stores/spaces/SpaceStore"; import JoinRuleDropdown from "../elements/JoinRuleDropdown"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; interface IProps { defaultPublic?: boolean; @@ -136,10 +137,13 @@ export default class CreateRoomDialog extends React.Component { } private onKeyDown = (event: KeyboardEvent) => { - if (event.key === Key.ENTER) { - this.onOk(); - event.preventDefault(); - event.stopPropagation(); + const action = getKeyBindingsManager().getAccessibilityAction(event); + switch (action) { + case KeyBindingAction.Enter: + this.onOk(); + event.preventDefault(); + event.stopPropagation(); + break; } }; diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index c8271033ad..0875b87c5c 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -45,7 +45,6 @@ import { showAnyInviteErrors, showCommunityInviteDialog, } from "../../../RoomInvite"; -import { Key } from "../../../Keyboard"; import { Action } from "../../../dispatcher/actions"; import { DefaultTagID } from "../../../stores/room-list/models"; import RoomListStore from "../../../stores/room-list/RoomListStore"; @@ -71,6 +70,8 @@ import UserIdentifierCustomisations from '../../../customisations/UserIdentifier import CopyableText from "../elements/CopyableText"; import { ScreenName } from '../../../PosthogTrackers'; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -803,20 +804,36 @@ export default class InviteDialog extends React.PureComponent { if (this.state.busy) return; + + let handled = true; const value = e.target.value.trim(); - const hasModifiers = e.ctrlKey || e.shiftKey || e.metaKey; - if (!value && this.state.targets.length > 0 && e.key === Key.BACKSPACE && !hasModifiers) { - // when the field is empty and the user hits backspace remove the right-most target + const action = getKeyBindingsManager().getAccessibilityAction(e); + + switch (action) { + case KeyBindingAction.Backspace: + if (value || this.state.targets.length <= 0) break; + + // when the field is empty and the user hits backspace remove the right-most target + this.removeMember(this.state.targets[this.state.targets.length - 1]); + break; + case KeyBindingAction.Space: + if (!value || !value.includes("@") || value.includes(" ")) break; + + // when the user hits space and their input looks like an e-mail/MXID then try to convert it + this.convertFilter(); + break; + case KeyBindingAction.Enter: + if (!value) break; + + // when the user hits enter with something in their field try to convert it + this.convertFilter(); + break; + default: + handled = false; + } + + if (handled) { e.preventDefault(); - this.removeMember(this.state.targets[this.state.targets.length - 1]); - } else if (value && e.key === Key.ENTER && !hasModifiers) { - // when the user hits enter with something in their field try to convert it - e.preventDefault(); - this.convertFilter(); - } else if (value && e.key === Key.SPACE && !hasModifiers && value.includes("@") && !value.includes(" ")) { - // when the user hits space and their input looks like an e-mail/MXID then try to convert it - e.preventDefault(); - this.convertFilter(); } }; diff --git a/src/components/views/dialogs/ScrollableBaseModal.tsx b/src/components/views/dialogs/ScrollableBaseModal.tsx index 294229219b..390178407f 100644 --- a/src/components/views/dialogs/ScrollableBaseModal.tsx +++ b/src/components/views/dialogs/ScrollableBaseModal.tsx @@ -19,11 +19,12 @@ import { MatrixClient } from "matrix-js-sdk/src/client"; import FocusLock from "react-focus-lock"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import { Key } from "../../../Keyboard"; import { IDialogProps } from "./IDialogProps"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { _t } from "../../../languageHandler"; import AccessibleButton from "../elements/AccessibleButton"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; export interface IScrollableBaseState { canSubmit: boolean; @@ -45,10 +46,13 @@ export default abstract class ScrollableBaseModal { - if (e.key === Key.ESCAPE) { - e.stopPropagation(); - e.preventDefault(); - this.cancel(); + const action = getKeyBindingsManager().getAccessibilityAction(e); + switch (action) { + case KeyBindingAction.Escape: + e.stopPropagation(); + e.preventDefault(); + this.cancel(); + break; } }; diff --git a/src/components/views/dialogs/SpotlightDialog.tsx b/src/components/views/dialogs/SpotlightDialog.tsx index 701cb3099b..21501f8e36 100644 --- a/src/components/views/dialogs/SpotlightDialog.tsx +++ b/src/components/views/dialogs/SpotlightDialog.tsx @@ -46,7 +46,6 @@ import { Type, useRovingTabIndex, } from "../../../accessibility/RovingTabIndex"; -import { Key } from "../../../Keyboard"; import AccessibleButton from "../elements/AccessibleButton"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import SpaceStore from "../../../stores/spaces/SpaceStore"; @@ -596,23 +595,33 @@ const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => } const onDialogKeyDown = (ev: KeyboardEvent) => { - const navAction = getKeyBindingsManager().getNavigationAction(ev); - switch (navAction) { - case "KeyBinding.closeDialogOrContextMenu" as KeyBindingAction: + const navigationAction = getKeyBindingsManager().getNavigationAction(ev); + switch (navigationAction) { case KeyBindingAction.FilterRooms: ev.stopPropagation(); ev.preventDefault(); onFinished(); break; } + + const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(ev); + switch (accessibilityAction) { + case KeyBindingAction.Escape: + ev.stopPropagation(); + ev.preventDefault(); + onFinished(); + break; + } }; const onKeyDown = (ev: KeyboardEvent) => { let ref: RefObject; - switch (ev.key) { - case Key.ARROW_UP: - case Key.ARROW_DOWN: + const action = getKeyBindingsManager().getAccessibilityAction(ev); + + switch (action) { + case KeyBindingAction.ArrowUp: + case KeyBindingAction.ArrowDown: ev.stopPropagation(); ev.preventDefault(); @@ -629,12 +638,12 @@ const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => } const idx = refs.indexOf(rovingContext.state.activeRef); - ref = findSiblingElement(refs, idx + (ev.key === Key.ARROW_UP ? -1 : 1)); + ref = findSiblingElement(refs, idx + (action === KeyBindingAction.ArrowUp ? -1 : 1)); } break; - case Key.ARROW_LEFT: - case Key.ARROW_RIGHT: + case KeyBindingAction.ArrowLeft: + case KeyBindingAction.ArrowRight: // only handle these keys when we are in the recently viewed row of options if (!query && rovingContext.state.refs.length > 0 && @@ -646,11 +655,10 @@ const SpotlightDialog: React.FC = ({ initialText = "", onFinished }) => const refs = rovingContext.state.refs.filter(refIsForRecentlyViewed); const idx = refs.indexOf(rovingContext.state.activeRef); - ref = findSiblingElement(refs, idx + (ev.key === Key.ARROW_LEFT ? -1 : 1)); + ref = findSiblingElement(refs, idx + (action === KeyBindingAction.ArrowLeft ? -1 : 1)); } break; - - case Key.ENTER: + case KeyBindingAction.Enter: ev.stopPropagation(); ev.preventDefault(); rovingContext.state.activeRef?.current?.click(); diff --git a/src/components/views/elements/AccessibleButton.tsx b/src/components/views/elements/AccessibleButton.tsx index a93bb5ab83..bc96bfe708 100644 --- a/src/components/views/elements/AccessibleButton.tsx +++ b/src/components/views/elements/AccessibleButton.tsx @@ -17,7 +17,8 @@ import React, { ReactHTML } from 'react'; import classnames from 'classnames'; -import { Key } from '../../../Keyboard'; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; export type ButtonEvent = React.MouseEvent | React.KeyboardEvent | React.FormEvent; @@ -92,29 +93,36 @@ export default function AccessibleButton({ // Browsers handle space and enter keypresses differently and we are only adjusting to the // inconsistencies here newProps.onKeyDown = (e) => { - if (e.key === Key.ENTER) { - e.stopPropagation(); - e.preventDefault(); - return onClick(e); - } - if (e.key === Key.SPACE) { - e.stopPropagation(); - e.preventDefault(); - } else { - onKeyDown?.(e); + const action = getKeyBindingsManager().getAccessibilityAction(e); + + switch (action) { + case KeyBindingAction.Enter: + e.stopPropagation(); + e.preventDefault(); + return onClick(e); + case KeyBindingAction.Space: + e.stopPropagation(); + e.preventDefault(); + break; + default: + onKeyDown?.(e); } }; newProps.onKeyUp = (e) => { - if (e.key === Key.SPACE) { - e.stopPropagation(); - e.preventDefault(); - return onClick(e); - } - if (e.key === Key.ENTER) { - e.stopPropagation(); - e.preventDefault(); - } else { - onKeyUp?.(e); + const action = getKeyBindingsManager().getAccessibilityAction(e); + + switch (action) { + case KeyBindingAction.Enter: + e.stopPropagation(); + e.preventDefault(); + break; + case KeyBindingAction.Space: + e.stopPropagation(); + e.preventDefault(); + return onClick(e); + default: + onKeyUp?.(e); + break; } }; } diff --git a/src/components/views/elements/Dropdown.tsx b/src/components/views/elements/Dropdown.tsx index 5689b7666f..9f071cafa9 100644 --- a/src/components/views/elements/Dropdown.tsx +++ b/src/components/views/elements/Dropdown.tsx @@ -20,8 +20,9 @@ import classnames from 'classnames'; import AccessibleButton, { ButtonEvent } from './AccessibleButton'; import { _t } from '../../../languageHandler'; -import { Key } from "../../../Keyboard"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; interface IMenuOptionProps { children: ReactElement; @@ -181,10 +182,12 @@ export default class Dropdown extends React.Component { private onAccessibleButtonClick = (ev: ButtonEvent) => { if (this.props.disabled) return; + const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent); + if (!this.state.expanded) { this.setState({ expanded: true }); ev.preventDefault(); - } else if ((ev as React.KeyboardEvent).key === Key.ENTER) { + } else if (action === KeyBindingAction.Enter) { // the accessible button consumes enter onKeyDown for firing onClick, so handle it here this.props.onOptionChange(this.state.highlightedOption); this.close(); @@ -214,14 +217,15 @@ export default class Dropdown extends React.Component { let handled = true; // These keys don't generate keypress events and so needs to be on keyup - switch (e.key) { - case Key.ENTER: + const action = getKeyBindingsManager().getAccessibilityAction(e); + switch (action) { + case KeyBindingAction.Enter: this.props.onOptionChange(this.state.highlightedOption); // fallthrough - case Key.ESCAPE: + case KeyBindingAction.Escape: this.close(); break; - case Key.ARROW_DOWN: + case KeyBindingAction.ArrowDown: if (this.state.expanded) { this.setState({ highlightedOption: this.nextOption(this.state.highlightedOption), @@ -230,7 +234,7 @@ export default class Dropdown extends React.Component { this.setState({ expanded: true }); } break; - case Key.ARROW_UP: + case KeyBindingAction.ArrowUp: if (this.state.expanded) { this.setState({ highlightedOption: this.prevOption(this.state.highlightedOption), diff --git a/src/components/views/elements/EditableText.tsx b/src/components/views/elements/EditableText.tsx index 122292a8f5..8a9e4ed91a 100644 --- a/src/components/views/elements/EditableText.tsx +++ b/src/components/views/elements/EditableText.tsx @@ -17,7 +17,8 @@ limitations under the License. import React, { createRef } from 'react'; -import { Key } from "../../../Keyboard"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; import { replaceableComponent } from "../../../utils/replaceableComponent"; enum Phases { @@ -124,9 +125,12 @@ export default class EditableText extends React.Component { this.showPlaceholder(false); } - if (ev.key === Key.ENTER) { - ev.stopPropagation(); - ev.preventDefault(); + const action = getKeyBindingsManager().getAccessibilityAction(ev); + switch (action) { + case KeyBindingAction.Enter: + ev.stopPropagation(); + ev.preventDefault(); + break; } // console.log("keyDown: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); @@ -141,10 +145,14 @@ export default class EditableText extends React.Component { this.value = (ev.target as HTMLDivElement).textContent; } - if (ev.key === Key.ENTER) { - this.onFinish(ev); - } else if (ev.key === Key.ESCAPE) { - this.cancelEdit(); + const action = getKeyBindingsManager().getAccessibilityAction(ev); + switch (action) { + case KeyBindingAction.Escape: + this.cancelEdit(); + break; + case KeyBindingAction.Enter: + this.onFinish(ev); + break; } // console.log("keyUp: textContent=" + ev.target.textContent + ", value=" + this.value + ", placeholder=" + this.placeholder); @@ -179,7 +187,8 @@ export default class EditableText extends React.Component { ): void => { // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; - const submit = ("key" in ev && ev.key === Key.ENTER) || shouldSubmit; + const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent); + const submit = action === KeyBindingAction.Enter || shouldSubmit; this.setState({ phase: Phases.Display, }, () => { diff --git a/src/components/views/elements/ImageView.tsx b/src/components/views/elements/ImageView.tsx index 11d07c4542..df9b128885 100644 --- a/src/components/views/elements/ImageView.tsx +++ b/src/components/views/elements/ImageView.tsx @@ -22,7 +22,6 @@ import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { _t } from '../../../languageHandler'; import AccessibleTooltipButton from "./AccessibleTooltipButton"; -import { Key } from "../../../Keyboard"; import MemberAvatar from "../avatars/MemberAvatar"; import { ContextMenuTooltipButton } from "../../../accessibility/context_menu/ContextMenuTooltipButton"; import MessageContextMenu from "../context_menus/MessageContextMenu"; @@ -38,6 +37,8 @@ import { normalizeWheelEvent } from "../../../utils/Mouse"; import { IDialogProps } from '../dialogs/IDialogProps'; import UIStore from '../../../stores/UIStore'; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; // Max scale to keep gaps around the image const MAX_SCALE = 0.95; @@ -292,10 +293,13 @@ export default class ImageView extends React.Component { }; private onKeyDown = (ev: KeyboardEvent) => { - if (ev.key === Key.ESCAPE) { - ev.stopPropagation(); - ev.preventDefault(); - this.props.onFinished(); + const action = getKeyBindingsManager().getAccessibilityAction(ev); + switch (action) { + case KeyBindingAction.Escape: + ev.stopPropagation(); + ev.preventDefault(); + this.props.onFinished(); + break; } }; diff --git a/src/components/views/elements/PowerSelector.tsx b/src/components/views/elements/PowerSelector.tsx index 1c3af65b45..2cf028e225 100644 --- a/src/components/views/elements/PowerSelector.tsx +++ b/src/components/views/elements/PowerSelector.tsx @@ -19,8 +19,9 @@ import React from 'react'; import * as Roles from '../../../Roles'; import { _t } from '../../../languageHandler'; import Field from "./Field"; -import { Key } from "../../../Keyboard"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; const CUSTOM_VALUE = "SELECT_VALUE_CUSTOM"; @@ -130,16 +131,19 @@ export default class PowerSelector extends React.Component { }; private onCustomKeyDown = (event: React.KeyboardEvent): void => { - if (event.key === Key.ENTER) { - event.preventDefault(); - event.stopPropagation(); + const action = getKeyBindingsManager().getAccessibilityAction(event); + switch (action) { + case KeyBindingAction.Enter: + event.preventDefault(); + event.stopPropagation(); - // Do not call the onChange handler directly here - it can cause an infinite loop. - // Long story short, a user hits Enter to submit the value which onChange handles as - // raising a dialog which causes a blur which causes a dialog which causes a blur and - // so on. By not causing the onChange to be called here, we avoid the loop because we - // handle the onBlur safely. - (event.target as HTMLInputElement).blur(); + // Do not call the onChange handler directly here - it can cause an infinite loop. + // Long story short, a user hits Enter to submit the value which onChange handles as + // raising a dialog which causes a blur which causes a dialog which causes a blur and + // so on. By not causing the onChange to be called here, we avoid the loop because we + // handle the onBlur safely. + (event.target as HTMLInputElement).blur(); + break; } }; diff --git a/src/components/views/emojipicker/Header.tsx b/src/components/views/emojipicker/Header.tsx index e4619dedf2..e364d87eae 100644 --- a/src/components/views/emojipicker/Header.tsx +++ b/src/components/views/emojipicker/Header.tsx @@ -19,9 +19,10 @@ import React from 'react'; import classNames from "classnames"; import { _t } from "../../../languageHandler"; -import { Key } from "../../../Keyboard"; import { CategoryKey, ICategory } from "./Category"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; interface IProps { categories: ICategory[]; @@ -57,18 +58,20 @@ class Header extends React.PureComponent { // https://www.w3.org/TR/wai-aria-practices/examples/tabs/tabs-1/tabs.html private onKeyDown = (ev: React.KeyboardEvent) => { let handled = true; - switch (ev.key) { - case Key.ARROW_LEFT: + + const action = getKeyBindingsManager().getAccessibilityAction(ev); + switch (action) { + case KeyBindingAction.ArrowLeft: this.changeCategoryRelative(-1); break; - case Key.ARROW_RIGHT: + case KeyBindingAction.ArrowRight: this.changeCategoryRelative(1); break; - case Key.HOME: + case KeyBindingAction.Home: this.changeCategoryAbsolute(0); break; - case Key.END: + case KeyBindingAction.End: this.changeCategoryAbsolute(this.props.categories.length - 1, -1); break; default: diff --git a/src/components/views/emojipicker/Search.tsx b/src/components/views/emojipicker/Search.tsx index ee9e9f5edf..98273e926b 100644 --- a/src/components/views/emojipicker/Search.tsx +++ b/src/components/views/emojipicker/Search.tsx @@ -18,8 +18,9 @@ limitations under the License. import React from 'react'; import { _t } from '../../../languageHandler'; -import { Key } from "../../../Keyboard"; import { replaceableComponent } from "../../../utils/replaceableComponent"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; interface IProps { query: string; @@ -37,10 +38,13 @@ class Search extends React.PureComponent { } private onKeyDown = (ev: React.KeyboardEvent) => { - if (ev.key === Key.ENTER) { - this.props.onEnter(); - ev.stopPropagation(); - ev.preventDefault(); + const action = getKeyBindingsManager().getAccessibilityAction(ev); + switch (action) { + case KeyBindingAction.Enter: + this.props.onEnter(); + ev.stopPropagation(); + ev.preventDefault(); + break; } }; diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index 44fa879b84..1d2f6a0483 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -480,6 +480,7 @@ export default class BasicMessageEditor extends React.Component } const autocompleteAction = getKeyBindingsManager().getAutocompleteAction(event); + const accessibilityAction = getKeyBindingsManager().getAccessibilityAction(event); if (model.autoComplete?.hasCompletions()) { const autoComplete = model.autoComplete; switch (autocompleteAction) { @@ -509,7 +510,7 @@ export default class BasicMessageEditor extends React.Component // there is no current autocomplete window, try to open it this.tabCompleteName(); handled = true; - } else if (event.key === Key.BACKSPACE || event.key === Key.DELETE) { + } else if ([KeyBindingAction.Delete, KeyBindingAction.Backspace].includes(accessibilityAction)) { this.formatBarRef.current.hide(); } diff --git a/src/components/views/rooms/RoomSublist.tsx b/src/components/views/rooms/RoomSublist.tsx index 32ff441661..b19bed523f 100644 --- a/src/components/views/rooms/RoomSublist.tsx +++ b/src/components/views/rooms/RoomSublist.tsx @@ -44,7 +44,6 @@ import defaultDispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import NotificationBadge from "./NotificationBadge"; import AccessibleTooltipButton from "../elements/AccessibleTooltipButton"; -import { Key } from "../../../Keyboard"; import { ActionPayload } from "../../../dispatcher/payloads"; import { polyfillTouchEvent } from "../../../@types/polyfill"; import ResizeNotifier from "../../../utils/ResizeNotifier"; @@ -503,14 +502,15 @@ export default class RoomSublist extends React.Component { }; private onKeyDown = (ev: React.KeyboardEvent) => { - switch (ev.key) { - // On ARROW_LEFT go to the sublist header - case Key.ARROW_LEFT: + const action = getKeyBindingsManager().getAccessibilityAction(ev); + switch (action) { + // On ArrowLeft go to the sublist header + case KeyBindingAction.ArrowLeft: ev.stopPropagation(); this.headerButton.current.focus(); break; - // Consume ARROW_RIGHT so it doesn't cause focus to get sent to composer - case Key.ARROW_RIGHT: + // Consume ArrowRight so it doesn't cause focus to get sent to composer + case KeyBindingAction.ArrowRight: ev.stopPropagation(); } }; diff --git a/src/components/views/rooms/RoomTile.tsx b/src/components/views/rooms/RoomTile.tsx index dc127185af..dababf8e75 100644 --- a/src/components/views/rooms/RoomTile.tsx +++ b/src/components/views/rooms/RoomTile.tsx @@ -25,7 +25,6 @@ import AccessibleButton, { ButtonEvent } from "../../views/elements/AccessibleBu import dis from '../../../dispatcher/dispatcher'; import defaultDispatcher from '../../../dispatcher/dispatcher'; import { Action } from "../../../dispatcher/actions"; -import { Key } from "../../../Keyboard"; import ActiveRoomObserver from "../../../ActiveRoomObserver"; import { _t } from "../../../languageHandler"; import { ChevronFace, ContextMenuTooltipButton } from "../../structures/ContextMenu"; @@ -54,6 +53,8 @@ import { CommunityPrototypeStore, IRoomProfile } from "../../../stores/Community import { replaceableComponent } from "../../../utils/replaceableComponent"; import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; interface IProps { room: Room; @@ -240,11 +241,14 @@ export default class RoomTile extends React.PureComponent { private onTileClick = (ev: React.KeyboardEvent) => { ev.preventDefault(); ev.stopPropagation(); + + const action = getKeyBindingsManager().getAccessibilityAction(ev); + dis.dispatch({ action: Action.ViewRoom, show_room_tile: true, // make sure the room is visible in the list room_id: this.props.room.roomId, - clear_search: (ev && (ev.key === Key.ENTER || ev.key === Key.SPACE)), + clear_search: [KeyBindingAction.Enter, KeyBindingAction.Space].includes(action), metricsTrigger: "RoomList", metricsViaKeyboard: ev.type !== "click", }); @@ -313,9 +317,12 @@ export default class RoomTile extends React.PureComponent { logger.warn(`Unexpected tag ${tagId} applied to ${this.props.room.roomId}`); } - if ((ev as React.KeyboardEvent).key === Key.ENTER) { - // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 - this.setState({ generalMenuPosition: null }); // hide the menu + const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent); + switch (action) { + case KeyBindingAction.Enter: + // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 + this.setState({ generalMenuPosition: null }); // hide the menu + break; } }; @@ -387,10 +394,12 @@ export default class RoomTile extends React.PureComponent { this.roomProps.notificationVolume = newState; - const key = (ev as React.KeyboardEvent).key; - if (key === Key.ENTER) { - // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 - this.setState({ notificationsMenuPosition: null }); // hide the menu + const action = getKeyBindingsManager().getAccessibilityAction(ev as React.KeyboardEvent); + switch (action) { + case KeyBindingAction.Enter: + // Implements https://www.w3.org/TR/wai-aria-practices/#keyboard-interaction-12 + this.setState({ notificationsMenuPosition: null }); // hide the menu + break; } } diff --git a/src/components/views/rooms/SearchBar.tsx b/src/components/views/rooms/SearchBar.tsx index 05aceb4d10..7331a93e05 100644 --- a/src/components/views/rooms/SearchBar.tsx +++ b/src/components/views/rooms/SearchBar.tsx @@ -20,10 +20,11 @@ import classNames from "classnames"; import AccessibleButton from "../elements/AccessibleButton"; import { _t } from '../../../languageHandler'; -import { Key } from "../../../Keyboard"; import DesktopBuildsNotice, { WarningKind } from "../elements/DesktopBuildsNotice"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { PosthogScreenTracker } from '../../../PosthogTrackers'; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; interface IProps { onCancelClick: () => void; @@ -61,11 +62,12 @@ export default class SearchBar extends React.Component { }; private onSearchChange = (e: React.KeyboardEvent) => { - switch (e.key) { - case Key.ENTER: + const action = getKeyBindingsManager().getAccessibilityAction(e); + switch (action) { + case KeyBindingAction.Enter: this.onSearch(); break; - case Key.ESCAPE: + case KeyBindingAction.Escape: this.props.onCancelClick(); break; } diff --git a/src/components/views/settings/IntegrationManager.tsx b/src/components/views/settings/IntegrationManager.tsx index e11e3bd9b8..c2faa126d2 100644 --- a/src/components/views/settings/IntegrationManager.tsx +++ b/src/components/views/settings/IntegrationManager.tsx @@ -18,10 +18,11 @@ import React from 'react'; import { _t } from '../../../languageHandler'; import dis from '../../../dispatcher/dispatcher'; -import { Key } from "../../../Keyboard"; import { replaceableComponent } from "../../../utils/replaceableComponent"; import { ActionPayload } from '../../../dispatcher/payloads'; import Spinner from "../elements/Spinner"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; interface IProps { // false to display an error saying that we couldn't connect to the integration manager @@ -65,10 +66,13 @@ export default class IntegrationManager extends React.Component } private onKeyDown = (ev: KeyboardEvent): void => { - if (ev.key === Key.ESCAPE) { - ev.stopPropagation(); - ev.preventDefault(); - this.props.onFinished(); + const action = getKeyBindingsManager().getAccessibilityAction(ev); + switch (action) { + case KeyBindingAction.Escape: + ev.stopPropagation(); + ev.preventDefault(); + this.props.onFinished(); + break; } }; diff --git a/src/components/views/settings/tabs/user/KeyboardUserSettingsTab.tsx b/src/components/views/settings/tabs/user/KeyboardUserSettingsTab.tsx index a67e67a46b..d05e226c1a 100644 --- a/src/components/views/settings/tabs/user/KeyboardUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/KeyboardUserSettingsTab.tsx @@ -35,8 +35,8 @@ const getKeyboardShortcutValue = (name: string): KeyBindingConfig => { return getKeyboardShortcuts()[name]?.default; }; -const getKeyboardShortcutDisplayName = (name: string): string => { - const keyboardShortcutDisplayName = getKeyboardShortcuts()[name]?.displayName as string; +const getKeyboardShortcutDisplayName = (name: string): string | null => { + const keyboardShortcutDisplayName = getKeyboardShortcuts()[name]?.displayName; return keyboardShortcutDisplayName && _t(keyboardShortcutDisplayName); }; @@ -93,8 +93,11 @@ const visibleCategories = Object.entries(CATEGORIES).filter(([categoryName]) => categoryName !== CategoryName.LABS || SdkConfig.get()['showLabsSettings']); const KeyboardShortcutRow: React.FC = ({ name }) => { + const displayName = getKeyboardShortcutDisplayName(name); + if (!displayName) return null; + return
- { getKeyboardShortcutDisplayName(name) } + { displayName }
; }; @@ -105,6 +108,8 @@ interface IKeyboardShortcutSectionProps { } const KeyboardShortcutSection: React.FC = ({ categoryName, category }) => { + if (!category.categoryLabel) return null; + return
{ _t(category.categoryLabel) }
{ category.settingNames.map((shortcutName) => { diff --git a/src/components/views/spaces/SpaceCreateMenu.tsx b/src/components/views/spaces/SpaceCreateMenu.tsx index 5203bd2c90..32de314483 100644 --- a/src/components/views/spaces/SpaceCreateMenu.tsx +++ b/src/components/views/spaces/SpaceCreateMenu.tsx @@ -38,7 +38,8 @@ import SettingsStore from "../../../settings/SettingsStore"; import defaultDispatcher from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; import { UserTab } from "../dialogs/UserSettingsDialog"; -import { Key } from "../../../Keyboard"; +import { getKeyBindingsManager } from "../../../KeyBindingsManager"; +import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; export const createSpace = async ( name: string, @@ -159,8 +160,11 @@ export const SpaceCreateForm: React.FC = ({ const domain = cli.getDomain(); const onKeyDown = (ev: KeyboardEvent) => { - if (ev.key === Key.ENTER) { - onSubmit(ev); + const action = getKeyBindingsManager().getAccessibilityAction(ev); + switch (action) { + case KeyBindingAction.Enter: + onSubmit(ev); + break; } }; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 7b8dfad5a7..33d0a4f898 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3404,6 +3404,7 @@ "[number]": "[number]", "Calls": "Calls", "Room List": "Room List", + "Accessibility": "Accessibility", "Navigation": "Navigation", "Autocomplete": "Autocomplete", "Toggle Bold": "Toggle Bold", @@ -3451,9 +3452,9 @@ "Next recently visited room or community": "Next recently visited room or community", "Switch to space by number": "Switch to space by number", "Open user settings": "Open user settings", + "Close dialog or context menu": "Close dialog or context menu", + "Activate selected button": "Activate selected button", "New line": "New line", "Force complete": "Force complete", - "Search (must be enabled)": "Search (must be enabled)", - "Close dialog or context menu": "Close dialog or context menu", - "Activate selected button": "Activate selected button" + "Search (must be enabled)": "Search (must be enabled)" } diff --git a/test/components/views/spaces/QuickThemeSwitcher-test.tsx b/test/components/views/spaces/QuickThemeSwitcher-test.tsx index b0688a6f1f..2631dbebdd 100644 --- a/test/components/views/spaces/QuickThemeSwitcher-test.tsx +++ b/test/components/views/spaces/QuickThemeSwitcher-test.tsx @@ -28,6 +28,7 @@ import { findById } from '../../../test-utils'; import { SettingLevel } from '../../../../src/settings/SettingLevel'; import dis from '../../../../src/dispatcher/dispatcher'; import { Action } from '../../../../src/dispatcher/actions'; +import PlatformPeg from "../../../../src/PlatformPeg"; jest.mock('../../../../src/theme'); jest.mock('../../../../src/components/views/settings/ThemeChoicePanel', () => ({ @@ -44,6 +45,8 @@ jest.mock('../../../../src/dispatcher/dispatcher', () => ({ register: jest.fn(), })); +PlatformPeg.get = () => ({ overrideBrowserShortcuts: () => false }); + describe('', () => { const defaultProps = { requestClose: jest.fn(),