> ## 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 Field - Web

> TreeSelectField components provide hierarchical tree selection with support for async loading, cascading selection, and client-side filtering.

> [!NOTE]
>
> For development, import `TreeSelectField` 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 field family includes two components for different use cases:

* **`TreeSelectField`** — 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)
* **`TreeSelectFieldSync`** — For client-side filtering of static tree option arrays

Both components provide a hierarchical tree dropdown with cascading parent/child selection, keyboard navigation, and adaptive display modes (popover or dialog).

## TreeSelectFieldSync (Static Options)

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

```tsx theme={null}
import { useState } from "react";
import { TreeSelectFieldSync, TreeSelectFieldValue } from "@servicetitan/anvil2/beta";

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<TreeSelectFieldValue[]>([]);

  return (
    <TreeSelectFieldSync
      label="Select departments"
      options={options}
      value={selected}
      onSelectedOptionsChange={setSelected}
      defaultExpandLevel={1}
    />
  );
};
```

### Filtering and Sorting

By default, `TreeSelectFieldSync` 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}
<TreeSelectFieldSync
  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}
<TreeSelectFieldSync
  options={options}
  filter={(nodes, searchValue) => {
    // Return a filtered tree — parent preservation is your responsibility
    return customTreeFilter(nodes, searchValue);
  }}
  // ...other props
/>
```

## TreeSelectField (Async Loading)

Use `TreeSelectField` 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 { TreeSelectField, TreeSelectFieldValue } from "@servicetitan/anvil2/beta";

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

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

### 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}
import { useState } from "react";
import {
  TreeSelectField,
  TreeSelectFieldNode,
  TreeSelectFieldValue,
} from "@servicetitan/anvil2/beta";

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

  return (
    <TreeSelectField
      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}
    />
  );
};
```

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.

### Progressive (Multi-Level) Loading

Lazy loading is recursive: when the children returned for one branch are themselves
branches with `children: null`, each level loads on demand as the user drills in.
This is the common shape for a file browser or a deep location hierarchy where you
never want to fetch the whole tree up front.

```tsx theme={null}
import { useState } from "react";
import {
  TreeSelectField,
  TreeSelectFieldNode,
  TreeSelectFieldValue,
} from "@servicetitan/anvil2/beta";

const FilePicker = () => {
  const [selected, setSelected] = useState<TreeSelectFieldValue[]>([]);

  const loadOptions = async (
    searchValue: string,
    parentNode?: TreeSelectFieldNode,
  ): Promise<TreeSelectFieldNode[]> => {
    // Expanding a folder: fetch only its immediate entries. Sub-folders come
    // back with `children: null` so they load on their own expansion.
    if (parentNode) {
      const entries = await fetchFolder(parentNode.id);
      return entries.map((entry) => ({
        id: entry.id,
        label: entry.name,
        children: entry.isFolder ? null : undefined,
        childCount: entry.isFolder ? entry.count : undefined,
      }));
    }
    // Roots — or a server-side search across the whole tree.
    if (searchValue) return searchAllFolders(searchValue);
    return fetchFolder("root");
  };

  return (
    <TreeSelectField
      label="Attach a file"
      loadOptions={loadOptions}
      value={selected}
      onSelectedOptionsChange={setSelected}
      selectionMode="single"
      // Don't fetch anything until the user opens the menu.
      initialLoad="open"
      // Keep recently-opened folders warm so re-expanding is instant.
      cache={{ enabled: true, maxSize: 50 }}
    />
  );
};
```

After a mutation that changes the underlying data (an upload, a rename, a delete),
call `invalidate()` on the ref to drop the cache and re-fetch from the top:

```tsx theme={null}
const fieldRef = useRef<TreeSelectFieldHandle>(null);

const handleUploadComplete = () => {
  fieldRef.current?.invalidate();
};
```

### Restoring a Selection Under Unloaded Branches

With lazy loading, a selected node can sit under branches that haven't been
fetched yet — most commonly when you **restore a persisted selection** on
mount, before the user has expanded anything. In linked mode the field shows
a parent as checked or indeterminate by looking at its children, but those
children aren't loaded, so the ancestor checkboxes can't reflect the
selection until the branch is expanded.

Provide the selected value's ancestry via the optional **`path`** field
(ancestor ids, root → parent) so the field can mark the correct ancestor
branches immediately, with no extra fetching:

```tsx theme={null}
// A selection restored from storage. `path` lets the field show
// "Engineering" and "Frontend" as indeterminate before they're loaded.
const [selected, setSelected] = useState<TreeSelectFieldValue[]>([
  {
    id: "alice",
    label: "Alice Chen",
    path: ["engineering", "frontend"],
  },
]);
```

You don't have to build `path` by hand for selections made through the UI:
when the field emits a selection whose ancestors are loaded, it **stamps
`path` onto the value for you**. Persisting `value` as-is and restoring it
later round-trips the ancestry automatically.

Without `path`, the field still resolves ancestry for any branch the user has
expanded this session (it remembers child→parent relationships as branches
load). A selection whose ancestors have never been loaded simply shows its
branches unchecked until they're expanded — at which point the state
self-corrects. Supplying `path` is what makes a cold restore correct up front.

> [!NOTE]
>
> During search, set `childCount` on branch nodes so the field can tell a
> partially-matched branch from a fully-selected one — see
> [Search and `childCount`](#search-and-childcount).

## 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
<TreeSelectFieldSync
  selectionMode="linked"
  options={options}
  // ...other props
/>

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

// Single-select mode — one node at a time
<TreeSelectFieldSync
  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}
<TreeSelectFieldSync
  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.

### What ends up in `value`

The same user action produces a different `value` array depending on the
strategy. Given this tree, with the user checking **Building B** (which checks
its only floor and both of that floor's rooms):

```
Headquarters
└─ Building B          ← checked
   └─ 3rd Floor
      ├─ Conference Room
      └─ Focus Room
```

| `valueConsistsOf` | Resulting `value` (by `label`) |
| - | - |
| `"ALL"` | Building B, 3rd Floor, Conference Room, Focus Room |
| `"BRANCH_PRIORITY"` | Building B (the branch replaces its fully-checked subtree) |
| `"BRANCH_ONLY"` | Building B, 3rd Floor (only branches; rooms are inert) |
| `"LEAF_PRIORITY"` | Conference Room, Focus Room (leaves only) |
| `"LEAF_ONLY"` | Conference Room, Focus Room (leaves only) |

`BRANCH_PRIORITY` is the right choice when "select the whole branch" should
read as a single chip; `LEAF_ONLY` is right when the consumer only ever wants
concrete items (e.g. people, not teams) in the submitted value.

## Display Modes

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

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

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

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

## Caching

`TreeSelectField` 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
<TreeSelectField cache={{ enabled: false }} {...props} />

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

### Clearing the Cache

Use a ref to imperatively clear the cache:

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

const ExampleComponent = () => {
  const fieldRef = useRef<TreeSelectFieldHandle>(null);

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

  return (
    <>
      <TreeSelectField ref={fieldRef} {...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 = () => {
  fieldRef.current?.invalidate();
};
```

> [!NOTE]
>
> `TreeSelectFieldSync` 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)
<TreeSelectField initialLoad="immediate" {...props} />

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

// Auto (currently equivalent to "immediate")
<TreeSelectField 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)
<TreeSelectFieldSync defaultExpandLevel={0} options={options} {...props} />

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

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

For full control, use `expandedIds` and `onExpandedIdsChange`. Because the set
lives in your state, external UI can drive expansion directly — for example, a
button that drills the tree open to a specific path:

```tsx theme={null}
const [expandedIds, setExpandedIds] = useState<Set<string | number>>(
  () => new Set(["hq", "bldg-b", "b-3"]),
);

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

// Jump straight to Building B › 3rd Floor
<button onClick={() => setExpandedIds(new Set(["hq", "bldg-b", "b-3"]))}>
  Go to 3rd Floor
</button>

// Collapse everything
<button onClick={() => setExpandedIds(new Set())}>Collapse all</button>
```

The imperative handle also provides `expandAll()` and `collapseAll()` for when
you don't need to track the exact set:

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

## Controlled Search

By default the search input is uncontrolled. Provide `searchValue` and
`onSearchChange` to keep it in sync with your own state — useful when an
external search box should drive the field, or when you filter `loadOptions`
server-side and want to react to the query yourself.

```tsx theme={null}
const [search, setSearch] = useState("");

<input
  value={search}
  onChange={(e) => setSearch(e.target.value)}
  placeholder="Search people…"
/>

<TreeSelectFieldSync
  options={options}
  value={selected}
  onSelectedOptionsChange={setSelected}
  searchValue={search}
  onSearchChange={setSearch}
  // ...other props
/>
```

Typing inside the field calls `onSearchChange`, and updating `searchValue`
externally re-filters the tree — the two stay in lockstep.

## Search and `childCount`

When a search filter hides some of a branch's children, the field only sees
the matches that remain. To avoid showing a branch as fully checked when an
unselected child is merely filtered out of view, set **`childCount`** (the
branch's true number of children) on branch nodes. The field compares the
loaded children against `childCount`: if fewer are present than the real
total, a branch with some — but not provably all — children selected renders
as indeterminate rather than checked.

```tsx theme={null}
// childCount reflects the full child set, independent of the current filter.
{
  id: "sales-marketing",
  label: "Sales & Marketing",
  childCount: 2, // even when only "Sales" matches the search
  children: null,
}
```

`TreeSelectFieldSync` derives `childCount` automatically from its static
`options`, so client-side filtering is always accurate. For async
`TreeSelectField`, return `childCount` from `loadOptions` (it should reflect
the unfiltered count). Without it, a partially-filtered branch falls back to
reflecting only its visible children.

## Field States

### Error State

```tsx theme={null}
<TreeSelectFieldSync
  label="Department"
  options={options}
  value={selected}
  onSelectedOptionsChange={setSelected}
  error="Please select at least one department"
/>
```

### Hint and Description

```tsx theme={null}
<TreeSelectFieldSync
  label="Department"
  options={options}
  value={selected}
  onSelectedOptionsChange={setSelected}
  hint="Select the departments this applies to"
  description="Used for routing and permissions"
/>
```

### Required Field

```tsx theme={null}
<TreeSelectFieldSync
  label="Department"
  options={options}
  value={selected}
  onSelectedOptionsChange={setSelected}
  required
/>
```

### Disabled and ReadOnly

When `disabled` is set, users cannot interact with the field.

```tsx theme={null}
<TreeSelectFieldSync
  label="Department"
  options={options}
  value={selected}
  onSelectedOptionsChange={setSelected}
  disabled
/>
```

When `readOnly` is set, users can browse the tree but cannot change the selection.

```tsx theme={null}
<TreeSelectFieldSync
  label="Department"
  options={options}
  value={selected}
  onSelectedOptionsChange={setSelected}
  readOnly
/>
```

### Prefix and Suffix

```tsx theme={null}
<TreeSelectFieldSync
  label="Region"
  options={regions}
  value={selected}
  onSelectedOptionsChange={setSelected}
  prefix="Location:"
/>
```

### Markdown in labels

The `label` prop supports inline markdown: bold (`**text**`), italic (`*text*`), highlight (`==text==`), and code (`` `text` ``).

### Hide the label

Use `hideLabel` to visually hide the label. The `label` string remains accessible to screen readers.

```tsx theme={null}
<TreeSelectFieldSync
  label="Department"
  hideLabel
  placeholder="Select departments..."
  options={options}
  value={selected}
  onSelectedOptionsChange={setSelected}
/>
```

## 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}
<TreeSelectField
  virtualize
  label="Large tree"
  loadOptions={loadLargeTree}
  value={selected}
  onSelectedOptionsChange={setSelected}
/>
```

## Disabling Search

Pass `disableSearch` to hide the search input:

```tsx theme={null}
<TreeSelectFieldSync
  disableSearch
  label="Category"
  options={options}
  value={selected}
  onSelectedOptionsChange={setSelected}
/>
```

## Chip Display

In multi-select mode, selected values appear as removable chips. Control chip layout with `singleRow` and `maxChips`:

```tsx theme={null}
// Single row with overflow count
<TreeSelectFieldSync
  singleRow
  options={options}
  // ...other props
/>

// Limit visible chips
<TreeSelectFieldSync
  maxChips={3}
  options={options}
  // ...other props
/>
```

### Chip Customization

Customize the appearance of individual chips using `getChipProps`. The callback receives each selected value and returns partial `ChipProps` (e.g., `color`, `icon`, `avatar`). Core chip props (`label`, `onClose`, `className`, `title`, `size`) are managed by the component and excluded from the callback's return type.

```tsx theme={null}
<TreeSelectFieldSync
  label="Department"
  options={options}
  value={selected}
  onSelectedOptionsChange={setSelected}
  getChipProps={(node) => ({
    color: node.id === "eng" ? "#1e88e5" : "#7b1fa2",
    icon: CategoryIcon,
  })}
/>
```

## Testing

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

Attach your own `data-*` attribute (such as `data-cy` or `data-testid`) to the field. It is forwarded to the field's wrapper element, letting you scope queries to a single instance:

```tsx theme={null}
<TreeSelectField data-cy="department-picker" label="Department" {...rest} />
```

```ts theme={null}
// Target the input within this specific field
cy.get('[data-cy="department-picker"] [data-anv-part="input"]').type("Eng");
```

| `data-anv-part` | Element |
| - | - |
| `input` | Combobox text input (searchable or read-only mode) |
| `chip` | Removable chip for a selected value (multi-select) |
| `toggle-button` | Button that opens and closes the menu |
| `menu` | Tree list container (`role="tree"`) |
| `option` | An individual tree row (`role="treeitem"`) |
| `expand-toggle` | Branch expand/collapse control within a tree row |
| `dialog` | Menu container in dialog display mode |
| `popover` | Menu container in popover display mode |

Tree rows also carry `data-id={node.id}` so you can pick one option among many: `[data-anv-part="option"][data-id="eng"]`.

> [!NOTE]
>
> The menu renders in the browser's top layer (`menu`, `option`, `expand-toggle`, `dialog`, and `popover` parts), outside the field's wrapper. Descendant queries scoped to your wrapper attribute will not match these parts. Target them by their part attribute directly, or scope to the menu's id — `#{id}-popover` or `#{id}-dialog`, where `{id}` is the field's `id` prop. Only one menu is open at a time.

**TreeSelectField Props**

## `TreeSelectField` Props

```tsx theme={null}
import { useState } from "react";
import { TreeSelectField, TreeSelectFieldValue } from "@servicetitan/anvil2/beta";

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

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

**label** `string` *required*

The label of the tree select field. Supports inline markdown formatting.

**loadOptions** `(searchValue: string, parentNode?: TreeSelectFieldNode) =\> TreeSelectFieldNode[] | Promise\<TreeSelectFieldNode[]\>` *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: TreeSelectFieldValue[]) =\> void` *required*

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

**value** `TreeSelectFieldValue[]` *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`)

**className** `string`

Custom CSS class name for the wrapper element.

**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.

**description** `ReactElement | string`

Description text displayed below the input field.

**disableSearch** `boolean`

Hides the search input.

**disabled** `boolean`

Whether the field is disabled. When disabled, the field cannot be interacted with and the dropdown cannot open.

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

**error** `boolean | string | string[] | ReactElement`

Error state for the field. When `true`, shows error styling. When a string, string array, or ReactElement, also displays as an error message below the field.

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

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

**getChipProps** `(value: TreeSelectFieldValue) =\> TreeSelectFieldChipProps`

Callback to customize chip appearance for each selected value. Returns partial chip props such as `color`, `icon`, or `avatar`. Core props (`label`, `onClose`, `className`, `title`, `size`) are managed by the component and excluded from this type.

**hideLabel** `boolean`

Visually hides the label while keeping it accessible to screen readers.

**hint** `ReactElement | string`

Hint text displayed below the input field.

**id** `string`

The id of the tree select field.

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

**labelNode** `ReactNode`

Custom label content. Overrides the `label` string for rendering but `label` is still used for accessibility.

**maxChips** `number`

Maximum number of chips to display in multi-select mode before showing an overflow count.

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

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

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

Callback fired when the search input value changes. Use with `searchValue` for controlled search.

**placeholder** `string`

Placeholder text for the search input.

**prefix** `string | ReactElement`

Content to display before the input field.

**readOnly** `boolean`

Whether the field is read-only. When read-only, the dropdown can open to browse the tree but selections cannot be changed.

**required** `boolean`

Whether the field is required. Shows a red asterisk (\*) next to the label.

**searchValue** `string`

Controlled search input value. Use with `onSearchChange` for controlled search.

**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`

**singleRow** `boolean`

When `true`, selected chips display in a single row with horizontal overflow.

**size** `"small" | "medium" | "large"`

The size of the tree select field.

**style** `CSSProperties`

Custom inline styles for the wrapper element.

**suffix** `string | ReactElement`

Content to display after the input field.

**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.

**warning** `string | string[]`

Warning message(s) displayed below the input field.

**TreeSelectFieldSync Props**

## `TreeSelectFieldSync` Props

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

```tsx theme={null}
import { useState } from "react";
import { TreeSelectFieldSync, TreeSelectFieldValue } from "@servicetitan/anvil2/beta";

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

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

  return (
    <TreeSelectFieldSync
      label="Select departments"
      onSelectedOptionsChange={setSelected}
      options={options}
      value={selected}
    />
  );
};
```

**label** `string` *required*

The label of the tree select field. Supports inline markdown formatting.

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

Callback fired when the selection changes.

**options** `TreeSelectFieldNode[]` *required*

The static tree of options to display.

**value** `TreeSelectFieldValue[]` *required*

The currently selected values.

**className** `string`

Custom CSS class name for the wrapper element.

**defaultExpandLevel** `number`

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

**description** `ReactElement | string`

Description text displayed below the input field.

**disableSearch** `boolean`

Hides the search input.

**disabled** `boolean`

Whether the field is disabled.

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

How to display the options menu.

**error** `boolean | string | string[] | ReactElement`

Error state for the field.

**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: TreeSelectFieldNode[], searchValue: string) => TreeSelectFieldNode[]` — 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.

**getChipProps** `(value: TreeSelectFieldValue) =\> TreeSelectFieldChipProps`

Callback to customize chip appearance for each selected value. Returns partial chip props such as `color`, `icon`, or `avatar`. Core props (`label`, `onClose`, `className`, `title`, `size`) are managed by the component and excluded from this type.

**hideLabel** `boolean`

Visually hides the label while keeping it accessible to screen readers.

**hint** `ReactElement | string`

Hint text displayed below the input field.

**id** `string`

The id of the tree select field.

**labelNode** `ReactNode`

Custom label content.

**maxChips** `number`

Maximum number of chips to display before showing an overflow count.

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

Callback fired when expansion state changes.

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

Callback fired when the search input value changes.

**placeholder** `string`

Placeholder text for the search input.

**prefix** `string | ReactElement`

Content to display before the input field.

**readOnly** `boolean`

Whether the field is read-only.

**required** `boolean`

Whether the field is required.

**searchValue** `string`

Controlled search input value.

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

Controls how nodes relate to each other during selection.

**singleRow** `boolean`

When `true`, selected chips display in a single row.

**size** `"small" | "medium" | "large"`

The size of the tree select field.

**style** `CSSProperties`

Custom inline styles for the wrapper element.

**suffix** `string | ReactElement`

Content to display after the input field.

**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.

**warning** `string | string[]`

Warning message(s) displayed below the input field.

**TreeSelectFieldNode Type**

## `TreeSelectFieldNode` Type

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

```tsx theme={null}
type TreeSelectFieldNode = {
  /** 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?: TreeSelectFieldNode[] | null;

  /** True number of children for a branch. Lets the field 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 TreeSelectFieldSync. */
  childCount?: number;

  /** Ancestor ids, root → parent. Selection-ancestry metadata, primarily
   *  meaningful on value objects: lets the field show correct ancestor
   *  check-states for a selection whose branches aren't loaded yet (e.g. a
   *  restored selection). Omit on loadOptions results — it isn't a
   *  structural attribute. */
  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: TreeSelectFieldNode[] = [
  {
    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)
];
```

**TreeSelectFieldValue Type**

## `TreeSelectFieldValue` Type

The value array contains slim objects with just enough data to render chips and identify nodes:

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

  /** The node's display text (used for chip labels) */
  label: string;

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

  /** Ancestor ids, root → parent. Supply on a restored selection so the
   *  field can show correct ancestor check-states before those branches
   *  load. The field 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<TreeSelectFieldValue[]>([]);

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

### Imperative Handle

Access imperative methods via a ref:

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

const fieldRef = useRef<TreeSelectFieldHandle>(null);

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

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

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


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