Monet
@etched/monet 0.0.1

Command palette

Ported

A command palette: a dialog with a search field and results in groups, for the keyboard first. The arrow keys move the highlight, Enter chooses, ⌘ or Ctrl with Enter chooses for a new tab, Escape closes. The app opens it, for example on ⌘K. Put a CommandPaletteInput, a CommandPaletteList and a CommandPaletteFooter with key hints in it. Native <div> attributes and ref go to its CommandPalettePanel.

Kumo source
import { ChartLineIcon, GearIcon, HouseIcon, PlusIcon, UserPlusIcon, UsersIcon } from "@phosphor-icons/react";
import { useState, type ReactNode } from "react";
import { Button } from "@etched/monet/button";
import { CommandPaletteEmpty, CommandPaletteInput, CommandPaletteItem, CommandPaletteList, CommandPaletteResults, CommandPaletteRoot } from "@etched/monet/command-palette";

interface Command {
  id: string;
  label: string;
  icon: ReactNode;
  disabled?: boolean;
}

interface CommandGroup {
  label: string;
  items: Command[];
}

const commands: CommandGroup[] = [
  {
    label: "Actions",
    items: [
      { id: "add-candidate", label: "Add candidate", icon: <UserPlusIcon /> },
      { id: "create-trace", label: "Create trace", icon: <PlusIcon /> },
      { id: "settings", label: "Open settings", icon: <GearIcon />, disabled: true },
    ],
  },
  {
    label: "Pages",
    items: [
      { id: "home", label: "Home", icon: <HouseIcon /> },
      { id: "pipeline", label: "Pipeline", icon: <ChartLineIcon /> },
      { id: "people", label: "People", icon: <UsersIcon /> },
    ],
  },
];

export function Preview() {
  const [open, setOpen] = useState(false);

  return (
    <>
      <Button onClick={() => setOpen(true)}>Search commands</Button>
      <CommandPaletteRoot open={open} onOpenChange={setOpen} items={commands[0]?.items ?? []} onSelect={() => setOpen(false)}>
        <CommandPaletteInput placeholder="Type a command" />
        <CommandPaletteList>
          <CommandPaletteResults>
            {(command: Command) => (
              <CommandPaletteItem key={command.id} value={command} disabled={command.disabled}>
                {command.label}
              </CommandPaletteItem>
            )}
          </CommandPaletteResults>
          <CommandPaletteEmpty />
        </CommandPaletteList>
      </CommandPaletteRoot>
    </>
  );
}

Installation

import { CommandPaletteRoot, CommandPaletteDialog, CommandPaletteEmpty, CommandPaletteFooter, CommandPaletteGroup, CommandPaletteGroupLabel, CommandPaletteHighlightedText, CommandPaletteInput, CommandPaletteItem, CommandPaletteItems, CommandPaletteList, CommandPaletteLoading, CommandPalettePanel, CommandPaletteResultItem, CommandPaletteResults } from "@etched/monet/command-palette";

Examples

Commands

Groups of commands. The app binds the shortcut: here ⌘K or Ctrl+K opens and closes it. The first result stays highlighted, so Enter runs the best match; ⌘ or Ctrl with Enter, or with a click, sets newTab. A disabled command shows, but does not run.

Last run: nothing yet
import { ChartLineIcon, GearIcon, HouseIcon, PlusIcon, UserPlusIcon, UsersIcon } from "@phosphor-icons/react";
import { useEffect, useState, type ReactNode } from "react";
import { Button } from "@etched/monet/button";
import { Kbd, KbdGroup } from "@etched/monet/kbd";
import { CommandPaletteEmpty, CommandPaletteFooter, CommandPaletteGroup, CommandPaletteGroupLabel, CommandPaletteInput, CommandPaletteItems, CommandPaletteList, CommandPaletteResultItem, CommandPaletteResults, CommandPaletteRoot } from "@etched/monet/command-palette";

interface Command {
  id: string;
  label: string;
  icon: ReactNode;
  disabled?: boolean;
}

interface CommandGroup {
  label: string;
  items: Command[];
}

const commands: CommandGroup[] = [
  {
    label: "Actions",
    items: [
      { id: "add-candidate", label: "Add candidate", icon: <UserPlusIcon /> },
      { id: "create-trace", label: "Create trace", icon: <PlusIcon /> },
      { id: "settings", label: "Open settings", icon: <GearIcon />, disabled: true },
    ],
  },
  {
    label: "Pages",
    items: [
      { id: "home", label: "Home", icon: <HouseIcon /> },
      { id: "pipeline", label: "Pipeline", icon: <ChartLineIcon /> },
      { id: "people", label: "People", icon: <UsersIcon /> },
    ],
  },
];

const hints = (
  <>
    <span>
      <KbdGroup>
        <Kbd>↑</Kbd>
        <Kbd>↓</Kbd>
      </KbdGroup>
      Navigate
    </span>
    <span>
      <Kbd>↵</Kbd>
      Open
    </span>
  </>
);

export function Commands() {
  const [open, setOpen] = useState(false);
  const [last, setLast] = useState("nothing yet");

  useEffect(() => {
    const toggle = (event: KeyboardEvent) => {
      if (event.key.toLowerCase() === "k" && (event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey) {
        event.preventDefault();
        setOpen((was) => !was);
      }
    };

    window.addEventListener("keydown", toggle);

    return () => window.removeEventListener("keydown", toggle);
  }, []);

  return (
    <div className="row">
      <Button onClick={() => setOpen(true)} aria-keyshortcuts="Meta+K Control+K">
        Search
        <KbdGroup>
          <Kbd>⌘</Kbd>
          <Kbd>K</Kbd>
        </KbdGroup>
      </Button>
      <span>Last run: {last}</span>
      <CommandPaletteRoot
        open={open}
        onOpenChange={setOpen}
        items={commands}
        onSelect={(command: Command, { newTab }) => {
          setLast(newTab ? `${command.label}, in a new tab` : command.label);
          setOpen(false);
        }}
      >
        <CommandPaletteInput placeholder="Type a command or search" />
        <CommandPaletteList>
          <CommandPaletteResults>
            {(group: CommandGroup) => (
              <CommandPaletteGroup key={group.label} items={group.items}>
                <CommandPaletteGroupLabel>{group.label}</CommandPaletteGroupLabel>
                <CommandPaletteItems>
                  {(command: Command) => (
                    <CommandPaletteResultItem key={command.id} value={command} title={command.label} icon={command.icon} disabled={command.disabled} />
                  )}
                </CommandPaletteItems>
              </CommandPaletteGroup>
            )}
          </CommandPaletteResults>
          <CommandPaletteEmpty>No commands match.</CommandPaletteEmpty>
        </CommandPaletteList>
        <CommandPaletteFooter>
          {hints}
          <span>
            <KbdGroup>
              <Kbd>⌘</Kbd>
              <Kbd>↵</Kbd>
            </KbdGroup>
            New tab
          </span>
        </CommandPaletteFooter>
      </CommandPaletteRoot>
    </div>
  );
}

Search results

Results the app finds itself, as Orbit's candidate search does: filter={null} shows them as they come, CommandPaletteLoading shows while it searches, and each result has a description line with the match marked. The footer counts the matches.

import { useEffect, useState } from "react";
import { Button } from "@etched/monet/button";
import { Kbd, KbdGroup } from "@etched/monet/kbd";
import { CommandPaletteEmpty, CommandPaletteFooter, CommandPaletteInput, CommandPaletteList, CommandPaletteLoading, CommandPaletteResultItem, CommandPaletteResults, CommandPaletteRoot, type HighlightRange } from "@etched/monet/command-palette";

interface Person {
  id: string;
  label: string;
  description: string;
}

const people: Person[] = [
  { id: "ana", label: "Ana Ruiz", description: "Design engineer · Etched · linkedin.com/in/anaruiz" },
  { id: "jose", label: "José Núñez", description: "Silicon engineer · Etched" },
  { id: "sam", label: "Sam Lee", description: "Recruiter · Northwind" },
  { id: "kai", label: "Kai Ito", description: "Data scientist · Contoso" },
  { id: "lena", label: "Lena Park", description: "Product manager · Fabrikam" },
];

/** Where the query first appears in a text, as a range to mark. */
const match = (text: string, query: string): HighlightRange[] => {
  const at = text.toLowerCase().indexOf(query.trim().toLowerCase());

  return query.trim() && at >= 0 ? [[at, at + query.trim().length - 1]] : [];
};

const hints = (
  <>
    <span>
      <KbdGroup>
        <Kbd>↑</Kbd>
        <Kbd>↓</Kbd>
      </KbdGroup>
      Navigate
    </span>
    <span>
      <Kbd>↵</Kbd>
      Open
    </span>
  </>
);

export function SearchResults() {
  const [open, setOpen] = useState(false);
  const [query, setQuery] = useState("");
  const [found, setFound] = useState<Person[]>([]);
  const [searching, setSearching] = useState(false);
  const [opened, setOpened] = useState("");

  // A stand-in for a server search: results come 400 ms after the last key.
  useEffect(() => {
    const words = query.trim().toLowerCase();
    setSearching(Boolean(words));
    setFound([]);

    if (!words) return;

    const timer = setTimeout(() => {
      setFound(people.filter((person) => `${person.label} ${person.description}`.toLowerCase().includes(words)));
      setSearching(false);
    }, 400);

    return () => clearTimeout(timer);
  }, [query]);

  return (
    <div className="row">
      <Button
        onClick={() => {
          setQuery("");
          setOpen(true);
        }}
      >
        Find a candidate
      </Button>
      {opened && <span>Opened {opened}</span>}
      <CommandPaletteRoot
        open={open}
        onOpenChange={setOpen}
        aria-label="Find a candidate"
        items={found}
        filter={null}
        value={query}
        onValueChange={setQuery}
        onSelect={(person: Person) => {
          setOpened(person.label);
          setOpen(false);
        }}
      >
        <CommandPaletteInput placeholder="Search candidates by name, role or company" />
        <CommandPaletteList>
          {searching ? (
            <CommandPaletteLoading />
          ) : (
            <>
              <CommandPaletteResults>
                {(person: Person) => (
                  <CommandPaletteResultItem key={person.id} value={person} title={person.label} titleHighlights={match(person.label, query)} description={person.description} />
                )}
              </CommandPaletteResults>
              <CommandPaletteEmpty>{query.trim() ? "No candidates match." : "Type a name, a role or a company."}</CommandPaletteEmpty>
            </>
          )}
        </CommandPaletteList>
        <CommandPaletteFooter end={query.trim() && !searching ? `${found.length} ${found.length === 1 ? "match" : "matches"}` : undefined}>{hints}</CommandPaletteFooter>
      </CommandPaletteRoot>
    </div>
  );
}

Two steps

CommandPaletteDialog with a CommandPalettePanel that changes: choose a person, then the trace to add them to. The dialog stays open, and the second step's search takes focus.

import { useState } from "react";
import { Button } from "@etched/monet/button";
import { Kbd } from "@etched/monet/kbd";
import { CommandPaletteDialog, CommandPaletteEmpty, CommandPaletteFooter, CommandPaletteInput, CommandPaletteList, CommandPalettePanel, CommandPaletteResultItem, CommandPaletteResults } from "@etched/monet/command-palette";

interface Person {
  id: string;
  label: string;
  description: string;
}

const people: Person[] = [
  { id: "ana", label: "Ana Ruiz", description: "Design engineer · Etched · linkedin.com/in/anaruiz" },
  { id: "jose", label: "José Núñez", description: "Silicon engineer · Etched" },
  { id: "sam", label: "Sam Lee", description: "Recruiter · Northwind" },
  { id: "kai", label: "Kai Ito", description: "Data scientist · Contoso" },
  { id: "lena", label: "Lena Park", description: "Product manager · Fabrikam" },
];

const traces = [
  { id: "design", label: "Design engineering", description: "Hiring · 3 open roles" },
  { id: "silicon", label: "Silicon", description: "Hiring · 5 open roles" },
  { id: "research", label: "Research", description: "Uncategorized · 1 open role" },
];

export function TwoSteps() {
  const [open, setOpen] = useState(false);
  const [person, setPerson] = useState<Person | null>(null);
  const [added, setAdded] = useState("");

  return (
    <div className="row">
      <Button
        onClick={() => {
          setPerson(null);
          setOpen(true);
        }}
      >
        Add to a trace
      </Button>
      {added && <span>{added}</span>}
      <CommandPaletteDialog open={open} onOpenChange={setOpen} aria-label="Add to a trace">
        {person ? (
          <CommandPalettePanel
            key="trace"
            items={traces}
            onSelect={(trace) => {
              setAdded(`Added ${person.label} to ${trace.label}`);
              setOpen(false);
            }}
          >
            <CommandPaletteInput placeholder={`Choose a trace for ${person.label}`} />
            <CommandPaletteList>
              <CommandPaletteResults>
                {(trace: (typeof traces)[number]) => <CommandPaletteResultItem key={trace.id} value={trace} title={trace.label} description={trace.description} />}
              </CommandPaletteResults>
              <CommandPaletteEmpty>No traces match.</CommandPaletteEmpty>
            </CommandPaletteList>
            <CommandPaletteFooter end="Esc to cancel">
              <span>
                <Kbd>↵</Kbd>
                Add to trace
              </span>
            </CommandPaletteFooter>
          </CommandPalettePanel>
        ) : (
          <CommandPalettePanel key="person" items={people} onSelect={setPerson}>
            <CommandPaletteInput placeholder="Choose a person" />
            <CommandPaletteList>
              <CommandPaletteResults>
                {(candidate: Person) => <CommandPaletteResultItem key={candidate.id} value={candidate} title={candidate.label} description={candidate.description} />}
              </CommandPaletteResults>
              <CommandPaletteEmpty>No people match.</CommandPaletteEmpty>
            </CommandPaletteList>
            <CommandPaletteFooter>
              <span>
                <Kbd>↵</Kbd>
                Choose a trace next
              </span>
            </CommandPaletteFooter>
          </CommandPalettePanel>
        )}
      </CommandPaletteDialog>
    </div>
  );
}

In place

CommandPalettePanel without a dialog, in a card; it does not take focus on its own. Each result shows where it is (breadcrumbs), with the search's matches marked in the path and the title.

SettingsNotifications
SettingsTeamMembers
TracerPipelineDesign engineering
TracerPipelineSilicon
NetworkWarm paths
import { useState } from "react";
import { Kbd, KbdGroup } from "@etched/monet/kbd";
import { CommandPaletteEmpty, CommandPaletteFooter, CommandPaletteInput, CommandPaletteList, CommandPalettePanel, CommandPaletteResultItem, CommandPaletteResults, type HighlightRange } from "@etched/monet/command-palette";

interface Page {
  id: string;
  label: string;
  path: string[];
}

const pages: Page[] = [
  { id: "notifications", label: "Notifications", path: ["Settings"] },
  { id: "members", label: "Members", path: ["Settings", "Team"] },
  { id: "design", label: "Design engineering", path: ["Tracer", "Pipeline"] },
  { id: "silicon", label: "Silicon", path: ["Tracer", "Pipeline"] },
  { id: "warm-paths", label: "Warm paths", path: ["Network"] },
];

/** Where the query first appears in a text, as a range to mark. */
const match = (text: string, query: string): HighlightRange[] => {
  const at = text.toLowerCase().indexOf(query.trim().toLowerCase());

  return query.trim() && at >= 0 ? [[at, at + query.trim().length - 1]] : [];
};

const hints = (
  <>
    <span>
      <KbdGroup>
        <Kbd>↑</Kbd>
        <Kbd>↓</Kbd>
      </KbdGroup>
      Navigate
    </span>
    <span>
      <Kbd>↵</Kbd>
      Open
    </span>
  </>
);

export function InPlace() {
  const [query, setQuery] = useState("");
  const [last, setLast] = useState("nothing yet");

  return (
    <div className="card">
      <CommandPalettePanel
        items={pages}
        value={query}
        onValueChange={setQuery}
        filter={(page, words) => [...page.path, page.label].join(" ").toLowerCase().includes(words.trim().toLowerCase())}
        onSelect={(page) => setLast(page.label)}
      >
        <CommandPaletteInput placeholder="Go to a page" />
        <CommandPaletteList>
          <CommandPaletteResults aria-label="Pages">
            {(page: Page) => (
              <CommandPaletteResultItem
                key={page.id}
                value={page}
                title={page.label}
                breadcrumbs={page.path}
                titleHighlights={match(page.label, query)}
                breadcrumbHighlights={page.path.map((part) => match(part, query))}
              />
            )}
          </CommandPaletteResults>
          <CommandPaletteEmpty />
        </CommandPaletteList>
        <CommandPaletteFooter end={`Last: ${last}`}>{hints}</CommandPaletteFooter>
      </CommandPalettePanel>
    </div>
  );
}

Accessibility

  • ArrowUp/ArrowDown move the highlight. The first result stays highlighted, so Enter chooses the best match; ⌘ or Ctrl with Enter chooses for a new tab.
  • Escape or the esc key closes it, and focus goes back to what had it before. Tab stays in the dialog.
  • A dialog named "Command palette" or by aria-label. The search is a combobox; results are options in a listbox named "Results", in groups named by their labels.
  • Focus stays in the search; aria-activedescendant points to the highlight. CommandPaletteEmpty is a polite status, and CommandPaletteLoading announces "Loading".
  • The arrows reach a disabled command, so screen readers hear it, but Enter does not run it.
  • Open it yourself, for example on ⌘K or Ctrl+K; it has no shortcut of its own.

API reference

CommandPaletteDialog

PropTypeDefaultDescription
open *boolean–
onOpenChange *(open: boolean) => void–
aria-labelstring"Command palette"Names the dialog. Pass translated text.
containerDialogProps["container"]document.bodyThe element the dialog renders into, such as a shadow root.
children *ReactNode–A CommandPalettePanel. Swap it for another to change steps without closing the dialog.

CommandPaletteEmpty

PropTypeDefaultDescription
classNamestring–

CommandPaletteFooter

PropTypeDefaultDescription
childrenReactNode–Key hints: each child is one hint, such as <span><Kbd>↵</Kbd> Open</span>.
endReactNode–At the end of the row, such as the number of matches.

CommandPaletteGroupLabel

PropTypeDefaultDescription
classNamestring–

CommandPaletteGroup

PropTypeDefaultDescription
classNamestring–

CommandPaletteHighlightedText

PropTypeDefaultDescription
text *string–
highlightsreadonly HighlightRange[]–Matches to mark, such as Fuse.js match indices. They may overlap.

CommandPaletteInput

PropTypeDefaultDescription
leadingReactNodea search iconBefore the field.
trailingReactNodein a dialog, an esc key that closes itAfter the field.
closeLabelstring"Close (esc)"Names the esc key. Pass translated text.

CommandPaletteItem

PropTypeDefaultDescription
classNamestring–
value *T–The item, as it is in items. onSelect gets it.
childrenReactNode–

CommandPalettePanel

PropTypeDefaultDescription
children *ReactNode–CommandPaletteInput, CommandPaletteList and an optional CommandPaletteFooter.
items *readonly TItem[] | readonly { items: readonly TItem[] }[]–The results: items, or groups that each have an items array (and anything else, such as a label).
valuestring–The search text, when the app holds it (for example to search on a server).
onValueChange(value: string) => void–Called with the search text on every change.
onItemHighlighted(item: TItem | undefined) => void–Called when the highlight moves to an item, by the arrow keys or the pointer.
itemToStringValue(item: TItem) => string–An item's text, to match the search. Items with a label need none.
filter((item: TItem, query: string) => boolean) | nullthe item's text contains the searchWhich items match the search text. null shows every item, when the app finds the results itself.
onSelect(item: TItem, details: { newTab: boolean }) => void–Called when an item is chosen, with Enter or a click. With ⌘ or Ctrl held, newTab is true.

CommandPaletteResultItem

PropTypeDefaultDescription
classNamestring–
value *T–The item, as it is in items. onSelect gets it.
title *string–
descriptionReactNode–A second line under the title, such as a role and company.
iconReactNode–Before the title, such as a Phosphor icon.
breadcrumbsreadonly string[]–Where the result is, shown before the title: ["Tracer", "Pipeline"].
titleHighlightsreadonly HighlightRange[]–Parts of the title that match the search.
breadcrumbHighlightsreadonly (readonly HighlightRange[])[]–Parts of each breadcrumb that match the search, in the same order as breadcrumbs.
externalboolean–Marks a result that opens another site.
showArrowbooleantrueAn arrow at the end of the highlighted result.

CommandPaletteResults

PropTypeDefaultDescription
classNamestring–

CommandPaletteRoot

PropTypeDefaultDescription
children *ReactNode–CommandPaletteInput, CommandPaletteList and an optional CommandPaletteFooter.
items *readonly TItem[] | readonly { items: readonly TItem[] }[]–The results: items, or groups that each have an items array (and anything else, such as a label).
valuestring–The search text, when the app holds it (for example to search on a server).
onValueChange(value: string) => void–Called with the search text on every change.
onItemHighlighted(item: TItem | undefined) => void–Called when the highlight moves to an item, by the arrow keys or the pointer.
itemToStringValue(item: TItem) => string–An item's text, to match the search. Items with a label need none.
filter((item: TItem, query: string) => boolean) | nullthe item's text contains the searchWhich items match the search text. null shows every item, when the app finds the results itself.
onSelect(item: TItem, details: { newTab: boolean }) => void–Called when an item is chosen, with Enter or a click. With ⌘ or Ctrl held, newTab is true.
open *boolean–
onOpenChange *(open: boolean) => void–
aria-labelstring"Command palette"Names the dialog. Pass translated text.
containerDialogProps["container"]document.bodyThe element the dialog renders into, such as a shadow root.