GrCommandPalette

  • core
  • navigation
  • API · 16 props
  • ⌨ keys

Opens a ⌘K search over app commands with groups and shortcuts.

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

Overview

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 — hotkey 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 asynchronous — source 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.

Playground controls come from the API, not from hand

Stage

This component lives above the screen, not inside a canvas: a screen opens it, and the playground has nothing to open it from. The examples below show it at work.

0 changed· state is in the URL

Examples 3 each with a live demo and its code

  • The command bar of an app

    Open it with the trigger or ⌘J — hotkey hangs a global shortcut, and the trigger says so. The list is flat: group makes the sections, shortcut draws the keys, description gives the context, keywords let «vat» find Billing settings, and a disabled command stays visible with its reason. recent-ids bring the last commands back on top; the page keeps the history.

    Acme Billing
    INV-0418 · Northwind Traders€1,284.50 · due 30 Sep · 6 days overdue
  • Customers searched on the server

    Twelve thousand customers do not fit in the browser. filterable="false" hands filtering to the owner: the query comes out with search, loading shows the wait, and the palette renders what came back. The item slot draws a row of its own, and the empty slot turns «nothing found» into «create it».

    Customers12,408 accounts · searched on the server
    Try «north» or «contoso».
  • A workspace switcher with its own filter

    A custom filter finds «wwi» by initials as well as by any part of the name. recent-ids keep the last two workspaces on top while the query is empty, size="sm" makes the palette compact, and the footer slot keeps the keys where the hands are.

    WorkspaceNorthwind Traders

API generated from the package · cannot drift

Props

PropTypedefaultDescription
filterGrCommandFilter | undefinedundefinedA matcher of your own for the local filtering.
size"sm" | "md" | "lg" | "xl" | "full" | undefinedundefinedThe width of the window on the `GrModal` panel scale. `lg` by default; taken from `GrConfigProvider`.
placeholderstring | undefinedundefinedA hint in the search field. By default — a locale string.
ariaLabelstring | undefinedundefinedThe accessible name of the palette and the search field. By default — a locale string.
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 | undefinedundefinedThe text of an empty result. The `#empty` slot is stronger; by default — a locale string.
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.
hotkeyIdstring | undefinedundefinedThe identifier of an entry in the registry (`granularityHotkeysPlugin`) instead of the combination as a string: the hint and the binding are taken from one source and cannot diverge. Stronger than `hotkey`.
maxHeightnumber | undefined360The maximum height of the list, in px.

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

EventSignatureDescription
update:modelValue(event: "update:modelValue", value: boolean): void—
search(event: "search", query: string): void—
select(event: "select", item: GrCommandItem): void—

Methods

MethodsSignatureDescription
open() => voidOpen the palette — for a button in the header or a menu item next to the shortcut.
close() => voidClose the palette.
toggle() => voidToggle the palette — the same as the global shortcut.

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrCommandItem, GrCommandGroup, GrCommandFilter } from '@feugene/granularity/components/GrCommandPalette'

Filedist/types/components/GrCommandPalette/config.d.ts

grCommandPaletteConfigconst
const grCommandPaletteConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrCommandPalette">;

Filedist/types/components/GrCommandPalette/filtering.d.ts

GrCommandItemtype
type GrCommandItem = {
    id: string;
    label: string;
    description?: string;
    icon?: string | Component;
    shortcut?: string[];
    group?: string;
    keywords?: string[];
    disabled?: boolean;
};
GrCommandGrouptype
type GrCommandGroup = {
    name: string | undefined;
    items: GrCommandItem[];
};
GrCommandFiltertype
type GrCommandFilter = (item: GrCommandItem, query: string) => boolean;
export declare function matchCommandItem(item: GrCommandItem, query: string): boolean;
export declare function filterCommandItems(items: GrCommandItem[], query: string, filter?: GrCommandFilter): GrCommandItem[];
export declare function groupCommandItems(items: GrCommandItem[]): GrCommandGroup[];
export type GrCommandMatchSegment = {

Filedist/types/components/GrCommandPalette/GrCommandPalette.vue.d.ts

GrCommandPalettePropsinterface
interface GrCommandPaletteProps {
    modelValue: boolean;
    items?: GrCommandItem[];
    recentIds?: string[];
    placeholder?: string;
    size?: GrCommandPaletteSize;
    hotkey?: string | null;
    hotkeyId?: string;
    filterable?: boolean;
    filter?: GrCommandFilter;
    loading?: boolean;
    closeOnSelect?: boolean;
    maxHeight?: number;
    virtual?: boolean;
    showHotkeyHint?: boolean;
    emptyText?: string;
    ariaLabel?: string;
}
GrCommandPaletteEmitsinterface
interface GrCommandPaletteEmits {
    (e: 'update:modelValue', value: boolean): void;
    (e: 'select', item: GrCommandItem): void;
    (e: 'search', query: string): void;
}

Filedist/types/components/GrCommandPalette/grCommandPaletteStyles.d.ts

GrCommandPaletteSizetype
type GrCommandPaletteSize = GrOverlaySize;

Filedist/types/components/GrCommandPalette/safelist.d.ts

grCommandPaletteSafelistconst
const grCommandPaletteSafelist: string[];

Filedist/types/components/GrCommandPalette/index.d.ts

GrCommandPaletteInstancetype
type GrCommandPaletteInstance = ComponentExposed<typeof GrCommandPaletteComponent>;

Accessibility

APG pattern

dialog + listbox

Keys

mod+K↑ / ↓Home / EndEnter

Usage from the library prose

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 (i-lucide-* — the application sets up the rule for it: in the engine factory or through a provider, otherwise a Vue component; 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.

An empty list takes up no space: the message sits right below the field, and the role="listbox" itself stays in the DOM — the field’s aria-controls points to it. To keep the window from collapsing between “searching” and “found”, reserve the height of the results area, not of the list:

css
[data-gr-command-palette] {
  --gr-command-results-min-height: 14rem;
}

Then “Nothing found”, the loading state and the #empty slot sit in the centre of the reserve. A reserve on the list itself (min-height on [data-gr-command-palette-list]) would push the message below an empty strip.

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.

vue
<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).