<script setup lang="ts">
import { ref } from 'vue'
import { MultiCombobox } from '@hina-ui/vue'
const studios = ref<Array<string | number>>([1])
const options = [
{ value: 1, label: 'Key' },
{ value: 2, label: 'Type-Moon' },
{ value: 3, label: 'Nitroplus' },
{ value: 4, label: 'Leaf' },
{ value: 5, label: 'Frontwing' },
{ value: 6, label: 'Yuzusoft' },
]
</script>
<template>
<MultiCombobox v-model="studios" :options="options" aria-label="制作公司" class="w-80" />
</template>
用法
import { MultiCombobox } from '@hina-ui/vue'
多选组合框是 Combobox 的多值形态:在同一个输入面里输入文字缩小范围,从列表中选择多项,已选项以标签排在输入区前面,放不下时换行。v-model 绑定选中值的数组,options 的类型见 Select。未声明的属性都会传给内部的文本输入,请用 aria-label 或者 aria-labelledby 命名。
还没有选择
<script setup lang="ts">
import { ref } from 'vue'
import { MultiCombobox, Stack, Text } from '@hina-ui/vue'
const genres = ref<Array<string | number>>([])
const options = [
{ value: 'school', label: '校园' },
{ value: 'sf', label: '科幻' },
{ value: 'romance', label: '恋爱' },
{ value: 'mystery', label: '悬疑' },
{ value: 'fantasy', label: '奇幻' },
{ value: 'daily', label: '日常' },
]
</script>
<template>
<Stack gap="sm" class="w-full max-w-sm">
<MultiCombobox v-model="genres" :options="options" placeholder="输入题材" aria-label="题材" />
<Text tone="muted" size="sm">{{ genres.length ? genres.join('、') : '还没有选择' }}</Text>
</Stack>
</template>
它与 MultiSelect 的区别在于能否输入:多选选择器只能从固定的选项里挑选,浮层打开期间锁定页面滚动;多选组合框通过输入文字缩小范围,浮层不锁定页面滚动,输入区始终可以输入。与 TagsInput 的区别在于值的来源:标签输入框接受任意文字,这里的值必须来自选项。
示例
远程搜索
ignoreFilter 关闭本地筛选,v-model:search 提供输入文字,远程搜索结果直接传给 options。selectedOptions 单独提供已选项资料,Chip 按 v-model 中的值解析名称;这些资料不会自动加入下拉列表,也不会增加选中项。
组件会记住选项名称,替换或清空搜索结果后,已选标签仍显示名称;外部资料异步到达或名称更新时同步显示。同一值同时出现在两份资料中时,标签名称以 selectedOptions 为准。搜索结果如果包含已选值,该行正常显示勾选状态。
示例预先回填三个标签,再请求搜索接口。请求的防抖与取消由调用方负责,loading 为真时展开箭头显示加载指示器。
示例请求随文档发布的静态 JSON,并在调用方模拟筛选,无需服务端代理。接入实际接口时替换请求地址与结果映射。
已选:3 · 候选:0
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { refDebounced, useFetch } from '@vueuse/core'
import { useRuntimeConfig } from '#app'
import { Inline, MultiCombobox, Stack, Text } from '@hina-ui/vue'
interface TagPage {
data: { items: Array<{ id: number; name: string; nameEn: string }> }
}
const selectedTags = [
{ value: 31, label: '轻小说' },
{ value: 32, label: '長月達平' },
{ value: 33, label: '穿越' },
]
const selected = ref<Array<string | number>>(selectedTags.map(tag => tag.value))
const baseURL = useRuntimeConfig().app.baseURL
const search = ref('')
const keyword = refDebounced(search, 300)
const url = computed(
() => `${baseURL}demo/tags.json?${new URLSearchParams({ search: keyword.value })}`,
)
const { data, isFetching, execute } = useFetch(url, {
refetch: true,
immediate: false,
}).json<TagPage>()
const options = computed(
() =>
data.value?.data.items
.filter(tag =>
tag.name.toLocaleLowerCase().includes(keyword.value.trim().toLocaleLowerCase()),
)
.slice(0, 10)
.map(tag => ({ value: tag.id, label: tag.name })) ?? [],
)
onMounted(() => execute())
</script>
<template>
<Stack class="w-80">
<MultiCombobox
v-model="selected"
v-model:search="search"
:options="options"
:selected-options="selectedTags"
:loading="isFetching"
ignore-filter
placeholder="搜索标签"
aria-label="标签"
>
<template #option="{ option }">
<Inline gap="sm" align="center" :wrap="false" class="min-w-0">
<Text as="span" class="truncate">{{ option.label }}</Text>
<Text as="span" tone="muted" size="xs" class="ms-auto shrink-0 font-mono">
#{{ option.value }}
</Text>
</Inline>
</template>
</MultiCombobox>
<Text tone="muted">已选:{{ selected.length }} · 候选:{{ options.length }}</Text>
</Stack>
</template>
定制内容
option 插槽定制列表中每一项的内容,例如加上封面与编号。
组件从 options 推断完整选项类型,插槽中的 option 保留额外字段及其类型;v-model 仍绑定选项的 value 数组。类型定义见 Select。
<script setup lang="ts">
import { ref } from 'vue'
import { Inline, MultiCombobox, Text } from '@hina-ui/vue'
const tags = ref<Array<string | number>>([12])
const options = [
{ value: 12, label: '校园', description: '1 204 部作品' },
{ value: 34, label: '科幻', description: '388 部作品' },
{ value: 56, label: '恋爱', description: '2 019 部作品' },
{ value: 78, label: '悬疑', description: '271 部作品' },
]
</script>
<template>
<MultiCombobox v-model="tags" :options="options" aria-label="标签" class="w-80">
<template #option="{ option }">
<Inline gap="sm" align="center" :wrap="false" class="min-w-0">
<Text as="span" class="truncate">{{ option.label }}</Text>
<Text as="span" tone="muted" size="xs" class="ms-auto shrink-0 font-mono">
#{{ option.value }}
</Text>
</Inline>
</template>
</MultiCombobox>
</template>
可清除
clearable 在末尾加一个清除按钮,点击后清空全部并把焦点交回输入区。每个标签都有移除按钮,所以默认不显示清除按钮。
<script setup lang="ts">
import { ref } from 'vue'
import { MultiCombobox } from '@hina-ui/vue'
const studios = ref<Array<string | number>>([1, 2])
const options = [
{ value: 1, label: 'Key' },
{ value: 2, label: 'Type-Moon' },
{ value: 3, label: 'Nitroplus' },
]
</script>
<template>
<MultiCombobox
v-model="studios"
:options="options"
clearable
aria-label="制作公司"
class="w-80"
/>
</template>
尺寸
size 有 sm、md、lg 三档,没有已选项时的高度与同档输入框相等。
<script setup lang="ts">
import { MultiCombobox, Stack } from '@hina-ui/vue'
const options = [
{ value: 1, label: 'Key' },
{ value: 2, label: 'Type-Moon' },
]
</script>
<template>
<Stack gap="sm" class="w-full max-w-sm">
<MultiCombobox size="sm" :options="options" :model-value="[1]" aria-label="小号" />
<MultiCombobox size="md" :options="options" :model-value="[1]" aria-label="中号" />
<MultiCombobox size="lg" :options="options" :model-value="[1]" aria-label="大号" />
</Stack>
</template>
状态
invalid 给输入面加上警示色,disabled 禁用整组。variant="secondary" 是放在 surface 之内的扁平形态。
<script setup lang="ts">
import { MultiCombobox, Stack } from '@hina-ui/vue'
const options = [
{ value: 1, label: 'Key' },
{ value: 2, label: 'Type-Moon' },
]
</script>
<template>
<Stack gap="sm" class="w-full max-w-sm">
<MultiCombobox :options="options" :model-value="[1]" invalid aria-label="校验未通过" />
<MultiCombobox :options="options" :model-value="[1]" disabled aria-label="已禁用" />
<MultiCombobox
:options="options"
:model-value="[1]"
variant="secondary"
aria-label="扁平形态"
/>
</Stack>
</template>
在表单中
放进 FormField 后,标签指向输入区,说明与错误信息由字段渲染;校验规则与提交交给 Form。值是数组,数量限制写在数组层。
<script setup lang="ts">
import { reactive, ref } from 'vue'
import * as v from 'valibot'
import { Button, Form, FormField, MultiCombobox, Text } from '@hina-ui/vue'
const tags = [
{ label: '恋爱', value: 'romance' },
{ label: '恋爱喜剧', value: 'rom-com' },
{ label: '校园', value: 'school' },
{ label: '异世界', value: 'isekai' },
{ label: '治愈', value: 'healing' },
{ label: '悬疑', value: 'mystery' },
]
const schema = v.object({
tags: v.pipe(
v.array(v.string('请选择标签')),
v.minLength(1, '至少选择一个标签'),
v.maxLength(3, '最多选择三个标签'),
),
})
const values = reactive({ tags: [] as Array<string | number> })
const saved = ref('')
async function save(data: unknown) {
await new Promise(resolve => setTimeout(resolve, 600))
saved.value = JSON.stringify(data)
}
</script>
<template>
<Form v-slot="{ submitting }" :values="values" :rules="schema" class="w-80" @submit="save">
<FormField name="tags" label="标签" description="一到三个" required>
<MultiCombobox v-model="values.tags" :options="tags" />
</FormField>
<Button type="submit" :loading="submitting" class="self-start">保存</Button>
<Text v-if="saved" tone="muted" size="sm">已保存:{{ saved }}</Text>
</Form>
</template>
虚拟滚动
virtualize 按需渲染可见范围附近的条目,与 VirtualList 共用测量与滚动底层。默认关闭;可传 { estimateSize, overscan } 调整预估行高和两侧预渲染数量,行高会按实际内容测量。键盘导航覆盖完整数据,禁用项会跳过。 搜索仍处理完整数据。 条目离开渲染范围后会卸载;插槽内需要持久保留的状态应按唯一 value 存在外部。
已选: 1
<script setup lang="ts">
import { ref } from 'vue'
import { MultiCombobox, Stack, Text } from '@hina-ui/vue'
const selected = ref<Array<string | number>>([7890])
const options = Array.from({ length: 10000 }, (_, index) => ({
value: index,
label: `条目 ${String(index + 1).padStart(5, '0')}`,
disabled: index % 97 === 0,
}))
</script>
<template>
<Stack gap="sm" class="w-80 max-w-full">
<MultiCombobox
v-model="selected"
:options="options"
:virtualize="{ estimateSize: 36, overscan: 6 }"
aria-label="一万项"
/>
<Text size="sm" tone="muted">已选: {{ selected.length }}</Text>
</Stack>
</template>
行为
- 输入即打开列表并筛选;上下方向键移动高亮,Enter 勾选或者取消勾选,列表保持展开,搜索词与筛选结果保留,方便在同一批结果里连续勾选;清空输入即回到完整列表。
- 点击输入面的空白处或者标签正文即聚焦输入区并打开列表,与点击输入区相同。
- 输入区为空时按退格移除最后一个标签。
- Esc 或者点击外部关闭列表。浮层不锁定页面滚动。
- 已选项在列表中保持勾选状态,再次选择即取消。
无障碍
- 文本输入是
role="combobox",列表为role="listbox",选项为role="option"并带aria-selected。 - 通过
aria-label或者aria-labelledby为文本输入命名;标签的移除按钮、清除按钮与展开按钮都有本地化名称。 invalid会同时在文本输入上设置aria-invalid。
API
Props
T extends SelectOption 从 options 与 selectedOptions 推断,默认是 SelectOption。
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
modelValue | Array<string | number> | [] | 选中的值 |
options | SelectItems<T> | — | 选项,类型见 Select |
virtualize | VirtualizeOptions | false | 虚拟滚动;预估行高按内容,overscan 6 |
selectedOptions | T[] | — | 已选项资料,仅用于标签回显,不加入候选列表 |
placeholder | string | 语言包 | 无已选项时输入区的占位文字 |
search | string | '' | 输入区的文字,支持 v-model:search |
ignoreFilter | boolean | false | 是否关闭本地筛选,交给调用方远程搜索 |
loading | boolean | false | 是否把展开箭头换成加载指示器 |
clearable | boolean | false | 是否显示清空全部的按钮 |
name | string | — | 表单字段名 |
open | boolean | false | 浮层是否打开,支持 v-model:open |
variant | 'primary' | 'secondary' | 'primary' | 形态 |
size | 'sm' | 'md' | 'lg' | 'md' | 尺寸 |
disabled | boolean | false | 是否禁用 |
invalid | boolean | false | 是否处于校验未通过状态 |
class | string | — | 追加至根元素的类名 |
插槽
| 插槽 | 参数 | 说明 |
|---|---|---|
option | { option: T } | 列表中每一项的内容 |
事件
| 事件 | 参数 | 说明 |
|---|---|---|
update:modelValue | value: Array<string | number> | 选中值变化 |
update:search | value: string | 输入文字变化 |
update:open | open: boolean | 浮层开合变化 |
clear | — | 全部清空 |
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }