GrCommandPalette
Берут, когда команд много и они разбросаны по интерфейсу.
Когда брать
- команд много и они разбросаны по интерфейсу — палитра даёт к ним один вход вместо поиска по меню;
- пользователь работает клавиатурой —
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
| Prop | Type | по умолчанию | Описание |
|---|---|---|---|
filter | GrCommandFilter | undefined | undefined | Кастомный матчер локальной фильтрации. |
size | "sm" | "md" | "lg" | "xl" | "full" | undefined | undefined | — |
placeholder | string | undefined | undefined | — |
ariaLabel | string | undefined | undefined | — |
loading | boolean | undefined | false | Внешне управляемое состояние загрузки (для remote-поиска). |
filterable | boolean | undefined | true | Локальная фильтрация по запросу. `false` — фильтрует владелец по событию `search`. |
closeOnSelect | boolean | undefined | true | Закрывать палитру после выбора команды. |
virtual | boolean | undefined | false | Виртуализация списка: в DOM живёт только окно вокруг вьюпорта. Высоту окна задаёт `maxHeight`. Включается осознанно: на сотне команд выигрыша нет, а в разметке остаётся только окно — вместе с ним меняется и то, что находит `querySelector` потребителя. Профильный сценарий — палитра приложения на тысячи команд. |
items | GrCommandItem[] | undefined | undefined | Плоский список команд; группировка — по полю `group` самой команды. |
emptyText | string | undefined | undefined | — |
recentIds | string[] | undefined | undefined | Недавние команды: пока запрос пуст, они поднимаются отдельной группой наверх в этом порядке и не дублируются ниже. |
hotkey | string | null | undefined | "mod+k" | Глобальное сочетание открытия. `null` — не вешать слушатель. |
maxHeight | number | undefined | 360 | Максимальная высота списка, px. |
showHotkeyHint | boolean | undefined | true | Показывать подсказку сочетания в поле ввода. |
modelValueобязательный | boolean | — | Открыта ли палитра. |
Slots
| Slot | Type | Описание |
|---|---|---|
item | { item: GrCommandItem; active: boolean; } | Строка списка вместо стандартной. |
empty | { query: string; } | Пустое состояние: получает запрос, чтобы предложить действие по нему. |
footer | any | Подвал палитры: подсказки по клавишам, счётчик. |
Events
| Event | Type | Описание |
|---|---|---|
update:modelValue | [value: boolean] | — |
search | [query: string] | — |
select | [item: GrCommandItem] | — |
Methods / Expose
| Methods / Expose | Type | Описание |
|---|---|---|
open | () => void | — |
close | () => void | — |
toggle | () => void | — |
Примеры 4
Команды с группами и сочетаниями клавиш
Палитра открывается по ⌘K (Ctrl+K вне macOS) или программно через v-model. Команды группируются полем group, ищутся по метке, описанию и keywords. Команда «Toggle theme» здесь настоящая: переключает тему через useTheme(), а её сочетание ⌘J повешено директивой v-hotkey — работает и без открытия палитры.
Last command: — · theme: light — try ⌘ J without opening the palette.
<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" отключает глобальное сочетание.
<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).
<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: —
<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— закрыть