Эта страница переведена машинным способом и может содержать ошибки. Читать оригинал на английском

Элементы

Command

Палитра команд — это поле поиска над списком команд: пользователь вводит несколько букв, список сужается до подходящих команд, стрелки перемещают выделение, а Return запускает выделенную команду. Command отвечает за поведение такой палитры: фильтрацию, секции, выделение, клавиатуру и то, что слышат пользователи screen reader. Фильтрация выполняется нативно, а строки виртуализированы: создаются только видимые строки, поэтому палитра из тысяч команд, файлов или эмодзи фильтруется при каждом нажатии клавиши. Внешний вид определяет приложение: команда сама ничего не рисует, приложение стилизует каждую часть (itemClass, groupClass, searchClass, …) и рисует содержимое строк (renderItem: значок, подпись, клавиши сочетания).

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 происходит из nsh/base, crate nsh-base в Rust. Запрос принадлежит приложению, если оно задаёт query и обновляет его в onQueryChange; без этого поле поиска хранит собственный запрос, начиная с defaultQuery. Элементы определяются по их id, собственному идентификатору приложения, но никогда по подписи: onSelect({ id }) сообщает, какой из них выбрал пользователь. В Rust его создаёт command(): по одному сеттеру на каждое свойство (.items(..), .query(..), .render_item(..)), по одному методу-слушателю на каждое событие и типизированные методы стилей.

#Элементы

items — это команды по порядку, каждая из которых является CommandItem:

ПолеТипОписание
idstringЧто идентифицирует его в onSelect и onHighlightChange: уникален среди элементов.
labelstringЧто показывает его строка, если её не рисует renderItem, что произносят screen reader и с чем сопоставляется запрос.
keywordsstring[]Дополнительные слова для сопоставления с запросом: синонимы, английское название рядом с переведённой подписью.
groupstringЕго секция: соседние элементы одной группы показываются под заголовком с этим названием.
shortcutstringСочетание клавиш той же команды в записи keymap ("mod+shift+p"), которое покажет его строка. Приложение привязывает его через keymap.bind.
disabledbooleanЕго нельзя выбрать: клавиши его пропускают, нажатия ничего не делают, применяются стили disabled:.
checkedbooleanОн действует в данный момент (тема, язык): применяются стили checked:, а screen reader сообщают, что он отмечен.

Запрос соответствует элементу, если его подпись или одно из ключевых слов содержит запрос, без учёта регистра и окружающих пробелов. Элементы сохраняют свой порядок, а секция без совпадений исчезает вместе со своим заголовком. renderItem получает CommandEntry для каждой видимой строки: поля элемента и shortcutLabel — его shortcut в том виде, в каком подсказки показывают его здесь (⇧⌘P на платформах Apple, Ctrl+Shift+P на остальных). Это то, чем является элемент, а выделение — это focus: строки: содержимое рисуется один раз для каждого элемента и сохраняет свои представления, пока выделение перемещается.

#Свойства

СвойствоRustТипПо умолчаниюОписание
items.items(..)CommandItem[][]Команды по порядку.
query.query(..)stringнетЗапрос (управляемый), который приложение задаёт в onQueryChange.
defaultQuery.default_query(..)stringнетНачальный запрос, пока не задан query.
searchable.searchable(..)booleantrueПоле поиска над строками. Без него показываются все элементы, а команда становится одной остановкой фокуса клавиатуры с вводом для быстрого перехода.
filterable.filterable(..)booleantrueЗапрос фильтрует элементы. Если отключить, показываются все элементы: на поиск приложение отвечает само (результаты сервера задаются как items). Новый запрос по-прежнему выделяет первый элемент.
loading.loading(..)booleanfalseВыполняется поиск: empty ожидает, а список для screen reader занят. Покажите свой индикатор в searchSuffix.
placeholder.placeholder(..)stringнетПодсказка (placeholder) поля поиска.
autoFocus.auto_focus(..)booleanfalseПоле поиска получает фокус клавиатуры, как только показано: палитра, которая открывается для ввода.
separator.separator(..)booleantrueСтрока-разделитель между двумя показанными секциями, стилизуется через separatorClass; никогда не бывает первой, последней или двойной.
accessibilityLabel.accessibility_label(..)stringнетКак screen reader называют список («Commands») и поле поиска, пока не задан searchAccessibilityLabel.
searchAccessibilityLabel.search_accessibility_label(..)stringaccessibilityLabelКак screen reader называют поле поиска: что оно ищет («Search commands»).
header, footer.header(..), .footer(..)JSX.ElementнетЧто показывается над полем поиска (заголовок) и под строками (подсказки по клавишам, счётчик).
empty.empty(..)JSX.ElementнетЧто показывается, когда нет ни одного элемента: запрос без совпадений, нет элементов.
searchPrefix, searchSuffix.search_prefix(..), .search_suffix(..)JSX.ElementнетЧто показывается перед полем поиска и после него в его панели: значок поиска, индикатор загрузки, пока loading.
renderItem.render_item(..)(entry: CommandEntry) => JSX.ElementнетСодержимое строки для видимых строк; без него строка показывает подпись элемента.
renderGroup.render_group(..)(group: CommandGroup) => JSX.ElementнетЗаголовок секции ({ heading }); без него заголовок показывает своё название.
class, styleметоды стилейstring, StyleProp<ViewStyle>нетСама команда: её размер, рамка и поверхность.
searchBarClass, searchBarStyle.search_bar_class(..), .search_bar_style(..)string, StyleProp<ViewStyle>нетСтрока с searchPrefix, полем поиска и searchSuffix.
searchClass, searchStyle.search_class(..), .search_style(..)string, StyleProp<TextStyle>нетПоле поиска.
listClass, listStyle.list_class(..), .list_style(..)string, StyleProp<ViewStyle>нетПрокручиваемая область со строками. Задайте ей высоту (max-h-80 или команду с заданным размером): строки прокручиваются внутри неё, и создаются только видимые.
itemClass, itemStyle.item_class(..), .item_style(..)string, StyleProp<TextStyle>нетСтрока каждого элемента и её состояния (ниже); стили текста применяются к подписи.
groupClass, groupStyle.group_class(..), .group_style(..)string, StyleProp<TextStyle>нетЗаголовок каждой секции.
separatorClass, separatorStyle.separator_class(..), .separator_style(..)string, StyleProp<ViewStyle>нетКаждый разделитель: высота и цвет линии.

#События

СобытиеRustДанныеКогда
onSelect.on_select(..){ id: string }Пользователь выбрал элемент: Return или нажатие на его строку.
onHighlightChange.on_highlight_change(..){ highlighted: string | null }Выделение переместилось (клавиши, указатель, новый запрос или новые элементы, highlight): чтобы показать предварительный просмотр того, что запустит Return.
onQueryChange.on_query_change(..){ query: string }Пользователь ввёл текст в поле поиска или Escape очистил его.
onCancel.on_cancel(..)нетEscape при пустом запросе: закройте то, что содержит команду (open диалога).

#Части

ЧастьВнутриСтилизуется черезСостояния
Командаclass, style
headerкомандаэлемент приложения
Панель поискакомандаsearchBarClass, searchBarStyle
searchPrefix, поле поиска, searchSuffixпанель поискаэлементы приложения; searchClass, searchStylefocus: поля
СписоккомандаlistClass, listStyle
Заголовок секциисписокgroupClass, groupStyle, renderGroup
РазделительсписокseparatorClass, separatorStyle
СтрокасписокitemClass, itemStylefocus: (выделена), checked:, disabled:, hover:, active:
Содержимое строкистрокаrenderItemgroup-focus:, group-checked:, group-disabled: под group в itemClass
emptyкомандаэлемент приложения
footerкомандаэлемент приложения

Показывайте выделенную строку через focus: (см. Руководства по дизайну).

#Ref

МетодОписание
focus()Передаёт фокус клавиатуры полю поиска (если его нет — команде).
highlight(id)Выделяет элемент с id и прокручивает его строку в область видимости; элемент, который не показан или отключён, остаётся как есть.
matchedCount()Promise<number>: сколько элементов показывает запрос, включая отключённые.
let palette: CommandRef | undefined;

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

#Палитра в диалоге

Поместите команду в Dialog и откройте её сочетанием клавиш приложения. Сначала Escape очищает запрос; при пустом запросе срабатывает onCancel, в котором приложение закрывает диалог.

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

#Поиск, на который отвечает приложение

С filterable={false} показываются все элементы, которые передало приложение: приложение выполняет поиск в onQueryChange (сервер, база данных) и задаёт items с результатами, а loading — пока ждёт.

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

#Доступность

  • У списка роль "listbox" с именем из accessibilityLabel; каждая строка — это "option" с именем из подписи её элемента, отмеченная и отключённая так же, как её элемент; заголовок секции — это "header", разделитель скрыт.
  • Поле поиска — текстовое поле с именем из searchAccessibilityLabel (иначе accessibilityLabel); его placeholder — это подсказка, которая показывается, пока поле пусто.
  • Поле поиска сохраняет фокус клавиатуры, пока стрелки перемещают выделение, а screen reader произносят подпись выделенного элемента. Без поля поиска выделенная строка получает фокус screen reader.
  • Пока loading, список занят.

Общие сведения о подписях, ролях и объявлениях см. в разделе Доступность.

#Клавиатура

КлавишаДействие
Ввод текстаФильтрует элементы; выделяется первое подходящее доступное совпадение.
Down, UpВыделяет следующий или предыдущий элемент, пропуская отключённые, от последнего к первому и обратно.
Page Down, Page UpПеремещает выделение на число видимых строк.
ReturnВыбирает выделенный элемент (onSelect).
EscapeОчищает запрос; при пустом запросе сообщает onCancel.
Home, EndБез поля поиска: первый или последний элемент (в поле они перемещают каретку).
Space, буквы, цифрыБез поля поиска: Space выбирает, буквы и цифры выделяют по быстрому вводу (typeahead).

#Примечания по платформам

  • Настольные платформы (macOS, Windows, Linux, GPUI) и веб: указатель выделяет строку, над которой проходит, а щелчок выбирает элемент в момент нажатия кнопки, поэтому поле поиска сохраняет фокус клавиатуры.
  • iOS, Android и Mac Catalyst: касание выбирает строку, а клавиша return на клавиатуре выбирает выделенный элемент и оставляет поле для следующего запроса.
  • Каждая платформа рисует то, что стилизует приложение: команду, её панель поиска, строки и их содержимое.