> ## 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 Edit Mode

> Guide for adopting the updated select and multiselect edit modes in editable DataTable cells.

export const VersionStatus = ({version}) => {
  const isUnreleased = version === "unreleased";
  return <Badge color={isUnreleased ? "orange" : "green"}>
      {isUnreleased ? "Unreleased" : `v${version}`}
    </Badge>;
};

<VersionStatus version="2.7.1" />

<Note>
  This guide covers changes to the **beta** DataTable component. These APIs may continue to evolve before the stable release.
</Note>

## Overview

The `select` and `multiselect` edit modes have been updated to use `SelectMenuSync` and `MultiSelectMenuSync` internally, replacing the previous `Menu` and `Popover+SearchField+ListView` implementations.

The `options` shape and `onChange` signature for both modes have changed to align with how `SelectMenu` and `MultiSelectMenu` work elsewhere in the design system.

## Design Rationale

### Why replace Menu and Popover with SelectMenu?

The previous implementations used basic primitives that lacked features available in the `SelectMenu` and `MultiSelectMenu` components:

* No built-in search field in the select dropdown
* No support for disabled options
* Inconsistent keyboard navigation compared to other select experiences in the product
* Separate codepaths to maintain for the same conceptual UI

By delegating to `SelectMenuSync` and `MultiSelectMenuSync`, the DataTable's select cells gain all of these features automatically, and the behavior is now consistent with select inputs elsewhere.

### Why option objects instead of raw primitives?

The previous `options` shape used `{ value, label }` where `value` was typed as the column's data type. This created a tight coupling between options and row data types, and required extra mapping logic in `onChange`.

The new shape uses `{ id, label }` — the same shape as `SelectMenuOption` and `MultiSelectMenuOption` — which aligns with how options are represented across the design system and eliminates the need for type gymnastics.

## Migration Guide

### Select edit mode

**Before:**

```tsx theme={null}
createColumn("status", {
  headerLabel: "Status",
  editConfig: {
    mode: "select",
    options: [
      { value: "open", label: "Open" },
      { value: "closed", label: "Closed" },
    ],
    onChange: (value, rowId) => {
      // value is "open" | "closed" (raw primitive)
      updateStatus(value, rowId);
    },
  },
});
```

**After:**

```tsx theme={null}
createColumn("status", {
  headerLabel: "Status",
  editConfig: {
    mode: "select",
    options: [
      { id: "open", label: "Open" },
      { id: "closed", label: "Closed" },
    ],
    onChange: (option, rowId) => {
      // option is SelectMenuOption | null
      if (option) updateStatus(String(option.id), rowId);
    },
  },
});
```

### Multiselect edit mode

**Before:**

```tsx theme={null}
createColumn("tags", {
  headerLabel: "Tags",
  editConfig: {
    mode: "multiselect",
    options: [
      { value: "frontend", label: "Frontend" },
      { value: "backend", label: "Backend" },
    ],
    onChange: (value, rowId) => {
      // value is string[] (raw primitives)
      updateTags(value, rowId);
    },
  },
});
```

**After:**

```tsx theme={null}
createColumn("tags", {
  headerLabel: "Tags",
  editConfig: {
    mode: "multiselect",
    options: [
      { id: "frontend", label: "Frontend" },
      { id: "backend", label: "Backend" },
    ],
    onChange: (options, rowId) => {
      // options is MultiSelectMenuOption[]
      updateTags(options.map((o) => String(o.id)), rowId);
    },
  },
});
```

### Live selection in multiselect

The previous multiselect cell buffered selections and only committed on Tab or F2. The new implementation calls `onChange` live with each selection toggle, matching how `MultiSelectField` works in forms. Pressing Escape closes the popover without reverting — the last `onChange` call reflects the committed state.

## Breaking Changes

<Warning>
  The `options` shape and `onChange` signature for `select` and `multiselect` edit modes have changed. Existing usage of these modes must be updated.
</Warning>

The following changes are **breaking**:

* **`SelectEditConfig.options`** — Changed from `{ value: T[K]; label: string }[]` to `SelectMenuOption[]` (uses `id` instead of `value`)
* **`SelectEditConfig.onChange`** — Changed from `(value: T[K], rowId: string) => void` to `(option: SelectMenuOption | null, rowId: string) => void`
* **`MultiselectEditConfig.options`** — Changed from `{ value: ArrayElement<T[K]>; label: string }[]` to `MultiSelectMenuOption[]` (uses `id` instead of `value`)
* **`MultiselectEditConfig.onChange`** — Changed from `(value: T[K], rowId: string) => void` to `(options: MultiSelectMenuOption[], rowId: string) => void`
* **Multiselect commit behavior** — `onChange` is now called live on each selection toggle rather than only on Tab or F2

### Why breaking?

Since DataTable is a beta component, we chose to align the API with the rest of the design system rather than maintain backward compatibility. The option object shape (`{ id, label }`) is consistent with every other select experience in Anvil2, and the `onChange` signatures match those of `SelectMenu` and `MultiSelectMenu`.

## Menu Customization

Both `select` and `multiselect` edit configs accept most props from `SelectMenuSync` and `MultiSelectMenuSync` to customize the underlying menu behavior. The only excluded props are those the DataTable cell manages itself (`trigger`, `label`, `value`, `onSelectedOptionChange` / `onSelectedOptionsChange`, `options`, `onMenuKeyDown`, `onImplicitClose`, `onExplicitClose`).

Common customizations:

```tsx theme={null}
createColumn("status", {
  headerLabel: "Status",
  editConfig: {
    mode: "select",
    options: [...],
    onChange: (option, rowId) => { ... },
    disableSearch: true,             // Hide the search field
    searchPlaceholder: "Find status...",
    width: 240,                      // Popover width in pixels (default: 320)
    displayMenuAs: "popover",        // Force popover on mobile too (default: "auto")
    virtualize: true,                // Enable windowed rendering for large option lists
    pinned: { ... },                 // Pin options to the top of the list
    filter: myCustomFilterFn,        // Custom client-side filter/sort logic
    groupToString: (g) => String(g), // Label formatter for grouped options
  },
});
```

For `mode: "multiselect"`, the `selectAll` and `selectFiltered` props are also available to add bulk-selection controls:

```tsx theme={null}
createColumn("tags", {
  headerLabel: "Tags",
  editConfig: {
    mode: "multiselect",
    options: [...],
    onChange: (options, rowId) => { ... },
    selectAll: true,          // Adds a "Select All" option at the top
    selectFiltered: true,     // Adds a "Select Filtered" option when searching
  },
});
```

When omitted, defaults are `displayMenuAs: "auto"` (dialog on mobile, popover on desktop) and `width: 320`.

## Async Select and Multiselect

Two new async edit modes are available: `"select-async"` and `"multiselect-async"`. These use `SelectMenu` and `MultiSelectMenu` directly (not the Sync variants) and accept a `loadOptions` function instead of a static `options` array.

### When to use async modes

* Options are fetched from an API rather than known at render time
* Different rows need different option sets (per-row async loading)
* Option lists are large enough to benefit from server-side filtering

### `loadOptions` and row context

The `loadOptions` function receives a `context` argument as its last parameter containing `{ row, rowId }`. This enables per-row option loading without any additional API surface:

```tsx theme={null}
// Column-level async (context unused):
createColumn("status", {
  headerLabel: "Status",
  editConfig: {
    mode: "select-async",
    loadOptions: (search) => api.fetchStatuses(search),
    onChange: (option, rowId) => {
      if (option) updateStatus(String(option.id), rowId);
    },
  },
});

// Per-row async (context used):
createColumn("status", {
  headerLabel: "Status",
  editConfig: {
    mode: "select-async",
    loadOptions: (search, { row }) => api.fetchAllowedStatuses(row.jobType, search),
    onChange: (option, rowId) => {
      if (option) updateStatus(String(option.id), rowId);
    },
  },
});
```

### Lazy variants

All four `SelectMenu` lazy variants are supported. Set `lazy` to choose the pagination strategy:

```tsx theme={null}
// Page-based pagination
createColumn("status", {
  headerLabel: "Status",
  editConfig: {
    mode: "select-async",
    lazy: "page",
    lazyOptions: { pageSize: 20 },
    loadOptions: (search, page, pageSize, { row }) =>
      api.fetchStatuses(row.region, search, page, pageSize),
    onChange: (option, rowId) => { ... },
  },
});
```

| `lazy` value      | Loader signature                          |
| ----------------- | ----------------------------------------- |
| `false` (default) | `(search, context)`                       |
| `"page"`          | `(search, pageNumber, pageSize, context)` |
| `"offset"`        | `(search, offset, limit, context)`        |
| `"group"`         | `(search, previousGroupKey, context)`     |

### Cache behavior

By default, caching is disabled (`cache={{ enabled: false }}`) to prevent cross-row cache pollution when `loadOptions` uses row context. To enable caching (for column-level loaders that don't depend on row data), pass `cache` explicitly:

```tsx theme={null}
editConfig: {
  mode: "select-async",
  cache: { enabled: true },
  loadOptions: (search) => api.fetchStatuses(search),
  onChange: (option, rowId) => { ... },
},
```

### Multiselect async value contract

Unlike `mode: "multiselect"` (which stores an array of IDs), `mode: "multiselect-async"` requires the cell value to be `MultiSelectMenuOption[]` — full option objects. The `onChange` callback receives the full objects and the consumer stores them wholesale:

```tsx theme={null}
// Row data type must have the field typed as MultiSelectMenuOption[]
type RowData = {
  id: string;
  tags: MultiSelectMenuOption[];
};

createColumn("tags", {
  headerLabel: "Tags",
  editConfig: {
    mode: "multiselect-async",
    loadOptions: (search) => api.searchTags(search),
    onChange: (options, rowId) => {
      // options is MultiSelectMenuOption[] — store directly
      updateTags(options, rowId);
    },
  },
});
```

This eliminates the async reconciliation problem (no need to reconstruct full option objects from IDs when the option list isn't yet loaded).

## New Exports

The following types are now used by these edit modes and are exported from `@servicetitan/anvil2/beta`:

* `SelectMenuOption` — Option type for `mode: "select"` and `mode: "select-async"` columns
* `MultiSelectMenuOption` — Option type for `mode: "multiselect"` and `mode: "multiselect-async"` columns
* `AsyncCellContext` — Context type passed to `loadOptions` in async edit configs (`{ row: T; rowId: string }`)
* `SelectAsyncCellEagerLoader` — Loader type for `mode: "select-async"` with `lazy: false`
* `SelectAsyncCellPageLoader` — Loader type for `mode: "select-async"` with `lazy: "page"`
* `SelectAsyncCellOffsetLoader` — Loader type for `mode: "select-async"` with `lazy: "offset"`
* `SelectAsyncCellGroupLoader` — Loader type for `mode: "select-async"` with `lazy: "group"`
* `MultiselectAsyncCellEagerLoader` — Loader type for `mode: "multiselect-async"` with `lazy: false`
* `MultiselectAsyncCellPageLoader` — Loader type for `mode: "multiselect-async"` with `lazy: "page"`
* `MultiselectAsyncCellOffsetLoader` — Loader type for `mode: "multiselect-async"` with `lazy: "offset"`
* `MultiselectAsyncCellGroupLoader` — Loader type for `mode: "multiselect-async"` with `lazy: "group"`
