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).
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),
)
})
}import { mount, signal, Text, View } from "nsh";
import { Command, type CommandItem } from "nsh/base";
const commands: CommandItem[] = [
{ id: "new", label: "New File", group: "File", shortcut: "mod+n" },
{ id: "open", label: "Open…", group: "File", shortcut: "mod+o" },
{ id: "theme", label: "Toggle Theme", group: "View", keywords: ["dark", "light"] },
{ id: "settings", label: "Settings", group: "View", shortcut: "mod+," },
];
function App() {
const chosen = signal("none");
return (
<Command
items={commands}
placeholder="Type a command or search…"
accessibilityLabel="Commands"
searchAccessibilityLabel="Search commands"
autoFocus
onSelect={({ id }) => chosen.set(id)}
class="w-96 overflow-hidden rounded-lg border border-border bg-popover"
searchBarClass="flex-row items-center gap-2 border-b border-border px-3"
searchClass="h-11 flex-1 text-sm text-foreground placeholder:text-muted-foreground"
listClass="max-h-80 p-1"
groupClass="px-2 py-1.5 text-xs font-medium text-muted-foreground"
separatorClass="-mx-1 my-1 h-px bg-border"
itemClass="group h-8 flex-row items-center rounded-md px-2 focus:bg-accent disabled:opacity-50"
empty={<Text class="py-6 text-center text-sm text-muted-foreground">No results.</Text>}
renderItem={(entry) => (
<View class="flex-1 flex-row items-center gap-2">
<Text class="flex-1 text-sm text-foreground group-focus:text-accent-foreground">{entry.label}</Text>
<Text class="text-xs text-muted-foreground">{entry.shortcutLabel ?? ""}</Text>
</View>
)}
/>
);
}
mount(App);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();
#Una paleta en un diálogo
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 poraccessibilityLabel; 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, poraccessibilityLabel); 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.