元素

Tree

树展示包含子项目的项目:项目的文件和文件夹、设置侧边栏的各个分区、文档的大纲。Tree 负责它的行为:哪些项目展开、哪一个被选中、键盘,以及屏幕阅读器听到的内容。它的行是虚拟化的:只创建可见的行,收起的项目的子项目根本不会创建,所以成千上万个项目的树也能立即打开和滚动。外观由应用决定:树本身什么也不画,应用为每一行设置样式(itemClass),并绘制行的内容(renderItem:图标、按项目深度缩进的标签、箭头、徽标)。

el-tree.tsx
el-tree.tsx运行中
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"
          : "Tap a folder to open it, a file to select 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}" })`)}
            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,在 Rust 中是 nsh-base crate。应用设置 expanded 和 selected 时,展开的项目和选中的项目由应用持有,并在 onExpandedChange 和 onSelectedChange 中更新;不设置时树自己保存状态,从 defaultExpanded 和 defaultSelected 开始。项目由 id 标识,这是应用自己的标识(路径、记录的键),永远不是标签。在 Rust 中由 tree() 创建,每个属性有一个 setter(.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。它描述项目本身,行的状态则通过行的 class 表达:每个项目的绘制只创建一次,在行被选中、展开或收起时保留其视图,所以箭头能以过渡动画旋转。

#属性

属性Rust类型默认值说明
items.items(..)TreeItem[][]顶层项目,每个带有它下面的项目。
expanded.expanded(..)string[]无展开项目的 id(受控)。一个项目在它上面的每个项目都展开时显示。
defaultExpanded.default_expanded(..)string[][]未设置 expanded 时,一开始展开的项目。
selected.selected(..)string | null无选中项目的 id(受控),null 表示没有。
defaultSelected.default_selected(..)string无未设置 selected 时,一开始选中的项目。
disabled.disabled(..)booleanfalse只读的树:不响应点按和按键,不是键盘焦点的一站,每一行都应用 disabled:;显示 expanded 和 selected 所说的状态。
reveal.reveal(..)booleanfalse展开和收起时,项目之后的行以弹簧动画移到新位置,由原生执行。开启“减弱动态效果”时关闭。
accessibilityLabel.accessibility_label(..)string无屏幕阅读器对树的称呼(“文件”)。
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):变化后的展开 id。
onSelectedChange.on_selected_change(..){ selected: string }用户选中了另一个项目(点按、方向键、Home、End、按首字母跳转)。
onExpand.on_expand(..){ id: string }用户展开了 id 对应的项目,在 onExpandedChange 之后。
onCollapse.on_collapse(..){ id: string }用户收起了 id 对应的项目,在 onExpandedChange 之后。

#部件

部件位于样式状态
树class、style
列表树listClass、listStyle
行列表itemClass、itemStyleselected:、aria-expanded:(展开的文件夹)、focus:(树拥有键盘焦点时按键作用的行:选中的行;选中项被收起的项目隐藏时,是那个项目的行;没有选中项时是第一行)、disabled:、hover:、active:
行的内容行renderItem在 itemClass 中加上 group 后的 group-selected:、group-aria-expanded:、group-focus:、group-disabled:

用 selected: 表示选中的行,用 focus: 表示键盘所在的位置(见设计指南)。把焦点画在行内(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 让整棵树只读:显示应用的状态,不接受输入。

<Tree items={items} disabled={locked()} itemClass="h-8 px-2 disabled:opacity-50" />

#无障碍

  • 树的角色是 "tree",名字来自 accessibilityLabel;每一行是一个 "treeitem",名字是项目的标签,带有层级(深度 + 1)、是否展开(文件夹)、是否选中和是否禁用。
  • 树获得键盘焦点时,以及按键移动选中项时,按键作用的行获得屏幕阅读器的焦点。

标签、角色和播报的一般说明见无障碍。

#键盘

在桌面和 Web 上(Tab 会到达响应点按的视图),Tree 遵循 WAI-ARIA 树视图模式:

按键作用
Tab、Shift+Tab一步进入树,一步离开;回来时按键从选中的行继续。没有选中项时,焦点显示在第一行,按 Down 选中它。
Down、Up选中下一行或上一行,跳过禁用的项目,从最后一行回到第一行,反之亦然。
Right展开收起的文件夹;在展开的文件夹上,选中它的第一个可用的子项目。
Left收起展开的文件夹;在其他项目上,选中它的父项目。
Home、End选中第一行或最后一行。
Return、Space展开或收起选中的文件夹。
字母、数字选中下一个标签以所输入内容开头的行。

选中项被收起的项目隐藏时(应用收起了它),按键作用于那个项目的行:Right 或 Return 展开它,Down 选中它之后的一行。

#平台说明

  • iOS、Android 和 Mac Catalyst:轻点选中一行,并展开或收起文件夹。
  • 桌面(macOS、Windows、Linux、GPUI)和 Web:点击效果相同,并让树获得键盘焦点,可用上面的按键在其中移动。
  • 每个平台都绘制应用设置的样式:树、行以及行的内容。