Ir al contenido
Contenido y datos

Tree

Un árbol de carpetas y archivos en el lenguaje de la lista de iCloud Drive: disclosure que gira, sangría por nivel, columnas alineadas, la selección de Drive (simple o múltiple) y, con grid, un treegrid que se recorre por celda.

import { Tree } from "sebs7n-ui/tree"

Ejemplos

Carpetas con columnas

La lista de iCloud Drive con disclosure: el triángulo gira, cada nivel se corre 20 y las columnas quedan alineadas. Probá el teclado: ↑↓ recorren, → abre y entra, ← sale y cierra, y tipear salta por nombre.

import { Tree } from "sebs7n-ui/tree"

function ConColumnas() {
  return (
    <Tree
      aria-label="Archivos"
      className="w-full"
      columns={[{ header: "Tipo", width: 180 }, { header: "Tamaño", width: 100, numeric: true }, { header: "Fecha", width: 120 }]}
      defaultExpanded={["facturas"]}
      defaultSelected="f-resumen"
      items={ARCHIVOS}
      nameHeader="Nombre"
    />
  )
}

Hijos que llegan después

Una carpeta con `hasChildren` pide sus hijos al abrirse (`onLoadChildren`); mientras tanto el ítem queda `aria-busy` y el triángulo pasa a un indicador de carga.

import { Tree, type TreeNode } from "sebs7n-ui/tree"
import { useState } from "react"

function Perezoso() {
  const [items, setItems] = useState<TreeNode[]>([
    { id: "archivo", label: "Archivo 2025", hasChildren: true },
    { id: "borradores", label: "Borradores", children: [] },
  ])
  return (
    <Tree
      aria-label="Archivo"
      className="w-full max-w-md"
      items={items}
      onLoadChildren={async (node) => {
        await new Promise((resolve) => setTimeout(resolve, 900))
        setItems((prev) =>
          prev.map((item) =>
            item.id === node.id
              ? { ...item, children: ["Enero", "Febrero", "Marzo"].map((mes) => ({ id: `${node.id}-${mes}`, label: `Facturas de ${mes}.zip` })) }
              : item
          )
        )
      }}
    />
  )
}

Varios elegidos

`selectionMode="multiple"`: ⌘ (o Ctrl) + click suma o saca, ⇧ + click elige el rango y ⌘A todo lo visible. Con el teclado, las flechas mueven sin elegir, Espacio suma o saca y ⇧ + ↑↓ extiende. Las elegidas seguidas son un solo bloque, como en el Finder.

import { Tree } from "sebs7n-ui/tree"
import { useState } from "react"

function Varios() {
  const [selected, setSelected] = useState<string[]>(["f-0012", "f-0013"])
  return (
    <div className="flex w-full flex-col gap-3">
      <Tree
        aria-label="Archivos"
        className="w-full"
        defaultExpanded={["facturas", "facturas-2026"]}
        items={ARCHIVOS}
        onSelectedChange={(ids) => setSelected(ids)}
        selected={selected}
        selectionMode="multiple"
      />
      <p className="text-callout text-label-secondary" role="status">
        {selected.length === 1 ? "1 elegido" : `${selected.length} elegidos`}
      </p>
    </div>
  )
}

Treegrid

Con `grid` (y `columns`) es un `treegrid`: la cabecera se lee y cada valor es una celda. → en una carpeta abierta o en un archivo entra a las celdas, ←/→ las recorren y ← en la primera vuelve a la fila.

import { Tree } from "sebs7n-ui/tree"

function Grilla() {
  return (
    <Tree
      aria-label="Archivos"
      className="w-full"
      columns={[{ header: "Tipo", width: 180 }, { header: "Tamaño", width: 100, numeric: true }, { header: "Fecha", width: 120 }]}
      defaultExpanded={["facturas"]}
      grid
      items={ARCHIVOS}
      nameHeader="Nombre"
    />
  )
}

Props

Generadas del TypeScript del paquete. Las propias del componente, más las heredadas del primitivo que tienen algo que explicar —marcadas «heredada de Base UI»—. El resto está en la línea «hereda de».

Tree

PropTipoPor defectoDescripción
items*TreeNode[]—Los nodos: { id, label, icon?, children?, hasChildren?, columns?, disabled? }. Un children (aunque vacío) la hace carpeta.
columnsTreeColumn[]—Columnas a la derecha: { header, width?, numeric?, hideBelow? }. Los valores van en node.columns, en el mismo orden. hideBelow: ancho del árbol en px por debajo del cual se esconde (por defecto, cuando no entra junto al nombre; 0, nunca).
defaultExpandedstring[][]Las carpetas abiertas al arrancar.
defaultSelectedstring | string[]—El id elegido al arrancar (en multiple, los ids).
expandedstring[]—Las carpetas abiertas (ids). Pasarlo lo vuelve controlado.
gridbooleanfalseUn treegrid: cabecera que se lee y celdas que se recorren con ←/→. Requiere columns.
labelsPartial<Labels["tree"]>—Textos: loading («Cargando…», lo que se lee en la carpeta mientras llegan sus hijos).
nameHeaderReact.ReactNode—El título de la columna del nombre, en la cabecera.
onExpandedChange(expanded: string[]) => void—Se llama con la lista nueva de carpetas abiertas.
onKeyDownKeyboardEventHandler<T>—Se llama antes de la navegación por teclado. Con event.preventDefault(), la tecla no navega.
onLoadChildren(node: TreeNode) => Promise<void>—Trae los hijos de una carpeta hasChildren. La app actualiza items y resuelve la promesa.
onLoadError(node: TreeNode, error: unknown) => void—La promesa de onLoadChildren falló: la carpeta se vuelve a cerrar (abrirla reintenta). Para avisar con un toast.
onOpen(node: TreeNode) => void—Enter o doble click sobre un ítem.
onSelectedChange((id: string | null, item: TreeNode | null) => void) | ((ids: string[], items: TreeNode[]) => void)—Se llama con el id y el nodo elegidos; en multiple, con los ids y los nodos, en el orden en que se ven.
selectedstring | string[]—El id elegido (en multiple, los ids). Pasarlo lo vuelve controlado.
selectionMode"multiple" | "single"—single (default): un elegido que sigue al foco. multiple: ⌘/Ctrl+click, ⇧+click, ⌘A, Espacio y ⇧+flechas; selected pasa a ser string[].
classNamestring—Se fusiona con las clases del componente vía cn() (tailwind-merge): lo que pongas gana.

Teclado

Tab
Entra al árbol en el ítem elegido (o en el primero) y sale de una: el árbol es una sola parada.
↓ ↑
El ítem visible siguiente o anterior. En simple, la selección sigue al foco; en múltiple, solo mueve el foco.
→
En una carpeta cerrada, la abre. En una abierta, va al primer hijo. Con grid: en una carpeta abierta o un archivo, entra a la primera celda; en una celda, va a la siguiente.
←
En una carpeta abierta, la cierra. Si no, va al padre. Con grid, en una celda va a la anterior, y desde la primera vuelve a la fila.
Home End
El primer y el último ítem visible. Con grid, en una celda: la primera y la última de la fila (con Ctrl, la misma celda en la primera y la última fila).
Enter
Abre el ítem (onOpen): el archivo, o entrar en la carpeta.
Espacio
Elige el ítem. En múltiple, lo suma o lo saca.
⇧ ↓ ↑ · ⇧ Home End
En múltiple: mueve y elige el rango desde el ancla (el último click o Espacio).
⌘A · Ctrl+A
En múltiple: elige todo lo visible; si ya estaba todo, nada.
⌘/Ctrl+click · ⇧+click
En múltiple: suma o saca un ítem · elige el rango desde el ancla.
a–z
Salta al próximo ítem cuyo nombre empieza con lo tipeado (medio segundo entre teclas), sin tildes ni mayúsculas.

Accesibilidad

  • role="tree" con treeitem planos que declaran aria-level, aria-setsize y aria-posinset: el lector dice «2026, nivel 2, 1 de 2» aunque no haya grupos anidados en el DOM.
  • Las carpetas llevan aria-expanded; los archivos no. La elegida, aria-selected="true".
  • Nombre obligatorio: aria-label o aria-labelledby en el Tree.
  • Foco itinerante: un solo ítem tiene tabIndex=0. El anillo es el interior de iCloud; sobre el acento, el inverso.
  • Una carpeta que está trayendo sus hijos queda aria-busy="true".
  • Las columnas se leen dentro del ítem («Factura 0012.pdf Documento PDF 128 KB 29/09/2026») y la cabecera es visual (aria-hidden). Una columna escondida por el ancho queda en display: none: no se lee ni se recorre con ←/→ en el treegrid.
  • Con `grid` es un role="treegrid": la cabecera es una fila de columnheader que se lee, cada fila es un row con aria-level, aria-setsize, aria-posinset, aria-expanded y aria-selected, y cada valor un gridcell. El foco itinerante pasa por filas y celdas: siempre hay un solo tabIndex=0. Un click en el nombre enfoca la fila (↓ sigue por filas); en un valor, esa celda (↓ sigue por esa columna).
  • Con selectionMode="multiple", el árbol lleva aria-multiselectable="true" y cada ítem su aria-selected. El modelo es el recomendado de WAI-ARIA: las flechas mueven sin elegir, así el lector no pierde la selección al recorrer.
  • El triángulo, los íconos y el indicador de carga son decorativos: el estado lo dicen aria-expanded y aria-busy.

Reglas de uso

  • Para jerarquías que se exploran en el lugar: carpetas de comprobantes, un plan de cuentas. Si al tocar una carpeta la pantalla entra en ella (como Drive web), es una List o una Table con Breadcrumb.
  • Selección simple que sigue al foco por defecto, como el Finder. selectionMode="multiple" para elegir varios (⌘/Ctrl, ⇧, ⌘A, Espacio): selected pasa a ser string[] y onSelectedChange recibe los ids y los nodos. Varias elegidas seguidas son un solo bloque.
  • `grid` cuando las columnas se leen o se usan de a una (copiar un tamaño, una acción por celda). Si solo acompañan al nombre, sin grid: el ítem ya las dice.
  • Columnas con los anchos de Drive: Tipo 180, Tamaño 100 (numeric), Fecha 180 o 120. El nombre toma el resto y se trunca (el completo queda en el title). La cabecera y las filas comparten una sola grilla: cada columna queda alineada a cualquier ancho.
  • En angosto, como Drive, se van columnas de derecha a izquierda: una columna se esconde cuando el árbol no le deja al nombre 224 px (sangría, triángulo e ícono + 160 de texto) junto con las de su izquierda; en un teléfono queda solo el nombre. Es una container query: mira el ancho del árbol, no el de la ventana. hideBelow elige el umbral de cada columna (px de ancho del árbol) y hideBelow: 0 la deja siempre: si no entra, el árbol se desplaza adentro de su caja, con la cabecera.
  • expanded y selected son controlables: guardalos en la URL o en el estado de la app si el árbol tiene que volver abierto.
  • Hijos perezosos: hasChildren + onLoadChildren, que actualiza items y resuelve la promesa. El árbol no guarda hijos por su cuenta.
  • Solo por subpath (sebs7n-ui/tree): no está en el barrel, por peso.
  • No virtualiza: hasta unos cientos de ítems visibles anda bien.

Relacionados