Skip to main content

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.

Beta FeatureThis 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 channel with any questions or feedback!

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.
Looking for a form field with a built-in label, error state, and selected chips? Use TreeSelectField instead.

TreeSelectMenuSync (Static Options)

Use TreeSelectMenuSync when you have a static tree of options that can be filtered client-side.
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 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 to customize the default filtering and sorting behavior:
<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:
<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

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:
<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:
// 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.
<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.
// 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:
StrategySelectable NodesValue Contains
"ALL"Any nodeAll checked nodes
"BRANCH_PRIORITY"Any nodeBranches replace their leaves when fully checked
"BRANCH_ONLY"Branches onlyOnly branches (leaves are visible but inert)
"LEAF_PRIORITY" (default)Leaves + expand branchesLeaves and childless branches
"LEAF_ONLY"Leaves onlyStrictly leaf nodes
<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:
// 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:
// 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:
// 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:
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:
const handleDataSourceChange = () => {
  menuRef.current?.invalidate();
};
TreeSelectMenuSync handles this automatically when its options prop reference changes.

Initial Load Behavior

Control when options are first loaded with the initialLoad prop:
// 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:
// 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:
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():
menuRef.current?.expandAll();
menuRef.current?.collapseAll();

Disabled Nodes

Individual nodes can be disabled by setting disabled: true:
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.
<TreeSelectMenu
  virtualize
  label="Large tree"
  loadOptions={loadLargeTree}
  value={selected}
  onSelectedOptionsChange={setSelected}
  trigger={(props) => <Button {...props}>Browse</Button>}
/>
Pass disableSearch to hide the search input:
<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:
<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.
Last modified on May 29, 2026