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

23.05.2026
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 Copy
npm install @macrulez/vue-command-palette
ts Copy
// 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 Copy
<template>
  <CommandPalette placeholder="Поиск команд…" :max-results="12" />
</template>

<script setup lang="ts">
import { CommandPalette } from '@macrulez/vue-command-palette'
</script>

Регистрация команд

Команды регистрируются из любого компонента через useRegisterGroup. Группа автоматически удаляется при размонтировании компонента — утечек нет.

ts Copy
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 Copy
useRegisterGroup({ id: 'nav',     priority: 100, ... }) // показывается первой
useRegisterGroup({ id: 'actions', priority: 60,  ... })
useRegisterGroup({ id: 'search',  priority: 40,  ... }) // показывается последней

Fuzzy-поиск с ранжированием

Поиск ищет по label, description, keywords и aliases. Результаты ранжируются по четырём уровням:

  1. Точное совпадение: "settings""Settings" — наивысший score
  2. Совпадение с начала: "set""Settings"
  3. Совпадение по вхождению: "tting""Settings"
  4. Fuzzy: "stgs""Settings" — поочерёдные символы без пропусков

Совпавшие символы подсвечиваются через highlightMatches. В слоте #item это выглядит так:

vue Copy
<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 Copy
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 Copy
{
  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 Copy
{
  id: 'delete-workspace',
  label: 'Удалить рабочее пространство',
  icon: '🗑️',
  confirm: 'Удалить рабочее пространство? Это действие нельзя отменить.',
  perform: async () => {
    await api.deleteWorkspace(workspaceId)
    router.push('/workspaces')
  },
},

Асинхронный поиск

onSearch позволяет подгружать результаты с сервера. Debounce встроен (200ms). Синхронные результаты (из зарегистрированных групп) показываются сразу, асинхронные добавляются сверху как приходят.

ts Copy
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 Copy
app.use(VCommandPalettePlugin, {
  persistRecent: true,
  maxRecent: 6,
  maxRecentPerGroup: 2,   // не более 2 команд из одной группы в истории
  localStorageKey: 'myapp:palette:recent',
})

maxRecentPerGroup помогает не забивать историю командами из одной группы, когда пользователь активно работает с одним разделом.


enabled() — динамическая доступность

enabled — функция, которая вычисляется при каждом рендере. Команда скрыта или недоступна в зависимости от состояния приложения:

ts Copy
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 Copy
app.use(VCommandPalettePlugin, { colorTheme: 'dark' })
ts Copy
// программное переключение из компонента
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 Copy
:root {
  --vcp-dialog-width: 640px;
  --vcp-dialog-bg: #1a1a2e;
  --vcp-item-active-bg: #16213e;
  --vcp-match-color: #818cf8;
  --vcp-item-height: 44px;
}

Кастомизация через слоты

Палитра предоставляет семь слотов:

vue Copy
<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 Copy
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 Copy
mount(PaletteProvider, {
  props: { commands: [myCmd] },
  slots: { default: MyChildComponent },
})

Сценарии в реальных проектах

SaaS: многофункциональный дашборд

В продуктах с десятками разделов и сотнями пользователей, где каждая роль видит разный набор функций, palettes эффективнее верхней навигации. Команды регистрируются модульно — каждый раздел приложения регистрирует свои команды при монтировании:

ts Copy
// 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 Copy
// в компоненте раздела
useBillingCommands()
useAnalyticsCommands()
useTeamCommands()

При размонтировании роута команды автоматически убираются из палитры. Пользователь видит только команды, актуальные для его прав и текущего контекста — за счёт enabled().


Редактор документов: контекстные действия

В документ-ориентированных приложениях состав команд зависит от выделенного текста, типа блока, режима просмотра. Палитра объединяет глобальные и контекстные действия:

ts Copy
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 Copy
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

Читать далее

24.05.2026

vue-storage-kit — реактивное хранилище для Vue 3 с шифрованием, миграциями и синхронизацией вкладок

Пакет, который закрывает всё что нужно для работы с localStorage, sessionStorage, IndexedDB и cookies во Vue 3 — TTL, AES-GCM шифрование, схема-миграции, кросс-вкладочная синхронизация и Pinia-персист, без единой лишней зависимости.

Метки
vue3typescriptlocalstorageindexeddbpinia