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
| Prop | Tipo | Por defecto | Descripción |
|---|---|---|---|
items* | TreeNode[] | — | Los nodos: { id, label, icon?, children?, hasChildren?, columns?, disabled? }. Un children (aunque vacío) la hace carpeta. |
columns | TreeColumn[] | — | 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). |
defaultExpanded | string[] | [] | Las carpetas abiertas al arrancar. |
defaultSelected | string | string[] | — | El id elegido al arrancar (en multiple, los ids). |
expanded | string[] | — | Las carpetas abiertas (ids). Pasarlo lo vuelve controlado. |
grid | boolean | false | Un treegrid: cabecera que se lee y celdas que se recorren con ←/→. Requiere columns. |
labels | Partial<Labels["tree"]> | — | Textos: loading («Cargando…», lo que se lee en la carpeta mientras llegan sus hijos). |
nameHeader | React.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. |
onKeyDown | KeyboardEventHandler<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. |
selected | string | 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[]. |
className | string | — | 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"contreeitemplanos que declaranaria-level,aria-setsizeyaria-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-labeloaria-labelledbyen elTree. - 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 endisplay: none: no se lee ni se recorre con ←/→ en el treegrid. - Con `grid` es un
role="treegrid": la cabecera es una fila decolumnheaderque se lee, cada fila es unrowconaria-level,aria-setsize,aria-posinset,aria-expandedyaria-selected, y cada valor ungridcell. El foco itinerante pasa por filas y celdas: siempre hay un solotabIndex=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 llevaaria-multiselectable="true"y cada ítem suaria-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-expandedyaria-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
Listo unaTableconBreadcrumb. - Selección simple que sigue al foco por defecto, como el Finder.
selectionMode="multiple"para elegir varios (⌘/Ctrl, ⇧, ⌘A, Espacio):selectedpasa a serstring[]yonSelectedChangerecibe 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 eltitle). 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.
hideBelowelige el umbral de cada columna (px de ancho del árbol) yhideBelow: 0la deja siempre: si no entra, el árbol se desplaza adentro de su caja, con la cabecera. expandedyselectedson controlables: guardalos en la URL o en el estado de la app si el árbol tiene que volver abierto.- Hijos perezosos:
hasChildren+onLoadChildren, que actualizaitemsy 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.