Esta página foi traduzida automaticamente e pode conter erros. Ler o original em inglês

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

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