Элементы
Command
Палитра команд — это поле поиска над списком команд: пользователь вводит несколько букв, список сужается до подходящих команд, стрелки перемещают выделение, а Return запускает выделенную команду. Command отвечает за поведение такой палитры: фильтрацию, секции, выделение, клавиатуру и то, что слышат пользователи screen reader. Фильтрация выполняется нативно, а строки виртуализированы: создаются только видимые строки, поэтому палитра из тысяч команд, файлов или эмодзи фильтруется при каждом нажатии клавиши. Внешний вид определяет приложение: команда сама ничего не рисует, приложение стилизует каждую часть (itemClass, groupClass, searchClass, …) и рисует содержимое строк (renderItem: значок, подпись, клавиши сочетания).
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 происходит из nsh/base, crate nsh-base в Rust. Запрос принадлежит приложению, если оно задаёт query и обновляет его в onQueryChange; без этого поле поиска хранит собственный запрос, начиная с defaultQuery. Элементы определяются по их id, собственному идентификатору приложения, но никогда по подписи: onSelect({ id }) сообщает, какой из них выбрал пользователь. В Rust его создаёт command(): по одному сеттеру на каждое свойство (.items(..), .query(..), .render_item(..)), по одному методу-слушателю на каждое событие и типизированные методы стилей.
#Элементы
items — это команды по порядку, каждая из которых является CommandItem:
| Поле | Тип | Описание |
|---|---|---|
id | string | Что идентифицирует его в onSelect и onHighlightChange: уникален среди элементов. |
label | string | Что показывает его строка, если её не рисует renderItem, что произносят screen reader и с чем сопоставляется запрос. |
keywords | string[] | Дополнительные слова для сопоставления с запросом: синонимы, английское название рядом с переведённой подписью. |
group | string | Его секция: соседние элементы одной группы показываются под заголовком с этим названием. |
shortcut | string | Сочетание клавиш той же команды в записи keymap ("mod+shift+p"), которое покажет его строка. Приложение привязывает его через keymap.bind. |
disabled | boolean | Его нельзя выбрать: клавиши его пропускают, нажатия ничего не делают, применяются стили disabled:. |
checked | boolean | Он действует в данный момент (тема, язык): применяются стили 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(..) | boolean | true | Поле поиска над строками. Без него показываются все элементы, а команда становится одной остановкой фокуса клавиатуры с вводом для быстрого перехода. |
filterable | .filterable(..) | boolean | true | Запрос фильтрует элементы. Если отключить, показываются все элементы: на поиск приложение отвечает само (результаты сервера задаются как items). Новый запрос по-прежнему выделяет первый элемент. |
loading | .loading(..) | boolean | false | Выполняется поиск: empty ожидает, а список для screen reader занят. Покажите свой индикатор в searchSuffix. |
placeholder | .placeholder(..) | string | нет | Подсказка (placeholder) поля поиска. |
autoFocus | .auto_focus(..) | boolean | false | Поле поиска получает фокус клавиатуры, как только показано: палитра, которая открывается для ввода. |
separator | .separator(..) | boolean | true | Строка-разделитель между двумя показанными секциями, стилизуется через separatorClass; никогда не бывает первой, последней или двойной. |
accessibilityLabel | .accessibility_label(..) | string | нет | Как screen reader называют список («Commands») и поле поиска, пока не задан searchAccessibilityLabel. |
searchAccessibilityLabel | .search_accessibility_label(..) | string | accessibilityLabel | Как 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, searchStyle | focus: поля |
| Список | команда | listClass, listStyle | |
| Заголовок секции | список | groupClass, groupStyle, renderGroup | |
| Разделитель | список | separatorClass, separatorStyle | |
| Строка | список | itemClass, itemStyle | focus: (выделена), checked:, disabled:, hover:, active: |
| Содержимое строки | строка | renderItem | group-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 на клавиатуре выбирает выделенный элемент и оставляет поле для следующего запроса.
- Каждая платформа рисует то, что стилизует приложение: команду, её панель поиска, строки и их содержимое.