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

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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.

```tsx
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

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

`*` obligatoria.

## Teclado

| Tecla | Qué hace |
|---|---|
| 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

[list-row](/docs/components/list-row.md) · [table](/docs/components/table.md) · [sidebar](/docs/components/sidebar.md)
