feat(a11y): Bring keyboard shortcuts to a global context (#40169)
parent
df36a852f9
commit
32f67f20fb
@ -0,0 +1,6 @@ |
||||
--- |
||||
'@rocket.chat/i18n': patch |
||||
'@rocket.chat/meteor': patch |
||||
--- |
||||
|
||||
Moves keyboard shortcuts from the contextual bar into a modal accessible from the user menu, and adds a hotkey to open it. |
||||
@ -1,19 +0,0 @@ |
||||
import type { RoomToolboxActionConfig } from '@rocket.chat/ui-contexts'; |
||||
import { lazy, useMemo } from 'react'; |
||||
|
||||
const KeyboardShortcuts = lazy(() => import('../../views/room/contextualBar/KeyboardShortcuts')); |
||||
|
||||
export const useKeyboardShortcutListRoomAction = () => { |
||||
return useMemo( |
||||
(): RoomToolboxActionConfig => ({ |
||||
id: 'keyboard-shortcut-list', |
||||
groups: ['channel', 'group', 'direct', 'direct_multiple', 'team'], |
||||
title: 'Keyboard_Shortcuts_Title', |
||||
icon: 'keyboard', |
||||
tabComponent: KeyboardShortcuts, |
||||
order: 99, |
||||
type: 'customization', |
||||
}), |
||||
[], |
||||
); |
||||
}; |
||||
@ -0,0 +1,136 @@ |
||||
import { Box, Divider } from '@rocket.chat/fuselage'; |
||||
import { GenericModal } from '@rocket.chat/ui-client'; |
||||
import type { ReactElement } from 'react'; |
||||
import { Fragment, memo } from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
type KeyCombo = { |
||||
mac: readonly string[]; |
||||
other: readonly string[]; |
||||
}; |
||||
|
||||
type ShortcutDefinition = { |
||||
id: string; |
||||
descriptionKey: string; |
||||
combos: readonly KeyCombo[]; |
||||
}; |
||||
|
||||
const SHORTCUTS: readonly ShortcutDefinition[] = [ |
||||
{ |
||||
id: 'openKeyboardShortcuts', |
||||
descriptionKey: 'Keyboard_Shortcuts_Show_Keyboard_Shortcuts', |
||||
combos: [{ mac: ['Shift', '?'], other: ['Shift', '?'] }], |
||||
}, |
||||
{ |
||||
id: 'openSearch', |
||||
descriptionKey: 'Keyboard_Shortcuts_Open_Channel_Slash_User_Search', |
||||
combos: [ |
||||
{ mac: ['Command', 'P'], other: ['Control', 'P'] }, |
||||
{ mac: ['Command', 'K'], other: ['Control', 'K'] }, |
||||
], |
||||
}, |
||||
{ |
||||
id: 'markAllAsRead', |
||||
descriptionKey: 'Keyboard_Shortcuts_Mark_all_as_read', |
||||
combos: [{ mac: ['Shift', 'Escape'], other: ['Control', 'Escape'] }], |
||||
}, |
||||
{ |
||||
id: 'editPreviousMessage', |
||||
descriptionKey: 'Keyboard_Shortcuts_Edit_Previous_Message', |
||||
combos: [{ mac: ['ArrowUp'], other: ['ArrowUp'] }], |
||||
}, |
||||
{ |
||||
id: 'moveToBeginningHorizontal', |
||||
descriptionKey: 'Keyboard_Shortcuts_Move_To_Beginning_Of_Message', |
||||
combos: [{ mac: ['Command', 'ArrowLeft'], other: ['Alt', 'ArrowLeft'] }], |
||||
}, |
||||
{ |
||||
id: 'moveToBeginningVertical', |
||||
descriptionKey: 'Keyboard_Shortcuts_Move_To_Beginning_Of_Message', |
||||
combos: [{ mac: ['Command', 'ArrowUp'], other: ['Alt', 'ArrowUp'] }], |
||||
}, |
||||
{ |
||||
id: 'moveToEndHorizontal', |
||||
descriptionKey: 'Keyboard_Shortcuts_Move_To_End_Of_Message', |
||||
combos: [{ mac: ['Command', 'ArrowRight'], other: ['Alt', 'ArrowRight'] }], |
||||
}, |
||||
{ |
||||
id: 'moveToEndVertical', |
||||
descriptionKey: 'Keyboard_Shortcuts_Move_To_End_Of_Message', |
||||
combos: [{ mac: ['Command', 'ArrowDown'], other: ['Alt', 'ArrowDown'] }], |
||||
}, |
||||
{ |
||||
id: 'newLine', |
||||
descriptionKey: 'Keyboard_Shortcuts_New_Line_In_Message', |
||||
combos: [{ mac: ['Shift', 'Enter'], other: ['Shift', 'Enter'] }], |
||||
}, |
||||
]; |
||||
|
||||
const KEY_LABEL_TRANSLATIONS: Record<string, string> = { |
||||
Command: 'Keyboard_Shortcut_Key_Command', |
||||
Control: 'Keyboard_Shortcut_Key_Control', |
||||
Option: 'Keyboard_Shortcut_Key_Option', |
||||
Alt: 'Keyboard_Shortcut_Key_Alt', |
||||
Shift: 'Keyboard_Shortcut_Key_Shift', |
||||
Enter: 'Keyboard_Shortcut_Key_Enter', |
||||
Escape: 'Keyboard_Shortcut_Key_Escape', |
||||
ArrowUp: 'Keyboard_Shortcut_Key_ArrowUp', |
||||
ArrowDown: 'Keyboard_Shortcut_Key_ArrowDown', |
||||
ArrowLeft: 'Keyboard_Shortcut_Key_ArrowLeft', |
||||
ArrowRight: 'Keyboard_Shortcut_Key_ArrowRight', |
||||
}; |
||||
|
||||
const isMacPlatform = (): boolean => |
||||
typeof navigator !== 'undefined' && typeof navigator.platform === 'string' && navigator.platform.toLowerCase().includes('mac'); |
||||
|
||||
type KeyboardShortcutsModalProps = { |
||||
onClose: () => void; |
||||
}; |
||||
|
||||
const KeyboardShortcutsModal = ({ onClose }: KeyboardShortcutsModalProps): ReactElement => { |
||||
const { t } = useTranslation(); |
||||
const isMac = isMacPlatform(); |
||||
|
||||
return ( |
||||
<GenericModal icon='keyboard' variant='info' title={t('Keyboard_Shortcuts_Title')} cancelText={t('Close')} onCancel={onClose}> |
||||
<Box is='dl' aria-label={t('Keyboard_Shortcuts_Title')} m={0}> |
||||
{SHORTCUTS.map(({ id, descriptionKey, combos }) => ( |
||||
<Box key={id} mbe={12}> |
||||
<Box is='dt' fontScale='p2m' fontWeight='700' mbe={4}> |
||||
{t(descriptionKey)} |
||||
</Box> |
||||
<Box is='dd' fontScale='p2' m={0} mbe={8}> |
||||
{combos.map((combo, comboIndex) => { |
||||
const keys = isMac ? combo.mac : combo.other; |
||||
return ( |
||||
<Fragment key={comboIndex}> |
||||
{comboIndex > 0 && ( |
||||
<Box is='span' mi={8} color='hint'> |
||||
{t('or')} |
||||
</Box> |
||||
)} |
||||
<Box is='kbd'> |
||||
{keys.map((token, tokenIndex) => ( |
||||
<Fragment key={tokenIndex}> |
||||
{tokenIndex > 0 && ( |
||||
<Box is='span' mi={4} aria-hidden='true'> |
||||
+ |
||||
</Box> |
||||
)} |
||||
<Box is='kbd'>{KEY_LABEL_TRANSLATIONS[token] ? t(KEY_LABEL_TRANSLATIONS[token]) : token}</Box> |
||||
</Fragment> |
||||
))} |
||||
</Box> |
||||
</Fragment> |
||||
); |
||||
})} |
||||
</Box> |
||||
<Divider aria-hidden='true' m={0} /> |
||||
</Box> |
||||
))} |
||||
</Box> |
||||
</GenericModal> |
||||
); |
||||
}; |
||||
|
||||
export default memo(KeyboardShortcutsModal); |
||||
@ -0,0 +1,12 @@ |
||||
import { useSetModal } from '@rocket.chat/ui-contexts'; |
||||
|
||||
import KeyboardShortcutsModal from '../KeyboardShortcutsModal'; |
||||
|
||||
export const useKeyboardShortcutsModalHandler = () => { |
||||
const setModal = useSetModal(); |
||||
|
||||
return () => { |
||||
const handleModalClose = () => setModal(null); |
||||
setModal(<KeyboardShortcutsModal onClose={handleModalClose} />); |
||||
}; |
||||
}; |
||||
@ -1,21 +0,0 @@ |
||||
import { Box, Divider, Margins } from '@rocket.chat/fuselage'; |
||||
import type { ReactElement } from 'react'; |
||||
|
||||
type KeyboardShortcutSectionProps = { |
||||
title: string; |
||||
command: string; |
||||
}; |
||||
|
||||
const KeyboardShortcutSection = ({ title, command }: KeyboardShortcutSectionProps): ReactElement => ( |
||||
<Margins block={16}> |
||||
<Box is='section' color='default'> |
||||
<Box fontScale='p2m' fontWeight='700'> |
||||
{title} |
||||
</Box> |
||||
<Divider /> |
||||
<Box fontScale='p2'>{command}</Box> |
||||
</Box> |
||||
</Margins> |
||||
); |
||||
|
||||
export default KeyboardShortcutSection; |
||||
@ -1,15 +0,0 @@ |
||||
import { Contextualbar } from '@rocket.chat/ui-client'; |
||||
import type { Meta, StoryFn } from '@storybook/react'; |
||||
|
||||
import KeyboardShortcutsWithData from './KeyboardShortcutsWithData'; |
||||
|
||||
export default { |
||||
component: KeyboardShortcutsWithData, |
||||
parameters: { |
||||
layout: 'fullscreen', |
||||
}, |
||||
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>], |
||||
} satisfies Meta<typeof KeyboardShortcutsWithData>; |
||||
|
||||
export const Default: StoryFn<typeof KeyboardShortcutsWithData> = () => <KeyboardShortcutsWithData />; |
||||
Default.storyName = 'KeyboardShortcuts'; |
||||
@ -1,39 +0,0 @@ |
||||
import { |
||||
ContextualbarHeader, |
||||
ContextualbarIcon, |
||||
ContextualbarTitle, |
||||
ContextualbarClose, |
||||
ContextualbarScrollableContent, |
||||
ContextualbarDialog, |
||||
} from '@rocket.chat/ui-client'; |
||||
import type { ReactElement } from 'react'; |
||||
import { memo } from 'react'; |
||||
import { useTranslation } from 'react-i18next'; |
||||
|
||||
import KeyboardShortcutSection from './KeyboardShortcutSection'; |
||||
|
||||
const KeyboardShortcuts = ({ handleClose }: { handleClose: () => void }): ReactElement => { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<ContextualbarDialog> |
||||
<ContextualbarHeader> |
||||
<ContextualbarIcon name='keyboard' /> |
||||
<ContextualbarTitle>{t('Keyboard_Shortcuts_Title')}</ContextualbarTitle> |
||||
{handleClose && <ContextualbarClose onClick={handleClose} />} |
||||
</ContextualbarHeader> |
||||
<ContextualbarScrollableContent> |
||||
<KeyboardShortcutSection title={t('Keyboard_Shortcuts_Open_Channel_Slash_User_Search')} command={t('Keyboard_Shortcuts_Keys_1')} /> |
||||
<KeyboardShortcutSection title={t('Keyboard_Shortcuts_Mark_all_as_read')} command={t('Keyboard_Shortcuts_Keys_8')} /> |
||||
<KeyboardShortcutSection title={t('Keyboard_Shortcuts_Edit_Previous_Message')} command={t('Keyboard_Shortcuts_Keys_2')} /> |
||||
<KeyboardShortcutSection title={t('Keyboard_Shortcuts_Move_To_Beginning_Of_Message')} command={t('Keyboard_Shortcuts_Keys_3')} /> |
||||
<KeyboardShortcutSection title={t('Keyboard_Shortcuts_Move_To_Beginning_Of_Message')} command={t('Keyboard_Shortcuts_Keys_4')} /> |
||||
<KeyboardShortcutSection title={t('Keyboard_Shortcuts_Move_To_End_Of_Message')} command={t('Keyboard_Shortcuts_Keys_5')} /> |
||||
<KeyboardShortcutSection title={t('Keyboard_Shortcuts_Move_To_End_Of_Message')} command={t('Keyboard_Shortcuts_Keys_6')} /> |
||||
<KeyboardShortcutSection title={t('Keyboard_Shortcuts_New_Line_In_Message')} command={t('Keyboard_Shortcuts_Keys_7')} /> |
||||
</ContextualbarScrollableContent> |
||||
</ContextualbarDialog> |
||||
); |
||||
}; |
||||
|
||||
export default memo(KeyboardShortcuts); |
||||
@ -1,11 +0,0 @@ |
||||
import { useRoomToolbox } from '@rocket.chat/ui-contexts'; |
||||
import type { ReactElement } from 'react'; |
||||
|
||||
import KeyboardShortcuts from './KeyboardShortcuts'; |
||||
|
||||
const KeyboardShortcutsWithData = (): ReactElement => { |
||||
const { closeTab } = useRoomToolbox(); |
||||
return <KeyboardShortcuts handleClose={closeTab} />; |
||||
}; |
||||
|
||||
export default KeyboardShortcutsWithData; |
||||
@ -1 +0,0 @@ |
||||
export { default } from './KeyboardShortcutsWithData'; |
||||
@ -0,0 +1,43 @@ |
||||
import { useSetModal } from '@rocket.chat/ui-contexts'; |
||||
import { useEffect } from 'react'; |
||||
import tinykeys from 'tinykeys'; |
||||
|
||||
import KeyboardShortcutsModal from '../../../navbar/NavBarSettingsToolbar/UserMenu/KeyboardShortcutsModal'; |
||||
|
||||
const shouldIgnoreKeyStroke = (target: EventTarget | null): boolean => { |
||||
if (!(target instanceof Element)) { |
||||
return false; |
||||
} |
||||
|
||||
if (target instanceof HTMLElement && target.isContentEditable) { |
||||
return true; |
||||
} |
||||
|
||||
const { tagName } = target; |
||||
if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') { |
||||
return true; |
||||
} |
||||
|
||||
return target.closest('dialog[open]') !== null; |
||||
}; |
||||
|
||||
export const useKeyboardShortcutsHotkey = () => { |
||||
const setModal = useSetModal(); |
||||
|
||||
useEffect(() => { |
||||
const handler = (event: KeyboardEvent) => { |
||||
if (shouldIgnoreKeyStroke(event.target)) { |
||||
return; |
||||
} |
||||
|
||||
event.preventDefault(); |
||||
|
||||
const handleClose = () => setModal(null); |
||||
setModal(<KeyboardShortcutsModal onClose={handleClose} />); |
||||
}; |
||||
|
||||
return tinykeys(window, { |
||||
'Shift+?': handler, |
||||
}); |
||||
}, [setModal]); |
||||
}; |
||||
Loading…
Reference in new issue