# TreeView

> An accessible tree of nested items with expand and collapse, single or multiple selection, roving-tabindex keyboard navigation with typeahead, lazy children and RTL-aware arrow keys.

Source: https://docs.nasaqui.com/components/tree-view

## Install

```bash
npx shadcn@latest add https://docs.nasaqui.com/r/tree-view.json
```

A hierarchy the user can expand, collapse and select from, such as a file explorer, an outline or a category
picker. It follows the ARIA tree pattern: one tab stop, arrow-key navigation, typeahead, and state on every row.

## When to use

- Data that is naturally nested and can be deep: folders, categories, an outline.
- Choosing one or several nodes from a hierarchy.
- Children that should load only when a node is opened.

## When not to use

- A few sections of content that open and close: use [`Accordion`](https://docs.nasaqui.com/components/accordion).
- One block of hidden content: use [`Collapsible`](https://docs.nasaqui.com/components/collapsible).
- Tabular data with columns: use [`DataTable`](https://docs.nasaqui.com/components/data-table).

## Import

```tsx
import { TreeView } from "@fadymondy/nasaq/web";
// inside this monorepo: "@nasaq/web"
```

## Quick start

```tsx
import { TreeView, type TreeNode } from "@fadymondy/nasaq/web";
import { FileText, Folder } from "lucide-react";

const items: TreeNode[] = [
  {
    id: "docs",
    label: "Documents",
    textValue: "Documents",
    icon: <Folder />,
    children: [{ id: "cv", label: "CV.pdf", textValue: "CV.pdf", icon: <FileText /> }],
  },
  { id: "notes", label: "Notes.txt", textValue: "Notes.txt", icon: <FileText /> },
];

export function Files() {
  return <TreeView aria-label="Files" items={items} defaultExpanded={["docs"]} />;
}
```

## Anatomy

```
TreeView                    data-slot="tree-view"        role="tree"
└─ row (one per visible node)  data-slot="tree-view-item"   role="treeitem"
   ├─ toggle                   data-slot="tree-view-toggle"  (chevron or spinner)
   ├─ icon                     data-slot="tree-view-icon"
   └─ label
```

Rows are a flat list with `aria-level`, `aria-setsize` and `aria-posinset`, so assistive technology reads the
structure without nested groups. Only the rows that are currently visible are in the DOM.

## API

**TreeView** (`TreeViewProps`): a `div`; extra props such as `aria-label` and `className` go to the tree.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `items` | `TreeNode[]` | | The tree. Every node needs a unique `id`. |
| `expanded` / `defaultExpanded` / `onExpandedChange` | `string[]` / `string[]` / `(ids) => void` | uncontrolled, `[]` | Expanded node ids. |
| `selected` / `defaultSelected` / `onSelectedChange` | `string[]` / `string[]` / `(ids) => void` | uncontrolled, `[]` | Selected node ids. |
| `selectionMode` | `"single" \| "multiple" \| "none"` | `"single"` | `multiple` toggles and sets `aria-multiselectable`. `none` removes `aria-selected`. |
| `onExpand` | `(node) => void \| Promise` | | Called when a node with no loaded `children` opens. Return a promise to show a spinner. |
| `dir` | `"ltr" \| "rtl"` | Nasaq direction | Decides which arrow key expands. |
| `indent` | `number` | `1.25` | Indent per level, in rem. Applied as `padding-inline-start`. |
| `loadingLabel` | `string` | "Loading" / "جارٍ التحميل" | Spinner label. |

**TreeNode**

| Field | Type | Description |
| --- | --- | --- |
| `id` | `string` | Unique. |
| `label` | `ReactNode` | Row content. |
| `textValue` | `string` | Text for typeahead. Give it whenever `label` is not a plain string. Falls back to `id`. |
| `icon` | `ReactNode` | Decorative icon before the label. |
| `children` | `TreeNode[]` | Loaded children. |
| `hasChildren` | `boolean` | Shows a chevron before children are loaded. Ignored once `children` is set. |
| `disabled` | `boolean` | Skipped by the keyboard and not selectable. |

**Helpers**: pure and framework-free, exported for tests and custom trees: `flattenTree`, `getKeyAction`,
`findTypeahead`, `nextSelection`, `getAncestorIds` (path to reveal a node), `isExpandable`.

## Examples

**Controlled expansion and selection**

```tsx
import { TreeView, getAncestorIds, type TreeNode } from "@fadymondy/nasaq/web";
import { useState } from "react";

export function Controlled({ items }: { items: TreeNode[] }) {
  const [expanded, setExpanded] = useState<string[]>([]);
  const [selected, setSelected] = useState<string[]>([]);
  // Reveal a node by expanding its ancestors.
  const reveal = (id: string) => setExpanded((prev) => [...new Set([...prev, ...getAncestorIds(items, id)])]);
  return (
    <>
      <button type="button" onClick={() => reveal("letter")}>Show letter</button>
      <TreeView aria-label="Files" items={items} expanded={expanded} onExpandedChange={setExpanded} selected={selected} onSelectedChange={setSelected} />
    </>
  );
}
```

**Lazy children**

```tsx
import { TreeView, type TreeNode } from "@fadymondy/nasaq/web";
import { useState } from "react";

export function Remote() {
  const [items, setItems] = useState<TreeNode[]>([{ id: "remote", label: "Remote", textValue: "Remote", hasChildren: true }]);
  const load = async (node: TreeNode) => {
    const children = await fetchChildren(node.id);
    setItems((prev) => prev.map((n) => (n.id === node.id ? { ...n, children } : n)));
  };
  return <TreeView aria-label="Files" items={items} onExpand={load} />;
}

declare function fetchChildren(id: string): Promise<TreeNode[]>;
```

**Multiple selection**

```tsx
import { TreeView, type TreeNode } from "@fadymondy/nasaq/web";

export function Pick({ items }: { items: TreeNode[] }) {
  return <TreeView aria-label="Categories" items={items} selectionMode="multiple" onSelectedChange={(ids) => console.log(ids)} />;
}
```

## Accessibility

| Key | Action |
| --- | --- |
| Down / Up | Next or previous visible row. |
| Right | Expands a closed node; on an open node, moves to its first child. Swapped in RTL. |
| Left | Collapses an open node; otherwise moves to the parent. Swapped in RTL. |
| Home / End | First or last row. |
| Enter / Space | Selects the row (toggles in multiple mode). |
| A character | Typeahead: the next row whose text starts with what you typed. Repeat a letter to cycle. |

- `role="tree"` with `role="treeitem"` rows carrying `aria-level`, `aria-setsize`, `aria-posinset`, `aria-expanded` (branches only) and `aria-selected`.
- One row is `tabindex="0"`, the rest `-1`: the focused row, else the first selected, else the first enabled row.
- Give the tree an `aria-label` (defaults to "Tree" / "شجرة"). A loading row has `aria-busy`.
- Clicking the chevron toggles expansion without changing the selection.

## RTL & i18n

- Indent is `padding-inline-start`, so children sit under the parent's start edge in both directions.
- The collapsed chevron mirrors in RTL (`Icon directional`); the open chevron points down in both.
- Left and Right arrow keys swap in RTL. The direction comes from the Nasaq provider, or the `dir` prop.
- Default strings ship in English and Arabic. Node labels are the caller's to translate.

## Styling & tokens

- Rows: `rounded-control`, `hover:bg-nq-hover`, `data-selected:bg-nq-selected`, `outline-nq-focus`. Icons and chevrons use `text-muted-foreground`.
- State attributes on a row: `data-selected`, `data-expanded`, `data-disabled`.
- Extend with `className`. Use tokens, never raw hex.

## Do / Don't

- Do set `textValue` on every node whose label is not a string.
- Do return a promise from `onExpand` so users see the spinner.
- Don't put interactive controls inside a row: the row is the control.
- Don't use it for a short flat list: use a list or `Select`.

## Related

- [`Accordion`](https://docs.nasaqui.com/components/accordion)
- [`Collapsible`](https://docs.nasaqui.com/components/collapsible)
- [`DataTable`](https://docs.nasaqui.com/components/data-table)

## Lab

https://docs.nasaqui.com/?path=/docs/components-navigation-tree-view--docs

## Code

### React

```tsx
import { TreeView, type TreeNode } from "@fadymondy/nasaq/web";
import { FileText, Folder } from "lucide-react";

const items: TreeNode[] = [
  {
    id: "docs",
    label: "Documents",
    textValue: "Documents",
    icon: <Folder />,
    children: [{ id: "cv", label: "CV.pdf", textValue: "CV.pdf", icon: <FileText /> }],
  },
  { id: "notes", label: "Notes.txt", textValue: "Notes.txt", icon: <FileText /> },
];

export function Files() {
  return <TreeView aria-label="Files" items={items} defaultExpanded={["docs"]} />;
}
```

### shadcn

```tsx
import { TreeView, type TreeNode } from "@/components/ui/tree-view";
import { FileText, Folder } from "lucide-react";

const items: TreeNode[] = [
  {
    id: "docs",
    label: "Documents",
    textValue: "Documents",
    icon: <Folder />,
    children: [{ id: "cv", label: "CV.pdf", textValue: "CV.pdf", icon: <FileText /> }],
  },
  { id: "notes", label: "Notes.txt", textValue: "Notes.txt", icon: <FileText /> },
];

export function Files() {
  return <TreeView aria-label="Files" items={items} defaultExpanded={["docs"]} />;
}
```

### Vue

```vue
<script setup lang="ts">
import { FileText, Folder } from "lucide-vue-next";
import { NqTreeView, type TreeNode } from "@fadymondy/nasaq/vue";

const items: TreeNode[] = [
  {
    id: "docs",
    label: "Documents",
    textValue: "Documents",
    icon: Folder,
    children: [{ id: "cv", label: "CV.pdf", textValue: "CV.pdf", icon: FileText }],
  },
  { id: "notes", label: "Notes.txt", textValue: "Notes.txt", icon: FileText },
];
</script>

<template>
  <NqTreeView aria-label="Files" :items="items" :default-expanded="['docs']" />
</template>
```

### Blade

```blade
@php
    $items = [
        [
            'id' => 'docs',
            'label' => 'Documents',
            'textValue' => 'Documents',
            'icon' => 'folder',
            'children' => [
                ['id' => 'cv', 'label' => 'CV.pdf', 'textValue' => 'CV.pdf', 'icon' => 'file-text'],
            ],
        ],
        ['id' => 'notes', 'label' => 'Notes.txt', 'textValue' => 'Notes.txt', 'icon' => 'file-text'],
    ];
@endphp
<x-nq::tree-view aria-label="Files" :items="$items" :default-expanded="['docs']" />
```

### HTML + Alpine

```html
<div data-slot="tree-view" role="tree" x-data="nqTreeView(JSON.parse('{\u0022nodes\u0022:[{\u0022id\u0022:\u0022docs\u0022,\u0022parent\u0022:null,\u0022expandable\u0022:true,\u0022lazy\u0022:false,\u0022disabled\u0022:false,\u0022text\u0022:\u0022Documents\u0022},{\u0022id\u0022:\u0022cv\u0022,\u0022parent\u0022:\u0022docs\u0022,\u0022expandable\u0022:false,\u0022lazy\u0022:false,\u0022disabled\u0022:false,\u0022text\u0022:\u0022CV.pdf\u0022},{\u0022id\u0022:\u0022notes\u0022,\u0022parent\u0022:null,\u0022expandable\u0022:false,\u0022lazy\u0022:false,\u0022disabled\u0022:false,\u0022text\u0022:\u0022Notes.txt\u0022}],\u0022expanded\u0022:[\u0022docs\u0022],\u0022selected\u0022:[],\u0022mode\u0022:\u0022single\u0022,\u0022dir\u0022:null}'))" x-modelable="selected" x-on:keydown="onKeydown($event)"
        aria-label="Files" class="flex flex-col gap-0.5 text-body text-foreground">
                            <div data-slot="tree-view-item" data-node-id="docs" x-bind="item('docs')"
                         data-expanded                         tabindex="0"
            aria-level="1" aria-setsize="2" aria-posinset="1"
             aria-expanded="true"              aria-selected="false"             style=" padding-inline-start:0.375rem"
            class="flex min-h-8 cursor-default items-center gap-1.5 rounded-control pe-2 outline-none hover:bg-nq-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-nq-focus data-[selected]:bg-nq-selected data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
            <span data-slot="tree-view-toggle" class="flex size-5 shrink-0 items-center justify-center text-muted-foreground"
                 x-on:click.stop="toggle('docs')" >
                                    <span x-show="loading.includes('docs')" style="display:none" role="status" class="inline-flex">
                        <svg data-slot="spinner" aria-hidden="true" class="size-3.5 animate-spin motion-reduce:animate-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M21 12a9 9 0 1 1-6.219-8.56"/>
</svg>                        <span class="sr-only">Loading</span>
                    </span>
                    <span x-show="!loading.includes('docs')" class="contents">
                        <span x-show="expanded.includes('docs')" class="contents" ><svg aria-hidden="true" class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="m6 9 6 6 6-6"/>
</svg></span>
                        <span x-show="!expanded.includes('docs')" class="contents"  style="display:none" ><svg data-slot="icon" aria-hidden="true" class="rtl:-scale-x-100 size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="m9 18 6-6-6-6"/>
</svg></span>
                    </span>
                            </span>
                            <span data-slot="tree-view-icon" aria-hidden="true" class="flex shrink-0 items-center text-muted-foreground [&_svg]:size-4"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>
</svg></span>
                                        <span class="min-w-0 flex-1 truncate">Documents</span>
                    </div>
                            <div data-slot="tree-view-item" data-node-id="cv" x-bind="item('cv')"
                                                tabindex="-1"
            aria-level="2" aria-setsize="1" aria-posinset="1"
                         aria-selected="false"             style=" padding-inline-start:1.625rem"
            class="flex min-h-8 cursor-default items-center gap-1.5 rounded-control pe-2 outline-none hover:bg-nq-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-nq-focus data-[selected]:bg-nq-selected data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
            <span data-slot="tree-view-toggle" class="flex size-5 shrink-0 items-center justify-center text-muted-foreground"
                >
                            </span>
                            <span data-slot="tree-view-icon" aria-hidden="true" class="flex shrink-0 items-center text-muted-foreground [&_svg]:size-4"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>
  <path d="M14 2v5a1 1 0 0 0 1 1h5"/>
  <path d="M10 9H8"/>
  <path d="M16 13H8"/>
  <path d="M16 17H8"/>
</svg></span>
                                        <span class="min-w-0 flex-1 truncate">CV.pdf</span>
                    </div>
                            <div data-slot="tree-view-item" data-node-id="notes" x-bind="item('notes')"
                                                tabindex="-1"
            aria-level="1" aria-setsize="2" aria-posinset="2"
                         aria-selected="false"             style=" padding-inline-start:0.375rem"
            class="flex min-h-8 cursor-default items-center gap-1.5 rounded-control pe-2 outline-none hover:bg-nq-hover focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-nq-focus data-[selected]:bg-nq-selected data-[disabled]:pointer-events-none data-[disabled]:opacity-50">
            <span data-slot="tree-view-toggle" class="flex size-5 shrink-0 items-center justify-center text-muted-foreground"
                >
                            </span>
                            <span data-slot="tree-view-icon" aria-hidden="true" class="flex shrink-0 items-center text-muted-foreground [&_svg]:size-4"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"/>
  <path d="M14 2v5a1 1 0 0 0 1 1h5"/>
  <path d="M10 9H8"/>
  <path d="M16 13H8"/>
  <path d="M16 17H8"/>
</svg></span>
                                        <span class="min-w-0 flex-1 truncate">Notes.txt</span>
                    </div>
    </div>
```
