Command Palette для Vue 3 — fuzzy-поиск, вложенные палитры и глобальные хоткеи из коробки

Command palette — это интерфейс быстрого доступа к командам через клавиатуру. Нажимаешь Cmd+K / Ctrl+K, появляется модальное окно с поиском, вводишь часть названия команды — получаешь отфильтрованный список. Enter — команда выполняется.
Этот паттерн пришёл из Spotlight, VS Code, Raycast и Linear. Он радикально ускоряет навигацию в сложных приложениях: вместо того чтобы искать кнопку в интерфейсе или помнить меню — просто открываешь палитру и набираешь.
Готовых решений для Vue 3 почти нет. Написал @macrulez/vue-command-palette — пакет с полным набором фич: fuzzy-поиск с ранжированием, группы команд с приоритетами, вложенные палитры (subCommands) с breadcrumb, история последних команд через localStorage, глобальные хоткеи с поддержкой $mod, диалог подтверждения, асинхронный поиск с debounce, переключатель тем прямо в палитре. Слоты для кастомизации каждого элемента UI.

Установка и подключение
bash
npm install @macrulez/vue-command-palette
ts
// main.ts
import { createApp } from 'vue'
import { VCommandPalettePlugin } from '@macrulez/vue-command-palette'
import '@macrulez/vue-command-palette/style.css'
import App from './App.vue'
const app = createApp(App)
app.use(VCommandPalettePlugin, {
hotkey: ['$mod', 'k'], // Cmd+K на macOS, Ctrl+K на Windows/Linux
colorTheme: 'system', // 'light' | 'dark' | 'system'
persistRecent: true,
maxRecent: 6,
onError: (err, cmd) => console.error(`Command "${cmd.label}" failed:`, err),
})
app.mount('#app')
Компонент CommandPalette размещается один раз в корне приложения и работает глобально:
vue
<template>
<CommandPalette placeholder="Поиск команд…" :max-results="12" />
</template>
<script setup lang="ts">
import { CommandPalette } from '@macrulez/vue-command-palette'
</script>
Регистрация команд
Команды регистрируются из любого компонента через useRegisterGroup. Группа автоматически удаляется при размонтировании компонента — утечек нет.
ts
import { useRegisterGroup } from '@macrulez/vue-command-palette'
useRegisterGroup({
id: 'navigation',
label: 'Навигация',
priority: 100,
commands: [
{
id: 'go-dashboard',
label: 'Перейти на Dashboard',
description: 'Главная страница с метриками',
icon: '🏠',
keywords: ['home', 'main', 'главная'],
shortcut: ['$mod', '1'],
perform: () => router.push('/dashboard'),
},
{
id: 'go-settings',
label: 'Настройки',
icon: '⚙️',
shortcut: ['$mod', ','],
perform: () => router.push('/settings'),
},
],
})
Несколько групп с разными приоритетами — результаты в палитре отсортированы по priority (убывание):
ts
useRegisterGroup({ id: 'nav', priority: 100, ... }) // показывается первой
useRegisterGroup({ id: 'actions', priority: 60, ... })
useRegisterGroup({ id: 'search', priority: 40, ... }) // показывается последней
Fuzzy-поиск с ранжированием
Поиск ищет по label, description, keywords и aliases. Результаты ранжируются по четырём уровням:
- Точное совпадение:
"settings"→"Settings"— наивысший score - Совпадение с начала:
"set"→"Settings" - Совпадение по вхождению:
"tting"→"Settings" - Fuzzy:
"stgs"→"Settings"— поочерёдные символы без пропусков
Совпавшие символы подсвечиваются через highlightMatches. В слоте #item это выглядит так:
vue
<template #item="{ command, active, matches }">
<div class="my-item" :class="{ active }">
<span>{{ command.icon }}</span>
<component :is="highlightMatches(command.label, matches)" />
<span>{{ command.description }}</span>
</div>
</template>
highlightMatches возвращает компонент который рендерит label с тегами <mark class="vcp-match"> на позициях совпадений.
Глобальные хоткеи и $mod
useRegisterGroup регистрирует не только команды в палитре, но и глобальные горячие клавиши через shortcut. Работают даже когда палитра закрыта.
ts
commands: [
{
id: 'new-doc',
label: 'Новый документ',
shortcut: ['$mod', 'n'], // Cmd+N / Ctrl+N
perform: () => createDocument(),
},
{
id: 'find',
label: 'Найти в документе',
shortcut: ['$mod', 'f'], // Cmd+F / Ctrl+F
perform: () => openFindPanel(),
},
{
id: 'go-home',
label: 'Перейти на главную',
shortcut: ['g', 'h'], // последовательность: g, затем h
perform: () => router.push('/'),
},
]
$mod резолвится в Meta (⌘) на macOS и Ctrl на Windows/Linux — не нужно писать платформо-зависимый код.
Последовательности клавиш (g h, g p) работают как в Vim-режиме GitHub: первая клавиша открывает контекст, вторая выполняет команду.
Вложенные палитры
Команда с subCommands при выборе открывает вложенную палитру. В заголовке появляется breadcrumb. Backspace или Escape — возврат на уровень выше.
ts
{
id: 'change-theme',
label: 'Изменить тему',
icon: '🎨',
subCommands: [
{
id: 'theme-light',
label: 'Светлая',
enabled: () => currentTheme.value !== 'light',
perform: () => { currentTheme.value = 'light' },
},
{
id: 'theme-dark',
label: 'Тёмная',
enabled: () => currentTheme.value !== 'dark',
perform: () => { currentTheme.value = 'dark' },
},
{
id: 'theme-system',
label: 'Системная',
enabled: () => currentTheme.value !== 'system',
perform: () => { currentTheme.value = 'system' },
},
],
},
Вложенность не ограничена — у subCommands тоже могут быть свои subCommands.
Диалог подтверждения
Деструктивные операции — удаление, экспорт, сброс — требуют подтверждения. Поле confirm показывает диалог перед выполнением, не нужно писать модалку отдельно:
ts
{
id: 'delete-workspace',
label: 'Удалить рабочее пространство',
icon: '🗑️',
confirm: 'Удалить рабочее пространство? Это действие нельзя отменить.',
perform: async () => {
await api.deleteWorkspace(workspaceId)
router.push('/workspaces')
},
},
Асинхронный поиск
onSearch позволяет подгружать результаты с сервера. Debounce встроен (200ms). Синхронные результаты (из зарегистрированных групп) показываются сразу, асинхронные добавляются сверху как приходят.
ts
useRegisterGroup({
id: 'docs',
label: 'Документация',
priority: 50,
commands: [],
onSearch: async (query) => {
const results = await api.searchDocs(query)
return results.map(doc => ({
id: `doc-${doc.id}`,
label: doc.title,
description: doc.excerpt,
icon: '📄',
perform: () => window.open(doc.url, '_blank'),
}))
},
})
Результаты с onSearch мёрджатся с синхронными и сортируются по score. Если одна и та же команда пришла из обоих источников — дубликат отбрасывается.
История последних команд
При persistRecent: true каждая выполненная команда записывается в localStorage. При открытии палитры с пустым запросом последние команды показываются в первой секции.
ts
app.use(VCommandPalettePlugin, {
persistRecent: true,
maxRecent: 6,
maxRecentPerGroup: 2, // не более 2 команд из одной группы в истории
localStorageKey: 'myapp:palette:recent',
})
maxRecentPerGroup помогает не забивать историю командами из одной группы, когда пользователь активно работает с одним разделом.
enabled() — динамическая доступность
enabled — функция, которая вычисляется при каждом рендере. Команда скрыта или недоступна в зависимости от состояния приложения:
ts
commands: [
{
id: 'publish',
label: 'Опубликовать',
enabled: () => currentUser.hasPermission('publish') && draft.value.isDirty,
perform: () => publishDraft(),
},
{
id: 'delete-comment',
label: 'Удалить комментарий',
enabled: () => selectedComment.value?.authorId === currentUser.id,
perform: () => deleteComment(selectedComment.value!.id),
},
]
Отличие от disabled: true: disabled — статичный флаг, команда всегда недоступна. enabled — реактивная функция, результат меняется в зависимости от ref/computed. При enabled: () => false команда не попадает в список вообще.
Темы: light / dark / system
Палитра не зависит от темы приложения — у неё собственный переключатель прямо в строке поиска. Начальная тема задаётся в опциях плагина, затем переключается пользователем или программно:
ts
app.use(VCommandPalettePlugin, { colorTheme: 'dark' })
ts
// программное переключение из компонента
import { useCommandPalette } from '@macrulez/vue-command-palette'
const { colorTheme } = useCommandPalette()
colorTheme.value = 'dark'
Режим 'system' следует prefers-color-scheme. 'light' и 'dark' применяют CSS-классы .vcp-theme-light / .vcp-theme-dark на overlay, которые переопределяют media query.
Более 20 CSS custom properties для полной кастомизации без прикосновения к исходникам:
css
:root {
--vcp-dialog-width: 640px;
--vcp-dialog-bg: #1a1a2e;
--vcp-item-active-bg: #16213e;
--vcp-match-color: #818cf8;
--vcp-item-height: 44px;
}
Кастомизация через слоты
Палитра предоставляет семь слотов:
vue
<CommandPalette>
<!-- Кнопка-триггер (если не нужна кнопка по умолчанию) -->
<template #trigger="{ toggle }">
<button @click="toggle">Открыть</button>
</template>
<!-- Шапка внутри диалога -->
<template #header>
<div class="my-header">Команды проекта</div>
</template>
<!-- Каждый элемент списка -->
<template #item="{ command, active, matches }">
<div class="my-item" :class="{ active }">
<span class="icon">{{ command.icon }}</span>
<component :is="highlightMatches(command.label, matches)" />
<span class="desc">{{ command.description }}</span>
<kbd v-for="k in command.shortcut" :key="k">{{ k }}</kbd>
</div>
</template>
<!-- Заголовок группы -->
<template #group-header="{ group }">
<div class="my-group-header">{{ group.label }}</div>
</template>
<!-- Пустое состояние -->
<template #empty="{ query }">
<div>Команда "{{ query }}" не найдена</div>
</template>
<!-- Подвал -->
<template #footer>
<div class="palette-footer">
<span><kbd>↑↓</kbd> навигация</span>
<span><kbd>↵</kbd> выбор</span>
<span><kbd>Esc</kbd> закрыть</span>
</div>
</template>
</CommandPalette>
Утилиты для тестирования
createPaletteContext создаёт изолированный контекст для unit-тестов без полного монтирования плагина:
ts
import { mount } from '@vue/test-utils'
import { createPaletteContext } from '@macrulez/vue-command-palette/testing'
import MyComponent from './MyComponent.vue'
test('команда вызывает router.push', async () => {
const push = vi.fn()
const { provide } = createPaletteContext({
commands: [
{ id: 'go-home', label: 'Go Home', perform: () => push('/') },
],
})
const wrapper = mount(MyComponent, {
global: { provide },
})
// симулируем выполнение команды
await wrapper.vm.execute({ id: 'go-home' })
expect(push).toHaveBeenCalledWith('/')
})
PaletteProvider — компонент-обёртка для тестов, когда нужен полноценный provide/inject в дочерних компонентах:
ts
mount(PaletteProvider, {
props: { commands: [myCmd] },
slots: { default: MyChildComponent },
})
Сценарии в реальных проектах
SaaS: многофункциональный дашборд
В продуктах с десятками разделов и сотнями пользователей, где каждая роль видит разный набор функций, palettes эффективнее верхней навигации. Команды регистрируются модульно — каждый раздел приложения регистрирует свои команды при монтировании:
ts
// src/modules/billing/useBillingCommands.ts
export function useBillingCommands() {
const { isAdmin } = useAuth()
useRegisterGroup({
id: 'billing',
label: 'Биллинг',
priority: 70,
commands: [
{
id: 'billing-overview',
label: 'Биллинг',
perform: () => router.push('/billing'),
},
{
id: 'billing-upgrade',
label: 'Повысить тариф',
description: 'Перейти на Pro или Enterprise',
enabled: () => plan.value !== 'enterprise',
perform: () => router.push('/billing/upgrade'),
},
{
id: 'billing-download-invoice',
label: 'Скачать счёт',
description: 'Последний выставленный счёт',
enabled: () => isAdmin.value,
confirm: 'Скачать счёт за текущий период?',
perform: async () => {
const blob = await api.downloadInvoice()
downloadBlob(blob, 'invoice.pdf')
},
},
],
})
}
ts
// в компоненте раздела
useBillingCommands()
useAnalyticsCommands()
useTeamCommands()
При размонтировании роута команды автоматически убираются из палитры. Пользователь видит только команды, актуальные для его прав и текущего контекста — за счёт enabled().
Редактор документов: контекстные действия
В документ-ориентированных приложениях состав команд зависит от выделенного текста, типа блока, режима просмотра. Палитра объединяет глобальные и контекстные действия:
ts
const editor = useEditor()
useRegisterGroup({
id: 'editor-format',
label: 'Форматирование',
priority: 90,
commands: [
{
id: 'format-bold',
label: 'Жирный',
shortcut: ['$mod', 'b'],
enabled: () => editor.hasSelection(),
perform: () => editor.toggleMark('bold'),
},
{
id: 'format-h1',
label: 'Заголовок H1',
enabled: () => editor.isInParagraph(),
perform: () => editor.setBlock('heading', { level: 1 }),
},
{
id: 'format-code-block',
label: 'Блок кода',
keywords: ['code', 'snippet', 'terminal'],
subCommands: [
{ id: 'code-ts', label: 'TypeScript', perform: () => editor.insertCodeBlock('typescript') },
{ id: 'code-py', label: 'Python', perform: () => editor.insertCodeBlock('python') },
{ id: 'code-sh', label: 'Shell', perform: () => editor.insertCodeBlock('bash') },
{ id: 'code-sql', label: 'SQL', perform: () => editor.insertCodeBlock('sql') },
],
},
],
})
useRegisterGroup({
id: 'editor-doc',
label: 'Документ',
priority: 60,
commands: [
{
id: 'doc-save',
label: 'Сохранить',
shortcut: ['$mod', 's'],
enabled: () => editor.isDirty(),
perform: async () => {
await api.saveDocument(editor.getContent())
toast('Сохранено')
},
},
{
id: 'doc-export-pdf',
label: 'Экспортировать в PDF',
confirm: 'Экспортировать текущий документ в PDF?',
perform: async () => {
const pdf = await api.exportPdf(documentId.value)
downloadBlob(pdf, 'document.pdf')
},
},
{
id: 'doc-delete',
label: 'Удалить документ',
enabled: () => currentUser.isOwner(documentId.value),
confirm: 'Удалить документ? Это действие нельзя отменить.',
perform: async () => {
await api.deleteDocument(documentId.value)
router.push('/documents')
},
},
],
})
Вложенная палитра для блока кода работает в два нажатия: сначала выбираешь «Блок кода», потом язык. Диалоги подтверждения на удалении и экспорте исключают случайный запуск.
Инструменты разработчика: DevTools-панель
Внутренние инструменты и debug-панели — идеальный кейс для палитры. Команды меняют состояние приложения на лету, имитируют события, переключают флаги фич:
ts
if (import.meta.env.DEV) {
useRegisterGroup({
id: 'devtools',
label: '🛠 DevTools',
priority: 1000, // всегда сверху
commands: [
{
id: 'dev-clear-storage',
label: '[DEV] Очистить localStorage',
perform: () => { localStorage.clear(); location.reload() },
},
{
id: 'dev-toggle-feature',
label: '[DEV] Feature Flags',
subCommands: Object.entries(featureFlags).map(([key, flag]) => ({
id: `flag-${key}`,
label: `${flag.enabled ? '✅' : '⬜'} ${flag.label}`,
perform: () => { flag.enabled = !flag.enabled },
})),
},
{
id: 'dev-impersonate',
label: '[DEV] Войти как пользователь',
onSearch: async (query) => {
const users = await api.searchUsers(query, { limit: 5 })
return users.map(u => ({
id: `impersonate-${u.id}`,
label: u.name,
description: u.email,
perform: () => auth.impersonate(u.id),
}))
},
},
{
id: 'dev-seed-data',
label: '[DEV] Сгенерировать тестовые данные',
confirm: 'Добавить 100 тестовых записей в текущий раздел?',
perform: async () => {
await api.seedData(currentSection.value)
toast('100 записей добавлено')
},
},
],
})
}
Группа регистрируется только в development-режиме. Поиск пользователей для имперсонации работает через onSearch — сервер отдаёт подходящих пользователей на лету. Флаги фич обновляются прямо в списке — текущее состояние видно через ✅/⬜ в заголовке команды.
NPM: https://www.npmjs.com/package/@macrulez/vue-command-palette
GitHub: https://github.com/macrulezru/vue-command-palette