# 多选器

## 使用方法

`MultiSelector` 从固定列表里选多项。触发器在没有搜索框时是 combobox，列表是 `aria-multiselectable` 的 listbox。选中一项不会关闭列表。

选项可以是字符串、带说明的对象、分隔线，或带标题的分组。`selectAll` 在列表顶部加一行全选，禁用项不会被全选带上。`searchable` 会在列表里显示搜索框，只过滤当前列表，不请求远程数据。

`triggerDisplay` 决定关闭后怎么展示已选项：`count` 显示「已选 N 项」，`labels` 拼接文字，`badges` 用标签。`maxBadges` 限制露出的个数，放不下时末尾显示 `+N`。宽度更窄时还会再收一次。

只读时已选值还在，可以聚焦并提交，但打不开列表，也没有清除和展开箭头，外观不会变暗。加载时触发器显示旋转指示，这时不提供创建行，也不说没有选项。`disabledMessage` 用来解释为什么禁用：悬停和键盘聚焦时弹出提示，触发器靠 `aria-disabled` 保持可聚焦，菜单仍然打不开。

关闭后的文字可以用函数改写，只对数量和标签两种展示生效，标签模式会忽略它。没有任何选中项时不调用，继续显示占位符。

和搜索一起打开创建后，输入的文字和任何选项标签都不相同（忽略大小写）时，列表第一行是创建。选中后，变更事件会多带一段描述，里面是创建和当时的文字。调用方要在同一次更新里把这个选项加进列表。没有搜索时不会出现创建行。

从外面开关列表时，在脚本里把句柄对象本身传进去。模板会把 ref 自动拆开，所以模板里要传一个接收句柄的函数。只读和禁用时，打开不会生效。

方向键移动高亮项，Enter 和空格切换选中。搜索框里的空格仍用于输入。没有高亮项时，Enter 会创建当前文字。Escape 关闭列表。关闭且没有在搜索时，Backspace 清空已选项。

### 结构

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

## 最佳实践

| 指引 | 实践 |
| --- | --- |
| 推荐 | 选项不多、用户需要对照着勾选时用多选器。 |
| 推荐 | 已选项要在触发器里认出来时，用 `badges` 并加上 `+N`。 |
| 避免 | 用多选器搜索远程数据。那种场景用标签输入框。 |

## 示例

### 底部弹层

在底部弹层里勾选，列表保持打开。

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

const selected = ref<string[]>([])
const options = computed(() => [
  'Design',
  'Engineering',
  'Marketing',
  'Operations',
])
</script>

<template>
  <div class="demo-field">
    <MultiSelector
      v-model="selected"
      :options="options"
      label="Teams"
      placeholder="Choose teams"
      presentation="bottom-sheet"
      select-all
    />
  </div>
</template>

<style scoped>
.demo-field {
  width: 20rem;
  max-width: calc(100vw - 4rem);
}
</style>
```

### 列可见性

隐藏标签，可搜索、可全选，触发器显示已选数量。

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

const selected = ref(['name', 'email', 'role', 'status'])
const options = computed(() => [
  { value: 'name', label: 'Name' },
  { value: 'email', label: 'Email' },
  { value: 'role', label: 'Role' },
  { value: 'status', label: 'Status' },
  { value: 'created', label: 'Created' },
  { value: 'updated', label: 'Updated' },
  { value: 'actions', label: 'Actions' },
])
</script>

<template>
  <div class="demo-field">
    <MultiSelector
      v-model="selected"
      :options="options"
      label="Columns"
      label-hidden
      placeholder="Columns"
      searchable
      select-all
      trigger-display="count"
    />
  </div>
</template>

<style scoped>
.demo-field {
  width: 20rem;
  max-width: calc(100vw - 4rem);
}
</style>
```

### 表单组合

表单里两个多选器，分别标成必填和可选，另有一个禁用的在悬停或键盘聚焦时说明原因。

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

const columns = ref(['name', 'email'])
const filters = ref<string[]>([])
const lockedColumns = ref(['name'])
</script>

<template>
  <div class="demo-stack">
    <MultiSelector
      v-model="columns"
      :options="[
        { value: 'name', label: 'Name' },
        { value: 'email', label: 'Email' },
        { value: 'role', label: 'Role' },
        { value: 'status', label: 'Status' },
        { value: 'created', label: 'Created at' },
      ]"
      label="Visible columns"
      description="Choose which columns to display in the table"
      required
      select-all
      trigger-display="labels"
    />
    <MultiSelector
      v-model="filters"
      :options="[
        'Active',
        'Inactive',
        'Pending',
        'Archived',
      ]"
      label="Status filter"
      description="Filter by status"
      placeholder="All statuses"
      optional
      trigger-display="badges"
    />
    <MultiSelector
      v-model="lockedColumns"
      :options="[
        { value: 'name', label: 'Name' },
        { value: 'email', label: 'Email' },
      ]"
      label="Locked columns"
      disabled
      disabled-message="Ask an administrator to change the saved view"
    />
  </div>
</template>

<style scoped>
.demo-stack {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  width: 20rem;
  max-width: calc(100vw - 4rem);
}
</style>
```

### 幽灵工具栏

无边框变体，和幽灵按钮排在同一条工具栏里。

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

const columns = ref(['name', 'email'])
const filters = ref(['active'])
</script>

<template>
  <div class="toolbar">
    <Button label="Refresh" variant="ghost" />
    <MultiSelector
      v-model="columns"
      :options="[
        { value: 'name', label: 'Name' },
        { value: 'email', label: 'Email' },
        { value: 'role', label: 'Role' },
        { value: 'status', label: 'Status' },
        { value: 'created', label: 'Created' },
      ]"
      label="Columns"
      label-hidden
      variant="ghost"
      trigger-display="labels"
      placeholder="Columns"
      width="max-content"
    />
    <MultiSelector
      v-model="filters"
      :options="[
        { value: 'active', label: 'Active' },
        { value: 'inactive', label: 'Inactive' },
        { value: 'pending', label: 'Pending' },
        { value: 'archived', label: 'Archived' },
      ]"
      label="Status"
      label-hidden
      variant="ghost"
      trigger-display="labels"
      placeholder="Status"
      status="warning"
      status-message="Some filters hide archived rows"
      status-variant="tooltip"
      width="max-content"
    />
    <Button label="Export" variant="ghost" />
  </div>
</template>

<style scoped>
.toolbar {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
  align-items: center;
  max-width: 100%;
}
</style>
```

### 可搜索

可搜索，并带全选。

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

const selected = ref<string[]>([])
</script>

<template>
  <div class="demo-field">
    <MultiSelector
      v-model="selected"
      :options="[
        'United States',
        'United Kingdom',
        'Canada',
        'Australia',
        'Germany',
        'France',
        'Japan',
        'Brazil',
        'India',
        'Mexico',
      ]"
      label="Countries"
      placeholder="Select countries..."
      searchable
      select-all
    />
  </div>
</template>

<style scoped>
.demo-field {
  width: 20rem;
  max-width: calc(100vw - 4rem);
}
</style>
```

### 分组权限

选项按标题分成几组。

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

const selected = ref<string[]>([])
const options = computed(() => [
  {
    type: 'section' as const,
    label: 'Read',
    options: [
      { value: 'read_posts', label: 'Read posts' },
      { value: 'read_comments', label: 'Read comments' },
      { value: 'read_users', label: 'Read users' },
    ],
  },
  {
    type: 'section' as const,
    label: 'Write',
    options: [
      { value: 'write_posts', label: 'Write posts' },
      { value: 'write_comments', label: 'Write comments' },
    ],
  },
])
</script>

<template>
  <div class="demo-field">
    <MultiSelector
      v-model="selected"
      :options="options"
      label="Permissions"
      placeholder="Select permissions..."
    />
  </div>
</template>

<style scoped>
.demo-field {
  width: 20rem;
  max-width: calc(100vw - 4rem);
}
</style>
```

### 创建和只读

输入现有列表里没有的名字可以创建。关闭后的文字可以改成自己的句子。只读保留已选值。

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

interface Permission {
  value: string
  label: string
}

const base: Permission[] = [
  { value: 'read', label: 'Read' },
  { value: 'billing', label: 'Billing' },
]

const options = ref<Permission[]>([...base])
const selected = ref<string[]>([])
const summary = ref(['read', 'billing'])
const locked = ref(['read'])

function onChange(values: (string | number)[], change?: MultiSelectorCreateChange): void {
  selected.value = values.map(String)
  if (change?.type !== 'create')
    return
  const query = change.query
  if (options.value.some(option => option.label.toLowerCase() === query.toLowerCase()))
    return
  options.value = [...options.value, { value: query, label: query }]
}
</script>

<template>
  <div style="width: 320px; max-width: 100%">
    <VStack :gap="6">
      <MultiSelector
        :model-value="selected"
        label="Permissions"
        :options="options"
        searchable
        has-create
        @change="onChange"
      />
      <MultiSelector
        v-model="summary"
        label="Summary"
        :options="base"
        trigger-display="labels"
        :format-value="items => items.map(item => item.label).join(' & ')"
      />
      <MultiSelector v-model="locked" label="Locked" :options="base" is-read-only />
    </VStack>
  </div>
</template>
```

## MultiSelector Props

| 名称 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `modelValue` | `(string \| number)[]` | `[]` | 已选值。 |
| `options` | `MultiSelectorOption[]` | 必需 | 字符串、选项对象、分隔线或分组。 |
| `label` | `string` | 必需 | 字段标签。 |
| `isLabelHidden` | `boolean` | `false` | 视觉上隐藏标签，读屏仍然读得到。旧名 `labelHidden` 同样可用，两者任一为真就隐藏。 |
| `placeholder` | `string` | `Select…` / `请选择` | 没有选中项时的占位符。 |
| `searchable` | `boolean` | `false` | 在列表里显示搜索框。 |
| `selectAll` | `boolean` | `false` | 显示全选行。跳过禁用项。 |
| `triggerDisplay` | `'count' \| 'labels' \| 'badges'` | `'count'` | 关闭后如何展示已选项。 |
| `maxBadges` | `number` | `3` | `badges` 和 `labels` 最多露出几项，更窄时还会按宽度再收。 |
| `clearable` | `boolean` | `false` | 是否可清除。 |
| `hasClear` | `boolean` | `false` | 有选中项时显示清除按钮。和旧名 `clearable` 等价，两者任一为真即生效。 |
| `checkboxPosition` | `'start' \| 'end'` | `'start'` | 勾选标记的位置。 |
| `variant` | `'input' \| 'secondary' \| 'ghost'` | `'input'` | `input` 带边框。`secondary` 对齐次要按钮。`ghost` 适合工具栏。后两种上的 attached 状态会收成独立说明。 |
| `containerClass` / `controlClass` | `class` | — | 分别加到字段外壳和触发器本体。根上的 class 落在本体。选项上的 `action` 和单选一样：点它不改选择，面板变成两列网格。 |
| `disabled` | `boolean` | `false` | 是否禁用。 |
| `disabledMessage` | `string` | — | 禁用原因。和 `disabled` 一起用时，鼠标悬停或键盘聚焦会弹出提示，触发器靠 `aria-disabled` 保持可聚焦，但仍然打不开列表。不用再在外面套 Tooltip：禁用的控件收不到悬停事件。旧名 `disabledReason` 等价。 |
| `disabledReason` | `string` | — | 禁用原因。有值时控件仍可聚焦。和 `disabledMessage` 等价。 |
| `isDefaultOpen` | `boolean` | `false` | 挂载后就展开列表。 |
| `status` | `'default' \| 'success' \| 'warning' \| 'error'` | `'default'` | 状态样式。 |
| `statusMessage` | `string` | — | 字段下方的状态说明。 |
| `statusVariant` | `'attached' \| 'detached' \| 'tooltip'` | `'attached'` | 状态说明的位置。ghost 会把 attached 收成独立说明。tooltip 把说明留在字段里的图标上。 |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | 尺寸。 |
| `name` | `string` | — | 表单字段名。每个已选值提交一次。 |
| `isReadOnly` | `boolean` | `false` | 只读。已选值还在，可以聚焦并提交，但不能打开或清除。不变暗。 |
| `isLoading` | `boolean` | `false` | 触发器显示加载指示。加载时没有创建行，也不显示空状态。 |
| `formatValue` | `(items) => string` | — | 自定义关闭后的文字。只用于数量和标签两种展示，标签徽章会忽略。没有选中项时不调用。 |
| `hasCreate` | `boolean` | `false` | 和搜索一起用。输入和任何标签都不相同（忽略大小写）时，第一行是创建。 |
| `handleRef` | `Ref \| (handle) => void` | — | 从外面打开、关闭或查询。模板里传函数。只读和禁用时打开无效。 |
| `emptyText` | `string` | 无匹配项 / No matches | 列表里没有任何选项时的文案。 |
| `emptySearchText` | `string` | 未找到结果 / No results found | 搜索没有命中时的文案。 |

## MultiSelector Emits

| 名称 | 载荷 | 说明 |
| --- | --- | --- |
| `update:modelValue` | `value: (string \| number)[]` | 选中项变化时触发。 |
| `change` | `value, change?` | 选中项变化时触发。创建时第二参是 `{ type: 'create', query }`。 |
| `search` | `query: string` | 搜索文字变化时触发。 |
| `openChange` | `isOpen: boolean` | 列表打开或关闭时触发。 |
| `click` | `event: MouseEvent` | 触发器上的点击，在列表切换之后触发。取消不了切换。 |
| `focus` | `event: FocusEvent` | 触发器获得焦点时触发。 |
| `keydown` | `event: KeyboardEvent` | 触发器上内置键盘模型没处理的按键才触发。它处理并取消的按键不再透出。 |

## MultiSelector Slots

| 名称 | 参数 | 说明 |
| --- | --- | --- |
| `icon` | — | 触发器开头的图标。 |
| `option` | `{ option, selected, active }` | 自定义选项。 |
| `empty` | — | 自定义空状态。 |
| `trigger` | `{ id, onClick, onKeyDown, onFocus, aria-haspopup, aria-expanded, aria-controls, aria-busy, triggerRef }` | 用一个自制控件（行内图标、芯片、图标按钮）替掉整个字段和触发器。把绑定铺到控件上，并把 `triggerRef` 作为 `:ref` 传进去，列表就以它为锚点。字段外壳（标签、状态、清除、加载）都不再渲染。 |
