Source: https://monarchui.dev/components/entity-picker/

Components · Forms

# EntityPicker

Pick one or several records (projects, contacts, teams) from a searchable list in a popover.

@rookcom/monarch-react

Component

## Project with groups, clear option and footer

## Usage examples

### Multiple with create

### Chip trigger

## Purpose

The "click the field, search, pick a record" pattern most products end up rebuilding: a trigger (a field-style button or the product’s own chip) opens a popover with a search box and a listbox. The product injects the records, search, rendering and creation; EntityPicker owns the trigger, popover, keyboard and selection model.

### Use when

- Picking a project, contact, company, team, status or space from a list that may be long or server-searched.
- The value is shown as a compact chip or field and edited in place (issue properties, ticket sidebars).
- Several records, with the list staying open while toggling (`multiple`).

### Avoid when

- A short fixed list with no search: use Select.
- People: use AssigneePicker, which adds avatars, "you" and "Unassigned".
- Typing straight into a form field: use Combobox.

## Design principles

- No product data in Monarch: records arrive as `options` or from `onSearch`, and rich rows come from `media`, `description`, `hint` or `renderOption`.
- One picker for every record type, so keyboard behaviour and accessibility are the same everywhere.
- Prefix matches rank first (`sortByMatch`, on by default), so "in" finds India before Argentina.
- Single pick closes and returns focus to the trigger; multiple keeps the list open.

## API reference

```tsx
import { EntityPicker } from '@rookcom/monarch-react';
```

| Export | Kind | Rendering | Root class |
| --- | --- | --- | --- |
| `EntityPicker` | component | client | `.mn-entity-picker-` |

Forwards its ref to the trigger button. Native button attributes go to the default trigger.

The panel is a Radix Popover (`role="dialog"`) containing an inline Combobox and an optional footer.

Shares option, message and search types with Combobox (`ComboboxOption`, `ComboboxMessages`, `ComboboxSearchContext`).

Generated from the TypeScript declarations of `packages/react/src/components/forms/entity-picker`.

## Properties

`trigger` replaces the default trigger; it must accept a ref and name itself, with its visible text in the name (for example `aria-label="WEB: project Website refresh"`).

`footer` may be a function of `{ close, query }`, for actions such as "Create “query”" or "Manage projects".

### `EntityPicker`

Pick a record (a project, contact, company, team…) from a searchable list in a popover. The product supplies the records, search, rendering and creation; EntityPicker supplies the trigger, the popover, keyboard support and the single or multiple selection model. Built on Combobox (`inline`).

| Prop | Type | Description |
| --- | --- | --- |
| `align` | `"center" \| "start" \| "end"` | Popover alignment against the trigger. Default `start`. |
| `className` | `string` |  |
| `clearOption` | `ComboboxClearOption` | An option at the top that clears the selection, such as "Unassigned" or "No project". |
| `closeOnSelect` | `boolean` | Close after a pick. Default: true, or false with `multiple`. |
| `contentClassName` | `string` | Class on the popover panel. |
| `defaultOpen` | `boolean` |  |
| `defaultValue` | `string \| null` |  |
| `defaultValues` | `string[]` |  |
| `disabled` | `boolean` |  |
| `emptyMessage` | `ReactNode` | Shown when nothing matches. |
| `filter` | `((option: ComboboxOption, query: string) => boolean)` | Custom filter for static `options`. Default: label, hint and keywords. |
| `footer` | `ReactNode \| ((context: EntityPickerFooterContext) => ReactNode)` | Content under the list, such as "Manage projects". A function gets `close` and the query. |
| `id` | `string` |  |
| `invalid` | `boolean` |  |
| `limit` | `number` | Render at most this many matches, with a message to keep typing. |
| `loading` | `boolean` | Shown while records load. |
| `messages` | `Partial<ComboboxMessages>` | Interface text, for translation. |
| `minSearchLength` | `number` | Shortest query `onSearch` runs for. Default 0. |
| `multiple` | `boolean` | Pick several records; the list stays open after each pick. |
| `onCreate` | `((query: string) => ComboboxOption \| null \| undefined \| Promise<ComboboxOption \| null \| undefined>)` | Adds a "Create “query”" option; return the new record to select it. |
| `onOpenChange` | `((open: boolean) => void)` |  |
| `onSearch` | `((query: string, context: ComboboxSearchContext) => Promise<ComboboxOption[]>)` | Load records from a server; see Combobox `onSearch`. Debounced, aborted when superseded. |
| `onValueChange` | `((value: string \| null) => void)` |  |
| `onValuesChange` | `((values: string[]) => void)` |  |
| `open` | `boolean` | Whether the picker is open (controlled). |
| `options` | `ComboboxOption[]` | The records to pick from. With `onSearch`, shown until the first search answers. |
| `placeholder` | `string` | Trigger text when nothing is selected. Default "Select…". |
| `renderOption` | `((option: ComboboxOption, state: ComboboxOptionState) => ReactNode)` | Custom option content. Default: media, label, description, hint and a check mark. |
| `renderValue` | `((selected: ComboboxOption[]) => ReactNode)` | Content of the default trigger for the current selection. Default: media and label, or "first +N". |
| `searchDebounce` | `number` | Milliseconds to wait after typing before `onSearch`. Default 250. |
| `searchLabel` | `string` | Accessible name of the search box. Default: `searchPlaceholder`. |
| `searchPlaceholder` | `string` | Placeholder of the search box. Default "Search…". |
| `size` | `"sm" \| "md" \| "lg"` |  |
| `sortByMatch` | `boolean` | Rank matches whose label starts with the query first. Default true. |
| `trigger` | `ReactElement<unknown, string \| import("/home/runner/work/monarch/monarch/node_modules/@types/react/index").JSXElementConstructor<any>>` | A custom trigger element (a chip, an icon button). It must accept a ref and name itself, for example `aria-label="Status: Open"`. Default: a field-style button. |
| `value` | `string \| null` | Selected record id (controlled). Single-select only. |
| `valueOptions` | `ComboboxOption[]` | Records for the current value(s) when they may not be in `options` (async lists). Used for labels. |
| `values` | `string[]` |  |

## Variants

| Prop | Values | Description |
| --- | --- | --- |
| `size` | `'sm'``'md'``'lg'` | Trigger height from `--mn-control-*`; the search row uses sm or md. |
| `multiple` | `'false'``'true'` | One record, or several shown as "first +N". |
| `align` | `'start'``'center'``'end'` | Popover alignment against the trigger. |

## States

| State | Exposed as | Behaviour |
| --- | --- | --- |
| Placeholder | `data-placeholder` | Nothing selected: the placeholder in muted text. |
| Open | `aria-expanded="true" on the trigger` | The popover with search and list is shown. |
| Selected | `aria-selected="true"` | Check mark on chosen records. |
| Loading / error / empty | `role="status"` | From Combobox: "Loading…", a retry after a failed search, or `emptyMessage`. |
| Invalid | `data-invalid, aria-invalid` | Danger border, from `invalid` or the Field. |
| Disabled | `disabled` | The trigger cannot open the picker. |

## Interaction behaviour

- Clicking the trigger (or Enter / Space on it) opens the popover and focuses the search box.
- Typing filters (or calls `onSearch` after the debounce); arrows move, Enter or a click picks.
- Single: picking closes the popover and returns focus to the trigger. Multiple: picking toggles and the list stays open.
- `clearOption` adds a first option such as "No project"; `onCreate` adds "Create “text”".
- Escape or clicking outside closes without changing the value.

## Keyboard support

| Keys | Action |
| --- | --- |
| Enter / Space / ArrowDown (trigger) | Opens the picker. |
| ArrowDown / ArrowUp | Moves the active record. |
| PageDown / PageUp, Home / End | Moves ten, or to the first or last record. |
| Enter | Picks or toggles the active record. |
| Escape | Closes and returns focus to the trigger. |
| Tab | Moves to the footer actions, if any. |

## Accessibility

Role

select-only combobox (the trigger) opening a dialog that holds a search combobox and a listbox

Accessible name

The Field label or

aria-label

; the selected value is the trigger’s content.

- The default trigger follows the APG select-only combobox: `role="combobox"`, named by its label, with `aria-expanded`, `aria-haspopup="dialog"` and `aria-controls`; its text is the value.
- The popover is a named dialog; focus moves into the search box and back to the trigger on close.
- The search box is an APG combobox with `aria-activedescendant`; groups are labelled `role="group"`.
- With `multiple` the listbox is `aria-multiselectable`.

## Responsive behaviour

- The popover is at least 16rem wide (or the trigger width) and never wider than the viewport minus 2rem.
- The trigger truncates long labels with an ellipsis; the list scrolls within 18rem.

## Theme support

- Trigger, panel and options use semantic tokens shared with Input and the menu styles; light, dark and more-contrast themes apply.
- The popover entrance animation respects reduced motion.

## Do and don't

### Do

- Use `onSearch` with the signal for server-backed records; pass `valueOptions` for selected records not in the results.
- Put management actions ("Manage projects", "Invite people") in the footer.
- Name a custom trigger with its value and keep its visible text in the name, such as "Open: status".

### Don't

- Do not build a second picker with checkboxes in a list; use `multiple`.
- Do not put product fetching inside a wrapper that refetches on every render; memoise or cache in the product.

## Related components

- [Combobox](https://monarchui.dev/components/combobox/): The search and list it is built on; use directly for an input-style field.
- [AssigneePicker](https://monarchui.dev/components/assignee-picker/): People preset with avatars.
- [Select](https://monarchui.dev/components/select/): Short lists without search.
- [Popover](https://monarchui.dev/components/popover/): The floating panel.

## Dependencies

| Kind | Depends on |
| --- | --- |
| npm packages | `@radix-ui/react-popover``react` |
| Monarch components | [Combobox](https://monarchui.dev/components/combobox/), [Field](https://monarchui.dev/components/form-field/) |

The trigger uses `.mn-input`; the panel uses `.mn-menu`.

## Testing coverage

- Opens from a labelled trigger, focuses the search, picks with Enter, closes and refocuses the trigger.
- Groups and prefix ranking; Escape closes without a change.
- Multiple stays open, toggles and summarises as "first +N".
- Clear option, footer with close and query, custom trigger.
- Async search plus create keeps the picked label; disabled and invalid from Field.

| Check | Covered |
| --- | --- |
| Axe in jsdom | Yes |
| Server rendering | Yes |
| Browser suites | Visual baselines: /__gallery/entity-picker (closed and open); Cross-browser axe: /components/entity-picker |

Tests: `packages/react/src/components/forms/entity-picker`

## All example code

### Project with groups, clear option and footer

```tsx
import { useState } from 'react';
import { Button, EntityPicker, Field, FieldLabel } from '@rookcom/monarch-react';
import type { ComboboxOption } from '@rookcom/monarch-react';

// BoardIcon, GridIcon, ListIcon: demo icons from the docs site; use your own.

const makeProjects = (): ComboboxOption[] => [
  {
    value: 'website',
    label: 'Website refresh',
    hint: 'WEB',
    group: 'Active',
    media: <BoardIcon />,
  },
  {
    value: 'mobile',
    label: 'Mobile onboarding',
    hint: 'MOB',
    group: 'Active',
    media: <ListIcon />,
  },
  {
    value: 'billing',
    label: 'Billing migration',
    hint: 'BIL',
    group: 'Active',
    media: <GridIcon />,
  },
  {
    value: 'research',
    label: 'Customer research',
    hint: 'RES',
    group: 'Paused',
    description: 'Paused until next quarter',
    media: <ListIcon />,
  },
];

function ProjectDemo() {
  const [projects] = useState(makeProjects);
  const [value, setValue] = useState<string | null>('mobile');
  return (
    <Field style={{ width: '100%', maxWidth: 300 }}>
      <FieldLabel>Project</FieldLabel>
      <EntityPicker
        options={projects}
        value={value}
        onValueChange={setValue}
        placeholder="Choose a project"
        clearOption={{ label: 'No project' }}
        footer={({ close }) => (
          <Button variant="ghost" size="sm" block onClick={close}>
            Manage projects
          </Button>
        )}
      />
    </Field>
  );
}

<ProjectDemo />
```

### Multiple with create

```tsx
import { useState } from 'react';
import { EntityPicker, Field, FieldLabel } from '@rookcom/monarch-react';
import type { ComboboxOption } from '@rookcom/monarch-react';

const labels: ComboboxOption[] = [
  { value: 'bug', label: 'Bug' },
  { value: 'feature', label: 'Feature' },
  { value: 'docs', label: 'Documentation' },
  { value: 'perf', label: 'Performance' },
];

function LabelsDemo() {
  const [values, setValues] = useState(['bug', 'perf']);
  const [options, setOptions] = useState(labels);
  return (
    <Field style={{ width: '100%', maxWidth: 300 }}>
      <FieldLabel>Labels</FieldLabel>
      <EntityPicker
        multiple
        options={options}
        values={values}
        onValuesChange={setValues}
        placeholder="Add labels"
        onCreate={(q) => {
          const option = { value: q.toLowerCase(), label: q };
          setOptions((list) => [...list, option]);
          return option;
        }}
      />
    </Field>
  );
}

<LabelsDemo />
```

### Chip trigger

```tsx
import { useState } from 'react';
import { Button, EntityPicker } from '@rookcom/monarch-react';
import type { ComboboxOption } from '@rookcom/monarch-react';

// BoardIcon, GridIcon, ListIcon: demo icons from the docs site; use your own.

const makeProjects = (): ComboboxOption[] => [
  {
    value: 'website',
    label: 'Website refresh',
    hint: 'WEB',
    group: 'Active',
    media: <BoardIcon />,
  },
  {
    value: 'mobile',
    label: 'Mobile onboarding',
    hint: 'MOB',
    group: 'Active',
    media: <ListIcon />,
  },
  {
    value: 'billing',
    label: 'Billing migration',
    hint: 'BIL',
    group: 'Active',
    media: <GridIcon />,
  },
  {
    value: 'research',
    label: 'Customer research',
    hint: 'RES',
    group: 'Paused',
    description: 'Paused until next quarter',
    media: <ListIcon />,
  },
];

function ChipDemo() {
  const [projects] = useState(makeProjects);
  const [value, setValue] = useState<string | null>('website');
  const current = projects.find((p) => p.value === value);
  return (
    <EntityPicker
      options={projects}
      value={value}
      onValueChange={setValue}
      size="sm"
      trigger={
        <Button
          size="xs"
          aria-label={`${current?.hint ?? 'None'}: project ${current?.label ?? 'none'}`}
        >
          {current?.hint ?? 'None'}
        </Button>
      }
    />
  );
}

<ChipDemo />
```
