Command palette
Fuzzy search over every registered command, grouped, with the recent ones first.
<script setup lang="ts">
import type { Command } from '@shared/composables/useCommandRegistry'
import CommandPalette from '@shared/components/common/CommandPalette.vue'
import { Button } from '@shared/components/ui/button'
import { useCommandPalette } from '@shared/composables/useCommandPalette'
import { useCommandRegistry } from '@shared/composables/useCommandRegistry'
import { CreditCard, LayoutDashboard, Plus, Settings, Users } from 'lucide-vue-next'
import { onMounted, onUnmounted, ref } from 'vue'
const { show } = useCommandPalette()
const { register, unregister } = useCommandRegistry()
const ran = ref('')
const COMMANDS: Command[] = [
{ id: 'demo-dashboard', title: 'Go to Dashboard', group: 'pages', icon: LayoutDashboard, shortcut: 'g d', handler: () => { ran.value = 'Dashboard' } },
{ id: 'demo-customers', title: 'Go to Customers', group: 'pages', icon: Users, keywords: ['clients', 'accounts'], handler: () => { ran.value = 'Customers' } },
{ id: 'demo-invoice', title: 'Create an invoice', group: 'actions', icon: Plus, handler: () => { ran.value = 'New invoice' } },
{ id: 'demo-billing', title: 'Billing settings', group: 'settings', icon: CreditCard, handler: () => { ran.value = 'Billing' } },
{ id: 'demo-appearance', title: 'Appearance', group: 'settings', icon: Settings, keywords: ['theme', 'dark mode'], handler: () => { ran.value = 'Appearance' } },
]
onMounted(() => register(COMMANDS))
onUnmounted(() => COMMANDS.forEach(c => unregister(c.id)))
</script>
<template>
<div class="space-y-3">
<Button @click="show()">
Open the command palette
</Button>
<CommandPalette />
<p class="text-sm text-muted-foreground">
{{ ran ? `Ran: ${ran}` : 'Type to filter, Enter to run' }}
</p>
</div>
</template>Usage
The component takes no props. It reads two singletons: the open state from useCommandPalette, and the list from useCommandRegistry. Mount it once near the root, and open it from anywhere with useCommandPalette().show().
In the template the Ctrl/Cmd + K binding lives in main.ts, not in this component. The component is lazy-loaded, so the shortcut has to work before its chunk exists.
features.commandPalette gates the navbar trigger and that key binding. It is read inside the handler, so switching it off applies on the next key press rather than the next reload. See Global config.
Registering commands
A module calls register() at load time. Registering an id that already exists replaces it, so the last registration wins.
<script setup lang="ts">
import type { Command } from '@shared/composables/useCommandRegistry'
import { Button } from '@shared/components/ui/button'
import { useCommandPalette } from '@shared/composables/useCommandPalette'
import { useCommandRegistry } from '@shared/composables/useCommandRegistry'
import { FileDown } from 'lucide-vue-next'
import { onUnmounted, ref } from 'vue'
const { show } = useCommandPalette()
const { register, unregister, execute } = useCommandRegistry()
const log = ref('')
const added = ref(false)
const EXPORT: Command = {
id: 'demo-export-quarter',
title: 'Export this quarter',
group: 'actions',
icon: FileDown,
keywords: ['csv', 'download'],
handler: () => { log.value = 'Exported Q1' },
}
function add(): void {
register(EXPORT)
added.value = true
}
function remove(): void {
unregister(EXPORT.id)
added.value = false
}
onUnmounted(remove)
</script>
<template>
<div class="flex flex-wrap items-center gap-2">
<Button size="sm" variant="outline" :disabled="added" @click="add">
Register
</Button>
<Button size="sm" variant="outline" :disabled="!added" @click="remove">
Unregister
</Button>
<Button size="sm" variant="outline" :disabled="!added" @click="execute(EXPORT.id)">
Run it directly
</Button>
<Button size="sm" variant="outline" @click="show()">
Open the palette
</Button>
<p class="w-full text-sm text-muted-foreground">
{{ log || (added ? 'Registered. Open the palette and search for "export".' : 'Not registered') }}
</p>
</div>
</template>Always run a command through execute(id) rather than calling its handler yourself. execute records recency first and then runs it, which is what keeps the Recent group correct without every call site touching it.
Command
| Key | Type | Description |
|---|---|---|
id | string | Unique. Re-registering the same id replaces the command. |
title | string | What the reader sees, and the main search target. |
group | 'pages' | 'actions' | 'settings' | 'recent' | The section it lands in. |
icon | Component | A Lucide icon. |
shortcut | string | A hint such as g d. Display only; it binds nothing. |
keywords | string[] | Extra search terms, for words that are not in the title. |
feature | keyof SmartAdminConfig['features'] | Hides the command while that flag is off. |
handler | () => void | Promise<void> | What running it does. |
Searching and grouping
Search is fuzzy, over title and keywords, with a 0.4 threshold and no position weighting, so a match late in a title counts as much as one at the start. The matched run of the title is highlighted in the result.
With an empty query the list is grouped Recent, Pages, Actions, Settings, and empty groups are not drawn. Recency holds the last eight commands, in localStorage under sa-recent-commands. While you are searching the Recent group is hidden, because relevance is the ordering you asked for.
A command whose feature flag is off is filtered here rather than at registration. Registration runs once at boot and the flag is a live setting, so filtering there would need a reload. The route behind a hidden command stays registered, so a direct URL still resolves, the same way a permission-gated page does.
API
Source: src/shared/components/common/CommandPalette.vue
Props
None.
Emits
None.
Slots
None.
Accessibility
- It is a dialog: focus moves to the search box, Tab is trapped, and Escape closes it.
- The list is a command list, so
ArrowUpandArrowDownmove the active item andEnterruns it, with the active item reported to assistive tech. - Group headings are real headings in the list, so the sections are announced rather than only drawn.
- The shortcut hint is text beside the command, not an image, so it is read out with it.
- The query resets on close, so the palette always reopens fresh.