Elemente
Tree
Eine Baumansicht zeigt Einträge, die Einträge enthalten: die Dateien und Ordner eines Projekts, die Abschnitte einer Seitenleiste mit Einstellungen, die Gliederung eines Dokuments. Tree verwaltet das Verhalten einer solchen Ansicht: welche Einträge aufgeklappt sind, welcher ausgewählt ist, die Tastatur und was Screenreader vorlesen. Ihre Zeilen sind virtualisiert: Nur die sichtbaren Zeilen werden erzeugt, und die Kinder eines eingeklappten Eintrags werden gar nicht erst erzeugt, sodass sich eine Baumansicht mit Tausenden von Einträgen sofort öffnet und scrollt. Das Aussehen bestimmt die App: Die Baumansicht zeichnet nichts selbst, die App gestaltet jede Zeile (itemClass) und zeichnet ihren Inhalt (renderItem: ein Icon, das Label, eingerückt nach der Tiefe des Eintrags, ein Chevron, ein Badge).
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 stammt aus nsh/base, dem nsh-base-Crate in Rust. Die aufgeklappten Einträge und der ausgewählte Eintrag gehören der App, wenn sie expanded und selected setzt und in onExpandedChange und onSelectedChange aktualisiert; ohne diese Props verwaltet die Baumansicht ihren Zustand selbst, ausgehend von defaultExpanded und defaultSelected. Einträge werden über ihre id identifiziert, die der App gehört (ein Pfad, der Schlüssel eines Datensatzes), niemals über ihr Label. In Rust erzeugt tree() sie, mit einem Setter pro Prop (.items(..), .expanded(..), .render_item(..)), einer Listener-Methode pro Ereignis und den typisierten Style-Methoden.
#Einträge
items sind die Einträge der obersten Ebene, jeweils ein TreeItem:
| Feld | Typ | Beschreibung |
|---|---|---|
id |
string |
Was den Eintrag in expanded, selected und den Ereignissen identifiziert: eindeutig in der Baumansicht (eine doppelt vergebene ID wird protokolliert, und nur der erste Eintrag mit dieser ID wird aufgeklappt oder ausgewählt). |
label |
string |
Was seine Zeile zeigt, sofern renderItem sie nicht zeichnet, was Screenreader vorlesen und womit die Typeahead-Suche abgleicht. |
children |
TreeItem[] |
Die Einträge darunter, in Reihenfolge. Hat er welche, ist er ein Ordner: Er lässt sich auf- und zuklappen. |
disabled |
boolean |
Der Nutzer kann ihn weder auswählen noch auf- oder zuklappen: Klicks und Tasten überspringen ihn, die disabled:-Styles greifen. Seine Kinder bleiben, wie expanded es festlegt. |
renderItem erhält für jede sichtbare Zeile ein TreeEntry: die id, das label und die disabled des Eintrags, seine depth (0 auf der obersten Ebene, 1 unter einem obersten Eintrag, …) und ob er ein folder ist. Das beschreibt, was der Eintrag ist, und die Zustände der Zeile sind ihre Klassen: Die Zeichnung wird einmal pro Eintrag erzeugt und behält ihre Views, während die Zeile ausgewählt, aufgeklappt oder zugeklappt wird, sodass sich ein Chevron mit einer Transition drehen kann.
#Props
| Prop | Rust | Typ | Standard | Beschreibung |
|---|---|---|---|---|
items |
.items(..) |
TreeItem[] |
[] |
Die Einträge der obersten Ebene, jeweils mit den Einträgen darunter. |
expanded |
.expanded(..) |
string[] |
keiner | Die IDs der aufgeklappten Einträge (controlled). Ein Eintrag wird angezeigt, solange alle Einträge über ihm aufgeklappt sind. |
defaultExpanded |
.default_expanded(..) |
string[] |
[] |
Die anfangs aufgeklappten Einträge, solange expanded nicht gesetzt ist. |
selected |
.selected(..) |
string | null |
keiner | Die ID des ausgewählten Eintrags (controlled), null für keinen. |
defaultSelected |
.default_selected(..) |
string |
keiner | Der anfangs ausgewählte Eintrag, solange selected nicht gesetzt ist. |
disabled |
.disabled(..) |
boolean |
false |
Eine schreibgeschützte Baumansicht: keine Klicks oder Tasten, kein Halt des Tastaturfokus, disabled: auf jeder Zeile; sie zeigt, was expanded und selected vorgeben. |
reveal |
.reveal(..) |
boolean |
false |
Beim Auf- und Zuklappen rücken die Zeilen nach dem Eintrag mit einer Feder-Animation (Spring) an ihre neuen Plätze, nativ ausgeführt. Bei reduzierter Bewegung deaktiviert. |
accessibilityLabel |
.accessibility_label(..) |
string |
keiner | Wie Screenreader die Baumansicht nennen („Dateien“). |
renderItem |
.render_item(..) |
(entry: TreeEntry) => JSX.Element |
keiner | Der Inhalt einer Zeile, für die sichtbaren Zeilen; ohne ihn zeigt eine Zeile das Label des Eintrags. |
itemClass, itemStyle |
.item_class(..), .item_style(..) |
string, StyleProp<TextStyle> |
keiner | Jede Zeile: ihre Höhe, ihr Padding, ihre Fläche und ihre Zustände (siehe unten); ihre Textstile wirken auf das Label. |
listClass, listStyle |
.list_class(..), .list_style(..) |
string, StyleProp<ViewStyle> |
keiner | Die Scroll-View der Zeilen innerhalb der Baumansicht: ihr Padding und ihre Abstände. |
class, style |
Style-Methoden | string, StyleProp<ViewStyle> |
keiner | Die Baumansicht selbst. Geben Sie ihr eine Höhe (oder flex-1 in einem Parent, der eine hat): Die Zeilen scrollen darin, und nur die sichtbaren werden erzeugt. |
#Ereignisse
| Ereignis | Rust | Payload | Wann |
|---|---|---|---|
onExpandedChange |
.on_expanded_change(..) |
{ expanded: string[] } |
Der Nutzer hat einen Eintrag auf- oder zugeklappt (ein Klick, Return, Leertaste, Pfeil nach rechts oder links, revealItem): die aufgeklappten IDs, wie sie dann wären. |
onSelectedChange |
.on_selected_change(..) |
{ selected: string } |
Der Nutzer hat einen anderen Eintrag ausgewählt (ein Klick, die Pfeiltasten, Pos1, Ende, Typeahead). |
onExpand |
.on_expand(..) |
{ id: string } |
Der Nutzer hat den Eintrag von id aufgeklappt, nach onExpandedChange. |
onCollapse |
.on_collapse(..) |
{ id: string } |
Der Nutzer hat den Eintrag von id zugeklappt, nach onExpandedChange. |
onAction |
.on_action(..) |
{ id: string } |
Der Nutzer hat den Eintrag von id aktiviert: Return auf dem ausgewählten Eintrag, wenn er keine Kinder hat, oder ein Doppelklick oder Doppeltipp auf einen beliebigen Eintrag, nach den eigenen Ereignissen des Klicks. Deaktivierte Einträge und eine deaktivierte Baumansicht melden es nie. |
Die Auswahl folgt den Pfeiltasten, daher öffnet ein Datei-Explorer eine Datei in onAction, nicht in onSelectedChange: Wer sich durch die Dateien bewegt, sieht sie nacheinander in der Vorschau; Return oder ein Doppelklick öffnet die gewünschte.
<Tree
items={files}
onSelectedChange={({ selected }) => preview.set(selected)}
onAction={({ id }) => openFile(id)}
/>
#Bestandteile
| Bestandteil | Enthalten in | Gestaltet mit | Zustände |
|---|---|---|---|
| Die Baumansicht | class, style |
||
| Die Liste | der Baumansicht | listClass, listStyle |
|
| Eine Zeile | der Liste | itemClass, itemStyle |
selected:, aria-expanded: (ein Ordner, der offen ist), focus: (solange die Baumansicht den Tastaturfokus hat, die Zeile, auf die die Tasten wirken: die ausgewählte Zeile, der zugeklappte Eintrag, unter dem eine verborgene Auswahl liegt, oder die erste Zeile, solange nichts ausgewählt ist), disabled:, hover:, active: |
| Der Inhalt einer Zeile | einer Zeile | renderItem |
group-selected:, group-aria-expanded:, group-focus:, group-disabled: unter group in itemClass |
Zeigen Sie die ausgewählte Zeile mit selected: und die Stelle der Tastatur mit focus: (siehe Design-Richtlinien). Zeichnen Sie den Fokus innerhalb der Zeile (focus:outline-2 focus:-outline-offset-2): Die Liste beschneidet, was über ihre Seiten hinausragt.
#Ref
| Methode | Beschreibung |
|---|---|
focus() |
Übernimmt den Tastaturfokus, so wie wenn Tab die Baumansicht erreicht. |
revealItem(id) |
Klappt die Einträge über dem Eintrag von id auf (gemeldet mit onExpandedChange und onExpand, der äußerste zuerst) und scrollt seine Zeile in den sichtbaren Bereich, sobald sie angezeigt wird: wenn die App der Änderung folgt, sofort; wenn nicht, endet die Anfrage dort. |
let files: TreeRef | undefined;
<Tree ref={(r) => (files = r)} items={items} />;
files?.revealItem("src/ui/tree.rs");
#Deaktiviert
Ein deaktivierter Eintrag nimmt keine Klicks an, die Pfeiltasten überspringen ihn, und Screenreader kündigen ihn als abgeblendet an. disabled an der Baumansicht macht sie schreibgeschützt: Sie zeigt, was der Zustand der App vorgibt, und nimmt keine Eingaben an.
<Tree items={items} disabled={locked()} itemClass="h-8 px-2 disabled:opacity-50" />
#Barrierefreiheit
- Die Baumansicht hat die Rolle
"tree", benannt durchaccessibilityLabel; jede Zeile ist ein"treeitem", benannt durch das Label ihres Eintrags, mit ihrer Ebene (ihre Tiefe + 1), ob sie aufgeklappt ist (Ordner), ausgewählt und deaktiviert. - Sobald die Baumansicht den Tastaturfokus erhält und sobald die Tasten die Auswahl bewegen, erhält die Zeile, auf die die Tasten wirken, den Fokus des Screenreaders.
Siehe Barrierefreiheit für Labels, Rollen und Ansagen im Allgemeinen.
#Tastatur
Auf den Desktops und im Web, wo Tab die Views erreicht, die Klicks annehmen, folgt ein Tree dem WAI-ARIA tree view pattern:
| Taste | Wirkung |
|---|---|
| Tab, Shift+Tab | Betritt die Baumansicht in einem Schritt und verlässt sie in einem Schritt; bei der Rückkehr setzen die Tasten an der ausgewählten Zeile an. Ist nichts ausgewählt, zeigt sich der Fokus auf der ersten Zeile, die mit Pfeil nach unten ausgewählt wird. |
| Pfeil nach unten, Pfeil nach oben | Wählt die nächste oder die vorherige Zeile aus, an deaktivierten Einträgen vorbei, von der letzten zur ersten und zurück. |
| Pfeil nach rechts | Klappt einen zugeklappten Ordner auf; bei einem aufgeklappten wählt sie sein erstes aktiviertes Kind aus. |
| Pfeil nach links | Klappt einen aufgeklappten Ordner zu; bei allem anderen wählt sie den Parent aus. |
| Pos1, Ende | Wählt die erste oder die letzte Zeile aus. |
| Return | Klappt den ausgewählten Ordner auf oder zu; bei einem Eintrag ohne Kinder wird er aktiviert (onAction). |
| Leertaste | Klappt den ausgewählten Ordner auf oder zu. |
| Buchstaben, Ziffern | Wählt die nächste Zeile aus, deren Label mit dem Eingegebenen beginnt. |
Solange die Auswahl unter einem zugeklappten Eintrag verborgen ist (die App hat ihn zugeklappt), wirken die Tasten auf die Zeile dieses Eintrags: Pfeil nach rechts oder Return klappt ihn auf, Pfeil nach unten wählt die Zeile danach aus.
In einem Right-to-left-Layout laufen die Zeilen von rechts, sodass Pfeil nach links und Pfeil nach rechts ihre Rollen tauschen, wie es das Pattern vorsieht: Pfeil nach links klappt einen Ordner auf und betritt ihn, Pfeil nach rechts klappt ihn zu und wählt den Parent aus. Pfeil nach oben, Pfeil nach unten, Pos1 und Ende behalten ihre Wirkung.
#Hinweise zu Plattformen
- iOS, Android und Mac Catalyst: Ein Tipp wählt eine Zeile aus und klappt einen Ordner auf oder zu; ein Doppeltipp aktiviert den Eintrag (
onAction). - Desktops (macOS, Windows, Linux, GPUI) und das Web: Ein Klick bewirkt dasselbe und gibt der Baumansicht den Tastaturfokus, und die oben genannten Tasten bewegen sich darin; ein Doppelklick aktiviert den Eintrag.
- Jede Plattform zeichnet, was die App gestaltet: die Baumansicht, ihre Zeilen und deren Inhalt.