# Select

A dropdown selector supporting single/multiple selection, groups, clearable, remote search, and custom creation, with size/status variants, controlled open, and selection limits, fully operable by keyboard.

## Single Select

<DemoBlock title="Single select">
  <oas-select placeholder="Select a fruit" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"},{"label":"Strawberry","value":"strawberry"}]'></oas-select>
</DemoBlock>

## Accessible Name (label)

<DemoBlock title="label (accessible name)">
  <oas-select id="select-label-set" label="City" placeholder="Select a city" options='[{"label":"Beijing","value":"bj"},{"label":"Shanghai","value":"sh"}]'></oas-select>
  <oas-select id="select-label-fallback" placeholder="No label, falls back to placeholder" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"}]'></oas-select>
  <span id="select-label-output" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm); min-width: 280px"></span>
</DemoBlock>

The `label` attribute provides the trigger's accessible name (`aria-label`): when set, screen readers announce it and it takes priority over the placeholder; when unset, it falls back to `placeholder`. The visible field-label position is handled by `oas-form-item`, and native `label for` association (click-to-focus) goes through the form-associated channel.

## Preset Value

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

## Multiple Select

<DemoBlock title="Multiple">
  <oas-select multiple value='["apple","banana","orange","strawberry"]' placeholder="Multiple" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"},{"label":"Strawberry","value":"strawberry"},{"label":"Watermelon","value":"watermelon"}]'></oas-select>
</DemoBlock>

In multiple mode `value` is a JSON array; selected items are shown as tags that can be removed individually. Tags wrap to new lines by default and the trigger grows with the content (no collapsing unless `max-tag-count` is set).

## Hide Selected (hide-selected)

<DemoBlock title="hide-selected: selected options hidden from the dropdown">
  <oas-select id="select-hide-selected" multiple hide-selected clearable placeholder="Selected items disappear" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"},{"label":"Strawberry","value":"strawberry"}]'></oas-select>
  <span id="select-hide-selected-output" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm); min-width: 200px"></span>
</DemoBlock>

`hide-selected` (multiple mode only) removes selected options from the dropdown — the list only shows "what is still selectable", useful for multi-select over large option sets; clearing (clearable) or removing a tag puts the option back into the list automatically. Selecting everything shows the empty state.

## Disabled

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

## Size

<DemoBlock title="Size (size)">
  <oas-space size="small">
    <oas-select size="small" placeholder="small" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-select>
    <oas-select placeholder="medium (default)" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-select>
    <oas-select size="large" placeholder="large" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-select>
  </oas-space>
</DemoBlock>

`size` supports `small` / `medium` (default) / `large`; control height and font size follow the global size tokens. When not set explicitly, the nearest `oas-config-provider` `size` injection applies (global density sync). Use `small` inside tables or `large` on filter bars directly.

## Variants

<DemoBlock title="variant">
  <oas-space size="small">
    <oas-select placeholder="outlined (default)" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"}]'></oas-select>
    <oas-select variant="filled" placeholder="filled" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"}]'></oas-select>
    <oas-select variant="borderless" placeholder="borderless" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"}]'></oas-select>
  </oas-space>
</DemoBlock>

`variant` mirrors the `oas-input` three-value variants: `outlined` (default border) / `filled` (tinted background, border appears on focus/open) / `borderless` (no border, no background — for plain-text embedding or low-chrome filter bars); invalid values silently fall back to the default. The `status` semantic color takes precedence over the variant background.

## Status

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

`status` supports `success` / `warning` / `error`, tinting the trigger border and focus ring. `error` also mirrors `aria-invalid="true"` onto the host (recognizable by screen readers); setting `aria-invalid` directly produces the same visual. Pairs well with form validation.

## Readonly

<DemoBlock title="Readonly (readonly) vs disabled">
  <oas-space size="small">
    <oas-select readonly value="banana" placeholder="Readonly" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-select>
    <oas-select disabled value="banana" placeholder="Disabled" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-select>
    <oas-select readonly multiple value='["apple","banana"]' placeholder="Multiple, readonly" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-select>
  </oas-space>
</DemoBlock>

`readonly` and `disabled` carry different form semantics: readonly is focusable, copyable, never opens the dropdown, and the value still submits (clear and tag-remove affordances are hidden); disabled is not focusable and its value does not submit. Use readonly to display values decided elsewhere, disabled to block interaction.

## Empty State

<DemoBlock title="No data">
  <oas-select placeholder="No options" options='[]'></oas-select>
</DemoBlock>

When options are empty, the dropdown shows "No data".

## Custom Empty

<DemoBlock title="Custom empty (empty slot)">
  <oas-select searchable placeholder="Type a non-matching keyword to see the empty state" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"}]'>
    <template slot="empty">
      <span style="display:inline-flex;align-items:center;gap:4px">🍒 No matching fruit found</span>
    </template>
  </oas-select>
</DemoBlock>

`template[slot="empty"]` overrides both the "no data" and "no match" default texts (which come from the locale registry); the content is cloned into the dropdown empty area.

## Searchable

<DemoBlock title="Searchable">
  <oas-select searchable placeholder="Type a keyword to filter" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"},{"label":"Strawberry","value":"strawberry"},{"label":"Watermelon","value":"watermelon"}]'></oas-select>
</DemoBlock>

Once opened, you can type directly to filter; when nothing matches, "No matching options" is shown.

## Custom Filtering

<DemoBlock title="Custom filtering (filterMethod)">
  <oas-select id="select-filter" searchable placeholder="Try typing apple / fruit" options='[{"label":"Apple 苹果","value":"apple"},{"label":"Banana 香蕉","value":"banana"},{"label":"Orange 橙子","value":"orange"}]'></oas-select>
</DemoBlock>

By default options are matched by `label` inclusion; assigning `el.filterMethod = (query, option) => boolean` (a JS property channel — attributes cannot carry functions) takes over local filtering, where `query` is the raw input and `option` the full option object. This example (see the page script at the bottom) matches both `value` and `label` (pinyin-initial matching and other custom rules work the same way); in `remote` mode the host owns data-side filtering and `filterMethod` does not apply.

## Controlled Search Input (input-value)

<DemoBlock title="input-value (controlled search text)">
  <oas-select id="select-input-value" searchable placeholder="Type a keyword, attribute mirrors live" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-select>
  <span id="select-input-value-output" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm); min-width: 220px"></span>
</DemoBlock>

`input-value` makes the search text controllable: when the attribute is present it is the source of truth for the search box (presets/external updates sync into the box and re-filter); user typing writes the attribute back and dispatches `oas-input-value-change` (`detail: { value }`) — the host can listen or simply read the attribute.

## Reserve Keyword (reserve-keyword)

<DemoBlock title="reserve-keyword (keep the keyword after selection)">
  <oas-space size="small">
    <oas-select multiple searchable placeholder="Default: clear keyword after selection" options='[{"label":"Apple","value":"apple"},{"label":"Apple juice","value":"juice"},{"label":"Banana","value":"banana"}]'></oas-select>
    <oas-select multiple searchable reserve-keyword placeholder="reserve-keyword: keep keyword" options='[{"label":"Apple","value":"apple"},{"label":"Apple juice","value":"juice"},{"label":"Banana","value":"banana"}]'></oas-select>
  </oas-space>
</DemoBlock>

In multiple + searchable mode, the keyword is cleared by default after selecting an option (the list resets so you can pick unrelated items); with `reserve-keyword` the keyword is kept, ideal for picking multiple items among similar results.

## Highlight First Option (default-active-first-option)

<DemoBlock title="default-active-first-option">
  <oas-space size="small">
    <oas-select value="orange" placeholder="Default: highlight selected option" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-select>
    <oas-select value="orange" default-active-first-option placeholder="Set: highlight the first option" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-select>
  </oas-space>
</DemoBlock>

When set, opening the dropdown highlights the first visible option (Enter picks it); by default the currently selected option is highlighted.

## Groups

<DemoBlock title="Grouped">
  <oas-select 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-select>
</DemoBlock>

<DemoBlock title="Grouped multiple">
  <oas-select multiple placeholder="Grouped multiple" 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"}]'></oas-select>
</DemoBlock>

Options carrying a `group` field are rendered under a group title (not selectable), with items indented; keyboard `↑`/`↓` navigates continuously across groups.

## Clearable

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

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

## Trigger Affixes & Custom Icons

<DemoBlock title="prefix / suffix / suffix-icon / clear-icon slots and show-arrow">
  <oas-space size="small" direction="vertical">
    <oas-select id="select-affix-demo" placeholder="Prefix icon + suffix text" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"}]'>
      <template slot="prefix"><span style="display:inline-flex">📍</span></template>
      <template slot="suffix"><span style="display:inline-flex">City</span></template>
    </oas-select>
    <oas-select id="select-icons-demo" clearable value="apple" placeholder="Custom arrow and clear icons" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"}]'>
      <template slot="suffix-icon"><span style="display:inline-flex">⌄</span></template>
      <template slot="clear-icon"><span style="display:inline-flex">✕</span></template>
    </oas-select>
    <oas-select id="select-no-arrow" show-arrow="false" placeholder="Hide the dropdown arrow (show-arrow)" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"}]'></oas-select>
  </oas-space>
</DemoBlock>

`template[slot="prefix"]` / `template[slot="suffix"]` render into the trigger affixes (icons, unit text, etc.); `template[slot="suffix-icon"]` replaces the default dropdown arrow (rotating with the container when expanded); `template[slot="clear-icon"]` replaces the default × icon of the clear button. `show-arrow="false"` hides the default arrow (arrow-less search-trigger scenarios).

## Remote Search

<DemoBlock title="Remote search (remote + loading)">
  <oas-select id="select-remote" remote searchable placeholder="Type a keyword to simulate remote search" options='[]'></oas-select>
  <span id="select-remote-output" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm); min-width: 180px"></span>
</DemoBlock>

In `remote` mode the component does no local filtering: typing dispatches `oas-input` for the host to request data, and the host sets `loading` during the request to show a loading placeholder. This example simulates an 800ms delayed filter:

<DemoBlock title="Remote loading placeholder">
  <oas-select remote searchable loading placeholder="Loading placeholder demo" options='[]'></oas-select>
</DemoBlock>

## Loading Text (loading-text)

<DemoBlock title="loading-text (custom loading copy)">
  <oas-select remote searchable loading loading-text="Loading data, please wait…" placeholder="Custom loading copy" options='[]'></oas-select>
</DemoBlock>

`loading-text` overrides the default loading placeholder copy (built-in locale copy by default); pair it with `remote` / `loading`.

## Remote Debounce

<DemoBlock title="Remote debounce (debounce)">
  <oas-select id="select-debounce" remote searchable debounce="300" placeholder="Rapid typing dispatches only the last input" options='[]'></oas-select>
  <span id="select-debounce-output" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm); min-width: 220px"></span>
</DemoBlock>

`debounce` (milliseconds, default `0` — dispatch immediately, leaving existing behavior untouched) applies only to `oas-input` dispatching in `remote` mode: rapid inputs within the window collapse into a single last-value dispatch, sparing the host a `setTimeout` boilerplate; local filtering is always instant.

## Tag Collapse

<DemoBlock title="Tag collapse (max-tag-count)">
  <oas-select multiple max-tag-count="2" value='["apple","banana","orange","strawberry"]' placeholder="Collapse into +N when exceeded" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"},{"label":"Strawberry","value":"strawberry"},{"label":"Watermelon","value":"watermelon"}]'></oas-select>
</DemoBlock>

Multiple-select tags wrap by default and do not collapse; only when `max-tag-count` is explicitly set do they collapse into `+N` (hover to see the remaining items).

## Hint & Auto Width

<DemoBlock title="hint copy / auto-width trigger">
  <oas-space size="small" direction="vertical">
    <oas-select hint="Pick your resident city; you can change it anytime" placeholder="Hint below the trigger (hint)" options='[{"label":"Beijing","value":"bj"},{"label":"Shanghai","value":"sh"}]'></oas-select>
    <oas-select auto-width value="banana" placeholder="auto-width" options='[{"label":"Apple","value":"apple"},{"label":"Banana Banana","value":"banana"}]'></oas-select>
  </oas-space>
</DemoBlock>

`hint` renders one line of hint copy below the trigger (wired via `aria-describedby`, screen-reader reachable); `auto-width` lets the trigger width shrink to fit the selected item/content (fixed `220px` by default).

## Reserve Widest Option (reserve-width)

<DemoBlock title="reserve-width + auto-width: no jitter when switching (open the dropdowns and switch to compare)">
  <oas-space size="small" direction="vertical">
    <oas-select id="sel-reserve" reserve-width auto-width value="a" placeholder="Pick alignment" options='[{"label":"Align left","value":"a"},{"label":"Align center","value":"b"},{"label":"Justified with auto wrap","value":"c"}]'></oas-select>
    <oas-select id="sel-narrow" auto-width value="a" placeholder="Control (no reserve)" options='[{"label":"Align left","value":"a"},{"label":"Align center","value":"b"},{"label":"Justified with auto wrap","value":"c"}]'></oas-select>
  </oas-space>
  <div style="margin-top: var(--oas-space-2)">
    <span id="sel-reserve-out" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm)"></span>
  </div>
</DemoBlock>

`reserve-width` (opt-in) measures the max text width of all option labels plus the placeholder and locks the trigger min-width: the trigger never jitters when switching values. It pairs well with `auto-width` — after shrinking to content, the reserved value acts as the floor (the first select above is locked to the widest option, the second is the control; open the dropdowns and switch values to see the difference). Both the `options` attribute and the declarative child channel participate; content changes re-measure automatically; removing the attribute releases the lock. The text width is an approximation (trigger chrome margin tunable via the `--oas-select-reserve-pad` variable, default 46px).

## Max Count

<DemoBlock title="Selection limit (max-count)">
  <oas-space size="small">
    <oas-select id="select-max" multiple max-count="2" placeholder="Pick at most 2" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"},{"label":"Strawberry","value":"strawberry"}]'></oas-select>
    <oas-tag id="select-max-log" type="warning">Limit hint</oas-tag>
  </oas-space>
</DemoBlock>

With `multiple` and `max-count` set, once the selection reaches the limit, unselected options become disabled and grayed out (selected ones can still be deselected, which re-enables the rest); attempts to exceed the limit — click or keyboard — dispatch `oas-exceed-limit` (`detail: { value, max }`), which this example uses to update the hint tag. `max-count` has no effect in single-select mode. Voting and tag-cap scenarios work out of the box.

## Allow Create

<DemoBlock title="Allow create">
  <oas-select id="select-create" allow-create searchable placeholder="Type a non-existent option to create" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-select>
  <span id="select-create-output" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm); min-width: 220px"></span>
</DemoBlock>

When search yields no match, a "Create {label}" item is shown; clicking or pressing Enter creates a new option from the input value and selects it. Creation dispatches `oas-create` (`detail: { value, label }` carrying the created text).

## Custom Option Rendering

<DemoBlock title="Custom options (icons + rich text)">
  <oas-select id="select-custom" multiple searchable placeholder="Select a fruit with icons" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"},{"label":"Strawberry","value":"strawberry"}]'></oas-select>
</DemoBlock>

Listen to `oas-option-render` (each option row) and `oas-tag-render` (multi-select tags); `detail.element` is the corresponding text container that the host can rewrite into any content (icons, rich text). You can also put `<template slot="option">` / `<template slot="tag">` inside the component for a static skeleton — `[data-option-label]` / `[data-tag-label]` nodes get bound to the option/tag text automatically.

## Large Datasets (Virtual Scrolling)

<DemoBlock title="10k options virtual scrolling">
  <oas-select id="select-virtual" virtual searchable clearable item-height="36" placeholder="10k options, smooth scrolling" options='[]'></oas-select>
</DemoBlock>

With `virtual` set, only the visible window is rendered (reusing the `oas-virtual-list` window math; leading/trailing padding carries the scroll height) — 10k options scroll smoothly. `item-height` tunes the fixed row height (default `36`). Keyboard `↑`/`↓` scrolling follows the highlighted item and `aria-activedescendant` keeps pointing at a visible row; options with a `group` field fall back to full rendering.

## Dropdown Height

<DemoBlock title="Dropdown height (--oas-select-dropdown-height)">
  <oas-space size="small">
    <oas-select style="--oas-select-dropdown-height: 120px" placeholder="Max dropdown height 120px" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"},{"label":"Strawberry","value":"strawberry"},{"label":"Watermelon","value":"watermelon"},{"label":"Grape","value":"grape"}]'></oas-select>
    <oas-select id="select-height-virtual" virtual style="--oas-select-dropdown-height: 160px" placeholder="Works in virtual mode too" options='[]'></oas-select>
  </oas-space>
</DemoBlock>

The dropdown max height goes through the CSS variable `--oas-select-dropdown-height` (default `240px`) — override it to adjust, no attribute API involved; virtual scrolling follows it as well (re-open the dropdown to apply). The dropdown width stays aligned with the trigger (the majority default).

## Declarative Child-Element Channel

Besides the `options` JSON, you can declare options declaratively with `<oas-option>` child elements (matching the native `<select><option>` mental model: the default slot text is the label; attributes map to the `options` fields — `value` / `disabled` / `group` group title). The `options` attribute *takes precedence when explicitly set*; otherwise child elements are parsed and converge into the same rendering path (virtual scrolling, grouping, search etc. are fully identical). Child additions, removals, attribute and text changes re-render automatically (MutationObserver).

<DemoBlock title="Declarative child elements (native select mental model)">
  <oas-space size="small" direction="vertical">
    <oas-select id="select-decl" placeholder="Select a fruit" value="banana">
      <oas-option value="apple" group="Temperate fruits">Apple</oas-option>
      <oas-option value="banana" group="Temperate fruits">Banana</oas-option>
      <oas-option value="orange" group="Tropical fruits">Orange</oas-option>
      <oas-option value="mango" group="Tropical fruits">Mango</oas-option>
      <oas-option value="other" disabled>Other (disabled)</oas-option>
    </oas-select>
    <oas-space size="small">
      <oas-button id="select-decl-add" size="small">Add an option dynamically</oas-button>
      <oas-tag id="select-decl-result" type="info">oas-change: banana</oas-tag>
    </oas-space>
  </oas-space>
</DemoBlock>

## Field Mapping (host .map())

When business field names (`name`/`id`/`team`, etc.) differ from the component contract (`label`/`value`), align them with a one-line host-side `.map()` instead of a field-alias attribute — the JSON serialization boundary of Web Components naturally has the host assemble the data, where field mapping costs nothing:

<DemoBlock title="Business field mapping (.map(), dual channel)">
  <oas-space size="small" direction="vertical">
    <oas-select id="select-map-json" placeholder="Options channel (.map + property assignment)"></oas-select>
    <oas-select id="select-map-child" placeholder="Child-element channel (.map + oas-option)"></oas-select>
  </oas-space>
</DemoBlock>

The two channels are equivalent (see the page script at the bottom): `MEMBERS.map((m) => ({ label: m.name, value: m.id }))` then either `el.options = ...` (the setter reflects to the attribute) or `.map()` into `<oas-option>` child elements appended one by one.

## Dropdown Header & Footer

<DemoBlock title="Dropdown header/footer slots">
  <oas-select id="select-header" multiple placeholder="Select-all bar + append action" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'>
    <template slot="header">
      <label style="display:inline-flex;align-items:center;gap:6px;cursor:pointer;margin:0">
        <input type="checkbox" id="select-header-all" />Select all
      </label>
    </template>
    <template slot="footer">
      <button type="button" id="select-header-add" style="appearance:none;border:none;background:transparent;padding:0;color:var(--oas-color-primary);cursor:pointer;font-size:inherit">＋ Append an item</button>
    </template>
  </oas-select>
</DemoBlock>

`template[slot="header"]` renders below the search box and above the options; `template[slot="footer"]` renders below the options (select-all bars, append buttons, etc.). Note: keyboard `↑`/`↓` navigation does not enter these areas — interactive content inside must be Tab-focusable (like the checkbox/button here) or modeled as an option; the logic is host-managed.

## Controlled Open

<DemoBlock title="Controlled open (open + oas-open-change)">
  <oas-space size="small" direction="vertical">
    <oas-space size="small">
      <oas-select id="select-open-a" placeholder="① Pick a role (auto-opens ②)" options='[{"label":"Admin","value":"admin"},{"label":"Developer","value":"dev"},{"label":"Guest","value":"guest"}]'></oas-select>
      <oas-select id="select-open-b" placeholder="② Pick a permission" options='[{"label":"Read","value":"read"},{"label":"Write","value":"write"},{"label":"Delete","value":"delete"}]'></oas-select>
    </oas-space>
    <oas-tag id="select-open-log" type="info">Event log</oas-tag>
  </oas-space>
</DemoBlock>

Setting the `open` attribute takes control of the dropdown (present = open, removed = closed; the property channel `el.open = true` works too); user gestures (click / Esc / outside click / close-on-select) dispatch `oas-open-change` (`detail: { open }`) without forcing the state back — the host decides whether to add or remove the attribute (the controlled-component convention). In this example, picking ① auto-opens ②, and ②'s close event is arbitrated by the host removing the attribute.

## Autofocus & Tab Order

<DemoBlock title="autofocus / tabindex">
  <oas-space size="small" direction="vertical">
    <oas-select autofocus placeholder="Auto-focused after mount (autofocus)" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"}]'></oas-select>
    <oas-select id="select-tabindex" tabindex="2" placeholder="tabindex=2 forwarded to the trigger" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"}]'></oas-select>
  </oas-space>
</DemoBlock>

`autofocus` focuses the trigger automatically after mount (the native autofocus does not pierce the Shadow DOM, so the component forwards it); `tabindex` is forwarded to the internal trigger to control the tab order (the host attribute is moved upon write, avoiding a double tab stop on host + trigger).

## Placement

<DemoBlock title="Placement (placement)">
  <oas-space size="small">
    <oas-select placement="top" placeholder="Open upward" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-select>
    <oas-select placeholder="auto (default)" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-select>
    <oas-select placement="bottom" placeholder="Open downward" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-select>
  </oas-space>
</DemoBlock>

`placement` supports `auto` (default — prefer bottom, auto-flip when space runs out) / `top` (force above) / `bottom` (force below). Use `top` for bottom-of-form layouts or long lists that feel more natural opening upward.

## Selected Option

<DemoBlock title="Selected option (oas-change carries option)">
  <oas-select id="select-option-detail" placeholder="Pick to see the full object" options='[{"group":"Temperate","label":"Apple","value":"apple"},{"group":"Tropical","label":"Banana","value":"banana"},{"group":"Tropical","label":"Orange","value":"orange"}]'></oas-select>
  <span id="select-option-detail-output" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm); min-width: 240px"></span>
</DemoBlock>

Besides `value`, the `oas-change` `detail` carries the full option object: `option` for single select (`label`/`value`/`group`/`disabled`, `null` when unmatched) and an `options` array for multiple select — no more host-side reverse lookup when a form submit needs the complete object.

## Focus Events

<DemoBlock title="Focus events (oas-focus / oas-blur)">
  <oas-space size="small">
    <oas-select id="select-focus" searchable placeholder="Focus / blur to see the log" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"}]'></oas-select>
    <oas-tag id="select-focus-log" type="info">Focus log</oas-tag>
  </oas-space>
</DemoBlock>

`oas-focus` / `oas-blur` dispatch when the component as a whole gains or loses focus (common for form wiring); internal focus moves (trigger ↔ search box) do not produce spurious events.

## Events

<DemoBlock title="Change events">
  <oas-select id="select-event" multiple placeholder="Select to trigger oas-change" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"},{"label":"Orange","value":"orange"}]'></oas-select>
  <span id="select-output" style="color: var(--oas-color-text-secondary); font-size: var(--oas-font-size-sm); min-width: 180px"></span>
</DemoBlock>

Listen to `oas-change`; `detail.value` is a string for single select and an array for multiple:

<script setup>
import { onMounted } from 'vue'
onMounted(() => {
  // reserve-width demo: show the trigger width after switching (locked vs control)
  const reserveOut = document.getElementById('sel-reserve-out')
  const reserveSel = document.getElementById('sel-reserve')
  const narrowSel = document.getElementById('sel-narrow')
  if (reserveSel && reserveOut) {
    const report = (el) => (el ? `(trigger width ${Math.round(el.getBoundingClientRect().width)}px)` : '')
    reserveSel.addEventListener('oas-change', (e) => {
      reserveOut.textContent = `Selected: ${e.detail.value} ${report(reserveSel)} vs control ${report(narrowSel)}`
    })
  }

  const el = document.getElementById('select-event')
  const out = document.getElementById('select-output')
  el?.addEventListener('oas-change', (e) => {
    out.textContent = `oas-change: [${e.detail.value.join(', ')}]`
  })

  // hide-selected demo: echo remaining options and current value after open/select (visible feedback)
  const hideSel = document.getElementById('select-hide-selected')
  const hideSelOut = document.getElementById('select-hide-selected-output')
  const syncHideSel = () => {
    if (!hideSel || !hideSelOut) return
    const root = hideSel.shadowRoot
    const visible = root ? [...root.querySelectorAll('[role="option"] .option-label')].map((n) => n.textContent) : []
    let current = []
    try {
      current = JSON.parse(hideSel.getAttribute('value') ?? '[]')
    } catch {
      current = []
    }
    hideSelOut.textContent = `Current: [${current.join(', ') || '—'}] · ${visible.length} options left in the dropdown`
  }
  hideSel?.addEventListener('oas-change', syncHideSel)
  hideSel?.addEventListener('oas-open-change', () => setTimeout(syncHideSel, 60))
  hideSel?.addEventListener('oas-clear', () => setTimeout(syncHideSel, 60))

  // remote search demo: simulate host requests, filter and refill options by label after an 800ms delay
  const remote = document.getElementById('select-remote')
  const remoteOut = document.getElementById('select-remote-output')
  const REMOTE_ALL = [
    { label: 'Apple', value: 'apple' },
    { label: 'Banana', value: 'banana' },
    { label: 'Orange', value: 'orange' },
    { label: 'Strawberry', value: 'strawberry' },
    { label: 'Watermelon', value: 'watermelon' },
  ]
  let remoteTimer = 0
  remote?.addEventListener('oas-input', (e) => {
    const q = e.detail.value
    window.clearTimeout(remoteTimer)
    remote.setAttribute('loading', '')
    remoteTimer = window.setTimeout(() => {
      remote.removeAttribute('loading')
      remote.setAttribute(
        'options',
        JSON.stringify(q ? REMOTE_ALL.filter((o) => o.label.includes(q)) : REMOTE_ALL),
      )
    }, 800)
  })
  remote?.addEventListener('oas-change', (e) => {
    remoteOut.textContent = `oas-change: ${e.detail.value}`
  })

  // custom option rendering demo: icons + rich text (rewrite element via oas-option-render / oas-tag-render)
  const custom = document.getElementById('select-custom')
  const CUSTOM_ICONS = { apple: '🍎', banana: '🍌', orange: '🍊', strawberry: '🍓' }
  custom?.addEventListener('oas-option-render', (e) => {
    const { option, element } = e.detail
    element.innerHTML = ''
    const ic = document.createElement('span')
    ic.textContent = CUSTOM_ICONS[option.value] ?? '•'
    const txt = document.createElement('span')
    txt.textContent = option.label
    element.append(ic, txt)
  })
  custom?.addEventListener('oas-tag-render', (e) => {
    const { value, element } = e.detail
    element.innerHTML = ''
    const ic = document.createElement('span')
    ic.textContent = CUSTOM_ICONS[value] ?? '•'
    element.append(ic)
  })

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

  // Declarative child-element channel demo: selection feedback + dynamic append (MutationObserver auto-refresh)
  const declSelect = document.getElementById('select-decl')
  const declResult = document.getElementById('select-decl-result')
  declSelect?.addEventListener('oas-change', (e) => {
    declResult.textContent = `oas-change: ${e.detail.value}`
  })
  document.getElementById('select-decl-add')?.addEventListener('click', () => {
    if (!declSelect) return
    const n = declSelect.children.length + 1
    const opt = document.createElement('oas-option')
    opt.setAttribute('value', `dyn-${n}`)
    opt.textContent = `Dynamic fruit ${n}`
    declSelect.appendChild(opt)
  })

  // Custom filtering demo: filterMethod matches both value and label (pinyin-initial rules work the same way)
  const filterEl = document.getElementById('select-filter')
  if (filterEl) {
    filterEl.filterMethod = (query, option) =>
      option.value.includes(query.toLowerCase()) ||
      option.label.toLowerCase().includes(query.toLowerCase())
  }

  // Remote debounce demo: the component already merges dispatches at debounce=300; the host just consumes oas-input
  const debounceEl = document.getElementById('select-debounce')
  const debounceOut = document.getElementById('select-debounce-output')
  let debounceCount = 0
  debounceEl?.addEventListener('oas-input', (e) => {
    debounceCount += 1
    debounceOut.textContent = `Dispatch #${debounceCount}: ${JSON.stringify(e.detail.value)}`
  })

  // Max count demo: oas-exceed-limit feedback
  const maxEl = document.getElementById('select-max')
  const maxLog = document.getElementById('select-max-log')
  maxEl?.addEventListener('oas-exceed-limit', (e) => {
    maxLog.textContent = `Reached the limit of ${e.detail.max}; cannot select "${e.detail.value}"`
  })
  maxEl?.addEventListener('oas-change', () => {
    maxLog.textContent = 'Limit hint'
  })

  // Dropdown height demo: fill the virtual list with data
  const heightVirtual = document.getElementById('select-height-virtual')
  if (heightVirtual) {
    heightVirtual.setAttribute(
      'options',
      JSON.stringify(Array.from({ length: 1000 }, (_, i) => ({ label: `Option ${i}`, value: `v${i}` }))),
    )
  }

  // Field mapping demo: business fields (name/id/team) → component contract (label/value), one .map() line
  const MEMBERS = [
    { name: 'Lin Lan', id: 'u1', team: 'Design' },
    { name: 'Chen Mo', id: 'u2', team: 'Frontend' },
    { name: 'Su Qing', id: 'u3', team: 'Frontend' },
    { name: 'Zhou Ye', id: 'u4', team: 'Data' },
  ]
  const mapJson = document.getElementById('select-map-json')
  if (mapJson) {
    // Property assignment must wait for the element upgrade (assigning before upgrade
    // installs an own property that shadows the prototype setter → attribute stays empty
    // → dropdown shows "No data"; the oas-option children channel is timing-safe)
    customElements.whenDefined('oas-select').then(() => {
      mapJson.options = MEMBERS.map((m) => ({ label: `${m.name} (${m.team})`, value: m.id }))
    })
  }
  const mapChild = document.getElementById('select-map-child')
  for (const o of MEMBERS.map((m) => ({ value: m.id, label: m.name }))) {
    const opt = document.createElement('oas-option')
    opt.setAttribute('value', o.value)
    opt.textContent = o.label
    mapChild?.appendChild(opt)
  }

  // Header/footer demo: header select-all / footer append (host-managed logic)
  const headerSelect = document.getElementById('select-header')
  const headerAll = document.getElementById('select-header-all')
  const HEADER_OPTIONS = [
    { label: 'Apple', value: 'apple' },
    { label: 'Banana', value: 'banana' },
    { label: 'Orange', value: 'orange' },
  ]
  headerAll?.addEventListener('change', () => {
    if (!headerSelect || !(headerAll instanceof HTMLInputElement)) return
    headerSelect.setAttribute(
      'value',
      JSON.stringify(headerAll.checked ? HEADER_OPTIONS.map((o) => o.value) : []),
    )
  })
  headerSelect?.addEventListener('oas-change', (e) => {
    if (headerAll instanceof HTMLInputElement) {
      headerAll.checked = e.detail.value.length === HEADER_OPTIONS.length
    }
  })
  let headerAddCount = 0
  document.getElementById('select-header-add')?.addEventListener('click', () => {
    if (!headerSelect) return
    headerAddCount += 1
    const next = [
      ...HEADER_OPTIONS,
      { label: `Extra ${headerAddCount}`, value: `extra-${headerAddCount}` },
    ]
    headerSelect.setAttribute('options', JSON.stringify(next))
  })

  // Controlled open demo: picking ① auto-opens ②; ②'s close event is arbitrated by the host
  const openA = document.getElementById('select-open-a')
  const openB = document.getElementById('select-open-b')
  const openLog = document.getElementById('select-open-log')
  const logOpen = (tag, open) => {
    openLog.textContent = `${tag} oas-open-change: ${open}`
  }
  openA?.addEventListener('oas-change', () => {
    if (openB) {
      openB.setAttribute('open', '')
      logOpen('②', true)
    }
  })
  openA?.addEventListener('oas-open-change', (e) => logOpen('①', e.detail.open))
  openB?.addEventListener('oas-open-change', (e) => {
    logOpen('②', e.detail.open)
    if (!e.detail.open) openB?.removeAttribute('open')
  })

  // Selected option demo: detail.option / detail.options carry full objects
  const detailEl = document.getElementById('select-option-detail')
  const detailOut = document.getElementById('select-option-detail-output')
  detailEl?.addEventListener('oas-change', (e) => {
    const opt = e.detail.option
    detailOut.textContent = opt
      ? `option: { label: ${opt.label}, value: ${opt.value}, group: ${opt.group ?? '—'} }`
      : 'option: null'
  })

  // Focus events demo
  const focusEl = document.getElementById('select-focus')
  const focusLog = document.getElementById('select-focus-log')
  focusEl?.addEventListener('oas-focus', () => {
    focusLog.textContent = 'oas-focus'
  })
  focusEl?.addEventListener('oas-blur', () => {
    focusLog.textContent = 'oas-blur'
  })

  // input-value controlled demo: attribute write-back + event echo
  const ivEl = document.getElementById('select-input-value')
  const ivOut = document.getElementById('select-input-value-output')
  ivEl?.addEventListener('oas-input-value-change', (e) => {
    ivOut.textContent = `input-value: ${JSON.stringify(e.detail.value)}`
  })

  // oas-create demo: creation feedback
  const createEl = document.getElementById('select-create')
  const createOut = document.getElementById('select-create-output')
  createEl?.addEventListener('oas-create', (e) => {
    createOut.textContent = `oas-create: ${e.detail.label}`
  })

  // Accessible name (label) demo: read the trigger aria-label (label set vs placeholder fallback)
  const readSelectLabel = () => {
    const a = document.getElementById('select-label-set')?.shadowRoot?.querySelector('[part="trigger"]')?.getAttribute('aria-label')
    const b = document.getElementById('select-label-fallback')?.shadowRoot?.querySelector('[part="trigger"]')?.getAttribute('aria-label')
    const out = document.getElementById('select-label-output')
    if (a !== undefined && b !== undefined && a !== null && b !== null && out) {
      out.textContent = `aria-label: set "${a}" / fallback "${b}"`
    } else {
      setTimeout(readSelectLabel, 60)
    }
  }
  readSelectLabel()
})
</script>

## required

<DemoBlock title="required (native validation chain)">
  <oas-select required name="fruit" placeholder="Fruit" options='[{"label":"Apple","value":"apple"},{"label":"Banana","value":"banana"}]'></oas-select>
</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.

## API

### oas-select

#### Attributes

| Attribute | Description | Type | Default |
| --- | --- | --- | --- |
| `allow-create` | Allow creating new options from the input value when nothing matches | `boolean` | — |
| `auto-width` | Trigger width shrinks to fit the selection | — | — |
| `autofocus` | Auto-focus the trigger after mount | `boolean` | — |
| `clearable` | Clearable (shows a clear button when a value exists; clearing dispatches `oas-clear`) | `boolean` | — |
| `debounce` | Remote search input debounce in ms (default 0 = immediate; only debounces oas-input in remote mode, local filtering stays instant) | `number` | — |
| `default-active-first-option` | Highlight the first visible option on open (defaults to the selected option) | `boolean` | — |
| `disabled` | Disabled | `boolean` | — |
| `hide-selected` | Hide selected options from the dropdown in multiple mode; they return after clearing or deselecting | `boolean` | — |
| `hint` | Hint line below the trigger, wired via aria-describedby | `string` | — |
| `input-value` | Controlled search text: typing writes back and emits oas-input-value-change; external updates sync into the search box | `string` | — |
| `item-height` | Fixed row height (px) when virtual scrolling | `string` | `36` |
| `label` | The trigger's accessible name (aria-label), taking priority over the placeholder | `string` | — |
| `loading` | Remote loading placeholder (use with `remote`) | `boolean` | — |
| `loading-text` | Custom loading placeholder copy (defaults to locale) | `string` | — |
| `max-count` | Multi-select limit: at the limit unselected options are disabled-greyed and oas-exceed-limit fires; selected items stay removable; single-select unchanged | `number` | — |
| `max-tag-count` | Collapse tags beyond this count into `+N` in multiple mode (opt-in; without it tags wrap instead of collapsing) | `number` | — |
| `multiple` | Multiple select | `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: attribute present = open, removed = closed; host gestures only fire oas-open-change (no forced write-back) | `boolean` | `false` |
| `options` | Options, JSON array `[{ label, value, disabled?, group? }]` | `Option[] \| string` | `[]` |
| `placeholder` | Placeholder text | — | — |
| `placement` | Dropdown direction: `auto` (default, bottom-first with flip) / `top` / `bottom` (forced, no flip) | `string` | `auto` |
| `readonly` | Read-only: focusable and copyable, no dropdown, value immutable (clear/remove buttons hidden) | `boolean` | — |
| `remote` | Remote search: no local filtering, typing dispatches `oas-input` for the host to request | `boolean` | — |
| `required` | Required marker (drives the native valueMissing validation chain; not passed through to the inner control) | `boolean` | — |
| `reserve-keyword` | Keep the keyword after selection in searchable mode (cleared by default, matching tree-select) | `boolean` | — |
| `reserve-width` | Reserve the widest option width on the trigger (opt-in): measures the max text width of all option labels plus the placeholder to lock the trigger min-width so switching values never jitters; pairs well with auto-width (the reserved value acts as the floor after shrinking); re-measured automatically when options/children change; removing the attribute releases the lock | `boolean` | — |
| `searchable` | Searchable (type to filter after opening the dropdown) | `boolean` | — |
| `show-arrow` | Set `"false"` to hide the default dropdown arrow | `string` | `true` |
| `size` | Size preset `small` / `medium` (default) / `large`: control height/font/chip height scale | `string` | `medium` |
| `status` | Validation status: `error` / `warning` / `success`; error mirrors aria-invalid on the host | `string` | — |
| `tabindex` | Forwarded to the internal trigger (host attribute removed after delegation to avoid double tab stops) | — | — |
| `value` | Current value (JSON array in multiple mode) | `string \| string[]` | — |
| `variant` | Visual variant: `outlined` (default) / `filled` / `borderless`; invalid values fall back silently; status colors take precedence | `string` | — |
| `virtual` | Virtual scrolling for large datasets: renders only the visible window (reuses oas-virtual-list); options with a `group` field fall back to full rendering | `boolean` | — |

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

| Property | Description | Type | Default |
| --- | --- | --- | --- |
| `value` | Current value: the selected value string, or an array of selected values when `multiple` (equivalent to reading the `value` attribute) | `string \| string[]` | — |

#### Events

| Event | Description |
| --- | --- |
| `oas-blur` | Fires when the component loses focus |
| `oas-change` | Selection/clear change, `detail: { value }` |
| `oas-clear` | Clear button clicked, `detail: { value }` (value before clearing) |
| `oas-create` | Dispatched when allow-create creates a new option, `detail: { value, label }` |
| `oas-exceed-limit` | Selection attempt past max-count (click/keyboard/create), `detail: { value, max }` |
| `oas-focus` | Fires when the component gains focus (trigger↔search inner moves are not reported) |
| `oas-input` | Input in `remote` mode, `detail: { value }` (for host requests) |
| `oas-input-value-change` | Dispatched on search-text change, `detail: { value }` |
| `oas-open-change` | Open state flips, `detail: { open }` |
| `oas-option-render` | Dispatched for each rendered option row, `detail: { index, option, element }` (element is the option label container; host can rewrite it into icon/rich text) |
| `oas-tag-render` | Dispatched when a multi-select tag renders, `detail: { value, label, element }` (element is the tag text container; host can rewrite it) |

#### Slots

| Name | Description |
| --- | --- |
| `template[slot="clear-icon"]` | Custom clear-button icon |
| `template[slot="empty"]` | Custom empty state (overrides both "no data" and "no match" defaults) |
| `template[slot="option"]` | Static option row template, cloned into each option label container; `[data-option-label]` nodes get bound to the option label |
| `template[slot="prefix"]` | Trigger prefix content template |
| `template[slot="suffix"]` | Trigger suffix content template |
| `template[slot="suffix-icon"]` | Custom trailing trigger icon (replaces the default arrow) |
| `template[slot="tag"]` | Static multi-select tag template, cloned into each chip text container; `[data-tag-label]` nodes get bound to the tag label |

#### CSS Variables

| CSS Variable | Default |
| --- | --- |
| `--oas-button-group-radius` | `var(--oas-radius-md)` |
| `--oas-glass-blur` | `none` |
| `--oas-glass-ring` | `transparent` |
| `--oas-select-dropdown-height` | `240px` |
| `--oas-select-reserve` | `0px` |

### oas-option

#### Attributes

| Attribute | Description | Type | Default |
| --- | --- | --- | --- |
| `disabled` | Disable this option (not selectable) | — | — |
| `group` | Group title (optional): options in the same group render a consecutive group title (not selectable), items are indented | — | — |
| `value` | Option value (data-carrier field of the declarative child-element channel) | — | — |

#### Slots

| Name | Description |
| --- | --- |
| default | Option label content (default slot text) |

> Options carrying a `group` field are rendered under a group title (not selectable), items are indented; keyboard navigation continues across groups.

Keyboard: `Enter` / `↓` to open, `↑`/`↓` to move the highlight (works inside the search box too), `Enter` to select, `Esc` to close.
