> ## Documentation Index
> Fetch the complete documentation index at: https://anvil.servicetitan.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Tree Select Menu - Web

> TreeSelectMenu components attach a hierarchical tree menu to any trigger element.

> [!NOTE]
>
> For development, import `TreeSelectMenu` from
> `@servicetitan/anvil2/beta`. For design, find it in the
> [Anvil2 file](https://www.figma.com/design/xaNQsvRKczoxu7pj0M6QT8/Anvil2-v3).

> [!NOTE]
>
> **Beta Feature**
>
> This feature is currently in beta, and needs to be imported from `@servicetitan/anvil2/beta`.
>
> While we hope to minimize breaking changes, they may occur due to feedback we receive or other improvements. These will always be documented in the changelog and communicated in Slack.
>
> Please reach out in the [#ask-designsystem](https://servicetitan.enterprise.slack.com/archives/CBSRGHTRS) channel with any questions or feedback!

**Implementation**

## Overview

The tree select menu family includes two components for different use cases:

* **`TreeSelectMenu`** — For async data loading with support for lazy branch expansion
  * Includes automatic debouncing of the search input (configurable via the `debounceMs` prop)
  * Includes automatic LRU caching of search results and loaded children (configurable via the `cache` prop)
* **`TreeSelectMenuSync`** — For client-side filtering of static tree option arrays

Both components attach a hierarchical tree dropdown to any trigger element via a `trigger` render prop. They support cascading parent/child selection, keyboard navigation, and adaptive display modes (popover or dialog). Use a tree select menu when you need tree selection behavior outside of a form field context — for example, attaching a dropdown to a button, icon, or custom element.

> [!NOTE]
>
> Looking for a form field with a built-in label, error state, and selected chips? Use [`TreeSelectField`](https://anvil.servicetitan.com/docs/components/tree-select-field/code) instead.

## TreeSelectMenuSync (Static Options)

Use `TreeSelectMenuSync` when you have a static tree of options that can be filtered client-side.

```tsx theme={null}
import { useState } from "react";
import { TreeSelectMenuSync, TreeSelectMenuValue } from "@servicetitan/anvil2/beta";
import { Button } from "@servicetitan/anvil2";

const options = [
  {
    id: "eng",
    label: "Engineering",
    children: [
      { id: "fe", label: "Frontend" },
      { id: "be", label: "Backend" },
      { id: "infra", label: "Infrastructure" },
    ],
  },
  {
    id: "design",
    label: "Design",
    children: [
      { id: "ux", label: "UX Design" },
      { id: "visual", label: "Visual Design" },
    ],
  },
  { id: "hr", label: "Human Resources" },
];

const ExampleComponent = () => {
  const [selected, setSelected] = useState<TreeSelectMenuValue[]>([]);

  return (
    <TreeSelectMenuSync
      label="Select departments"
      options={options}
      value={selected}
      onSelectedOptionsChange={setSelected}
      defaultExpandLevel={1}
      trigger={(props) => (
        <Button {...props}>
          {selected.length > 0
            ? `${selected.length} selected`
            : "Select departments"}
        </Button>
      )}
    />
  );
};
```

### Filtering and Sorting

By default, `TreeSelectMenuSync` uses [match-sorter](https://github.com/kentcdodds/match-sorter) to filter options by their `label` and `searchText` fields. The tree structure is preserved: parent nodes of any matching node remain visible so the hierarchy is clear.

You can customize this behavior in two ways:

#### Using match-sorter options

Pass a [match-sorter options object](https://github.com/kentcdodds/match-sorter#options) to customize the default filtering and sorting behavior:

```tsx theme={null}
<TreeSelectMenuSync
  options={options}
  filter={{
    keys: ["label", "searchText"],
    // ...other match-sorter options
  }}
  // ...other props
/>
```

#### Using a custom filter function

Pass a function for full control over both filtering and tree structure. The returned array determines the exact tree shown in the dropdown:

```tsx theme={null}
<TreeSelectMenuSync
  options={options}
  filter={(nodes, searchValue) => {
    // Return a filtered tree — parent preservation is your responsibility
    return customTreeFilter(nodes, searchValue);
  }}
  // ...other props
/>
```

## TreeSelectMenu (Async Loading)

Use `TreeSelectMenu` when tree data needs to be fetched from an API or when branches load their children on demand.

### Basic Async Loading

```tsx theme={null}
import { useState } from "react";
import { TreeSelectMenu, TreeSelectMenuValue } from "@servicetitan/anvil2/beta";
import { Button } from "@servicetitan/anvil2";

const ExampleComponent = () => {
  const [selected, setSelected] = useState<TreeSelectMenuValue[]>([]);

  return (
    <TreeSelectMenu
      label="Select categories"
      loadOptions={async (searchValue) => {
        const response = await fetch(`/api/categories?q=${searchValue}`);
        return response.json();
      }}
      value={selected}
      onSelectedOptionsChange={setSelected}
      trigger={(props) => (
        <Button {...props}>
          {selected.length > 0
            ? `${selected.length} selected`
            : "Select categories"}
        </Button>
      )}
    />
  );
};
```

### Lazy Branch Loading

Branches with `children: null` are treated as unloaded. When expanded, `loadOptions` is called with the parent node to fetch its children:

```tsx theme={null}
<TreeSelectMenu
  label="Select location"
  loadOptions={async (searchValue, parentNode) => {
    if (parentNode) {
      // Load children for the expanded branch
      const response = await fetch(`/api/locations/${parentNode.id}/children`);
      return response.json();
    }
    // Load root nodes
    const response = await fetch(`/api/locations?q=${searchValue}`);
    return response.json();
  }}
  value={selected}
  onSelectedOptionsChange={setSelected}
  trigger={(props) => <Button {...props}>Select location</Button>}
/>
```

Root nodes returned by `loadOptions` should use `children: null` for branches whose children haven't been loaded yet:

```tsx theme={null}
// Example response from /api/locations
[
  {
    id: "us",
    label: "United States",
    children: null, // Children will be loaded when this branch is expanded
  },
  {
    id: "ca",
    label: "Canada",
    children: null,
  },
]
```

When a user selects a branch with unloaded descendants in linked selection mode, all descendant branches are recursively loaded before the cascade is applied. A loading spinner appears on the checkbox during this process.

## The `trigger` Render Prop

The `trigger` render prop receives a set of props that must be spread onto the element that opens the menu. The trigger is responsible for rendering its own visible label.

```tsx theme={null}
<TreeSelectMenuSync
  label="Departments"
  options={options}
  value={selected}
  onSelectedOptionsChange={setSelected}
  trigger={({ ref, onClick, onKeyDown, "aria-expanded": ariaExpanded, ...rest }) => (
    <Button ref={ref} onClick={onClick} onKeyDown={onKeyDown} aria-expanded={ariaExpanded} {...rest}>
      Departments
    </Button>
  )}
/>
```

The trigger props provide:

* `ref` — Ref to the trigger element, used for popover positioning and focus restoration when the menu closes.
* `onClick` — Toggles the menu open and closed.
* `onKeyDown` — Handles keyboard interactions (e.g., opening the menu with Enter or arrow keys).
* `aria-haspopup`, `aria-controls`, `aria-expanded` — ARIA attributes describing the relationship between the trigger and the tree dropdown.
* `data-state` — Either `"open"` or `"close"`, useful for styling the trigger based on menu state.

## Selection Modes

The `selectionMode` prop controls how nodes relate to each other during selection:

* **`"linked"`** (default) — Parent-child cascading. Selecting a parent checks all children; selecting all children checks the parent. The `valueConsistsOf` prop controls which nodes appear in the value array.
* **`"independent"`** — Each node is selected independently with no cascading.
* **`"single"`** — Only one node can be selected at a time. The menu closes after selection.

```tsx theme={null}
// Linked mode (default) — cascading selection
<TreeSelectMenuSync
  selectionMode="linked"
  options={options}
  // ...other props
/>

// Independent mode — no cascading
<TreeSelectMenuSync
  selectionMode="independent"
  options={options}
  // ...other props
/>

// Single-select mode — one node at a time
<TreeSelectMenuSync
  selectionMode="single"
  options={options}
  // ...other props
/>
```

## Value Strategies

In linked selection mode, the `valueConsistsOf` prop controls which checked nodes appear in the `value` array:

| Strategy | Selectable Nodes | Value Contains |
| - | - | - |
| `"ALL"` | Any node | All checked nodes |
| `"BRANCH_PRIORITY"` | Any node | Branches replace their leaves when fully checked |
| `"BRANCH_ONLY"` | Branches only | Only branches (leaves are visible but inert) |
| `"LEAF_PRIORITY"` (default) | Leaves + expand branches | Leaves and childless branches |
| `"LEAF_ONLY"` | Leaves only | Strictly leaf nodes |

```tsx theme={null}
<TreeSelectMenuSync
  selectionMode="linked"
  valueConsistsOf="BRANCH_PRIORITY"
  options={options}
  // ...other props
/>
```

In single-select mode, `valueConsistsOf` restricts which node types can be selected: `"LEAF_ONLY"` allows only leaves, `"BRANCH_ONLY"` allows only branches, and the other strategies allow any node.

## Display Modes

Control how the options menu is displayed using the `displayMenuAs` prop:

```tsx theme={null}
// Automatically choose based on device (default)
<TreeSelectMenuSync displayMenuAs="auto" {...props} />

// Always show as popover (not recommended for mobile)
<TreeSelectMenuSync displayMenuAs="popover" {...props} />

// Always show as dialog
<TreeSelectMenuSync displayMenuAs="dialog" {...props} />
```

## Popover Width

Use `popoverWidth` to control the popover's width:

```tsx theme={null}
// Match the trigger's width (useful when the trigger is a wide button or input)
<TreeSelectMenuSync popoverWidth="reference" {...props} />

// Fixed pixel width
<TreeSelectMenuSync popoverWidth={400} {...props} />

// Any CSS width value
<TreeSelectMenuSync popoverWidth="20rem" {...props} />
```

## Caching

`TreeSelectMenu` caches `loadOptions` results by default using two separate LRU caches: one for search results and one for lazy-loaded children. Configure caching behavior:

```tsx theme={null}
// Disable caching
<TreeSelectMenu cache={{ enabled: false }} {...props} />

// Configure max cache size (default: 15)
<TreeSelectMenu cache={{ maxSize: 100 }} {...props} />
```

### Clearing the Cache

Use a ref to imperatively clear the cache:

```tsx theme={null}
import { useRef } from "react";
import { TreeSelectMenu, TreeSelectMenuHandle } from "@servicetitan/anvil2/beta";

const ExampleComponent = () => {
  const menuRef = useRef<TreeSelectMenuHandle>(null);

  const handleRefresh = () => {
    menuRef.current?.clearCache();
  };

  return (
    <>
      <TreeSelectMenu ref={menuRef} {...props} />
      <button onClick={handleRefresh}>Refresh Options</button>
    </>
  );
};
```

### Invalidating Options

Call `invalidate()` to clear the cache and reload options from the data source:

```tsx theme={null}
const handleDataSourceChange = () => {
  menuRef.current?.invalidate();
};
```

> [!NOTE]
>
> `TreeSelectMenuSync` handles this automatically when its `options` prop reference changes.

## Initial Load Behavior

Control when options are first loaded with the `initialLoad` prop:

```tsx theme={null}
// Load immediately on mount (default)
<TreeSelectMenu initialLoad="immediate" {...props} />

// Load when user opens the dropdown
<TreeSelectMenu initialLoad="open" {...props} />

// Auto (currently equivalent to "immediate")
<TreeSelectMenu initialLoad="auto" {...props} />
```

## Controlled Expansion

By default, expansion state is managed internally. Use `defaultExpandLevel` to set the initial depth:

```tsx theme={null}
// Start with all branches collapsed (default)
<TreeSelectMenuSync defaultExpandLevel={0} options={options} {...props} />

// Start with first level expanded
<TreeSelectMenuSync defaultExpandLevel={1} options={options} {...props} />

// Start fully expanded
<TreeSelectMenuSync defaultExpandLevel={Infinity} options={options} {...props} />
```

For full control, use `expandedIds` and `onExpandedIdsChange`:

```tsx theme={null}
const [expandedIds, setExpandedIds] = useState(new Set(["eng", "design"]));

<TreeSelectMenuSync
  expandedIds={expandedIds}
  onExpandedIdsChange={setExpandedIds}
  options={options}
  // ...other props
/>
```

The imperative handle also provides `expandAll()` and `collapseAll()`:

```tsx theme={null}
menuRef.current?.expandAll();
menuRef.current?.collapseAll();
```

## Disabled Nodes

Individual nodes can be disabled by setting `disabled: true`:

```tsx theme={null}
const options = [
  {
    id: "eng",
    label: "Engineering",
    children: [
      { id: "fe", label: "Frontend" },
      { id: "be", label: "Backend", disabled: true },
    ],
  },
];
```

Disabled nodes appear with reduced opacity and cannot be selected. In linked mode, disabled nodes are excluded from cascade selection.

## Virtualization

Pass `virtualize` to enable windowed rendering for large trees. Only visible rows are mounted to the DOM.

```tsx theme={null}
<TreeSelectMenu
  virtualize
  label="Large tree"
  loadOptions={loadLargeTree}
  value={selected}
  onSelectedOptionsChange={setSelected}
  trigger={(props) => <Button {...props}>Browse</Button>}
/>
```

## Disabling Search

Pass `disableSearch` to hide the search input:

```tsx theme={null}
<TreeSelectMenuSync
  disableSearch
  label="Category"
  options={options}
  value={selected}
  onSelectedOptionsChange={setSelected}
  trigger={(props) => <Button {...props}>Category</Button>}
/>
```

## Close Callbacks

Distinguish between an explicit close (Escape, clicking the trigger again) and an implicit close (clicking outside the menu) with `onExplicitClose` and `onImplicitClose`:

```tsx theme={null}
<TreeSelectMenuSync
  onExplicitClose={() => analytics.track("tree_menu_dismissed")}
  onImplicitClose={() => analytics.track("tree_menu_blurred")}
  options={options}
  value={selected}
  onSelectedOptionsChange={setSelected}
  // ...other props
/>
```

Use `onMenuKeyDown` to observe keyboard events that occur while the menu is open.

## React Accessibility

* The trigger element exposes `aria-haspopup="tree"`, `aria-controls`, and `aria-expanded` to describe its relationship to the dropdown.
* The dropdown uses `role="tree"` with the appropriate `role="treeitem"` rows and `aria-expanded` / `aria-selected` attributes.
* The required `label` prop is used as the accessible name for the menu and as the dialog title in dialog mode.
* Focus is restored to the trigger element when the menu closes via Escape or by selecting an option in single-select mode.

## Testing

Interactive sub-elements carry a stable `data-anv-part` attribute so end-to-end tests can target them. The library owns the part names; you supply the instance identifier.

There is no wrapper root — attach your own `data-*` (such as `data-cy`) to `TreeSelectMenu`. It is merged into the trigger render-prop bag, so spread `{...props}` onto your trigger element:

```tsx theme={null}
<TreeSelectMenu
  data-cy="department-menu"
  trigger={(props) => <Button {...props}>Department</Button>}
  {...rest}
/>
```

```ts theme={null}
cy.get('[data-cy="department-menu"][data-anv-part="trigger"]').click();
cy.get('[data-anv-part="option"][data-id="eng"]').click();
```

| `data-anv-part` | Element |
| - | - |
| `trigger` | Your trigger element (via the `trigger` render prop) |
| `input` | Search field inside the open menu |
| `menu` | Tree list container |
| `option` | An individual tree row (`data-id` = node id) |
| `expand-toggle` | Branch expand/collapse control |
| `dialog` | Menu container in dialog display mode |
| `popover` | Menu container in popover display mode |

> [!NOTE]
>
> The menu renders in the browser's top layer (`menu`, `option`, `expand-toggle`, `dialog`, and `popover` parts), outside the trigger. Descendant queries scoped under the trigger will not match these parts. Target them by their part attribute directly. Only one menu is open at a time.

**TreeSelectMenu Props**

## `TreeSelectMenu` Props

```tsx theme={null}
import { useState } from "react";
import { TreeSelectMenu, TreeSelectMenuValue } from "@servicetitan/anvil2/beta";
import { Button } from "@servicetitan/anvil2";

const ExampleComponent = () => {
  const [selected, setSelected] = useState<TreeSelectMenuValue[]>([]);

  return (
    <TreeSelectMenu
      label="Select categories"
      loadOptions={async (searchValue, parentNode) => {
        if (parentNode) {
          return fetchChildren(parentNode.id);
        }
        return fetchRoots(searchValue);
      }}
      onSelectedOptionsChange={setSelected}
      value={selected}
      trigger={(props) => <Button {...props}>Select categories</Button>}
    />
  );
};
```

**label** `string` *required*

Accessible label for the menu. Used by screen readers and as the dialog title in dialog mode.

**loadOptions** `(searchValue: string, parentNode?: TreeSelectMenuNode) =\> TreeSelectMenuNode[] | Promise\<TreeSelectMenuNode[]\>` *required*

Function to load tree nodes. Called with an empty string on initial load and with the search value when the user types. When a branch with `children: null` is expanded, called again with the parent node as the second argument to load its children.

**onSelectedOptionsChange** `(nodes: TreeSelectMenuValue[]) =\> void` *required*

Callback fired when the selection changes. Receives the new array of selected values.

**trigger** `(props: TreeSelectMenuTriggerProps) =\> ReactElement` *required*

Render function that receives trigger props (`ref`, `onClick`, `onKeyDown`, ARIA attributes, `data-state`) and returns the element that opens the menu. Spread the props onto a button or interactive element.

**value** `TreeSelectMenuValue[]` *required*

The currently selected values. Always an array, even in single-select mode.

**cache** `\{ enabled?: boolean; maxSize?: number \}`

Configuration for caching `loadOptions` results. Two separate LRU caches are maintained: one for search results and one for lazy-loaded children.

* `enabled` — Whether caching is enabled (default: `true`)
* `maxSize` — Maximum entries per cache before eviction (default: `15`)

**debounceMs** `number` (default: 200)

Milliseconds to debounce search input before calling `loadOptions`.

**defaultExpandLevel** `number`

Initial expansion depth. `0` keeps all branches collapsed, `1` expands the first level, `Infinity` expands everything. Applied when tree data first loads.

**disableSearch** `boolean`

Hides the search input.

**displayMenuAs** `"auto" | "popover" | "dialog"` (default: auto)

How to display the options menu:

* `auto` — Popover on desktop, dialog on mobile
* `popover` — Always display as popover
* `dialog` — Always display as dialog

**expandedIds** `Set\<string | number\>`

Controlled set of expanded branch node IDs. Use with `onExpandedIdsChange` for full control over expansion state.

**id** `string`

The id of the menu. Used to link the trigger and the tree element via ARIA.

**initialLoad** `"auto" | "immediate" | "open"`

Controls when `loadOptions` is first called:

* `auto` — Currently equivalent to `immediate`
* `immediate` — Load on component mount
* `open` — Load when dropdown is opened

**onExpandedIdsChange** `(ids: Set\<string | number\>) =\> void`

Callback fired when expansion state changes. Use with `expandedIds` for controlled expansion.

**onExplicitClose** `() =\> void`

Callback fired when the menu closes due to an explicit user action (Escape, clicking the trigger to dismiss, or selecting an option in single-select mode). Focus is restored to the trigger.

**onImplicitClose** `() =\> void`

Callback fired when the menu closes implicitly (e.g., clicking outside the menu). Focus is not restored to the trigger.

**onMenuKeyDown** `(event: KeyboardEvent) =\> void`

Callback fired for keyboard events that occur while the menu is open.

**onSearchChange** `(value: string) =\> void`

Callback fired when the search input value changes.

**popoverWidth** `"reference" | number | string`

Width of the popover. Pass `"reference"` to match the trigger element's width, a number for pixels, or any CSS width value.

**searchPlaceholder** `string`

Placeholder text for the search input.

**selectionMode** `"single" | "independent" | "linked"` (default: linked)

Controls how nodes relate to each other during selection:

* `single` — One node at a time, menu closes after selection
* `independent` — No parent-child cascading
* `linked` — Parent-child cascading with configurable value shape via `valueConsistsOf`

**valueConsistsOf** `"ALL" | "BRANCH_PRIORITY" | "BRANCH_ONLY" | "LEAF_PRIORITY" | "LEAF_ONLY"` (default: LEAF\_PRIORITY)

Controls which nodes are selectable and how the `value` array is shaped in linked selection mode:

* `ALL` — Any node selectable, all checked nodes in value
* `BRANCH_PRIORITY` — Any node selectable, branches replace leaves when fully checked
* `BRANCH_ONLY` — Only branches selectable, leaves visible but inert
* `LEAF_PRIORITY` — Leaves and childless branches in value
* `LEAF_ONLY` — Strictly leaf nodes in value

**virtualize** `boolean`

Enables windowed rendering for the tree dropdown. Only visible rows are mounted to the DOM.

**TreeSelectMenuSync Props**

## `TreeSelectMenuSync` Props

`TreeSelectMenuSync` accepts all props from `TreeSelectMenu` except `loadOptions`, `debounceMs`, `cache`, and `initialLoad`, plus the following:

```tsx theme={null}
import { useState } from "react";
import { TreeSelectMenuSync, TreeSelectMenuValue } from "@servicetitan/anvil2/beta";
import { Button } from "@servicetitan/anvil2";

const options = [
  {
    id: "eng",
    label: "Engineering",
    children: [
      { id: "fe", label: "Frontend" },
      { id: "be", label: "Backend" },
    ],
  },
];

const ExampleComponent = () => {
  const [selected, setSelected] = useState<TreeSelectMenuValue[]>([]);

  return (
    <TreeSelectMenuSync
      label="Select departments"
      onSelectedOptionsChange={setSelected}
      options={options}
      value={selected}
      trigger={(props) => <Button {...props}>Select departments</Button>}
    />
  );
};
```

**label** `string` *required*

Accessible label for the menu. Used by screen readers and as the dialog title in dialog mode.

**onSelectedOptionsChange** `(nodes: TreeSelectMenuValue[]) =\> void` *required*

Callback fired when the selection changes.

**options** `TreeSelectMenuNode[]` *required*

The static tree of options to display.

**trigger** `(props: TreeSelectMenuTriggerProps) =\> ReactElement` *required*

Render function that receives trigger props and returns the element that opens the menu.

**value** `TreeSelectMenuValue[]` *required*

The currently selected values.

**defaultExpandLevel** `number`

Initial expansion depth. `0` keeps all branches collapsed, `1` expands the first level, `Infinity` expands everything.

**disableSearch** `boolean`

Hides the search input.

**displayMenuAs** `"auto" | "popover" | "dialog"` (default: auto)

How to display the options menu.

**expandedIds** `Set\<string | number\>`

Controlled set of expanded branch node IDs.

**filter** `function | MatchSorterOptions`

Controls how options are filtered when the user types a search value. Can be:

* A function: `(nodes: TreeSelectMenuNode[], searchValue: string) => TreeSelectMenuNode[]` — receives all options and returns the filtered tree
* A [match-sorter options object](https://github.com/kentcdodds/match-sorter#options) to customize the default match-sorter behavior

Default: Filters by `label` and `searchText` using match-sorter, preserving parent nodes of any match.

**id** `string`

The id of the menu.

**onExpandedIdsChange** `(ids: Set\<string | number\>) =\> void`

Callback fired when expansion state changes.

**onExplicitClose** `() =\> void`

Callback fired when the menu closes due to an explicit user action.

**onImplicitClose** `() =\> void`

Callback fired when the menu closes implicitly.

**onMenuKeyDown** `(event: KeyboardEvent) =\> void`

Callback fired for keyboard events that occur while the menu is open.

**onSearchChange** `(value: string) =\> void`

Callback fired when the search input value changes.

**popoverWidth** `"reference" | number | string`

Width of the popover.

**searchPlaceholder** `string`

Placeholder text for the search input.

**selectionMode** `"single" | "independent" | "linked"` (default: linked)

Controls how nodes relate to each other during selection.

**valueConsistsOf** `"ALL" | "BRANCH_PRIORITY" | "BRANCH_ONLY" | "LEAF_PRIORITY" | "LEAF_ONLY"` (default: LEAF\_PRIORITY)

Controls which nodes are selectable and how the `value` array is shaped.

**virtualize** `boolean`

Enables windowed rendering for the tree dropdown.

**TreeSelectMenuNode Type**

## `TreeSelectMenuNode` Type

Nodes passed to or returned from TreeSelectMenu components must conform to this shape:

```tsx theme={null}
type TreeSelectMenuNode = {
  /** Unique identifier for the node */
  id: string | number;

  /** Display text for the node */
  label: string;

  /** Optional text used for search matching (in addition to label) */
  searchText?: string;

  /** Whether the node is disabled and cannot be selected */
  disabled?: boolean;

  /** Optional extra data to associate with the node */
  extra?: Record<string, unknown>;

  /** Child nodes — determines the node type:
   *  - undefined: leaf node
   *  - []: empty branch (expanded shows "No items")
   *  - [...]: branch with children
   *  - null: unloaded lazy branch (children loaded on expand)
   */
  children?: TreeSelectMenuNode[] | null;

  /** True number of children for a branch. Lets the menu compute accurate
   *  parent check-state when children are filtered out by search or not yet
   *  loaded. Returned from loadOptions for async trees; derived
   *  automatically by TreeSelectMenuSync. */
  childCount?: number;

  /** Ancestor ids, root → parent. Selection-ancestry metadata, primarily
   *  meaningful on value objects: lets the menu show correct ancestor
   *  check-states for a selection whose branches aren't loaded yet (e.g. a
   *  restored selection). Omit on loadOptions results. */
  path?: (string | number)[];
};
```

### Node Types

The `children` property determines the node's role in the tree:

| `children` value | Node type | Behavior |
| - | - | - |
| `undefined` | Leaf | Terminal node, no expand icon |
| `[]` | Empty branch | Shows expand icon, displays "No items" when expanded |
| `[...]` | Branch | Shows expand icon, renders children when expanded |
| `null` | Unloaded branch | Shows expand icon, triggers `loadOptions` with parent node on expand |

### Example with Mixed Node Types

```tsx theme={null}
const nodes: TreeSelectMenuNode[] = [
  {
    id: "eng",
    label: "Engineering",
    children: [
      { id: "fe", label: "Frontend" },           // leaf
      { id: "be", label: "Backend" },             // leaf
      { id: "infra", label: "Infrastructure" },   // leaf
    ],
  },
  {
    id: "design",
    label: "Design",
    children: null,    // lazy branch — children loaded on expand
  },
  {
    id: "archive",
    label: "Archived Teams",
    children: [],      // empty branch
  },
  { id: "hr", label: "Human Resources" },        // leaf (no children property)
];
```

**TreeSelectMenuValue Type**

## `TreeSelectMenuValue` Type

The value array contains slim objects with just enough data to identify selected nodes:

```tsx theme={null}
type TreeSelectMenuValue = {
  /** The node's unique identifier */
  id: string | number;

  /** The node's display text */
  label: string;

  /** Optional extra data from the node */
  extra?: Record<string, unknown>;

  /** Ancestor ids, root → parent. Supply on a restored selection so the
   *  menu can show correct ancestor check-states before those branches
   *  load. The menu stamps it automatically on selections it emits, so a
   *  persisted-then-restored value round-trips the ancestry. */
  path?: (string | number)[];
};
```

### Usage

The `value` prop is always an array, even in single-select mode:

```tsx theme={null}
// Multi-select
const [selected, setSelected] = useState<TreeSelectMenuValue[]>([]);

// Single-select (still an array, but contains 0 or 1 items)
const [selected, setSelected] = useState<TreeSelectMenuValue[]>([]);
```

### Imperative Handle

Access imperative methods via a ref:

```tsx theme={null}
import { useRef } from "react";
import { TreeSelectMenuHandle } from "@servicetitan/anvil2/beta";

const menuRef = useRef<TreeSelectMenuHandle>(null);

// Clear the options cache
menuRef.current?.clearCache();

// Clear cache and reload options
menuRef.current?.invalidate();

// Expand or collapse all branches
menuRef.current?.expandAll();
menuRef.current?.collapseAll();
```


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.