# vue-ui-select

A production-grade Vue 3 select component — drop-in replacement for AngularJS `ui-select`.

Search, multi-select, tagging, grouping, keyboard navigation, ARIA accessibility, teleport, and four built-in themes.

**[Live Demo & Playground](https://vue-ui-select.vercel.app)**

## Why this exists

The AngularJS `ui-select` directive had a quality that's hard to find in modern component libraries: you could build complex select scenarios — multi-select, grouping, async search, tagging, custom rendering — **entirely in the template**, without writing a single extra line of JavaScript. Slots and props were expressive enough that your component code stayed clean and focused on business logic, never polluted with select-widget plumbing.

This library brings that same philosophy to Vue 3. The richer the slot and prop API, the less JavaScript you write. Localization, custom layouts, async data, tag creation — it's all configurable from the template. If you ever felt nostalgic about how productive `ui-select` made you, this is for you.

## Features

- **Single & multi-select** — strings, objects, or dictionaries
- **Type-ahead search** — client-side filtering with configurable search fields
- **Remote search** — `debounce` the `search` event and feed `items` from an API
- **Grouping** — `group-by` string property or function, with `group-filter`
- **Tagging** — create new items on the fly, with token separators
- **Keyboard navigation** — Arrow keys, Enter, Escape, Tab, Backspace
- **ARIA accessible** — combobox role, `aria-expanded`, `aria-activedescendant`, listbox/option roles, named group headers and tag controls, and a visible keyboard focus ring
- **Teleport** — `append-to-body` to escape overflow containers
- **Dropdown positioning** — auto, up, or down
- **4 built-in themes** — Tailwind CSS, Bootstrap, Select2, Selectize
- **Dark mode** — Tailwind theme supports `.dark` / `[data-theme="dark"]`
- **App-wide defaults** — set `searchEnabled`, `clearable`, `appendToBody`, `closeOnSelect` once via plugin options
- **Form-validator friendly** — dispatches native `input` / `blur` on the host so any DOM-event validator (oop-validator, VeeValidate, hand-rolled) works without coupling. `input` marks user edits only, and a field is not marked touched merely because its dropdown was opened
- **TypeScript** — full type definitions included
- **Composable architecture** — every feature is a standalone composable

## Installation

```bash
npm install vue-ui-select
```

## Upgrading to 2.0

Most applications need no code changes. Read the first section and skim the rest.

### The one you are most likely to notice

**A plugin-wide `clearable: true` now actually works.** The prop was declared in a way that stopped
it ever reaching the plugin default, so the setting did nothing. If you configured it, every select
that does not pass `clearable` itself will now show a × once it has a value. That may be a lot of
clear buttons appearing at once. If you would rather not have them, set `clearable: false` in the
plugin defaults — instances that pass the prop explicitly are unaffected.

`clearable` also works in multi-select now, where it previously did nothing. It clears every
unlocked tag at once.

### Vue 3.5 is now the minimum

The peer range was `^3.3.0` and is now `^3.5.0`. Vue 3.5 introduced `useId()`, which is the only
way to generate ids that match between a server render and its hydration; before it there was no
SSR-safe option at all. If you are on 3.3 or 3.4 and not server-rendering, 1.x continues to work.

### If you use an object (dictionary) source

Event payloads and every consumer callback now receive the **value** rather than the internal
`{ key, value }` wrapper. Drop the `.value`:

```vue
<!-- 1.x -->
:group-by="item => item.value.tier"
@select="e => save(e.item.value)"

<!-- 2.0 -->
:group-by="item => item.tier"
@select="e => save(e.item)"
```

`track-by` and `bind-property` are unchanged. Array sources are unaffected, with one exception: an
array whose items happen to have exactly the keys `key` and `value` used to be mistaken for a
dictionary wrapper and silently unwrapped. Such items now round-trip whole.

### Smaller behaviour changes

- **`@paste` on `<ui-select>` now reaches your handler.** It was previously declared as a component
  event, which stripped it from attribute fallthrough while never firing. If you have one, it will
  start running.
- **A multi-select no longer opens on focus.** Tabbing into it leaves it closed; clicking or typing
  opens it. This is what stopped the dropdown reopening every time the browser window regained
  focus.
- **The synthetic `input` event fires only for user edits**, matching a native control, which does
  not fire `input` when code assigns to `.value`. A validator that relied on it firing for
  programmatic writes needs another trigger.
- **Clearing respects `lock-choice`.** Locked items survive the × control, Backspace and `clear()`,
  and the `clear` payload lists only what was really removed.
- **Re-selecting an already-selected item emits nothing.** It previously emitted
  `update:modelValue` and `select` with an unchanged model.
- **A prefixed kebab alias is now usable.** `prefix: 'My'` registered `Myui-select`, which no
  template can write; it now registers `my-ui-select`.
- **Disabled selects leave the tab order**, and keyboard focus now draws a visible outline in all
  four themes.
- **`aria-multiselectable` moved from the combobox to the listbox**, where it is valid. The root
  also takes an `aria-label` from `placeholder` when you supply no label of your own.
- **A multi-select at its `limit` now disables the options it cannot take**, instead of ignoring
  clicks in silence, and emits `limit-reached` if something picks anyway. Already-selected options
  stay enabled. See [Limiting how many can be picked](#limiting-how-many-can-be-picked).

### If you style the internals

The minimum-length prompt now renders **inside** the choices container rather than replacing it, so
that `aria-controls` always names an element that exists. CSS treating
`.ui-select-choices__minimum-length` as a sibling of `.ui-select-choices` needs updating. The class,
the data attribute and the slot are unchanged.

### Types

`UiSelectEmits` gained `clear` and `limit-reached`, and lost `paste`. `UiSelectProps` gained
`debounce`, which existed at runtime but could not be typed. `ClearEventPayload` and
`LimitReachedPayload` are new exports.

---

## Quick Start

### Plugin (recommended)

```ts
import { createApp } from 'vue'
import UiSelectPlugin from 'vue-ui-select'
import 'vue-ui-select/themes/tailwind.css'

const app = createApp(App)
app.use(UiSelectPlugin)
app.mount('#app')
```

#### Plugin options

```ts
app.use(UiSelectPlugin, {
  theme: 'tailwind',        // 'tailwind' | 'bootstrap' | 'select2' | 'selectize'
  prefix: '',               // optional component name prefix (e.g. 'My' → MyUiSelect)
  defaults: {
    searchEnabled: false,   // turn search off everywhere by default
    clearable: true,        // show the clear (×) on every instance
    appendToBody: true,     // teleport every dropdown to <body>
    closeOnSelect: false,   // keep dropdown open after each pick
    removeSelected: false,  // keep the chosen option in the list, like the original ui-select
  },
  messages: {               // the default empty-state copy; see "No choice (empty state)"
    noOptions: 'No options',
    noResults: 'No results',
    noOtherOptions: 'No other options',
    allSelected: 'All options selected',
  },
})
```

Per-instance props always win: `<ui-select :search-enabled="true">` overrides the plugin default. Defaults are applied only when the prop is omitted, so existing code that doesn't pass these props will pick up the global value automatically.

### Individual imports

```ts
import { UiSelect, UiSelectMatch, UiSelectChoices } from 'vue-ui-select'
```

## Mini Tutorial

Everything is built with **three tags**. Think of them like a sandwich:

```html
<ui-select>            ← the wrapper (the bread)
  <ui-select-match>    ← what the user sees when something is picked (top slice)
  <ui-select-choices>  ← the dropdown list to pick from (bottom slice)
</ui-select>
```

That's it. Every feature in this library is just props and slots on these three tags.

---

### Step 1 — The simplest possible select

Pick one thing from a list of strings:

```vue
<template>
  <ui-select v-model="color">
    <ui-select-match placeholder="Pick a color..." />
    <ui-select-choices :items="['Red', 'Green', 'Blue']">
      <template #choice="{ item }">{{ item }}</template>
    </ui-select-choices>
  </ui-select>
</template>

<script setup>
import { ref } from 'vue'
const color = ref(null)
</script>
```

**What's happening:**
- `v-model="color"` — the selected value goes here
- `:items` — your list of options
- `#choice` — how each option looks in the dropdown

---

### Step 2 — Working with objects

Real apps use objects, not strings. Just tell the component which field is the ID:

```vue
<template>
  <ui-select v-model="person">
    <ui-select-match placeholder="Pick a person...">
      <template #default="{ selected }">{{ selected.name }}</template>
    </ui-select-match>
    <ui-select-choices :items="people" :track-by="'id'">
      <template #choice="{ item }">{{ item.name }}</template>
    </ui-select-choices>
  </ui-select>
</template>

<script setup>
import { ref } from 'vue'
const person = ref(null)
const people = [
  { id: 1, name: 'Alice' },
  { id: 2, name: 'Bob' },
  { id: 3, name: 'Charlie' },
]
</script>
```

**What changed:**
- `:track-by="'id'"` — tells the component how to compare objects
- `#default="{ selected }"` — controls how the *picked* item displays (not the dropdown — the input area)

---

### Step 3 — Add search

Just add `:search-fields` and you get type-ahead filtering for free:

```vue
<ui-select-choices :items="people" :track-by="'id'" :search-fields="['name']">
  <template #choice="{ item }">{{ item.name }}</template>
</ui-select-choices>
```

Want to search multiple fields? Pass them all in: `:search-fields="['name', 'email', 'city']"`

> Object items are only filtered by the fields you declare — with none declared they
> pass through untouched (that's what makes API-backed search work with zero config).
> Plain strings and numbers always match by value, no config needed.

---

### Step 4 — Multi-select

Add `:multiple="true"` to the wrapper. Now `v-model` becomes an **array**:

```vue
<template>
  <ui-select v-model="selected" :multiple="true">
    <ui-select-match placeholder="Add people...">
      <template #tag="{ item, removeItem }">
        {{ item.name }}
        <button @click="removeItem(item)">&times;</button>
      </template>
    </ui-select-match>
    <ui-select-choices :items="people" :track-by="'id'" :search-fields="['name']">
      <template #choice="{ item }">{{ item.name }}</template>
    </ui-select-choices>
  </ui-select>
</template>

<script setup>
import { ref } from 'vue'
const selected = ref([])
</script>
```

**What changed:**
- `:multiple="true"` — now you can pick many items
- `#tag` replaces `#default` — each selected item shows as a tag/chip
- `removeItem(item)` — call this to remove a tag

---

### Step 5 — Highlight search matches

Use the `highlighted()` function from the slot to bold the matching text:

```vue
<ui-select-choices :items="people" :track-by="'id'" :search-fields="['name']">
  <template #choice="{ item, search, highlighted }">
    <span v-html="highlighted(item.name, search)"></span>
  </template>
</ui-select-choices>
```

Type "ali" and you'll see "**Ali**ce" with the match bolded.

---

### Step 6 — Grouping

Group items by a property:

```vue
<ui-select-choices :items="people" :track-by="'id'" :group-by="'country'">
  <template #group-header="{ groupName }">🌍 {{ groupName }}</template>
  <template #choice="{ item }">{{ item.name }}</template>
</ui-select-choices>
```

---

### Step 7 — Checkbox multi-select

Show checkboxes next to each item — great for "pick many without closing the dropdown":

```vue
<ui-select v-model="selected" :multiple="true" :remove-selected="false" :close-on-select="false">
  <ui-select-match placeholder="Pick colors...">
    <template #tag="{ item, removeItem }">
      {{ item }} <button @click="removeItem(item)">&times;</button>
    </template>
  </ui-select-match>
  <ui-select-choices :items="['Red', 'Green', 'Blue']" :show-checkboxes="true">
    <template #choice="{ item }">{{ item }}</template>
  </ui-select-choices>
</ui-select>
```

**Key props for checkboxes:**
- `:show-checkboxes="true"` — render checkboxes
- `:remove-selected="false"` — keep checked items visible in the dropdown
- `:close-on-select="false"` — don't close after each pick

---

### Cheat sheet

| I want to... | What to add |
|---|---|
| Pick one item | `<ui-select v-model="val">` |
| Pick many items | add `:multiple="true"` |
| Search/filter | add `:search-fields="['name']"` on choices |
| Use objects | add `:track-by="'id'"` on choices |
| Bold search matches | use `highlighted(item.name, search)` in `#choice` slot |
| Group items | add `:group-by="'country'"` on choices |
| Checkboxes | add `:show-checkboxes="true"` on choices |
| Tagging (create new items) | add `:tagging="true"` on wrapper |
| Clear button | add `:clearable="true"` on wrapper |
| Empty state message | add `<ui-select-no-choice>No results</ui-select-no-choice>` |

## Usage

### Single select

```vue
<template>
  <ui-select v-model="selected" :clearable="true">
    <ui-select-match placeholder="Pick a person...">
      <template #default="{ selected }">{{ selected?.name }}</template>
    </ui-select-match>
    <ui-select-choices
      :items="people"
      :track-by="'id'"
      :search-fields="['name', 'email']"
    >
      <template #choice="{ item, search, highlighted }">
        <span v-html="highlighted(item.name, search)"></span>
      </template>
    </ui-select-choices>
  </ui-select>
</template>
```

### Multi-select

```vue
<template>
  <ui-select v-model="selected" :multiple="true" :clearable="true">
    <ui-select-match placeholder="Add people...">
      <template #tag="{ item, removeItem }">
        <span class="chip">
          {{ item.name }}
          <button @click="removeItem(item)">&times;</button>
        </span>
      </template>
    </ui-select-match>
    <ui-select-choices :items="people" :track-by="'id'" :search-fields="['name']">
      <template #choice="{ item }">{{ item.name }}</template>
    </ui-select-choices>
  </ui-select>
</template>
```

### Limiting how many can be picked

`limit` caps a multi-select. Once the cap is reached the remaining options render as disabled, so
the list explains itself instead of quietly ignoring clicks. Keyboard navigation skips them too.

Options already in the selection stay enabled — they are the way back down. Remove one and
everything becomes selectable again.

```vue
<ui-select v-model="chosen" :multiple="true" :limit="3" @limit-reached="onLimit">
  <ui-select-match placeholder="Pick up to three..." />
  <ui-select-choices :items="people" :track-by="'id'" :search-fields="['name']">
    <template #choice="{ item }">{{ item.name }}</template>
  </ui-select-choices>
</ui-select>
```

```js
function onLimit({ item, limit }) {
  // `item` is whatever was refused: one of your options, or — under tagging —
  // the raw text that would have become a tag.
  toast(`You can only pick ${limit}.`)
}
```

Style the refused rows with `.ui-select-choices__row--disabled`, the same class `disable-choice`
already uses.

`limit-reached` covers the cases a disabled row cannot. Clicking an option at the limit is
impossible, because the row is disabled, so the event does **not** fire for that. It fires when
something picks by other means:

- the exposed `select()` method;
- creating a tag — by Enter, by a token key, or by pasting text containing one. Tagging has no row
  to grey out, so the event is the only signal. It reports once per attempt, not once per token,
  and the text stays in the search box.

With tagging on, the "(new)" row also hides itself at the limit, for the same reason the options
grey out.

### Tagging

```vue
<ui-select v-model="tags" :multiple="true" :tagging="true" :tagging-tokens="[',']">
  <ui-select-match placeholder="Type to add tags...">
    <template #tag="{ item, removeItem }">
      {{ item }} <button @click="removeItem(item)">&times;</button>
    </template>
  </ui-select-match>
  <ui-select-choices :items="suggestions">
    <template #choice="{ item }">{{ item }}</template>
  </ui-select-choices>
</ui-select>
```

### Checkbox multi-select

```vue
<template>
  <ui-select v-model="selected" :multiple="true" :remove-selected="false" :close-on-select="false">
    <ui-select-match placeholder="Pick people...">
      <template #tag="{ item, removeItem }">
        {{ item.name }} <button @click="removeItem(item)">&times;</button>
      </template>
    </ui-select-match>
    <ui-select-choices :items="people" :track-by="'id'" :search-fields="['name']" :show-checkboxes="true">
      <template #choice="{ item }">{{ item.name }}</template>
    </ui-select-choices>
  </ui-select>
</template>
```

> **Tip:** Pair `:show-checkboxes="true"` with `:remove-selected="false"` and `:close-on-select="false"` so selected items stay visible with checked checkboxes and the dropdown remains open for multiple picks.

### Grouping

```vue
<ui-select-choices
  :items="people"
  :track-by="'id'"
  :group-by="'country'"
  :group-filter="['US', 'UK']"
  :search-fields="['name']"
>
  <template #group-header="{ groupName }">{{ groupName }}</template>
  <template #choice="{ item }">{{ item.name }}</template>
</ui-select-choices>
```

### Object source (dictionary)

Pass a plain object and each entry becomes a choice. The `choice` slot receives the **value** as
`item`, plus the `key` alongside it:

```vue
<ui-select-choices :items="{ r: 'Red', g: 'Green', b: 'Blue' }">
  <template #choice="{ item, key }">{{ key }}: {{ item }}</template>
</ui-select-choices>
```

`v-model` receives the value (`'Red'`). To bind the key instead, put `bind-property="key"` on
`<ui-select-choices>`:

```vue
<ui-select v-model="colour">           <!-- colour === 'r' -->
  <ui-select-choices :items="colours" bind-property="key" />
</ui-select>
```

**Everything you write receives the value, never the internal wrapper.** That holds for the event
payloads and for `group-by`, `disable-choice`, `sort-fn`, `filter-fn` and `lock-choice`:

```vue
<ui-select-choices
  :items="roles"
  :group-by="role => role.tier"          <!-- the value, not { key, value } -->
  :disable-choice="role => role.retired"
/>
```

The two exceptions are `track-by` and `bind-property`, which address the wrapper on purpose — that
is what makes `bind-property="key"` work.

### Append to body (teleport)

```vue
<ui-select v-model="val" :append-to-body="true">
  <!-- dropdown renders to document.body, escaping overflow containers -->
</ui-select>
```

A teleported dropdown is no longer inside the containers its control lives in, so it does three
things deliberately that it would otherwise get from the browser for free:

- It tracks the control as the page or any inner container scrolls, and it is positioned correctly
  whether `<body>` is static, positioned, or transformed, and in right-to-left layouts.
- It **hides itself while its control is scrolled out of a clipping container**, instead of
  floating over whatever now occupies that space. It reappears when the control does, and stays
  open throughout — the search term and the highlight survive.
- It forwards wheel scrolling it cannot use back to the containers the control belongs to, walking
  outwards past any that have reached their end, so the page never freezes while it is open.

### No choice (empty state)

An empty list always explains itself. With no `<ui-select-no-choice>` of your own, the dropdown
shows one of four messages, chosen by **why** the list is empty:

| Reason | Single mode | Multiple mode |
|--------|-------------|---------------|
| The source has no options | No options | No options |
| Nothing matched the search | No results | No results |
| Every match is already chosen (`remove-selected` hid it) | No other options | All options selected |

The third row is the one edit screens hit constantly: a select whose only option is already the
value. It used to open onto a blank panel. Change the copy once for the whole app through the
plugin's `messages` (see [Plugin options](#plugin-options)).

For markup, or per-instance text, supply the slot. It receives the same `reason`, plus the
trimmed `search` term and `multiple`:

```vue
<ui-select v-model="val">
  <ui-select-match placeholder="Select...">...</ui-select-match>
  <ui-select-choices :items="items">...</ui-select-choices>
  <ui-select-no-choice v-slot="{ reason, search }">
    <span v-if="reason === 'no-results'">Nothing matches “{{ search }}”</span>
    <span v-else-if="reason === 'all-selected'">That is the only option</span>
    <span v-else>Nothing to choose from</span>
  </ui-select-no-choice>
</ui-select>
```

The message never shows while `loading`, below `minimum-input-length` (the prompt shows instead),
or while a tagging "(new)" row is on offer, since each of those already says what is going on.

If you would rather the chosen option simply stayed in the list — which is what the original
AngularJS ui-select did in single mode, and what most other selects do — set `remove-selected`
to `false`, per instance or app-wide via `defaults.removeSelected`.

### Remote search (API-backed)

The rule that decides everything here: **`debounce` delays only the `search` event —
filtering is never debounced.** So local lists need nothing, and remote lists need the
four pieces below.

```vue
<template>
  <ui-select
    v-model="company"
    search-enabled
    :debounce="300"
    :loading="searching"
    @search="onSearch"
  >
    <ui-select-match placeholder="Search by company name or code">
      <template #default="{ selected }">{{ selected?.name ?? '' }}</template>
    </ui-select-match>

    <!-- No search-fields: object items with none declared are NOT re-filtered
         client-side, so the API's rows render exactly as returned. -->
    <ui-select-choices
      :items="companies"
      :track-by="'id'"
      :minimum-input-length="MIN_SEARCH_LENGTH"
    >
      <template #choice="{ item }">{{ item?.name }}</template>
    </ui-select-choices>

    <ui-select-no-choice>No companies found</ui-select-no-choice>
  </ui-select>
</template>

<script setup>
import { ref } from 'vue'

const MIN_SEARCH_LENGTH = 2

const company = ref(null)
const companies = ref([])
const searching = ref(false)

let latestSearchId = 0

async function onSearch(term) {
  if (!term || term.length < MIN_SEARCH_LENGTH) {
    companies.value = []
    return
  }

  const searchId = ++latestSearchId
  searching.value = true

  try {
    const { data } = await api.searchCompanies(term)
    // A newer search has already gone out — this response is stale.
    if (searchId !== latestSearchId) return
    companies.value = data ?? []
  } catch (error) {
    if (searchId !== latestSearchId) return
    companies.value = []
  } finally {
    if (searchId === latestSearchId) searching.value = false
  }
}
</script>
```

**Why no `search-fields` here?** The component only filters what you tell it to filter:
object items with no declared `search-fields` (and no `filter-fn`) pass through as-is,
because the assumption is that whoever supplied the items — your API — already filtered
them. Declare `search-fields` and you opt back into client-side filtering. One caveat:
**primitive items always filter by value**, so a remote list of plain strings still needs
`:filter-fn="() => true"` to keep server rows the term doesn't literally contain.

**Keep the request-id guard even with `debounce`.** Debouncing collapses one burst of
keystrokes into a single request; it does not order responses. Type `hil`, pause, type
`ton`, and two requests are in flight — the first can still land last.

**An empty term arrives immediately**, not after the delay: clearing the input or closing
the dropdown emits `search` with `''` straight away, which is your cue to drop the
results. The early return above handles it.

**Local search needs none of this** — give `<ui-select-choices>` a `search-fields` array
and filtering happens in the component. `debounce` is only worth adding offline if your
own `search` handler does something costly (writing a query param, analytics, a store
round-trip).

> **Coming from AngularJS?** `refresh` is the `search` event and `refresh-delay` is the
> `debounce` prop — which now sits on `<ui-select>`, next to the event it modifies.

## API Reference

### `<ui-select>` Props

Props marked † can also be set as plugin-wide defaults — see [Plugin options](#plugin-options).

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `v-model` | `any` | — | Selected value (object, primitive, or array for multi) |
| `multiple` | `boolean` | `false` | Enable multi-select mode |
| `disabled` | `boolean` | `false` | Disable the component |
| `clearable` † | `boolean` | `false` | Show the clear (×). In multi-select it clears every unlocked tag at once |
| `search-enabled` † | `boolean` | `true` | Enable search/filter |
| `reset-search-input` | `boolean` | `true` | Clear search on close |
| `close-on-select` † | `boolean` | `true` | Close dropdown after selection |
| `append-to-body` † | `boolean` | `false` | Teleport dropdown to `<body>` |
| `position` | `'auto' \| 'up' \| 'down'` | `'auto'` | Dropdown position |
| `theme` | `string` | `'tailwind'` | Theme name |
| `placeholder` | `string` | `''` | Placeholder text |
| `tagging` | `boolean \| Function` | `false` | Enable tagging, or provide a factory function |
| `tagging-label` | `string \| false` | `'(new)'` | Label for new tag row |
| `tagging-tokens` | `string[]` | `[',']` | Characters that trigger tag creation |
| `limit` | `number` | — | Max selections in multi mode. Once reached, the unselected options are disabled; see [Limiting how many can be picked](#limiting-how-many-can-be-picked) |
| `remove-selected` | `boolean` | `true` | Hide chosen items from the dropdown. Also settable app-wide via the plugin's `defaults.removeSelected`. The original AngularJS ui-select only did this in multiple mode; set it to `false` to match |
| `loading` | `boolean` | `false` | Show loading spinner |
| `debounce` | `number` | `0` | Milliseconds to wait after typing stops before emitting `search`. Filtering stays instant |
| `autofocus` | `boolean` | `false` | Focus on mount |
| `input-id` | `string` | — | Custom ID for the search input. See the labelling note below |
| `lock-choice` | `Function` | — | Predicate: locked items cannot be removed, by the tag ×, by Backspace, by `clear()`, or by the clear control |

#### Server-side rendering

The component renders on the server without special handling, and hydrates cleanly.

A multi-select always renders its search input, and that input carries a generated id. The id comes
from Vue's own `useId()`, which is deterministic per application, so the server and the client
agree. (This is why the peer range requires Vue 3.5: `useId()` does not exist before it, and there
is no SSR-safe id to fall back on.)

A single-select renders no generated id at all while its dropdown is closed, which is how the
server always renders it, so it is unaffected either way.

If you would rather pin the id yourself — to pair it with a `<label for>`, say — pass `input-id`
and it is used verbatim:

```vue
<ui-select v-model="countries" :multiple="true" input-id="country-select"> ... </ui-select>
```

#### Labelling a select

In **multi-select** the search input is always rendered, so `input-id` pairs with a
`<label for>` in the usual way.

In **single-select** the search input lives inside the dropdown and therefore only exists
while the dropdown is open. A `<label for="...">` pointing at it dangles whenever the select
is closed. Label the component itself instead:

```vue
<span id="country-label">Country</span>
<ui-select v-model="country" aria-labelledby="country-label"> ... </ui-select>
```

`aria-label` works too. With neither, the component falls back to using `placeholder` as the
accessible name, so a select always announces as something more useful than "combobox".

### `<ui-select>` Events

| Event | Payload | Description |
|-------|---------|-------------|
| `update:modelValue` | `any` | v-model update |
| `select` | `{ item, model }` | Item selected |
| `remove` | `{ item, model }` | Item removed — also fired by a clear (once per cleared item), so side state keyed to removals stays in sync |
| `clear` | `{ items, model }` | Selection cleared (× icon, Backspace, or the exposed `clear()` method); `items` is what was actually cleared, which excludes anything `lock-choice` protects |
| `limit-reached` | `{ item, limit }` | A pick was refused because `limit` was already reached — see below |
| `search` | `string` | Search text changed |
| `highlight` | `any` | Active option changed (keyboard navigation) |
| `open` | — | Dropdown opened |
| `close` | — | Dropdown closed |

In the `select`, `remove` and `clear` payloads, `model` is exactly the value `v-model` has just
received, and `item` / `items` are in the same shape — so an object (dictionary) source gives you
the value, not the internal `{ key, value }` wrapper. Re-selecting something already selected
changes nothing and therefore emits nothing.

### `<ui-select-match>` Slots

| Slot | Scope | Description |
|------|-------|-------------|
| `default` | `{ selected, search, isOpen }` | Single-mode selected display |
| `tag` | `{ item, index, remove, removeItem, isLocked }` | Multi-mode tag/chip. `remove` and `removeItem` are the same function; `item` is the resolved object even when `bind-property` is set |

### `<ui-select-choices>` Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `items` | `any[] \| Record<string, any>` | `[]` | Items source |
| `track-by` | `string \| Function` | — | Identity key/function |
| `bind-property` | `string` | — | Emit a nested property instead of full object |
| `search-fields` | `string[]` | `[]` | Fields to search. Object items with none declared are NOT filtered (strings/numbers always match by value) |
| `filter-fn` | `Function` | — | Custom filter function; overrides `search-fields` |
| `group-by` | `string \| Function` | — | Group by property or function |
| `group-filter` | `string[] \| Function` | — | Filter/reorder groups |
| `disable-choice` | `Function` | — | Predicate: disable specific items |
| `sort-fn` | `Function` | — | Custom sort function |
| `minimum-input-length` | `number` | `0` | Min chars before showing results, counted after trimming. The prompt shows from the moment the dropdown opens |
| `show-checkboxes` | `boolean` | `false` | Show inline checkboxes in dropdown choices |

### `<ui-select-choices>` Slots

| Slot | Scope | Description |
|------|-------|-------------|
| `choice` | `{ item, index, search, highlighted, isActive, isSelected, isDisabled }` | Item row. With an object (dictionary) source the scope also carries `key`, and `item` is the unwrapped value |
| `group-header` | `{ groupName }` | Group header |
| `tag-row` | `{ search, label }` | The "(new)" create-tag row shown for a novel term when tagging |
| `minimum-length` | `{ remaining, minimum }` | Localizable "type N more characters" message |

### `<ui-select-no-choice>` Slots

| Slot | Scope | Description |
|------|-------|-------------|
| `default` | `{ reason, search, multiple }` | Replaces the built-in empty-state message. `reason` is `'no-options'`, `'no-results'` or `'all-selected'`; `search` is the trimmed term |

## Themes

Import one CSS file:

```ts
// Tailwind (default)
import 'vue-ui-select/themes/tailwind.css'

// Bootstrap
import 'vue-ui-select/themes/bootstrap.css'

// Select2
import 'vue-ui-select/themes/select2.css'

// Selectize
import 'vue-ui-select/themes/selectize.css'
```

Set theme per-instance:

```vue
<ui-select theme="bootstrap" ...>
```

Or globally via plugin:

```ts
app.use(UiSelectPlugin, { theme: 'select2' })
```

### Dark mode (Tailwind)

Add `.dark` to `<html>` or use `[data-theme="dark"]`:

```html
<html class="dark">
```

## Form validation

`<ui-select>` dispatches native `input` and `blur` events on its root element, so it integrates with any DOM-event-based validator without coupling. The library knows nothing about specific validation packages — it just behaves like a native form control.

### With oop-validator

`v-required`, `v-minlength`, `v-pattern` and other directives drop directly onto `<ui-select>` the same way `ng-required` did in AngularJS:

```html
<form name="signup" v-submit="handleSubmit">
  <ui-select
    name="countryId"
    v-model="formData.countryId"
    v-required
  >
    <ui-select-match placeholder="Select a country...">
      <template #default="{ selected }">{{ selected?.name }}</template>
    </ui-select-match>
    <ui-select-choices
      :items="countries"
      track-by="id"
      bind-property="id"
      :search-fields="['name']"
    >
      <template #choice="{ item }">{{ item.name }}</template>
    </ui-select-choices>
  </ui-select>

  <div
    v-show="form.fields.value?.countryId?.$touched || form.$submitted.value"
    v-messages="form.fields.value?.countryId?.$error ?? {}"
  >
    <span v-message="'required'">Please pick a country.</span>
  </div>
</form>
```

```ts
import { useForm } from 'oop-validator/vue'

const formData = reactive({ countryId: null })
const form = useForm('signup', formData)

function handleSubmit() {
  // form.$valid is true here
}
```

### Custom rules for multi-select

oop-validator's built-in `v-minlength` only works on strings — register a custom rule that implements `IValidationRule` for "pick at least N":

```ts
import { IValidationRule } from 'oop-validator'

class MinSelectionsRule extends IValidationRule {
  private min = 1
  private msg = ''
  constructor(min = 1) { super(); this.min = min }
  isValid(value: unknown): [boolean, string] {
    const arr = value == null ? [] : value
    if (!Array.isArray(arr)) return [false, 'Value must be a list.']
    const ok = arr.length >= this.min
    return [ok, ok ? '' : (this.msg || `Pick at least ${this.min}.`)]
  }
  isMatch(type: string) { return type.toLowerCase() === 'minselections' }
  setParams(params: { length?: number }) {
    if (params?.length != null) this.min = params.length
  }
  setErrorMessage(message: string) { this.msg = message }
}

const rule = new MinSelectionsRule(2)
rule.setErrorMessage('Pick at least 2 languages.')
form.registerRule('languages', 'minSelections', rule)
//                  ↑
//                  rule scoped to this field only — does not bleed to other fields on the same form
```

See the [Form Validation page](https://vue-ui-select.vercel.app/form-validation) for a full working demo.

### Validation styling

When a validator sets `v-invalid` AND `v-touched` on `<ui-select>` (or on a native `<input>` / `<textarea>` / `<select>` inside your forms), the theme CSS shows a red border. The classes are inert until a validator activates them, so consumers not using validation see no styling difference.

Per-rule classes (`v-invalid-required`, `v-invalid-minlength`, etc.) are also set on the host element — useful for targeting specific failure states in custom CSS.

**Multi-theme note:** the theme CSS files all define rules for native `<input>` / `<textarea>` / `<select>` (using each theme's red palette). If you import more than one theme in the same app, the last-imported theme's red wins on native form controls due to CSS source order. Most projects only need one theme; if you need finer control, import the theme you actually use and override `.v-invalid.v-touched` colors in your own stylesheet.

### Validator-agnostic

Nothing above is specific to oop-validator — the integration is just two native DOM events (`input`, `blur`) plus CSS class names (`v-invalid`, `v-touched`, etc.). Any validator that observes those events and toggles those classes (VeeValidate with a custom adapter, hand-rolled, etc.) works the same way. `vue-ui-select` has zero runtime dependency on any validation library.

## Migration from AngularJS ui-select

| AngularJS | Vue 3 |
|-----------|-------|
| `<ui-select>` | `<ui-select>` |
| `<ui-select-match>` | `<ui-select-match>` |
| `<ui-select-choices>` | `<ui-select-choices>` |
| `<ui-select-no-choice>` | `<ui-select-no-choice>` |
| `ng-model` | `v-model` |
| `repeat="item in items"` | `:items="items"` |
| `group-by="'prop'"` | `:group-by="'prop'"` |
| `group-filter` | `:group-filter` |
| `track by item.id` | `:track-by="'id'"` |
| `$select.search` | `search` slot prop |
| `highlight` filter | `highlighted()` slot function |
| `tagging` | `:tagging="true"` or `:tagging="factory"` |
| `tagging-tokens` | `:tagging-tokens="[',']"` |
| `close-on-select` | `:close-on-select="false"` |
| `append-to-body` | `:append-to-body="true"` |
| `theme` attribute | `theme` prop |
| `refresh="load($select.search)"` | `@search="load"` |
| `refresh-delay="300"` | `:debounce="300"` on `<ui-select>` |

## Development

```bash
# Install dependencies
npm install

# Start playground
npm run dev

# Type check
npm run typecheck

# Run unit tests
npm test

# Run E2E tests
npm run test:e2e

# Build library
npm run build
```

## License

MIT
