Command Palette
A Cmd/Ctrl+K launcher - every action and page a few keystrokes away, with fuzzy search, nested pages, async results and recents
Overview
E.commandPalette() builds a search box over a list of commands. Type a few letters -
in order, anywhere in the name - and press Enter. Commands can run code, follow a link, or open a
nested page of more commands. Press Ctrl K anywhere on this page.
<code>E.commandPalette()</code> builds a search box over a list of commands. Type a few letters -
in order, anywhere in the name - and press Enter. Commands can run code, follow a link, or open a
nested page of more commands. Press <kbd id="hotkey-hint">Ctrl K</kbd> anywhere on this page./**
* Overview - the hotkey hint in the platform's own key names (the palette below owns the hotkey).
*/
export function mount(root) {
$(root).find('#hotkey-hint').text(E.commandPalette.formatShortcut('mod+k').join(' '));
}Key Features
- Fuzzy search - "chth" finds "Change theme"; matched letters are highlighted (always as text, never HTML)
- Groups and recents - headings per group; recently run commands first, remembered with
S - Nested pages - from an array or an async function; Backspace or Escape goes back up
- Async source - debounced, aborted when the query changes, with loading, empty and error states
- Keyboard first - arrows, Home/End, Enter, Escape; Tab stays inside (a focus trap)
- Accessible - the ARIA combobox + listbox pattern, with the result count announced
Basic Usage
const palette = E.commandPalette({
commands: [
{id: 'new', title: 'New page', group: 'Pages', icon: 'plus', shortcut: 'mod+n', action: newPage},
{id: 'docs', title: 'Documentation', href: '/docs/', keywords: ['help']}
]
});
// Cmd+K on a Mac, Ctrl+K elsewhere - or palette.open()
The palette
Open it with the button or the hotkey. Try "toast", "chth" (then Enter for the nested theme page), "pop" for a link, or the Repositories page, which loads its commands asynchronously. Run a command or two and open the palette again: they appear under Recent.
<div class="sx-cp-row mb-4">
<button id="open-main" class="sx-cp-launch" type="button" aria-keyshortcuts="Control+K">
<span data-icon="search" data-icon-size="16" aria-hidden="true"></span>
<span>Search commands…</span>
<kbd id="launch-kbd">Ctrl K</kbd>
</button>
<button id="clear-recent" class="btn btn-sm btn-ghost" type="button">Forget recents</button>
</div>/**
* The palette - commands, a nested page, an async page, links and recents. Owns Ctrl/Cmd+K.
*/
export function mount(root) {
const timers = new Set();
// A pretend request: resolves after `ms`, or rejects when the signal aborts.
const later = (ms, value, signal) => new Promise((resolve, reject) => {
const timer = setTimeout(() => {
timers.delete(timer);
resolve(value);
}, ms);
timers.add(timer);
signal?.addEventListener('abort', () => {
clearTimeout(timer);
timers.delete(timer);
reject(new DOMException('Aborted', 'AbortError'));
});
});
const repo = (name, subtitle) => ({
id: 'repo:' + name, title: name, subtitle, icon: 'box',
action: () => E.toast(name, {type: 'info'})
});
const pages = ['Modal', 'Popover', 'Slideover', 'Tooltip', 'Focus Trap', 'Toast'];
const palette = E.commandPalette({
recentKey: 'showcase-palette',
commands: [
{id: 'toast-ok', title: 'Show a success toast', group: 'Actions', icon: 'check',
shortcut: 'mod+shift+s', action: () => E.toast('Saved', {type: 'success'})},
{id: 'toast-info', title: 'Show an info toast', group: 'Actions', icon: 'info',
keywords: ['message', 'notify'], action: () => E.toast('Just so you know', {type: 'info'})},
{id: 'copy-link', title: 'Copy link to this page', group: 'Actions', icon: 'link', shortcut: 'mod+l',
action: () => E.toast('Link copied (pretend)', {type: 'success'})},
{id: 'count', title: 'Count, and stay open', subtitle: 'keepOpen: true', group: 'Actions', icon: 'plus',
keepOpen: true, action: (command) => {
command.times = (command.times || 0) + 1;
command.subtitle = 'Pressed ' + command.times + (command.times === 1 ? ' time' : ' times');
}},
{id: 'danger', title: 'Delete everything', subtitle: 'Refused by beforeselect', group: 'Actions',
icon: 'trash'},
{id: 'disabled', title: 'Upgrade plan', subtitle: 'disabled: true', group: 'Actions', icon: 'star',
disabled: true},
// A nested page from an array.
{id: 'theme', title: 'Change theme…', group: 'Appearance', icon: 'droplet',
keywords: ['colour', 'dark'], placeholder: 'Pick a theme…',
children: Domma.theme.listThemes().map((t) => ({
id: 'theme:' + t,
title: t,
group: /-dark$/.test(t) ? 'Dark' : 'Light',
icon: /-dark$/.test(t) ? 'moon' : 'sun',
action: () => Domma.theme.set(t)
}))},
// A nested page loaded when it is opened.
{id: 'repos', title: 'Repositories…', subtitle: 'Loaded when you open it', group: 'Go to',
icon: 'git-branch',
children: (command, {signal}) => later(600, [
repo('domma', 'The framework'),
repo('domma-reactive', 'Reactivity'),
repo('domma-celebrate', 'Seasonal effects')
], signal)},
// Links.
...pages.map((name) => ({
id: 'go:' + name,
title: name,
subtitle: 'Showcase page',
group: 'Go to',
icon: 'external-link',
href: '/showcase/elements/' + name.toLowerCase().replace(/\s+/g, '-')
}))
],
onBeforeSelect: (command) => {
if (command.id === 'danger') {
E.toast('Not today', {type: 'warning'});
return false;
}
}
});
// The hint shows the platform's own key names.
$(root).find('#launch-kbd').text(E.commandPalette.formatShortcut('mod+k').join(' '));
$(root).find('#open-main').on('click', () => palette.open());
$(root).find('#clear-recent').on('click', () => {
palette.clearRecent();
E.toast('Recents forgotten', {type: 'info'});
});
return () => {
timers.forEach(clearTimeout);
palette.destroy();
};
}const palette = E.commandPalette({
recentKey: 'showcase-palette',
commands: [
{id: 'toast-ok', title: 'Show a success toast', group: 'Actions', icon: 'check',
shortcut: 'mod+shift+s', action: () => E.toast('Saved', {type: 'success'})},
{id: 'theme', title: 'Change theme…', group: 'Appearance', icon: 'droplet',
children: Domma.theme.listThemes().map(t => ({
id: 'theme:' + t, title: t, action: () => Domma.theme.set(t)
}))},
{id: 'repos', title: 'Repositories…', group: 'Go to', icon: 'git-branch',
children: async (command, {signal}) => loadRepos(signal)},
{id: 'go-popover', title: 'Popover', subtitle: 'Showcase page', group: 'Go to',
icon: 'external-link', href: '/showcase/elements/popover'}
],
onBeforeSelect: (command) => {
if (command.id === 'danger') {
E.toast('Not today', {type: 'warning'});
return false; // refuse it
}
}
});
$('#open-main').on('click', () => palette.open());
// teardown: palette.destroy()
Searching a server
With source(query, {signal}) the palette asks somewhere else. Here a pretend people directory answers after 400 ms. Each keystroke waits out the debounce before asking, and a new query aborts the request still running through signal - the counters show it. Tick the box to make the "server" fail and see the error state; search for something nobody is called to see the empty state.
<div class="sx-cp-row mb-4">
<button id="open-people" class="btn btn-primary" type="button">Find a person</button>
<label class="sx-cp-row text-sm">
<input id="people-fail" type="checkbox"> Make the server fail
</label>
<span class="text-sm text-gray-600">
Requests: <strong id="people-sent">0</strong> sent,
<strong id="people-aborted">0</strong> aborted
</span>
</div>/**
* Searching a server - source(query, {signal}) with debounce, aborts, empty and error states.
*/
export function mount(root) {
const directory = ['Ada Lovelace', 'Alan Turing', 'Grace Hopper', 'Katherine Johnson', 'Margaret Hamilton',
'Tim Berners-Lee', 'Barbara Liskov', 'Donald Knuth', 'Edsger Dijkstra', 'Frances Allen', 'Radia Perlman',
'Ken Thompson', 'Dennis Ritchie', 'Hedy Lamarr', 'Sophie Wilson'];
const timers = new Set();
const counts = {sent: 0, aborted: 0};
const show = () => {
$(root).find('#people-sent').text(String(counts.sent));
$(root).find('#people-aborted').text(String(counts.aborted));
};
// The best fuzzy matches, as commands.
const search = (query) => directory
.map((name) => ({name, match: E.commandPalette.fuzzyMatch(query, name)}))
.filter((r) => r.match)
.sort((a, b) => b.match.score - a.match.score)
.slice(0, 8)
.map((r) => ({
id: 'person:' + r.name,
title: r.name,
subtitle: r.name.toLowerCase().replace(/[^a-z]+/g, '.') + '@example.org',
icon: 'user',
action: () => E.toast('Messaging ' + r.name, {type: 'success'})
}));
// A pretend server: answers after 400 ms, unless the query changes first.
const ask = (query, signal, fail) => new Promise((resolve, reject) => {
counts.sent++;
show();
const timer = setTimeout(() => {
timers.delete(timer);
if (fail) reject(new Error('Server unavailable'));
else resolve(search(query));
}, 400);
timers.add(timer);
signal.addEventListener('abort', () => {
if (!timers.delete(timer)) return; // already answered
clearTimeout(timer);
counts.aborted++;
show();
reject(new DOMException('Aborted', 'AbortError'));
});
});
const people = E.commandPalette({
hotkey: false,
label: 'Find a person',
placeholder: 'Search people…',
debounce: 200,
emptyText: 'Nobody by that name',
errorText: 'The directory did not answer. Try again.',
sourceGroup: 'People',
source: (query, {signal}) => ask(query.trim(), signal, $(root).find('#people-fail').prop('checked'))
});
$(root).find('#open-people').on('click', () => people.open());
return () => {
timers.forEach(clearTimeout);
people.destroy();
};
}const people = E.commandPalette({
hotkey: false,
placeholder: 'Search people…',
debounce: 200,
source: (query, {signal}) => new Promise((resolve, reject) => {
const timer = setTimeout(() => resolve(search(query)), 400);
signal.onabort = () => {
clearTimeout(timer);
reject(new DOMException('Aborted', 'AbortError'));
};
})
// in a real page: (query, {signal}) => H.get('/api/people?q=' + encodeURIComponent(query), {signal})
});
Keyboard
| Key | Does |
|---|---|
| Ctrl K / ⌘ K | Open or close (the hotkey option; false for none) |
| ↑ ↓ | Move through the results, wrapping, skipping disabled ones |
| Home End | First and last result |
| Enter | Run the highlighted command, or open its page |
| Backspace | In an empty box: up one page |
| Esc | Up one page; at the top, close |
| Tab | Stays inside the palette |
Shortcuts on commands (shortcut: 'mod+s') are shown as hints in the platform's own names and given to screen readers as aria-keyshortcuts; binding them is up to you.
Events
beforeopen, open, beforeclose, close, query, beforeselect (cancelable), select and run - as onX options, .on('x') listeners and bubbling dm:command-palette:x DOM events (detail = {instance, args}). Return false from beforeopen, beforeclose or beforeselect to refuse: the Delete everything command above is refused by onBeforeSelect. This log is one document listener, so it hears both palettes.
- Nothing yet - use the palettes above.
<ul id="demo-log" class="sx-cp-log mb-4">
<li>Nothing yet - use the palettes above.</li>
</ul>/**
* Events - one document listener logs every palette on the page.
*/
export function mount(root) {
const list = $(root).find('#demo-log');
const EVENTS = 'dm:command-palette:open dm:command-palette:close dm:command-palette:select dm:command-palette:run';
const log = (message) => {
list.find('li').filter((i, el) => /^Nothing yet/.test(el.textContent)).remove();
list.prepend($('<li>').text(D().format('HH:mm:ss') + ' - ' + message));
list.find('li').filter((i) => i >= 8).remove();
};
// detail = {instance, args}; select and run pass the command first.
const onEvent = (e) => {
const name = e.type.split(':').pop();
const command = e.detail.args[0];
log(name + (command && command.title ? ' "' + command.title + '"' : ''));
};
$(document).on(EVENTS, onEvent);
return () => $(document).off(EVENTS, onEvent);
}const EVENTS = 'dm:command-palette:open dm:command-palette:close dm:command-palette:select dm:command-palette:run';
const onEvent = (e) => {
const command = e.detail.args[0]; // select and run: the command
log(e.type.split(':').pop() + (command.title ? ' "' + command.title + '"' : ''));
};
$(document).on(EVENTS, onEvent);
// teardown: $(document).off(EVENTS, onEvent)
palette.on('run', (command) => console.log(command.id)); // or on one palette
API
const palette = E.commandPalette({
commands: [], // {id, title, subtitle, group, icon, keywords, shortcut, href, target,
// action(command, palette, event), disabled, hidden, keepOpen, children}
source: null, // (query, {signal}) => Promise<commands>
debounce: 150,
hotkey: 'mod+k', // or ['mod+k', '/'], or false
placeholder: 'Type a command or search…',
label: 'Command palette',
sourceGroup: null, // heading for source results without a group
maxResults: 50,
recentKey: null, // S key; null = no recents
recentLimit: 5,
emptyText, loadingText, errorText, footer: true,
navigate: null, // (href, {newTab, command}) => void
onOpen, onClose, onQuery, onBeforeSelect, onSelect, onRun
});
palette.open('query'); palette.close(); palette.toggle(); palette.isOpen();
palette.setCommands(list); palette.addCommands(list); palette.removeCommand('id'); palette.getCommands();
palette.back(); palette.query(); palette.recent(); palette.clearRecent(); palette.destroy();
E.commandPalette.formatShortcut('mod+k'); // ['Ctrl', 'K'] - or ['⌘', 'K'] on a Mac
E.commandPalette.fuzzyMatch('chth', 'Change theme'); // {score, indices} or null
E.commandPalette.claims(event); // true when a palette's hotkey owns this keydown