Esta página se tradujo automáticamente y puede contener errores. Leer el original en inglés

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

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; 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()))
        })
}

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 de accessibilityLabel; 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.