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.
An unhandled error has occurred.
Sedna.UI
v0.17.0 · main

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 sednaUi.palette.register, and writes the palette itself in its layout — the markup below. Until something is registered, or with no palette in the page, the library leaves the browser's own Ctrl+K alone. A command registered from C# carries an Href and navigates. A run callback is a JavaScript function, and a function does not cross into C# — navigation is what a serialisable command carries.

…or press Ctrl K anywhere on this site.
<button class="btn" type="button" onclick="sednaUi.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.

// JavaScript — a command may run a callback.
sednaUi.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
// function does not cross into C#, 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, with .palette-footer carrying the keys as .kbd literals. .palette-group heads a run of commands from the same group and is dropped once a query has reordered the list; its <li> needs role="presentation", because a listbox may own only options and a bare <li> breaks aria-required-children.

  • Orders
  • Dispatch this order Reserved lines only
  • Discard the draft Cannot be undone
No command matches “refund”.
<div class="sedna-col sedna-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, aria-selected is the keyboard's position and not :hover, and .palette-empty should name what was searched rather than say “no results”; the ranking is sednaUi._.score, shared with the header search so the two cannot rank one query differently.

<!-- The palette an app writes, once, in its layout. sednaUi.palette fills the list
     from the templates and draws nothing itself, so every word here is yours to
     translate. Leave the list empty: the script owns its children. The group and
     empty templates are optional. -->
<dialog class="palette" data-palette aria-label="Commands">
    <input class="palette-input" type="text" placeholder="Search commands…" aria-label="Search commands" />

    <ul class="palette-list" aria-label="Commands"></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>

    <!-- One command. data-label and data-note take text; data-icon takes the command's
         icon class. A slot with nothing to say is removed. role="presentation" on the
         <li> is load-bearing: a listbox may only own options. -->
    <template data-palette-item>
        <li role="presentation">
            <div class="palette-item" role="option">
                <i data-icon aria-hidden="true"></i><span data-label></span>
                <span class="palette-item-note" data-note></span>
            </div>
        </li>
    </template>

    <!-- A heading over a run of commands from one group, while nothing is typed. -->
    <template data-palette-group>
        <li role="presentation"><div class="palette-group" data-group></div></li>
    </template>

    <!-- In place of the options. Say what was searched, not just "no results". -->
    <template data-palette-empty>
        <li role="presentation"><div class="palette-empty">Nothing matches &ldquo;<span data-query></span>&rdquo;.</div></li>
    </template>
</dialog>