Questa pagina è stata tradotta automaticamente e può contenere errori. Leggi l'originale in inglese

Elementi

Tree

Un tree mostra elementi che contengono altri elementi: i file e le cartelle di un progetto, le sezioni della barra laterale delle impostazioni, la struttura di un documento. Tree gestisce il comportamento di uno di essi: quali elementi sono espansi, quale è selezionato, la tastiera e ciò che sentono gli screen reader. Le sue righe sono virtualizzate: vengono create solo le righe in vista, e i figli di un elemento compresso non vengono mai creati, quindi un tree di migliaia di elementi si apre e scorre all’istante. L’aspetto è quello dell’app: il tree non disegna nulla di suo, l’app stila ogni riga (itemClass) e ne disegna il contenuto (renderItem: un’icona, l’etichetta rientrata in base alla profondità dell’elemento, un chevron, un 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; 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 da nsh/base, il crate nsh-base in Rust. Gli elementi espansi e quello selezionato appartengono all’app quando questa imposta expanded e selected, che aggiorna in onExpandedChange e onSelectedChange; senza di essi il tree mantiene un proprio stato, a partire da defaultExpanded e defaultSelected. Gli elementi sono identificati dal loro id, scelto dall’app (un percorso, la chiave di un record), mai dalla loro etichetta. In Rust, tree() lo crea, con un setter per ogni prop (.items(..), .expanded(..), .render_item(..)), un metodo listener per ogni evento e i metodi di stile tipizzati.

#Elementi

items sono gli elementi al livello superiore, ciascuno un TreeItem:

CampoTipoDescrizione
idstringCiò che lo identifica in expanded, selected e negli eventi: univoco nel tree (un id dato due volte viene registrato nel log, e solo il suo primo elemento si espande o viene selezionato).
labelstringCiò che la sua riga mostra a meno che renderItem non la disegni, ciò che dicono gli screen reader e ciò che la ricerca digitata confronta.
childrenTreeItem[]Gli elementi al suo interno, in ordine. Se ne ha, è una cartella: si espande e si comprime.
disabledbooleanL’utente non può selezionarlo, espanderlo o comprimerlo: i press e i tasti lo saltano, si applicano gli stili disabled:. I suoi figli restano come stabilito da expanded.

renderItem riceve un TreeEntry per ogni riga in vista: id, label e disabled dell’elemento, la sua depth (0 al livello superiore, 1 sotto un elemento di primo livello, …) e se è un folder. È ciò che l’elemento è, mentre gli stati della riga sono le sue classi: il disegno viene realizzato una sola volta per elemento e conserva le sue view mentre la riga è selezionata, espansa o compressa, quindi un chevron ruota con una transizione.

#Props

PropRustTipoPredefinitoDescrizione
items.items(..)TreeItem[][]Gli elementi al livello superiore, ciascuno con gli elementi al suo interno.
expanded.expanded(..)string[]nessunoGli id degli elementi espansi (controllato). Un elemento è visibile finché ogni elemento sopra di esso è espanso.
defaultExpanded.default_expanded(..)string[][]Gli elementi inizialmente espansi, finché expanded non è impostato.
selected.selected(..)string | nullnessunoL’id dell’elemento selezionato (controllato), null per nessuno.
defaultSelected.default_selected(..)stringnessunoL’elemento inizialmente selezionato, finché selected non è impostato.
disabled.disabled(..)booleanfalseUn tree di sola lettura: nessun press né tasto, nessuna sosta del focus della tastiera, disabled: su ogni riga; mostra ciò che dicono expanded e selected.
reveal.reveal(..)booleanfalseEspandere e comprimere spostano le righe successive all’elemento nelle nuove posizioni con una spring, eseguita in modo nativo. Disattivato con movimento ridotto.
accessibilityLabel.accessibility_label(..)stringnessunoCome gli screen reader chiamano il tree (“Files”).
renderItem.render_item(..)(entry: TreeEntry) => JSX.ElementnessunoIl contenuto di una riga, per le righe in vista; senza di esso una riga mostra l’etichetta dell’elemento.
itemClass, itemStyle.item_class(..), .item_style(..)string, StyleProp<TextStyle>nessunoOgni riga: altezza, padding, superficie e stati (vedi sotto); i suoi stili di testo raggiungono l’etichetta.
listClass, listStyle.list_class(..), .list_style(..)string, StyleProp<ViewStyle>nessunoLa scroll view delle righe all’interno del tree: il suo padding e gli spazi.
class, stylemetodi di stilestring, StyleProp<ViewStyle>nessunoIl tree stesso. Dagli un’altezza (oppure flex-1 in un genitore che ne ha una): le righe scorrono al suo interno, e vengono create solo quelle in vista.

#Eventi

EventoRustPayloadQuando
onExpandedChange.on_expanded_change(..){ expanded: string[] }L’utente ha espanso o compresso un elemento (un press, Return, Space, Right o Left, revealItem): gli id espansi così come sarebbero.
onSelectedChange.on_selected_change(..){ selected: string }L’utente ha selezionato un altro elemento (un press, i tasti freccia, Home, End, la ricerca digitata).
onExpand.on_expand(..){ id: string }L’utente ha espanso l’elemento di id, dopo onExpandedChange.
onCollapse.on_collapse(..){ id: string }L’utente ha compresso l’elemento di id, dopo onExpandedChange.
onAction.on_action(..){ id: string }L’utente ha agito sull’elemento di id: Return sull’elemento selezionato quando non ha figli, oppure un doppio clic o doppio tap su qualsiasi elemento, dopo gli eventi propri del press. Gli elementi disabilitati e un tree disabilitato non lo segnalano mai.

La selezione segue i tasti freccia, quindi un file explorer apre un file in onAction, non in onSelectedChange: scorrere i file ne mostra l’anteprima uno a uno, Return o un doppio clic aprono quello che l’utente vuole.

<Tree
  items={files}
  onSelectedChange={({ selected }) => preview.set(selected)}
  onAction={({ id }) => openFile(id)}
/>

#Parti

ParteDentroStilizzata conStati
Il treeclass, style
La listail treelistClass, listStyle
Una rigala listaitemClass, itemStyleselected:, aria-expanded: (una cartella aperta), focus: (mentre il tree ha il focus della tastiera, la riga su cui agiscono i tasti: la riga selezionata, l’elemento compresso sotto cui si trova una selezione nascosta, oppure la prima riga se nulla è selezionato), disabled:, hover:, active:
Il contenuto di una rigauna rigarenderItemgroup-selected:, group-aria-expanded:, group-focus:, group-disabled: sotto group in itemClass

Mostra la riga selezionata con selected: e la posizione della tastiera con focus: (vedi Linee guida di design). Disegna il focus all’interno della riga (focus:outline-2 focus:-outline-offset-2): la lista ritaglia ciò che sporge oltre i suoi lati.

#Ref

MetodoDescrizione
focus()Prende il focus della tastiera, come quando Tab raggiunge il tree.
revealItem(id)Espande gli elementi sopra l’elemento di id (segnalati con onExpandedChange e onExpand, dal più esterno) e porta la sua riga in vista non appena è visibile: se l’app segue la modifica, subito; se non la segue, la richiesta termina lì.
let files: TreeRef | undefined;

<Tree ref={(r) => (files = r)} items={items} />;
files?.revealItem("src/ui/tree.rs");

#Disabilitato

Un elemento disabilitato non accetta press, i tasti freccia lo saltano e gli screen reader lo annunciano come attenuato. disabled sul tree lo rende di sola lettura: mostra ciò che dice lo stato dell’app e non accetta input.

<Tree items={items} disabled={locked()} itemClass="h-8 px-2 disabled:opacity-50" />

#Accessibilità

  • Il tree ha il ruolo "tree", con nome dato da accessibilityLabel; ogni riga è un "treeitem" con il nome dell’etichetta del suo elemento, con il suo livello (la sua profondità + 1), se è espansa (cartelle), selezionata e disabilitata.
  • Quando il tree prende il focus della tastiera e quando i tasti spostano la selezione, la riga su cui agiscono i tasti prende il focus dello screen reader.

Vedi Accessibilità per etichette, ruoli e annunci in generale.

#Tastiera

Sui desktop e sul web, dove Tab raggiunge le view che accettano press, un Tree segue il pattern WAI-ARIA tree view:

TastoCosa fa
Tab, Shift+TabEntra nel tree in un solo passaggio e ne esce in un solo passaggio; al ritorno, i tasti proseguono dalla riga selezionata. Se nulla è selezionato, il focus appare sulla prima riga, che Down seleziona.
Down, UpSeleziona la riga successiva o precedente, saltando gli elementi disabilitati, dall’ultima alla prima e viceversa.
RightEspande una cartella compressa; su una già espansa, seleziona il suo primo figlio abilitato.
LeftComprime una cartella espansa; su qualsiasi altro elemento, seleziona il suo genitore.
Home, EndSeleziona la prima o l’ultima riga.
ReturnEspande o comprime la cartella selezionata; su un elemento senza figli, agisce su di esso (onAction).
SpaceEspande o comprime la cartella selezionata.
Lettere, cifreSeleziona la riga successiva la cui etichetta inizia con quanto digitato.

Mentre la selezione è nascosta sotto un elemento compresso (l’app lo ha compresso), i tasti agiscono sulla riga di quell’elemento: Right o Return lo espande, Down seleziona la riga che lo segue.

In un layout da destra a sinistra le righe partono da destra, quindi Left e Right si scambiano i ruoli, come dice il pattern: Left espande una cartella ed entra in essa, Right la comprime e seleziona il genitore. Up, Down, Home e End restano invariati.

#Note sulle piattaforme

  • iOS, Android e Mac Catalyst: un tap seleziona una riga ed espande o comprime una cartella; un doppio tap agisce sull’elemento (onAction).
  • Desktop (macOS, Windows, Linux, GPUI) e web: un clic fa lo stesso e dà al tree il focus della tastiera, e i tasti sopra descritti si muovono al suo interno; un doppio clic agisce sull’elemento.
  • Ogni piattaforma disegna ciò che l’app stila: il tree, le sue righe e il loro contenuto.