<script setup lang="ts">
import { ref } from 'vue'
import { Select } from '@hina-ui/vue'
const type = ref('gal')
const types = [
{ value: 'gal', label: 'Galgame' },
{ value: 'ln', label: '轻小说' },
{ value: 'manga', label: '漫画' },
{ value: 'anime', label: '动画' },
]
</script>
<template>
<Select v-model="type" :options="types" aria-label="作品类型" class="w-64" />
</template>
用法
import { Select } from '@hina-ui/vue'
下拉选择框由触发器与浮层列表组成。options 提供选项,v-model 绑定选中的值。每个选项是 { value, label },可以附带 description 与 disabled;带 options 且不带 value 的项是分组。触发器与输入框共用同一副输入面。
当前值:无
<script setup lang="ts">
import { ref } from 'vue'
import { Select, Stack, Text } from '@hina-ui/vue'
const status = ref<string | null>(null)
const statuses = [
{ value: 'wish', label: '想看' },
{ value: 'doing', label: '在看' },
{ value: 'done', label: '看过' },
{ value: 'dropped', label: '抛弃' },
]
</script>
<template>
<Stack class="w-56">
<Select v-model="status" :options="statuses" placeholder="选择收藏状态" aria-label="收藏状态" />
<Text tone="muted">当前值:{{ status ?? '无' }}</Text>
</Stack>
</template>
示例
清除
clearable 在有值且未禁用时显示清除按钮。点击后将 v-model 设为 null 并触发 clear,焦点回到触发器,列表保持关闭。
<script setup lang="ts">
import { ref } from 'vue'
import { Select } from '@hina-ui/vue'
const work = ref<string | null>('clannad')
const works = [
{ value: 'summer-pockets', label: 'Summer Pockets' },
{ value: 'clannad', label: 'CLANNAD' },
{ value: 'rewrite', label: 'Rewrite' },
]
</script>
<template>
<Select v-model="work" :options="works" clearable aria-label="作品" class="w-72" />
</template>
分组
分组项带 label 与 options,可以与普通选项混排。
<script setup lang="ts">
import { ref } from 'vue'
import { Select } from '@hina-ui/vue'
const sort = ref('latest')
const sorts = [
{ value: 'latest', label: '最近更新' },
{
label: '按评分',
options: [
{ value: 'rating-desc', label: '评分从高到低' },
{ value: 'rating-asc', label: '评分从低到高' },
],
},
{
label: '按发售',
options: [
{ value: 'release-desc', label: '最新发售' },
{ value: 'release-asc', label: '最早发售' },
],
},
]
</script>
<template>
<Select v-model="sort" :options="sorts" aria-label="排序" class="w-56" />
</template>
定制内容
option 插槽定制列表中每一项的内容,value 插槽定制触发器里显示的内容,两者都能拿到当前选项。
组件从 options 推断完整选项类型,插槽中的 option 保留额外字段及其类型;v-model 仍绑定 value。可用 SelectOption<{ icon: Component }> 声明额外字段,示例直接通过 option.icon 读取图标。
<script setup lang="ts">
import { ref, type Component } from 'vue'
import { BookOpen, Clapperboard, Gamepad2 } from '@lucide/vue'
import { Inline, Select, Stack, Text, type SelectOption } from '@hina-ui/vue'
const type = ref('gal')
const types: SelectOption<{ icon: Component }>[] = [
{ icon: Gamepad2, value: 'gal', label: 'Galgame', description: '视觉小说与冒险游戏' },
{ icon: BookOpen, value: 'ln', label: '轻小说', description: '文库本与网络连载' },
{ icon: Clapperboard, value: 'anime', label: '动画', description: 'TV、剧场版与 OVA' },
]
</script>
<template>
<Select v-model="type" :options="types" aria-label="作品类型" class="w-64">
<template #value="{ option }">
<Inline as="span" gap="sm" :wrap="false">
<component :is="option.icon" />
{{ option.label }}
</Inline>
</template>
<template #option="{ option }">
<Inline as="span" gap="sm" :wrap="false">
<component :is="option.icon" />
<Stack as="span" gap="none" class="min-w-0">
<Text as="span">{{ option.label }}</Text>
<Text as="span" size="xs" tone="muted">{{ option.description }}</Text>
</Stack>
</Inline>
</template>
</Select>
</template>
尺寸
三档尺寸与输入框相同。
<script setup lang="ts">
import { Select, Stack } from '@hina-ui/vue'
const options = [
{ value: 'gal', label: 'Galgame' },
{ value: 'ln', label: '轻小说' },
]
</script>
<template>
<Stack class="w-56">
<Select size="sm" :options="options" model-value="gal" aria-label="小号" />
<Select size="md" :options="options" model-value="gal" aria-label="中号" />
<Select size="lg" :options="options" model-value="gal" aria-label="大号" />
</Stack>
</template>
形态
primary 直接放在页面底色上,带边框与阴影;secondary 放在卡片等表面内,只有一层浅色底。
<script setup lang="ts">
import { Card, Select, Stack } from '@hina-ui/vue'
const options = [
{ value: 'gal', label: 'Galgame' },
{ value: 'ln', label: '轻小说' },
]
</script>
<template>
<Stack class="w-56">
<Select :options="options" placeholder="直接放在页面上" aria-label="页面上的选择框" />
<Card>
<Select
variant="secondary"
:options="options"
placeholder="放在卡片内"
aria-label="卡片内的选择框"
/>
</Card>
</Stack>
</template>
状态
invalid 标出校验未通过,disabled 禁用整个选择框,选项上的 disabled 只禁用该项。选项为空时列表显示提示。
<script setup lang="ts">
import { Select, Stack } from '@hina-ui/vue'
const options = [
{ value: 'gal', label: 'Galgame' },
{ value: 'ln', label: '轻小说' },
{ value: 'manga', label: '漫画', disabled: true },
]
</script>
<template>
<Stack class="w-56">
<Select invalid :options="options" placeholder="请选择类型" aria-label="校验未通过" />
<Select disabled :options="options" model-value="gal" aria-label="已禁用" />
<Select :options="[]" placeholder="没有可选项" aria-label="空列表" />
</Stack>
</template>
在表单中
放进 FormField 后,标签指向触发器,说明与错误信息由字段渲染并关联到它;校验规则、校验时机与提交交给 Form。
<script setup lang="ts">
import { reactive, ref } from 'vue'
import * as v from 'valibot'
import { Button, Form, FormField, Select, Text } from '@hina-ui/vue'
const categories = [
{ label: '游戏', value: 'game' },
{ label: '小说', value: 'novel' },
{ label: '漫画', value: 'manga' },
]
const schema = v.object({
category: v.pipe(v.string('请选择分类'), v.nonEmpty('请选择分类')),
})
const values = reactive({ category: null as string | number | null })
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="category" label="分类" required>
<Select v-model="values.category" :options="categories" />
</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 存在外部。
已选: 7890
<script setup lang="ts">
import { ref } from 'vue'
import { Select, Stack, Text } from '@hina-ui/vue'
const selected = ref<string | number | null>(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-56 max-w-full">
<Select
v-model="selected"
:options="options"
:virtualize="{ estimateSize: 36, overscan: 6 }"
aria-label="一万项"
/>
<Text size="sm" tone="muted">已选: {{ selected }}</Text>
</Stack>
</template>
行为
- 浮层贴着触发器展开,宽度与触发器相同,列表超出高度时在浮层内滚动。
- 打开期间页面锁定滚动,点击外部或者按 Esc 关闭。
- 支持方向键、Home、End 以及输入首字母快速定位。
- 选中后浮层关闭,焦点回到触发器。
无障碍
- 触发器为
role="combobox",列表为role="listbox",选项为role="option"并带aria-selected。 - 应当配合
label元素或者aria-label提供名称。invalid同时设置aria-invalid。
API
Props
T extends SelectOption 从 options 推断,默认是 SelectOption。
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
modelValue | string | number | null | — | 选中的值 |
options | SelectItems<T> | — | 选项,见下方类型 |
virtualize | VirtualizeOptions | false | 虚拟滚动;预估行高按内容,overscan 6 |
clearable | boolean | false | 是否显示清除按钮 |
placeholder | string | 语言包 | 无值时显示的文字 |
open | boolean | false | 浮层是否打开,支持 v-model:open |
variant | 'primary' | 'secondary' | 'primary' | 形态 |
size | 'sm' | 'md' | 'lg' | 'md' | 尺寸 |
invalid | boolean | false | 是否校验未通过 |
name | string | — | 原生表单字段名 |
required | boolean | false | 设置 name 后启用原生必填校验 |
autocomplete | string | — | 原生表单自动填充提示 |
disabled | boolean | false | 是否禁用 |
class | string | — | 追加至根元素的类名 |
插槽
| 插槽 | 参数 | 说明 |
|---|---|---|
value | { option: T } | 触发器里显示的内容 |
option | { option: T } | 列表中每一项的内容 |
事件
| 事件 | 参数 | 说明 |
|---|---|---|
update:modelValue | value: string | number | null | 选中值变化 |
update:open | open: boolean | 浮层开合变化 |
clear | — | 清除选中值 |
类型
type SelectOption<T extends object = object> = {
value: string | number
label: string
description?: string
disabled?: boolean
} & T
interface SelectOptionGroup<T extends SelectOption = SelectOption> {
label: string
options: T[]
}
type SelectItems<T extends SelectOption = SelectOption> = Array<T | SelectOptionGroup<T>>
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }