Command palette
Summary
The Ctrl/⌘+K command palette:
a modal search field over a registry of commands, the
GitHub/Linear pattern. Soma.palette() renders its
own DOM (there is nothing to author) as a native
<dialog> shown in the top layer, exactly
like dialogs, and follows the APG
combobox pattern: focus stays in the input and the highlighted
option is conveyed via
aria-activedescendant. Choosing a command hides
the palette, fires the bubbling
soma-palette-run event and calls the command's
run().
When to use
| Situation | Guidance |
|---|---|
| Power-user access to actions scattered across the app | The home turf: one searchable entry point for navigation and commands, without hunting through menus. |
| Searching content (pages, records, people) | Use quicksearch, an inline, non-modal field where you render the results. |
| A handful of fixed key bindings | Use Soma.shortcuts directly; the palette is worth it once commands need discovery. The two compose — a command's kbd hint can advertise its dedicated shortcut. |
| Choosing a value for a form field | Use select. The palette runs actions, it doesn't hold a value. |
Examples
Filtering and sections
Filtering is a case-insensitive substring
match over each command's title and
keywords (no fuzzy ranking, so results are
predictable). Matches keep registration order and regroup under
their section headings; a section with no matches
drops its heading, and unsectioned commands list first. When
nothing matches, the palette shows the localised
palette.empty string (or your
emptyText). Every open starts from an empty query
and the full list, with the first command pre-highlighted so
Enter always has a target.
Keyboard
| Key | Action |
|---|---|
| Ctrl/⌘+K | Opens the palette, from anywhere except a typing context (see The global shortcut below). |
| Typing | Re-filters on every keystroke; the first match is pre-highlighted. |
| ↓ / ↑ | Move the highlight, wrapping at either end. The pointer re-anchors the same single highlight on hover. |
| Enter | Runs the highlighted command and hides the palette. |
| Esc | Hides without running (the platform's close request on the native dialog) — as does a click on the backdrop. |
| Tab | Contained natively: the page behind the modal is inert, and the input is the palette's only tab stop, so focus never escapes. |
Top layer, focus and ARIA
The palette is a native <dialog> opened with
showModal(), like
dialog2: the platform puts it in the
top layer with its ::backdrop, makes the page
behind it inert (focus containment without a JS trap), routes
Esc to it, and restores focus to the previously focused
element on close, so a command that acts on the page finds
focus back where the user left it. The dialog role and
modality are native (no aria-modal bookkeeping);
the element is labelled from the palette.label
catalog string. Inside, the input is a
role="combobox" over the always-visible
role="listbox"; focus stays in the input the
whole time and the highlighted option is exposed via
aria-activedescendant + aria-selected
(the APG pattern). Sections are role="group"
wrappers labelled by a presentational heading. While the
palette is open, other Soma.shortcuts bindings are
suppressed, exactly as with an open modal dialog.
The global shortcut
Each instance binds ctrl+k and meta+k
through the shared Soma.shortcuts
registry, so registry semantics apply: the combo never fires
while an input, textarea, select or contenteditable has focus,
nor while a modal is open. Create one palette per
page — a second instance would race it for the combo;
pass shortcut: false to auxiliary palettes (or to
take over the binding yourself) and open them with
.show():
const aux = Soma.palette({ shortcut: false, commands: […] });
document.querySelector('#open-aux').addEventListener('click', () => aux.show());
HTML
Nothing to author — the component builds and appends its DOM to
<body> on first show. The generated anatomy,
for styling and testing reference:
<!-- a native <dialog>: role/modality are implicit, the open attribute
(managed by the platform via showModal/close) is the state -->
<dialog class="soma-palette" aria-label="Command palette" open>
<div class="soma-palette-field">
<span class="soma-icon soma-icon-search"></span>
<input class="soma-palette-input" role="combobox" aria-expanded="true"
aria-controls="soma-palette-list-1" aria-autocomplete="list"
placeholder="Type a command…" />
</div>
<div class="soma-palette-list" id="soma-palette-list-1" role="listbox">
<div class="soma-palette-item soma-active" role="option" aria-selected="true">
<span class="soma-palette-item-title">Open keyboard help</span>
</div>
<div class="soma-palette-group" role="group" aria-labelledby="soma-palette-section-1">
<div class="soma-palette-section" role="presentation"
id="soma-palette-section-1">Navigate</div>
<div class="soma-palette-item" role="option" aria-selected="false">
<span class="soma-palette-item-title">Go to dashboard</span>
<kbd class="soma-palette-kbd">G D</kbd>
</div>
</div>
<!-- or, when nothing matches: -->
<div class="soma-palette-empty">No matching commands</div>
</div>
</dialog>
CSS classes
| Class | Effect |
|---|---|
.soma-palette | The modal card, a native <dialog> in the top layer with its ::backdrop. Solid surface, top-anchored at 15vh, 560px wide, fade-and-rise enter transition (off under reduced motion); hidden by the platform while closed. |
.soma-palette-field | The inset search row wrapping icon + input. |
.soma-palette-input | The combobox input. |
.soma-palette-list | The listbox. Scrolls when the results outgrow the card. |
.soma-palette-group / .soma-palette-section | A section wrapper and its small-caps heading. |
.soma-palette-item | A command row (row height follows the density tokens); .soma-active marks the single keyboard/pointer highlight. |
.soma-palette-item-title | The row text; truncates with an ellipsis. |
.soma-palette-kbd | The end-aligned mono shortcut hint. |
.soma-palette-empty | The "no matching commands" row. |
JavaScript
Constructor and options
| Member | Description |
|---|---|
Soma.palette(options?) | Build a palette. A factory, not a singleton — every call is a new, independent instance (there is no element to key one on); create one per page. No auto-init: palettes are created, not declared. |
commands | Array of command objects (shape below). Default []. Commands can arrive later via register(). |
placeholder | Input placeholder. Default: the localised palette.placeholder ("Type a command…"). |
emptyText | The no-matches row text. Default: the localised palette.empty ("No matching commands"). |
shortcut | false skips binding the global Ctrl/⌘+K. Default true. |
Strings resolve through Soma.i18n at
construction (palette.label,
palette.placeholder) and at render time
(palette.empty). Set the locale before creating
the palette.
The command shape
| Field | Description |
|---|---|
id | Required, unique. register() replaces an existing command with the same id; soma-palette-run carries it in detail. |
title | Required. The rendered row text, matched by the filter. Inserted as text, never HTML. |
section | Optional group heading. Commands without one list first, ungrouped. |
keywords | Optional extra match terms (a plain string, e.g. synonyms). |
kbd | Optional shortcut hint rendered end-aligned in mono. Display only; bind the actual keys with Soma.shortcuts. |
run(cmd) | Optional — invoked after the palette hides, with the command object. Omit it to handle everything through the run event instead. |
Instance methods
| Member | Description |
|---|---|
.show() / .hide() | Open / close. Every show resets the query to the full list and focuses the input; hide restores focus. No-ops when already in that state. |
.register(cmd | cmds) | Add or replace (by id) one command or an array; an open palette re-filters immediately. Throws without an id and title. |
.unregister(id) | Remove a command; unknown ids are ignored. |
.on(event, fn) / .off(event, fn) | Subscribe/unsubscribe: 'run', 'show', 'hide' (without the soma-palette- prefix). |
.isOpen / .el | Open state; the generated dialog element (attached to <body> on first show). |
.destroy() | Hide, unbind the global shortcut and the internal listeners, remove the element. The instance is done — build a new one if needed. |
All methods except destroy() return the instance,
so calls chain
(Soma.palette().register(cmds).show()).
Events
| Event | Fires | detail |
|---|---|---|
soma-palette-run | When a command is chosen (Enter or click), after the palette closes, before the command's run(). | { id } |
soma-palette-show | When the palette opens. | — |
soma-palette-hide | When it closes, with or without a run. Follows the platform's close event (browsers fire it from a queued task, so on a run it can arrive after soma-palette-run). | — |
All three are bubbling CustomEvents dispatched on the palette
element, so a document-level listener works for delegated
wiring, e.g. command analytics without touching any
run:
document.addEventListener('soma-palette-run', (e) => {
track('command', e.detail.id);
});
Plugin-style composition: a portal plugin contributes its commands to the page's palette at load time and withdraws them on unload:
// The host page owns the instance…
window.portalPalette = Soma.palette();
// …and each plugin registers what it brings.
portalPalette.register([
{ id: 'audit.export', title: 'Export audit log', section: 'Audit',
run: () => exportAudit() },
{ id: 'audit.open', title: 'Open audit log', section: 'Audit', kbd: 'G A',
run: () => { location.href = '/audit'; } },
]);
Soma.shortcuts('ga').followLink('#nav-audit'); // the kbd hint's real binding
// on plugin unload:
portalPalette.unregister('audit.export');
portalPalette.unregister('audit.open');
The live examples above, exactly as this page wires them:
const output = document.getElementById('pal-output');
const say = (line) => { output.textContent = line; };
const pal = Soma.palette({
commands: [
{ id: 'help', title: 'Open keyboard help',
run: () => say('Keyboard help opened.') },
{ id: 'go-dashboard', title: 'Go to dashboard', section: 'Navigate', kbd: 'G D',
run: () => say('Navigated to the dashboard.') },
{ id: 'go-deployments', title: 'Go to deployments', section: 'Navigate', kbd: 'G P',
run: () => say('Navigated to deployments.') },
{ id: 'go-settings', title: 'Go to settings', section: 'Navigate',
run: () => say('Navigated to settings.') },
{ id: 'restart', title: 'Restart service', section: 'Actions', keywords: 'reboot bounce',
run: () => say('Service restart requested.') },
{ id: 'rotate', title: 'Rotate credentials', section: 'Actions', keywords: 'secrets keys',
run: () => say('Credential rotation started.') },
{ id: 'backup', title: 'Run backup', section: 'Actions', kbd: '⌘B',
run: () => say('Backup running…') },
{ id: 'toast', title: 'Show a toast', section: 'Actions',
run: () => Soma.toast({ title: 'Hello from the palette', appearance: 'success' }) },
],
});
document.getElementById('pal-open').addEventListener('click', () => pal.show());
pal.on('run', (e) => {
document.getElementById('pal-last').textContent = e.detail.id;
});
// Custom placeholder + empty text; shortcut: false — the main palette
// owns Ctrl/⌘K on this page.
const pal2 = Soma.palette({
shortcut: false,
placeholder: 'Search actions…',
emptyText: 'No actions match — try "log"',
commands: [
{ id: 'log-tail', title: 'Tail the log', run: () => say('Tailing the log…') },
{ id: 'log-clear', title: 'Clear the log', run: () => say('Log cleared.') },
{ id: 'log-download', title: 'Download the log', kbd: '⌘L',
run: () => say('Log downloading…') },
],
});
document.getElementById('pal2-open').addEventListener('click', () => pal2.show());
// Dynamic registration against the main palette.
const toggle = document.getElementById('pal-extra');
const status = document.getElementById('pal-extra-status');
let registered = false;
toggle.addEventListener('click', () => {
registered = !registered;
if (registered) {
pal.register({ id: 'purge', title: 'Purge cache', section: 'Actions',
keywords: 'clear invalidate', run: () => say('Cache purged.') });
} else {
pal.unregister('purge');
}
toggle.textContent = registered ? 'Unregister “Purge cache”' : 'Register “Purge cache”';
status.textContent = registered
? 'Registered — open the palette and type "purge".'
: 'Unregistered — the palette is back to its original 8 commands.';
});