HextaUI

ベータVercel の UI SDK 0.1.0-beta.1 上に構築。API は変わる可能性があります。

Tree

Vercel の UI SDK 上のツリービュー。ファイルや入れ子のページ向けで、ファイルパス、チェックボックス、リンク、必要時に読み込む子項目に対応します。

依存関係
  • @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

コンポーネント、HextaUIのテーマトークン、依存するHextaUIコンポーネントを追加します。

UI SDK は ui-sdk というカスケードレイヤーに少しだけ独自の CSS を追加します。自分のクラスが優先されるよう、グローバルスタイルシートの先頭、Tailwind の import より上でこのレイヤーを宣言してください。

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

中に TreeGroup を持つ項目は枝になります。各項目に一意の value を、ツリーにアクセシブルな名前を付けます。フォーカスは項目に留まり、見える行はそのラベルなので、選択・現在・フォーカスのスタイルはすべてラベルに付けます。

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

子を渡さずに items を渡すと、矢印付きのスタイル済みの行が描画されます。各項目は value、label、任意の 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 }

ファイルエクスプローラーには paths を渡します。フォルダーが先に並びます。activation="automatic" では矢印キーで選択され、Promise を返す onAction は完了まで行を保留中にします。

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

mode="multiple" では Cmd または Ctrl クリックで項目を切り替え、Shift で範囲を広げます。expandOnPress={false} にすると展開は矢印アイコンと矢印キーだけで行います。

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

checkable を付け、各ラベルに TreeItemCheckbox を置きます。枝をチェックすると中身がすべてチェックされ、一部だけチェックされた枝にはダッシュが出ます。

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

href を持つ項目はリンクとして描画されます。mode="none" と current を指定すると、現在のページに aria-current が付き、親が開き、タブ移動先になります。

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

子がまだない項目に branch を付けると、初めて開いたときに loadChildren が走ります。読み込み中は矢印がスピナーに、失敗すると再試行アイコンに変わります。Archive は一度だけ失敗します。

"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" は開いた各グループにインデントのガイド線を描き、size="sm" は行を低くします。

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 }

createTreeController() のコントローラーで、どこからでも項目の展開・折りたたみ・選択・表示ができます。

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

hiddenUntilFound では閉じたグループもページに残るため、ブラウザーのページ内検索で Phobos が見つかり、Mars が開いて表示されます。

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 }

無効な項目もスクリーンリーダーが見つけられるようフォーカスは受けますが、選択・チェック・展開はできません。

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 }

行は右からインデントされ、矢印は左を向き、左右の矢印キーが入れ替わります。

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 }
キーアクション
↓↑次または前の表示中の項目に移動します。
→枝を開くか、最初の子に移動します。
←枝を閉じるか、親に移動します。
HomeEnd最初または最後の表示中の項目に移動します。
Alt→枝とその中身をすべて開きます。
*フォーカス中の枝と同じ階層の枝をすべて開きます。
Enter項目を選択してアクションを実行するか、リンク先へ移動します。
Spaceチェック可能なツリーでは項目をチェックし、それ以外では選択します。
Shift↓複数選択を広げます。
⌘A複数選択モードで表示中の項目をすべて選択します。

ツリーはタブ移動先が 1 つの WAI-ARIA ツリービューパターンに従います。Tab では現在のページ、選択中の項目、または最初の項目に移動します。文字を入力すると、その文字で始まる次の項目に移動します。遅い onAction の実行中は 1 秒後にスクリーンリーダーが pendingAnnouncement を読み上げるので、ページの言語で設定してください。

モーションが許可されている場合のみ、グループは高さのアニメーションで開閉します。タッチ画面では行が高くなり、強制カラーモードでは選択中と現在の行に輪郭線が付きます。

各パーツは同名の UI SDK パーツをラップし、render やパーツの状態を受け取る関数にもできる className を含むすべての props を受け付けます。

プロパティ型デフォルト
items階層全体。子を渡さない場合、ツリーはここからスタイル済みの行を描画します。
TreeItemData[]–
pathsファイルパス。フォルダーは枝になり、/ で終わるパスは空のフォルダーです。
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"
onAction押したときや Enter で実行されます。Promise を返すと項目が保留中になります。
(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"
controllercreateTreeController() から作成します。
TreeController–
属性説明
data-slot="tree"CSS でツリーを指定します。
data-variantバリアント:default または lines。
data-sizeサイズ:default または sm。
--tree-row-height各行の高さ。size で設定します。
--tree-indent各階層のインデント幅。
プロパティ型デフォルト
valueツリー内で一意。
string–
branch子を後で読み込む枝に指定します。
boolean–
disabled
booleanfalse
labelラベルがプレーンテキストでないときに、先行入力で照合するテキスト。
string–
render
ReactElement | (props, state) => ReactElement–
属性説明
data-slot="tree-item"CSS で項目を指定します。フォーカスを持ちます。
data-level1 から始まる深さ。
data-branch枝に付きます。
data-expanded枝が開いている間に付きます。
--tree-levelフラットなリストをインデントするための深さ。
プロパティ型デフォルト
render
ReactElement | (props, state) => ReactElement–
属性説明
data-slot="tree-label"CSS で行を指定します。
data-selected項目が選択されている間に付きます。
data-current現在のページの項目に付きます。
data-expanded枝が開いている間に付きます。
data-pending項目の onAction の実行中に付きます。
data-disabled項目が無効のときに存在します。
プロパティ型デフォルト
children既定は山形の矢印、読み込み中はスピナー、失敗後は再試行アイコンです。
ReactNode | (state) => ReactNode–
属性説明
data-slot="tree-indicator"CSS でシェブロンを指定します。
data-loading子の読み込み中に付きます。
data-failed子の読み込みに失敗した後に付きます。
プロパティ型デフォルト
children既定はチェック、混在時はダッシュです。
ReactNode | (state) => ReactNode–
属性説明
data-slot="tree-checkbox"CSS でチェックボックスを指定します。
data-checkedチェック中に付きます。
data-indeterminate枝が一部だけチェックされている間に付きます。
プロパティ型デフォルト
keepMounted
boolean–
hiddenUntilFound
boolean–
属性説明
data-slot="tree-group"CSS でグループを指定します。
data-open開いている間存在します。
--tree-group-heightアニメーション中のグループの高さ。