本頁由機器翻譯,可能有錯誤。 閱讀英文原文

元素

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,也就是 Rust 中的 nsh-base crate。當應用程式設定 expanded 與 selected,並在 onExpandedChange 與 onSelectedChange 中更新它們時,展開的項目與選取的項目由應用程式掌管;若未設定,樹會自行維持狀態,從 defaultExpanded 與 defaultSelected 開始。項目以其 id 識別,這是應用程式自己的值(路徑、記錄的鍵),絕不以標籤識別。在 Rust 中,tree() 負責建立它,每個屬性有一個 setter(.items(..)、.expanded(..)、.render_item(..)),每個事件有一個監聽方法,並提供具型別的樣式方法。

#Items

items 是最上層的項目,每個都是一個 TreeItem:

欄位型別說明
idstring在 expanded、selected 與各事件中用來識別它的值:在整棵樹中必須唯一(id 重複時會記錄日誌,且只有第一個項目能展開或被選取)。
labelstring其資料列顯示的內容(除非由 renderItem 繪製)、螢幕閱讀器朗讀的內容,以及 typeahead 比對的對象。
childrenTreeItem[]其下的項目,依順序排列。只要有任何子項目,它就是資料夾:可展開與收合。
disabledboolean使用者無法選取、展開或收合它:點按與按鍵都會略過它,disabled: 樣式會生效。其子項目依 expanded 的設定而定。

renderItem 會為每個可見的資料列取得一個 TreeEntry:項目的 id、label 與 disabled、其 depth(最上層為 0,最上層項目之下為 1,依此類推),以及它是否為 folder。它描述項目本身是什麼,而資料列的狀態則以 class 表示:每個項目的內容只繪製一次,並在資料列被選取、展開或收合時保留其 view,因此箭頭符號可以帶著轉場動畫旋轉。

#Props

屬性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無螢幕閱讀器稱呼這棵樹的名稱(「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):資料列會在其內捲動,且只會建立可見範圍內的資料列。

#Events

事件Rust內容時機
onExpandedChange.on_expanded_change(..){ expanded: string[] }使用者展開或收合了某個項目(點按、Return、Space、Right 或 Left、revealItem):內容為變更後會得到的已展開 id。
onSelectedChange.on_selected_change(..){ selected: string }使用者選取了另一個項目(點按、方向鍵、Home、End、typeahead)。
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)}
/>

#Parts

部分位於樣式設定狀態
樹class、style
清單樹listClass、listStyle
資料列清單itemClass、itemStyleselected:、aria-expanded:(已開啟的資料夾)、focus:(當樹擁有鍵盤焦點時,按鍵所作用的資料列:已選取的資料列、隱藏的選取項目所在的已收合項目,或在未選取任何項目時的第一個資料列)、disabled:、hover:、active:
資料列的內容資料列renderItemgroup-selected:、group-aria-expanded:、group-focus:,以及在 itemClass 中 group 之下的 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

已停用的項目不接受點按,方向鍵會略過它,螢幕閱讀器會將它宣告為已變暗。在樹上設定 disabled 會使其成為唯讀:它顯示應用程式狀態所指定的內容,不接受任何輸入。

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

#Accessibility

  • 樹的角色為 "tree",其名稱由 accessibilityLabel 指定;每個資料列是一個 "treeitem",名稱為其項目的標籤,並帶有其層級(深度 + 1)、是否已展開(資料夾)、是否已選取以及是否已停用。
  • 當樹取得鍵盤焦點,以及按鍵移動選取項目時,按鍵所作用的資料列會取得螢幕閱讀器的焦點。

關於標籤、角色與宣告的一般說明,請參閱無障礙。

#Keyboard

在桌面平台與網頁上,Tab 可到達接受點按的 view,此時 Tree 遵循 WAI-ARIA tree view pattern:

按鍵作用
Tab、Shift+Tab一步進入樹,也一步離開樹;再回來時,按鍵會從已選取的資料列繼續。若未選取任何項目,焦點會顯示在第一個資料列上,按 Down 會選取它。
Down、Up選取下一個或上一個資料列,略過已停用的項目,由最後一個循環到第一個,反之亦然。
Right展開已收合的資料夾;在已展開的資料夾上,選取其第一個可用的子項目。
Left收合已展開的資料夾;在其他項目上,選取其父項目。
Home、End選取第一個或最後一個資料列。
Return展開或收合已選取的資料夾;在沒有子項目的項目上,對它執行動作(onAction)。
Space展開或收合已選取的資料夾。
字母、數字選取下一個標籤以所輸入內容開頭的資料列。

當選取項目被隱藏在已收合的項目之下(由應用程式收合)時,按鍵會作用於該項目的資料列:Right 或 Return 會展開它,Down 會選取它之後的資料列。

在由右至左的版面配置中,資料列從右側開始排列,因此 Left 與 Right 會互換,與該 pattern 的規定相同:Left 展開資料夾並進入其中,Right 收合它並選取其父項目。Up、Down、Home 與 End 維持不變。

#Platform notes

  • iOS、Android 與 Mac Catalyst:點一下會選取資料列,並展開或收合資料夾;連點兩下則對該項目執行動作(onAction)。
  • 桌面平台(macOS、Windows、Linux、GPUI)與網頁:點按的效果相同,並會讓樹取得鍵盤焦點,上述按鍵即可在其中移動;按兩下則對該項目執行動作。
  • 所有平台都會繪製應用程式所設定樣式的內容:樹、其資料列及其內容。