Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

Elementos

Command

Una paleta de comandos es un campo de búsqueda sobre una lista de comandos: el usuario escribe unas letras, la lista se reduce a los comandos que coinciden, las teclas de flecha mueven el resaltado y Return ejecuta el comando resaltado. Command se encarga del comportamiento de una: el filtrado, las secciones, el resaltado, el teclado y lo que oyen los lectores de pantalla. El filtrado se ejecuta de forma nativa y las filas están virtualizadas: solo se crean las filas visibles, así que una paleta de miles de comandos, archivos o emojis filtra en cada pulsación. El aspecto es el de la app: el comando no dibuja nada por sí mismo, la app da estilo a cada parte (itemClass, groupClass, searchClass, …) y dibuja el contenido de las filas (renderItem: un icono, la etiqueta, las teclas del atajo).

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"
            searchAccessibilityLabel="Search 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 proviene de nsh/base, el crate nsh-base de Rust. La consulta es de la app cuando esta define query, que actualiza en onQueryChange; sin ello, el campo de búsqueda conserva la suya, a partir de defaultQuery. Los elementos se identifican por su id, propio de la app, nunca por su etiqueta: onSelect({ id }) indica cuál eligió el usuario. En Rust, command() lo crea, con un setter por prop (.items(..), .query(..), .render_item(..)), un método de escucha por evento y los métodos de estilo tipados.

#Elementos

items son los comandos, en orden, cada uno un CommandItem:

Campo Tipo Descripción
id string Lo que lo identifica en onSelect y onHighlightChange: único entre los elementos.
label string Lo que muestra su fila a menos que renderItem la dibuje, lo que dicen los lectores de pantalla y lo que coincide con la consulta.
keywords string[] Más palabras con las que coincide la consulta: sinónimos, un nombre en inglés junto a una etiqueta traducida.
group string Su sección: los elementos adyacentes de un mismo grupo se muestran bajo un encabezado con este título.
shortcut string La combinación de teclas del mismo comando, tal como la nombra keymap ("mod+shift+p"), para que la muestre su fila. La app la enlaza con keymap.bind.
disabled boolean No se puede elegir: las teclas lo saltan, las pulsaciones no hacen nada, se aplican los estilos de disabled:.
checked boolean Es el que está en vigor (un tema, un idioma): se aplican los estilos de checked: y los lectores de pantalla indican que está marcado.

La consulta coincide con un elemento cuando su etiqueta o una de sus palabras clave la contiene, sin distinguir mayúsculas de minúsculas e ignorando los espacios alrededor. Los elementos conservan su orden, y una sección sin coincidencias desaparece junto con su encabezado. renderItem recibe un CommandEntry por cada fila visible: los campos del elemento y shortcutLabel, su shortcut tal como las sugerencias lo muestran aquí (⇧⌘P en las plataformas de Apple, Ctrl+Shift+P en el resto). Es lo que el elemento es, y el resaltado es el focus: de la fila: el dibujo se hace una vez por elemento y conserva sus vistas mientras el resaltado se mueve.

#Props

Prop Rust Tipo Valor por defecto Descripción
items .items(..) CommandItem[] [] Los comandos, en orden.
query .query(..) string ninguno La consulta (controlada), que la app define en onQueryChange.
defaultQuery .default_query(..) string ninguno La consulta inicial, mientras query no esté definida.
searchable .searchable(..) boolean true Un campo de búsqueda sobre las filas. Sin él se muestran todos los elementos, y el comando es una sola parada del foco de teclado, con escritura predictiva (typeahead).
filterable .filterable(..) boolean true La consulta filtra los elementos. Si se desactiva, se muestran todos: para una búsqueda que la app resuelve por sí misma (los resultados de un servidor definidos como items). Una nueva consulta sigue resaltando el primer elemento.
loading .loading(..) boolean false Hay una búsqueda en curso: empty espera, y la lista está ocupada para los lectores de pantalla. Muestra tu indicador en searchSuffix.
placeholder .placeholder(..) string ninguno El texto de marcador de posición del campo de búsqueda.
autoFocus .auto_focus(..) boolean false El campo de búsqueda recibe el foco de teclado al mostrarse: una paleta que se abre para escribir en ella.
separator .separator(..) boolean true Una fila separadora entre dos secciones visibles, con el estilo de separatorClass; nunca la primera, la última ni duplicada.
accessibilityLabel .accessibility_label(..) string ninguno Cómo llaman los lectores de pantalla a la lista (“Comandos”), y al campo de búsqueda mientras searchAccessibilityLabel no esté definida.
searchAccessibilityLabel .search_accessibility_label(..) string accessibilityLabel Cómo llaman los lectores de pantalla al campo de búsqueda: lo que busca (“Buscar comandos”).
header, footer .header(..), .footer(..) JSX.Element ninguno Lo que se muestra sobre el campo de búsqueda (un título) y bajo las filas (las sugerencias de teclas, un recuento).
empty .empty(..) JSX.Element ninguno Lo que se muestra mientras no hay ningún elemento: una consulta sin coincidencias, o sin elementos.
searchPrefix, searchSuffix .search_prefix(..), .search_suffix(..) JSX.Element ninguno Lo que se muestra antes y después del campo de búsqueda en su barra: un icono de búsqueda, un indicador de carga mientras loading.
renderItem .render_item(..) (entry: CommandEntry) => JSX.Element ninguno El contenido de una fila, para las filas visibles; sin él, una fila muestra la etiqueta del elemento.
renderGroup .render_group(..) (group: CommandGroup) => JSX.Element ninguno El encabezado de una sección ({ heading }); sin él, un encabezado muestra su título.
class, style métodos de estilo string, StyleProp<ViewStyle> ninguno El propio comando: su tamaño, marco y superficie.
searchBarClass, searchBarStyle .search_bar_class(..), .search_bar_style(..) string, StyleProp<ViewStyle> ninguno La fila de searchPrefix, el campo de búsqueda y searchSuffix.
searchClass, searchStyle .search_class(..), .search_style(..) string, StyleProp<TextStyle> ninguno El campo de búsqueda.
listClass, listStyle .list_class(..), .list_style(..) string, StyleProp<ViewStyle> ninguno La vista de desplazamiento de las filas. Dale una altura (max-h-80, o un comando con tamaño): las filas se desplazan dentro de ella, y solo se crean las visibles.
itemClass, itemStyle .item_class(..), .item_style(..) string, StyleProp<TextStyle> ninguno La fila de cada elemento y sus estados (abajo); sus estilos de texto alcanzan a la etiqueta.
groupClass, groupStyle .group_class(..), .group_style(..) string, StyleProp<TextStyle> ninguno El encabezado de cada sección.
separatorClass, separatorStyle .separator_class(..), .separator_style(..) string, StyleProp<ViewStyle> ninguno Cada separador: la altura y el color de una línea.

#Eventos

Evento Rust Carga útil Cuándo
onSelect .on_select(..) { id: string } El usuario eligió un elemento: Return, o una pulsación sobre su fila.
onHighlightChange .on_highlight_change(..) { highlighted: string | null } El resaltado se movió (las teclas, el puntero, una nueva consulta o nuevos elementos, highlight): para previsualizar lo que ejecutaría Return.
onQueryChange .on_query_change(..) { query: string } El usuario escribió en el campo de búsqueda, o Escape lo vació.
onCancel .on_cancel(..) ninguna Escape con la consulta vacía: cierra lo que contiene el comando (el open de un diálogo).

#Partes

Parte Dentro de Se estiliza con Estados
El comando class, style
header el comando el elemento de la app
La barra de búsqueda el comando searchBarClass, searchBarStyle
searchPrefix, el campo de búsqueda, searchSuffix la barra de búsqueda los elementos de la app; searchClass, searchStyle el focus: del campo
La lista el comando listClass, listStyle
El encabezado de una sección la lista groupClass, groupStyle, renderGroup
Un separador la lista separatorClass, separatorStyle
Una fila la lista itemClass, itemStyle focus: (resaltada), checked:, disabled:, hover:, active:
El contenido de una fila una fila renderItem group-focus:, group-checked:, group-disabled: bajo group en itemClass
empty el comando el elemento de la app
footer el comando el elemento de la app

Muestra la fila resaltada con focus: (consulta las guías de diseño).

#Ref

Método Descripción
focus() Da el foco de teclado al campo de búsqueda (sin él, al comando).
highlight(id) Resalta el elemento de id y desplaza su fila hasta dejarla visible; un elemento no visible o deshabilitado queda como está.
matchedCount() Promise<number>: cuántos elementos muestra la consulta, incluidos los deshabilitados.
let palette: CommandRef | undefined;

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

Coloca el comando en un Dialog y ábrelo con el atajo de la app. Escape vacía primero la consulta; con la consulta vacía es onCancel, donde la app cierra el diálogo.

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>;

#Una búsqueda que resuelve la app

Con filterable={false} se muestran todos los elementos que da la app: la app busca en onQueryChange (un servidor, una base de datos) y define items con los resultados, loading mientras espera.

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

#Accesibilidad

  • La lista tiene el rol "listbox", nombrada por accessibilityLabel; cada fila es un "option" nombrado por la etiqueta de su elemento, marcado y deshabilitado según lo esté su elemento; el encabezado de una sección es un "header", y un separador está oculto.
  • El campo de búsqueda es un campo de texto nombrado por searchAccessibilityLabel (si no, por accessibilityLabel); su marcador de posición es una pista que se muestra mientras está vacío.
  • El campo de búsqueda conserva el foco de teclado mientras las teclas de flecha mueven el resaltado, y los lectores de pantalla leen la etiqueta resaltada. Sin campo de búsqueda, la fila resaltada recibe el foco del lector de pantalla.
  • Mientras loading, la lista está ocupada.

Consulta Accesibilidad para etiquetas, roles y anuncios en general.

#Teclado

Tecla Qué hace
Escribir Filtra los elementos; se resalta la primera coincidencia habilitada.
Flecha abajo, Flecha arriba Resalta el elemento siguiente o el anterior, saltando los deshabilitados, del último al primero y viceversa.
Page Down, Page Up Mueve el resaltado según las filas visibles.
Return Elige el elemento resaltado (onSelect).
Escape Vacía la consulta; con la consulta vacía, notifica onCancel.
Home, End Sin campo de búsqueda: el primer o el último elemento (en el campo mueven su cursor).
Espacio, letras, dígitos Sin campo de búsqueda: Espacio elige, y las letras y los dígitos resaltan por escritura predictiva (typeahead).

#Notas por plataforma

  • Escritorio (macOS, Windows, Linux, GPUI) y web: el puntero resalta la fila sobre la que se mueve, y un clic elige al presionar el botón, de modo que el campo de búsqueda conserva el foco de teclado.
  • iOS, Android y Mac Catalyst: un toque elige una fila, y la tecla de retorno del teclado elige el elemento resaltado y mantiene el campo para la siguiente consulta.
  • Todas las plataformas dibujan lo que la app estiliza: el comando, su barra de búsqueda, sus filas y su contenido.