Элементы
Tree
Tree показывает элементы, которые содержат другие элементы: файлы и папки проекта, разделы боковой панели настроек, структуру документа. Tree отвечает за поведение такого дерева: какие элементы раскрыты, какой выбран, как работает клавиатура и что слышат пользователи скринридеров. Строки дерева виртуализированы: создаются только видимые строки, а дочерние элементы свёрнутого элемента не создаются вовсе, поэтому дерево из тысяч элементов открывается и прокручивается мгновенно. Внешний вид определяет приложение: само дерево ничего не рисует, приложение стилизует каждую строку (itemClass) и рисует её содержимое (renderItem: значок, подпись с отступом по глубине элемента, шеврон, бейдж).
import { mount, signal, Text, View } from "nsh";
import { Button, Tree, type TreeItem, type TreeRef } from "nsh/base";
import { button, buttonText, desktop, Log, Panel, Split, TreeRow, treeItemClass, ui, wide } from "./palette";
// A project's files: folders that open with their chevron, files, sizes at
// the end, a folder the app keeps out of reach (`disabled`). The tree owns
// the keys and the semantics; every pixel of a row is `itemClass` and
// `TreeRow` over the item's depth.
const files: TreeItem[] = [
{
id: "src",
label: "src",
children: [
{ id: "src/main.rs", label: "main.rs" },
{ id: "src/lib.rs", label: "lib.rs" },
{
id: "src/ui",
label: "ui",
children: [
{ id: "src/ui/mod.rs", label: "mod.rs" },
{ id: "src/ui/tree.rs", label: "tree.rs" },
{ id: "src/ui/sidebar.rs", label: "sidebar.rs" },
{
id: "src/ui/icons",
label: "icons",
children: [
{ id: "src/ui/icons/folder.svg", label: "folder.svg" },
{ id: "src/ui/icons/file.svg", label: "file.svg" },
],
},
],
},
],
},
{
id: "tests",
label: "tests",
children: [
{ id: "tests/tree.rs", label: "tree.rs" },
{ id: "tests/keys.rs", label: "keys.rs" },
],
},
{ id: "target", label: "target", disabled: true, children: [{ id: "target/debug", label: "debug" }] },
{ id: "Cargo.toml", label: "Cargo.toml" },
{ id: "README.md", label: "README.md" },
];
const sizes: Record<string, string> = {
"src/main.rs": "1.2 KB",
"src/lib.rs": "4.8 KB",
"src/ui/mod.rs": "640 B",
"src/ui/tree.rs": "21 KB",
"src/ui/sidebar.rs": "6.3 KB",
"src/ui/icons/folder.svg": "412 B",
"src/ui/icons/file.svg": "388 B",
"tests/tree.rs": "18 KB",
"tests/keys.rs": "3.1 KB",
"Cargo.toml": "906 B",
"README.md": "2.4 KB",
};
const expanded = signal(["src", "src/ui"]);
const selected = signal<string | null>("src/ui/tree.rs");
const events = signal<string[]>([]);
const log = (line: string) => events.set([line, ...events()].slice(0, 4));
let tree: TreeRef | undefined;
function App() {
return (
<View style={{ flex: 1, backgroundColor: ui().page, padding: 20, paddingTop: 24, gap: 14 }}>
<Text style={{ fontSize: 22, fontWeight: "700", color: ui().text }}>tree</Text>
<Text style={{ color: ui().muted }}>
{desktop
? "Click a folder, or Tab in and use the arrows: Right opens and enters, Left closes and leaves; double-click a file or press Return to act on it"
: "Tap a folder to open it, a file to select it; double-tap a file to act on it"}
</Text>
<Split
gap={14}
inspector={
<>
<Panel title="selected">
<Text style={{ fontSize: 13, color: ui().text }}>{selected() ?? "none"}</Text>
</Panel>
<Panel title="expanded">
<Text style={{ fontSize: 13, color: ui().text }}>{expanded().join(", ") || "none"}</Text>
</Panel>
<Log title="events" lines={events()} minHeight={92} />
</>
}
>
<View style={{ width: wide() ? 300 : undefined, alignSelf: wide() ? "center" : "stretch", gap: 10 }}>
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
<Button {...button("secondary", { size: "sm" })} onPress={() => tree?.revealItem("src/ui/icons/file.svg")}>
<Text style={buttonText()}>Reveal file.svg</Text>
</Button>
<Button
{...button("ghost", { size: "sm" })}
disabled={expanded().length === 0}
onPress={() => {
expanded.set([]);
log("collapse all");
}}
>
<Text style={buttonText("ghost")}>Collapse all</Text>
</Button>
</View>
<Tree
ref={(r) => (tree = r)}
items={files}
expanded={expanded()}
selected={selected()}
reveal
accessibilityLabel="Project files"
style={{ height: desktop ? 320 : 360, borderRadius: 12, ...ui().card }}
listStyle={{ padding: 6 }}
itemClass={treeItemClass}
onExpandedChange={({ expanded: next }) => expanded.set(next)}
onSelectedChange={({ selected: next }) => {
selected.set(next);
log(`onSelectedChange({ selected: "${next}" })`);
}}
onExpand={({ id }) => log(`onExpand({ id: "${id}" })`)}
onCollapse={({ id }) => log(`onCollapse({ id: "${id}" })`)}
onAction={({ id }) => log(`onAction({ id: "${id}" })`)}
renderItem={(entry) => <TreeRow entry={entry} detail={entry.disabled ? "excluded" : sizes[entry.id]} />}
/>
</View>
</Split>
</View>
);
}
mount(App);use nsh_base::{TreeEntry, TreeItem, theme, tree};
use nsh_core::prelude::*;
fn files() -> impl IntoElement {
let items = vec![
TreeItem::new("src", "src")
.child(TreeItem::new("src/main.rs", "main.rs"))
.child(TreeItem::new("src/ui", "ui").child(TreeItem::new("src/ui/tree.rs", "tree.rs"))),
TreeItem::new("Cargo.toml", "Cargo.toml"),
];
let selected = signal(None::<SharedString>);
tree()
.items(items)
.default_expanded(vec!["src".into()])
.selected(move || selected.get())
.on_selected_change(move |id: &SharedString| selected.set(Some(id.clone())))
.accessibility_label("Files")
.h(px(320.0))
.list_class("p-1")
.item_class(
"group rounded-md hover:bg-accent selected:bg-primary/10 \
focus:outline-2 focus:-outline-offset-2 focus:outline-ring disabled:opacity-50",
)
.render_item(|entry: &TreeEntry| {
view()
.flex_row()
.gap_2()
.h_8()
.items_center()
.rounded_md()
.pl(px(8.0 + 16.0 * entry.depth as f32))
.child(text(if entry.folder { "▸" } else { " " }).text_color(move || theme().muted_foreground))
.child(text(entry.label.clone()))
})
}import { mount, signal, Text, View } from "nsh";
import { Tree, type TreeItem } from "nsh/base";
const files: TreeItem[] = [
{
id: "src",
label: "src",
children: [
{ id: "src/main.rs", label: "main.rs" },
{ id: "src/ui", label: "ui", children: [{ id: "src/ui/tree.rs", label: "tree.rs" }] },
],
},
{ id: "Cargo.toml", label: "Cargo.toml" },
];
function App() {
const expanded = signal(["src"]);
const selected = signal<string | null>(null);
return (
<Tree
items={files}
expanded={expanded()}
selected={selected()}
onExpandedChange={({ expanded: next }) => expanded.set(next)}
onSelectedChange={({ selected: next }) => selected.set(next)}
accessibilityLabel="Files"
class="h-80 w-64 rounded-lg border border-border"
listClass="p-1"
itemClass="group h-8 rounded-md hover:bg-accent selected:bg-primary/10 focus:outline-2 focus:-outline-offset-2 focus:outline-ring disabled:opacity-50"
renderItem={(entry) => (
<View class="h-8 flex-row items-center gap-2" style={{ paddingLeft: 8 + entry.depth * 16 }}>
<Text class="w-3 text-muted-foreground transition-transform group-aria-expanded:rotate-90">
{entry.folder ? "›" : ""}
</Text>
<Text class="text-sm text-foreground">{entry.label}</Text>
</View>
)}
/>
);
}
mount(App);Tree поставляется в nsh/base, крейте nsh-base для Rust. Раскрытые элементы и выбранный элемент принадлежат приложению, если оно задаёт expanded и selected и обновляет их в onExpandedChange и onSelectedChange; без них дерево хранит собственное состояние, начиная с defaultExpanded и defaultSelected. Элементы идентифицируются по id, который задаёт приложение (путь, ключ записи), но никогда по подписи. В Rust дерево создаёт tree(): у него есть сеттер для каждого свойства (.items(..), .expanded(..), .render_item(..)), метод-слушатель для каждого события и типизированные методы стилей.
#Элементы
items задаёт элементы верхнего уровня, каждый из которых является TreeItem:
| Поле | Тип | Описание |
|---|---|---|
id | string | Идентифицирует элемент в expanded, selected и событиях: уникален в пределах дерева (повторно заданный id попадает в лог, и раскрывается или выбирается только первый элемент с ним). |
label | string | Что показывает строка, если её не рисует renderItem, что произносят скринридеры и с чем сопоставляется поиск по набору текста. |
children | TreeItem[] | Вложенные элементы по порядку. Если они есть, элемент является папкой: он раскрывается и сворачивается. |
disabled | boolean | Пользователь не может выбрать, раскрыть или свернуть элемент: нажатия и клавиши его пропускают, применяются стили disabled:. Его дочерние элементы остаются такими, как задано в expanded. |
renderItem получает TreeEntry для каждой видимой строки: id, label и disabled элемента, его depth (0 на верхнем уровне, 1 под элементом верхнего уровня, …) и признак того, является ли он folder. Это описывает, чем является элемент, а состояния строки выражаются её классами: содержимое рисуется один раз для элемента и сохраняет свои представления, пока строка выбирается, раскрывается или сворачивается, поэтому шеврон поворачивается с переходом.
#Props
| Свойство | Rust | Тип | По умолчанию | Описание |
|---|---|---|---|---|
items | .items(..) | TreeItem[] | [] | Элементы верхнего уровня, каждый со своими вложенными элементами. |
expanded | .expanded(..) | string[] | нет | Идентификаторы раскрытых элементов (controlled). Элемент виден, пока раскрыты все элементы над ним. |
defaultExpanded | .default_expanded(..) | string[] | [] | Раскрытые элементы в начале, пока не задан expanded. |
selected | .selected(..) | string | null | нет | Идентификатор выбранного элемента (controlled), null, если выбора нет. |
defaultSelected | .default_selected(..) | string | нет | Выбранный элемент в начале, пока не задан selected. |
disabled | .disabled(..) | boolean | false | Дерево только для чтения: без нажатий и клавиш, без остановки фокуса клавиатуры, disabled: на каждой строке; оно показывает то, что задают expanded и selected. |
reveal | .reveal(..) | boolean | false | При раскрытии и сворачивании строки после элемента перемещаются на новые места с пружинной анимацией, выполняемой нативно. Отключается при включённом режиме уменьшения движения. |
accessibilityLabel | .accessibility_label(..) | string | нет | Как скринридеры называют дерево («Files»). |
renderItem | .render_item(..) | (entry: TreeEntry) => JSX.Element | нет | Содержимое строки для видимых строк; без него строка показывает подпись элемента. |
itemClass, itemStyle | .item_class(..), .item_style(..) | string, StyleProp<TextStyle> | нет | Каждая строка: её высота, отступы, поверхность и состояния (см. ниже); стили текста применяются к подписи. |
listClass, listStyle | .list_class(..), .list_style(..) | string, StyleProp<ViewStyle> | нет | Прокручиваемая область со строками внутри дерева: её отступы и промежутки. |
class, style | методы стилей | string, StyleProp<ViewStyle> | нет | Само дерево. Задайте ему высоту (или flex-1 в родителе, у которого она есть): строки прокручиваются внутри него, и создаются только видимые. |
#События
| Событие | Rust | Данные | Когда |
|---|---|---|---|
onExpandedChange | .on_expanded_change(..) | { expanded: string[] } | Пользователь раскрыл или свернул элемент (нажатие, Return, Space, Right или Left, revealItem): передаются идентификаторы раскрытых элементов в том виде, какими они стали бы. |
onSelectedChange | .on_selected_change(..) | { selected: string } | Пользователь выбрал другой элемент (нажатие, стрелки, Home, End, поиск по набору текста). |
onExpand | .on_expand(..) | { id: string } | Пользователь раскрыл элемент с id после onExpandedChange. |
onCollapse | .on_collapse(..) | { id: string } | Пользователь свернул элемент с id после onExpandedChange. |
onAction | .on_action(..) | { id: string } | Пользователь выполнил действие над элементом с id: Return на выбранном элементе без дочерних, либо двойной щелчок или двойное касание на любом элементе, после собственных событий нажатия. Отключённые элементы и отключённое дерево его никогда не сообщают. |
Выбор следует за стрелками, поэтому файловый менеджер открывает файл в onAction, а не в onSelectedChange: при перемещении по файлам они показываются в предпросмотре один за другим, а Return или двойной щелчок открывает тот, который нужен пользователю.
<Tree
items={files}
onSelectedChange={({ selected }) => preview.set(selected)}
onAction={({ id }) => openFile(id)}
/>
#Части
| Часть | Внутри | Стилизуется с помощью | Состояния |
|---|---|---|---|
| Дерево | class, style | ||
| Список | дерево | listClass, listStyle | |
| Строка | список | itemClass, itemStyle | selected:, aria-expanded: (раскрытая папка), focus: (пока дерево имеет фокус клавиатуры, строка, на которую действуют клавиши: выбранная строка, свёрнутый элемент, под которым скрыт выбор, или первая строка, пока ничего не выбрано), disabled:, hover:, active: |
| Содержимое строки | строка | renderItem | group-selected:, group-aria-expanded:, group-focus:, group-disabled: под group в itemClass |
Показывайте выбранную строку с помощью selected:, а положение клавиатуры с помощью focus: (см. Design guides). Рисуйте фокус внутри строки (focus:outline-2 focus:-outline-offset-2): список обрезает всё, что выходит за его края.
#Ref
| Метод | Описание |
|---|---|
focus() | Получает фокус клавиатуры, как при переходе на дерево клавишей Tab. |
revealItem(id) | Раскрывает элементы над элементом с id (сообщается через onExpandedChange и onExpand, начиная с самого внешнего) и прокручивает его строку в область видимости, когда она появляется: если приложение следует за изменением, сразу; если нет, запрос на этом заканчивается. |
let files: TreeRef | undefined;
<Tree ref={(r) => (files = r)} items={items} />;
files?.revealItem("src/ui/tree.rs");
#Disabled
Отключённый элемент не принимает нажатий, стрелки его пропускают, а скринридеры объявляют его как приглушённый. disabled на дереве делает его доступным только для чтения: оно показывает то, что говорит состояние приложения, и не принимает ввод.
<Tree items={items} disabled={locked()} itemClass="h-8 px-2 disabled:opacity-50" />
#Доступность
- У дерева роль
"tree", его имя задаётaccessibilityLabel; каждая строка является"treeitem"с именем, равным подписи её элемента, а также с уровнем (глубина + 1), состоянием раскрытия (для папок), выбора и отключения. - Когда дерево получает фокус клавиатуры и когда клавиши перемещают выбор, строка, на которую действуют клавиши, получает фокус скринридера.
Общие сведения о подписях, ролях и объявлениях см. в Accessibility.
#Клавиатура
На десктопах и в вебе, где Tab переходит к представлениям, принимающим нажатия, Tree следует шаблону WAI-ARIA tree view:
| Клавиша | Действие |
|---|---|
| Tab, Shift+Tab | Входит в дерево за один шаг и выходит из него за один шаг; при возврате клавиши продолжают действовать с выбранной строки. Если ничего не выбрано, фокус показывается на первой строке, которую выбирает Down. |
| Down, Up | Выбирает следующую или предыдущую строку, пропуская отключённые элементы, от последней к первой и обратно. |
| Right | Раскрывает свёрнутую папку; на раскрытой выбирает её первый доступный дочерний элемент. |
| Left | Сворачивает раскрытую папку; в остальных случаях выбирает родителя. |
| Home, End | Выбирает первую или последнюю строку. |
| Return | Раскрывает или сворачивает выбранную папку; на элементе без дочерних выполняет над ним действие (onAction). |
| Space | Раскрывает или сворачивает выбранную папку. |
| Буквы, цифры | Выбирает следующую строку, подпись которой начинается с набранного. |
Пока выбор скрыт под свёрнутым элементом (приложение свернуло его), клавиши действуют на строку этого элемента: Right или Return раскрывает его, Down выбирает следующую за ним строку.
В раскладке справа налево строки идут от правого края, поэтому Left и Right меняются местами, как предписывает шаблон: Left раскрывает папку и входит в неё, Right сворачивает её и выбирает родителя. Up, Down, Home и End остаются прежними.
#Особенности платформ
- iOS, Android и Mac Catalyst: касание выбирает строку и раскрывает или сворачивает папку; двойное касание выполняет действие над элементом (
onAction). - Десктопы (macOS, Windows, Linux, GPUI) и веб: щелчок делает то же самое и передаёт дереву фокус клавиатуры, после чего в нём работают клавиши, описанные выше; двойной щелчок выполняет действие над элементом.
- На каждой платформе рисуется то, что стилизует приложение: дерево, его строки и их содержимое.