GrCommandPalette
Opens a ⌘K search over app commands with groups and shortcuts.
Machine-translated from the Russian original, not yet reviewed. Read the original
When to take it
- there are many commands and they are scattered across the interface — the palette gives one way in instead of a search through the menus;
- the user works with the keyboard —
hotkeyopens the palette from anywhere, and everything after that is done with the arrows; - the commands are grouped — sections, recents (
recentIds) and shortcut hints are already there; - the source is asynchronous —
sourceloads items on request,virtualholds a long list.
When to take something else
| Need | Take |
|---|---|
| A form field value is being selected | GrSelect |
| An object is searched for with loading | GrAutocomplete |
| The actions belong to a single object | GrDropdownMenu |
| A catalogue of dashboard widgets | GrDashboardPalette |
| A search across the content of a page rather than commands | GrInput |
The palette does not replace navigation: it speeds up the work of someone who already knows what they are looking for. It cannot be the only way to reach a section — a new user will not guess to press a shortcut.
The commands
The model is flat: id, label, the optional description, icon, shortcut,
group, keywords, disabled. icon is a Vue component or an icon class from your
UnoCSS build (see “Icons”). The groups are assembled
from group in the order of first appearance;
commands without a group stay a nameless group in their place.
id is required and has to be unique: it is also the render key and the target of
aria-activedescendant. Duplicates give identical DOM ids, and the focus moves to
the wrong command — in a dev build the component warns about that.
The filter and the highlighting
By default a substring (case-insensitive) is matched in the label, the description,
the name of the group and keywords. The matched fragment of the label and the
description is highlighted with <mark>; the colour is set by the
--gr-command-match-bg variable.
A filter of your own may match by anything at all — for instance by keywords
only. Then there is no match in the label and no highlighting appears: there is
nothing to highlight.
filterable="false" hands the filtering outside (a remote search): the component
shows what has arrived and gives the query away with a search event.
The recents
recentIds raises commands to the top as a group of their own — in the order of the
array itself, not of the list of commands. They are removed from the rest of the
list: one command cannot occur twice, it has a single id.
The section lives only while the query is empty. With a non-empty query relevance rules: history would draw the eye to the wrong place from the very first letter.
The states
Loading and “nothing found” are shown by a single live region (role="status",
aria-live="polite") outside the list. The spinner icon is decorative in the
process: an aria-label on an element without a role is ignored by most AT, and
before that the loading state was not announced at all.
Inside role="listbox" the only direct children remain role="group" — the heading
of a group lies inside it and is declared presentational, and it gives the group its
name through aria-labelledby. Otherwise aria-required-children breaks, and the
panel here is always expanded, that is, this is the main state.
Virtualisation
virtual keeps in the DOM only the window around the viewport; the height of the
window is set by maxHeight. The scenario it is meant for is an application palette
with thousands of commands.
<GrCommandPalette v-model="open" :items="commands" virtual :max-height="360" />
The groups survive the window. If the list is scrolled into the middle of a
group, its heading is no longer in the markup — the role="group" wrapper is created
all the same and takes its name through aria-label instead of aria-labelledby.
The set is counted per group. With virtual the commands carry
aria-setsize/aria-posinset, and that is the size of their group rather than of
the whole palette — that is what ARIA requires. In the ordinary mode the attributes
are absent: there the set is visible in the DOM.
The active command is always mounted. The arrows scroll the list to it before
moving aria-activedescendant. How the primitive works —
virtual-list.md.
The keyboard and the hotkey
↑/↓ walk the commands (disabled ones are skipped, the walk is a ring),
Home/End go to the edges, Enter runs, Esc closes. The order of the walk
matches the order on the screen, the recents included.
hotkey (mod+k by default) hangs a global shortcut; mod is ⌘ on Apple and Ctrl
everywhere else. The platform is detected after mounting: there is no navigator
on the server, and the first client render has to match the server one, otherwise the
hint shifts during hydration.
The imperative API
open(), close() and toggle() through a ref on the component — the same set as
in the other overlays. They are needed where the palette is opened by something other
than its hotkey: a “Find a command” menu item, a button in the header, a link from
the onboarding.
The palette is controlled, so the methods emit update:modelValue — the state stays
in the parent’s v-model (more on that in GrModal.md).
Playground 11
Loading…
<GrCommandPalette />Install
npm i @feugene/granularityImport
import { GrCommandPalette } from '@feugene/granularity/components/GrCommandPalette'API
Props
| Prop | Type | default | Description |
|---|---|---|---|
filter | GrCommandFilter | undefined | undefined | A matcher of your own for the local filtering. |
size | "sm" | "md" | "lg" | "xl" | "full" | undefined | undefined | — |
placeholder | string | undefined | undefined | — |
ariaLabel | string | undefined | undefined | — |
loading | boolean | undefined | false | The loading state governed from the outside (for a remote search). |
filterable | boolean | undefined | true | Local filtering by the query. `false` — the owner filters on the `search` event. |
closeOnSelect | boolean | undefined | true | Close the palette after a command is chosen. |
virtual | boolean | undefined | false | Virtualisation of the list: only the window around the viewport lives in the DOM. The height of the window is set by `maxHeight`. Switch it on deliberately: on a hundred commands there is no gain, while only the window is left in the markup — and with it changes what the consumer’s `querySelector` finds. The scenario it is meant for is an application palette of thousands of commands. |
items | GrCommandItem[] | undefined | undefined | A flat list of commands; the grouping comes from the `group` field of a command itself. |
emptyText | string | undefined | undefined | — |
recentIds | string[] | undefined | undefined | The recent commands: while the query is empty they are raised to the top as a group of their own in this order and are not duplicated below. |
hotkey | string | null | undefined | "mod+k" | The global combination that opens it. `null` — do not hang a listener. |
maxHeight | number | undefined | 360 | The maximum height of the list, in px. |
showHotkeyHint | boolean | undefined | true | Whether to show the hint of the combination in the input field. |
modelValuerequired | boolean | — | Whether the palette is open. |
Slots
| Slot | Type | Description |
|---|---|---|
item | { item: GrCommandItem; active: boolean; } | A row of the list instead of the standard one. |
empty | { query: string; } | The empty state: it receives the query, so that an action can be offered for it. |
footer | any | The footer of the palette: key hints, a counter. |
Events
| Event | Type | Description |
|---|---|---|
update:modelValue | [value: boolean] | — |
search | [query: string] | — |
select | [item: GrCommandItem] | — |
Methods / Expose
| Methods / Expose | Type | Description |
|---|---|---|
open | () => void | — |
close | () => void | — |
toggle | () => void | — |
Examples 4
Commands with groups and shortcuts
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>Remote search
<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>Recent commands and match highlighting
<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>Virtual
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>Accessibility
- APG pattern
dialog + listbox