Command palette
tier 2 — classes
The Ctrl+K box: type, see matching commands, run one. A
<dialog>, so the top layer, the focus trap and Escape are the
platform's.
Open it
Live. This site registers one command per page with
drSimpleUi.palette.register. Until something is registered the library leaves
the browser's own Ctrl+K alone, so a site that documents the palette and
never calls register has one in the stylesheet and none in the browser.
Positioned near the top rather than centred: the list grows downwards, and a centred box moves under the reader's eye as they type.
A command registered from C# carries an Href and navigates. A run
callback is JavaScript-only, because the library never calls back into .NET.
<button class="btn" type="button" onclick="drSimpleUi.palette.open()">
<i class="ri-command-line"></i> Open the palette
</button>
<span class="form-hint">…or press <span class="kbd">Ctrl</span> <span class="kbd">K</span> anywhere on this site.</span>
Registering commands
register() replaces the whole list, so call it again whenever what is available
changes — after a permission check, or on navigation. keywords makes a command
findable by a word that is not in its label, and scores below the same word in the label so a
command is never outranked by one that merely mentions it.
// JavaScript — a command may run a callback.
drSimpleUi.palette.register([
{ label: 'Dispatch this order', icon: 'ri-send-plane-line', group: 'Orders',
note: 'Reserved lines only', keywords: 'send ship release',
run: () => dispatchCurrentOrder() },
{ label: 'Open the queue', icon: 'ri-inbox-line', group: 'Go to', href: '/queue' },
]);
// C# — a command registered from .NET carries an Href, never a callback: the
// library does not call back into .NET, so navigation is the one action a
// serialisable command can perform.
await Ui.RegisterCommandsAsync(
[
new PaletteCommand { Label = "Open the queue", Icon = "ri-inbox-line",
Group = "Go to", Href = "/queue" },
new PaletteCommand { Label = "Account settings", Icon = "ri-user-line",
Group = "Go to", Href = "/settings", Keywords = "profile theme" },
]);
// register() REPLACES the list. Call it again whenever what is available
// changes — after a permission check, or on navigation.
//
// Groups are shown in registration order and dropped once a query has
// reordered the list, because a heading over unrelated results is worse than
// no heading. Ctrl/Cmd-K is wired for you, and does nothing until at least one
// command is registered, so the browser's own binding is left alone.
Matching, and nothing found
The two states side by side, laid out statically here so both are visible at once.
.palette-group heads a run of commands from the same group; groups are shown in
registration order and dropped once a query has reordered the list, because a heading over
unrelated results is worse than no heading.
The group heading's <li> carries role="presentation", and it is
load-bearing: a listbox may own only options, so a bare
<li> here breaks aria-required-children — and stops being a
listitem at the same time, because the <ul> is then no longer a list.
.palette-empty names the query rather than saying “no results”, so the
reader can see the box holds what they think it does. .palette-footer carries the
keys; .kbd reuses the inline-code tint, because that is what it is — a literal the
reader types.
- Orders
- Dispatch this order Reserved lines only
- Discard the draft Cannot be undone
<div class="dr-col dr-gap-3" style="max-width:520px">
<div class="palette" style="position:static; inset:auto; display:flex; flex-direction:column">
<input class="palette-input" type="text" value="dis" readonly aria-label="Run a command" />
<ul class="palette-list" role="listbox" aria-label="Commands">
<li role="presentation"><div class="palette-group">Orders</div></li>
<li class="palette-item" role="option" aria-selected="true">
<i class="ri-send-plane-line"></i> Dispatch this order
<span class="palette-item-note">Reserved lines only</span>
</li>
<li class="palette-item" role="option" aria-selected="false">
<i class="ri-close-circle-line"></i> Discard the draft
<span class="palette-item-note">Cannot be undone</span>
</li>
</ul>
<div class="palette-footer">
<span><span class="kbd">↑</span> <span class="kbd">↓</span> to move</span>
<span><span class="kbd">Enter</span> to run</span>
<span><span class="kbd">Esc</span> to close</span>
</div>
</div>
<div class="palette" style="position:static; inset:auto; display:flex; flex-direction:column">
<input class="palette-input" type="text" value="refund" readonly aria-label="Run a command" />
<div class="palette-empty">No command matches “refund”.</div>
</div>
</div>
The markup
What the library builds, for an app rendering its own command list. If you are calling
register() you write none of it.
The list is a real role="listbox" of role="option" items, with the
input as the combobox owning it through aria-activedescendant. That claim is made
only because the keyboard contract behind it is implemented in full: arrows,
Home/End, Enter, and the highlight moving while focus stays
in the input. aria-selected is the keyboard's position, which is a different
thing from :hover — moving the pointer must not move the keyboard.
.palette-empty is the no-matches state. Say what was searched, not just
“no results”.
There is no fuzzy-match library. The scorer is drSimpleUi._.score, shared with
the header search so the two cannot rank the same query differently.
<!-- What drSimpleUi.palette builds. Written out because an app rendering its own
command list needs the classes; if you call palette.register() you never write
any of this. -->
<dialog class="palette" id="cmdk">
<input class="palette-input" type="text" placeholder="Search commands…"
role="combobox" aria-expanded="true" aria-controls="cmdk-list"
aria-activedescendant="cmdk-0" aria-label="Search commands" autocomplete="off" />
<ul class="palette-list" id="cmdk-list" role="listbox" aria-label="Commands">
<!-- role="presentation" on the <li> is load-bearing: a listbox may only own
options, and a bare <li> breaks aria-required-children. -->
<li role="presentation"><div class="palette-group">Actions</div></li>
<!-- role="option" on a div, not a button: an option is not a button, and being
one inside a listbox is what makes aria-selected legal. axe rejects
aria-selected on a plain button, correctly. -->
<li role="presentation">
<div class="palette-item" role="option" id="cmdk-0" aria-selected="true">
<i class="ri-check-double-line" aria-hidden="true"></i> Approve the selected reservation
<span class="palette-item-note">Ctrl A</span>
</div>
</li>
<li role="presentation">
<div class="palette-item" role="option" id="cmdk-1" aria-selected="false">
<i class="ri-user-shared-line" aria-hidden="true"></i> Reassign order
</div>
</li>
<!-- The no-matches state, in place of the options. Say what was searched, not
just "no results" — the reader needs to know the query was what they
thought it was. -->
<li role="presentation"><div class="palette-empty">Nothing matches “xyzzy”.</div></li>
</ul>
<div class="palette-footer">
<span><span class="kbd">↑</span> <span class="kbd">↓</span> to move</span>
<span><span class="kbd">Enter</span> to run</span>
<span><span class="kbd">Esc</span> to close</span>
</div>
</dialog>