Skip to main content

Keyboard Shortcuts

This service provides a system for creating and subscribing to keyboard shortcuts.

Examples

Registering a Shortcut

An event can be registered by calling registerShortcut. Shortcut registering can only be done from a Typescript file following the naming convention APPNAME/entry.ts. In order to keep our shortcuts system maintainable, all shortcuts must be codified in a Typescript type before they can be registered. Adding the types is a straightforward process and the example below includes how we can do this:

import { $t, registerShortcut } from '@rhapsody/keyboard-shortcuts'

// ↓↓↓ Here is where we define the types for the shortcuts we're about to register.
// Each shortcut should be listed with the key being the shortcut name and the type
// being the type for any data passed along with the shortcut. If no data will be
// included, `void` should be used as the type. Notice also how `KeyboardShortcutsMap`
// does not need to be imported. We can append members to it directly using the
// syntax below. See the documentation for `KeyboardShortcutsMap` for more information.
declare module '@rhapsody/keyboard-shortcuts/types' {
interface KeyboardShortcutsMap {
'shortcut.omnibar.open': void
}
}

registerShortcut({
id: 'shortcut.omnibar.open',
keySequence: '/',
scope: 'global',
description: $t({ defaultMessage: 'Open search' }),
})

Shortcut keySequence format

  1. Hotkey matches against the event.key, and uses standard W3C key names for keys and modifiers as documented in UI Events KeyboardEvent key Values.
  2. At minimum a hotkey string must specify one bare key.
  3. Multiple hotkeys (aliases) are separated by a ,. For example the hotkey a,b would activate if the user typed a or b.
  4. Multiple keys separated by a blank space represent a key sequence. For example the hotkey g n would activate when a user types the g key followed by the n key.
  5. Modifier key combos are separated with a + and are prepended to a key in a consistent order as follows: "Control+Alt+Meta+Shift+KEY".
  6. "Mod" is a special modifier that localizes to Meta on MacOS/iOS, and Control on Windows/Linux.
    1. "Mod+" can appear in any order in a hotkey string. For example: "Mod+Alt+Shift+KEY"
    2. Neither the Control or Meta modifiers should appear in a hotkey string with Mod.
  7. "Plus" and "Space" are special key names to represent the + and keys respectively, because these symbols cannot be represented in the normal hotkey string syntax.
  8. You can use the comma key , as a hotkey, e.g. a,, would activate if the user typed a or ,. Control+,,x would activate for Control+, or x.
  9. "Shift" should be included if it would be held and the key is uppercase: ie, Shift+A not A
    1. MacOS outputs lowercase key names when Meta+Shift is held (ie, Meta+Shift+a). In an attempt to normalize this, hotkey will automatically map these key names to uppercase, so the uppercase keys should still be used (ie, "Meta+Shift+A" or "Mod+Shift+A"). However, this normalization only works on US keyboard layouts.
Example

The following hotkey would match if the user typed the key sequence a and then b, OR if the user held down the Control, Alt and / keys at the same time.

'a b,Control+Alt+/'

Shortcut nameing

Regarding shortcuts naming, all shortcuts are required to adhere to the naming convention shortcut.<NAMESPACE>.<NAMESPACE>.<OPTIONAL_NAMESPACE>. Below are a few annotated examples:

  • shortcut.omnibar.open: A shortcut for the omnibar to open the omnibar.
  • shortcut.globalNav.workspace.show: A shortcut on the Global Nav used to show the workspace dropdown.
More Information on Typing in the Shortcuts Service

In order to keep the shortcuts service maintainable, we require all shortcuts to be typed. This involved providing the shortcut name. We accomplish this using some fancy type footwork as well as by utilizing a Typescript feature called declaration merging.

The primary interface the shortcuts system utilizes is KeyboardShortcutsMap. This interface starts out as an empty interface and is augmented by other files in the project to build up the full list of shortcut names. We gather the full list of shortcut names by using keyof KeyboardShortcutsMap. This is what gives us autocomplete for the first parameter in all the exported functions.

Building up this type uses declaration merging. With declaration merging, we can append members (key/type pairs) to an interface that's already been declared elsewhere. In the example above, the line declare module '@rhapsody/shortcuts/types' tells Typescript we're going to be extending the types.ts file in the @rhapsody/shortcuts package. From there, adding to the interface looks exactly the same as defining a regular interface. Once caveat is that one cannot overwrite an existing member unless it is over the same type. This isn't an issue for us with KeyboardShortcutsMap because each shortcut name must be globally unique.

Using a Shortcut

We can subscribe to shortcuts using useShortcut. useShortcut requires the shortcut ID to look up the correct key sequence, a callback that will fire every time the shortcut occurs, options to pass to react-hotkeys-hook, and a dependencies array to control the callback reference.

import { useShortcut } from '@rhapsody/keyboard-shortcuts'

useShortcut(
'shortcut.omnibar.open',
(payload) => console.log('Here is the payload:', payload),
{ preventDefault: true },
[dependency1, dependency2]
)

Testing

When testing a component uses to a shortcut you need to register that shortcut within the testing environment.

import React from 'react'
import { registerShortcut } from '@rhapsody/keyboard-shortcuts'

beforeAll(() => {
registerShortcut({
id: 'shortcut.omnibar.open',
keySequence: '/',
scope: 'global',
description: 'Open search',
})
})

describe('example', () => {
it('runs a test', async () => {
expect('test').toBeInTheDocument()
})
})