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).

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"
          : "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 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:

FieldTypeDescription
idstringWhat 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).
labelstringWhat its row shows unless renderItem draws it, what screen readers say and what typeahead matches.
childrenTreeItem[]The items under it, in order. With any, it is a folder: it expands and collapses.
disabledbooleanThe 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

PropRustTypeDefaultDescription
items.items(..)TreeItem[][]The items at the top, each with the items under it.
expanded.expanded(..)string[]noneThe 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 | nullnoneThe selected item’s id (controlled), null for none.
defaultSelected.default_selected(..)stringnoneThe selected item at first, while selected is not set.
disabled.disabled(..)booleanfalseA 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(..)booleanfalseExpanding and collapsing move the rows after the item to their new places with a spring, run natively. Off with reduced motion.
accessibilityLabel.accessibility_label(..)stringnoneWhat screen readers call the tree (“Files”).
renderItem.render_item(..)(entry: TreeEntry) => JSX.ElementnoneA 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>noneEach row: its height, padding, surface and states (below); its text styles reach the label.
listClass, listStyle.list_class(..), .list_style(..)string, StyleProp<ViewStyle>noneThe scroll view of the rows inside the tree: its padding and gaps.
class, stylestyle methodsstring, StyleProp<ViewStyle>noneThe 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

EventRustPayloadWhen
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

PartInsideStyled withStates
The treeclass, style
The listthe treelistClass, listStyle
A rowthe listitemClass, itemStyleselected:, 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 contenta rowrenderItemgroup-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

MethodDescription
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 by accessibilityLabel; 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:

KeyWhat it does
Tab, Shift+TabEnters 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, UpSelects the next or the previous row, past disabled items, from the last to the first and back.
RightExpands a collapsed folder; on an expanded one, selects its first enabled child.
LeftCollapses an expanded folder; on anything else, selects its parent.
Home, EndSelects the first or the last row.
Return, SpaceExpands or collapses the selected folder.
Letters, digitsSelects 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.