# Combobox

A filterable combobox whose input is the control: **an always-visible editable input** shows the selected label, typing filters options in real time, and `value` takes `option.value` on selection. Multi-select is built in via `multiple`.

> **Choosing among the three (select / combobox / auto-complete)**
>
> | Component | Value source | Input form | One-line positioning |
> | --- | --- | --- | --- |
> | `oas-select` | Must come from the option set | Button-triggered; no always-visible input | The default form of constrained choice (multiple/create/remote full suite) |
> | `oas-combobox` | Must come from the option set | Input is the control; typing only filters | Always-visible-input constrained choice (single + multiple) |
> | `oas-auto-complete` | **Any free text** | Input is the control | Suggestion input (value need not come from options) |
>
> combobox keeps the pure-filtering semantics of "value always from options": multiple is built in (`multiple`, mirroring the `oas-select` multiple contract); **no** allow-create (use `oas-select allow-create` or `oas-dynamic-tags` for creation, `oas-auto-complete` for free text) — no duplication with existing capabilities in the library.

## Basic Usage

<DemoBlock title="Basic usage">
  <oas-combobox placeholder="Type or select" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"},{"label":"Strawberry","value":"strawberry"}]'></oas-combobox>
</DemoBlock>

Click/focus to open the dropdown; typing filters in real time (`filterable` is enabled by default, substring-matching the label); `↑`/`↓` move the highlight, `Enter` selects, `Esc` closes.

## Preset Value (controlled)

<DemoBlock title="Preset value (controlled)">
  <oas-combobox value="banana" placeholder="Selected value" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-combobox>
</DemoBlock>

`value` is a controlled property: it is written back after selection; externally changing the property also immediately reflects in the input's label.

## Externally Controlled Value

<DemoBlock title="External controlled value">
  <oas-combobox id="cb-controlled" placeholder="Set value externally via button" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-combobox>
  <oas-button id="cb-set-apple" size="small">Set to Apple</oas-button>
  <oas-button id="cb-clear-value" size="small">Clear value</oas-button>
</DemoBlock>

In controlled mode, the host can drive the component display via the `value` attribute at any time:

## Groups

<DemoBlock title="Groups (group)">
  <oas-combobox placeholder="Browse by group" options='[{"group":"Temperate fruits","label":"Apple","value":"apple"},{"group":"Temperate fruits","label":"Pear","value":"pear"},{"group":"Tropical fruits","label":"Banana","value":"banana"},{"group":"Tropical fruits","label":"Mango","value":"mango"},{"label":"Other","value":"other"}]'></oas-combobox>
</DemoBlock>

Options with a `group` field render group titles (not selectable) with indented options; `↑`/`↓` navigate continuously across groups (same JSON contract as `oas-select`).

## Custom Filter Function

<DemoBlock title="Custom filtering (el.filter)">
  <oas-combobox id="cb-filter" placeholder="Type pinyin initials (pg / xj / cz)" options='[{"label":"Apple","value":"pingguo"},{"label":"Banana","value":"xiangjiao"},{"label":"Orange","value":"chengzi"}]'></oas-combobox>
  <span id="cb-filter-output" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm); min-width: 160px"></span>
</DemoBlock>

`el.filter = (option, query) => boolean` is a JS property channel (attributes cannot carry functions): it takes over local filtering (e.g. pinyin initials, remote matching); set to `null` to restore the default label-substring filter; not involved when `filterable="false"`.

## Size & Status

<DemoBlock title="Sizes (size)">
  <oas-space size="small" direction="vertical">
    <oas-combobox size="small" placeholder="small" options='[{"label":"Apple","value":"apple"}]'></oas-combobox>
    <oas-combobox placeholder="medium (default)" options='[{"label":"Apple","value":"apple"}]'></oas-combobox>
    <oas-combobox size="large" placeholder="large" options='[{"label":"Apple","value":"apple"}]'></oas-combobox>
  </oas-space>
</DemoBlock>

<DemoBlock title="Status (status)">
  <oas-space size="small">
    <oas-combobox status="success" value="apple" options='[{"label":"Apple","value":"apple"}]'></oas-combobox>
    <oas-combobox status="warning" value="apple" options='[{"label":"Apple","value":"apple"}]'></oas-combobox>
    <oas-combobox status="error" value="apple" options='[{"label":"Apple","value":"apple"}]'></oas-combobox>
  </oas-space>
</DemoBlock>

`size` supports `small / medium / large` (follows the nearest config-provider injection); `status` supports `success / warning / error` (`error` syncs `aria-invalid`, drivable by `oas-form-item` validation).

## Readonly

<DemoBlock title="Readonly">
  <oas-combobox readonly value="apple" placeholder="Readonly" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"}]'></oas-combobox>
</DemoBlock>

`readonly` allows focus & copy but no value change; focus and keyboard no longer open the dropdown.

## Controlled Open

<DemoBlock title="Controlled open + oas-open-change">
  <oas-combobox id="cb-open" placeholder="Host-driven open/close" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-combobox>
  <oas-button id="cb-open-toggle" size="small">Toggle open</oas-button>
  <span id="cb-open-output" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm); min-width: 180px"></span>
</DemoBlock>

The `open` attribute is the controlled open state (internal open/close writes back to it); every transition dispatches `oas-open-change` (`detail.open` boolean) — linked scenarios (opening via external buttons/shortcuts) are implemented by the host driving the attribute.

## Large Data (Virtual Scroll)

<DemoBlock title="10k-option virtual scroll">
  <oas-combobox id="cb-virtual" virtual item-height="48" clearable placeholder="10,000 options, smooth scrolling" options='[]'></oas-combobox>
</DemoBlock>

With `virtual`, only the visible window renders (reusing `oas-virtual-list` window computation) — 10k options scroll smoothly; `item-height` adjusts the fixed height (default `36`). Keyboard `↑`/`↓` navigation auto-follows the highlighted item and `aria-activedescendant` keeps pointing at a visible item; options with `group` automatically fall back to full rendering.

## Filtering & Blur Boundaries

<DemoBlock title="Input filtering + blur fallback">
  <oas-combobox value="apple" placeholder="Falls back automatically on blur after typing" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"},{"label":"Strawberry","value":"strawberry"},{"label":"Watermelon","value":"watermelon"}]'></oas-combobox>
</DemoBlock>

If you type without selecting and then blur/press `Esc`, the input falls back to the currently selected label (non-destructive by default — the selected value is not lost) — combobox values always come from options; for free text use `oas-auto-complete`.

## Not Filterable

<DemoBlock title='Filtering disabled (filterable="false")'>
  <oas-combobox filterable="false" placeholder="No filtering on typing, select via keyboard only" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-combobox>
</DemoBlock>

With `filterable="false"`, typing no longer filters options; select only via keyboard `↑`/`↓` + `Enter` or mouse.

## Clearable

<DemoBlock title="Clearable">
  <oas-combobox clearable value="apple" placeholder="Clearable" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-combobox>
</DemoBlock>

When a value is selected, a clear button appears; clicking clears `value` and dispatches `oas-clear` and `oas-change`.

## Multiple

Set `multiple` to enter multi-select mode (mirroring the `oas-select` multiple contract): `value` becomes a JSON array string, selected options render as **removable tags** (click `×` to remove one); after each pick the panel **stays open** and the input clears its filter for continuous selection; typing only filters (free-text semantics are off in multiple mode — blur discards the filter and never touches the selection). `oas-change` detail is `{ value: string[], options: Array<Option|null> }`; `clearable` clears all and dispatches `oas-clear` (detail holds the pre-clear array).

<DemoBlock title="Multiple (multiple + tags)">
  <oas-combobox id="cb-multi" multiple clearable placeholder="Type to filter, pick many" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"},{"label":"Strawberry","value":"strawberry"},{"label":"Watermelon","value":"watermelon"}]'></oas-combobox>
  <span id="cb-multi-out" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm); min-width: 220px"></span>
</DemoBlock>

### Multi-select Limit & Tag Collapse (max-count / max-tag-count)

`max-count` caps how many options can be selected (multiple only): once reached, unselected options are greyed out in the dropdown (`aria-disabled`) and clicks/keyboard picks are blocked with `oas-exceed-limit` (`detail: { value, max }`); **selected options can still be removed**. When `max-tag-count` is explicitly set, the selected tags are laid out on one line and the overflow collapses into `+N` (hover title lists the folded labels); without it tags wrap and the control grows in height.

<DemoBlock title="Multi-select limit (max-count=3) + tag collapse (max-tag-count=2)">
  <oas-combobox id="cb-multi-max" multiple max-count="3" max-tag-count="2" placeholder="Pick up to 3" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"},{"label":"Strawberry","value":"strawberry"}]'></oas-combobox>
  <span id="cb-multi-max-out" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm); min-width: 220px"></span>
</DemoBlock>

Presets and external control: `value='["apple","banana"]'` drives the tags directly; reading `el.value` returns the selected array and writing an array (or a single value) applies immediately without dispatching events. Form submission uses the native FormData "same-name multiple entries" semantics (one same-name entry per selected value); `required` validates "at least one selected".

## Loading

<DemoBlock title="Loading">
  <oas-combobox loading placeholder="Focus to see the loading placeholder" options='[]'></oas-combobox>
</DemoBlock>

While `loading`, the dropdown shows a loading placeholder (the host sets it during remote data requests).

## Empty State

<DemoBlock title="No options (empty)">
  <oas-combobox placeholder="No options" options='[]'></oas-combobox>
</DemoBlock>

When options are empty, the dropdown shows the empty state; when filtering yields no match, the no-match state is shown.

## Disabled

<DemoBlock title="Disabled">
  <oas-combobox disabled value="apple" placeholder="Disabled" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"}]'></oas-combobox>
</DemoBlock>

## Events

<DemoBlock title="Event output">
  <oas-combobox id="cb-event" clearable placeholder="Type or select" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-combobox>
  <span id="cb-output" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm); min-width: 220px"></span>
</DemoBlock>

Listen to `oas-input` (filter keyword), `oas-change` (selection), `oas-clear` (clear), `oas-open-change` (open transitions):

<script setup>
import { onMounted } from 'vue'
onMounted(() => {
  const el = document.getElementById('cb-event')
  const out = document.getElementById('cb-output')
  const set = (name, e) => {
    out.textContent = `${name}: ${JSON.stringify(e.detail)}`
  }
  el?.addEventListener('oas-input', (e) => set('oas-input', e))
  el?.addEventListener('oas-change', (e) => set('oas-change', e))
  el?.addEventListener('oas-clear', (e) => set('oas-clear', e))
  el?.addEventListener('oas-open-change', (e) => set('oas-open-change', e))

  // controlled value demo: the host drives the value attribute externally
  const controlled = document.getElementById('cb-controlled')
  document.getElementById('cb-set-apple')?.addEventListener('click', () => {
    controlled?.setAttribute('value', 'apple')
  })
  document.getElementById('cb-clear-value')?.addEventListener('click', () => {
    controlled?.removeAttribute('value')
  })

  // custom filter demo: pinyin-initial matching (JS property channel)
  const filterEl = document.getElementById('cb-filter')
  const filterOut = document.getElementById('cb-filter-output')
  if (filterEl) {
    filterEl.filter = (option, query) => {
      const q = query.toLowerCase()
      return option.value.startsWith(q) || option.label.includes(query)
    }
    filterEl.addEventListener('oas-input', (e) => {
      filterOut.textContent = `Query: ${e.detail.value}`
    })
  }

  // controlled open demo: host-driven + event echo
  const openEl = document.getElementById('cb-open')
  const openOut = document.getElementById('cb-open-output')
  openEl?.addEventListener('oas-open-change', (e) => {
    openOut.textContent = `oas-open-change: ${e.detail.open}`
  })
  document.getElementById('cb-open-toggle')?.addEventListener('click', () => {
    if (!openEl) return
    if (openEl.hasAttribute('open')) openEl.removeAttribute('open')
    else openEl.setAttribute('open', '')
  })

  // multiple demo: tag add/remove + event echo
  const multi = document.getElementById('cb-multi')
  const multiOut = document.getElementById('cb-multi-out')
  const renderMulti = () => {
    if (multiOut) multiOut.textContent = `value: ${multi?.getAttribute('value') ?? ''}`
  }
  multi?.addEventListener('oas-change', renderMulti)
  multi?.addEventListener('oas-clear', renderMulti)
  renderMulti()

  // multi-select limit demo: visible oas-exceed-limit feedback
  const multiMax = document.getElementById('cb-multi-max')
  const multiMaxOut = document.getElementById('cb-multi-max-out')
  const renderMultiMax = () => {
    if (multiMaxOut) multiMaxOut.textContent = `value: ${multiMax?.getAttribute('value') ?? ''}`
  }
  multiMax?.addEventListener('oas-change', renderMultiMax)
  multiMax?.addEventListener('oas-exceed-limit', (e) => {
    if (multiMaxOut) multiMaxOut.textContent = `Limit reached (${e.detail.max}); "${e.detail.value}" blocked`
  })
  renderMultiMax()

  // virtual scroll demo: 10k options
  const virtual = document.getElementById('cb-virtual')
  if (virtual) {
    virtual.setAttribute(
      'options',
      JSON.stringify(
        Array.from({ length: 10000 }, (_, i) => ({ label: `Option ${i}`, value: `v${i}` })),
      ),
    )
  }
})
</script>

## Mobile form (bottom sheet)

On touch screens (coarse pointer) or narrow viewports (<768px), the option list is automatically hosted by an `oas-bottom-sheet` bottom sheet: rises from the viewport bottom + backdrop + drag handle swipe-down to close (backdrop click / Esc also close), with bottom safe-area inset; the input stays in place and focusing it still pops the list inside the sheet for selection; the desktop form keeps the original floating dropdown. Inside the sheet, option rows are lifted to a 44px touch target (`--oas-touch-target-min`).

## required

<DemoBlock title="required (native validation chain)">
  <oas-combobox required name="fruit" placeholder="Select or type" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"}]'></oas-combobox>
</DemoBlock>

`required` drives the native validation chain (form-associated): when unfilled, `checkValidity()` returns false (`valueMissing`) and native form submission is blocked; it recovers to `:valid` once filled.

Programmatic read/write of the current value goes through the public `value` property: `el.value` reads the selected value (`value` attribute) first, falling back to the in-progress draft when nothing is selected (FormData semantics); `el.value = x` writes the controlled `value` attribute and reflects the option label in the input (label/value separation) without dispatching events.

## autocomplete Passthrough

<DemoBlock title="autocomplete passthrough">
  <oas-combobox autocomplete="off" placeholder="Browser autofill disabled" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"}]'></oas-combobox>
</DemoBlock>

`autocomplete` passes through to the inner native input (defaults to `off`: combobox draws its own dropdown, so browser autofill is disabled by default). Inside a native form with a `name`, the browser uses this to decide whether to offer historical entries.

## API

### oas-combobox

#### Attributes

| Attribute | Description | Type | Default |
| --- | --- | --- | --- |
| `autocomplete` | Passed through to the inner native input (falls back to off) | — | — |
| `clearable` | Clearable (shows a clear button when a value exists; clearing dispatches `oas-clear`) | `boolean` | — |
| `disabled` | Disabled (no input, no dropdown) | `boolean` | — |
| `filterable` | Filter labels in real time while typing (`filterable="false"` disables local filtering) | `string` | `true` |
| `item-height` | Virtual-scroll fixed row height (px, with `virtual`, default 36) | `string` | `36` |
| `loading` | Loading placeholder (dropdown shows "Loading…") | `boolean` | — |
| `max-count` | Multi-select limit (only with `multiple`): once reached, unselected options are greyed out and blocked with `oas-exceed-limit`; selected options can still be removed | `string` | — |
| `max-tag-count` | Tag collapse count (only with `multiple`): when explicitly set, tags are laid out on one line and the overflow collapses into `+N` (title lists the folded labels); without it tags wrap by default | `number` | — |
| `multiple` | Multi-select mode (mirrors oas-select multiple semantics: the value is a JSON array, selected options render as removable tags, the panel stays open after each pick for continuous selection; typing only filters, free-text semantics are off) | `boolean` | — |
| `name` | Form field name (key for native FormData submission; also the key of same-name multiple entries in multiple mode; the browser skips submission when name is absent) | `string` | — |
| `open` | Controlled open state (internal open/close writes back to the attribute; every transition dispatches `oas-open-change`; forced closed under readonly/disabled); in the mobile form (touch / viewport <768px) the open panel is hosted by an oas-bottom-sheet bottom sheet (swipe-down/backdrop/Esc to close); desktop keeps the floating dropdown | `boolean` | — |
| `options` | Options, JSON array `[{ label, value, disabled?, group? }]` (group is the group title, same contract as oas-select) | `Option[] \| string` | `[]` |
| `placeholder` | Placeholder text | — | — |
| `readonly` | Readonly (focusable & copyable, no value change; focus and keyboard never open the dropdown) | `boolean` | — |
| `required` | Required marker (drives the native valueMissing validation chain; not passed through to the inner control) | `boolean` | — |
| `size` | Size tier: small / medium / large (default medium, follows the nearest config-provider injection) | `string` | `medium` |
| `status` | Validation status: success / warning / error (error syncs aria-invalid, drivable by oas-form-item validation) | `string` | — |
| `value` | Current value (controlled, the selected option's `option.value`; a JSON array string under `multiple`) | `string \| string[]` | — |
| `virtual` | Virtual scroll (reuses oas-virtual-list to render only the visible window; options with group fall back to full rendering) | `boolean` | — |

#### Property (JS property; see each row for attribute reflection)

| Property | Description | Type | Default |
| --- | --- | --- | --- |
| `filter` | Custom filter function (property channel, `(option, query) => boolean`) | `((option: Option, query: string) => boolean) \| null` | — |
| `value` | Current value: single is the selected value string (falls back to the typed draft when nothing is selected); `multiple` is the array of selected values | `string \| string[]` | — |

#### Events

| Event | Description |
| --- | --- |
| `oas-change` | Selection/clear/removal change; single `detail: { value }`; multiple `detail: { value: string[], options: Array<Option\|null> }` (mirrors the oas-select multiple contract) |
| `oas-clear` | Clear button clicked; single `detail: { value }` (value before clearing); multiple `detail: { value: string[] }` (array before clearing) |
| `oas-exceed-limit` | Selection attempt beyond the multiple max-count limit, `detail: { value, max }` |
| `oas-input` | Filter keyword typed, `detail: { value }` |
| `oas-open-change` | Open state transition (both controlled setAttribute and internal toggles dispatch), `detail: { open }` |

#### CSS Variables

| CSS Variable | Default |
| --- | --- |
| `--oas-glass-blur` | `none` |
| `--oas-glass-ring` | `transparent` |

Keyboard: `Enter` / focus to open, `↑`/`↓` to move the highlight, `Enter` to select, `Esc` to close and revert.
