HextaUI

BetaHecho sobre el UI SDK 0.1.0-beta.1 de Vercel. Su API puede cambiar.

Árbol

Una vista de árbol sobre el UI SDK de Vercel para archivos y páginas anidadas, con rutas de archivo, casillas, enlaces e hijos que cargan bajo demanda.

Depende de
  • @tabler/icons-react
  • @ui-sdk/core
  • @ui-sdk/react
  • class-variance-authority
components
ui
button.tsx
tree.tsx
theme.tsx
package.json
README.md
import {
  IconBrandTypescript,
  IconFileText,
  IconFolder,
  IconFolderOpen,
} from "@tabler/icons-react"

import {
  Tree,
  TreeGroup,
  TreeItem,
  TreeItemIndicator,
  TreeItemLabel,
  type TreeItemData,
} from "@/components/ui/ui-sdk-tree"

const files: TreeItemData[] = [
  {
    value: "app",
    label: "app",
    children: [
      { value: "app/layout.tsx", label: "layout.tsx" },
      { value: "app/page.tsx", label: "page.tsx" },
      { value: "app/globals.css", label: "globals.css" },
    ],
  },
  {
    value: "components",
    label: "components",
    children: [
      {
        value: "components/ui",
        label: "ui",
        children: [
          { value: "components/ui/button.tsx", label: "button.tsx" },
          { value: "components/ui/tree.tsx", label: "tree.tsx" },
        ],
      },
      { value: "components/theme.tsx", label: "theme.tsx" },
    ],
  },
  { value: "package.json", label: "package.json" },
  { value: "README.md", label: "README.md" },
]

function Items({ items }: { items: TreeItemData[] }) {
  return items.map((item) => (
    <TreeItem key={item.value} value={item.value}>
      <TreeItemLabel>
        {item.children ? (
          <>
            <TreeItemIndicator />
            <IconFolder className="group-data-expanded/tree-label:hidden" />
            <IconFolderOpen className="not-group-data-expanded/tree-label:hidden" />
          </>
        ) : item.label.endsWith(".tsx") ? (
          <IconBrandTypescript />
        ) : (
          <IconFileText />
        )}
        <span>{item.label}</span>
      </TreeItemLabel>
      {item.children && (
        <TreeGroup>
          <Items items={item.children} />
        </TreeGroup>
      )}
    </TreeItem>
  ))
}

function UiSdkTreeDemo() {
  return (
    <Tree
      aria-label="Project files"
      items={files}
      defaultExpanded={["components", "components/ui"]}
      defaultValue="components/ui/tree.tsx"
      className="max-w-xs"
    >
      <Items items={files} />
    </Tree>
  )
}

export { UiSdkTreeDemo }
pnpm dlx shadcn@latest add https://hextaui.com/r/ui-sdk-tree.json

Añade el componente, los tokens del tema de HextaUI y los componentes de HextaUI de los que depende.

UI SDK añade un poco de CSS propio en una capa de cascada llamada ui-sdk. Declara esa capa al principio de tu hoja de estilos global, encima del import de Tailwind, para que tus clases ganen.

@layer ui-sdk;

@import "tailwindcss";
import {
  Tree,
  TreeGroup,
  TreeItem,
  TreeItemIndicator,
  TreeItemLabel,
} from "@/components/ui/ui-sdk-tree"
<Tree aria-label="Project files">
  <TreeItem value="src">
    <TreeItemLabel>
      <TreeItemIndicator />
      <span>src</span>
    </TreeItemLabel>
    <TreeGroup>
      <TreeItem value="src/index.ts">
        <TreeItemLabel>
          <span>index.ts</span>
        </TreeItemLabel>
      </TreeItem>
    </TreeGroup>
  </TreeItem>
</Tree>

Un elemento con un TreeGroup dentro es una rama. Da a cada elemento un value único y al árbol un nombre accesible. El foco se queda en el elemento y su label es la fila que ves, así que los estilos de seleccionado, actual y enfocado van en el label.

Tree
└── TreeItem
    ├── TreeItemLabel
    │   ├── TreeItemIndicator
    │   └── TreeItemCheckbox
    └── TreeGroup
        └── TreeItem

Pasa items sin hijos y el árbol renderiza por ti filas con estilo y chevrons. Cada elemento tiene un value, un label y children opcionales.

import { Tree, type TreeItemData } from "@/components/ui/ui-sdk-tree"

const items: TreeItemData[] = [
  {
    value: "fruit",
    label: "Fruit",
    children: [
      { value: "apple", label: "Apple" },
      { value: "banana", label: "Banana" },
      { value: "cherry", label: "Cherry" },
    ],
  },
  {
    value: "vegetables",
    label: "Vegetables",
    children: [
      { value: "carrot", label: "Carrot" },
      { value: "leek", label: "Leek" },
    ],
  },
  { value: "bread", label: "Bread" },
]

function UiSdkTreeData() {
  return (
    <Tree
      aria-label="Groceries"
      items={items}
      defaultExpanded={["fruit"]}
      className="max-w-xs"
    />
  )
}

export { UiSdkTreeData }

Pasa paths para un explorador de archivos, con las carpetas primero. Con activation="automatic" las flechas seleccionan, y un onAction que devuelve una promesa marca la fila como pendiente hasta que termine.

"use client"

import * as React from "react"
import { IconLoader2 } from "@tabler/icons-react"

import {
  Tree,
  TreeGroup,
  TreeItem,
  TreeItemIndicator,
  TreeItemLabel,
  treeFromPaths,
  type TreeItemData,
} from "@/components/ui/ui-sdk-tree"

const paths = [
  "src/index.ts",
  "src/lib/format.ts",
  "src/lib/parse.ts",
  "src/routes/home.tsx",
  "src/routes/about.tsx",
  "tests/",
  "package.json",
  "tsconfig.json",
]

const items = treeFromPaths(paths)
const files = new Set(paths.filter((path) => !path.endsWith("/")))

function Items({ items }: { items: TreeItemData[] }) {
  return items.map((item) => (
    <TreeItem key={item.value} value={item.value}>
      <TreeItemLabel>
        {(state) => (
          <>
            {item.children && <TreeItemIndicator />}
            <span>{item.label}</span>
            {state.pending && (
              <IconLoader2 className="ms-auto motion-safe:animate-spin" />
            )}
          </>
        )}
      </TreeItemLabel>
      {item.children && item.children.length > 0 && (
        <TreeGroup>
          <Items items={item.children} />
        </TreeGroup>
      )}
    </TreeItem>
  ))
}

function UiSdkTreePaths() {
  const [opened, setOpened] = React.useState<string | null>(null)

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <Tree
        aria-label="Project"
        paths={paths}
        defaultExpanded={["src"]}
        defaultValue="src/index.ts"
        activation="automatic"
        onAction={async (value) => {
          if (!files.has(value)) {
            return
          }
          await new Promise((resolve) => setTimeout(resolve, 600))
          setOpened(value)
        }}
      >
        <Items items={items} />
      </Tree>
      <p className="text-sm text-muted-foreground" aria-live="polite">
        {opened ? `Opened ${opened}` : "Press Enter to open a file."}
      </p>
    </div>
  )
}

export { UiSdkTreePaths }

Con mode="multiple", Cmd o Ctrl con clic alterna un elemento y Shift amplía un rango. expandOnPress={false} deja la expansión al chevron y a las flechas.

"use client"

import * as React from "react"

import { Tree, type TreeItemData } from "@/components/ui/ui-sdk-tree"

const items: TreeItemData[] = [
  {
    value: "inbox",
    label: "Inbox",
    children: [
      { value: "updates", label: "Updates" },
      { value: "forums", label: "Forums" },
      { value: "promotions", label: "Promotions" },
    ],
  },
  {
    value: "projects",
    label: "Projects",
    children: [
      { value: "website", label: "Website" },
      { value: "mobile", label: "Mobile app" },
    ],
  },
  { value: "archive", label: "Archive" },
  { value: "trash", label: "Trash" },
]

function UiSdkTreeMultiple() {
  const [value, setValue] = React.useState(["updates", "website"])

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <Tree
        aria-label="Folders"
        items={items}
        mode="multiple"
        value={value}
        onValueChange={setValue}
        expandOnPress={false}
        defaultExpanded={["inbox", "projects"]}
      />
      <p className="text-sm text-muted-foreground">{value.length} selected</p>
    </div>
  )
}

export { UiSdkTreeMultiple }

Añade checkable y un TreeItemCheckbox en cada label. Marcar una rama marca todo lo que contiene, y una rama marcada en parte muestra un guion.

"use client"

import * as React from "react"

import {
  Tree,
  TreeGroup,
  TreeItem,
  TreeItemCheckbox,
  TreeItemIndicator,
  TreeItemLabel,
  type TreeItemData,
} from "@/components/ui/ui-sdk-tree"

const items: TreeItemData[] = [
  {
    value: "frontend",
    label: "Frontend",
    children: [
      { value: "react", label: "React" },
      { value: "vue", label: "Vue" },
      { value: "svelte", label: "Svelte" },
    ],
  },
  {
    value: "backend",
    label: "Backend",
    children: [
      { value: "node", label: "Node" },
      {
        value: "databases",
        label: "Databases",
        children: [
          { value: "postgres", label: "Postgres" },
          { value: "sqlite", label: "SQLite" },
        ],
      },
    ],
  },
]

function Items({ items }: { items: TreeItemData[] }) {
  return items.map((item) => (
    <TreeItem key={item.value} value={item.value}>
      <TreeItemLabel>
        {item.children && <TreeItemIndicator />}
        <TreeItemCheckbox />
        <span>{item.label}</span>
      </TreeItemLabel>
      {item.children && (
        <TreeGroup>
          <Items items={item.children} />
        </TreeGroup>
      )}
    </TreeItem>
  ))
}

function UiSdkTreeCheckboxes() {
  const [checked, setChecked] = React.useState(["react", "postgres"])

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <Tree
        aria-label="Stack"
        items={items}
        mode="none"
        checkable
        checked={checked}
        onCheckedChange={setChecked}
        defaultExpanded={["frontend", "backend", "databases"]}
      >
        <Items items={items} />
      </Tree>
      <p className="text-sm text-muted-foreground">
        {checked.length > 0 ? checked.join(", ") : "Nothing checked"}
      </p>
    </div>
  )
}

export { UiSdkTreeCheckboxes }

Los elementos con href se renderizan como enlaces. Con mode="none" y current, la página actual recibe aria-current, sus padres se abren y toma la parada de tabulación.

"use client"

import * as React from "react"

import { Tree, type TreeItemData } from "@/components/ui/ui-sdk-tree"

const pages: TreeItemData[] = [
  {
    value: "getting-started",
    label: "Getting started",
    children: [
      { value: "introduction", label: "Introduction", href: "#introduction" },
      { value: "installation", label: "Installation", href: "#installation" },
    ],
  },
  {
    value: "components",
    label: "Components",
    children: [
      { value: "accordion", label: "Accordion", href: "#accordion" },
      { value: "dialog", label: "Dialog", href: "#dialog" },
      { value: "tree", label: "Tree", href: "#tree" },
    ],
  },
  { value: "changelog", label: "Changelog", href: "#changelog" },
]

function UiSdkTreeLinks() {
  const [current, setCurrent] = React.useState("tree")

  return (
    <Tree
      aria-label="Docs"
      items={pages}
      mode="none"
      current={current}
      onAction={(value, { event }) => {
        if (value === "getting-started" || value === "components") {
          return
        }
        event?.preventDefault()
        setCurrent(value)
      }}
      className="max-w-xs"
    />
  )
}

export { UiSdkTreeLinks }

Marca un elemento como branch antes de que existan sus hijos, y loadChildren se ejecuta la primera vez que se abre. El chevron se convierte en un spinner mientras carga y en un icono de reintento si falla. Archive falla una vez.

"use client"

import * as React from "react"

import {
  Tree,
  TreeGroup,
  TreeItem,
  TreeItemIndicator,
  TreeItemLabel,
} from "@/components/ui/ui-sdk-tree"

type Row = { value: string; label: string; branch?: boolean }

const top: Row[] = [
  { value: "users", label: "users", branch: true },
  { value: "orders", label: "orders", branch: true },
  { value: "archive", label: "archive", branch: true },
]

async function fetchRows(value: string, signal: AbortSignal, fail: boolean) {
  await new Promise((resolve, reject) => {
    const timer = setTimeout(resolve, 800)
    signal.addEventListener("abort", () => {
      clearTimeout(timer)
      reject(signal.reason)
    })
  })
  if (fail) {
    throw new Error("Network error")
  }
  return Array.from({ length: 3 }, (_, index) => ({
    value: `${value}/${index + 1}`,
    label: `${value}_${index + 1}.json`,
  }))
}

function Items({
  items,
  loaded,
}: {
  items: Row[]
  loaded: Record<string, Row[]>
}) {
  return items.map((item) => (
    <TreeItem key={item.value} value={item.value} branch={item.branch}>
      <TreeItemLabel>
        {item.branch && <TreeItemIndicator />}
        <span>{item.label}</span>
      </TreeItemLabel>
      {loaded[item.value] && (
        <TreeGroup>
          <Items items={loaded[item.value]} loaded={loaded} />
        </TreeGroup>
      )}
    </TreeItem>
  ))
}

function UiSdkTreeLazy() {
  const [loaded, setLoaded] = React.useState<Record<string, Row[]>>({})
  const failed = React.useRef(false)

  return (
    <Tree
      aria-label="Database"
      loadChildren={async (value, { signal }) => {
        const fail = value === "archive" && !failed.current
        failed.current ||= fail
        const rows = await fetchRows(value, signal, fail)
        setLoaded((current) => ({ ...current, [value]: rows }))
      }}
      className="max-w-xs"
    >
      <Items items={top} loaded={loaded} />
    </Tree>
  )
}

export { UiSdkTreeLazy }

variant="lines" dibuja una guía de sangría para cada grupo abierto, y size="sm" hace las filas más bajas.

import { Tree, type TreeItemData } from "@/components/ui/ui-sdk-tree"

const items: TreeItemData[] = [
  {
    value: "src",
    label: "src",
    children: [
      {
        value: "src/components",
        label: "components",
        children: [
          { value: "src/components/header.tsx", label: "header.tsx" },
          { value: "src/components/footer.tsx", label: "footer.tsx" },
        ],
      },
      { value: "src/main.tsx", label: "main.tsx" },
    ],
  },
  { value: "vite.config.ts", label: "vite.config.ts" },
]

function UiSdkTreeLines() {
  return (
    <Tree
      aria-label="Source"
      items={items}
      variant="lines"
      size="sm"
      defaultExpanded={["src", "src/components"]}
      className="max-w-xs"
    />
  )
}

export { UiSdkTreeLines }

Un controlador de createTreeController() expande, contrae, selecciona y revela elementos desde cualquier lugar.

"use client"

import * as React from "react"

import { Button } from "@/components/ui/button"
import {
  createTreeController,
  Tree,
  type TreeItemData,
} from "@/components/ui/ui-sdk-tree"

const items: TreeItemData[] = [
  {
    value: "design",
    label: "Design",
    children: [
      { value: "tokens", label: "Tokens" },
      {
        value: "icons",
        label: "Icons",
        children: [
          { value: "outline", label: "Outline" },
          { value: "filled", label: "Filled" },
        ],
      },
    ],
  },
  {
    value: "engineering",
    label: "Engineering",
    children: [
      { value: "api", label: "API" },
      { value: "web", label: "Web" },
    ],
  },
]

function UiSdkTreeController() {
  const [tree] = React.useState(createTreeController)

  return (
    <div className="flex w-full max-w-xs flex-col gap-3">
      <div className="flex gap-2">
        <Button variant="outline" size="sm" onClick={() => tree.expandAll()}>
          Expand all
        </Button>
        <Button variant="outline" size="sm" onClick={() => tree.collapseAll()}>
          Collapse all
        </Button>
        <Button variant="ghost" size="sm" onClick={() => tree.reveal("filled")}>
          Reveal Filled
        </Button>
      </div>
      <Tree aria-label="Teams" items={items} controller={tree} />
    </div>
  )
}

export { UiSdkTreeController }

Con hiddenUntilFound, los grupos cerrados siguen en la página, así la búsqueda del navegador encuentra Phobos y abre Mars para mostrarlo.

import { Tree, type TreeItemData } from "@/components/ui/ui-sdk-tree"

const items: TreeItemData[] = [
  {
    value: "planets",
    label: "Planets",
    children: [
      { value: "mercury", label: "Mercury" },
      { value: "venus", label: "Venus" },
      {
        value: "earth",
        label: "Earth",
        children: [{ value: "moon", label: "The Moon" }],
      },
      {
        value: "mars",
        label: "Mars",
        children: [
          { value: "phobos", label: "Phobos" },
          { value: "deimos", label: "Deimos" },
        ],
      },
    ],
  },
  {
    value: "dwarf-planets",
    label: "Dwarf planets",
    children: [
      { value: "ceres", label: "Ceres" },
      {
        value: "pluto",
        label: "Pluto",
        children: [{ value: "charon", label: "Charon" }],
      },
    ],
  },
]

function UiSdkTreeFindInPage() {
  return (
    <Tree
      aria-label="Solar system"
      items={items}
      hiddenUntilFound
      className="max-w-xs"
    />
  )
}

export { UiSdkTreeFindInPage }

Un elemento deshabilitado puede recibir foco, así los lectores de pantalla lo encuentran, pero no se puede seleccionar, marcar ni abrir.

import { Tree, type TreeItemData } from "@/components/ui/ui-sdk-tree"

const items: TreeItemData[] = [
  {
    value: "workspace",
    label: "Workspace",
    children: [
      { value: "general", label: "General" },
      { value: "billing", label: "Billing", disabled: true },
      { value: "members", label: "Members" },
    ],
  },
  { value: "danger", label: "Danger zone", disabled: true },
]

function UiSdkTreeDisabled() {
  return (
    <Tree
      aria-label="Settings"
      items={items}
      defaultExpanded={["workspace"]}
      defaultValue="general"
      className="max-w-xs"
    />
  )
}

export { UiSdkTreeDisabled }

Las filas se sangran desde la derecha, el chevron apunta a la izquierda y las flechas izquierda y derecha se intercambian.

import { Tree, type TreeItemData } from "@/components/ui/ui-sdk-tree"

const items: TreeItemData[] = [
  {
    value: "documents",
    label: "المستندات",
    children: [
      { value: "reports", label: "التقارير" },
      { value: "invoices", label: "الفواتير" },
    ],
  },
  {
    value: "photos",
    label: "الصور",
    children: [{ value: "trips", label: "الرحلات" }],
  },
  { value: "notes", label: "ملاحظات" },
]

function UiSdkTreeRtl() {
  return (
    <div dir="rtl" className="w-full max-w-xs">
      <Tree
        aria-label="الملفات"
        items={items}
        defaultExpanded={["documents"]}
        defaultValue="reports"
      />
    </div>
  )
}

export { UiSdkTreeRtl }
KeyAcción
↓↑Pasa al elemento visible siguiente o anterior.
→Abre una rama o pasa a su primer hijo.
←Cierra una rama o pasa a su padre.
HomeEndPasa al primer o último elemento visible.
Alt→Abre una rama y todo lo que contiene.
*Abre todas las ramas junto a la enfocada.
EnterSelecciona el elemento y ejecuta su acción, o sigue su enlace.
SpaceMarca el elemento si el árbol es marcable; si no, lo selecciona.
Shift↓Amplía una selección múltiple.
⌘ASelecciona todos los elementos visibles en modo múltiple.

El árbol sigue el patrón WAI-ARIA de vista de árbol con una sola parada de tabulación. Tab llega a la página actual, al elemento seleccionado o al primero. Escribir una letra pasa al siguiente elemento que empieza por ella. Mientras corre un onAction lento, los lectores de pantalla oyen pendingAnnouncement tras un segundo, así que ponlo en el idioma de tu página.

Los grupos se abren y cierran por altura solo cuando se permite el movimiento. En pantallas táctiles las filas son más altas, y en modo de colores forzados las filas seleccionada y actual tienen un contorno.

Cada parte envuelve la parte de UI SDK del mismo nombre y acepta todas sus props, incluidas render y un className que puede ser una función del estado de la parte.

PropTipoPredeterminado
itemsToda la jerarquía. Sin hijos, el árbol renderiza filas con estilo a partir de ella.
TreeItemData[]–
pathsRutas de archivo. Las carpetas se vuelven ramas; una ruta que termina en / es una carpeta vacía.
string[]–
mode
"single" | "multiple" | "none""single"
value
string | string[] | null–
defaultValue
string | string[] | null–
onValueChange
(value, details) => void–
activation
"automatic" | "manual""manual"
expanded
string[]–
defaultExpanded
string[]–
onExpandedChange
(expanded, details) => void–
expandOnPress
booleantrue
checkable
booleanfalse
checked
string[]–
defaultChecked
string[]–
onCheckedChange
(checked, details) => void–
checkStrategy
"leaf" | "trunk" | "branch" | "independent""leaf"
onActionSe ejecuta al pulsar o con Enter. Devuelve una promesa para marcar el elemento como pendiente.
(value, details) => unknown–
pendingAnnouncement
string–
loadChildren
(value, { signal }) => Promise<unknown>–
current
string | null–
disabled
booleanfalse
hiddenUntilFound
booleanfalse
keepMounted
booleanfalse
variant
"default" | "lines""default"
size
"default" | "sm""default"
controllerDe createTreeController().
TreeController–
AtributoDescripción
data-slot="tree"Selecciona el árbol en CSS.
data-variantLa variante: default o lines.
data-sizeEl tamaño: default o sm.
--tree-row-heightLa altura de cada fila, definida por size.
--tree-indentCuánto se sangra cada nivel.
PropTipoPredeterminado
valueÚnico en el árbol.
string–
branchÚsalo para una rama cuyos hijos cargan después.
boolean–
disabled
booleanfalse
labelEl texto que busca la escritura anticipada cuando el label no es texto plano.
string–
render
ReactElement | (props, state) => ReactElement–
AtributoDescripción
data-slot="tree-item"Selecciona un elemento en CSS. Tiene el foco.
data-levelLa profundidad, desde 1.
data-branchPresente en una rama.
data-expandedPresente mientras una rama está abierta.
--tree-levelLa profundidad, para sangrar una lista plana.
PropTipoPredeterminado
render
ReactElement | (props, state) => ReactElement–
AtributoDescripción
data-slot="tree-label"Selecciona una fila en CSS.
data-selectedPresente mientras el elemento está seleccionado.
data-currentPresente en el elemento de la página actual.
data-expandedPresente mientras una rama está abierta.
data-pendingPresente mientras se ejecuta el onAction del elemento.
data-disabledPresente cuando el elemento está deshabilitado.
PropTipoPredeterminado
childrenPor defecto, un chevron, un spinner mientras carga y un icono de reintento tras un fallo.
ReactNode | (state) => ReactNode–
AtributoDescripción
data-slot="tree-indicator"Apunta al chevron en CSS.
data-loadingPresente mientras cargan los hijos.
data-failedPresente tras fallar la carga de los hijos.
PropTipoPredeterminado
childrenPor defecto, una marca, o un guion si está mixto.
ReactNode | (state) => ReactNode–
AtributoDescripción
data-slot="tree-checkbox"Selecciona la casilla en CSS.
data-checkedPresente mientras está marcado.
data-indeterminatePresente mientras una rama está marcada en parte.
PropTipoPredeterminado
keepMounted
boolean–
hiddenUntilFound
boolean–
AtributoDescripción
data-slot="tree-group"Selecciona un grupo en CSS.
data-openPresente mientras está abierto.
--tree-group-heightLa altura del grupo mientras se anima.