GrCommandPalette

Пакет: @feugene/granularityядроГруппа: Навигация

Берут, когда команд много и они разбросаны по интерфейсу.

Когда брать

  • команд много и они разбросаны по интерфейсу — палитра даёт к ним один вход вместо поиска по меню;
  • пользователь работает клавиатуройhotkey открывает палитру откуда угодно, дальше всё делается стрелками;
  • команды сгруппированы — разделы, недавние (recentIds) и подсказки сочетаний уже есть;
  • источник асинхронныйsource подгружает элементы по запросу, virtual держит длинный список.

Когда взять другое

НужноБерите
Выбирается значение поля формыGrSelect
Ищется объект с подгрузкойGrAutocomplete
Действия относятся к одному объектуGrDropdownMenu
Каталог виджетов дашбордаGrDashboardPalette
Поиск по содержимому страницы, а не по командамGrInput

Палитра не заменяет навигацию: она ускоряет работу тому, кто уже знает, что ищет. Единственный способ добраться до раздела ею быть не может — новый пользователь не догадается нажать сочетание.

Команды

Модель плоская: id, label, опциональные description, icon, shortcut, group, keywords, disabled. icon — Vue-компонент либо класс иконки вашей UnoCSS-сборки (см. «Иконки»). Группы собираются из group в порядке первого появления; команды без группы остаются безымянной группой на своём месте.

id обязателен и обязан быть уникальным: он же ключ рендера и цель aria-activedescendant. Дубли дают одинаковые DOM-id, и фокус уезжает не на ту команду, — в dev-сборке компонент об этом предупреждает.

Фильтр и подсветка

По умолчанию матчится подстрока (без учёта регистра) в метке, описании, имени группы и keywords. Совпавший фрагмент метки и описания подсвечивается <mark>; цвет настраивается переменной --gr-command-match-bg.

Свой filter может матчить по чему угодно — например только по keywords. Тогда в метке совпадения нет, и подсветка не появляется: подсвечивать нечего.

filterable="false" отдаёт фильтрацию наружу (remote-поиск): компонент показывает то, что пришло, а запрос отдаёт событием search.

Недавние

recentIds поднимает команды отдельной группой наверх — в порядке самого массива, а не списка команд. Из остального списка они убираются: одна команда не может встречаться дважды, у неё один id.

Секция живёт, только пока запрос пуст. С непустым запросом правит релевантность: история увела бы взгляд не туда с первой же буквы.

Состояния

Загрузка и «ничего не найдено» показываются одним живым регионом (role="status", aria-live="polite") вне списка. Иконка спиннера при этом декоративна: aria-label на элементе без роли большинство AT игнорируют, и раньше состояние загрузки не объявлялось никак.

Внутри role="listbox" прямыми потомками остаются только role="group" — заголовок группы лежит внутри неё и объявлен презентационным, имя группе он даёт через aria-labelledby. Иначе ломается aria-required-children, а панель здесь развёрнута всегда, то есть это основное состояние.

Виртуализация

virtual оставляет в DOM только окно вокруг вьюпорта; высоту окна задаёт maxHeight. Профильный сценарий — палитра приложения на тысячи команд.

<GrCommandPalette v-model="open" :items="commands" virtual :max-height="360" />

Группы переживают окно. Если список прокручен внутрь группы, её заголовка в разметке уже нет — обёртка role="group" всё равно создаётся и берёт имя через aria-label вместо aria-labelledby.

Набор считается по группе. При virtual команды несут aria-setsize/aria-posinset, и это размер их группы, а не всей палитры — так того требует ARIA. В обычном режиме атрибутов нет: там набор виден по DOM.

Активная команда всегда смонтирована. Стрелки прокручивают список до неё прежде, чем перевести aria-activedescendant. Устройство примитива — virtual-list.md.

Клавиатура и хоткей

/ ходят по командам (disabled пропускаются, обход зациклен), Home/End — к краям, Enter выполняет, Esc закрывает. Порядок обхода совпадает с экранным, включая «недавние».

hotkey (по умолчанию mod+k) вешает глобальное сочетание; mod — это ⌘ на Apple и Ctrl везде ещё. Платформа определяется после монтирования: на сервере navigator нет, и первый клиентский рендер обязан совпасть с серверным, иначе подсказка разъезжается при гидрации.

Императивный API

open(), close() и toggle() через ref на компоненте — тот же состав, что у остальных оверлеев. Нужны там, где палитру открывает не её хоткей: пункт меню «Найти команду», кнопка в шапке, ссылка из онбординга.

Палитра управляемая, поэтому методы эмитят update:modelValue — состояние остаётся в v-model родителя (подробнее — GrModal.md).

Playground 11

Загружается…

Код
<GrCommandPalette />

Установка

npm i @feugene/granularity

Импорт

import { GrCommandPalette } from '@feugene/granularity/components/GrCommandPalette'

API

Props

PropTypeпо умолчаниюОписание
filterGrCommandFilter | undefinedundefinedКастомный матчер локальной фильтрации.
size"sm" | "md" | "lg" | "xl" | "full" | undefinedundefined
placeholderstring | undefinedundefined
ariaLabelstring | undefinedundefined
loadingboolean | undefinedfalseВнешне управляемое состояние загрузки (для remote-поиска).
filterableboolean | undefinedtrueЛокальная фильтрация по запросу. `false` — фильтрует владелец по событию `search`.
closeOnSelectboolean | undefinedtrueЗакрывать палитру после выбора команды.
virtualboolean | undefinedfalseВиртуализация списка: в DOM живёт только окно вокруг вьюпорта. Высоту окна задаёт `maxHeight`. Включается осознанно: на сотне команд выигрыша нет, а в разметке остаётся только окно — вместе с ним меняется и то, что находит `querySelector` потребителя. Профильный сценарий — палитра приложения на тысячи команд.
itemsGrCommandItem[] | undefinedundefinedПлоский список команд; группировка — по полю `group` самой команды.
emptyTextstring | undefinedundefined
recentIdsstring[] | undefinedundefinedНедавние команды: пока запрос пуст, они поднимаются отдельной группой наверх в этом порядке и не дублируются ниже.
hotkeystring | null | undefined"mod+k"Глобальное сочетание открытия. `null` — не вешать слушатель.
maxHeightnumber | undefined360Максимальная высота списка, px.
showHotkeyHintboolean | undefinedtrueПоказывать подсказку сочетания в поле ввода.
modelValueобязательныйbooleanОткрыта ли палитра.

Slots

SlotTypeОписание
item{ item: GrCommandItem; active: boolean; }Строка списка вместо стандартной.
empty{ query: string; }Пустое состояние: получает запрос, чтобы предложить действие по нему.
footeranyПодвал палитры: подсказки по клавишам, счётчик.

Events

EventTypeОписание
update:modelValue[value: boolean]
search[query: string]
select[item: GrCommandItem]

Methods / Expose

Methods / ExposeTypeОписание
open() => void
close() => void
toggle() => void

Примеры 4

Команды с группами и сочетаниями клавиш

Палитра открывается по ⌘K (Ctrl+K вне macOS) или программно через v-model. Команды группируются полем group, ищутся по метке, описанию и keywords. Команда «Toggle theme» здесь настоящая: переключает тему через useTheme(), а её сочетание ⌘J повешено директивой v-hotkey — работает и без открытия палитры.

or press K

Last command: · theme: light — try J without opening the palette.

Basic
<script setup lang="ts">
import { computed, ref } from 'vue'

import { GrButton, GrCommandPalette, GrKbd, useTheme, vHotkey, type GrCommandItem } from '@feugene/granularity'

const open = ref(false)
const lastCommand = ref<string | null>(null)

// Команда «Toggle theme» настоящая: переключает тему витрины через `useTheme()`.
const { isDark, toggleTheme } = useTheme()

const commands = computed<GrCommandItem[]>(() => [
  { id: 'new-doc', label: 'New document', icon: 'i-lucide-file-plus', group: 'File', shortcut: ['', 'N'], keywords: ['create'] },
  { id: 'open', label: 'Open…', description: 'Recent files', icon: 'i-lucide-folder-open', group: 'File', shortcut: ['', 'O'] },
  { id: 'export', label: 'Export to PDF', icon: 'i-lucide-download', group: 'File' },
  { id: 'invite', label: 'Invite a teammate', icon: 'i-lucide-user-plus', group: 'Team' },
  { id: 'roles', label: 'Manage roles', icon: 'i-lucide-shield-check', group: 'Team' },
  {
    id: 'theme',
    label: 'Toggle theme',
    description: isDark.value ? 'Now: dark' : 'Now: light',
    icon: isDark.value ? 'i-lucide-sun' : 'i-lucide-moon',
    group: 'Settings',
    shortcut: ['', 'J'],
    keywords: ['dark', 'light'],
  },
  { id: 'billing', label: 'Billing', description: 'Plan and invoices', icon: 'i-lucide-credit-card', group: 'Settings' },
  { id: 'archive', label: 'Archive workspace', icon: 'i-lucide-archive', group: 'Settings', disabled: true },
])

function onSelect(item: GrCommandItem): void {
  lastCommand.value = item.label
  if (item.id === 'theme')
    toggleTheme()
}

// Сочетание, которое палитра только показывает, здесь работает по-настоящему:
// `v-hotkey` вешает глобальный слушатель (Meta — macOS, Ctrl — остальные).
const hotkeys = {
  'Meta+J': toggleTheme,
  'Ctrl+J': toggleTheme,
}
</script>

<template>
  <div v-hotkey="hotkeys" class="grid gap-4">
    <div class="flex items-center gap-3">
      <GrButton @click="open = true">
        Open palette
      </GrButton>
      <span class="text-sm text-[var(--gr-muted-fg)]">
        or press <GrKbd size="sm"></GrKbd> <GrKbd size="sm">K</GrKbd>
      </span>
    </div>

    <p class="text-sm text-[var(--gr-muted-fg)]">
      Last command: <code>{{ lastCommand ?? '—' }}</code> · theme: <code>{{ isDark ? 'dark' : 'light' }}</code>
      — try <GrKbd size="sm"></GrKbd> <GrKbd size="sm">J</GrKbd> without opening the palette.
    </p>

    <!-- `mod+k` на странице занят общим поиском витрины: два слушателя открывали бы
         сразу две палитры. Демо открывается кнопкой и своим ⌘J. -->
    <GrCommandPalette v-model="open" :items="commands" :hotkey="null" @select="onSelect">
      <template #footer>
        <span class="flex items-center gap-1"><GrKbd size="sm"></GrKbd><GrKbd size="sm"></GrKbd> to navigate</span>
        <span class="flex items-center gap-1"><GrKbd size="sm"></GrKbd> to run</span>
        <span class="flex items-center gap-1"><GrKbd size="sm">Esc</GrKbd> to close</span>
      </template>
    </GrCommandPalette>
  </div>
</template>

Поле ввода — role="combobox", список — role="listbox", активная команда указывается через aria-activedescendant: фокус не покидает поиск.

Удалённый поиск

:filterable="false" отдаёт фильтрацию наружу: палитра эмитит search, владелец подставляет результаты и loading. :hotkey="null" отключает глобальное сочетание.

Async
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrCommandPalette, type GrCommandItem } from '@feugene/granularity'

const open = ref(false)
const loading = ref(false)
const results = ref<GrCommandItem[]>([])

const catalog: GrCommandItem[] = [
  { id: 'u-1', label: 'Anna Kovalenko', description: 'Design · Berlin', icon: 'i-lucide-user', group: 'People' },
  { id: 'u-2', label: 'Mark Tarasov', description: 'Backend · Tbilisi', icon: 'i-lucide-user', group: 'People' },
  { id: 'p-1', label: 'Onboarding revamp', description: 'Project · in progress', icon: 'i-lucide-folder', group: 'Projects' },
  { id: 'p-2', label: 'Pricing page A/B', description: 'Project · planned', icon: 'i-lucide-folder', group: 'Projects' },
  { id: 'd-1', label: 'Q3 report.pdf', description: 'Document · 2.4 MB', icon: 'i-lucide-file-text', group: 'Documents' },
]

let searchTimer: ReturnType<typeof setTimeout> | null = null

// Имитация похода на сервер: палитра не фильтрует сама (`:filterable="false"`),
// список приходит снаружи.
function onSearch(query: string): void {
  if (searchTimer)
    clearTimeout(searchTimer)

  if (!query) {
    loading.value = false
    results.value = []
    return
  }

  loading.value = true
  searchTimer = setTimeout(() => {
    const needle = query.toLowerCase()
    results.value = catalog.filter(item =>
      item.label.toLowerCase().includes(needle) || item.description?.toLowerCase().includes(needle),
    )
    loading.value = false
  }, 600)
}
</script>

<template>
  <div class="grid gap-4">
    <GrButton variant="outline" @click="open = true">
      Search the workspace
    </GrButton>

    <GrCommandPalette
      v-model="open"
      :items="results"
      :filterable="false"
      :loading="loading"
      :hotkey="null"
      placeholder="Search people, projects, documents…"
      @search="onSearch"
    >
      <template #empty="{ query }">
        {{ query ? `Nothing found for “${query}”` : 'Start typing to search' }}
      </template>
    </GrCommandPalette>
  </div>
</template>

Недавние команды и подсветка совпадений

recentIds поднимает команды отдельной группой наверх — в порядке самого массива и без дублей ниже, — пока запрос пуст. С первой же буквой секция уступает место релевантности, а совпавшие фрагменты метки и описания подсвечиваются <mark> (цвет — переменная --gr-command-match-bg).

команда не выбрана
Недавние: theme, invite · начните печатать — секция уступит место результатам, а совпадения подсветятся

Recent
<script setup lang="ts">
import { ref } from 'vue'

import { GrBadge, GrButton, GrCommandPalette, type GrCommandItem } from '@feugene/granularity'

const open = ref(false)
const lastCommand = ref<string | null>(null)

// История выбора: последние три команды поднимаются наверх, пока запрос пуст.
const recentIds = ref<string[]>(['theme', 'invite'])

const commands: GrCommandItem[] = [
  { id: 'new-doc', label: 'New document', icon: 'i-lucide-file-plus', group: 'File', keywords: ['create'] },
  { id: 'open', label: 'Open…', description: 'Recent files', icon: 'i-lucide-folder-open', group: 'File' },
  { id: 'export', label: 'Export to PDF', icon: 'i-lucide-download', group: 'File' },
  { id: 'invite', label: 'Invite a teammate', icon: 'i-lucide-user-plus', group: 'Team' },
  { id: 'theme', label: 'Toggle theme', description: 'Dark or light', icon: 'i-lucide-moon', group: 'Settings' },
  { id: 'billing', label: 'Billing', description: 'Plan and invoices', icon: 'i-lucide-credit-card', group: 'Settings' },
]

function onSelect(item: GrCommandItem) {
  lastCommand.value = item.label
  recentIds.value = [item.id, ...recentIds.value.filter(id => id !== item.id)].slice(0, 3)
}
</script>

<template>
  <div class="grid gap-3">
    <div class="flex flex-wrap items-center gap-3">
      <GrButton variant="outline" @click="open = true">
        Открыть палитру
      </GrButton>
      <GrBadge size="sm">
        {{ lastCommand ?? 'команда не выбрана' }}
      </GrBadge>
    </div>

    <div class="text-xs text-[var(--gr-muted-fg)]">
      Недавние: {{ recentIds.join(', ') || '—' }} · начните печатать — секция уступит место
      результатам, а совпадения подсветятся
    </div>

    <GrCommandPalette
      v-model="open"
      :items="commands"
      :recent-ids="recentIds"
      hotkey=""
      @select="onSelect"
    />
  </div>
</template>

Палитра на 5 000 команд

С virtual в DOM живёт только окно вокруг вьюпорта; высоту окна задаёт maxHeight. Группы при этом сохраняются: если список прокручен внутрь группы, её обёртка всё равно создаётся и берёт имя через aria-label — заголовка в разметке в этот момент нет.

Last command:

Virtual
<script setup lang="ts">
import { ref } from 'vue'

import { GrButton, GrCommandPalette, type GrCommandItem } from '@feugene/granularity'

const open = ref(false)
const lastCommand = ref<string | null>(null)

// Сорок групп по сто двадцать пять команд. Группы переживают окно: если список
// прокручен внутрь группы, её обёртка всё равно есть и берёт имя через
// `aria-label` — заголовка в разметке в этот момент нет.
const commands: GrCommandItem[] = Array.from({ length: 40 }, (_, groupIndex) =>
  Array.from({ length: 125 }, (_, index) => ({
    id: `g${groupIndex + 1}-cmd-${index + 1}`,
    label: `Group ${groupIndex + 1} · Command ${index + 1}`,
    group: `Group ${groupIndex + 1}`,
  }))).flat()

function onSelect(item: GrCommandItem): void {
  lastCommand.value = item.label
}
</script>

<template>
  <div class="grid gap-4">
    <GrButton class="justify-self-start" @click="open = true">
      Open palette with 5 000 commands
    </GrButton>

    <p class="text-sm text-[var(--gr-muted-fg)]">
      Last command: <code>{{ lastCommand ?? '—' }}</code>
    </p>

    <!-- Хоткей выключен: `mod+k` принадлежит общему поиску витрины. -->
    <GrCommandPalette
      v-model="open"
      :items="commands"
      :hotkey="null"
      virtual
      :max-height="360"
      @select="onSelect"
    />
  </div>
</template>

aria-setsize/aria-posinset считаются по своей группе, а не по всему списку. Стрелки прокручивают список до активной команды прежде, чем перевести на неё aria-activedescendant: вне окна элемента в DOM нет.

Доступность

Паттерн APG
dialog + listbox
Клавиши
mod+K — открыть, / — по результатам, Home/End — к краям, Enter — выполнить, Esc — закрыть

Полный клавиатурный контракт пакета

Документация компонентаВсе компоненты