# Selector

## Usage

`Selector` opens a list when the trigger is clicked. Choosing one option closes the list. It can filter the list, clear the current value, and disable individual options.

Pass the options and bind the value:

```vue
<script setup lang="ts">
import { Selector } from '@astryx-vue/core/Selector'
import { ref } from 'vue'

const selected = ref(null)
const options = [
  { value: 'apple', label: 'Apple' },
  { value: 'banana', label: 'Banana' },
  { value: 'cherry', label: 'Cherry' },
]
</script>

<template>
  <Selector v-model="selected" :options="options" placeholder="Select a fruit" />
</template>
```

Each option has a `value` and a `label`. Set `disabled` to skip an option, or `description` to add a second line of text.

`searchable` shows a search field that filters the list. `hasClear` adds a clear button next to the current value; `clearable` is the older name. The menu is at least as wide as the trigger, and grows when an option label is longer.

A read-only field keeps the current value, stays focusable, and still submits with the form. It does not open, and it has no clear button or chevron. It does not dim. Disabled wins when both are set. While loading, the trigger shows a spinner and the menu does not claim that nothing was found. An empty catalog and a search with no matches use different sentences. The check mark sits at the end of a selected row by default, and can move to the start. An unselected row does not reserve space for it. `disabledMessage` explains a disabled field: a tooltip follows hover and keyboard focus, the trigger stays focusable through `aria-disabled`, and the list still does not open.

Arrow keys move the highlight. Home and End jump to the ends. Enter selects the highlighted option, and Escape closes the list. Once the search field is open, the arrow keys and Enter apply there, and Space still types a character.

Built-in strings such as the placeholder and the empty state are English. Call `provideAstryxLocale('zh-CN')` to use the Chinese strings.

## Best Practices

| Guideline | Practice |
| --- | --- |
| Do | Enable search when options exceed 10 items to help users find items quickly. |
| Do | Provide a clear button for non-required fields so users can deselect. |
| Do | Use concise, clear option labels and avoid overly long text. |
| Don't | Put more than 100 options in a selector; consider pagination or async loading. |
| Don't | Use selector for multi-select; use a dedicated multi-select component instead. |

### Anatomy

- **Field**: Standalone Field shell that provides the label and optional supporting content; omitted inside InputGroup.
- **Trigger**: Painted control that displays the current selection or placeholder and opens the selection surface when editable.
- **Icon-rendered start icon**: Optional leading semantic icon or icon component rendered through Icon.
- **Caller-rendered start content**: Optional arbitrary slot content rendered directly at the start of the trigger.
- **Trigger clear button**: Shared clear action that removes the selected value when clearable is set.
- **Status icon**: Status glyph shown in place of the disclosure indicator for attached or tooltip status.
- **Indicator icon**: Trailing chevron shown when status presentation does not replace it; reflects collapsed or expanded state.
- **Search row**: Panel header with a borderless search input and optional clear action.
- **Search icon**: Leading magnifier rendered through Icon inside the search row.
- **Search clear button**: Shared clear action shown in the search row while a query is present.
- **Option row**: Selectable row for one supplied option.
- **Default option content**: Label, description, and icon drawn from the option data when the option slot is not provided.
- **Option slot content**: Content supplied through the option slot, in place of the default label and description.
- **Option selection indicator**: Resolved selection mark rendered for each option in its checked or unchecked state. Its layout space collapses when the resolved indicator draws nothing.
- **Option divider**: Divider supplied in the public options data to separate adjacent option groups.
- **Section heading**: Visible heading for a labeled group of option rows.
- **Empty state**: Message shown when the shared panel content has no options or no search matches.
- **Pointer popup**: Anchored painted surface that hosts the shared panel content for popover presentation.
- **Touch sheet heading**: Heading above the shared panel content in bottom-sheet presentation.
- **Touch sheet**: BottomSheet surface that hosts the same panel content for bottom-sheet presentation.

## Examples

### Bottom sheet

Presents a single-selection list in a bottom sheet for compact touch interfaces.

```vue
<script setup lang="ts">
import { Selector } from '@astryx-vue/core/Selector'
import { ref } from 'vue'

const value = ref<string | undefined>()
const options = [
  { value: 'design', label: 'Design' },
  { value: 'engineering', label: 'Engineering' },
  { value: 'marketing', label: 'Marketing' },
  { value: 'operations', label: 'Operations' },
]
</script>

<template>
  <div style="width: 320px; max-width: 100%">
    <Selector
      v-model="value"
      label="Team"
      :options="options"
      placeholder="Choose a team"
      presentation="bottom-sheet"
    />
  </div>
</template>
```

### Clearable

A clear button resets the selected value.

```vue
<script setup lang="ts">
import { Selector } from '@astryx-vue/core/Selector'
import { ref } from 'vue'

const value = ref<string | null>('engineering')
const options = [
  { value: 'engineering', label: 'Engineering' },
  { value: 'design', label: 'Design' },
  { value: 'marketing', label: 'Marketing' },
  { value: 'sales', label: 'Sales' },
]
</script>

<template>
  <Selector
    v-model="value"
    label="Department"
    :options="options"
    placeholder="Choose a department..."
    has-clear
    :width="300"
  />
</template>
```

### Ghost toolbar

A borderless selector sits with ghost buttons in a toolbar.

```vue
<script setup lang="ts">
import { Button } from '@astryx-vue/core/Button'
import { Selector } from '@astryx-vue/core/Selector'
import { HStack } from '@astryx-vue/core/Stack'
import { ref } from 'vue'

const view = ref<string | undefined>('week')
const density = ref<string | undefined>('comfortable')
</script>

<template>
  <HStack align="center" :gap="2">
    <Button label="Today" variant="ghost" />
    <Selector
      v-model="view"
      label="Calendar view"
      is-label-hidden
      variant="ghost"
      :options="[
        { value: 'day', label: 'Day' },
        { value: 'week', label: 'Week' },
        { value: 'month', label: 'Month' },
      ]"
    />
    <Selector
      v-model="density"
      label="Density"
      is-label-hidden
      variant="ghost"
      status="warning"
      status-message="This setting affects all users"
      status-variant="tooltip"
      :options="[
        { value: 'compact', label: 'Compact' },
        { value: 'comfortable', label: 'Comfortable' },
        { value: 'spacious', label: 'Spacious' },
      ]"
    />
    <Button label="Export" variant="ghost" />
  </HStack>
</template>
```

### Secondary

The secondary trigger rests like a secondary button: no border, and a neutral fill.

```vue
<script setup lang="ts">
import { Selector } from '@astryx-vue/core/Selector'
import { ref } from 'vue'

const priority = ref('normal')
</script>

<template>
  <Selector
    v-model="priority"
    label="Priority"
    variant="secondary"
    :options="[
      { value: 'low', label: 'Low' },
      { value: 'normal', label: 'Normal' },
      { value: 'high', label: 'High' },
    ]"
  />
</template>
```

### Option actions

An option can carry an action. Activating it does not change the selection. Once any option has an action, the menu becomes two columns.

```vue
<script setup lang="ts">
import { Selector } from '@astryx-vue/core/Selector'
import { h, ref } from 'vue'

const city = ref('paris')
const edited = ref('')

function edit(name: string) {
  return h('button', {
    type: 'button',
    onClick: () => {
      edited.value = name
    },
  }, 'Edit')
}
</script>

<template>
  <div>
    <Selector
      v-model="city"
      label="City"
      :options="[
        { value: 'paris', label: 'Paris', action: edit('Paris') },
        { value: 'lyon', label: 'Lyon', action: edit('Lyon') },
      ]"
    />
    <p v-if="edited">
      Last edited: {{ edited }}
    </p>
  </div>
</template>
```

### Grouped sections

Options are grouped into labeled sections.

```vue
<script setup lang="ts">
import { Selector } from '@astryx-vue/core/Selector'
import { ref } from 'vue'

const value = ref<string | undefined>()
const options = [
  {
    type: 'section' as const,
    title: 'North America',
    options: [
      { value: 'nyc', label: 'New York' },
      { value: 'sf', label: 'San Francisco' },
      { value: 'sea', label: 'Seattle' },
    ],
  },
  {
    type: 'section' as const,
    title: 'Europe',
    options: [
      { value: 'ldn', label: 'London' },
      { value: 'ber', label: 'Berlin' },
    ],
  },
  {
    type: 'section' as const,
    title: 'Asia Pacific',
    options: [
      { value: 'tyo', label: 'Tokyo' },
      { value: 'sgp', label: 'Singapore' },
    ],
  },
]
</script>

<template>
  <Selector
    v-model="value"
    label="Office"
    :options="options"
    placeholder="Choose an office..."
    :width="300"
  />
</template>
```

### Option descriptions

Options carry a description, so the menu draws a two-line row. A one-line value stays at the size token. A two-line value grows by one line. Inside an input group the value stays on one line.

```vue
<script setup lang="ts">
import { Button } from '@astryx-vue/core/Button'
import { Field } from '@astryx-vue/core/Field'
import { InputGroup } from '@astryx-vue/core/InputGroup'
import { Selector } from '@astryx-vue/core/Selector'
import { VStack } from '@astryx-vue/core/Stack'
import { ref } from 'vue'

const visibility = [
  {
    value: 'private',
    label: 'Private',
    icon: 'css-icon css-icon--sm i-carbon-locked',
    description: 'Only members can access this space and its content.',
  },
  {
    value: 'public',
    label: 'Public',
    icon: 'css-icon css-icon--sm i-carbon-earth',
    description: 'Anyone at the company can find and join this space.',
  },
]

const condensed = ref('private')
const full = ref('private')
const grouped = ref('private')
</script>

<template>
  <VStack :gap="6" style="width: min(100%, 560px)">
    <Selector
      v-model="condensed"
      label="Visibility"
      description="Default trigger: one line at the size token."
      :options="visibility"
    />
    <Selector
      v-model="full"
      label="Visibility"
      description="A stacked value: the description gets its own line, and the trigger grows by exactly one line."
      :options="visibility"
    >
      <template #selected="{ option }">
        <span v-if="option" class="selector-value">
          <span :class="option.icon" aria-hidden="true" />
          <span>
            <span class="selector-value__label">{{ option.label }}</span>
            <span class="selector-value__description">{{ option.description }}</span>
          </span>
        </span>
      </template>
    </Selector>
    <Field input-id="visibility-grouped" label="Visibility">
      <InputGroup>
        <Selector
          v-model="grouped"
          label="Visibility"
          is-label-hidden
          :options="visibility"
        >
          <template #selected="{ option }">
            <span v-if="option" class="selector-value selector-value--inline">
              <span :class="option.icon" aria-hidden="true" />
              <span class="selector-value__label">{{ option.label }}</span>
              <span class="selector-value__description">{{ option.description }}</span>
            </span>
          </template>
        </Selector>
        <template #after>
          <Button label="Save" />
        </template>
      </InputGroup>
    </Field>
  </VStack>
</template>

<style scoped>
.selector-value {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  min-width: 0;
}

.selector-value--inline {
  align-items: center;
  flex: 1;
  width: 100%;
  min-width: 0;
}

.selector-value--inline > :first-child {
  flex: none;
}

.selector-value > span {
  min-width: 0;
  overflow: hidden;
}

.selector-value__label,
.selector-value__description {
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.selector-value--inline .selector-value__label {
  flex: none;
}

.selector-value--inline .selector-value__description {
  flex: 1 1 auto;
  min-width: 0;
  margin-top: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.selector-value__description {
  color: var(--astryx-color-text-secondary, #525252);
  font-size: 0.75rem;
  line-height: 1.4;
}
</style>
```

### Validation states

Error, warning, and success validation states.

```vue
<script setup lang="ts">
import { Selector } from '@astryx-vue/core/Selector'
import { VStack } from '@astryx-vue/core/Stack'
import { ref } from 'vue'

const roles = [
  { value: 'admin', label: 'Admin' },
  { value: 'editor', label: 'Editor' },
  { value: 'viewer', label: 'Viewer' },
]

const empty = ref<string | undefined>()
const viewer = ref('viewer')
const admin = ref('admin')
</script>

<template>
  <div style="width: 300px; max-width: 100%">
    <VStack :gap="6">
      <Selector
        v-model="empty"
        label="Role"
        :options="roles"
        placeholder="Choose a role..."
        status="error"
        status-message="Please select a role"
      />
      <Selector
        v-model="viewer"
        label="Role"
        :options="roles"
        status="warning"
        status-message="Viewer has limited access"
      />
      <Selector
        v-model="admin"
        label="Role"
        :options="roles"
        status="success"
      />
    </VStack>
  </div>
</template>
```

### Read-only, disabled, loading, and search

Read-only keeps the current value. Loading does not claim the catalog is empty. Required is marked beside the label. A disabled field explains itself in a tooltip on hover or keyboard focus and stays focusable. A search with no matches uses its own sentence, and the check mark can sit at the start of the row.

```vue
<script setup lang="ts">
import { Selector } from '@astryx-vue/core/Selector'
import { VStack } from '@astryx-vue/core/Stack'
import { ref } from 'vue'

const fruits = [
  { value: 'apple', label: 'Apple' },
  { value: 'banana', label: 'Banana' },
  { value: 'cherry', label: 'Cherry' },
]

const saved = ref('banana')
const pending = ref<string | null>(null)
const required = ref<string | null>(null)
const search = ref<string | null>('apple')
const locked = ref<string | null>('banana')
</script>

<template>
  <div style="width: 320px; max-width: 100%">
    <VStack :gap="6">
      <Selector v-model="saved" label="Saved fruit" :options="fruits" is-read-only />
      <Selector v-model="pending" label="Catalog" :options="[]" is-loading placeholder="Loading fruit" />
      <Selector v-model="required" label="Required fruit" :options="fruits" is-required />
      <Selector
        v-model="locked"
        label="Locked fruit"
        :options="fruits"
        disabled
        disabled-message="Only the owner of this list can change it"
      />
      <Selector
        v-model="search"
        label="Search fruit"
        :options="fruits"
        searchable
        indicator-position="start"
        empty-search-text="Nothing like that"
      />
    </VStack>
  </div>
</template>
```

## Selector Props

| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `modelValue` | `string \| number \| null` | `null` | Selected value. |
| `options` | `SelectorOption[]` | Required | Options. An `action` on any item latches the popup into a two-column grid. Activating the action does not change the selection. |
| `placeholder` | `string` | `Select…` / `请选择` | Placeholder text. English by default; Chinese when `zh-CN` is provided. |
| `searchable` | `boolean` | `false` | Whether to support search. |
| `clearable` | `boolean` | `false` | Whether clearable. |
| `hasClear` | `boolean` | `false` | Shows a clear button when a value is selected. The clear also passes `null` to `change`. `clearable` is the older name; either one turns it on. |
| `disabled` | `boolean` | `false` | Whether disabled. |
| `disabledMessage` | `string` | — | Why the field is disabled. With `disabled`, hovering or keyboard focus shows a tooltip and the trigger stays focusable through `aria-disabled`; it still cannot open the list. Do not wrap a disabled selector in a tooltip: it would swallow the hover. `disabledReason` is the older name. |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Size. |
| `variant` | `'input' \| 'secondary' \| 'ghost' \| 'default'` | `'default'` | `input` and the older name `default` are the bordered field. `secondary` matches a secondary button. `ghost` is borderless. On the latter two, an attached status becomes detached. |
| `isLabelHidden` | `boolean` | `false` | Hides the label visually. The accessible name stays. |
| `containerClass` / `containerStyle` | `class` / `style` | — | Styles for the field shell. They do not land on the trigger button. |
| `controlClass` / `controlStyle` | `class` / `style` | — | Styles for the bordered box. A class or style on the component root lands here too. |
| `status` | `'default' \| 'error' \| 'warning' \| 'success'` | `'default'` | Status. |
| `label` | `string` | — | Visible label. Without it, the placeholder is the hidden accessible name. |
| `description` | `string` | — | Help text under the label. |
| `isReadOnly` | `boolean` | `false` | Read-only. The value stays, can be focused and submitted, and cannot be opened or cleared. It does not dim. Disabled wins when both are set. |
| `isLoading` | `boolean` | `false` | Shows a spinner in the trigger. The empty state stays hidden until loading finishes. |
| `isOptional` | `boolean` | `false` | Marks the label as optional. |
| `isRequired` | `boolean` | `false` | Marks the label as required and sets aria-required. |
| `indicatorPosition` | `'start' \| 'end'` | `'end'` | Which edge of the option holds the selection mark. An unchosen check takes no space. After the theme maps that mark to a circle, an unchosen row draws the empty circle too. |
| `emptyText` | `string` | No matches / 无匹配项 | Shown when the catalog itself is empty. |
| `emptySearchText` | `string` | No results found / 未找到结果 | Shown when a search matches nothing. |

## Selector Emits

| Name | Parameters | Description |
| --- | --- | --- |
| `update:modelValue` | `value: string \| number \| null` | Emitted when value changes. |
| `change` | `value: string \| number \| null` | Emitted when value changes. |
| `search` | `query: string` | Emitted when search input changes. |
| `click` | `event: MouseEvent` | Emitted for a click on the trigger, before the list toggles. `preventDefault()` does not keep it closed. |
| `focus` | `event: FocusEvent` | Emitted when the trigger takes focus. |
| `keydown` | `event: KeyboardEvent` | Emitted for keys on the trigger that the built-in keyboard model leaves alone. Keys it handles and cancels are not emitted. |

## Selector Slots

| Name | Parameters | Description |
| --- | --- | --- |
| `selected` | `{ option: SelectorOption \| null }` | Customize selected item display. |
| `option` | `{ option: SelectorOption, isActive: boolean, isSelected: boolean }` | Customize option display. |
| `empty` | — | Customize empty state. |
