Cette page a été traduite automatiquement et peut contenir des erreurs. Lire l'original en anglais

É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).

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

ChampTypeDescription
idstringCe qui l’identifie dans onSelect et onHighlightChange : unique parmi les éléments.
labelstringCe que sa ligne affiche, sauf si renderItem la dessine, ce que disent les lecteurs d’écran et ce que la requête recherche.
keywordsstring[]Des mots supplémentaires que la requête recherche : synonymes, un nom anglais à côté d’un libellé traduit.
groupstringSa section : les éléments adjacents d’un même groupe s’affichent sous un en-tête portant ce titre.
shortcutstringLe 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.
disabledbooleanIl ne peut pas être choisi : les touches le sautent, les appuis ne font rien, les styles disabled: s’appliquent.
checkedbooleanC’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

PropRustTypeDéfautDescription
items.items(..)CommandItem[][]Les commandes, dans l’ordre.
query.query(..)stringaucunLa requête (contrôlée), que l’app définit dans onQueryChange.
defaultQuery.default_query(..)stringaucunLa requête initiale, tant que query n’est pas définie.
searchable.searchable(..)booleantrueUn 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(..)booleantrueLa 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(..)booleanfalseUne recherche est en cours : empty attend, et la liste est occupée pour les lecteurs d’écran. Affichez votre indicateur dans searchSuffix.
placeholder.placeholder(..)stringaucunLe texte indicatif du champ de recherche.
autoFocus.auto_focus(..)booleanfalseLe 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(..)booleantrueUne 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(..)stringaucunLe 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(..)stringaccessibilityLabelLe nom que les lecteurs d’écran donnent au champ de recherche : ce qu’il recherche (« Rechercher des commandes »).
header, footer.header(..), .footer(..)JSX.ElementaucunCe qui s’affiche au-dessus du champ de recherche (un titre) et sous les lignes (les indications de touches, un compteur).
empty.empty(..)JSX.ElementaucunCe 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.ElementaucunCe 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.ElementaucunLe 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.ElementaucunL’en-tête d’une section ({ heading }) ; sans lui, un en-tête affiche son titre.
class, styleméthodes de stylestring, StyleProp<ViewStyle>aucunLa commande elle-même : sa taille, son cadre et sa surface.
searchBarClass, searchBarStyle.search_bar_class(..), .search_bar_style(..)string, StyleProp<ViewStyle>aucunLa ligne de searchPrefix, du champ de recherche et de searchSuffix.
searchClass, searchStyle.search_class(..), .search_style(..)string, StyleProp<TextStyle>aucunLe champ de recherche.
listClass, listStyle.list_class(..), .list_style(..)string, StyleProp<ViewStyle>aucunLa 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>aucunLa 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>aucunL’en-tête de chaque section.
separatorClass, separatorStyle.separator_class(..), .separator_style(..)string, StyleProp<ViewStyle>aucunChaque séparateur : la hauteur et la couleur d’un trait.

#Événements

ÉvénementRustDonnéesQuand
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

PartieDansStylée avecÉtats
La commandeclass, style
headerla commandel’élément de l’app
La barre de recherchela commandesearchBarClass, searchBarStyle
searchPrefix, le champ de recherche, searchSuffixla barre de rechercheles éléments de l’app ; searchClass, searchStylele focus: du champ
La listela commandelistClass, listStyle
L’en-tête d’une sectionla listegroupClass, groupStyle, renderGroup
Un séparateurla listeseparatorClass, separatorStyle
Une lignela listeitemClass, itemStylefocus: (en surbrillance), checked:, disabled:, hover:, active:
Le contenu d’une ligneune lignerenderItemgroup-focus:, group-checked:, group-disabled: sous group dans itemClass
emptyla commandel’élément de l’app
footerla commandel’élément de l’app

Affichez la ligne en surbrillance avec focus: (voir Guides de conception).

#Ref

MéthodeDescription
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 par accessibilityLabel ; 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 (sinon accessibilityLabel) ; 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

ToucheEffet
SaisieFiltre les éléments ; la première correspondance activée est mise en surbrillance.
Bas, HautMet 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édenteDéplace la surbrillance du nombre de lignes visibles.
EntréeChoisit l’élément en surbrillance (onSelect).
ÉchapVide la requête ; avec une requête vide, signale onCancel.
Début, FinSans champ de recherche : le premier ou le dernier élément (dans le champ, elles déplacent son curseur).
Espace, lettres, chiffresSans 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.