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

# Select Menu - Web

> SelectMenu components provide a searchable dropdown menu attachable to any trigger element, with support for async data loading and client-side filtering.

> [!NOTE]
>
> For development, import `SelectMenu` from
> `@servicetitan/anvil2`. For design, find it in the
> [Anvil2 file](https://www.figma.com/design/xaNQsvRKczoxu7pj0M6QT8/Anvil2-v3?node-id=2246-8956).

> [!WARNING]
>
> Importing `SelectMenu` from `@servicetitan/anvil2/beta` is
> deprecated. Import from `@servicetitan/anvil2` instead. The beta export will
> be removed in Anvil2 4.0.

**Implementation**

## Overview

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

* **`SelectMenu`** — For async data loading with support for pagination (page-based, offset-based, or group-based lazy loading)
  * Includes automatic debouncing of the search input (configurable via the `debounceMs` prop)
  * Includes automatic caching of the search input (configurable via the `cache` prop)
* **`SelectMenuSync`** — For client-side filtering of static option arrays

Both components attach a searchable dropdown to any trigger element via a `trigger` render prop. The menu closes after an option is selected. Use `SelectMenu` when you need 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 input? Use [`SelectField`](https://anvil.servicetitan.com/docs/components/select-field/code) instead.

## SelectMenuSync (Static Options)

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

```tsx theme={null}
import { useState } from "react";
import { SelectMenuSync } from "@servicetitan/anvil2";
import { Button } from "@servicetitan/anvil2";

const options = [
  { id: 1, label: "Option One" },
  { id: 2, label: "Option Two" },
  { id: 3, label: "Option Three" },
];

const ExampleComponent = () => {
  const [selectedOption, setSelectedOption] = useState(null);

  return (
    <SelectMenuSync
      label="Options"
      options={options}
      value={selectedOption}
      onSelectedOptionChange={setSelectedOption}
      trigger={(props) => (
        <Button {...props}>
          {selectedOption ? selectedOption.label : "Select an option"}
        </Button>
      )}
    />
  );
};
```

### Filtering and Sorting

By default, `SelectMenuSync` uses [match-sorter](https://github.com/kentcdodds/match-sorter) to filter options by their `label` and `searchText` fields. Results are also ranked by match quality, so the best matches appear first. Before any search is performed, options appear in the order they are supplied.
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 (e.g., change which keys are matched or adjust ranking):

```tsx theme={null}
<SelectMenuSync
  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 sort order. The returned array determines the exact order options appear in the dropdown:

```tsx theme={null}
<SelectMenuSync
  options={options}
  filter={(options, searchValue) => {
    return options.filter((option) =>
      option.label?.toLowerCase().includes(searchValue.toLowerCase())
    );
  }}
  // ...other props
/>
```

## SelectMenu (Async Loading)

Use `SelectMenu` when options need to be fetched from an API or when dealing with large datasets that require server-side filtering.

### Basic Async Loading

```tsx theme={null}
import { useState } from "react";
import { SelectMenu } from "@servicetitan/anvil2";
import { Button } from "@servicetitan/anvil2";

const ExampleComponent = () => {
  const [selectedOption, setSelectedOption] = useState(null);

  return (
    <SelectMenu
      label="Users"
      searchPlaceholder="Search users..."
      loadOptions={async (searchValue) => {
        const response = await fetch(`/api/users?q=${searchValue}`);
        const users = await response.json();
        return users.map((user) => ({
          id: user.id,
          label: user.name,
        }));
      }}
      value={selectedOption}
      onSelectedOptionChange={setSelectedOption}
      trigger={(props) => (
        <Button {...props}>
          {selectedOption ? selectedOption.label : "Select a user"}
        </Button>
      )}
    />
  );
};
```

### Lazy Loading Modes

`SelectMenu` supports three lazy loading modes for paginated data:

#### Page-based Pagination

```tsx theme={null}
<SelectMenu
  lazy="page"
  lazyOptions={{ pageSize: 10 }}
  loadOptions={async (searchValue, pageNumber, pageSize) => {
    const response = await fetch(
      `/api/items?q=${searchValue}&page=${pageNumber}&size=${pageSize}`
    );
    const { data, totalCount } = await response.json();
    return {
      options: data.map((item) => ({ id: item.id, label: item.name })),
      hasMore: pageNumber * pageSize + data.length < totalCount,
    };
  }}
  // ...other props
/>
```

#### Offset-based Pagination

```tsx theme={null}
<SelectMenu
  lazy="offset"
  lazyOptions={{ limit: 15 }}
  loadOptions={async (searchValue, offset, limit) => {
    const response = await fetch(
      `/api/items?q=${searchValue}&offset=${offset}&limit=${limit}`
    );
    const { data, totalCount } = await response.json();
    return {
      options: data.map((item) => ({ id: item.id, label: item.name })),
      hasMore: offset + limit < totalCount,
    };
  }}
  // ...other props
/>
```

#### Group-based Loading

For loading grouped options incrementally:

```tsx theme={null}
<SelectMenu
  lazy="group"
  groupToString={(groupValue) => String(groupValue)}
  loadOptions={async (searchValue, previousGroupKey) => {
    const response = await fetch(
      `/api/items?q=${searchValue}&afterGroup=${previousGroupKey || ""}`
    );
    const { data, hasMore } = await response.json();
    return {
      options: data.map((item) => ({
        id: item.id,
        label: item.name,
        group: item.category,
      })),
      hasMore,
    };
  }}
  // ...other props
/>
```

## Display Modes

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

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

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

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

## Popover Width

Control the width of the popover using the `width` prop:

```tsx theme={null}
// Fixed pixel width
<SelectMenu width={300} {...props} />

// CSS string value
<SelectMenu width="20rem" {...props} />
```

## Caching

`SelectMenu` caches `loadOptions` results by default. Configure caching behavior:

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

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

### Clearing the Cache

Use a ref to imperatively clear the cache:

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

const ExampleComponent = () => {
  const selectMenuRef = useRef(null);

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

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

### Invalidating Options

Call `invalidate()` to clear the cache and reload options from the data source. Use this when the underlying data has changed and the component needs to reflect the update:

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

const ExampleComponent = () => {
  const selectMenuRef = useRef(null);

  const handleDataSourceChange = () => {
    selectMenuRef.current?.invalidate();
  };

  return (
    <SelectMenu ref={selectMenuRef} {...props} />
  );
};
```

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

## Initial Load Behavior

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

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

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

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

## Disabling Search

Pass `disableSearch` to remove the search input from inside the menu. The menu renders only the option list, and keyboard focus moves directly to the list container.

This is useful when the option list is short and well-known or you are integrating with an API that does not support search.

```tsx theme={null}
import { useState } from "react";
import { SelectMenuSync } from "@servicetitan/anvil2";
import { Button } from "@servicetitan/anvil2";

const options = [
  { id: 1, label: "Small" },
  { id: 2, label: "Medium" },
  { id: 3, label: "Large" },
];

const ExampleComponent = () => {
  const [selectedOption, setSelectedOption] = useState(null);

  return (
    <SelectMenuSync
      disableSearch
      label="Size"
      options={options}
      value={selectedOption}
      onSelectedOptionChange={setSelectedOption}
      trigger={(props) => (
        <Button {...props}>
          {selectedOption ? selectedOption.label : "Select a size"}
        </Button>
      )}
    />
  );
};
```

## Disabled Options

Individual options can be disabled by setting `disabled: true` on the option:

```tsx theme={null}
const options = [
  { id: 1, label: "Available Option" },
  { id: 2, label: "Unavailable Option", disabled: true },
  { id: 3, label: "Another Available Option" },
];

<SelectMenuSync
  label="Select an option"
  options={options}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  trigger={(props) => <Button {...props}>Choose</Button>}
/>
```

## Pinned Options

Pin frequently used or suggested options above the list using the `pinned` prop. Each pinned section requires a `label` and an `options` value, which can be a static array or a dynamic loader function.

### Static Pinned Options

Pass an object with `label` and a static `options` array:

```tsx theme={null}
<SelectMenu
  label="Books"
  pinned={{
    label: "Favorites",
    options: [
      { id: "fav-1", label: "The Martian" },
      { id: "fav-2", label: "Dune" },
    ],
  }}
  loadOptions={fetchBooks}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  trigger={(props) => <Button {...props}>Select a book</Button>}
/>
```

### Dynamic Pinned Options

Pass a function as `options` to compute pinned options based on the current search value:

```tsx theme={null}
<SelectMenu
  label="Books"
  pinned={{
    label: "AI Suggestions",
    options: async (searchValue) => {
      const suggestions = await fetchAISuggestions(searchValue);
      return suggestions.map((s) => ({ id: s.id, label: s.title }));
    },
  }}
  loadOptions={fetchBooks}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  trigger={(props) => <Button {...props}>Select a book</Button>}
/>
```

By default, the loader re-runs whenever the search value changes. Set `searchReactive: false` to call the loader once and reuse the result across all search values:

```tsx theme={null}
<SelectMenu
  label="Books"
  pinned={{
    label: "Your Favorites",
    options: async () => {
      return await fetchFavorites();
    },
    searchReactive: false,
  }}
  loadOptions={fetchBooks}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  trigger={(props) => <Button {...props}>Select a book</Button>}
/>
```

### Multiple Pinned Sections

Pass an array of pinned section objects:

```tsx theme={null}
<SelectMenu
  label="Books"
  pinned={[
    {
      label: "AI Suggestions",
      options: async (searchValue) => {
        const suggestions = await fetchAISuggestions(searchValue);
        return suggestions.map((s) => ({ id: s.id, label: s.title }));
      },
    },
    {
      label: "Your Favorites",
      options: [
        { id: "fav-1", label: "Dune" },
        { id: "fav-2", label: "Foundation" },
      ],
    },
  ]}
  loadOptions={fetchBooks}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  trigger={(props) => <Button {...props}>Select a book</Button>}
/>
```

## Grouping Options

Options can be organized into visual groups by adding a `group` property to each option. Groups appear as labeled sections in the dropdown.

### Basic Grouping

Add a `group` property to options and provide a `groupToString` function to display group labels:

```tsx theme={null}
const options = [
  { id: 1, label: "Apple", group: "fruits" },
  { id: 2, label: "Banana", group: "fruits" },
  { id: 3, label: "Carrot", group: "vegetables" },
  { id: 4, label: "Broccoli", group: "vegetables" },
];

<SelectMenuSync
  label="Food"
  options={options}
  groupToString={(group) =>
    group === "fruits" ? "Fruits" : "Vegetables"
  }
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  trigger={(props) => <Button {...props}>Select food</Button>}
/>
```

### Group Sorting

Use `groupSorter` to control the order of groups. By default, groups appear in the order they are first encountered in the options array.

This prop is available on `SelectMenuSync` and non-lazy `SelectMenu`:

> [!WARNING]
>
> Avoid using `groupSorter` with `lazy="group"`. When groups load incrementally from the server and get re-sorted, the menu content shifts unexpectedly, creating a disorienting user experience. Instead, have your server return groups in the desired order.

```tsx theme={null}
const options = [
  { id: 1, label: "Item A", group: 3 },
  { id: 2, label: "Item B", group: 1 },
  { id: 3, label: "Item C", group: 2 },
];

<SelectMenuSync
  label="Items"
  options={options}
  groupToString={(group) => `Priority ${group}`}
  groupSorter={(a, b) => Number(a) - Number(b)}
  value={selectedOption}
  onSelectedOptionChange={setSelectedOption}
  trigger={(props) => <Button {...props}>Select item</Button>}
/>
```

## Virtualization

By default, all dropdown options render to the DOM at once. This works well for typical lists but degrades performance with very large option sets. Pass `virtualize` to enable windowed rendering, which only renders the items currently visible in the scroll viewport plus a small overscan buffer.

Consider enabling `virtualize` when the dropdown feels sluggish to open or keyboard navigation becomes laggy. These symptoms typically appear around 200-500 items depending on device performance and item complexity.

```tsx theme={null}
import { useState } from "react";
import { SelectMenuSync } from "@servicetitan/anvil2";
import { Button } from "@servicetitan/anvil2";

const options = Array.from({ length: 5000 }, (_, i) => ({
  id: i,
  label: `Option ${i + 1}`,
}));

const ExampleComponent = () => {
  const [selectedOption, setSelectedOption] = useState(null);

  return (
    <SelectMenuSync
      virtualize
      label="Large dataset"
      options={options}
      value={selectedOption}
      onSelectedOptionChange={setSelectedOption}
      trigger={(props) => (
        <Button {...props}>
          {selectedOption ? selectedOption.label : "Select an option"}
        </Button>
      )}
    />
  );
};
```

## Clear Button

Pass a `clear` config to render a "Clear" button in the menu footer. Clicking Clear fires the provided `onClick` handler and closes the menu; consumers typically use it to reset their selection state.

```tsx theme={null}
<SelectMenu
  label="Owner"
  // …
  clear={{
    onClick: () => setSelected(null),
  }}
/>
```

The footer layout adapts automatically when other footer affordances are present:

* **Clear alone** — full-width row.
* **Clear + Add new** — Add-new stacks full-width above Clear.

## Adding New Items

Provide an `onAddNewItem` handler to render an "Add new item" button below the option list. This affordance lets users create a value that does not exist yet — a new category, tag, or job type — quickly from the menu.

The button sits in a footer region inside the menu. Clicking or activating it via keyboard closes the menu and invokes the `onAddNewItem` handler with the current search text.

From here, it is your responsibility to launch a `Dialog` (or `Drawer`, or any other overlay) for collecting the new item's details, append the result to the option source, invalidate the cache, and update selection state.

You may pair `onAddNewItem` with `addItemLabel` to control the button text. The label accepts a static node or a function of the current search text — for example, returning `Add "foo"` when the user has typed `foo`.

When `addItemLabel` is a function, return `null` (or `undefined`) to hide the button for the current search text. Use this when the affordance should only appear for valid input — for example, showing "Add email" only after a well-formed address is typed:

```tsx theme={null}
addItemLabel={(searchText) =>
  isValidEmail(searchText) ? `Add "${searchText}"` : null
}
onAddNewItem={openAddEmailDialog}
```

selectmenu-add-new-item

[StackBlitz demo](https://stackblitz.com/github/servicetitan/anvil2-docs-live-code/tree/main/examples/selectmenu-add-new-item?file=src/App.tsx)

```tsx lines expandable theme={null}
import { useEffect, useRef, useState } from "react";
import {
  SelectMenu,
  type SelectMenuHandle,
  type SelectMenuOption,
} from "@servicetitan/anvil2";
import { Button, Dialog, Flex, TextField } from "@servicetitan/anvil2";

function App() {
  const [categories, setCategories] = useState<SelectMenuOption[]>([
    { id: "hvac-repair", label: "HVAC Repair" },
    { id: "plumbing-leak", label: "Plumbing Leak" },
    { id: "electrical-panel", label: "Electrical Panel" },
    { id: "appliance-install", label: "Appliance Install" },
  ]);
  const [selected, setSelected] = useState<SelectMenuOption | null>(null);
  const menuRef = useRef<SelectMenuHandle>(null);
  const containerRef = useRef<HTMLDivElement>(null);

  const [dialogOpen, setDialogOpen] = useState(false);
  const [draftLabel, setDraftLabel] = useState("");

  // Open the menu on mount so the Add-new button is visible in screenshots.
  useEffect(() => {
    const timer = setTimeout(() => {
      const trigger = containerRef.current?.querySelector("button");
      trigger?.focus();
      trigger?.click();
    }, 100);
    return () => clearTimeout(timer);
  }, []);

  const openCategoryDialog = (initialLabel: string) => {
    setDraftLabel(initialLabel);
    setDialogOpen(true);
  };

  const saveCategory = () => {
    const id = draftLabel.trim().toLowerCase().replace(/\s+/g, "-");
    const newCategory: SelectMenuOption = { id, label: draftLabel };
    setCategories((prev) => [...prev, newCategory]);
    setSelected(newCategory);
    menuRef.current?.invalidate();
    setDialogOpen(false);
  };

  return (
    <div ref={containerRef} style={{ minWidth: "384px", minHeight: "420px" }}>
      <SelectMenu
        ref={menuRef}
        label="Job category"
        searchPlaceholder="Search categories..."
        initialLoad="open"
        loadOptions={async (searchValue) =>
          searchValue
            ? categories.filter((c) =>
                c.label.toLowerCase().includes(searchValue.toLowerCase()),
              )
            : categories
        }
        value={selected}
        onSelectedOptionChange={setSelected}
        addItemLabel={(searchText) =>
          searchText ? `Create "${searchText}" category` : "Create new category"
        }
        onAddNewItem={openCategoryDialog}
        trigger={(props) => (
          <Button {...props} appearance="secondary">
            {selected ? selected.label : "Set category"}
          </Button>
        )}
      />

      <Dialog open={dialogOpen} onClose={() => setDialogOpen(false)}>
        <Dialog.Header>Create new category</Dialog.Header>
        <Dialog.Content>
          <Flex direction="column" gap="3">
            <TextField
              label="Display name"
              required
              value={draftLabel}
              onChange={(e) => setDraftLabel(e.target.value)}
            />
          </Flex>
        </Dialog.Content>
        <Dialog.Footer>
          <Dialog.CancelButton appearance="ghost">Cancel</Dialog.CancelButton>
          <Button
            appearance="primary"
            onClick={saveCategory}
            disabled={!draftLabel.trim()}
          >
            Create category
          </Button>
        </Dialog.Footer>
      </Dialog>
    </div>
  );
}

export default App;
```

> [!NOTE]
>
> Call `invalidate()` on the imperative handle after appending the new option.
> This clears the cached search results and forces a refetch the next time the
> menu opens, so the new item appears immediately. `clearCache()` alone wipes
> the cache but keeps the previously displayed options on screen until
> something else triggers a reload.

## 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 `SelectMenu`. It is merged into the trigger render-prop bag, so spread `{...props}` onto your trigger element:

```tsx theme={null}
<SelectMenu
  data-cy="category-menu"
  trigger={(props) => <Button {...props}>Category</Button>}
  {...rest}
/>
```

```ts theme={null}
cy.get('[data-cy="category-menu"][data-anv-part="trigger"]').click();
cy.get('[data-anv-part="input"]').type("Eng");
```

| `data-anv-part` | Element |
| - | - |
| `trigger` | Your trigger element (via the `trigger` render prop) |
| `input` | Search field inside the open menu |
| `menu` | Option list container |
| `option` | An individual option row |
| `dialog` | Menu container in dialog display mode |
| `popover` | Menu container in popover display mode |
| `clear-button` | Footer clear action (when `clear` is configured) |

> [!NOTE]
>
> The menu renders in the browser's top layer (`menu`, `option`, `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.

**SelectMenu Props**

## `SelectMenu` Props

```tsx theme={null}
import { useState } from "react";
import { SelectMenu } from "@servicetitan/anvil2";
import { Button } from "@servicetitan/anvil2";

const ExampleComponent = () => {
  const [selectedOption, setSelectedOption] = useState(null);

  return (
    <SelectMenu
      label="Users"
      loadOptions={async (searchValue) => {
        const response = await fetch(`/api/users?q=${searchValue}`);
        const users = await response.json();
        return users.map((user) => ({
          id: user.id,
          label: user.name,
        }));
      }}
      onSelectedOptionChange={setSelectedOption}
      value={selectedOption}
      trigger={(props) => (
        <Button {...props}>
          {selectedOption ? selectedOption.label : "Select a user"}
        </Button>
      )}
    />
  );
};
```

**label** `string` *required*

Accessible label for the menu. Used by screen readers and as the dialog title on mobile. Not rendered visually in the trigger.

**loadOptions** `function` *required*

Function to load options. The signature depends on the `lazy` mode:

* **Non-lazy**: `(searchValue: string) => SelectMenuOption[] | Promise<SelectMenuOption[]>`
* **Page-based**: `(searchValue: string, pageNumber: number, pageSize: number) => { options: SelectMenuOption[], hasMore?: boolean }`
* **Offset-based**: `(searchValue: string, offset: number, limit: number) => { options: SelectMenuOption[], hasMore?: boolean }`
* **Group-based**: `(searchValue: string, previousGroupKey: string | number | null) => { options: SelectMenuGroupedOption[], hasMore?: boolean }`

**onSelectedOptionChange** `(option: SelectMenuOption | null) =\> void` *required*

Callback fired when the selected option changes.

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

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

**value** `SelectMenuOption | null` *required*

The currently selected option. Must be controlled state.

**addItemLabel** `ReactNode | (searchText: string) =\> ReactNode`

Label rendered inside the "Add new item" button. May be a static node or a callback that receives the current search text and returns a dynamic label (for example, `Add "${searchText}"`). When the callback returns `null` or `undefined`, the button is not rendered — use this to hide the affordance until the search text is valid (for example, a well-formed email). Defaults to `"Add new item"` when omitted but `onAddNewItem` is provided.

**cache** `SelectMenuCacheOptions`

Configuration for caching `loadOptions` results:

* `enabled` — Whether caching is enabled (default: `true`)
* `maxSize` — Maximum number of search values to cache before clearing (default: `15`)

**clear** `\{ onClick: () =\> void; label?: string; disabled?: boolean \}`

Configures a "Clear" button rendered in the menu footer. Presence of this prop enables the button — omit to hide it.

* `onClick` — Called when the user clicks Clear. The menu also closes automatically after `onClick` runs; consumers typically use this to clear their selection state.
* `label` — Button label. Defaults to `"Clear"`.
* `disabled` — Whether the button is inert.

When `clear` is provided alongside an "Add new item" affordance, the Add-new button stacks full-width above the Clear button; otherwise Clear renders alone on a full-width row.

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

Milliseconds to debounce search input before calling `loadOptions`.

**disableSearch** `boolean` (default: false)

Removes the search input from inside the menu. When true, the menu renders only the option list and keyboard focus moves to the list container.

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

**groupSorter** `(a: SelectMenuGroupByValue, b: SelectMenuGroupByValue) =\> number`

Function to compare two group values for sorting. When provided, groups are sorted using this comparator. Without this, groups appear in the order they are first encountered. Best used with `SelectMenuSync` or non-lazy `SelectMenu`. Avoid using with `lazy="group"` as it causes the menu to shift unexpectedly when new groups load.

**groupToString** `(groupValue: SelectMenuGroupByValue) =\> string`

Function to convert group values to display labels. Only used with grouped options. `SelectMenuGroupByValue` is `string | number`.

**id** `string`

The id of the select menu.

**initialLoad** `"auto" | "immediate" | "open"` (default: auto)

Controls when `loadOptions` is first called:

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

**lazy** `"page" | "offset" | "group" | false` (default: false)

Enables lazy loading with the specified pagination strategy.

**lazyOptions** `object`

Configuration for lazy loading:

* **Page mode**: `{ pageSize?: number }` (default pageSize: 20)
* **Offset mode**: `{ limit?: number }` (default limit: 20)
* **Group mode**: `{}`

**onAddNewItem** `(searchText: string) =\> void`

Click handler for the "Add new item" button. The button is only rendered when this prop is provided. Receives the current search text. The menu closes synchronously on click and the handler runs immediately after — typically opening a Dialog or Drawer to collect the new item's details. Consumers own overlay presentation, focus return, option list updates, and selection state. See the [Adding New Items](#adding-new-items) section for a full example.

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

Callback when the search value changes.

**pinned** `SelectMenuPinnedOptions`

Options to pin to the top of the list. Accepts a single section object or an array of section objects. Each section requires:

* `label` — Display label for the section header
* `options` — Static array (`SelectMenuOption[]`) or dynamic loader function (`(searchValue: string) => SelectMenuOption[] | Promise<SelectMenuOption[]>`)
* `searchReactive` — (Optional) Whether to re-call the loader when the search value changes. Defaults to `true`. Set to `false` to call the loader once and reuse the result.
* `cacheSize` — (Optional) Maximum number of search results to cache per section. Defaults to `15`. Only applies when `searchReactive` is `true`.

**searchPlaceholder** `string`

Placeholder text shown in the search input inside the menu.

**searchValue** `string`

Controlled search value. In most cases you do not need this — lean on the `searchValue` parameter in `loadOptions` instead.

**virtualize** `boolean` (default: false)

Enables windowed rendering for the dropdown list. Improves performance for large option sets by only rendering visible items. Consider enabling when the dropdown feels sluggish to open or keyboard navigation becomes laggy, typically around 200-500+ items.

**width** `number | string` (default: 320)

Width of the popover menu. Accepts a number (pixels) or a CSS string value (e.g., `"20rem"`).

**SelectMenuSync Props**

## `SelectMenuSync` Props

`SelectMenuSync` accepts all props from `SelectMenu` except `loadOptions`, `lazy`, `debounceMs`, `cache`, and `initialLoad`, plus the following:

```tsx theme={null}
import { useState } from "react";
import { SelectMenuSync } from "@servicetitan/anvil2";
import { Button } from "@servicetitan/anvil2";

const options = [
  { id: 1, label: "Option One" },
  { id: 2, label: "Option Two" },
  { id: 3, label: "Option Three" },
];

const ExampleComponent = () => {
  const [selectedOption, setSelectedOption] = useState(null);

  return (
    <SelectMenuSync
      label="Options"
      onSelectedOptionChange={setSelectedOption}
      options={options}
      value={selectedOption}
      trigger={(props) => (
        <Button {...props}>
          {selectedOption ? selectedOption.label : "Select an option"}
        </Button>
      )}
    />
  );
};
```

**label** `string` *required*

Accessible label for the menu.

**onSelectedOptionChange** `(option: SelectMenuOption | null) =\> void` *required*

Callback fired when the selected option changes.

**options** `SelectMenuOption[]` *required*

The array of options to display in the select menu.

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

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

**value** `SelectMenuOption | null` *required*

The currently selected option. Must be controlled state.

**addItemLabel** `ReactNode | (searchText: string) =\> ReactNode`

Label rendered inside the "Add new item" button. May be a static node or a callback that receives the current search text and returns a dynamic label (for example, `Add "${searchText}"`). When the callback returns `null` or `undefined`, the button is not rendered — use this to hide the affordance until the search text is valid (for example, a well-formed email). Defaults to `"Add new item"` when omitted but `onAddNewItem` is provided.

**clear** `\{ onClick: () =\> void; label?: string; disabled?: boolean \}`

Configures a "Clear" button rendered in the menu footer. Presence of this prop enables the button — omit to hide it. The menu closes automatically after `onClick` runs. See the [`SelectMenu` Props](#selectmenu-props) tab for full details.

**disableSearch** `boolean` (default: false)

Removes the search input from inside the menu. When true, the menu renders only the option list.

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

How to display the options menu.

**filter** `function | MatchSorterOptions`

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

* A function: `(options: SelectMenuOption[], searchValue: string) => SelectMenuOption[]` — receives all options and returns the filtered subset in the desired display order
* A [match-sorter options object](https://github.com/kentcdodds/match-sorter#options) to customize the default match-sorter behavior (e.g., which keys to match against, ranking strategy)

Before any search is performed, options appear in the order they are supplied. Default: Filters by `label` and `searchText` fields using match-sorter, with results ranked by match quality.

**groupSorter** `(a: SelectMenuGroupByValue, b: SelectMenuGroupByValue) =\> number`

Function to compare two group values for sorting. When provided, options are sorted by group using this comparator, then by match-sort order within each group. Ungrouped options appear last.

**groupToString** `(groupValue: SelectMenuGroupByValue) =\> string`

Function to convert group values to display labels. Only used with grouped options. `SelectMenuGroupByValue` is `string | number`.

**id** `string`

The id of the select menu.

**onAddNewItem** `(searchText: string) =\> void`

Click handler for the "Add new item" button. The button is only rendered when this prop is provided. Receives the current search text. The menu closes synchronously on click and the handler runs immediately after — typically opening a Dialog or Drawer to collect the new item's details. Consumers own overlay presentation, focus return, option list updates, and selection state. See the [Adding New Items](#adding-new-items) section for a full example.

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

Callback when the search value changes.

**pinned** `SelectMenuPinnedOptions`

Options to pin to the top of the list. Accepts the same section object format as `SelectMenu`'s `pinned` prop.

**searchPlaceholder** `string`

Placeholder text shown in the search input inside the menu.

**searchValue** `string`

Controlled search value. In most cases you do not need this.

**virtualize** `boolean` (default: false)

Enables windowed rendering for the dropdown list. Improves performance for large option sets by only rendering visible items.

**width** `number | string`

Width of the popover menu. Accepts a number (pixels) or a CSS string value.

**SelectMenuOption Type**

## `SelectMenuOption` Type

Options passed to or returned from `SelectMenu` components must conform to this shape. This is the same type as `SelectFieldOption`.

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

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

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

  /** Group key for grouped options (required when using lazy="group") */
  group?: string | number;

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

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

  /** Optional rich content configuration */
  content?: {
    title?: string;
    description?: string;
    chips?: Pick<ChipProps, "label" | "color" | "textWrap">[];
    avatar?: Pick<AvatarProps, "name" | "status" | "color" | "image">;
    icon?: Pick<IconProps, "svg" | "color">;
  };
};
```

### Content Properties

The `content` object supports the following optional properties:

* `title` — Replaces the `label` as the primary display text in the dropdown.
* `description` — Secondary text displayed below the title.
* `chips` — Array of chip configurations rendered below the title and description. Each chip accepts `label`, `color`, and `textWrap`.
* `avatar` — Renders an avatar to the left of the option text. Accepts `name`, `status`, `color`, and `image`.
* `icon` — Renders an icon to the right of the option text. Accepts `svg` and `color`.

### Example with Rich Content

```tsx theme={null}
const options: SelectMenuOption[] = [
  {
    id: 1,
    label: "John Doe",
    searchText: "john.doe@example.com",
    content: {
      title: "John Doe",
      description: "Engineering",
    },
  },
  {
    id: 2,
    label: "Jane Smith",
    searchText: "jane.smith@example.com",
    content: {
      title: "Jane Smith",
      description: "Design",
    },
  },
];
```

### Example with Disabled Options

```tsx theme={null}
const options: SelectMenuOption[] = [
  {
    id: 1,
    label: "Available Option",
  },
  {
    id: 2,
    label: "Unavailable Option",
    disabled: true,
  },
  {
    id: 3,
    label: "Another Available Option",
  },
];
```


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