Merge matrix-react-sdk into element-web
Merge remote-tracking branch 'repomerge/t3chguy/repomerge' into t3chguy/repo-merge Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { KeyCombo } from "../KeyBindingsManager";
|
||||
import { IS_MAC, Key } from "../Keyboard";
|
||||
import { _t, _td } from "../languageHandler";
|
||||
import PlatformPeg from "../PlatformPeg";
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import {
|
||||
DESKTOP_SHORTCUTS,
|
||||
DIGITS,
|
||||
IKeyboardShortcuts,
|
||||
KeyBindingAction,
|
||||
KEYBOARD_SHORTCUTS,
|
||||
KeyboardShortcutSetting,
|
||||
MAC_ONLY_SHORTCUTS,
|
||||
} from "./KeyboardShortcuts";
|
||||
|
||||
/**
|
||||
* This function gets the keyboard shortcuts that should be presented in the UI
|
||||
* but they shouldn't be consumed by KeyBindingDefaults. That means that these
|
||||
* have to be manually mirrored in KeyBindingDefaults.
|
||||
*/
|
||||
const getUIOnlyShortcuts = (): IKeyboardShortcuts => {
|
||||
const ctrlEnterToSend = SettingsStore.getValue("MessageComposerInput.ctrlEnterToSend");
|
||||
|
||||
const keyboardShortcuts: IKeyboardShortcuts = {
|
||||
[KeyBindingAction.SendMessage]: {
|
||||
default: {
|
||||
key: Key.ENTER,
|
||||
ctrlOrCmdKey: ctrlEnterToSend,
|
||||
},
|
||||
displayName: _td("composer|send_button_title"),
|
||||
},
|
||||
[KeyBindingAction.NewLine]: {
|
||||
default: {
|
||||
key: Key.ENTER,
|
||||
shiftKey: !ctrlEnterToSend,
|
||||
},
|
||||
displayName: _td("keyboard|composer_new_line"),
|
||||
},
|
||||
[KeyBindingAction.CompleteAutocomplete]: {
|
||||
default: {
|
||||
key: Key.ENTER,
|
||||
},
|
||||
displayName: _td("action|complete"),
|
||||
},
|
||||
[KeyBindingAction.ForceCompleteAutocomplete]: {
|
||||
default: {
|
||||
key: Key.TAB,
|
||||
},
|
||||
displayName: _td("keyboard|autocomplete_force"),
|
||||
},
|
||||
[KeyBindingAction.SearchInRoom]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
key: Key.F,
|
||||
},
|
||||
displayName: _td("keyboard|search"),
|
||||
},
|
||||
};
|
||||
|
||||
if (PlatformPeg.get()?.overrideBrowserShortcuts()) {
|
||||
// XXX: This keyboard shortcut isn't manually added to
|
||||
// KeyBindingDefaults as it can't be easily handled by the
|
||||
// KeyBindingManager
|
||||
keyboardShortcuts[KeyBindingAction.SwitchToSpaceByNumber] = {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
key: DIGITS,
|
||||
},
|
||||
displayName: _td("keyboard|switch_to_space"),
|
||||
};
|
||||
}
|
||||
|
||||
return keyboardShortcuts;
|
||||
};
|
||||
|
||||
/**
|
||||
* This function gets keyboard shortcuts that can be consumed by the KeyBindingDefaults.
|
||||
*/
|
||||
export const getKeyboardShortcuts = (): IKeyboardShortcuts => {
|
||||
const overrideBrowserShortcuts = PlatformPeg.get()?.overrideBrowserShortcuts();
|
||||
|
||||
return (Object.keys(KEYBOARD_SHORTCUTS) as KeyBindingAction[])
|
||||
.filter((k) => {
|
||||
if (KEYBOARD_SHORTCUTS[k]?.controller?.settingDisabled) return false;
|
||||
if (MAC_ONLY_SHORTCUTS.includes(k) && !IS_MAC) return false;
|
||||
if (DESKTOP_SHORTCUTS.includes(k) && !overrideBrowserShortcuts) return false;
|
||||
|
||||
return true;
|
||||
})
|
||||
.reduce((o, key) => {
|
||||
o[key as KeyBindingAction] = KEYBOARD_SHORTCUTS[key as KeyBindingAction];
|
||||
return o;
|
||||
}, {} as IKeyboardShortcuts);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets keyboard shortcuts that should be presented to the user in the UI.
|
||||
*/
|
||||
export const getKeyboardShortcutsForUI = (): IKeyboardShortcuts => {
|
||||
const entries = [...Object.entries(getUIOnlyShortcuts()), ...Object.entries(getKeyboardShortcuts())] as [
|
||||
KeyBindingAction,
|
||||
KeyboardShortcutSetting,
|
||||
][];
|
||||
|
||||
return entries.reduce((acc, [key, value]) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
}, {} as IKeyboardShortcuts);
|
||||
};
|
||||
|
||||
export const getKeyboardShortcutValue = (name: KeyBindingAction): KeyCombo | undefined => {
|
||||
return getKeyboardShortcutsForUI()[name]?.default;
|
||||
};
|
||||
|
||||
export const getKeyboardShortcutDisplayName = (name: KeyBindingAction): string | undefined => {
|
||||
const keyboardShortcutDisplayName = getKeyboardShortcutsForUI()[name]?.displayName;
|
||||
return keyboardShortcutDisplayName && _t(keyboardShortcutDisplayName);
|
||||
};
|
||||
@@ -0,0 +1,730 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2021, 2022 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { _td, TranslationKey } from "../languageHandler";
|
||||
import { IS_MAC, IS_ELECTRON, Key } from "../Keyboard";
|
||||
import { IBaseSetting } from "../settings/Settings";
|
||||
import { KeyCombo } from "../KeyBindingsManager";
|
||||
|
||||
export enum KeyBindingAction {
|
||||
/** Send a message */
|
||||
SendMessage = "KeyBinding.sendMessageInComposer",
|
||||
/** Go backwards through the send history and use the message in composer view */
|
||||
SelectPrevSendHistory = "KeyBinding.previousMessageInComposerHistory",
|
||||
/** Go forwards through the send history */
|
||||
SelectNextSendHistory = "KeyBinding.nextMessageInComposerHistory",
|
||||
/** Start editing the user's last sent message */
|
||||
EditPrevMessage = "KeyBinding.editPreviousMessage",
|
||||
/** Start editing the user's next sent message */
|
||||
EditNextMessage = "KeyBinding.editNextMessage",
|
||||
/** Cancel editing a message or cancel replying to a message */
|
||||
CancelReplyOrEdit = "KeyBinding.cancelReplyInComposer",
|
||||
/** Show the sticker picker */
|
||||
ShowStickerPicker = "KeyBinding.showStickerPicker",
|
||||
|
||||
/** Set bold format the current selection */
|
||||
FormatBold = "KeyBinding.toggleBoldInComposer",
|
||||
/** Set italics format the current selection */
|
||||
FormatItalics = "KeyBinding.toggleItalicsInComposer",
|
||||
/** Insert link for current selection */
|
||||
FormatLink = "KeyBinding.FormatLink",
|
||||
/** Set code format for current selection */
|
||||
FormatCode = "KeyBinding.FormatCode",
|
||||
/** Format the current selection as quote */
|
||||
FormatQuote = "KeyBinding.toggleQuoteInComposer",
|
||||
/** Undo the last editing */
|
||||
EditUndo = "KeyBinding.editUndoInComposer",
|
||||
/** Redo editing */
|
||||
EditRedo = "KeyBinding.editRedoInComposer",
|
||||
/** Insert new line */
|
||||
NewLine = "KeyBinding.newLineInComposer",
|
||||
/** Move the cursor to the start of the message */
|
||||
MoveCursorToStart = "KeyBinding.jumpToStartInComposer",
|
||||
/** Move the cursor to the end of the message */
|
||||
MoveCursorToEnd = "KeyBinding.jumpToEndInComposer",
|
||||
|
||||
/** Accepts chosen autocomplete selection */
|
||||
CompleteAutocomplete = "KeyBinding.completeAutocomplete",
|
||||
/** Accepts chosen autocomplete selection or,
|
||||
* if the autocompletion window is not shown, open the window and select the first selection */
|
||||
ForceCompleteAutocomplete = "KeyBinding.forceCompleteAutocomplete",
|
||||
/** Move to the previous autocomplete selection */
|
||||
PrevSelectionInAutocomplete = "KeyBinding.previousOptionInAutoComplete",
|
||||
/** Move to the next autocomplete selection */
|
||||
NextSelectionInAutocomplete = "KeyBinding.nextOptionInAutoComplete",
|
||||
/** Close the autocompletion window */
|
||||
CancelAutocomplete = "KeyBinding.cancelAutoComplete",
|
||||
|
||||
/** Clear room list filter field */
|
||||
ClearRoomFilter = "KeyBinding.clearRoomFilter",
|
||||
/** Navigate up/down in the room list */
|
||||
PrevRoom = "KeyBinding.downerRoom",
|
||||
/** Navigate down in the room list */
|
||||
NextRoom = "KeyBinding.upperRoom",
|
||||
/** Select room from the room list */
|
||||
SelectRoomInRoomList = "KeyBinding.selectRoomInRoomList",
|
||||
/** Collapse room list section */
|
||||
CollapseRoomListSection = "KeyBinding.collapseSectionInRoomList",
|
||||
/** Expand room list section, if already expanded, jump to first room in the selection */
|
||||
ExpandRoomListSection = "KeyBinding.expandSectionInRoomList",
|
||||
|
||||
/** Scroll up in the timeline */
|
||||
ScrollUp = "KeyBinding.scrollUpInTimeline",
|
||||
/** Scroll down in the timeline */
|
||||
ScrollDown = "KeyBinding.scrollDownInTimeline",
|
||||
/** Dismiss read marker and jump to bottom */
|
||||
DismissReadMarker = "KeyBinding.dismissReadMarkerAndJumpToBottom",
|
||||
/** Jump to oldest unread message */
|
||||
JumpToOldestUnread = "KeyBinding.jumpToOldestUnreadMessage",
|
||||
/** Upload a file */
|
||||
UploadFile = "KeyBinding.uploadFileToRoom",
|
||||
/** Focus search message in a room (must be enabled) */
|
||||
SearchInRoom = "KeyBinding.searchInRoom",
|
||||
/** Jump to the first (downloaded) message in the room */
|
||||
JumpToFirstMessage = "KeyBinding.jumpToFirstMessageInTimeline",
|
||||
/** Jump to the latest message in the room */
|
||||
JumpToLatestMessage = "KeyBinding.jumpToLastMessageInTimeline",
|
||||
|
||||
/** Jump to room search (search for a room) */
|
||||
FilterRooms = "KeyBinding.filterRooms",
|
||||
/** Toggle the space panel */
|
||||
ToggleSpacePanel = "KeyBinding.toggleSpacePanel",
|
||||
/** Toggle the room side panel */
|
||||
ToggleRoomSidePanel = "KeyBinding.toggleRightPanel",
|
||||
/** Toggle the user menu */
|
||||
ToggleUserMenu = "KeyBinding.toggleTopLeftMenu",
|
||||
/** Toggle the short cut help dialog */
|
||||
ShowKeyboardSettings = "KeyBinding.showKeyBindingsSettings",
|
||||
/** Got to the Element home screen */
|
||||
GoToHome = "KeyBinding.goToHomeView",
|
||||
/** Select prev room */
|
||||
SelectPrevRoom = "KeyBinding.previousRoom",
|
||||
/** Select next room */
|
||||
SelectNextRoom = "KeyBinding.nextRoom",
|
||||
/** Select prev room with unread messages */
|
||||
SelectPrevUnreadRoom = "KeyBinding.previousUnreadRoom",
|
||||
/** Select next room with unread messages */
|
||||
SelectNextUnreadRoom = "KeyBinding.nextUnreadRoom",
|
||||
|
||||
/** Switches to a space by number */
|
||||
SwitchToSpaceByNumber = "KeyBinding.switchToSpaceByNumber",
|
||||
/** Opens user settings */
|
||||
OpenUserSettings = "KeyBinding.openUserSettings",
|
||||
/** Navigates backward */
|
||||
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",
|
||||
/** Toggles webcam while on a call */
|
||||
ToggleWebcamInCall = "KeyBinding.toggleWebcamInCall",
|
||||
|
||||
/** 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",
|
||||
}
|
||||
|
||||
export type KeyboardShortcutSetting = Omit<IBaseSetting<KeyCombo>, "supportedLevels" | "displayName"> & {
|
||||
displayName?: TranslationKey;
|
||||
};
|
||||
|
||||
// TODO: We should figure out what to do with the keyboard shortcuts that are not handled by KeybindingManager
|
||||
export type IKeyboardShortcuts = Partial<Record<KeyBindingAction, KeyboardShortcutSetting>>;
|
||||
|
||||
export interface ICategory {
|
||||
categoryLabel?: TranslationKey;
|
||||
// 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",
|
||||
ROOM = "Room",
|
||||
AUTOCOMPLETE = "Autocomplete",
|
||||
LABS = "Labs",
|
||||
}
|
||||
|
||||
// Meta-key representing the digits [0-9] often found at the top of standard keyboard layouts
|
||||
export const DIGITS = "digits";
|
||||
|
||||
export const ALTERNATE_KEY_NAME: Record<string, TranslationKey> = {
|
||||
[Key.PAGE_UP]: _td("keyboard|page_up"),
|
||||
[Key.PAGE_DOWN]: _td("keyboard|page_down"),
|
||||
[Key.ESCAPE]: _td("keyboard|escape"),
|
||||
[Key.ENTER]: _td("keyboard|enter"),
|
||||
[Key.SPACE]: _td("keyboard|space"),
|
||||
[Key.HOME]: _td("keyboard|home"),
|
||||
[Key.END]: _td("keyboard|end"),
|
||||
[Key.ALT]: _td("keyboard|alt"),
|
||||
[Key.CONTROL]: _td("keyboard|control"),
|
||||
[Key.SHIFT]: _td("keyboard|shift"),
|
||||
[DIGITS]: _td("keyboard|number"),
|
||||
};
|
||||
export const KEY_ICON: Record<string, string> = {
|
||||
[Key.ARROW_UP]: "↑",
|
||||
[Key.ARROW_DOWN]: "↓",
|
||||
[Key.ARROW_LEFT]: "←",
|
||||
[Key.ARROW_RIGHT]: "→",
|
||||
};
|
||||
if (IS_MAC) {
|
||||
KEY_ICON[Key.META] = "⌘";
|
||||
KEY_ICON[Key.ALT] = "⌥";
|
||||
KEY_ICON[Key.SHIFT] = "⇧";
|
||||
}
|
||||
|
||||
export const CATEGORIES: Record<CategoryName, ICategory> = {
|
||||
[CategoryName.COMPOSER]: {
|
||||
categoryLabel: _td("settings|preferences|composer_heading"),
|
||||
settingNames: [
|
||||
KeyBindingAction.SendMessage,
|
||||
KeyBindingAction.NewLine,
|
||||
KeyBindingAction.FormatBold,
|
||||
KeyBindingAction.FormatItalics,
|
||||
KeyBindingAction.FormatQuote,
|
||||
KeyBindingAction.FormatLink,
|
||||
KeyBindingAction.FormatCode,
|
||||
KeyBindingAction.EditUndo,
|
||||
KeyBindingAction.EditRedo,
|
||||
KeyBindingAction.MoveCursorToStart,
|
||||
KeyBindingAction.MoveCursorToEnd,
|
||||
KeyBindingAction.CancelReplyOrEdit,
|
||||
KeyBindingAction.EditNextMessage,
|
||||
KeyBindingAction.EditPrevMessage,
|
||||
KeyBindingAction.SelectNextSendHistory,
|
||||
KeyBindingAction.SelectPrevSendHistory,
|
||||
KeyBindingAction.ShowStickerPicker,
|
||||
],
|
||||
},
|
||||
[CategoryName.CALLS]: {
|
||||
categoryLabel: _td("keyboard|category_calls"),
|
||||
settingNames: [KeyBindingAction.ToggleMicInCall, KeyBindingAction.ToggleWebcamInCall],
|
||||
},
|
||||
[CategoryName.ROOM]: {
|
||||
categoryLabel: _td("common|room"),
|
||||
settingNames: [
|
||||
KeyBindingAction.SearchInRoom,
|
||||
KeyBindingAction.UploadFile,
|
||||
KeyBindingAction.DismissReadMarker,
|
||||
KeyBindingAction.JumpToOldestUnread,
|
||||
KeyBindingAction.ScrollUp,
|
||||
KeyBindingAction.ScrollDown,
|
||||
KeyBindingAction.JumpToFirstMessage,
|
||||
KeyBindingAction.JumpToLatestMessage,
|
||||
],
|
||||
},
|
||||
[CategoryName.ROOM_LIST]: {
|
||||
categoryLabel: _td("keyboard|category_room_list"),
|
||||
settingNames: [
|
||||
KeyBindingAction.SelectRoomInRoomList,
|
||||
KeyBindingAction.ClearRoomFilter,
|
||||
KeyBindingAction.CollapseRoomListSection,
|
||||
KeyBindingAction.ExpandRoomListSection,
|
||||
KeyBindingAction.NextRoom,
|
||||
KeyBindingAction.PrevRoom,
|
||||
],
|
||||
},
|
||||
[CategoryName.ACCESSIBILITY]: {
|
||||
categoryLabel: _td("common|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("keyboard|category_navigation"),
|
||||
settingNames: [
|
||||
KeyBindingAction.ToggleUserMenu,
|
||||
KeyBindingAction.ToggleRoomSidePanel,
|
||||
KeyBindingAction.ToggleSpacePanel,
|
||||
KeyBindingAction.ShowKeyboardSettings,
|
||||
KeyBindingAction.GoToHome,
|
||||
KeyBindingAction.FilterRooms,
|
||||
KeyBindingAction.SelectNextUnreadRoom,
|
||||
KeyBindingAction.SelectPrevUnreadRoom,
|
||||
KeyBindingAction.SelectNextRoom,
|
||||
KeyBindingAction.SelectPrevRoom,
|
||||
KeyBindingAction.OpenUserSettings,
|
||||
KeyBindingAction.SwitchToSpaceByNumber,
|
||||
KeyBindingAction.PreviousVisitedRoomOrSpace,
|
||||
KeyBindingAction.NextVisitedRoomOrSpace,
|
||||
KeyBindingAction.NextLandmark,
|
||||
KeyBindingAction.PreviousLandmark,
|
||||
],
|
||||
},
|
||||
[CategoryName.AUTOCOMPLETE]: {
|
||||
categoryLabel: _td("keyboard|category_autocomplete"),
|
||||
settingNames: [
|
||||
KeyBindingAction.CancelAutocomplete,
|
||||
KeyBindingAction.NextSelectionInAutocomplete,
|
||||
KeyBindingAction.PrevSelectionInAutocomplete,
|
||||
KeyBindingAction.CompleteAutocomplete,
|
||||
KeyBindingAction.ForceCompleteAutocomplete,
|
||||
],
|
||||
},
|
||||
[CategoryName.LABS]: {
|
||||
categoryLabel: _td("common|labs"),
|
||||
settingNames: [KeyBindingAction.ToggleHiddenEventVisibility],
|
||||
},
|
||||
};
|
||||
|
||||
export const DESKTOP_SHORTCUTS = [
|
||||
KeyBindingAction.OpenUserSettings,
|
||||
KeyBindingAction.SwitchToSpaceByNumber,
|
||||
KeyBindingAction.PreviousVisitedRoomOrSpace,
|
||||
KeyBindingAction.NextVisitedRoomOrSpace,
|
||||
];
|
||||
|
||||
export const MAC_ONLY_SHORTCUTS = [KeyBindingAction.OpenUserSettings];
|
||||
|
||||
// This is very intentionally modelled after SETTINGS as it will make it easier
|
||||
// to implement customizable keyboard shortcuts
|
||||
// TODO: TravisR will fix this nightmare when the new version of the SettingsStore becomes a thing
|
||||
// XXX: Exported for tests
|
||||
export const KEYBOARD_SHORTCUTS: IKeyboardShortcuts = {
|
||||
[KeyBindingAction.FormatBold]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
key: Key.B,
|
||||
},
|
||||
displayName: _td("keyboard|composer_toggle_bold"),
|
||||
},
|
||||
[KeyBindingAction.FormatItalics]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
key: Key.I,
|
||||
},
|
||||
displayName: _td("keyboard|composer_toggle_italics"),
|
||||
},
|
||||
[KeyBindingAction.FormatQuote]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
shiftKey: true,
|
||||
key: Key.GREATER_THAN,
|
||||
},
|
||||
displayName: _td("keyboard|composer_toggle_quote"),
|
||||
},
|
||||
[KeyBindingAction.FormatCode]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
key: Key.E,
|
||||
},
|
||||
displayName: _td("keyboard|composer_toggle_code_block"),
|
||||
},
|
||||
[KeyBindingAction.FormatLink]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
shiftKey: true,
|
||||
key: Key.L,
|
||||
},
|
||||
displayName: _td("keyboard|composer_toggle_link"),
|
||||
},
|
||||
[KeyBindingAction.CancelReplyOrEdit]: {
|
||||
default: {
|
||||
key: Key.ESCAPE,
|
||||
},
|
||||
displayName: _td("keyboard|cancel_reply"),
|
||||
},
|
||||
[KeyBindingAction.EditNextMessage]: {
|
||||
default: {
|
||||
key: Key.ARROW_DOWN,
|
||||
},
|
||||
displayName: _td("keyboard|navigate_next_message_edit"),
|
||||
},
|
||||
[KeyBindingAction.EditPrevMessage]: {
|
||||
default: {
|
||||
key: Key.ARROW_UP,
|
||||
},
|
||||
displayName: _td("keyboard|navigate_prev_message_edit"),
|
||||
},
|
||||
[KeyBindingAction.MoveCursorToStart]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
key: Key.HOME,
|
||||
},
|
||||
displayName: _td("keyboard|composer_jump_start"),
|
||||
},
|
||||
[KeyBindingAction.MoveCursorToEnd]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
key: Key.END,
|
||||
},
|
||||
displayName: _td("keyboard|composer_jump_end"),
|
||||
},
|
||||
[KeyBindingAction.SelectNextSendHistory]: {
|
||||
default: {
|
||||
altKey: true,
|
||||
ctrlKey: true,
|
||||
key: Key.ARROW_DOWN,
|
||||
},
|
||||
displayName: _td("keyboard|composer_navigate_next_history"),
|
||||
},
|
||||
[KeyBindingAction.SelectPrevSendHistory]: {
|
||||
default: {
|
||||
altKey: true,
|
||||
ctrlKey: true,
|
||||
key: Key.ARROW_UP,
|
||||
},
|
||||
displayName: _td("keyboard|composer_navigate_prev_history"),
|
||||
},
|
||||
[KeyBindingAction.ShowStickerPicker]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
key: Key.SEMICOLON,
|
||||
},
|
||||
displayName: _td("keyboard|send_sticker"),
|
||||
},
|
||||
[KeyBindingAction.ToggleMicInCall]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
key: Key.D,
|
||||
},
|
||||
displayName: _td("keyboard|toggle_microphone_mute"),
|
||||
},
|
||||
[KeyBindingAction.ToggleWebcamInCall]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
key: Key.E,
|
||||
},
|
||||
displayName: _td("keyboard|toggle_webcam_mute"),
|
||||
},
|
||||
[KeyBindingAction.DismissReadMarker]: {
|
||||
default: {
|
||||
key: Key.ESCAPE,
|
||||
},
|
||||
displayName: _td("keyboard|dismiss_read_marker_and_jump_bottom"),
|
||||
},
|
||||
[KeyBindingAction.JumpToOldestUnread]: {
|
||||
default: {
|
||||
shiftKey: true,
|
||||
key: Key.PAGE_UP,
|
||||
},
|
||||
displayName: _td("keyboard|jump_to_read_marker"),
|
||||
},
|
||||
[KeyBindingAction.UploadFile]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
shiftKey: true,
|
||||
key: Key.U,
|
||||
},
|
||||
displayName: _td("keyboard|upload_file"),
|
||||
},
|
||||
[KeyBindingAction.ScrollUp]: {
|
||||
default: {
|
||||
key: Key.PAGE_UP,
|
||||
},
|
||||
displayName: _td("keyboard|scroll_up_timeline"),
|
||||
},
|
||||
[KeyBindingAction.ScrollDown]: {
|
||||
default: {
|
||||
key: Key.PAGE_DOWN,
|
||||
},
|
||||
displayName: _td("keyboard|scroll_down_timeline"),
|
||||
},
|
||||
[KeyBindingAction.FilterRooms]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
key: Key.K,
|
||||
},
|
||||
displayName: _td("keyboard|jump_room_search"),
|
||||
},
|
||||
[KeyBindingAction.SelectRoomInRoomList]: {
|
||||
default: {
|
||||
key: Key.ENTER,
|
||||
},
|
||||
displayName: _td("keyboard|room_list_select_room"),
|
||||
},
|
||||
[KeyBindingAction.CollapseRoomListSection]: {
|
||||
default: {
|
||||
key: Key.ARROW_LEFT,
|
||||
},
|
||||
displayName: _td("keyboard|room_list_collapse_section"),
|
||||
},
|
||||
[KeyBindingAction.ExpandRoomListSection]: {
|
||||
default: {
|
||||
key: Key.ARROW_RIGHT,
|
||||
},
|
||||
displayName: _td("keyboard|room_list_expand_section"),
|
||||
},
|
||||
[KeyBindingAction.NextRoom]: {
|
||||
default: {
|
||||
key: Key.ARROW_DOWN,
|
||||
},
|
||||
displayName: _td("keyboard|room_list_navigate_down"),
|
||||
},
|
||||
[KeyBindingAction.PrevRoom]: {
|
||||
default: {
|
||||
key: Key.ARROW_UP,
|
||||
},
|
||||
displayName: _td("keyboard|room_list_navigate_up"),
|
||||
},
|
||||
[KeyBindingAction.ToggleUserMenu]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
key: Key.BACKTICK,
|
||||
},
|
||||
displayName: _td("keyboard|toggle_top_left_menu"),
|
||||
},
|
||||
[KeyBindingAction.ToggleRoomSidePanel]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
key: Key.PERIOD,
|
||||
},
|
||||
displayName: _td("keyboard|toggle_right_panel"),
|
||||
},
|
||||
[KeyBindingAction.ShowKeyboardSettings]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
key: Key.SLASH,
|
||||
},
|
||||
displayName: _td("keyboard|keyboard_shortcuts_tab"),
|
||||
},
|
||||
[KeyBindingAction.GoToHome]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
altKey: !IS_MAC,
|
||||
shiftKey: IS_MAC,
|
||||
key: Key.H,
|
||||
},
|
||||
displayName: _td("keyboard|go_home_view"),
|
||||
},
|
||||
[KeyBindingAction.SelectNextUnreadRoom]: {
|
||||
default: {
|
||||
shiftKey: true,
|
||||
altKey: true,
|
||||
key: Key.ARROW_DOWN,
|
||||
},
|
||||
displayName: _td("keyboard|next_unread_room"),
|
||||
},
|
||||
[KeyBindingAction.SelectPrevUnreadRoom]: {
|
||||
default: {
|
||||
shiftKey: true,
|
||||
altKey: true,
|
||||
key: Key.ARROW_UP,
|
||||
},
|
||||
displayName: _td("keyboard|prev_unread_room"),
|
||||
},
|
||||
[KeyBindingAction.SelectNextRoom]: {
|
||||
default: {
|
||||
altKey: true,
|
||||
key: Key.ARROW_DOWN,
|
||||
},
|
||||
displayName: _td("keyboard|next_room"),
|
||||
},
|
||||
[KeyBindingAction.SelectPrevRoom]: {
|
||||
default: {
|
||||
altKey: true,
|
||||
key: Key.ARROW_UP,
|
||||
},
|
||||
displayName: _td("keyboard|prev_room"),
|
||||
},
|
||||
[KeyBindingAction.CancelAutocomplete]: {
|
||||
default: {
|
||||
key: Key.ESCAPE,
|
||||
},
|
||||
displayName: _td("keyboard|autocomplete_cancel"),
|
||||
},
|
||||
[KeyBindingAction.NextSelectionInAutocomplete]: {
|
||||
default: {
|
||||
key: Key.ARROW_DOWN,
|
||||
},
|
||||
displayName: _td("keyboard|autocomplete_navigate_next"),
|
||||
},
|
||||
[KeyBindingAction.PrevSelectionInAutocomplete]: {
|
||||
default: {
|
||||
key: Key.ARROW_UP,
|
||||
},
|
||||
displayName: _td("keyboard|autocomplete_navigate_prev"),
|
||||
},
|
||||
[KeyBindingAction.ToggleSpacePanel]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
shiftKey: true,
|
||||
key: Key.D,
|
||||
},
|
||||
displayName: _td("keyboard|toggle_space_panel"),
|
||||
},
|
||||
[KeyBindingAction.ToggleHiddenEventVisibility]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
shiftKey: true,
|
||||
key: Key.H,
|
||||
},
|
||||
displayName: _td("keyboard|toggle_hidden_events"),
|
||||
},
|
||||
[KeyBindingAction.JumpToFirstMessage]: {
|
||||
default: {
|
||||
key: Key.HOME,
|
||||
ctrlKey: true,
|
||||
},
|
||||
displayName: _td("keyboard|jump_first_message"),
|
||||
},
|
||||
[KeyBindingAction.JumpToLatestMessage]: {
|
||||
default: {
|
||||
key: Key.END,
|
||||
ctrlKey: true,
|
||||
},
|
||||
displayName: _td("keyboard|jump_last_message"),
|
||||
},
|
||||
[KeyBindingAction.EditUndo]: {
|
||||
default: {
|
||||
key: Key.Z,
|
||||
ctrlOrCmdKey: true,
|
||||
},
|
||||
displayName: _td("keyboard|composer_undo"),
|
||||
},
|
||||
[KeyBindingAction.EditRedo]: {
|
||||
default: {
|
||||
key: IS_MAC ? Key.Z : Key.Y,
|
||||
ctrlOrCmdKey: true,
|
||||
shiftKey: IS_MAC,
|
||||
},
|
||||
displayName: _td("keyboard|composer_redo"),
|
||||
},
|
||||
[KeyBindingAction.PreviousVisitedRoomOrSpace]: {
|
||||
default: {
|
||||
metaKey: IS_MAC,
|
||||
altKey: !IS_MAC,
|
||||
key: IS_MAC ? Key.SQUARE_BRACKET_LEFT : Key.ARROW_LEFT,
|
||||
},
|
||||
displayName: _td("keyboard|navigate_prev_history"),
|
||||
},
|
||||
[KeyBindingAction.NextVisitedRoomOrSpace]: {
|
||||
default: {
|
||||
metaKey: IS_MAC,
|
||||
altKey: !IS_MAC,
|
||||
key: IS_MAC ? Key.SQUARE_BRACKET_RIGHT : Key.ARROW_RIGHT,
|
||||
},
|
||||
displayName: _td("keyboard|navigate_next_history"),
|
||||
},
|
||||
[KeyBindingAction.SwitchToSpaceByNumber]: {
|
||||
default: {
|
||||
ctrlOrCmdKey: true,
|
||||
key: DIGITS,
|
||||
},
|
||||
displayName: _td("keyboard|switch_to_space"),
|
||||
},
|
||||
[KeyBindingAction.OpenUserSettings]: {
|
||||
default: {
|
||||
metaKey: true,
|
||||
key: Key.COMMA,
|
||||
},
|
||||
displayName: _td("keyboard|open_user_settings"),
|
||||
},
|
||||
[KeyBindingAction.Escape]: {
|
||||
default: {
|
||||
key: Key.ESCAPE,
|
||||
},
|
||||
displayName: _td("keyboard|close_dialog_menu"),
|
||||
},
|
||||
[KeyBindingAction.Enter]: {
|
||||
default: {
|
||||
key: Key.ENTER,
|
||||
},
|
||||
displayName: _td("keyboard|activate_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,
|
||||
},
|
||||
},
|
||||
[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"),
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright 2024 New Vector Ltd.
|
||||
* Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
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");
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useReducer,
|
||||
Reducer,
|
||||
Dispatch,
|
||||
RefObject,
|
||||
ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { getKeyBindingsManager } from "../KeyBindingsManager";
|
||||
import { KeyBindingAction } from "./KeyboardShortcuts";
|
||||
import { FocusHandler, Ref } from "./roving/types";
|
||||
|
||||
/**
|
||||
* Module to simplify implementing the Roving TabIndex accessibility technique
|
||||
*
|
||||
* Wrap the Widget in an RovingTabIndexContextProvider
|
||||
* and then for all buttons make use of useRovingTabIndex or RovingTabIndexWrapper.
|
||||
* The code will keep track of which tabIndex was most recently focused and expose that information as `isActive` which
|
||||
* can then be used to only set the tabIndex to 0 as expected by the roving tabindex technique.
|
||||
* When the active button gets unmounted the closest button will be chosen as expected.
|
||||
* Initially the first button to mount will be given active state.
|
||||
*
|
||||
* https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets#Technique_1_Roving_tabindex
|
||||
*/
|
||||
|
||||
// Check for form elements which utilize the arrow keys for native functions
|
||||
// like many of the text input varieties.
|
||||
//
|
||||
// i.e. it's ok to press the down arrow on a radio button to move to the next
|
||||
// radio. But it's not ok to press the down arrow on a <input type="text"> to
|
||||
// move away because the down arrow should move the cursor to the end of the
|
||||
// input.
|
||||
export function checkInputableElement(el: HTMLElement): boolean {
|
||||
return el.matches('input:not([type="radio"]):not([type="checkbox"]), textarea, select, [contenteditable=true]');
|
||||
}
|
||||
|
||||
export interface IState {
|
||||
activeRef?: Ref;
|
||||
refs: Ref[];
|
||||
}
|
||||
|
||||
export interface IContext {
|
||||
state: IState;
|
||||
dispatch: Dispatch<IAction>;
|
||||
}
|
||||
|
||||
export const RovingTabIndexContext = createContext<IContext>({
|
||||
state: {
|
||||
refs: [], // list of refs in DOM order
|
||||
},
|
||||
dispatch: () => {},
|
||||
});
|
||||
RovingTabIndexContext.displayName = "RovingTabIndexContext";
|
||||
|
||||
export enum Type {
|
||||
Register = "REGISTER",
|
||||
Unregister = "UNREGISTER",
|
||||
SetFocus = "SET_FOCUS",
|
||||
Update = "UPDATE",
|
||||
}
|
||||
|
||||
export interface IAction {
|
||||
type: Exclude<Type, Type.Update>;
|
||||
payload: {
|
||||
ref: Ref;
|
||||
};
|
||||
}
|
||||
|
||||
interface UpdateAction {
|
||||
type: Type.Update;
|
||||
payload?: undefined;
|
||||
}
|
||||
|
||||
type Action = IAction | UpdateAction;
|
||||
|
||||
const refSorter = (a: Ref, b: Ref): number => {
|
||||
if (a === b) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const position = a.current!.compareDocumentPosition(b.current!);
|
||||
|
||||
if (position & Node.DOCUMENT_POSITION_FOLLOWING || position & Node.DOCUMENT_POSITION_CONTAINED_BY) {
|
||||
return -1;
|
||||
} else if (position & Node.DOCUMENT_POSITION_PRECEDING || position & Node.DOCUMENT_POSITION_CONTAINS) {
|
||||
return 1;
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
export const reducer: Reducer<IState, Action> = (state: IState, action: Action) => {
|
||||
switch (action.type) {
|
||||
case Type.Register: {
|
||||
if (!state.activeRef) {
|
||||
// Our list of refs was empty, set activeRef to this first item
|
||||
state.activeRef = action.payload.ref;
|
||||
}
|
||||
|
||||
// Sadly due to the potential of DOM elements swapping order we can't do anything fancy like a binary insert
|
||||
state.refs.push(action.payload.ref);
|
||||
state.refs.sort(refSorter);
|
||||
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
case Type.Unregister: {
|
||||
const oldIndex = state.refs.findIndex((r) => r === action.payload.ref);
|
||||
|
||||
if (oldIndex === -1) {
|
||||
return state; // already removed, this should not happen
|
||||
}
|
||||
|
||||
if (state.refs.splice(oldIndex, 1)[0] === state.activeRef) {
|
||||
// we just removed the active ref, need to replace it
|
||||
// pick the ref closest to the index the old ref was in
|
||||
if (oldIndex >= state.refs.length) {
|
||||
state.activeRef = findSiblingElement(state.refs, state.refs.length - 1, true);
|
||||
} else {
|
||||
state.activeRef =
|
||||
findSiblingElement(state.refs, oldIndex) || findSiblingElement(state.refs, oldIndex, true);
|
||||
}
|
||||
if (document.activeElement === document.body) {
|
||||
// if the focus got reverted to the body then the user was likely focused on the unmounted element
|
||||
setTimeout(() => state.activeRef?.current?.focus(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
// update the refs list
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
case Type.SetFocus: {
|
||||
// if the ref doesn't change just return the same object reference to skip a re-render
|
||||
if (state.activeRef === action.payload.ref) return state;
|
||||
// update active ref
|
||||
state.activeRef = action.payload.ref;
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
case Type.Update: {
|
||||
state.refs.sort(refSorter);
|
||||
return { ...state };
|
||||
}
|
||||
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
interface IProps {
|
||||
handleLoop?: boolean;
|
||||
handleHomeEnd?: boolean;
|
||||
handleUpDown?: boolean;
|
||||
handleLeftRight?: boolean;
|
||||
handleInputFields?: boolean;
|
||||
scrollIntoView?: boolean | ScrollIntoViewOptions;
|
||||
children(renderProps: { onKeyDownHandler(ev: React.KeyboardEvent): void; onDragEndHandler(): void }): ReactNode;
|
||||
onKeyDown?(ev: React.KeyboardEvent, state: IState, dispatch: Dispatch<IAction>): void;
|
||||
}
|
||||
|
||||
export const findSiblingElement = (
|
||||
refs: RefObject<HTMLElement>[],
|
||||
startIndex: number,
|
||||
backwards = false,
|
||||
loop = false,
|
||||
): RefObject<HTMLElement> | undefined => {
|
||||
if (backwards) {
|
||||
for (let i = startIndex; i < refs.length && i >= 0; i--) {
|
||||
if (refs[i].current?.offsetParent !== null) {
|
||||
return refs[i];
|
||||
}
|
||||
}
|
||||
if (loop) {
|
||||
return findSiblingElement(refs.slice(startIndex + 1), refs.length - 1, true, false);
|
||||
}
|
||||
} else {
|
||||
for (let i = startIndex; i < refs.length && i >= 0; i++) {
|
||||
if (refs[i].current?.offsetParent !== null) {
|
||||
return refs[i];
|
||||
}
|
||||
}
|
||||
if (loop) {
|
||||
return findSiblingElement(refs.slice(0, startIndex), 0, false, false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const RovingTabIndexProvider: React.FC<IProps> = ({
|
||||
children,
|
||||
handleHomeEnd,
|
||||
handleUpDown,
|
||||
handleLeftRight,
|
||||
handleLoop,
|
||||
handleInputFields,
|
||||
scrollIntoView,
|
||||
onKeyDown,
|
||||
}) => {
|
||||
const [state, dispatch] = useReducer<Reducer<IState, Action>>(reducer, {
|
||||
refs: [],
|
||||
});
|
||||
|
||||
const context = useMemo<IContext>(() => ({ state, dispatch }), [state]);
|
||||
|
||||
const onKeyDownHandler = useCallback(
|
||||
(ev: React.KeyboardEvent) => {
|
||||
if (onKeyDown) {
|
||||
onKeyDown(ev, context.state, context.dispatch);
|
||||
if (ev.defaultPrevented) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let handled = false;
|
||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||
let focusRef: RefObject<HTMLElement> | undefined;
|
||||
// Don't interfere with input default keydown behaviour
|
||||
// but allow people to move focus from it with Tab.
|
||||
if (!handleInputFields && checkInputableElement(ev.target as HTMLElement)) {
|
||||
switch (action) {
|
||||
case KeyBindingAction.Tab:
|
||||
handled = true;
|
||||
if (context.state.refs.length > 0) {
|
||||
const idx = context.state.refs.indexOf(context.state.activeRef!);
|
||||
focusRef = findSiblingElement(
|
||||
context.state.refs,
|
||||
idx + (ev.shiftKey ? -1 : 1),
|
||||
ev.shiftKey,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
// check if we actually have any items
|
||||
switch (action) {
|
||||
case KeyBindingAction.Home:
|
||||
if (handleHomeEnd) {
|
||||
handled = true;
|
||||
// move focus to first (visible) item
|
||||
focusRef = findSiblingElement(context.state.refs, 0);
|
||||
}
|
||||
break;
|
||||
|
||||
case KeyBindingAction.End:
|
||||
if (handleHomeEnd) {
|
||||
handled = true;
|
||||
// move focus to last (visible) item
|
||||
focusRef = findSiblingElement(context.state.refs, context.state.refs.length - 1, true);
|
||||
}
|
||||
break;
|
||||
|
||||
case KeyBindingAction.ArrowDown:
|
||||
case KeyBindingAction.ArrowRight:
|
||||
if (
|
||||
(action === KeyBindingAction.ArrowDown && handleUpDown) ||
|
||||
(action === KeyBindingAction.ArrowRight && handleLeftRight)
|
||||
) {
|
||||
handled = true;
|
||||
if (context.state.refs.length > 0) {
|
||||
const idx = context.state.refs.indexOf(context.state.activeRef!);
|
||||
focusRef = findSiblingElement(context.state.refs, idx + 1, false, handleLoop);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
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!);
|
||||
focusRef = findSiblingElement(context.state.refs, idx - 1, true, handleLoop);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
|
||||
if (focusRef) {
|
||||
focusRef.current?.focus();
|
||||
// programmatic focus doesn't fire the onFocus handler, so we must do the do ourselves
|
||||
dispatch({
|
||||
type: Type.SetFocus,
|
||||
payload: {
|
||||
ref: focusRef,
|
||||
},
|
||||
});
|
||||
if (scrollIntoView) {
|
||||
focusRef.current?.scrollIntoView(scrollIntoView);
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
context,
|
||||
onKeyDown,
|
||||
handleHomeEnd,
|
||||
handleUpDown,
|
||||
handleLeftRight,
|
||||
handleLoop,
|
||||
handleInputFields,
|
||||
scrollIntoView,
|
||||
],
|
||||
);
|
||||
|
||||
const onDragEndHandler = useCallback(() => {
|
||||
dispatch({
|
||||
type: Type.Update,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<RovingTabIndexContext.Provider value={context}>
|
||||
{children({ onKeyDownHandler, onDragEndHandler })}
|
||||
</RovingTabIndexContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Hook to register a roving tab index
|
||||
// inputRef parameter specifies the ref to use
|
||||
// onFocus should be called when the index gained focus in any manner
|
||||
// isActive should be used to set tabIndex in a manner such as `tabIndex={isActive ? 0 : -1}`
|
||||
// ref should be passed to a DOM node which will be used for DOM compareDocumentPosition
|
||||
export const useRovingTabIndex = <T extends HTMLElement>(
|
||||
inputRef?: RefObject<T>,
|
||||
): [FocusHandler, boolean, RefObject<T>] => {
|
||||
const context = useContext(RovingTabIndexContext);
|
||||
let ref = useRef<T>(null);
|
||||
|
||||
if (inputRef) {
|
||||
// if we are given a ref, use it instead of ours
|
||||
ref = inputRef;
|
||||
}
|
||||
|
||||
// setup (after refs)
|
||||
useEffect(() => {
|
||||
context.dispatch({
|
||||
type: Type.Register,
|
||||
payload: { ref },
|
||||
});
|
||||
// teardown
|
||||
return () => {
|
||||
context.dispatch({
|
||||
type: Type.Unregister,
|
||||
payload: { ref },
|
||||
});
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const onFocus = useCallback(() => {
|
||||
context.dispatch({
|
||||
type: Type.SetFocus,
|
||||
payload: { ref },
|
||||
});
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const isActive = context.state.activeRef === ref;
|
||||
return [onFocus, isActive, ref];
|
||||
};
|
||||
|
||||
// re-export the semantic helper components for simplicity
|
||||
export { RovingTabIndexWrapper } from "./roving/RovingTabIndexWrapper";
|
||||
export { RovingAccessibleButton } from "./roving/RovingAccessibleButton";
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { forwardRef } from "react";
|
||||
|
||||
import { RovingTabIndexProvider } from "./RovingTabIndex";
|
||||
import { getKeyBindingsManager } from "../KeyBindingsManager";
|
||||
import { KeyBindingAction } from "./KeyboardShortcuts";
|
||||
|
||||
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {}
|
||||
|
||||
// This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines.
|
||||
// https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar
|
||||
// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
|
||||
const Toolbar = forwardRef<HTMLDivElement, IProps>(({ children, ...props }, ref) => {
|
||||
const onKeyDown = (ev: React.KeyboardEvent): void => {
|
||||
const target = ev.target as HTMLElement;
|
||||
// Don't interfere with input default keydown behaviour
|
||||
if (target.tagName === "INPUT") return;
|
||||
|
||||
let handled = true;
|
||||
|
||||
// HOME and END are handled by RovingTabIndexProvider
|
||||
const action = getKeyBindingsManager().getAccessibilityAction(ev);
|
||||
switch (action) {
|
||||
case KeyBindingAction.ArrowUp:
|
||||
case KeyBindingAction.ArrowDown:
|
||||
if (target.hasAttribute("aria-haspopup")) {
|
||||
target.click();
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
handled = false;
|
||||
}
|
||||
|
||||
if (handled) {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
}
|
||||
};
|
||||
|
||||
// We handle both up/down and left/right as is allowed in the above WAI ARIA best practices
|
||||
return (
|
||||
<RovingTabIndexProvider handleHomeEnd handleLeftRight handleUpDown onKeyDown={onKeyDown}>
|
||||
{({ onKeyDownHandler }) => (
|
||||
<div {...props} onKeyDown={onKeyDownHandler} role="toolbar" ref={ref}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</RovingTabIndexProvider>
|
||||
);
|
||||
});
|
||||
|
||||
export default Toolbar;
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ComponentProps, forwardRef, Ref } from "react";
|
||||
|
||||
import AccessibleButton from "../../components/views/elements/AccessibleButton";
|
||||
|
||||
type Props<T extends keyof JSX.IntrinsicElements> = ComponentProps<typeof AccessibleButton<T>> & {
|
||||
label?: string;
|
||||
// whether the context menu is currently open
|
||||
isExpanded: boolean;
|
||||
};
|
||||
|
||||
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
|
||||
export const ContextMenuButton = forwardRef(function <T extends keyof JSX.IntrinsicElements>(
|
||||
{ label, isExpanded, children, onClick, onContextMenu, element, ...props }: Props<T>,
|
||||
ref: Ref<HTMLElement>,
|
||||
) {
|
||||
return (
|
||||
<AccessibleButton
|
||||
{...props}
|
||||
element={element as keyof JSX.IntrinsicElements}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu ?? onClick ?? undefined}
|
||||
aria-label={label}
|
||||
aria-haspopup={true}
|
||||
aria-expanded={isExpanded}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</AccessibleButton>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ComponentProps, forwardRef, Ref } from "react";
|
||||
|
||||
import AccessibleButton from "../../components/views/elements/AccessibleButton";
|
||||
|
||||
type Props<T extends keyof JSX.IntrinsicElements> = ComponentProps<typeof AccessibleButton<T>> & {
|
||||
// whether the context menu is currently open
|
||||
isExpanded: boolean;
|
||||
};
|
||||
|
||||
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
|
||||
export const ContextMenuTooltipButton = forwardRef(function <T extends keyof JSX.IntrinsicElements>(
|
||||
{ isExpanded, children, onClick, onContextMenu, element, ...props }: Props<T>,
|
||||
ref: Ref<HTMLElement>,
|
||||
) {
|
||||
return (
|
||||
<AccessibleButton
|
||||
{...props}
|
||||
element={element as keyof JSX.IntrinsicElements}
|
||||
onClick={onClick}
|
||||
onContextMenu={onContextMenu ?? onClick ?? undefined}
|
||||
aria-haspopup={true}
|
||||
aria-expanded={isExpanded}
|
||||
disableTooltip={isExpanded}
|
||||
ref={ref}
|
||||
>
|
||||
{children}
|
||||
</AccessibleButton>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { RovingAccessibleButton } from "../RovingTabIndex";
|
||||
|
||||
interface IProps extends React.ComponentProps<typeof RovingAccessibleButton> {
|
||||
label?: string;
|
||||
}
|
||||
|
||||
// Semantic component for representing a role=menuitem
|
||||
export const MenuItem: React.FC<IProps> = ({ children, label, ...props }) => {
|
||||
const ariaLabel = props["aria-label"] || label;
|
||||
|
||||
return (
|
||||
<RovingAccessibleButton {...props} role="menuitem" aria-label={ariaLabel}>
|
||||
{children}
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { RovingAccessibleButton } from "../RovingTabIndex";
|
||||
|
||||
interface IProps extends React.ComponentProps<typeof RovingAccessibleButton> {
|
||||
label?: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
// Semantic component for representing a role=menuitemcheckbox
|
||||
export const MenuItemCheckbox: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
|
||||
return (
|
||||
<RovingAccessibleButton
|
||||
{...props}
|
||||
role="menuitemcheckbox"
|
||||
aria-checked={active}
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { RovingAccessibleButton } from "../RovingTabIndex";
|
||||
|
||||
interface IProps extends React.ComponentProps<typeof RovingAccessibleButton> {
|
||||
label?: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
// Semantic component for representing a role=menuitemradio
|
||||
export const MenuItemRadio: React.FC<IProps> = ({ children, label, active, disabled, ...props }) => {
|
||||
return (
|
||||
<RovingAccessibleButton
|
||||
{...props}
|
||||
role="menuitemradio"
|
||||
aria-checked={active}
|
||||
aria-disabled={disabled}
|
||||
disabled={disabled}
|
||||
aria-label={label}
|
||||
>
|
||||
{children}
|
||||
</RovingAccessibleButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { useRovingTabIndex } from "../RovingTabIndex";
|
||||
import StyledCheckbox from "../../components/views/elements/StyledCheckbox";
|
||||
import { KeyBindingAction } from "../KeyboardShortcuts";
|
||||
import { getKeyBindingsManager } from "../../KeyBindingsManager";
|
||||
|
||||
interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
|
||||
label?: string;
|
||||
onChange(): void; // we handle keyup/down ourselves so lose the ChangeEvent
|
||||
onClose(): void; // gets called after onChange on KeyBindingAction.ActivateSelectedButton
|
||||
}
|
||||
|
||||
// Semantic component for representing a styled role=menuitemcheckbox
|
||||
export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent): void => {
|
||||
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();
|
||||
}
|
||||
};
|
||||
const onKeyUp = (e: React.KeyboardEvent): void => {
|
||||
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 (
|
||||
<StyledCheckbox
|
||||
{...props}
|
||||
role="menuitemcheckbox"
|
||||
aria-label={label}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{children}
|
||||
</StyledCheckbox>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018 New Vector Ltd
|
||||
Copyright 2015, 2016 OpenMarket Ltd
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
|
||||
import { useRovingTabIndex } from "../RovingTabIndex";
|
||||
import StyledRadioButton from "../../components/views/elements/StyledRadioButton";
|
||||
import { KeyBindingAction } from "../KeyboardShortcuts";
|
||||
import { getKeyBindingsManager } from "../../KeyBindingsManager";
|
||||
|
||||
interface IProps extends React.ComponentProps<typeof StyledRadioButton> {
|
||||
label?: string;
|
||||
onChange(): void; // we handle keyup/down ourselves so lose the ChangeEvent
|
||||
onClose(): void; // gets called after onChange on KeyBindingAction.Enter
|
||||
}
|
||||
|
||||
// Semantic component for representing a styled role=menuitemradio
|
||||
export const StyledMenuItemRadio: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent): void => {
|
||||
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();
|
||||
}
|
||||
};
|
||||
const onKeyUp = (e: React.KeyboardEvent): void => {
|
||||
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 (
|
||||
<StyledRadioButton
|
||||
{...props}
|
||||
role="menuitemradio"
|
||||
aria-label={label}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
onKeyUp={onKeyUp}
|
||||
onFocus={onFocus}
|
||||
inputRef={ref}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
>
|
||||
{children}
|
||||
</StyledRadioButton>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ComponentProps } from "react";
|
||||
|
||||
import AccessibleButton from "../../components/views/elements/AccessibleButton";
|
||||
import { useRovingTabIndex } from "../RovingTabIndex";
|
||||
import { Ref } from "./types";
|
||||
|
||||
type Props<T extends keyof JSX.IntrinsicElements> = Omit<
|
||||
ComponentProps<typeof AccessibleButton<T>>,
|
||||
"inputRef" | "tabIndex"
|
||||
> & {
|
||||
inputRef?: Ref;
|
||||
focusOnMouseOver?: boolean;
|
||||
};
|
||||
|
||||
// Wrapper to allow use of useRovingTabIndex for simple AccessibleButtons outside of React Functional Components.
|
||||
export const RovingAccessibleButton = <T extends keyof JSX.IntrinsicElements>({
|
||||
inputRef,
|
||||
onFocus,
|
||||
onMouseOver,
|
||||
focusOnMouseOver,
|
||||
element,
|
||||
...props
|
||||
}: Props<T>): JSX.Element => {
|
||||
const [onFocusInternal, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return (
|
||||
<AccessibleButton
|
||||
{...props}
|
||||
element={element as keyof JSX.IntrinsicElements}
|
||||
onFocus={(event: React.FocusEvent) => {
|
||||
onFocusInternal();
|
||||
onFocus?.(event);
|
||||
}}
|
||||
onMouseOver={(event: React.MouseEvent) => {
|
||||
if (focusOnMouseOver) onFocusInternal();
|
||||
onMouseOver?.(event);
|
||||
}}
|
||||
ref={ref}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { ReactElement } from "react";
|
||||
|
||||
import { useRovingTabIndex } from "../RovingTabIndex";
|
||||
import { FocusHandler, Ref } from "./types";
|
||||
|
||||
interface IProps {
|
||||
inputRef?: Ref;
|
||||
children(renderProps: { onFocus: FocusHandler; isActive: boolean; ref: Ref }): ReactElement<any, any>;
|
||||
}
|
||||
|
||||
// Wrapper to allow use of useRovingTabIndex outside of React Functional Components.
|
||||
export const RovingTabIndexWrapper: React.FC<IProps> = ({ children, inputRef }) => {
|
||||
const [onFocus, isActive, ref] = useRovingTabIndex(inputRef);
|
||||
return children({ onFocus, isActive, ref });
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2020 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { RefObject } from "react";
|
||||
|
||||
export type Ref = RefObject<HTMLElement>;
|
||||
|
||||
export type FocusHandler = () => void;
|
||||
Reference in New Issue
Block a user