Elementi
Command
Una command palette è un campo di ricerca su un elenco di comandi: l’utente digita qualche lettera, l’elenco si restringe ai comandi che corrispondono, i tasti freccia spostano l’evidenziazione e Invio esegue il comando evidenziato. Command gestisce il comportamento di una palette: filtro, sezioni, evidenziazione, tastiera e ciò che sentono gli screen reader. Il filtro viene eseguito in modo nativo e le righe sono virtualizzate: vengono create solo le righe visibili, quindi una palette di migliaia di comandi, file o emoji filtra a ogni tasto premuto. L’aspetto è dell’app: il comando non disegna nulla di suo, l’app stila ogni parte (itemClass, groupClass, searchClass, …) e disegna il contenuto delle righe (renderItem: un’icona, l’etichetta, i tasti della scorciatoia).
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 deriva da nsh/base, il crate nsh-base di Rust. La query è dell’app quando questa imposta query, che aggiorna in onQueryChange; senza, il campo di ricerca mantiene una query propria, a partire da defaultQuery. Gli elementi sono identificati dal loro id, proprio dell’app, mai dalla loro etichetta: onSelect({ id }) dice quale ha scelto l’utente. In Rust lo crea command(), con un setter per ogni prop (.items(..), .query(..), .render_item(..)), un metodo listener per ogni evento e i metodi di stile tipizzati.
#Items
items sono i comandi, in ordine, ciascuno un CommandItem:
| Campo | Tipo | Descrizione |
|---|---|---|
id | string | Ciò che lo identifica in onSelect e onHighlightChange: univoco tra gli elementi. |
label | string | Ciò che mostra la sua riga a meno che renderItem non la disegni, ciò che dicono gli screen reader e ciò con cui corrisponde la query. |
keywords | string[] | Altre parole con cui corrisponde la query: sinonimi, un nome inglese accanto a un’etichetta tradotta. |
group | string | La sua sezione: gli elementi adiacenti di uno stesso gruppo compaiono sotto un’intestazione con questo titolo. |
shortcut | string | La combinazione di tasti dello stesso comando, come la chiama keymap ("mod+shift+p"), da mostrare nella sua riga. L’app la associa con keymap.bind. |
disabled | boolean | Non può essere scelto: i tasti lo saltano, le pressioni non fanno nulla, si applicano gli stili disabled:. |
checked | boolean | È quello attualmente in uso (un tema, una lingua): si applicano gli stili checked: e gli screen reader dicono che è selezionato. |
La query corrisponde a un elemento quando la sua etichetta o una delle sue parole chiave la contiene, senza distinguere maiuscole e minuscole e ignorando gli spazi attorno. Gli elementi mantengono il loro ordine, e una sezione senza corrispondenze scompare insieme alla sua intestazione. renderItem riceve un CommandEntry per ogni riga visibile: i campi dell’elemento e shortcutLabel, il suo shortcut come lo mostrano i suggerimenti in questo contesto (⇧⌘P sulle piattaforme Apple, Ctrl+Shift+P altrove). È ciò che l’elemento è, mentre l’evidenziazione è il focus: della riga: il disegno viene realizzato una sola volta per elemento e mantiene le sue view mentre l’evidenziazione si sposta.
#Props
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
items | .items(..) | CommandItem[] | [] | I comandi, in ordine. |
query | .query(..) | string | nessuno | La query (controllata), che l’app imposta in onQueryChange. |
defaultQuery | .default_query(..) | string | nessuno | La query iniziale, finché query non è impostato. |
searchable | .searchable(..) | boolean | true | Un campo di ricerca sopra le righe. Senza di esso compaiono tutti gli elementi e il comando è una sola tappa del focus da tastiera, con typeahead. |
filterable | .filterable(..) | boolean | true | La query filtra gli elementi. Se disattivato, compaiono tutti gli elementi: per una ricerca a cui risponde l’app stessa (i risultati di un server impostati come items). Una nuova query evidenzia comunque il primo elemento. |
loading | .loading(..) | boolean | false | Una ricerca è in corso: empty è in attesa e l’elenco risulta occupato per gli screen reader. Mostra il tuo indicatore in searchSuffix. |
placeholder | .placeholder(..) | string | nessuno | Il placeholder del campo di ricerca. |
autoFocus | .auto_focus(..) | boolean | false | Il campo di ricerca prende il focus da tastiera appena mostrato: una palette che si apre per essere digitata. |
separator | .separator(..) | boolean | true | Una riga separatore tra due sezioni visibili, stilizzata da separatorClass; mai per prima, per ultima o doppia. |
accessibilityLabel | .accessibility_label(..) | string | nessuno | Come gli screen reader chiamano l’elenco (“Commands”) e il campo di ricerca finché searchAccessibilityLabel non è impostato. |
searchAccessibilityLabel | .search_accessibility_label(..) | string | accessibilityLabel | Come gli screen reader chiamano il campo di ricerca: ciò che cerca (“Search commands”). |
header, footer | .header(..), .footer(..) | JSX.Element | nessuno | Ciò che compare sopra il campo di ricerca (un titolo) e sotto le righe (i suggerimenti dei tasti, un conteggio). |
empty | .empty(..) | JSX.Element | nessuno | Ciò che compare quando non c’è alcun elemento da mostrare: una query senza corrispondenze, nessun elemento. |
searchPrefix, searchSuffix | .search_prefix(..), .search_suffix(..) | JSX.Element | nessuno | Ciò che compare prima e dopo il campo di ricerca nella sua barra: un’icona di ricerca, uno spinner mentre loading. |
renderItem | .render_item(..) | (entry: CommandEntry) => JSX.Element | nessuno | Il contenuto di una riga, per le righe visibili; senza di esso una riga mostra l’etichetta dell’elemento. |
renderGroup | .render_group(..) | (group: CommandGroup) => JSX.Element | nessuno | L’intestazione di una sezione ({ heading }); senza di esso un’intestazione mostra il proprio titolo. |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | Il comando stesso: dimensione, riquadro e superficie. |
searchBarClass, searchBarStyle | .search_bar_class(..), .search_bar_style(..) | string, StyleProp<ViewStyle> | nessuno | La riga di searchPrefix, il campo di ricerca e searchSuffix. |
searchClass, searchStyle | .search_class(..), .search_style(..) | string, StyleProp<TextStyle> | nessuno | Il campo di ricerca. |
listClass, listStyle | .list_class(..), .list_style(..) | string, StyleProp<ViewStyle> | nessuno | La scroll view delle righe. Dalle un’altezza (max-h-80, o un comando con dimensione): le righe scorrono al suo interno e vengono create solo quelle visibili. |
itemClass, itemStyle | .item_class(..), .item_style(..) | string, StyleProp<TextStyle> | nessuno | La riga di ogni elemento e i suoi stati (sotto); i suoi stili di testo raggiungono l’etichetta. |
groupClass, groupStyle | .group_class(..), .group_style(..) | string, StyleProp<TextStyle> | nessuno | L’intestazione di ogni sezione. |
separatorClass, separatorStyle | .separator_class(..), .separator_style(..) | string, StyleProp<ViewStyle> | nessuno | Ogni separatore: l’altezza e il colore di una linea. |
#Events
| Evento | Rust | Payload | Quando |
|---|---|---|---|
onSelect | .on_select(..) | { id: string } | L’utente ha scelto un elemento: Invio, o una pressione sulla sua riga. |
onHighlightChange | .on_highlight_change(..) | { highlighted: string | null } | L’evidenziazione si è spostata (i tasti, il puntatore, una nuova query o nuovi elementi, highlight): per anticipare ciò che eseguirebbe Invio. |
onQueryChange | .on_query_change(..) | { query: string } | L’utente ha digitato nel campo di ricerca, oppure Escape lo ha svuotato. |
onCancel | .on_cancel(..) | nessuno | Escape con una query vuota: chiude ciò che contiene il comando (il open di una finestra di dialogo). |
#Parts
| Parte | Dentro | Stilizzata con | Stati |
|---|---|---|---|
| Il comando | class, style | ||
header | il comando | l’elemento dell’app | |
| La barra di ricerca | il comando | searchBarClass, searchBarStyle | |
searchPrefix, il campo di ricerca, searchSuffix | la barra di ricerca | gli elementi dell’app; searchClass, searchStyle | il focus: del campo |
| L’elenco | il comando | listClass, listStyle | |
| L’intestazione di una sezione | l’elenco | groupClass, groupStyle, renderGroup | |
| Un separatore | l’elenco | separatorClass, separatorStyle | |
| Una riga | l’elenco | itemClass, itemStyle | focus: (evidenziata), checked:, disabled:, hover:, active: |
| Il contenuto di una riga | una riga | renderItem | group-focus:, group-checked:, group-disabled: sotto group in itemClass |
empty | il comando | l’elemento dell’app | |
footer | il comando | l’elemento dell’app |
Mostra la riga evidenziata con focus: (vedi Design guides).
#Ref
| Metodo | Descrizione |
|---|---|
focus() | Dà il focus da tastiera al campo di ricerca (senza di esso, al comando). |
highlight(id) | Evidenzia l’elemento di id e porta la sua riga in vista scorrendo; un elemento non visibile o disabilitato resta com’è. |
matchedCount() | Promise<number>: quanti elementi mostra la query, inclusi quelli disabilitati. |
let palette: CommandRef | undefined;
<Command ref={(r) => (palette = r)} items={items} />;
palette?.focus();
#A palette in a dialog
Metti il comando in un Dialog e aprilo con la scorciatoia dell’app. Escape svuota prima la query; con una query vuota è onCancel, dove l’app chiude la finestra di dialogo.
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
Con filterable={false} compaiono tutti gli elementi che l’app fornisce: l’app cerca in onQueryChange (un server, un database) e imposta items con i risultati, loading mentre attende.
<Command
items={results()}
filterable={false}
loading={searching()}
onQueryChange={({ query }) => search(query)}
searchSuffix={searching() ? <Spinner /> : undefined}
empty={<Text>No matches</Text>}
/>
#Accessibility
- L’elenco ha il ruolo
"listbox", nominato daaccessibilityLabel; ogni riga è un"option"nominato dall’etichetta del suo elemento, selezionato e disabilitato come lo è il suo elemento; l’intestazione di una sezione è un"header", un separatore è nascosto. - Il campo di ricerca è un campo di testo nominato da
searchAccessibilityLabel(altrimentiaccessibilityLabel); il suo placeholder è un suggerimento mostrato finché è vuoto. - Il campo di ricerca mantiene il focus da tastiera mentre i tasti freccia spostano l’evidenziazione, e gli screen reader leggono l’etichetta evidenziata. Senza un campo di ricerca, la riga evidenziata prende il focus dello screen reader.
- Mentre
loading, l’elenco è occupato.
Vedi Accessibility per etichette, ruoli e annunci in generale.
#Keyboard
| Tasto | Cosa fa |
|---|---|
| Digitazione | Filtra gli elementi; la prima corrispondenza abilitata viene evidenziata. |
| Giù, Su | Evidenzia l’elemento successivo o precedente, saltando quelli disabilitati, dall’ultimo al primo e viceversa. |
| Pag. Giù, Pag. Su | Sposta l’evidenziazione del numero di righe visibili. |
| Invio | Sceglie l’elemento evidenziato (onSelect). |
| Escape | Svuota la query; con una query vuota, segnala onCancel. |
| Home, Fine | Senza un campo di ricerca: il primo o l’ultimo elemento (nel campo spostano il cursore). |
| Spazio, lettere, cifre | Senza un campo di ricerca: Spazio sceglie, lettere e cifre evidenziano tramite typeahead. |
#Platform notes
- Desktop (macOS, Windows, Linux, GPUI) e web: il puntatore evidenzia la riga su cui passa e un clic sceglie nel momento in cui il pulsante viene premuto, così il campo di ricerca mantiene il focus da tastiera.
- iOS, Android e Mac Catalyst: un tocco sceglie una riga e il tasto di invio della tastiera sceglie l’elemento evidenziato e mantiene il campo per la query successiva.
- Ogni piattaforma disegna ciò che l’app stila: il comando, la sua barra di ricerca, le sue righe e il loro contenuto.