Elements

Command

A command palette is a search field over a list of commands: the user types a few letters, the list narrows to the commands that match, the arrow keys move the highlight and Return runs the highlighted command. Command owns the behavior of one: filtering, sections, the highlight, the keyboard and what screen readers hear. Filtering runs natively and the rows are virtualized: only the rows in view are made, so a palette of thousands of commands, files or emoji filters at every keystroke. The look is the app’s: the command draws nothing of its own, the app styles each part (itemClass, groupClass, searchClass, …) and draws the rows’ content (renderItem: an icon, the label, the shortcut’s keys).

el-command.tsx
el-command.tsxLive
import { keymap, mount, signal, Text, View } from "nsh";
import { Command, type CommandItem, type CommandRef } from "nsh/base";
import {
  CommandEmpty,
  CommandHeading,
  CommandRow,
  commandClasses,
  desktop,
  Icon,
  type IconName,
  Log,
  Panel,
  Split,
  ui,
  wide,
} from "./palette";

// A command palette: sections with headings, shortcuts shown as the
// platform writes them, a disabled command, the appearance in effect
// checked, keywords that find a command by another word. The command owns
// the filtering, the highlight, the keys and the semantics; every pixel is
// `commandClasses` and `CommandRow`.
const icons: Record<string, IconName> = {
  new: "plus",
  open: "folder",
  save: "download",
  share: "share",
  find: "search",
  replace: "replace",
  terminal: "terminal",
  sidebar: "sidebar",
  dark: "moon",
  light: "sun",
  docs: "document",
  feedback: "chat",
};

const appearance = signal<"dark" | "light">("dark");
const highlighted = signal<string | null>(null);
const query = signal("");
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 4));
let palette: CommandRef | undefined;

const items = (): CommandItem[] => [
  { id: "new", label: "New File", group: "File", shortcut: "mod+n" },
  { id: "open", label: "Open Folder…", group: "File", shortcut: "mod+o" },
  { id: "save", label: "Save All", group: "File", shortcut: "mod+alt+s" },
  { id: "share", label: "Share Project", group: "File", disabled: true },
  { id: "find", label: "Find in Files", group: "Edit", shortcut: "mod+shift+f", keywords: ["search", "grep"] },
  { id: "replace", label: "Replace in Files", group: "Edit", shortcut: "mod+shift+h" },
  { id: "terminal", label: "Toggle Terminal", group: "View", shortcut: "ctrl+j", keywords: ["console", "shell"] },
  { id: "sidebar", label: "Toggle Sidebar", group: "View", shortcut: "mod+b" },
  { id: "dark", label: "Dark Appearance", group: "View", keywords: ["theme"], checked: appearance() === "dark" },
  { id: "light", label: "Light Appearance", group: "View", keywords: ["theme"], checked: appearance() === "light" },
  { id: "docs", label: "Open Documentation", group: "Help", keywords: ["guide", "manual"] },
  { id: "feedback", label: "Send Feedback", group: "Help" },
];

function App() {
  keymap.bind("mod+k", () => palette?.focus());
  return (
    <View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
      <Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>command</Text>
      <Text style={{ color: ui().muted }}>
        {desktop
          ? `Type to filter, the arrows to move, Return to run, Escape to clear; ${keymap.label("mod+k")} focuses it`
          : "Type to filter, tap a command to run it"}
      </Text>

      <Split
        gap={14}
        inspector={
          <>
            <Panel title="highlighted">
              <Text style={{ fontSize: 13, color: ui().text }}>{highlighted() ?? "none"}</Text>
            </Panel>
            <Panel title="query">
              <Text style={{ fontSize: 13, color: ui().text }}>{query() ? `“${query()}”` : "empty"}</Text>
            </Panel>
            <Log title="events" lines={events()} minHeight={92} />
          </>
        }
      >
        <View style={{ width: wide() ? 380 : undefined, alignSelf: wide() ? "center" : "stretch" }}>
          <Command
            ref={(r) => (palette = r)}
            items={items()}
            query={query()}
            onQueryChange={({ query: next }) => query.set(next)}
            onHighlightChange={({ highlighted: next }) => highlighted.set(next)}
            onCancel={() => log("onCancel()")}
            onSelect={({ id }) => {
              if (id === "dark" || id === "light") appearance.set(id);
              log(`onSelect({ id: "${id}" })`);
            }}
            placeholder="Type a command or search…"
            accessibilityLabel="Commands"
            class={commandClasses.root}
            searchBarClass={commandClasses.searchBar}
            searchClass={commandClasses.search}
            listClass={commandClasses.list}
            groupClass={commandClasses.group}
            separatorClass={commandClasses.separator}
            itemClass={commandClasses.item}
            style={{ height: desktop ? 360 : 400 }}
            searchPrefix={<Icon name="search" size={16} tint={ui().muted} />}
            empty={<CommandEmpty label="No commands match" />}
            renderGroup={(group) => <CommandHeading group={group} />}
            renderItem={(entry) => <CommandRow entry={entry} icon={icons[entry.id]} note={entry.disabled ? "Pro" : undefined} />}
          />
        </View>
      </Split>
    </View>
  );
}

mount(App);
use nsh_base::{CommandEntry, CommandItem, command, theme};
use nsh_core::prelude::*;

fn palette() -> impl IntoElement {
    let items = vec![
        CommandItem::new("new", "New File").group("File").shortcut("mod+n"),
        CommandItem::new("open", "Open…").group("File").shortcut("mod+o"),
        CommandItem::new("theme", "Toggle Theme").group("View").keywords(["dark", "light"]),
        CommandItem::new("settings", "Settings").group("View").shortcut("mod+,"),
    ];
    let chosen = signal(SharedString::from("none"));
    command()
        .items(items)
        .placeholder("Type a command or search…")
        .accessibility_label("Commands")
        .auto_focus(true)
        .on_select(move |id: &SharedString| chosen.set(id.clone()))
        .empty(text("No results.").text_color(move || theme().muted_foreground))
        .h(px(360.0))
        .render_item(|entry: &CommandEntry| {
            view()
                .flex_row()
                .items_center()
                .h_8()
                .px_2()
                .child(text(entry.label.clone()).flex_1())
                .child(
                    text(entry.shortcut_label.clone().unwrap_or_default())
                        .text_color(move || theme().muted_foreground),
                )
        })
}

Command comes from nsh/base, the nsh-base crate in Rust. The query is the app’s when it sets query, which it updates in onQueryChange; without it the search field keeps its own, starting from defaultQuery. Items are identified by their id, the app’s own, never by their label: onSelect({ id }) says which one the user chose. In Rust, command() makes it, with a setter per prop (.items(..), .query(..), .render_item(..)), a listener method per event and the typed style methods.

#Items

items is the commands, in order, each a CommandItem:

FieldTypeDescription
idstringWhat identifies it in onSelect and onHighlightChange: unique among the items.
labelstringWhat its row shows unless renderItem draws it, what screen readers say and what the query matches.
keywordsstring[]More words the query matches: synonyms, an English name beside a translated label.
groupstringIts section: adjacent items of one group show under a header with this heading.
shortcutstringThe keystroke of the same command, as keymap names it ("mod+shift+p"), for its row to show. The app binds it with keymap.bind.
disabledbooleanIt cannot be chosen: the keys pass over it, presses do nothing, disabled: styles apply.
checkedbooleanIt is the one in effect (a theme, a language): checked: styles apply and screen readers say it is checked.

The query matches an item when its label or one of its keywords holds it, ignoring case and the spaces around it. The items keep their order, and a section with no match goes with its header. renderItem gets a CommandEntry for each row in view: the item’s fields, and shortcutLabel, its shortcut as hints show it here (⇧⌘P on Apple’s platforms, Ctrl+Shift+P elsewhere). It is what the item is, and the highlight is the row’s focus:: the drawing is made once per item and keeps its views while the highlight moves.

#Props

PropRustTypeDefaultDescription
items.items(..)CommandItem[][]The commands, in order.
query.query(..)stringnoneThe query (controlled), which the app sets in onQueryChange.
defaultQuery.default_query(..)stringnoneThe query at first, while query is not set.
searchable.searchable(..)booleantrueA search field above the rows. Without it every item shows, and the command is one stop of the keyboard focus with typeahead.
filterable.filterable(..)booleantrueThe query filters the items. Turned off, every item shows: for a search the app answers itself (a server’s results set as items). A new query still highlights the first item.
loading.loading(..)booleanfalseA search is under way: empty waits, and the list is busy for screen readers. Show your indicator in searchSuffix.
placeholder.placeholder(..)stringnoneThe search field’s placeholder.
autoFocus.auto_focus(..)booleanfalseThe search field takes the keyboard focus once shown: a palette that opens to be typed in.
separator.separator(..)booleantrueA separator row between two sections shown, styled by separatorClass; never first, last or doubled.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers call the search field and the list (“Commands”).
header, footer.header(..), .footer(..)JSX.ElementnoneWhat shows above the search field (a title) and below the rows (the keys’ hints, a count).
empty.empty(..)JSX.ElementnoneWhat shows while no item does: a query that matches nothing, no items.
searchPrefix, searchSuffix.search_prefix(..), .search_suffix(..)JSX.ElementnoneWhat shows before and after the search field in its bar: a search icon, a spinner while loading.
renderItem.render_item(..)(entry: CommandEntry) => JSX.ElementnoneA row’s content, for the rows in view; without it a row shows the item’s label.
renderGroup.render_group(..)(group: CommandGroup) => JSX.ElementnoneA section’s header ({ heading }); without it a header shows its heading.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe command itself: its size, frame and surface.
searchBarClass, searchBarStyle.search_bar_class(..), .search_bar_style(..)string, StyleProp<ViewStyle>noneThe row of searchPrefix, the search field and searchSuffix.
searchClass, searchStyle.search_class(..), .search_style(..)string, StyleProp<TextStyle>noneThe search field.
listClass, listStyle.list_class(..), .list_style(..)string, StyleProp<ViewStyle>noneThe scroll view of the rows. Give it a height (max-h-80, or a sized command): the rows scroll inside it, and only those in view are made.
itemClass, itemStyle.item_class(..), .item_style(..)string, StyleProp<TextStyle>noneEach item’s row and its states (below); its text styles reach the label.
groupClass, groupStyle.group_class(..), .group_style(..)string, StyleProp<TextStyle>noneEach section’s header.
separatorClass, separatorStyle.separator_class(..), .separator_style(..)string, StyleProp<ViewStyle>noneEach separator: a rule’s height and color.

#Events

EventRustPayloadWhen
onSelect.on_select(..){ id: string }The user chose an item: Return, or a press on its row.
onHighlightChange.on_highlight_change(..){ highlighted: string | null }The highlight moved (the keys, the pointer, a new query or new items, highlight): to preview what Return would run.
onQueryChange.on_query_change(..){ query: string }The user typed in the search field, or Escape cleared it.
onCancel.on_cancel(..)noneEscape with an empty query: close what holds the command (a dialog’s open).

#Parts

PartInsideStyled withStates
The commandclass, style
headerthe commandthe app’s element
The search barthe commandsearchBarClass, searchBarStyle
searchPrefix, the search field, searchSuffixthe search barthe app’s elements; searchClass, searchStylethe field’s focus:
The listthe commandlistClass, listStyle
A section’s headerthe listgroupClass, groupStyle, renderGroup
A separatorthe listseparatorClass, separatorStyle
A rowthe listitemClass, itemStylefocus: (highlighted), checked:, disabled:, hover:, active:
A row’s contenta rowrenderItemgroup-focus:, group-checked:, group-disabled: under group in itemClass
emptythe commandthe app’s element
footerthe commandthe app’s element

Show the highlighted row with focus: (see Design guides).

#Ref

MethodDescription
focus()Gives the search field the keyboard focus (without one, the command).
highlight(id)Highlights the item of id and scrolls its row into view; an item not shown or disabled stays as it is.
matchedCount()Promise<number>: how many items the query shows, disabled ones included.
let palette: CommandRef | undefined;

<Command ref={(r) => (palette = r)} items={items} />;
palette?.focus();

#A palette in a dialog

Put the command in a Dialog and open it with the app’s shortcut. Escape clears the query first; with an empty query it is onCancel, where the app closes the dialog.

import { keymap } from "nsh";

const open = signal(false);
keymap.bind("mod+k", () => open.set(!open()));

<Dialog open={open()} onOpenChange={({ open: next }) => open.set(next)}>
  <Command
    items={commands}
    autoFocus
    onCancel={() => open.set(false)}
    onSelect={({ id }) => {
      open.set(false);
      run(id);
    }}
  />
</Dialog>;

#A search the app answers

With filterable={false} every item the app gives shows: the app searches in onQueryChange (a server, a database) and sets items with the results, loading while it waits.

<Command
  items={results()}
  filterable={false}
  loading={searching()}
  onQueryChange={({ query }) => search(query)}
  searchSuffix={searching() ? <Spinner /> : undefined}
  empty={<Text>No matches</Text>}
/>

#Accessibility

  • The list has the role "listbox", named by accessibilityLabel; each row is an "option" named by its item’s label, checked and disabled as its item is; a section’s header is a "header", a separator is hidden.
  • The search field keeps the keyboard focus while the arrow keys move the highlight, and screen readers hear the highlighted label. Without a search field, the highlighted row takes the screen reader’s focus.
  • While loading, the list is busy.

See Accessibility for labels, roles and announcements in general.

#Keyboard

KeyWhat it does
TypingFilters the items; the first enabled match is highlighted.
Down, UpHighlights the next or the previous item, past disabled ones, from the last to the first and back.
Page Down, Page UpMoves the highlight by the rows in view.
ReturnChooses the highlighted item (onSelect).
EscapeClears the query; with an empty query, reports onCancel.
Home, EndWithout a search field: the first or the last item (in the field they move its caret).
Space, letters, digitsWithout a search field: Space chooses, letters and digits highlight by typeahead.

#Platform notes

  • Desktops (macOS, Windows, Linux, GPUI) and the web: the pointer highlights the row it moves over, and a click chooses as the button goes down, so the search field keeps the keyboard focus.
  • iOS, Android and Mac Catalyst: a tap chooses a row, and the keyboard’s return key chooses the highlighted item and keeps the field for the next query.
  • Every platform draws what the app styles: the command, its search bar, its rows and their content.