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).
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 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:
| Campo | Tipo | Descrizione |
|---|---|---|
id | string | Ciò 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). |
label | string | Ciò 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. |
children | TreeItem[] | Gli elementi al suo interno, in ordine. Se ne ha, è una cartella: si espande e si comprime. |
disabled | boolean | L’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
| Prop | Rust | Tipo | Predefinito | Descrizione |
|---|---|---|---|---|
items | .items(..) | TreeItem[] | [] | Gli elementi al livello superiore, ciascuno con gli elementi al suo interno. |
expanded | .expanded(..) | string[] | nessuno | Gli 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 | null | nessuno | L’id dell’elemento selezionato (controllato), null per nessuno. |
defaultSelected | .default_selected(..) | string | nessuno | L’elemento inizialmente selezionato, finché selected non è impostato. |
disabled | .disabled(..) | boolean | false | Un 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(..) | boolean | false | Espandere 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(..) | string | nessuno | Come gli screen reader chiamano il tree (“Files”). |
renderItem | .render_item(..) | (entry: TreeEntry) => JSX.Element | nessuno | Il 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> | nessuno | Ogni 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> | nessuno | La scroll view delle righe all’interno del tree: il suo padding e gli spazi. |
class, style | metodi di stile | string, StyleProp<ViewStyle> | nessuno | Il 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
| Evento | Rust | Payload | Quando |
|---|---|---|---|
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
| Parte | Dentro | Stilizzata con | Stati |
|---|---|---|---|
| Il tree | class, style | ||
| La lista | il tree | listClass, listStyle | |
| Una riga | la lista | itemClass, itemStyle | selected:, 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 riga | una riga | renderItem | group-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
| Metodo | Descrizione |
|---|---|
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 daaccessibilityLabel; 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:
| Tasto | Cosa fa |
|---|---|
| Tab, Shift+Tab | Entra 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, Up | Seleziona la riga successiva o precedente, saltando gli elementi disabilitati, dall’ultima alla prima e viceversa. |
| Right | Espande una cartella compressa; su una già espansa, seleziona il suo primo figlio abilitato. |
| Left | Comprime una cartella espansa; su qualsiasi altro elemento, seleziona il suo genitore. |
| Home, End | Seleziona la prima o l’ultima riga. |
| Return | Espande o comprime la cartella selezionata; su un elemento senza figli, agisce su di esso (onAction). |
| Space | Espande o comprime la cartella selezionata. |
| Lettere, cifre | Seleziona 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.