# 选择器

## 使用方法

`Selector` 是一个下拉选择器组件，用户点击后展开选项列表，选择一项后收起。它支持搜索过滤、清除选择、禁用选项等功能。

基本用法只需要传入选项数组和绑定值：

```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: '苹果' },
  { value: 'banana', label: '香蕉' },
  { value: 'cherry', label: '樱桃' },
]
</script>

<template>
  <Selector v-model="selected" :options="options" placeholder="选择水果" />
</template>
```

每个选项对象包含 `value`（唯一标识）和 `label`（显示文本）。可选的 `disabled` 属性可以禁用特定选项，`description` 可以添加辅助说明。

开启 `searchable` 属性后，选择器会显示搜索框，用户可以输入文字过滤选项。开启 `hasClear` 后，已选中的值旁边会出现清除按钮；`clearable` 是旧名字。

只读时当前值还在，可以聚焦，也会随表单提交，但打不开列表，也没有清除和展开箭头，外观不会变暗。同时禁用时按禁用处理。加载时触发器里是旋转指示，列表在选项到达之前不说「没有结果」。没有任何选项，和搜索之后没有命中，是两句不同的话。选中标记默认画在行尾，也可以改到行首；没选中时标记不占位。`disabledMessage` 用来解释为什么禁用：悬停和键盘聚焦时弹出提示，触发器靠 `aria-disabled` 保持可聚焦，但列表仍然打不开。

方向键上下移动高亮项，Home 和 End 跳到首尾，Enter 选中当前项，Escape 关闭列表。搜索框打开后，方向键和回车作用在搜索框上，空格仍用于输入。

菜单至少和触发器一样宽，选项更长时会跟着变宽。组件自带文案默认是英文，调用 `provideAstryxLocale('zh-CN')` 后改为中文。文档页会按当前语言自动提供。

### 结构

- **字段**: 提供标签和可选辅助内容的独立 Field 外壳。放在 InputGroup 里时不画这一层。
- **触发器**: 着色控件，显示当前选择或占位。可编辑时打开选择表面。
- **图标渲染的起始图标**: 可选的前导语义图标或图标组件，通过 Icon 绘制。
- **使用方渲染的起始内容**: 直接画在触发器开头的可选任意插槽内容。
- **触发器清除按钮**: 共用的清除操作。clearable 打开时去掉已选值。
- **状态图标**: 状态以附着或提示方式呈现时，用来代替展开指示的状态符号。
- **指示图标**: 状态呈现没有把它换掉时显示的尾部箭头，反映收起或展开。
- **搜索行**: 面板头部，带无边框搜索框和可选的清除操作。
- **搜索图标**: 搜索行里通过 Icon 画出的前导放大镜。
- **搜索清除按钮**: 已经有查询词时，搜索行里的共用清除操作。
- **选项行**: 一个选项所在的可选择行。
- **默认选项内容**: 没有提供 option 插槽时，从选项数据画出的标签、说明和图标。
- **选项插槽内容**: 通过 option 插槽提供的内容，用来代替默认的标签和说明。
- **选项选中指示**: 每个选项在已选或未选状态下画出的选中标记。解析后的指示什么都不画时，它占的布局空间会收起来。
- **选项分隔线**: 公开选项数据里用来分开相邻选项组的分隔线。
- **分区标题**: 一组带标签的选项行的可见标题。
- **空状态**: 共用面板里没有选项，或搜索没有匹配时的消息。
- **指针弹层**: 浮层呈现方式里，承载共用面板内容的锚定着色表面。
- **触摸面板标题**: 底部面板呈现方式里，共用面板内容上方的标题。
- **触摸面板**: 底部面板呈现方式里，承载同一套面板内容的 BottomSheet 表面。

## 最佳实践

| 指引 | 实践 |
| --- | --- |
| 推荐 | 选项数量超过 10 项时开启搜索功能，帮助用户快速定位。 |
| 推荐 | 为非必填字段提供清除按钮，让用户可以取消选择。 |
| 推荐 | 使用简洁清晰的选项标签，避免过长的文本。 |
| 避免 | 在选择器中放置超过 100 项选项，考虑分页或异步加载。 |
| 避免 | 使用选择器做多选，应使用专门的多选组件。 |

## 示例

### 底部弹层

在底部弹层里展示单选列表，适合紧凑的触控界面。

```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>
```

### 可清除

清除按钮可以把已选的值重置掉。

```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>
```

### 幽灵工具栏

无边框的选择器和幽灵按钮一起放在工具栏里。

```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>
```

### 次要样式

次要样式和次要按钮同一套静止外观：没有边框，铺一层中性底。

```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>
```

### 选项操作

选项可以带一个操作。点操作不会改当前选择。只要有一项带了操作，列表就变成两列。

```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>
```

### 分组

选项按带标题的分组排列。

```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>
```

### 选项说明

选项带说明时，菜单画成两行。单行的值保持控件高度，两行的值正好高出一行。放进输入组后，值仍保持一行。

```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>
```

### 校验状态

错误、警告和成功三种校验状态。

```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>
```

### 只读、禁用、加载和搜索

只读保留当前值，加载时不声称没有选项。必填标在标签旁。禁用的字段在悬停或键盘聚焦时用气泡说明原因，并且仍可聚焦。搜索没有命中时用另一句文案，选中标记可以画在行首。

```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

| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `modelValue` | `string \| number \| null` | `null` | 选中的值。 |
| `options` | `SelectorOption[]` | 必需 | 选项数组。每项可以带 `disabled`、`description` 和 `action`。只要有一项的 `action` 不是空的，弹出层就变成两列网格，点操作不会改选择。 |
| `placeholder` | `string` | `Select…` / `请选择` | 占位符文本。未设置语言时为英文，提供 `zh-CN` 后为中文。 |
| `searchable` | `boolean` | `false` | 是否支持搜索。 |
| `clearable` | `boolean` | `false` | 是否可清除。 |
| `hasClear` | `boolean` | `false` | 选中值时显示清除按钮，清除会把 `null` 交给 `change`。和旧名 `clearable` 等价，两者任一为真即生效。 |
| `disabled` | `boolean` | `false` | 是否禁用。 |
| `disabledMessage` | `string` | — | 禁用原因。和 `disabled` 一起用时，鼠标悬停或键盘聚焦会弹出提示，触发器靠 `aria-disabled` 保持可聚焦，但仍然打不开列表。不用再在外面套 Tooltip：禁用的控件收不到悬停事件。旧名 `disabledReason` 等价。 |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | 选择器尺寸。 |
| `variant` | `'input' \| 'secondary' \| 'ghost' \| 'default'` | `'default'` | `input` 和旧名 `default` 都是带边框的输入框。`secondary` 对齐次要按钮。`ghost` 去掉边框，适合工具栏。后两种上的 attached 状态会收成独立说明。 |
| `isLabelHidden` | `boolean` | `false` | 视觉上隐藏标签，读屏仍然读得到。 |
| `containerClass` / `containerStyle` | `class` / `style` | — | 加到字段外壳。不会落到触发按钮上。 |
| `controlClass` / `controlStyle` | `class` / `style` | — | 加到画边框的本体。根上直接写的 class 和 style 也落在这里。 |
| `status` | `'default' \| 'error' \| 'warning' \| 'success'` | `'default'` | 状态样式。 |
| `label` | `string` | — | 可见标签。不传时用占位文本作为隐藏的无障碍名称。 |
| `description` | `string` | — | 标签下方的说明。 |
| `isReadOnly` | `boolean` | `false` | 只读。值还在，可以聚焦并提交，但不能打开、清除。不变暗。和禁用同时设置时按禁用处理。 |
| `isLoading` | `boolean` | `false` | 触发器显示加载指示。加载完成前不显示空状态。 |
| `isOptional` | `boolean` | `false` | 在标签旁标成可选。 |
| `isRequired` | `boolean` | `false` | 在标签旁标成必填，并设置 aria-required。 |
| `indicatorPosition` | `'start' \| 'end'` | `'end'` | 单选标记放在选项的哪一侧。没选中时默认的勾不占位。主题把勾换成圆点后，没选中的行也会画出空心圆。 |
| `emptyText` | `string` | 无匹配项 / No matches | 没有任何选项时的文案。 |
| `emptySearchText` | `string` | 未找到结果 / No results found | 搜索没有命中时的文案。 |

## Selector Emits

| 名称 | 载荷 | 说明 |
| --- | --- | --- |
| `update:modelValue` | `value: string \| number \| null` | 值变化时触发。 |
| `change` | `value: string \| number \| null` | 值变化时触发。 |
| `search` | `query: string` | 搜索输入时触发。 |
| `click` | `event: MouseEvent` | 触发器上的点击，在面板切换之前触发。`preventDefault()` 不会让面板保持关闭。 |
| `focus` | `event: FocusEvent` | 触发器获得焦点时触发。 |
| `keydown` | `event: KeyboardEvent` | 触发器上内置键盘模型没处理的按键才触发。它处理并取消的按键不再透出。 |

## Selector Slots

| 名称 | 参数 | 说明 |
| --- | --- | --- |
| `selected` | `{ option: SelectorOption \| null }` | 自定义选中项显示。 |
| `option` | `{ option: SelectorOption, isActive: boolean, isSelected: boolean }` | 自定义选项显示。 |
| `empty` | — | 自定义空状态。 |
