Elementos
Tree
Uma árvore mostra itens que contêm itens: os arquivos e pastas de um projeto, as seções da barra lateral de configurações, a estrutura de tópicos de um documento. Tree é responsável pelo comportamento de uma árvore: quais itens estão expandidos, qual está selecionado, o teclado e o que os leitores de tela anunciam. Suas linhas são virtualizadas: apenas as linhas visíveis são criadas, e os filhos de um item recolhido nunca são criados, então uma árvore com milhares de itens abre e rola instantaneamente. A aparência é do app: a árvore não desenha nada por conta própria, e o app estiliza cada linha (itemClass) e desenha seu conteúdo (renderItem: um ícone, o rótulo recuado de acordo com a profundidade do item, um chevron, um selo).
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 vem de nsh/base, o crate nsh-base em Rust. Os itens expandidos e o item selecionado pertencem ao app quando ele define expanded e selected, que ele atualiza em onExpandedChange e onSelectedChange; sem eles, a árvore mantém seu próprio estado, começando de defaultExpanded e defaultSelected. Os itens são identificados por seu id, definido pelo próprio app (um caminho, a chave de um registro), nunca pelo rótulo. Em Rust, tree() a cria, com um setter por prop (.items(..), .expanded(..), .render_item(..)), um método de listener por evento e os métodos de estilo tipados.
#Itens
items são os itens do topo, cada um um TreeItem:
| Campo | Tipo | Descrição |
|---|---|---|
id |
string |
O que o identifica em expanded, selected e nos eventos: único na árvore (um id repetido é registrado no log, e somente o primeiro item com ele é expandido ou selecionado). |
label |
string |
O que sua linha mostra, a menos que renderItem a desenhe, o que os leitores de tela dizem e o que a busca por digitação (typeahead) compara. |
children |
TreeItem[] |
Os itens abaixo dele, em ordem. Se houver algum, ele é uma pasta: expande e recolhe. |
disabled |
boolean |
O usuário não pode selecioná-lo, expandi-lo nem recolhê-lo: toques e teclas o ignoram, e os estilos de disabled: se aplicam. Seus filhos permanecem como expanded determina. |
renderItem recebe um TreeEntry para cada linha visível: o id, o label e o disabled do item, sua depth (0 no topo, 1 sob um item do topo, …) e se ele é um folder. Isso é o que o item é, e os estados da linha são suas classes: o desenho é feito uma vez por item e mantém suas views enquanto a linha é selecionada, expandida ou recolhida, de modo que um chevron gira com uma transição.
#Props
| Prop | Rust | Tipo | Padrão | Descrição |
|---|---|---|---|---|
items |
.items(..) |
TreeItem[] |
[] |
Os itens do topo, cada um com os itens abaixo dele. |
expanded |
.expanded(..) |
string[] |
nenhum | Os ids dos itens expandidos (controlado). Um item é exibido enquanto todos os itens acima dele estiverem expandidos. |
defaultExpanded |
.default_expanded(..) |
string[] |
[] |
Os itens expandidos inicialmente, enquanto expanded não está definido. |
selected |
.selected(..) |
string | null |
nenhum | O id do item selecionado (controlado), null para nenhum. |
defaultSelected |
.default_selected(..) |
string |
nenhum | O item selecionado inicialmente, enquanto selected não está definido. |
disabled |
.disabled(..) |
boolean |
false |
Uma árvore somente leitura: sem toques nem teclas, sem parada do foco do teclado, disabled: em todas as linhas; ela mostra o que expanded e selected determinam. |
reveal |
.reveal(..) |
boolean |
false |
Expandir e recolher movem as linhas após o item para suas novas posições com uma mola (spring), executada nativamente. Desativado com movimento reduzido. |
accessibilityLabel |
.accessibility_label(..) |
string |
nenhum | Como os leitores de tela chamam a árvore (“Files”). |
renderItem |
.render_item(..) |
(entry: TreeEntry) => JSX.Element |
nenhum | O conteúdo de uma linha, para as linhas visíveis; sem ele, a linha mostra o rótulo do item. |
itemClass, itemStyle |
.item_class(..), .item_style(..) |
string, StyleProp<TextStyle> |
nenhum | Cada linha: sua altura, preenchimento, superfície e estados (abaixo); seus estilos de texto chegam ao rótulo. |
listClass, listStyle |
.list_class(..), .list_style(..) |
string, StyleProp<ViewStyle> |
nenhum | A scroll view das linhas dentro da árvore: seu preenchimento e seus espaçamentos. |
class, style |
métodos de estilo | string, StyleProp<ViewStyle> |
nenhum | A própria árvore. Dê a ela uma altura (ou flex-1 em um pai que tenha uma): as linhas rolam dentro dela, e somente as visíveis são criadas. |
#Eventos
| Evento | Rust | Payload | Quando |
|---|---|---|---|
onExpandedChange |
.on_expanded_change(..) |
{ expanded: string[] } |
O usuário expandiu ou recolheu um item (um toque, Return, Espaço, Seta para a direita ou Seta para a esquerda, revealItem): os ids expandidos como ficariam. |
onSelectedChange |
.on_selected_change(..) |
{ selected: string } |
O usuário selecionou outro item (um toque, as setas, Home, End, typeahead). |
onExpand |
.on_expand(..) |
{ id: string } |
O usuário expandiu o item de id, após onExpandedChange. |
onCollapse |
.on_collapse(..) |
{ id: string } |
O usuário recolheu o item de id, após onExpandedChange. |
onAction |
.on_action(..) |
{ id: string } |
O usuário agiu sobre o item de id: Return no item selecionado quando ele não tem filhos, ou um clique duplo ou toque duplo em qualquer item, após os eventos do próprio toque. Itens desabilitados e uma árvore desabilitada nunca o reportam. |
A seleção acompanha as setas do teclado, então um explorador de arquivos abre um arquivo em onAction, não em onSelectedChange: percorrer os arquivos os exibe em pré-visualização um a um, e Return ou um clique duplo abre o que o usuário deseja.
<Tree
items={files}
onSelectedChange={({ selected }) => preview.set(selected)}
onAction={({ id }) => openFile(id)}
/>
#Partes
| Parte | Dentro de | Estilizada com | Estados |
|---|---|---|---|
| A árvore | class, style |
||
| A lista | a árvore | listClass, listStyle |
|
| Uma linha | a lista | itemClass, itemStyle |
selected:, aria-expanded: (uma pasta que está aberta), focus: (enquanto a árvore tem o foco do teclado, a linha sobre a qual as teclas agem: a linha selecionada, o item recolhido sob o qual uma seleção oculta se encontra, ou a primeira linha enquanto nada está selecionado), disabled:, hover:, active: |
| O conteúdo de uma linha | uma linha | renderItem |
group-selected:, group-aria-expanded:, group-focus:, group-disabled: sob group em itemClass |
Mostre a linha selecionada com selected: e a posição do teclado com focus: (veja Guias de design). Desenhe o foco dentro da linha (focus:outline-2 focus:-outline-offset-2): a lista corta o que ultrapassa suas laterais.
#Ref
| Método | Descrição |
|---|---|
focus() |
Recebe o foco do teclado, como quando Tab chega à árvore. |
revealItem(id) |
Expande os itens acima do item de id (reportado com onExpandedChange e onExpand, do mais externo para o mais interno) e rola sua linha para a área visível assim que ela é exibida: quando o app acompanha a mudança, imediatamente; quando não acompanha, a solicitação termina ali. |
let files: TreeRef | undefined;
<Tree ref={(r) => (files = r)} items={items} />;
files?.revealItem("src/ui/tree.rs");
#Desabilitado
Um item desabilitado não recebe toques, as setas o ignoram e os leitores de tela o anunciam como esmaecido. disabled na árvore a torna somente leitura: ela mostra o que o estado do app determina e não aceita entrada.
<Tree items={items} disabled={locked()} itemClass="h-8 px-2 disabled:opacity-50" />
#Acessibilidade
- A árvore tem o papel
"tree", nomeada poraccessibilityLabel; cada linha é um"treeitem"nomeado pelo rótulo de seu item, com seu nível (sua profundidade + 1), se está expandido (pastas), selecionado e desabilitado. - Quando a árvore recebe o foco do teclado e quando as teclas movem a seleção, a linha sobre a qual as teclas agem recebe o foco do leitor de tela.
Veja Acessibilidade para rótulos, papéis e anúncios em geral.
#Teclado
Nos desktops e na web, onde Tab alcança as views que recebem toques, um Tree segue o padrão de tree view do WAI-ARIA:
| Tecla | O que faz |
|---|---|
| Tab, Shift+Tab | Entra na árvore em uma etapa e sai dela em uma etapa; ao voltar, as teclas continuam a partir da linha selecionada. Sem nenhuma seleção, o foco aparece na primeira linha, que a Seta para baixo seleciona. |
| Seta para baixo, Seta para cima | Seleciona a próxima ou a linha anterior, pulando itens desabilitados, da última para a primeira e vice-versa. |
| Seta para a direita | Expande uma pasta recolhida; em uma já expandida, seleciona seu primeiro filho habilitado. |
| Seta para a esquerda | Recolhe uma pasta expandida; em qualquer outro caso, seleciona o pai. |
| Home, End | Seleciona a primeira ou a última linha. |
| Return | Expande ou recolhe a pasta selecionada; em um item sem filhos, age sobre ele (onAction). |
| Espaço | Expande ou recolhe a pasta selecionada. |
| Letras, dígitos | Seleciona a próxima linha cujo rótulo começa com o que foi digitado. |
Enquanto a seleção está oculta sob um item recolhido (o app o recolheu), as teclas agem sobre a linha desse item: Seta para a direita ou Return o expande, Seta para baixo seleciona a linha seguinte a ele.
Em um layout da direita para a esquerda, as linhas fluem a partir da direita, então Seta para a esquerda e Seta para a direita trocam de lugar, como diz o padrão: Seta para a esquerda expande uma pasta e entra nela, Seta para a direita a recolhe e seleciona o pai. Seta para cima, Seta para baixo, Home e End permanecem como são.
#Notas de plataforma
- iOS, Android e Mac Catalyst: um toque seleciona uma linha e expande ou recolhe uma pasta; um toque duplo age sobre o item (
onAction). - Desktops (macOS, Windows, Linux, GPUI) e a web: um clique faz o mesmo e dá à árvore o foco do teclado, e as teclas acima se movem nela; um clique duplo age sobre o item.
- Todas as plataformas desenham o que o app estiliza: a árvore, suas linhas e o conteúdo delas.