useShortcuts
Binds keyboard shortcuts. Ctrl+K is simultaneous; g d is a sequence. Both register themselves in the cheat sheet.
The example below binds through bindShortcuts and releases on unmount, because it is running inside a page you will navigate away from. useShortcuts itself binds for the life of the app.
<script setup lang="ts">
import { bindShortcuts, useShortcutRegistry } from '@shared/composables/useShortcuts'
import { Badge } from '@shared/components/ui/badge'
import { onBeforeUnmount, ref } from 'vue'
const lastFired = ref('Nothing yet.')
const sequenceCount = ref(0)
const comboCount = ref(0)
const cleanups = bindShortcuts(
{
'g x': () => {
sequenceCount.value += 1
lastFired.value = 'g x — the sequence'
},
'Ctrl+Shift+Y': () => {
comboCount.value += 1
lastFired.value = 'Ctrl+Shift+Y — the combo'
},
},
{
'g x': { description: 'Fire the sequence example', group: 'Docs' },
'Ctrl+Shift+Y': { description: 'Fire the combo example', group: 'Docs' },
},
)
const { shortcuts } = useShortcutRegistry()
onBeforeUnmount(() => {
for (const stop of cleanups)
stop()
})
</script>
<template>
<div class="flex flex-col gap-4">
<p class="sa-text-sm text-muted-foreground">
Click anywhere outside a field, then press <kbd>g</kbd> then <kbd>x</kbd>, or
<kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>Y</kbd>.
</p>
<div class="flex flex-wrap gap-2">
<Badge variant="outline">
g x fired {{ sequenceCount }}
</Badge>
<Badge variant="outline">
Ctrl+Shift+Y fired {{ comboCount }}
</Badge>
</div>
<p class="rounded-lg border border-border p-3 sa-text-sm">
Last: <strong>{{ lastFired }}</strong>
</p>
<div>
<p class="sa-text-sm font-semibold">
In the registry under "Docs"
</p>
<ul class="mt-1 sa-text-sm text-muted-foreground">
<li v-for="entry in shortcuts.filter(s => s.group === 'Docs')" :key="entry.combo">
<code>{{ entry.combo }}</code> — {{ entry.description }}
</li>
</ul>
</div>
<input
type="text"
placeholder="Type g x in here and nothing fires"
class="rounded-md border border-border bg-background px-3 py-2 sa-text-sm"
>
</div>
</template>Import
import {
bindShortcuts,
registerShortcutMeta,
unregisterShortcutMeta,
useShortcutCheatSheet,
useShortcutRegistry,
useShortcuts,
} from '@shared/composables/useShortcuts'Signature
function useShortcuts(shortcuts: ShortcutMap, meta?: Record<string, ShortcutMeta>): void
type ShortcutMap = Record<string, () => void>
interface ShortcutMeta {
description: string
group?: string
}Combo syntax
| Form | Example | Means |
|---|---|---|
+ between keys | 'Ctrl+K' | Pressed together. |
| Spaces between keys | 'g d' | g, then d, within 800ms. |
VueUse's magic-keys has no native sequences, so those are detected here.
Example
<script setup lang="ts">
import { useShortcuts } from '@shared/composables/useShortcuts'
import { useRouter } from 'vue-router'
const router = useRouter()
useShortcuts(
{
'g i': () => { void router.push('/invoices') },
'Ctrl+Shift+N': () => { void router.push('/invoices/new') },
},
{
'g i': { description: 'Go to invoices', group: 'Navigation' },
'Ctrl+Shift+N': { description: 'New invoice', group: 'Actions' },
},
)
</script>
<template>
<p>Press <kbd>g</kbd> then <kbd>i</kbd>.</p>
</template>Notes
- A bare-key shortcut is suppressed while focus is in an input, a textarea, a select or anything contenteditable, so
?does not open the cheat sheet in the middle of a sentence. A combo with a modifier always fires. - The same combo can be bound twice, and only the newest one runs. Each binding takes a token and pushes it onto a stack per combo; the handler checks it owns the top of that stack. Without it, a page binding
g swould fire its own handler and the global one on the same keystroke. - A sequence resets if the wrong key is pressed, or after 800ms of nothing.
useShortcutsbinds for the life of the app and never cleans up. For a page, useusePageShortcuts, which tears down on scope dispose.
bindShortcuts
The core both wrappers use. It returns an array of cleanup functions, so you can own the teardown yourself:
import { bindShortcuts } from '@shared/composables/useShortcuts'
const cleanups = bindShortcuts({ 'g h': () => console.log('home') })
for (const stop of cleanups)
stop()useShortcutRegistry
function useShortcutRegistry(): {
shortcuts: Readonly<Ref<RegisteredShortcut[]>>
}RegisteredShortcut is { combo: string, description: string, group: string }. group defaults to common.general.
Every registration is listed, in the order it was made, and a combo can appear more than once. The cheat sheet shows the last registration for each combo, which is what that key does right now — the same rule the binding itself follows.
useShortcutCheatSheet
function useShortcutCheatSheet(): {
open: Ref<boolean>
}Singleton open state, so the navbar button, the ? key and the host in App.vue all drive one ref.
What is bound already
| Combo | Does |
|---|---|
? | Opens the cheat sheet. |
g d | Dashboard. |
g s | Settings. |
g p | Profile. |
t s | Toggles the sidebar. |
t c | Toggles the theme customizer. |
Ctrl+K | Opens the command palette. |
g goes somewhere, t toggles a piece of chrome.
Ctrl+K is a plain listener, not a useShortcuts binding. It needs preventDefault() to beat the browser's own search box, and it has to work before the lazily loaded palette chunk arrives. It is registered in the cheat sheet by hand with registerShortcutMeta, and it reads the commandPalette feature flag inside the handler, so switching that off in Settings takes effect on the next keystroke rather than the next reload.
Related
usePageShortcuts— the same thing, scoped to a page.- Shortcut cheat sheet — the
?dialog. useCommandPalette— whatCtrl+Kopens.