要素
Tree
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 から提供されます。これは Rust の nsh-base クレートです。アプリが expanded と selected を設定すると、展開された項目と選択された項目はアプリのものになり、アプリは onExpandedChange と onSelectedChange でそれらを更新します。設定しない場合、Tree は defaultExpanded と defaultSelected を初期値として独自の状態を保持します。項目は、ラベルではなく、アプリ独自の id (パス、レコードのキーなど) で識別されます。Rust では tree() が Tree を作成し、プロパティごとのセッター (.items(..)、.expanded(..)、.render_item(..))、イベントごとのリスナーメソッド、型付きのスタイルメソッドを備えています。
#Items
items は最上位の項目で、それぞれが TreeItem です。
| Field | Type | Description |
|---|---|---|
id | string | expanded、selected、各イベントで項目を識別するもので、ツリー内で一意である必要があります (同じ id が 2 回指定された場合はログに記録され、最初の項目だけが展開または選択されます)。 |
label | string | renderItem が描画しない場合に行に表示される内容、スクリーンリーダーが読み上げる内容、そしてタイプアヘッドの照合対象です。 |
children | TreeItem[] | その下にある項目を順に並べたものです。1 つでもあればフォルダとなり、展開と折りたたみができます。 |
disabled | boolean | ユーザーは選択、展開、折りたたみができません。押下やキー操作はこの項目を飛ばし、disabled: のスタイルが適用されます。子の状態は expanded に従います。 |
renderItem は、表示範囲内の各行に対して TreeEntry を受け取ります。これには、項目の id、label、disabled、その depth (最上位は 0、最上位項目の下は 1、…)、そして folder かどうかが含まれます。これは項目が何であるかを表すもので、行の状態はそのクラスとして表されます。描画は項目ごとに 1 回だけ行われ、行が選択、展開、折りたたみされてもビューは保持されるため、シェブロンをトランジションで回転させることができます。
#Props
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
items | .items(..) | TreeItem[] | [] | 最上位の項目で、それぞれがその下の項目を持ちます。 |
expanded | .expanded(..) | string[] | none | 展開された項目の id (controlled)。項目は、その上位の項目がすべて展開されている間だけ表示されます。 |
defaultExpanded | .default_expanded(..) | string[] | [] | expanded が設定されていない場合の、最初に展開される項目です。 |
selected | .selected(..) | string | null | none | 選択された項目の id (controlled)。未選択の場合は null です。 |
defaultSelected | .default_selected(..) | string | none | selected が設定されていない場合の、最初に選択される項目です。 |
disabled | .disabled(..) | boolean | false | 読み取り専用のツリーです。押下やキー操作を受け付けず、キーボードフォーカスも止まらず、すべての行に disabled: が付きます。expanded と selected が示す内容を表示します。 |
reveal | .reveal(..) | boolean | false | 展開と折りたたみの際に、項目の後ろの行がスプリングアニメーションで新しい位置へ移動します。アニメーションはネイティブに実行されます。モーション軽減が有効な場合はオフになります。 |
accessibilityLabel | .accessibility_label(..) | string | none | スクリーンリーダーが Tree を呼ぶ名前です (“Files” など)。 |
renderItem | .render_item(..) | (entry: TreeEntry) => JSX.Element | none | 表示範囲内の行の内容です。指定しない場合、行には項目のラベルが表示されます。 |
itemClass, itemStyle | .item_class(..), .item_style(..) | string, StyleProp<TextStyle> | none | 各行の高さ、パディング、サーフェス、状態 (後述) です。テキストスタイルはラベルに反映されます。 |
listClass, listStyle | .list_class(..), .list_style(..) | string, StyleProp<ViewStyle> | none | Tree 内にある行のスクロールビューのパディングとギャップです。 |
class, style | style methods | string, StyleProp<ViewStyle> | none | Tree 自体です。高さを指定してください (高さを持つ親の中では flex-1 でも可)。行はその中でスクロールし、表示範囲内の行だけが作られます。 |
#Events
| Event | Rust | Payload | When |
|---|---|---|---|
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 の後に発生します。 |
onAction | .on_action(..) | { id: string } | ユーザーが id の項目に対して操作を行ったとき。子を持たない選択中の項目での Return、または任意の項目でのダブルクリックかダブルタップが該当し、押下自体のイベントの後に発生します。無効な項目や無効な Tree では報告されません。 |
選択は矢印キーに追従するため、ファイルエクスプローラーではファイルを onAction ではなく onSelectedChange で開きます。ファイル間を移動すると 1 つずつプレビューされ、Return またはダブルクリックでユーザーが望むファイルが開きます。
<Tree
items={files}
onSelectedChange={({ selected }) => preview.set(selected)}
onAction={({ id }) => openFile(id)}
/>
#Parts
| Part | Inside | Styled with | States |
|---|---|---|---|
| The tree | class, style | ||
| The list | the tree | listClass, listStyle | |
| A row | the list | itemClass, itemStyle | selected:, aria-expanded: (開いているフォルダ), focus: (Tree がキーボードフォーカスを持っている間、キーが作用する行。選択中の行、非表示の選択項目が属する折りたたまれた項目、または何も選択されていない場合は最初の行), disabled:, hover:, active: |
| A row’s content | a row | renderItem | group-selected:, group-aria-expanded:, group-focus:, itemClass 内の group の下の group-disabled: |
選択された行は selected: で、キーボードの位置は focus: で示してください (Design guides を参照)。フォーカスは行の内側に描画してください (focus:outline-2 focus:-outline-offset-2)。リストは、側面からはみ出す部分をクリップします。
#Ref
| Method | Description |
|---|---|
focus() | Tab で Tree に到達したときと同様に、キーボードフォーカスを取得します。 |
revealItem(id) | id の項目より上位の項目を展開し (onExpandedChange と onExpand で、外側から順に報告されます)、その行が表示されたらスクロールして表示範囲に入れます。アプリが変更に従う場合は即座に行われ、従わない場合はそこで要求が終了します。 |
let files: TreeRef | undefined;
<Tree ref={(r) => (files = r)} items={items} />;
files?.revealItem("src/ui/tree.rs");
#Disabled
無効な項目は押下を受け付けず、矢印キーはその項目を飛ばし、スクリーンリーダーは薄く表示されているものとして読み上げます。Tree に disabled を指定すると読み取り専用になり、アプリの状態が示す内容を表示するだけで、入力は受け付けません。
<Tree items={items} disabled={locked()} itemClass="h-8 px-2 disabled:opacity-50" />
#Accessibility
- Tree のロールは
"tree"で、accessibilityLabelによって名前が付けられます。各行は"treeitem"で、項目のラベルが名前になり、レベル (深さ + 1)、展開されているか (フォルダの場合)、選択されているか、無効かどうかが伝えられます。 - Tree がキーボードフォーカスを得たとき、およびキーで選択が移動したときに、キーが作用する行がスクリーンリーダーのフォーカスを得ます。
ラベル、ロール、アナウンス全般については Accessibility を参照してください。
#Keyboard
Tab で押下を受け付けるビューに到達できるデスクトップとウェブでは、Tree は WAI-ARIA tree view pattern に従います。
| Key | What it does |
|---|---|
| Tab, Shift+Tab | 1 ステップで Tree に入り、1 ステップで Tree から出ます。戻ってきたときは、選択された行からキー操作が続きます。何も選択されていない場合、フォーカスは最初の行に表示され、Down でそれを選択します。 |
| Down, Up | 無効な項目を飛ばして、次または前の行を選択します。最後から最初へ、またその逆にも移動します。 |
| Right | 折りたたまれたフォルダを展開します。展開済みのフォルダでは、最初の有効な子を選択します。 |
| Left | 展開されたフォルダを折りたたみます。それ以外では、その親を選択します。 |
| Home, End | 最初または最後の行を選択します。 |
| Return | 選択中のフォルダを展開または折りたたみます。子を持たない項目では、その項目に対して操作を行います (onAction)。 |
| Space | 選択中のフォルダを展開または折りたたみます。 |
| Letters, digits | 入力された文字列で始まるラベルを持つ次の行を選択します。 |
選択が折りたたまれた項目の下に隠れている間 (アプリが折りたたんだ場合)、キーはその項目の行に作用します。Right または Return で展開され、Down でその次の行が選択されます。
右から左へのレイアウトでは行が右から並ぶため、パターンの定めるとおり Left と Right の役割が入れ替わります。Left はフォルダを展開してその中に入り、Right はフォルダを折りたたんで親を選択します。Up、Down、Home、End は変わりません。
#Platform notes
- iOS、Android、Mac Catalyst: タップで行を選択し、フォルダを展開または折りたたみます。ダブルタップで項目に対して操作を行います (
onAction)。 - デスクトップ (macOS、Windows、Linux、GPUI) とウェブ: クリックでも同じことが行われ、Tree にキーボードフォーカスが移り、上記のキーで操作できます。ダブルクリックで項目に対して操作を行います。
- すべてのプラットフォームで、アプリがスタイルを設定したもの、つまり Tree、その行、行の内容が描画されます。