HextaUI

BetaGebaut auf Vercels UI SDK 0.1.0-beta.1. Die API kann sich ändern.

Tree

Eine Baumansicht auf Vercels UI SDK für Dateien und verschachtelte Seiten, mit Dateipfaden, Checkboxen, Links und Kindern, die bei Bedarf laden.

Abhängig von
  • @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

Fügt die Komponente, die HextaUI-Theme-Tokens und alle HextaUI-Komponenten hinzu, von denen sie abhängt.

UI SDK bringt etwas eigenes CSS in einem Cascade Layer namens ui-sdk mit. Deklariere diesen Layer ganz oben im globalen Stylesheet, über dem Tailwind-Import, damit deine Klassen gewinnen.

@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>

Ein Element mit einer TreeGroup darin ist ein Zweig. Gib jedem Element einen eindeutigen value und dem Baum einen zugänglichen Namen. Der Fokus bleibt auf dem Element, und sein Label ist die sichtbare Zeile, daher landen ausgewählte, aktuelle und fokussierte Stile alle auf dem Label.

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

Übergib items ohne Kinder, und der Baum rendert gestylte Zeilen mit Chevrons für dich. Jedes Element hat einen value, ein label und optional children.

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 }

Übergib paths für einen Datei-Explorer, Ordner zuerst. Mit activation="automatic" wählen die Pfeiltasten aus, und ein onAction, das ein Promise zurückgibt, markiert die Zeile bis zum Abschluss als ausstehend.

"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 }

Mit mode="multiple" schaltet Cmd- oder Strg-Klick ein Element um, und Shift erweitert einen Bereich. expandOnPress={false} überlässt das Aufklappen dem Chevron und den Pfeiltasten.

"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 }

Füge checkable und in jedem Label eine TreeItemCheckbox hinzu. Ein abgehakter Zweig hakt alles darin ab, und ein teilweise abgehakter Zweig zeigt einen Strich.

"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 }

Elemente mit href werden als Links gerendert. Mit mode="none" und current bekommt die aktuelle Seite aria-current, ihre Eltern klappen auf und sie erhält den Tab-Stopp.

"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 }

Markiere ein Element als branch, bevor seine Kinder existieren, und loadChildren läuft beim ersten Öffnen. Der Chevron wird beim Laden zum Spinner und bei einem Fehler zum Wiederholen-Symbol. Archive schlägt einmal fehl.

"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" zeichnet für jede offene Gruppe eine Einrückungslinie, und size="sm" macht die Zeilen niedriger.

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 }

Ein Controller aus createTreeController() klappt Elemente von überall auf und zu, wählt sie aus und macht sie sichtbar.

"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 }

Mit hiddenUntilFound bleiben geschlossene Gruppen in der Seite, sodass die Suche des Browsers Phobos findet und Mars aufklappt, um es zu zeigen.

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 }

Ein deaktiviertes Element kann weiterhin den Fokus erhalten, damit Screenreader es finden, lässt sich aber nicht auswählen, abhaken oder öffnen.

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 }

Zeilen werden von rechts eingerückt, der Chevron zeigt nach links, und die Pfeiltasten links und rechts tauschen.

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 }
TasteAktion
↓↑Springt zum nächsten oder vorherigen sichtbaren Element.
→Öffnet einen Zweig oder springt zu seinem ersten Kind.
←Schließt einen Zweig oder springt zu seinem Elternelement.
HomeEndSpringt zum ersten oder letzten sichtbaren Element.
Alt→Öffnet einen Zweig und alles darin.
*Öffnet alle Zweige neben dem fokussierten.
EnterWählt das Element aus und führt seine Aktion aus oder folgt seinem Link.
SpaceHakt das Element ab, wenn der Baum abhakbar ist, sonst wählt es es aus.
Shift↓Erweitert eine Mehrfachauswahl.
⌘AWählt im Mehrfachmodus alle sichtbaren Elemente aus.

Der Baum folgt dem WAI-ARIA-Muster für Baumansichten mit einem Tab-Stopp. Tab landet auf der aktuellen Seite, dem ausgewählten oder dem ersten Element. Ein getippter Buchstabe springt zum nächsten Element, das damit beginnt. Während ein langsames onAction läuft, hören Screenreader nach einer Sekunde pendingAnnouncement, also setze es in der Sprache deiner Seite.

Gruppen klappen nur dann mit Höhenanimation auf und zu, wenn Bewegung erlaubt ist. Auf Touchscreens sind Zeilen höher, und im Modus „Erzwungene Farben“ bekommen ausgewählte und aktuelle Zeilen einen Umriss.

Jeder Teil umhüllt den gleichnamigen UI-SDK-Teil und nimmt alle seine Props an, auch render und einen className, der eine Funktion des Zustands sein kann.

PropTypStandard
itemsDie ganze Hierarchie. Ohne Kinder rendert der Baum daraus gestylte Zeilen.
TreeItemData[]–
pathsDateipfade. Ordner werden zu Zweigen; ein Pfad, der auf / endet, ist ein leerer Ordner.
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"
onActionLäuft bei Druck oder Enter. Gib ein Promise zurück, um das Element als ausstehend zu markieren.
(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"
controllerAus createTreeController().
TreeController–
AttributBeschreibung
data-slot="tree"Spricht den Baum in CSS an.
data-variantDie Variante: default oder lines.
data-sizeDie Größe: default oder sm.
--tree-row-heightDie Höhe jeder Zeile, gesetzt durch size.
--tree-indentWie weit jede Ebene eingerückt wird.
PropTypStandard
valueEindeutig im Baum.
string–
branchSetze es für einen Zweig, dessen Kinder später laden.
boolean–
disabled
booleanfalse
labelDer Text, den die Tippsuche abgleicht, wenn das Label kein reiner Text ist.
string–
render
ReactElement | (props, state) => ReactElement–
AttributBeschreibung
data-slot="tree-item"Spricht ein Element in CSS an. Es hält den Fokus.
data-levelDie Tiefe, ab 1.
data-branchVorhanden auf einem Zweig.
data-expandedVorhanden, solange ein Zweig offen ist.
--tree-levelDie Tiefe, um eine flache Liste einzurücken.
PropTypStandard
render
ReactElement | (props, state) => ReactElement–
AttributBeschreibung
data-slot="tree-label"Spricht eine Zeile in CSS an.
data-selectedVorhanden, solange das Element ausgewählt ist.
data-currentVorhanden auf dem Element der aktuellen Seite.
data-expandedVorhanden, solange ein Zweig offen ist.
data-pendingVorhanden, während onAction des Elements läuft.
data-disabledVorhanden, wenn das Item deaktiviert ist.
PropTypStandard
childrenStandard ist ein Chevron, beim Laden ein Spinner und nach einem Fehler ein Wiederholen-Symbol.
ReactNode | (state) => ReactNode–
AttributBeschreibung
data-slot="tree-indicator"Das Chevron per CSS ansprechen.
data-loadingVorhanden, während Kinder laden.
data-failedVorhanden, nachdem das Laden der Kinder fehlschlug.
PropTypStandard
childrenStandard ist ein Haken, bei gemischtem Zustand ein Strich.
ReactNode | (state) => ReactNode–
AttributBeschreibung
data-slot="tree-checkbox"Spricht die Checkbox in CSS an.
data-checkedVorhanden, solange abgehakt.
data-indeterminateVorhanden, solange ein Zweig teilweise abgehakt ist.
PropTypStandard
keepMounted
boolean–
hiddenUntilFound
boolean–
AttributBeschreibung
data-slot="tree-group"Spricht eine Gruppe in CSS an.
data-openVorhanden, solange geöffnet.
--tree-group-heightDie Höhe der Gruppe während der Animation.