GrCommandPalette

Package: @feugene/granularitycoreGroup: navigation

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 keyboardhotkey opens 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 asynchronoussource loads items on request, virtual holds a long list.

When to take something else

NeedTake
A form field value is being selectedGrSelect
An object is searched for with loadingGrAutocomplete
The actions belong to a single objectGrDropdownMenu
A catalogue of dashboard widgetsGrDashboardPalette
A search across the content of a page rather than commandsGrInput

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…

Code
<GrCommandPalette />

Install

npm i @feugene/granularity

Import

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

API

Props

PropTypedefaultDescription
filterGrCommandFilter | undefinedundefinedA matcher of your own for the local filtering.
size"sm" | "md" | "lg" | "xl" | "full" | undefinedundefined
placeholderstring | undefinedundefined
ariaLabelstring | undefinedundefined
loadingboolean | undefinedfalseThe loading state governed from the outside (for a remote search).
filterableboolean | undefinedtrueLocal filtering by the query. `false` — the owner filters on the `search` event.
closeOnSelectboolean | undefinedtrueClose the palette after a command is chosen.
virtualboolean | undefinedfalseVirtualisation 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.
itemsGrCommandItem[] | undefinedundefinedA flat list of commands; the grouping comes from the `group` field of a command itself.
emptyTextstring | undefinedundefined
recentIdsstring[] | undefinedundefinedThe 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.
hotkeystring | null | undefined"mod+k"The global combination that opens it. `null` — do not hang a listener.
maxHeightnumber | undefined360The maximum height of the list, in px.
showHotkeyHintboolean | undefinedtrueWhether to show the hint of the combination in the input field.
modelValuerequiredbooleanWhether the palette is open.

Slots

SlotTypeDescription
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.
footeranyThe footer of the palette: key hints, a counter.

Events

EventTypeDescription
update:modelValue[value: boolean]
search[query: string]
select[item: GrCommandItem]

Methods / Expose

Methods / ExposeTypeDescription
open() => void
close() => void
toggle() => void

Examples 4

Commands with groups and shortcuts

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>

Remote search

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>

Recent commands and match highlighting

команда не выбрана
Недавние: 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>

Virtual

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>

Accessibility

APG pattern
dialog + listbox

Full keyboard contract of the package

Component documentationAll components