busy-office-ui
Menu

Pattern: command bar

Who uses it: power users who know what they want — a dispatcher jumping to PO-88213, a controller running "post period" — without walking the menu. How often: dozens of times a day, which is why it is keyboard-first and why the shortcut must never be stolen from the browser. What "done" looks like: they typed a few characters and the screen they wanted is open, hands never leaving the keyboard. Not a replacement for navigation — it rewards people who already know the name of the thing. A first-time or occasional user needs the visible structure of app launch, and a user narrowing an unknown set needs the filter panel; a command bar answers neither.

The screen

This palette is dialog + combobox + kbd, composed. The framework ships no command-bar component and does not need one: of the palette CSS this site runs, 7 of 10 declarations already exist in .bo-dialog, and the keyboard contract is combobox's in full.

Opened by a button here, deliberately: this site already binds ⌘K to its own docs search. That collision is the first thing to check in your own app — a command bar that fights the host for a shortcut loses, silently.

↑↓ move Enter open Esc close
  • PO-88213 — Umbrella Logistics
  • PO-88214 — Northwind Supply
  • INV-4471 — Umbrella Logistics
  • CC-4021 — Warehouse cost center
  • Post period 2026-07
  • Create purchase order
  • Run reorder report

Anatomy

  1. Top-anchored dialog — dialog, in the top layer so it escapes the app shell's overflow. Three extra declarations anchor it near the top instead of centring it; that is the whole visual difference.
  2. Query input + result list — combobox, which carries the entire keyboard and ARIA contract: aria-expanded, aria-activedescendant moved by ↑/↓, aria-selected on the active option, Esc and click-outside for free.
  3. Keyboard hint strip — kbd, above the input. Render the shortcut per platform: ⌘K on Mac, Ctrl K everywhere else, which for a back-office framework is the majority.
  4. The shortcut binding — your code, not a behavior. Which key the app owns is a page-level decision.

Data contract

GET /command?q=pos            -> 200
{
  "groups": [
    { "label": "Records", "items": [
      { "id": "po-88213", "text": "PO-88213 — Umbrella Logistics", "href": "/po/88213" }
    ]},
    { "label": "Actions", "items": [
      { "id": "act-post", "text": "Post period 2026-07", "href": "/period/post" }
    ]}
  ]
}

# Nothing matched: 200 with an EMPTY groups array, never a 404.
# "No results" is a state of the screen, not an error of the request.

# The user may not run it: omit the item. Never return an action that
# 4xx's when chosen — see /concepts/permissions.

Group results by kind — records and actions answer different questions and a flat list makes the user read every row to tell them apart. Two rules do the heavy lifting: no matches is a 200 with an empty list, never a 404; and an action the user may not run is omitted, not disabled — returning one that 4xx's when chosen teaches the user to distrust the bar.

The binding, and the failure rule

<!-- The shortcut is YOUR four lines, not a behavior: which key
     the app owns is a page-level decision. -->
<script type="module">
  import { initCombobox } from '@busy-office/ui/js';
  initCombobox();

  const dlg = document.getElementById('cmd');
  addEventListener('keydown', (e) => {
    if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
      e.preventDefault();
      dlg.open ? dlg.close() : dlg.showModal();
    }
  });
  // Backdrop click closes — a native <dialog> does not do this for you.
  dlg.addEventListener('click', (e) => { if (e.target === dlg) dlg.close(); });
</script>

The non-obvious rule, and the one worth copying: if the result source cannot load, remove the trigger. A command bar that opens onto an empty box is worse than no command bar — the user types, nothing happens, and they cannot tell whether the search is broken or the record does not exist. This site does exactly that: if the search index fails to fetch, the button deletes itself.

The listbox is a popover — so nothing sits below it

Combobox renders its listbox as a [popover] in the top layer, which is what stops it being clipped by a scrolling ancestor. The consequence only shows up once results appear: the list is painted over whatever follows the input in the dialog. This page shipped a hint strip below the input and measured it covered — footer at y 122–175, listbox at 126–284 — hidden at exactly the moment its contents become useful.

Removing popover is not the fix: it is load-bearing. Measured with the attribute stripped, the list still renders, but aria-expanded stays false, no aria-activedescendant is set and nothing is aria-selected — the whole keyboard contract goes silent while the screen still looks correct. So put keyboard hints, counts, and any footer above the input, and keep everything below it empty.

States

StateWhat the screen shows
Empty query (just opened)Recent records and the handful of actions this role runs daily — an empty palette wastes the trip. Never a blank box.
Typing, results foundGrouped by kind, most-likely group first. The active option carries aria-selected, so it is announced as the arrow keys move.
No matches"No records or actions match xyz" inside the list region, plus what would help — most misses are a wrong prefix, so say which formats are searchable.
Slow source (> ~200ms)A busy state on the list region, not a spinner over the input — the user is still typing and must never be blocked. Keep the previous results visible underneath.
Source unavailableThe trigger is removed at load. See the failure rule above.
PermissionActions the user cannot run are absent from the response, so this state never reaches the screen. That is the point of filtering server-side.
Shortcut already takenThe host owns the key. Detect it at build time, not by racing — and fall back to a visible trigger, which every user can find and no user needs told about.
No JSThe trigger button has no popovertarget or other native affordance — its <dialog> is opened purely by a click listener in this page's script. With no JS, clicking the trigger does nothing at all: the palette is unreachable, not degraded, and the combobox filtering inside it never comes into play.

Typing, committing, clearing

Open the palette above and try each: type PO to search records, type post to reach an action, press Enter to commit, and use the ✕ to clear without leaving.

What the palette does at each step
MomentWhat the palette does
Keying in a command Same input, same list. A palette that made the user choose "search mode" or "command mode" first would be asking them to know the answer before they typed it — the whole point is one box. Actions and records are separate GROUPS in the listbox, not separate modes.
Keying in a search Filtering starts on the first character; initCombobox() owns it. Nothing is committed until Enter, so arrowing through results changes the active option, not the value.
Enter — the value afterwards The combobox commits the active option and leaves that text in the input selected, so the next keystroke replaces it rather than appending. In a palette the commit usually navigates, so decide what the box holds when the user comes back: this demo clears and closes, because a palette reopened onto last week's query is a palette you have to clear before you can use.
Clear A visible ✕, shown only when there is something to clear. It empties the query and returns focus to the input — it does not close the palette, which is the difference between "I mistyped" and "I'm done".
Escape, and why it takes two The listbox is a popover, so the first Escape is the platform light-dismissing it; the second closes the dialog. That layering is free and correct — but it surprises people, so say it in the hint rather than fighting it.

Markup

<dialog id="cmd" class="bo-dialog cmd-palette" aria-label="Command bar">
  <!-- Hints ABOVE the input, not below: see "The listbox is a popover" -->
  <div class="cmd-palette__hint">
    <span><kbd class="bo-kbd">↑</kbd><kbd class="bo-kbd">↓</kbd> move</span>
    <span><kbd class="bo-kbd">Enter</kbd> open</span>
    <span><kbd class="bo-kbd">Esc</kbd> close</span>
  </div>
  <div class="bo-combobox">
    <input class="bo-input" type="text" role="combobox" id="cmd-input"
           aria-expanded="false" aria-controls="cmd-list" aria-autocomplete="list"
           aria-label="Search records and actions"
           autocomplete="off" placeholder="Search records and actions…">
    <ul class="bo-combobox__listbox" id="cmd-list" role="listbox" popover>
      <li class="bo-combobox__option" role="option" id="cmd-1"
          data-value="PO-88213">PO-88213 — Umbrella Logistics</li>
      <!-- …one per result… -->
    </ul>
  </div>
</dialog>

<!-- Three declarations are all that separates a palette from a dialog: -->
<style>
  /* overflow MUST stay visible. With overflow:hidden the results listbox does
     not render at all — measured, not guessed. The listbox is a [popover] and
     this dialog is its containing block, so clipping the dialog clips the
     results away and the palette silently becomes a search box that finds
     nothing. See "The listbox is a popover" on this page. */
  .cmd-palette { margin-block-start: 12vh; margin-inline: auto; overflow: visible; }
  /* Padding, or the hint row sits flush in the corner of an 8px-rounded box
     and reads as a separate floating bar rather than part of the palette. */
  .cmd-palette__hint {
    display: flex; gap: var(--bo-space-4);
    font-size: var(--bo-font-size-xs);
    padding: var(--bo-space-2) var(--bo-space-3);
    border-block-end: var(--bo-border-width) solid var(--bo-color-border-default);
  }
</style>

Components used

complexity 1 of 4 — one input, one list; the composition is the whole trick.

↑↓ move Enter open Esc close