Elements
Tree
A tree shows items that hold items: a project’s files and folders, the sections of a settings sidebar, a document’s
outline. Tree owns the behavior of one: which items are expanded, which one is selected, the keyboard and what
screen readers hear. Its rows are virtualized: only the rows in view are made, and the children of a collapsed item are
never made at all, so a tree of thousands of items opens and scrolls at once. The look is the app’s: the tree draws
nothing of its own, the app styles each row (itemClass) and draws its content (renderItem: an icon, the label
indented by the item’s depth, a chevron, a 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"
: "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()))
})
}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 comes from nsh/base, the nsh-base crate in Rust. The expanded items and the
selected one are the app’s when it sets expanded and selected, which it updates in onExpandedChange and
onSelectedChange; without them the tree keeps its own state, starting from defaultExpanded and defaultSelected.
Items are identified by their id, the app’s own (a path, a record’s key), never by their label. In Rust, tree()
makes it, with a setter per prop (.items(..), .expanded(..), .render_item(..)), a listener method per event and
the typed style methods.
#Items
items is the items at the top, each a TreeItem:
| Field | Type | Description |
|---|---|---|
id | string | What identifies it in expanded, selected and the events: unique in the tree (an id given twice is logged, and only its first item expands or is selected). |
label | string | What its row shows unless renderItem draws it, what screen readers say and what typeahead matches. |
children | TreeItem[] | The items under it, in order. With any, it is a folder: it expands and collapses. |
disabled | boolean | The user cannot select, expand or collapse it: presses and keys pass it by, disabled: styles apply. Its children stay as expanded says. |
renderItem gets a TreeEntry for each row in view: the item’s id, label and disabled, its depth (0 at the
top, 1 under a top item, …) and whether it is a folder. It is what the item is, and the row’s states are its
classes: the drawing is made once per item and keeps its views while the row is selected, expanded or collapsed, so a
chevron turns with a transition.
#Props
| Prop | Rust | Type | Default | Description |
|---|---|---|---|---|
items | .items(..) | TreeItem[] | [] | The items at the top, each with the items under it. |
expanded | .expanded(..) | string[] | none | The ids of the expanded items (controlled). An item shows while every item above it is expanded. |
defaultExpanded | .default_expanded(..) | string[] | [] | The expanded items at first, while expanded is not set. |
selected | .selected(..) | string | null | none | The selected item’s id (controlled), null for none. |
defaultSelected | .default_selected(..) | string | none | The selected item at first, while selected is not set. |
disabled | .disabled(..) | boolean | false | A read-only tree: no presses or keys, no stop of the keyboard focus, disabled: on every row; it shows what expanded and selected say. |
reveal | .reveal(..) | boolean | false | Expanding and collapsing move the rows after the item to their new places with a spring, run natively. Off with reduced motion. |
accessibilityLabel | .accessibility_label(..) | string | none | What screen readers call the tree (“Files”). |
renderItem | .render_item(..) | (entry: TreeEntry) => JSX.Element | none | A row’s content, for the rows in view; without it a row shows the item’s label. |
itemClass, itemStyle | .item_class(..), .item_style(..) | string, StyleProp<TextStyle> | none | Each row: its height, padding, surface and states (below); its text styles reach the label. |
listClass, listStyle | .list_class(..), .list_style(..) | string, StyleProp<ViewStyle> | none | The scroll view of the rows inside the tree: its padding and gaps. |
class, style | style methods | string, StyleProp<ViewStyle> | none | The tree itself. Give it a height (or flex-1 in a parent that has one): the rows scroll inside it, and only those in view are made. |
#Events
| Event | Rust | Payload | When |
|---|---|---|---|
onExpandedChange | .on_expanded_change(..) | { expanded: string[] } | The user expanded or collapsed an item (a press, Return, Space, Right or Left, revealItem): the expanded ids as they would be. |
onSelectedChange | .on_selected_change(..) | { selected: string } | The user selected another item (a press, the arrow keys, Home, End, typeahead). |
onExpand | .on_expand(..) | { id: string } | The user expanded the item of id, after onExpandedChange. |
onCollapse | .on_collapse(..) | { id: string } | The user collapsed the item of id, after onExpandedChange. |
#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: (a folder that is open), focus: (while the tree has the keyboard focus, the row the keys act on: the selected row, the collapsed item a hidden selection is under, or the first row while nothing is selected), disabled:, hover:, active: |
| A row’s content | a row | renderItem | group-selected:, group-aria-expanded:, group-focus:, group-disabled: under group in itemClass |
Show the selected row with selected: and the keyboard’s place with focus: (see
Design guides). Draw the focus inside the row
(focus:outline-2 focus:-outline-offset-2): the list clips what reaches past its sides.
#Ref
| Method | Description |
|---|---|
focus() | Takes the keyboard focus, as Tab reaching the tree does. |
revealItem(id) | Expands the items above the item of id (reported with onExpandedChange and onExpand, the outermost first) and scrolls its row into view once it shows: when the app follows the change, at once; when it does not, the request ends there. |
let files: TreeRef | undefined;
<Tree ref={(r) => (files = r)} items={items} />;
files?.revealItem("src/ui/tree.rs");
#Disabled
A disabled item takes no presses, the arrow keys pass it by, and screen readers announce it as dimmed. disabled on
the tree makes it read-only: it shows what the app’s state says and takes no input.
<Tree items={items} disabled={locked()} itemClass="h-8 px-2 disabled:opacity-50" />
#Accessibility
- The tree has the role
"tree", named byaccessibilityLabel; each row is a"treeitem"named by its item’s label, with its level (its depth + 1), whether it is expanded (folders), selected and disabled. - As the tree takes the keyboard focus and as the keys move the selection, the row the keys act on takes the screen reader’s focus.
See Accessibility for labels, roles and announcements in general.
#Keyboard
On the desktops and the web, where Tab reaches the views that take presses, a Tree follows the
WAI-ARIA tree view pattern:
| Key | What it does |
|---|---|
| Tab, Shift+Tab | Enters the tree in one step, and leaves it in one step; coming back, the keys go on from the selected row. With nothing selected, the focus shows on the first row, which Down selects. |
| Down, Up | Selects the next or the previous row, past disabled items, from the last to the first and back. |
| Right | Expands a collapsed folder; on an expanded one, selects its first enabled child. |
| Left | Collapses an expanded folder; on anything else, selects its parent. |
| Home, End | Selects the first or the last row. |
| Return, Space | Expands or collapses the selected folder. |
| Letters, digits | Selects the next row whose label starts with what was typed. |
While the selection is hidden under a collapsed item (the app collapsed it), the keys act on that item’s row: Right or Return expands it, Down selects the row after it.
#Platform notes
- iOS, Android and Mac Catalyst: a tap selects a row and expands or collapses a folder.
- Desktops (macOS, Windows, Linux, GPUI) and the web: a click does the same and gives the tree the keyboard focus, and the keys above move in it.
- Every platform draws what the app styles: the tree, its rows and their content.