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

Элементы

Tree

Tree показывает элементы, которые содержат другие элементы: файлы и папки проекта, разделы боковой панели настроек, структуру документа. Tree отвечает за поведение такого дерева: какие элементы раскрыты, какой выбран, как работает клавиатура и что слышат пользователи скринридеров. Строки дерева виртуализированы: создаются только видимые строки, а дочерние элементы свёрнутого элемента не создаются вовсе, поэтому дерево из тысяч элементов открывается и прокручивается мгновенно. Внешний вид определяет приложение: само дерево ничего не рисует, приложение стилизует каждую строку (itemClass) и рисует её содержимое (renderItem: значок, подпись с отступом по глубине элемента, шеврон, бейдж).

el-tree.tsx
el-tree.tsxLive
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()))
        })
}

Tree поставляется в nsh/base, крейте nsh-base для Rust. Раскрытые элементы и выбранный элемент принадлежат приложению, если оно задаёт expanded и selected и обновляет их в onExpandedChange и onSelectedChange; без них дерево хранит собственное состояние, начиная с defaultExpanded и defaultSelected. Элементы идентифицируются по id, который задаёт приложение (путь, ключ записи), но никогда по подписи. В Rust дерево создаёт tree(): у него есть сеттер для каждого свойства (.items(..), .expanded(..), .render_item(..)), метод-слушатель для каждого события и типизированные методы стилей.

#Элементы

items задаёт элементы верхнего уровня, каждый из которых является TreeItem:

ПолеТипОписание
idstringИдентифицирует элемент в expanded, selected и событиях: уникален в пределах дерева (повторно заданный id попадает в лог, и раскрывается или выбирается только первый элемент с ним).
labelstringЧто показывает строка, если её не рисует renderItem, что произносят скринридеры и с чем сопоставляется поиск по набору текста.
childrenTreeItem[]Вложенные элементы по порядку. Если они есть, элемент является папкой: он раскрывается и сворачивается.
disabledbooleanПользователь не может выбрать, раскрыть или свернуть элемент: нажатия и клавиши его пропускают, применяются стили 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(..)booleanfalseДерево только для чтения: без нажатий и клавиш, без остановки фокуса клавиатуры, disabled: на каждой строке; оно показывает то, что задают expanded и selected.
reveal.reveal(..)booleanfalseПри раскрытии и сворачивании строки после элемента перемещаются на новые места с пружинной анимацией, выполняемой нативно. Отключается при включённом режиме уменьшения движения.
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, itemStyleselected:, aria-expanded: (раскрытая папка), focus: (пока дерево имеет фокус клавиатуры, строка, на которую действуют клавиши: выбранная строка, свёрнутый элемент, под которым скрыт выбор, или первая строка, пока ничего не выбрано), disabled:, hover:, active:
Содержимое строкистрокаrenderItemgroup-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) и веб: щелчок делает то же самое и передаёт дереву фокус клавиатуры, после чего в нём работают клавиши, описанные выше; двойной щелчок выполняет действие над элементом.
  • На каждой платформе рисуется то, что стилизует приложение: дерево, его строки и их содержимое.