[//]: # (This file is auto-generated by the docs:generate-skills script, do not edit it manually)

# TreeView

Import: `import { TreeView } from '@neo4j-ndl/react/next'`

## Props

### TreeView

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `ariaLabel` | `string \| null` | ✅ |  | The aria-label for the tree. Required for accessibility, unless using ariaLabelledby. Pass null to omit. |
| `ariaLabelledby` | `string` |  |  | The aria-labelledby for the tree. Pass a string of space-separated IDs of elements that label the tree. |
| `children` | `ReactNode` |  |  | The children of the tree. Should be TreeView.Item components or TreeView.SkeletonItem components only. |
| `defaultSelectedIds` | `string[]` |  |  | Initially selected item ids for an uncontrolled root-managed selection. |
| `onSelectedIdsChange` | `((selectedIds: string[]) => void)` |  |  | Callback called with the complete next selection whenever the tree manages selection. Fires once per interaction. |
| `ref` | `Ref<HTMLDivElement>` |  |  | A ref to apply to the root element. |
| `selectedIds` | `string[]` |  |  | Selected item ids. Makes the tree manage selection itself, instead of each item managing its own. |
| `selectionMode` | `'multiple-replace' \| 'multiple' \| 'single'` |  | `single` | Selection mode for the tree. Defaults to "single". |

Selection can be managed per item, with `isSelected` and `onSelectedChange`
on each `TreeView.Item`, or by the tree itself, by passing `selectedIds` (or
`defaultSelectedIds`) together with `onSelectedIdsChange`. When the tree
manages selection, every item needs an `id`, and each item's own
`isSelected` and `onSelectedChange` are ignored.

The available selection modes are:

- `single` — one item at a time. An unmodified click selects the clicked item and never clears it.
- `multiple` — multi-select with checkboxes. An unmodified click toggles an item, and `Shift` repeats that across a range without clearing anything outside it.
- `multiple-replace` — multi-select without checkboxes. An unmodified click selects only that item, `Cmd`/`Ctrl` toggles it and moves the anchor, and `Shift` extends from that anchor while retaining its selection snapshot. `Shift` + arrow extends the range.

### TreeView.Item

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `actionMenuItems` | `ReactNode` |  |  | ReactNode containing Menu.Item elements to render in an action menu. When provided, an action button is rendered. |
| `actionMenuProps` | `MenuProps & { as?: ElementType<any, keyof IntrinsicElements>; } & BaseProps<ElementType<any, keyof IntrinsicElements>>` |  |  | Props forwarded to the Menu sub-components (anchorRef, onClose). |
| `children` | `ReactNode` |  |  | Nested TreeView.Item or TreeView.SkeletonItem elements rendered as sub-items in a collapsible group. |
| `defaultExpanded` | `boolean` |  | `false` | Default expansion for uncontrolled items |
| `hasChildren` | `boolean` |  |  | Whether the item has children. Decides if the item should render a chevron for expansion. Needed for lazy loading. |
| `id` | `string` |  |  | Identifies the item. Required when the tree manages selection via `selectedIds`. |
| `isDisabled` | `boolean` |  |  | Whether the item is disabled |
| `isExpanded` | `boolean` |  |  | Whether the item is expanded. Makes the item controlled. |
| `isIndeterminate` | `boolean` |  |  | Whether the item is in an indeterminate state. Only meaningful in multi-select mode for parent nodes. |
| `isLoading` | `boolean` |  |  | Whether the item is loading. Applies aria-busy="true" to the item. |
| `isSelectable` | `boolean` |  |  | Whether the item can be selected. Defaults to true when the tree manages selection, and otherwise to whether `onSelectedChange` is set. |
| `isSelected` | `boolean` |  |  | Whether the item is selected. Ignored when the tree manages selection via `selectedIds`. |
| `leadingVisual` | `ReactNode` |  |  | Leading visual for the item. |
| `onExpandedChange` | `(isExpanded: boolean) => void` |  |  | Callback called when the item is expanded/collapsed. |
| `onSelectedChange` | `(isSelected: boolean) => void` |  |  | Callback called when the item is selected/deselected. Receives the new selected state. Ignored when the tree manages selection via `selectedIds`. |
| `ref` | `Ref<HTMLDivElement>` |  |  | A ref to apply to the root element. |
| `title` | `ReactNode` |  |  | The label content displayed in the item row. |
| `tooltipContent` | `ReactNode` |  |  | Content rendered inside the tooltip. When provided, the item is wrapped in a Tooltip. |
| `tooltipProps` | `TooltipObjectProps` |  |  | Props forwarded to the Tooltip sub-components (root, trigger, content). |
| `trailingContent` | `ReactNode` |  |  | Trailing content for the item. Shown after the children but before the action menu. |

### TreeView.SkeletonItem

| Prop | Type | Required | Default | Description |
|------|------|----------|---------|-------------|
| `ref` | `Ref<HTMLDivElement>` |  |  | A ref to apply to the root element. |
| `rows` | `number` |  | `1` | The number of rows to render. |

## Accessibility

## Keyboard interactions

Implements the keyboard interactions defined in the [WAI-ARIA TreeView pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/).

| Key | Description |
|-----|-------------|
| `ArrowDown` | Moves focus to the next visible tree item |
| `ArrowUp` | Moves focus to the previous visible tree item |
| `ArrowRight` | Expands a collapsed parent item. If already expanded, moves focus to the first child item |
| `ArrowLeft` | Collapses an expanded parent item. If already collapsed (or a leaf), moves focus to the parent item |
| `Home` | Moves focus to the first tree item |
| `End` | Moves focus to the last visible tree item |
| `Enter` | Selects the focused item, or toggles expansion if the item is not selectable |
| `Space` | Selects the focused item, or toggles expansion if the item is not selectable |
| `Shift + F10` | Opens the action menu on the focused item (when `actionMenu` is provided) |
| `Cmd/Ctrl + Enter`, `Cmd/Ctrl + Space` | `multiple-replace` only: toggles the focused item without clearing the rest |
| `Shift + Enter`, `Shift + Space` | Both multiple modes: extends the range from the anchor to the focused item |
| `Shift + ArrowUp`, `Shift + ArrowDown` | Both multiple modes: moves focus and extends the range to the newly focused item |

The tree uses a roving tabindex strategy: only the currently focused item has `tabIndex={0}`, all other items have `tabIndex={-1}`. When the tree root receives focus it delegates to the previously focused item, or the first item if none was focused. While focus is inside the tree, the root removes itself from the tab order, so the whole tree is a single tab stop: `Tab` and `Shift+Tab` always move focus out of the tree in one press.

The toggle modifier is `Cmd` on Apple platforms and `Ctrl` everywhere else, so `Ctrl` + click keeps its macOS meaning of opening the context menu.

### Selection in the multiple modes

Keyboard and pointer take the same modifiers and drive the same selection, so the two can be mixed freely. What each modifier means depends on the mode:

| Modifier | `multiple-replace` | `multiple` |
|----------|--------------------|------------|
| none | Selects only that item, clearing the rest | Toggles that item |
| `Cmd`/`Ctrl` | Toggles that item, leaving the rest alone | Nothing extra, since an unmodified click already toggles |
| `Shift` | Selects the range from the anchor and keeps the selection the anchor was placed in | Repeats the anchor's state across the range, leaving anything outside it alone |
| `Cmd`/`Ctrl` + `Shift` | Same as `Shift` alone | Same as `Shift` alone |

`Shift` + `ArrowUp`/`ArrowDown` moves focus and extends the range in one step, and `Shift` + `Enter`/`Space` extends it to the focused item.

Because `multiple` toggles, its ranges paint rather than replace: ticking an item and then `Shift`-clicking five rows down ticks all six, while unticking that item first unticks the same range. A keyboard range has no preceding click to repeat, so it selects outwards from the focused item.

Ranges run between an anchor and a target. Any unmodified or `Cmd`/`Ctrl` selection moves the anchor to that item; extending a range leaves it where it is, so a range can be grown and shrunk repeatedly from the same starting point. In `multiple-replace`, the anchor also remembers the selection immediately after it was placed. This means selecting several disconnected items with `Cmd`/`Ctrl`, then using `Shift`, keeps those earlier picks and starts the range at the most recently toggled item. Toggling that item off still moves the anchor there, and the later range selects outwards from it. If no anchor exists when a range starts, the currently focused item becomes one. If the anchor ends up inside a collapsed group, the range is measured from its nearest visible ancestor, so collapsing a group cannot silently shrink a range to a single item.

Ranges skip disabled items and never reach into collapsed groups. In `multiple-replace`, earlier picks from the anchor snapshot are retained only while visible; hidden selectable items are cleared, so collapsing a group never strands a selection the user can no longer see. `multiple` never clears outside the range, so nothing hidden is touched.

Note that in `multiple-replace` an unmodified `Enter`/`Space` selects exclusively rather than toggling, which departs from the WAI-ARIA multi-select tree pattern. It is deliberate: it keeps the keyboard and the pointer behaving identically, and `Cmd`/`Ctrl` + `Enter`/`Space` still covers additive selection. `multiple` toggles, as the pattern describes.

### Announcements

Range selections are announced in a polite live region rendered next to the tree, as `4 items selected`, counting the whole selection rather than the rows the range just moved. A range can move any number of rows at once, so nothing else reports the total. Selections made without `Shift` clear the region instead, since the row they moved already carries its own `aria-selected`/`aria-checked`, and a count left behind would stop describing the tree as soon as the next selection changed it. The message is English and not currently configurable.

## WAI-ARIA roles and attributes

The TreeView component follows the [WAI-ARIA TreeView pattern](https://www.w3.org/WAI/ARIA/apg/patterns/treeview/).

- The root container has `role="tree"` and is labeled via `aria-label` or `aria-labelledby`
- When `selectionMode` is `"multiple"` or `"multiple-replace"`, the root sets `aria-multiselectable="true"`
- Each item has `role="treeitem"` with `aria-level`, `aria-posinset`, and `aria-setsize` set automatically
- Parent items (items with children) set `aria-expanded` and `aria-owns` linking to their child `role="group"` container
- In single and `multiple-replace` selection modes, selected items set `aria-selected`. In multiple selection mode, selected items set `aria-checked`, with `aria-checked="mixed"` for indeterminate parent nodes
- Only selectable items expose `aria-selected`/`aria-checked`. An item is selectable when `isSelectable` says so, and otherwise when the tree manages selection or the item has an `onSelectedChange`. The rest are announced as non-selectable, which is what lets folders act as pure containers
- The live region carrying selection announcements has `role="status"` and `aria-live="polite"`. It sits beside the tree rather than inside it, since `role="tree"` only allows `treeitem` and `group` children
- Disabled items set `aria-disabled="true"`
- Loading items set `aria-busy="true"`
- When `actionMenu` is provided, the treeitem sets `aria-keyshortcuts="Shift+F10"` and the action button sets `aria-haspopup="menu"`, `aria-expanded`, and `aria-label="Actions"`
- `TreeView.SkeletonItem` is `aria-hidden="true"` since it is a visual placeholder with no interactive content
- Child groups use `role="group"` and are `hidden` when the parent is collapsed

## Implementation guidelines

- Always provide either `ariaLabel` or `ariaLabelledby` on the root so the tree has an accessible name
- `role="tree"` only allows children with roles `treeitem` or `group`. The built-in sub-components handle this automatically, but wrapping items in custom elements without a valid role will break the tree semantics for assistive technologies
- In single selection mode (`selectionMode="single"`, the default), selecting always replaces and never deselects. When the tree manages selection through `selectedIds`, that is enforced for you. With per-item `isSelected`/`onSelectedChange` only the clicked item is told about the change, so keeping at most one item selected at a time stays the consumer's responsibility
- In multiple selection mode, manage `isIndeterminate` on parent nodes to communicate partial selection via `aria-checked="mixed"`. Screen reader users rely on this to understand that some but not all children are selected
- The multiple modes compute the whole selection for you. With per-item callbacks that means `onSelectedChange` fires on every item that changed, so one gesture can mean many callbacks — keep those state updates cheap. Passing `selectedIds` and `onSelectedIdsChange` on the root instead reports the next selection once per gesture, which is easier to reconcile when a range flips many items at once
- `isSelectable={false}` keeps an item out of selection entirely. Clicking it toggles its expansion instead, in every selection mode. When the tree manages selection through `selectedIds` every item is selectable by default, so folders that should stay pure containers have to opt out explicitly
- `multiple-replace` has no checkbox. Selection shows up only as `aria-selected` and the accent bar, so keep those two states distinguishable by more than colour
- Adjacent selected items merge into one rounded block. That is decorative only — each item still announces its own `aria-selected`
- When using `hasChildren` for lazy loading, set `isLoading` to apply `aria-busy="true"` and render `TreeView.SkeletonItem` inside the expanded item so users know content is being fetched
- The checkbox rendered in multiple selection mode has `tabIndex={-1}` — selection is driven from the treeitem via `Enter`/`Space`, not from the checkbox directly
- The action menu button has `tabIndex={-1}` and is only reachable via `Shift+F10`, keeping arrow-key navigation within the tree clean

### Related WCAG criteria

- [2.1.1 Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html) (A): All tree items are fully navigable and operable via keyboard, including range selection in both multiple modes via `Shift` + arrow or `Shift` + `Enter`/`Space`
- [2.4.3 Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) (A): Roving tabindex ensures a logical focus order; `ArrowLeft` returns focus to the parent item
- [1.3.1 Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) (A): Tree hierarchy is expressed via `aria-level`, `aria-posinset`, `aria-setsize`, and `role="group"` nesting
- [4.1.2 Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) (A): Roles (`tree`, `treeitem`, `group`), states (`aria-expanded`, `aria-selected`/`aria-checked`, `aria-disabled`, `aria-busy`), and accessible names (`aria-label`/`aria-labelledby`) are set automatically by the component

## Examples

### Actions

```tsx
import '@neo4j-ndl/base/lib/neo4j-ds-styles.css';

import { Menu } from '@neo4j-ndl/react';
import { DocumentIconOutline, FolderIconSolid } from '@neo4j-ndl/react/icons';
import { TreeView } from '@neo4j-ndl/react/next';
import { useState } from 'react';

const Component = () => {
  const [expanded, setExpanded] = useState<Record<string, boolean>>({});
  const [selected, setSelected] = useState<string | null>(null);

  const toggle = (key: string) =>
    setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));

  return (
    <TreeView ariaLabel="Project files">
      <TreeView.Item
        title="src"
        hasChildren
        isExpanded={expanded['src']}
        onExpandedChange={() => toggle('src')}
        isSelected={selected === 'src'}
        onSelectedChange={() => setSelected('src')}
        leadingVisual={<FolderIconSolid />}
        actionMenuItems={
          <>
            <Menu.Item
              title="New File"
              onClick={() => alert('New file in src')}
            />
            <Menu.Item title="Rename" onClick={() => alert('Rename src')} />
            <Menu.Item title="Delete" onClick={() => alert('Delete src')} />
          </>
        }
      >
        <TreeView.Item
          title="index.ts"
          isSelected={selected === 'index'}
          onSelectedChange={() => setSelected('index')}
          leadingVisual={<DocumentIconOutline />}
          actionMenuItems={
            <>
              <Menu.Item title="Open" onClick={() => alert('Open index.ts')} />
              <Menu.Item
                title="Rename"
                onClick={() => alert('Rename index.ts')}
              />
              <Menu.Item
                title="Delete"
                onClick={() => alert('Delete index.ts')}
              />
            </>
          }
        />
        <TreeView.Item
          title="utils.ts"
          isSelected={selected === 'utils'}
          onSelectedChange={() => setSelected('utils')}
          leadingVisual={<DocumentIconOutline />}
          actionMenuItems={
            <>
              <Menu.Item title="Open" onClick={() => alert('Open utils.ts')} />
              <Menu.Item
                title="Rename"
                onClick={() => alert('Rename utils.ts')}
              />
              <Menu.Item
                title="Delete"
                onClick={() => alert('Delete utils.ts')}
              />
            </>
          }
        />
      </TreeView.Item>
      <TreeView.Item
        title="tests"
        hasChildren
        isExpanded={expanded['tests']}
        onExpandedChange={() => toggle('tests')}
        isSelected={selected === 'tests'}
        onSelectedChange={() => setSelected('tests')}
        leadingVisual={<FolderIconSolid />}
        actionMenuItems={
          <>
            <Menu.Item
              title="Run Tests"
              onClick={() => alert('Run all tests')}
            />
            <Menu.Item
              title="New Test"
              onClick={() => alert('New test in tests')}
            />
            <Menu.Item title="Delete" onClick={() => alert('Delete tests')} />
          </>
        }
      >
        <TreeView.Item
          title="index.test.ts"
          isSelected={selected === 'index-test'}
          onSelectedChange={() => setSelected('index-test')}
          leadingVisual={<DocumentIconOutline />}
          actionMenuItems={
            <>
              <Menu.Item
                title="Run"
                onClick={() => alert('Run index.test.ts')}
              />
              <Menu.Item
                title="Rename"
                onClick={() => alert('Rename index.test.ts')}
              />
              <Menu.Item
                title="Delete"
                onClick={() => alert('Delete index.test.ts')}
              />
            </>
          }
        />
        <TreeView.Item
          title="utils.test.ts"
          isSelected={selected === 'utils-test'}
          onSelectedChange={() => setSelected('utils-test')}
          leadingVisual={<DocumentIconOutline />}
          actionMenuItems={
            <>
              <Menu.Item
                title="Run"
                onClick={() => alert('Run utils.test.ts')}
              />
              <Menu.Item
                title="Rename"
                onClick={() => alert('Rename utils.test.ts')}
              />
              <Menu.Item
                title="Delete"
                onClick={() => alert('Delete utils.test.ts')}
              />
            </>
          }
        />
      </TreeView.Item>
      <TreeView.Item
        title="README.md"
        isSelected={selected === 'readme'}
        onSelectedChange={() => setSelected('readme')}
        leadingVisual={<DocumentIconOutline />}
        actionMenuItems={
          <>
            <Menu.Item title="Open" onClick={() => alert('Open README.md')} />
            <Menu.Item
              title="Rename"
              onClick={() => alert('Rename README.md')}
            />
            <Menu.Item
              title="Delete"
              onClick={() => alert('Delete README.md')}
            />
          </>
        }
      />
    </TreeView>
  );
};

export default Component;
```

### Large

```tsx
import '@neo4j-ndl/base/lib/neo4j-ds-styles.css';

import { DocumentIconOutline, FolderIconSolid } from '@neo4j-ndl/react/icons';
import { TreeView } from '@neo4j-ndl/react/next';
import { useState } from 'react';

type FileNode = {
  children?: FileNode[];
  id: string;
  title: string;
};

const TOP_FOLDER_COUNT = 7;
const SUBFOLDER_COUNT = 9;
const FILE_COUNT = 10;

/**
 * 7 top folders + 63 subfolders + 630 files = 700 items, all expanded so every
 * row is mounted and visible. Exercises the worst-case performance paths:
 * arrow-key navigation, Shift ranges, and the multiple-replace edge styling.
 */
const files: FileNode[] = Array.from(
  { length: TOP_FOLDER_COUNT },
  (_unused, folderIndex) => ({
    children: Array.from({ length: SUBFOLDER_COUNT }, (_unused, subIndex) => ({
      children: Array.from({ length: FILE_COUNT }, (_unused, fileIndex) => ({
        id: `file-${folderIndex}-${subIndex}-${fileIndex}`,
        title: `file-${folderIndex}-${subIndex}-${fileIndex}.txt`,
      })),
      id: `subfolder-${folderIndex}-${subIndex}`,
      title: `Subfolder ${folderIndex}.${subIndex}`,
    })),
    id: `folder-${folderIndex}`,
    title: `Folder ${folderIndex}`,
  }),
);

const Component = () => {
  const [selectedIds, setSelectedIds] = useState<string[]>([]);

  const renderNode = (node: FileNode) => (
    <TreeView.Item
      key={node.id}
      id={node.id}
      title={node.title}
      defaultExpanded
      leadingVisual={
        node.children === undefined ? (
          <DocumentIconOutline />
        ) : (
          <FolderIconSolid />
        )
      }
    >
      {node.children?.map(renderNode)}
    </TreeView.Item>
  );

  return (
    <>
      <p style={{ marginBottom: '8px' }}>
        700 items, {selectedIds.length} selected
      </p>
      <TreeView
        ariaLabel="Large file explorer"
        selectionMode="multiple-replace"
        selectedIds={selectedIds}
        onSelectedIdsChange={setSelectedIds}
      >
        {files.map(renderNode)}
      </TreeView>
    </>
  );
};

export default Component;
```

### Lazy

```tsx
import '@neo4j-ndl/base/lib/neo4j-ds-styles.css';

import {
  DocumentIconOutline,
  FolderIconSolid,
  PhotoIconOutline,
} from '@neo4j-ndl/react/icons';
import { TreeView } from '@neo4j-ndl/react/next';
import { useCallback, useMemo, useRef, useState } from 'react';

type TreeNode = {
  id: string;
  label: string;
  children?: TreeNode[];
};

const TREE_DATA: TreeNode[] = [
  {
    children: [
      {
        children: [
          { id: 'app-tsx', label: 'App.tsx' },
          { id: 'index-tsx', label: 'index.tsx' },
          { id: 'styles-css', label: 'styles.css' },
        ],
        id: 'frontend',
        label: 'Frontend',
      },
      {
        children: [
          { id: 'server-ts', label: 'server.ts' },
          { id: 'routes-ts', label: 'routes.ts' },
        ],
        id: 'backend',
        label: 'Backend',
      },
      { id: 'readme-md', label: 'README.md' },
    ],
    id: 'projects',
    label: 'Projects',
  },
  {
    children: [
      {
        children: [
          { id: 'beach-jpg', label: 'beach.jpg' },
          { id: 'sunset-jpg', label: 'sunset.jpg' },
        ],
        id: 'vacation',
        label: 'Vacation',
      },
      { id: 'profile-png', label: 'profile.png' },
    ],
    id: 'photos',
    label: 'Photos',
  },
  {
    children: [
      { id: 'resume-pdf', label: 'resume.pdf' },
      { id: 'cover-letter-pdf', label: 'cover-letter.pdf' },
      { id: 'notes-txt', label: 'notes.txt' },
    ],
    id: 'documents',
    label: 'Documents',
  },
  {
    children: [
      {
        children: [
          { id: 'chill-m3u', label: 'chill.m3u' },
          { id: 'workout-m3u', label: 'workout.m3u' },
        ],
        id: 'playlists',
        label: 'Playlists',
      },
      { id: 'favorites-m3u', label: 'favorites.m3u' },
    ],
    id: 'music',
    label: 'Music',
  },
  { id: 'todo-txt', label: 'TODO.txt' },
];

const LOAD_DELAY_MS = 600;

function iconForNode(node: TreeNode) {
  if (node.children) {
    return <FolderIconSolid />;
  }
  if (/\.(jpg|jpeg|png|gif|svg)$/i.test(node.label)) {
    return <PhotoIconOutline />;
  }
  return <DocumentIconOutline />;
}

type NodeState = {
  isExpanded: boolean;
  isLoading: boolean;
  hasLoaded: boolean;
};

const INITIAL_NODE_STATE: NodeState = {
  hasLoaded: false,
  isExpanded: false,
  isLoading: false,
};

function useTreeState() {
  const [nodeStates, setNodeStates] = useState<Record<string, NodeState>>({});
  const [selectedId, setSelectedId] = useState<string | null>(null);
  const timers = useRef<Record<string, ReturnType<typeof setTimeout>>>({});

  const getNodeState = useCallback(
    (id: string): NodeState => nodeStates[id] ?? INITIAL_NODE_STATE,
    [nodeStates],
  );

  const handleExpand = useCallback((id: string, expanded: boolean) => {
    setNodeStates((prev) => {
      const current = prev[id] ?? INITIAL_NODE_STATE;
      if (expanded && !current.hasLoaded) {
        if (timers.current[id]) {
          clearTimeout(timers.current[id]);
        }
        timers.current[id] = setTimeout(() => {
          setNodeStates((prev) => ({
            ...prev,
            [id]: { hasLoaded: true, isExpanded: true, isLoading: false },
          }));
          delete timers.current[id];
        }, LOAD_DELAY_MS);
        return {
          ...prev,
          [id]: { hasLoaded: false, isExpanded: true, isLoading: true },
        };
      }
      return { ...prev, [id]: { ...current, isExpanded: expanded } };
    });
  }, []);

  const handleSelect = useCallback(
    (id: string) => setSelectedId((prev) => (prev === id ? null : id)),
    [],
  );

  return { getNodeState, handleExpand, handleSelect, selectedId };
}

type RenderContext = {
  getNodeState: (id: string) => NodeState;
  selectedId: string | null;
  handleExpand: (id: string, expanded: boolean) => void;
  handleSelect: (id: string) => void;
};

function renderNode(node: TreeNode, ctx: RenderContext) {
  const hasChildren =
    node.children !== undefined &&
    node.children !== null &&
    node.children.length > 0;
  const state = ctx.getNodeState(node.id);

  return (
    <TreeView.Item
      key={node.id}
      title={node.label}
      hasChildren={hasChildren}
      isExpanded={state.isExpanded}
      isLoading={state.isLoading}
      isSelected={ctx.selectedId === node.id}
      leadingVisual={iconForNode(node)}
      onExpandedChange={(expanded: boolean) =>
        ctx.handleExpand(node.id, expanded)
      }
      onSelectedChange={() => ctx.handleSelect(node.id)}
    >
      {hasChildren && state.isLoading && !state.hasLoaded && (
        <TreeView.SkeletonItem rows={2} />
      )}
      {/* Rendering children only while expanded keeps collapsed subtrees out
          of the DOM, so large trees only pay for the rows the user can see.
          All item state lives outside the tree (keyed by node id), so nothing
          is lost when a subtree unmounts on collapse. */}
      {hasChildren &&
        state.isExpanded &&
        state.hasLoaded &&
        node.children!.map((child) => renderNode(child, ctx))}
    </TreeView.Item>
  );
}

const Component = () => {
  const { getNodeState, selectedId, handleExpand, handleSelect } =
    useTreeState();

  const ctx = useMemo<RenderContext>(
    () => ({ getNodeState, handleExpand, handleSelect, selectedId }),
    [getNodeState, selectedId, handleExpand, handleSelect],
  );

  return (
    <TreeView ariaLabel="Lazy-loaded file explorer">
      {TREE_DATA.map((node) => renderNode(node, ctx))}
    </TreeView>
  );
};

export default Component;
```

### Multi

```tsx
import '@neo4j-ndl/base/lib/neo4j-ds-styles.css';

import {
  DocumentIconOutline,
  FolderIconSolid,
  PhotoIconOutline,
} from '@neo4j-ndl/react/icons';
import { TreeView } from '@neo4j-ndl/react/next';
import { useCallback, useState } from 'react';

/**
 * Every row owns its own checkbox: `onSelectedChange` reports the state the row
 * should end up in, and the story stores exactly that. Folders are ordinary
 * selectable rows here, unrelated to what sits inside them.
 *
 * Deriving a folder's state from its children instead needs the tree to own the
 * selection, since a Shift range moves several rows at once and a cascade has
 * to be applied to the whole set in one pass. See the root-managed selection
 * story for that.
 */
const Component = () => {
  const [expanded, setExpanded] = useState<Record<string, boolean>>({});
  const [selected, setSelected] = useState<Record<string, boolean>>({});

  const toggleExpanded = (key: string) =>
    setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));

  const setRowSelected = useCallback((key: string, isSelected: boolean) => {
    setSelected((prev) => ({ ...prev, [key]: isSelected }));
  }, []);

  return (
    <TreeView ariaLabel="File explorer" selectionMode="multiple">
      <TreeView.Item
        title="Documents"
        hasChildren
        isExpanded={expanded['documents']}
        onExpandedChange={() => toggleExpanded('documents')}
        isSelected={selected['documents'] ?? false}
        onSelectedChange={(isSelected) =>
          setRowSelected('documents', isSelected)
        }
        leadingVisual={<FolderIconSolid />}
      >
        <TreeView.Item
          title="Work"
          hasChildren
          isExpanded={expanded['work']}
          onExpandedChange={() => toggleExpanded('work')}
          isSelected={selected['work'] ?? false}
          onSelectedChange={(isSelected) => setRowSelected('work', isSelected)}
          leadingVisual={<FolderIconSolid />}
        >
          <TreeView.Item
            title="Report.pdf"
            isSelected={selected['report'] ?? false}
            onSelectedChange={(isSelected) =>
              setRowSelected('report', isSelected)
            }
            leadingVisual={<DocumentIconOutline />}
          />
          <TreeView.Item
            title="Presentation.pptx"
            isDisabled
            isSelected={selected['presentation'] ?? false}
            onSelectedChange={(isSelected) =>
              setRowSelected('presentation', isSelected)
            }
            leadingVisual={<DocumentIconOutline />}
          />
        </TreeView.Item>
        <TreeView.Item
          title="Resume.pdf"
          isSelected={selected['resume'] ?? false}
          onSelectedChange={(isSelected) =>
            setRowSelected('resume', isSelected)
          }
          leadingVisual={<DocumentIconOutline />}
        />
      </TreeView.Item>
      <TreeView.Item
        title="Images"
        hasChildren
        isExpanded={expanded['images']}
        onExpandedChange={() => toggleExpanded('images')}
        isSelected={selected['images'] ?? false}
        onSelectedChange={(isSelected) => setRowSelected('images', isSelected)}
        leadingVisual={<FolderIconSolid />}
      >
        <TreeView.Item
          title="Photo.jpg"
          isSelected={selected['photo'] ?? false}
          onSelectedChange={(isSelected) => setRowSelected('photo', isSelected)}
          leadingVisual={<PhotoIconOutline />}
        />
        <TreeView.Item
          title="Screenshot.png"
          isSelected={selected['screenshot'] ?? false}
          onSelectedChange={(isSelected) =>
            setRowSelected('screenshot', isSelected)
          }
          leadingVisual={<PhotoIconOutline />}
        />
      </TreeView.Item>
    </TreeView>
  );
};

export default Component;
```

### Multiple Replace

```tsx
import '@neo4j-ndl/base/lib/neo4j-ds-styles.css';

import {
  DocumentIconOutline,
  FolderIconSolid,
  PhotoIconOutline,
} from '@neo4j-ndl/react/icons';
import { TreeView } from '@neo4j-ndl/react/next';
import { useState } from 'react';

/**
 * Letting the tree own the selection keeps the items declarative: they carry an
 * `id` and nothing else, and a Shift range that moves several rows at once
 * arrives as a single `onSelectedIdsChange`.
 */
const Component = () => {
  const [selectedIds, setSelectedIds] = useState<string[]>([]);

  return (
    <TreeView
      ariaLabel="File explorer"
      selectionMode="multiple-replace"
      selectedIds={selectedIds}
      onSelectedIdsChange={setSelectedIds}
    >
      <TreeView.Item
        id="documents"
        title="Documents"
        hasChildren
        defaultExpanded
        leadingVisual={<FolderIconSolid />}
      >
        <TreeView.Item
          id="work"
          title="Work"
          hasChildren
          defaultExpanded
          leadingVisual={<FolderIconSolid />}
        >
          <TreeView.Item
            id="report"
            title="Report.pdf"
            isDisabled
            leadingVisual={<DocumentIconOutline />}
          />
          <TreeView.Item
            id="presentation"
            title="Presentation.pptx"
            leadingVisual={<DocumentIconOutline />}
          />
        </TreeView.Item>
        <TreeView.Item
          id="resume"
          title="Resume.pdf"
          leadingVisual={<DocumentIconOutline />}
        />
      </TreeView.Item>
      <TreeView.Item
        id="images"
        title="Images"
        hasChildren
        leadingVisual={<FolderIconSolid />}
      >
        <TreeView.Item
          id="photo"
          title="Photo.jpg"
          leadingVisual={<PhotoIconOutline />}
        />
        <TreeView.Item
          id="screenshot"
          title="Screenshot.png"
          leadingVisual={<PhotoIconOutline />}
        />
      </TreeView.Item>
    </TreeView>
  );
};

export default Component;
```

### Non Selectable Folders

```tsx
import '@neo4j-ndl/base/lib/neo4j-ds-styles.css';

import { DocumentIconOutline, FolderIconSolid } from '@neo4j-ndl/react/icons';
import { TreeView } from '@neo4j-ndl/react/next';
import { useState } from 'react';

/**
 * Folders here are pure containers: `isSelectable={false}` makes them expand on
 * click without ever becoming part of the selection, and screen readers
 * announce them as non-selectable rather than as unselected rows.
 */
const Component = () => {
  const [selectedIds, setSelectedIds] = useState<string[]>([]);

  return (
    <TreeView
      ariaLabel="File explorer"
      selectedIds={selectedIds}
      onSelectedIdsChange={setSelectedIds}
    >
      <TreeView.Item
        id="documents"
        title="Documents"
        hasChildren
        isSelectable={false}
        leadingVisual={<FolderIconSolid />}
      >
        <TreeView.Item
          id="work"
          title="Work"
          hasChildren
          isSelectable={false}
          leadingVisual={<FolderIconSolid />}
        >
          <TreeView.Item
            id="report"
            title="Report.pdf"
            leadingVisual={<DocumentIconOutline />}
          />
          <TreeView.Item
            id="presentation"
            title="Presentation.pptx"
            leadingVisual={<DocumentIconOutline />}
          />
        </TreeView.Item>
        <TreeView.Item
          id="resume"
          title="Resume.pdf"
          leadingVisual={<DocumentIconOutline />}
        />
      </TreeView.Item>
      <TreeView.Item
        id="images"
        title="Images"
        hasChildren
        isSelectable={false}
        leadingVisual={<FolderIconSolid />}
      >
        <TreeView.Item
          id="photo"
          title="Photo.jpg"
          leadingVisual={<DocumentIconOutline />}
        />
        <TreeView.Item
          id="screenshot"
          title="Screenshot.png"
          leadingVisual={<DocumentIconOutline />}
        />
      </TreeView.Item>
    </TreeView>
  );
};

export default Component;
```

### Root Selection

```tsx
import '@neo4j-ndl/base/lib/neo4j-ds-styles.css';

import { DocumentIconOutline, FolderIconSolid } from '@neo4j-ndl/react/icons';
import { TreeView } from '@neo4j-ndl/react/next';
import { useCallback, useState } from 'react';

type FileNode = {
  children?: FileNode[];
  id: string;
  title: string;
};

const files: FileNode[] = [
  {
    children: [
      {
        children: [
          { id: 'report', title: 'Report.pdf' },
          { id: 'presentation', title: 'Presentation.pptx' },
        ],
        id: 'work',
        title: 'Work',
      },
      { id: 'resume', title: 'Resume.pdf' },
    ],
    id: 'documents',
    title: 'Documents',
  },
  {
    children: [
      { id: 'photo', title: 'Photo.jpg' },
      { id: 'screenshot', title: 'Screenshot.png' },
    ],
    id: 'images',
    title: 'Images',
  },
];

const walk = (nodes: FileNode[], visit: (node: FileNode) => void) => {
  nodes.forEach((node) => {
    visit(node);
    walk(node.children ?? [], visit);
  });
};

const getDescendantIds = (node: FileNode): string[] =>
  (node.children ?? []).flatMap((child) => [
    child.id,
    ...getDescendantIds(child),
  ]);

const getLeafIds = (node: FileNode): string[] =>
  node.children === undefined ? [node.id] : node.children.flatMap(getLeafIds);

/** Folders follow their contents: selected only when every leaf below them is. */
const syncFolders = (nodes: FileNode[], selected: Set<string>) => {
  nodes.forEach((node) => {
    if (node.children === undefined) {
      return;
    }
    syncFolders(node.children, selected);
    if (getLeafIds(node).every((id) => selected.has(id))) {
      selected.add(node.id);
    } else {
      selected.delete(node.id);
    }
  });
};

const Component = () => {
  const [selectedIds, setSelectedIds] = useState<string[]>(['resume']);
  const selected = new Set(selectedIds);

  /**
   * The tree reports the whole selection at once, so cascading folders onto
   * their contents takes a single pass even when a Shift range flipped many
   * rows.
   */
  const handleSelectedIdsChange = useCallback((nextIds: string[]) => {
    setSelectedIds((previousIds) => {
      const next = new Set(nextIds);
      const previous = new Set(previousIds);
      walk(files, (node) => {
        const isSelected = next.has(node.id);
        if (
          node.children === undefined ||
          isSelected === previous.has(node.id)
        ) {
          return;
        }
        getDescendantIds(node).forEach((id) => {
          if (isSelected) {
            next.add(id);
          } else {
            next.delete(id);
          }
        });
      });
      syncFolders(files, next);
      return [...next];
    });
  }, []);

  const renderNode = (node: FileNode) => {
    const leafIds = getLeafIds(node);
    const selectedLeafCount = leafIds.filter((id) => selected.has(id)).length;
    return (
      <TreeView.Item
        key={node.id}
        id={node.id}
        title={node.title}
        defaultExpanded
        isIndeterminate={
          node.children !== undefined &&
          selectedLeafCount > 0 &&
          selectedLeafCount < leafIds.length
        }
        leadingVisual={
          node.children === undefined ? (
            <DocumentIconOutline />
          ) : (
            <FolderIconSolid />
          )
        }
      >
        {node.children?.map(renderNode)}
      </TreeView.Item>
    );
  };

  return (
    <TreeView
      ariaLabel="File explorer"
      selectionMode="multiple"
      selectedIds={selectedIds}
      onSelectedIdsChange={handleSelectedIdsChange}
    >
      {files.map(renderNode)}
    </TreeView>
  );
};

export default Component;
```

### Single

```tsx
import '@neo4j-ndl/base/lib/neo4j-ds-styles.css';

import { DocumentIconOutline, FolderIconSolid } from '@neo4j-ndl/react/icons';
import { TreeView } from '@neo4j-ndl/react/next';
import { useState } from 'react';

const Component = () => {
  const [expanded, setExpanded] = useState<Record<string, boolean>>({});
  const [selected, setSelected] = useState<string | null>(null);

  const toggle = (key: string) =>
    setExpanded((prev) => ({ ...prev, [key]: !prev[key] }));

  return (
    <TreeView ariaLabel="File explorer">
      <TreeView.Item
        title="Documents"
        hasChildren
        isExpanded={expanded['documents']}
        onExpandedChange={() => toggle('documents')}
        isSelected={selected === 'documents'}
        onSelectedChange={() => setSelected('documents')}
        leadingVisual={<FolderIconSolid />}
      >
        <TreeView.Item
          title="Work"
          hasChildren
          isExpanded={expanded['work']}
          onExpandedChange={() => toggle('work')}
          isSelected={selected === 'work'}
          onSelectedChange={() => setSelected('work')}
          leadingVisual={<FolderIconSolid />}
        >
          <TreeView.Item
            title="Report.pdf"
            isSelected={selected === 'report'}
            onSelectedChange={() => setSelected('report')}
            leadingVisual={<DocumentIconOutline />}
          />
          <TreeView.Item
            title="Presentation.pptx"
            isDisabled
            isSelected={selected === 'presentation'}
            onSelectedChange={() => setSelected('presentation')}
            leadingVisual={<DocumentIconOutline />}
          />
        </TreeView.Item>
        <TreeView.Item
          title="Resume.pdf"
          isSelected={selected === 'resume'}
          onSelectedChange={() => setSelected('resume')}
          leadingVisual={<DocumentIconOutline />}
        />
      </TreeView.Item>
      <TreeView.Item
        title="Images (disabled)"
        hasChildren
        isExpanded={expanded['images']}
        onExpandedChange={() => toggle('images')}
        isSelected={selected === 'images'}
        onSelectedChange={() => setSelected('images')}
        leadingVisual={<FolderIconSolid />}
        isDisabled
      />
      <TreeView.Item
        title="README.md"
        isSelected={selected === 'readme'}
        onSelectedChange={() => setSelected('readme')}
        leadingVisual={<DocumentIconOutline />}
      />
    </TreeView>
  );
};

export default Component;
```

### Tooltip

```tsx
import '@neo4j-ndl/base/lib/neo4j-ds-styles.css';

import { Menu } from '@neo4j-ndl/react';
import {
  AppearanceIcon,
  CheckCircleIconSolid,
  DatabaseSignalIcon,
} from '@neo4j-ndl/react/icons';
import { TreeView } from '@neo4j-ndl/react/next';
import { useState } from 'react';

const Component = () => {
  const [selected, setSelected] = useState<string | null>(null);

  return (
    <TreeView ariaLabel="Databases" style={{ marginTop: '100px' }}>
      <TreeView.Item
        title="Production"
        isSelected={selected === 'production'}
        onSelectedChange={() => setSelected('production')}
        leadingVisual={<DatabaseSignalIcon />}
        trailingContent={
          <CheckCircleIconSolid className="n-size-5 n-text-success-bg-status" />
        }
        actionMenuItems={
          <>
            <Menu.Item title="Edit" />
            <Menu.Item title="Delete" />
            <Menu.Item title="Rename" />
          </>
        }
        tooltipContent={
          <>
            <span className="n-font-semibold">Production</span>
            <span className="n-flex n-items-center n-gap-token-4 n-mt-token-8">
              <CheckCircleIconSolid className="n-size-5 n-text-success-bg-status" />
              : Endorsed
            </span>
          </>
        }
      />
      <TreeView.Item
        title="Development"
        isSelected={selected === 'development'}
        onSelectedChange={() => setSelected('development')}
        leadingVisual={<DatabaseSignalIcon />}
        trailingContent={
          <AppearanceIcon className="n-size-5 n-text-warning-bg-status" />
        }
        tooltipContent={
          <>
            <span className="n-font-semibold">Development</span>
            <span className="n-flex n-items-center n-gap-token-4 n-mt-token-8">
              <AppearanceIcon className="n-size-5 n-text-warning-bg-status" />:
              In development
            </span>
          </>
        }
      />
    </TreeView>
  );
};

export default Component;
```

### Trailing

```tsx
import '@neo4j-ndl/base/lib/neo4j-ds-styles.css';

import { Menu } from '@neo4j-ndl/react';
import {
  AppearanceIcon,
  CheckCircleIconSolid,
  DatabaseSignalIcon,
} from '@neo4j-ndl/react/icons';
import { TreeView } from '@neo4j-ndl/react/next';
import { useState } from 'react';

const Component = () => {
  const [selected, setSelected] = useState<string | null>(null);

  return (
    <TreeView ariaLabel="Databases">
      <TreeView.Item
        title="Production"
        isSelected={selected === 'production'}
        onSelectedChange={() => setSelected('production')}
        leadingVisual={<DatabaseSignalIcon />}
        trailingContent={
          <CheckCircleIconSolid className="n-size-5 n-text-success-bg-status" />
        }
        actionMenuItems={
          <>
            <Menu.Item title="Edit" />
            <Menu.Item title="Delete" />
            <Menu.Item title="Rename" />
          </>
        }
      />
      <TreeView.Item
        title="Development"
        isSelected={selected === 'development'}
        onSelectedChange={() => setSelected('development')}
        leadingVisual={<DatabaseSignalIcon />}
        trailingContent={
          <AppearanceIcon className="n-size-5 n-text-warning-bg-status" />
        }
      />
    </TreeView>
  );
};

export default Component;
```

### Uncontrolled

```tsx
import '@neo4j-ndl/base/lib/neo4j-ds-styles.css';

import { DocumentIconOutline, FolderIconSolid } from '@neo4j-ndl/react/icons';
import { TreeView } from '@neo4j-ndl/react/next';
import { useState } from 'react';

const Component = () => {
  const [selected, setSelected] = useState<string | null>(null);

  return (
    <TreeView ariaLabel="File explorer">
      <TreeView.Item
        title="Documents (expanded by default)"
        defaultExpanded={true}
        leadingVisual={<FolderIconSolid />}
      >
        <TreeView.Item title="Work" leadingVisual={<FolderIconSolid />}>
          <TreeView.Item
            title="Report.pdf"
            isSelected={selected === 'report'}
            onSelectedChange={() => setSelected('report')}
            leadingVisual={<DocumentIconOutline />}
          />
          <TreeView.Item
            title="Presentation.pptx"
            isSelected={selected === 'presentation'}
            onSelectedChange={() => setSelected('presentation')}
            leadingVisual={<DocumentIconOutline />}
          />
        </TreeView.Item>
        <TreeView.Item
          title="Resume.pdf"
          isSelected={selected === 'resume'}
          onSelectedChange={() => setSelected('resume')}
          leadingVisual={<DocumentIconOutline />}
        />
      </TreeView.Item>
      <TreeView.Item
        title="Images (collapsed by default)"
        leadingVisual={<FolderIconSolid />}
        defaultExpanded={false}
      >
        <TreeView.Item
          title="Photo.jpg"
          isSelected={selected === 'photo'}
          onSelectedChange={() => setSelected('photo')}
          leadingVisual={<DocumentIconOutline />}
        />
        <TreeView.Item
          title="Screenshot.png"
          isSelected={selected === 'screenshot'}
          onSelectedChange={() => setSelected('screenshot')}
          leadingVisual={<DocumentIconOutline />}
        />
      </TreeView.Item>
    </TreeView>
  );
};

export default Component;
```
