Elementos
Tree
Un árbol muestra elementos que contienen elementos: los archivos y carpetas de un proyecto, las secciones de la barra lateral de ajustes, el esquema de un documento. Tree se encarga del comportamiento de uno: qué elementos están expandidos, cuál está seleccionado, el teclado y lo que oyen los lectores de pantalla. Sus filas están virtualizadas: solo se crean las filas visibles, y los hijos de un elemento contraído no se crean en absoluto, de modo que un árbol con miles de elementos se abre y se desplaza al instante. El aspecto es el de la app: el árbol no dibuja nada por sí mismo, la app da estilo a cada fila (itemClass) y dibuja su contenido (renderItem: un icono, la etiqueta con sangría según la profundidad del elemento, un chevron, una insignia).
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 proviene de nsh/base, el crate nsh-base de Rust. Los elementos expandidos y el seleccionado pertenecen a la app cuando esta define expanded y selected, que actualiza en onExpandedChange y onSelectedChange; sin ellos, el árbol mantiene su propio estado, a partir de defaultExpanded y defaultSelected. Los elementos se identifican por su id, propio de la app (una ruta, la clave de un registro), nunca por su etiqueta. En Rust, tree() lo crea, con un setter por cada prop (.items(..), .expanded(..), .render_item(..)), un método de listener por cada evento y los métodos de estilo tipados.
#Elementos
items son los elementos del nivel superior, cada uno un TreeItem:
| Campo | Tipo | Descripción |
|---|---|---|
id |
string |
Lo que lo identifica en expanded, selected y los eventos: único en el árbol (un id repetido se registra en el log, y solo su primer elemento se expande o se selecciona). |
label |
string |
Lo que muestra su fila a menos que renderItem la dibuje, lo que dicen los lectores de pantalla y lo que coincide con la búsqueda por escritura. |
children |
TreeItem[] |
Los elementos que tiene debajo, en orden. Si tiene alguno, es una carpeta: se expande y se contrae. |
disabled |
boolean |
El usuario no puede seleccionarlo, expandirlo ni contraerlo: las pulsaciones y las teclas lo saltan, se aplican los estilos de disabled:. Sus hijos quedan como indique expanded. |
renderItem recibe un TreeEntry por cada fila visible: los id, label y disabled del elemento, su depth (0 en el nivel superior, 1 bajo un elemento superior, …) y si es un folder. Es lo que el elemento es, y los estados de la fila son sus clases: el dibujo se hace una sola vez por elemento y conserva sus vistas mientras la fila está seleccionada, expandida o contraída, de modo que un chevron gira con una transición.
#Props
| Prop | Rust | Tipo | Valor por defecto | Descripción |
|---|---|---|---|---|
items |
.items(..) |
TreeItem[] |
[] |
Los elementos del nivel superior, cada uno con los elementos que tiene debajo. |
expanded |
.expanded(..) |
string[] |
ninguno | Los ids de los elementos expandidos (controlado). Un elemento se muestra mientras todos los elementos por encima de él estén expandidos. |
defaultExpanded |
.default_expanded(..) |
string[] |
[] |
Los elementos expandidos al principio, mientras expanded no esté definido. |
selected |
.selected(..) |
string | null |
ninguno | El id del elemento seleccionado (controlado), null si no hay ninguno. |
defaultSelected |
.default_selected(..) |
string |
ninguno | El elemento seleccionado al principio, mientras selected no esté definido. |
disabled |
.disabled(..) |
boolean |
false |
Un árbol de solo lectura: sin pulsaciones ni teclas, sin parada del foco del teclado, disabled: en cada fila; muestra lo que dicen expanded y selected. |
reveal |
.reveal(..) |
boolean |
false |
Al expandir y contraer, las filas posteriores al elemento se mueven a su nueva posición con un resorte, ejecutado de forma nativa. Desactivado con movimiento reducido. |
accessibilityLabel |
.accessibility_label(..) |
string |
ninguno | Cómo llaman los lectores de pantalla al árbol (“Archivos”). |
renderItem |
.render_item(..) |
(entry: TreeEntry) => JSX.Element |
ninguno | El contenido de una fila, para las filas visibles; sin él, una fila muestra la etiqueta del elemento. |
itemClass, itemStyle |
.item_class(..), .item_style(..) |
string, StyleProp<TextStyle> |
ninguno | Cada fila: su altura, relleno, superficie y estados (más abajo); sus estilos de texto llegan a la etiqueta. |
listClass, listStyle |
.list_class(..), .list_style(..) |
string, StyleProp<ViewStyle> |
ninguno | La vista de desplazamiento de las filas dentro del árbol: su relleno y sus espacios. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
ninguno | El propio árbol. Dale una altura (o flex-1 en un padre que la tenga): las filas se desplazan dentro de él, y solo se crean las visibles. |
#Eventos
| Evento | Rust | Carga | Cuándo |
|---|---|---|---|
onExpandedChange |
.on_expanded_change(..) |
{ expanded: string[] } |
El usuario expandió o contrajo un elemento (una pulsación, Intro, Espacio, Derecha o Izquierda, revealItem): los ids expandidos tal como quedarían. |
onSelectedChange |
.on_selected_change(..) |
{ selected: string } |
El usuario seleccionó otro elemento (una pulsación, las teclas de flecha, Inicio, Fin, búsqueda por escritura). |
onExpand |
.on_expand(..) |
{ id: string } |
El usuario expandió el elemento de id, después de onExpandedChange. |
onCollapse |
.on_collapse(..) |
{ id: string } |
El usuario contrajo el elemento de id, después de onExpandedChange. |
onAction |
.on_action(..) |
{ id: string } |
El usuario actuó sobre el elemento de id: Intro sobre el elemento seleccionado cuando no tiene hijos, o un doble clic o doble toque sobre cualquier elemento, después de los eventos propios de la pulsación. Los elementos deshabilitados y un árbol deshabilitado nunca lo notifican. |
La selección sigue a las teclas de flecha, así que un explorador de archivos abre un archivo en onAction, no en onSelectedChange: al recorrer los archivos se previsualizan uno a uno, e Intro o un doble clic abre el que el usuario quiere.
<Tree
items={files}
onSelectedChange={({ selected }) => preview.set(selected)}
onAction={({ id }) => openFile(id)}
/>
#Partes
| Parte | Dentro de | Estilizada con | Estados |
|---|---|---|---|
| El árbol | class, style |
||
| La lista | el árbol | listClass, listStyle |
|
| Una fila | la lista | itemClass, itemStyle |
selected:, aria-expanded: (una carpeta abierta), focus: (mientras el árbol tiene el foco del teclado, la fila sobre la que actúan las teclas: la fila seleccionada, el elemento contraído bajo el cual está una selección oculta, o la primera fila mientras no haya nada seleccionado), disabled:, hover:, active: |
| El contenido de una fila | una fila | renderItem |
group-selected:, group-aria-expanded:, group-focus:, group-disabled: bajo group en itemClass |
Muestra la fila seleccionada con selected: y la posición del teclado con focus: (consulta las Guías de diseño). Dibuja el foco dentro de la fila (focus:outline-2 focus:-outline-offset-2): la lista recorta lo que sobresale por sus lados.
#Ref
| Método | Descripción |
|---|---|
focus() |
Toma el foco del teclado, igual que cuando Tab llega al árbol. |
revealItem(id) |
Expande los elementos por encima del elemento de id (notificados con onExpandedChange y onExpand, el más externo primero) y desplaza su fila hasta dejarla visible una vez que se muestra: si la app sigue el cambio, de inmediato; si no lo sigue, la solicitud termina ahí. |
let files: TreeRef | undefined;
<Tree ref={(r) => (files = r)} items={items} />;
files?.revealItem("src/ui/tree.rs");
#Deshabilitado
Un elemento deshabilitado no recibe pulsaciones, las teclas de flecha lo saltan y los lectores de pantalla lo anuncian como atenuado. disabled en el árbol lo convierte en solo lectura: muestra lo que dice el estado de la app y no admite entrada.
<Tree items={items} disabled={locked()} itemClass="h-8 px-2 disabled:opacity-50" />
#Accesibilidad
- El árbol tiene el rol
"tree", con el nombre deaccessibilityLabel; cada fila es un"treeitem"con el nombre de la etiqueta de su elemento, con su nivel (su profundidad + 1), si está expandida (carpetas), seleccionada y deshabilitada. - Cuando el árbol toma el foco del teclado y cuando las teclas mueven la selección, la fila sobre la que actúan las teclas toma el foco del lector de pantalla.
Consulta Accesibilidad para etiquetas, roles y anuncios en general.
#Teclado
En escritorio y en la web, donde Tab llega a las vistas que reciben pulsaciones, un Tree sigue el patrón de vista de árbol de WAI-ARIA:
| Tecla | Qué hace |
|---|---|
| Tab, Shift+Tab | Entra en el árbol en un solo paso y sale de él en un solo paso; al volver, las teclas continúan desde la fila seleccionada. Sin nada seleccionado, el foco se muestra en la primera fila, que Abajo selecciona. |
| Abajo, Arriba | Selecciona la fila siguiente o la anterior, saltando los elementos deshabilitados, de la última a la primera y viceversa. |
| Derecha | Expande una carpeta contraída; en una expandida, selecciona su primer hijo habilitado. |
| Izquierda | Contrae una carpeta expandida; en cualquier otro caso, selecciona su padre. |
| Inicio, Fin | Selecciona la primera o la última fila. |
| Intro | Expande o contrae la carpeta seleccionada; en un elemento sin hijos, actúa sobre él (onAction). |
| Espacio | Expande o contrae la carpeta seleccionada. |
| Letras, dígitos | Selecciona la siguiente fila cuya etiqueta empieza por lo que se escribió. |
Mientras la selección está oculta bajo un elemento contraído (la app lo contrajo), las teclas actúan sobre la fila de ese elemento: Derecha o Intro lo expande, Abajo selecciona la fila posterior.
En un diseño de derecha a izquierda las filas parten desde la derecha, así que Izquierda y Derecha intercambian sus funciones, como indica el patrón: Izquierda expande una carpeta y entra en ella, Derecha la contrae y selecciona el padre. Arriba, Abajo, Inicio y Fin mantienen las suyas.
#Notas por plataforma
- iOS, Android y Mac Catalyst: un toque selecciona una fila y expande o contrae una carpeta; un doble toque actúa sobre el elemento (
onAction). - Escritorio (macOS, Windows, Linux, GPUI) y la web: un clic hace lo mismo y da al árbol el foco del teclado, y las teclas anteriores se mueven en él; un doble clic actúa sobre el elemento.
- Cada plataforma dibuja lo que la app estiliza: el árbol, sus filas y su contenido.