Connection lost. Reconnecting… attempt 1 of 8
Paused. Your work is held on the server.
Could not reconnect.
This session has expired on the server.
DR.Simple_UI
Showing main. This can be ahead of the version your app has installed — check what a class says it needs before copying it. Releases (opens in a new tab)

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.

…or press Ctrl K anywhere on this site.
<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
No command matches “refund”.
<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">&uarr;</span> <span class="kbd">&darr;</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 &ldquo;refund&rdquo;.</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 &ldquo;xyzzy&rdquo;.</div></li>
    </ul>

    <div class="palette-footer">
        <span><span class="kbd">&uarr;</span> <span class="kbd">&darr;</span> to move</span>
        <span><span class="kbd">Enter</span> to run</span>
        <span><span class="kbd">Esc</span> to close</span>
    </div>
</dialog>