Éléments
Command
Une palette de commandes est un champ de recherche au-dessus d’une liste de commandes : l’utilisateur tape quelques lettres, la liste se réduit aux commandes qui correspondent, les flèches déplacent la mise en surbrillance et Entrée exécute la commande en surbrillance. Command gère le comportement d’une palette : le filtrage, les sections, la surbrillance, le clavier et ce qu’entendent les lecteurs d’écran. Le filtrage s’exécute nativement et les lignes sont virtualisées : seules les lignes visibles sont créées, de sorte qu’une palette de milliers de commandes, de fichiers ou d’emoji filtre à chaque frappe. L’apparence appartient à l’app : la commande ne dessine rien d’elle-même, l’app style chaque partie (itemClass, groupClass, searchClass, …) et dessine le contenu des lignes (renderItem : une icône, le libellé, les touches du raccourci).
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 provient de nsh/base, le crate nsh-base en Rust. La requête appartient à l’app lorsque celle-ci définit query, qu’elle met à jour dans onQueryChange ; sans cela, le champ de recherche garde la sienne, à partir de defaultQuery. Les éléments sont identifiés par leur id, propre à l’app, jamais par leur libellé : onSelect({ id }) indique lequel l’utilisateur a choisi. En Rust, command() la construit, avec un setter par prop (.items(..), .query(..), .render_item(..)), une méthode d’écouteur par événement et les méthodes de style typées.
#Éléments
items désigne les commandes, dans l’ordre, chacune étant un CommandItem :
| Champ | Type | Description |
|---|---|---|
id | string | Ce qui l’identifie dans onSelect et onHighlightChange : unique parmi les éléments. |
label | string | Ce que sa ligne affiche, sauf si renderItem la dessine, ce que disent les lecteurs d’écran et ce que la requête recherche. |
keywords | string[] | Des mots supplémentaires que la requête recherche : synonymes, un nom anglais à côté d’un libellé traduit. |
group | string | Sa section : les éléments adjacents d’un même groupe s’affichent sous un en-tête portant ce titre. |
shortcut | string | Le raccourci clavier de la même commande, tel que le nomme keymap ("mod+shift+p"), à afficher sur sa ligne. L’app le lie avec keymap.bind. |
disabled | boolean | Il ne peut pas être choisi : les touches le sautent, les appuis ne font rien, les styles disabled: s’appliquent. |
checked | boolean | C’est celui qui est en vigueur (un thème, une langue) : les styles checked: s’appliquent et les lecteurs d’écran indiquent qu’il est coché. |
La requête correspond à un élément lorsque son libellé ou l’un de ses mots-clés la contient, sans tenir compte de la casse ni des espaces qui l’entourent. Les éléments gardent leur ordre, et une section sans correspondance disparaît avec son en-tête. renderItem reçoit un CommandEntry pour chaque ligne visible : les champs de l’élément, et shortcutLabel, son shortcut tel que les indications l’affichent ici (⇧⌘P sur les plateformes d’Apple, Ctrl+Shift+P ailleurs). Il décrit ce qu’est l’élément, et la surbrillance est le focus: de la ligne : le dessin est réalisé une seule fois par élément et conserve ses vues pendant que la surbrillance se déplace.
#Props
| Prop | Rust | Type | Défaut | Description |
|---|---|---|---|---|
items | .items(..) | CommandItem[] | [] | Les commandes, dans l’ordre. |
query | .query(..) | string | aucun | La requête (contrôlée), que l’app définit dans onQueryChange. |
defaultQuery | .default_query(..) | string | aucun | La requête initiale, tant que query n’est pas définie. |
searchable | .searchable(..) | boolean | true | Un champ de recherche au-dessus des lignes. Sans lui, tous les éléments s’affichent, et la commande constitue un arrêt du focus clavier avec saisie prédictive. |
filterable | .filterable(..) | boolean | true | La requête filtre les éléments. Désactivée, tous les éléments s’affichent : pour une recherche dont l’app fournit elle-même les réponses (les résultats d’un serveur définis comme items). Une nouvelle requête met quand même en surbrillance le premier élément. |
loading | .loading(..) | boolean | false | Une recherche est en cours : empty attend, et la liste est occupée pour les lecteurs d’écran. Affichez votre indicateur dans searchSuffix. |
placeholder | .placeholder(..) | string | aucun | Le texte indicatif du champ de recherche. |
autoFocus | .auto_focus(..) | boolean | false | Le champ de recherche prend le focus clavier dès qu’il est affiché : une palette qui s’ouvre pour qu’on y tape. |
separator | .separator(..) | boolean | true | Une ligne de séparation entre deux sections affichées, stylée par separatorClass ; jamais en première ni en dernière position, ni en double. |
accessibilityLabel | .accessibility_label(..) | string | aucun | Le nom que les lecteurs d’écran donnent à la liste (« Commandes »), et au champ de recherche tant que searchAccessibilityLabel n’est pas défini. |
searchAccessibilityLabel | .search_accessibility_label(..) | string | accessibilityLabel | Le nom que les lecteurs d’écran donnent au champ de recherche : ce qu’il recherche (« Rechercher des commandes »). |
header, footer | .header(..), .footer(..) | JSX.Element | aucun | Ce qui s’affiche au-dessus du champ de recherche (un titre) et sous les lignes (les indications de touches, un compteur). |
empty | .empty(..) | JSX.Element | aucun | Ce qui s’affiche lorsqu’aucun élément ne l’est : une requête sans correspondance, aucun élément. |
searchPrefix, searchSuffix | .search_prefix(..), .search_suffix(..) | JSX.Element | aucun | Ce qui s’affiche avant et après le champ de recherche dans sa barre : une icône de recherche, un indicateur de progression pendant loading. |
renderItem | .render_item(..) | (entry: CommandEntry) => JSX.Element | aucun | Le contenu d’une ligne, pour les lignes visibles ; sans lui, une ligne affiche le libellé de l’élément. |
renderGroup | .render_group(..) | (group: CommandGroup) => JSX.Element | aucun | L’en-tête d’une section ({ heading }) ; sans lui, un en-tête affiche son titre. |
class, style | méthodes de style | string, StyleProp<ViewStyle> | aucun | La commande elle-même : sa taille, son cadre et sa surface. |
searchBarClass, searchBarStyle | .search_bar_class(..), .search_bar_style(..) | string, StyleProp<ViewStyle> | aucun | La ligne de searchPrefix, du champ de recherche et de searchSuffix. |
searchClass, searchStyle | .search_class(..), .search_style(..) | string, StyleProp<TextStyle> | aucun | Le champ de recherche. |
listClass, listStyle | .list_class(..), .list_style(..) | string, StyleProp<ViewStyle> | aucun | La vue défilante des lignes. Donnez-lui une hauteur (max-h-80, ou une commande dimensionnée) : les lignes défilent à l’intérieur, et seules celles visibles sont créées. |
itemClass, itemStyle | .item_class(..), .item_style(..) | string, StyleProp<TextStyle> | aucun | La ligne de chaque élément et ses états (ci-dessous) ; ses styles de texte atteignent le libellé. |
groupClass, groupStyle | .group_class(..), .group_style(..) | string, StyleProp<TextStyle> | aucun | L’en-tête de chaque section. |
separatorClass, separatorStyle | .separator_class(..), .separator_style(..) | string, StyleProp<ViewStyle> | aucun | Chaque séparateur : la hauteur et la couleur d’un trait. |
#Événements
| Événement | Rust | Données | Quand |
|---|---|---|---|
onSelect | .on_select(..) | { id: string } | L’utilisateur a choisi un élément : Entrée, ou un appui sur sa ligne. |
onHighlightChange | .on_highlight_change(..) | { highlighted: string | null } | La surbrillance s’est déplacée (les touches, le pointeur, une nouvelle requête ou de nouveaux éléments, highlight) : pour prévisualiser ce qu’exécuterait Entrée. |
onQueryChange | .on_query_change(..) | { query: string } | L’utilisateur a tapé dans le champ de recherche, ou Échap l’a vidé. |
onCancel | .on_cancel(..) | aucune | Échap avec une requête vide : fermer ce qui contient la commande (le open d’une boîte de dialogue). |
#Parties
| Partie | Dans | Stylée avec | États |
|---|---|---|---|
| La commande | class, style | ||
header | la commande | l’élément de l’app | |
| La barre de recherche | la commande | searchBarClass, searchBarStyle | |
searchPrefix, le champ de recherche, searchSuffix | la barre de recherche | les éléments de l’app ; searchClass, searchStyle | le focus: du champ |
| La liste | la commande | listClass, listStyle | |
| L’en-tête d’une section | la liste | groupClass, groupStyle, renderGroup | |
| Un séparateur | la liste | separatorClass, separatorStyle | |
| Une ligne | la liste | itemClass, itemStyle | focus: (en surbrillance), checked:, disabled:, hover:, active: |
| Le contenu d’une ligne | une ligne | renderItem | group-focus:, group-checked:, group-disabled: sous group dans itemClass |
empty | la commande | l’élément de l’app | |
footer | la commande | l’élément de l’app |
Affichez la ligne en surbrillance avec focus: (voir Guides de conception).
#Ref
| Méthode | Description |
|---|---|
focus() | Donne le focus clavier au champ de recherche (sans champ, à la commande). |
highlight(id) | Met en surbrillance l’élément de id et fait défiler sa ligne jusqu’à la zone visible ; un élément non affiché ou désactivé reste inchangé. |
matchedCount() | Promise<number> : combien d’éléments la requête affiche, y compris les éléments désactivés. |
let palette: CommandRef | undefined;
<Command ref={(r) => (palette = r)} items={items} />;
palette?.focus();
#Une palette dans une boîte de dialogue
Placez la commande dans un Dialog et ouvrez-le avec le raccourci de l’app. Échap vide d’abord la requête ; avec une requête vide, c’est onCancel, où l’app ferme la boîte de dialogue.
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>;
#Une recherche dont l’app fournit les réponses
Avec filterable={false}, tous les éléments fournis par l’app s’affichent : l’app effectue la recherche dans onQueryChange (un serveur, une base de données) et définit items avec les résultats, loading pendant qu’elle attend.
<Command
items={results()}
filterable={false}
loading={searching()}
onQueryChange={({ query }) => search(query)}
searchSuffix={searching() ? <Spinner /> : undefined}
empty={<Text>No matches</Text>}
/>
#Accessibilité
- La liste a le rôle
"listbox", nommée paraccessibilityLabel; chaque ligne est un"option"nommé d’après le libellé de son élément, coché et désactivé comme l’est son élément ; l’en-tête d’une section est un"header", un séparateur est masqué. - Le champ de recherche est un champ de texte nommé par
searchAccessibilityLabel(sinonaccessibilityLabel) ; son texte indicatif est un conseil affiché tant qu’il est vide. - Le champ de recherche garde le focus clavier pendant que les flèches déplacent la surbrillance, et les lecteurs d’écran énoncent le libellé en surbrillance. Sans champ de recherche, la ligne en surbrillance reçoit le focus du lecteur d’écran.
- Pendant
loading, la liste est occupée.
Voir Accessibilité pour les libellés, les rôles et les annonces en général.
#Clavier
| Touche | Effet |
|---|---|
| Saisie | Filtre les éléments ; la première correspondance activée est mise en surbrillance. |
| Bas, Haut | Met en surbrillance l’élément suivant ou précédent, en sautant les éléments désactivés, du dernier au premier et inversement. |
| Page suivante, Page précédente | Déplace la surbrillance du nombre de lignes visibles. |
| Entrée | Choisit l’élément en surbrillance (onSelect). |
| Échap | Vide la requête ; avec une requête vide, signale onCancel. |
| Début, Fin | Sans champ de recherche : le premier ou le dernier élément (dans le champ, elles déplacent son curseur). |
| Espace, lettres, chiffres | Sans champ de recherche : Espace choisit, les lettres et les chiffres mettent en surbrillance par saisie prédictive. |
#Notes sur les plateformes
- Ordinateurs de bureau (macOS, Windows, Linux, GPUI) et web : le pointeur met en surbrillance la ligne qu’il survole, et un clic choisit dès que le bouton est enfoncé, de sorte que le champ de recherche garde le focus clavier.
- iOS, Android et Mac Catalyst : un appui choisit une ligne, et la touche Retour du clavier choisit l’élément en surbrillance et conserve le champ pour la requête suivante.
- Chaque plateforme dessine ce que l’app style : la commande, sa barre de recherche, ses lignes et leur contenu.