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

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.

The palette
<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.

Searching a server
Requests: 0 sent, 0 aborted
<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.

Events
  • 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