<script setup lang="ts">
import { ref } from 'vue'
import { Listbox } from '@hina-ui/vue'
const status = ref('doing')
const statuses = [
{ value: 'wish', label: '想看' },
{ value: 'doing', label: '在看' },
{ value: 'done', label: '看过' },
{ value: 'dropped', label: '抛弃' },
]
</script>
<template>
<Listbox v-model="status" :options="statuses" aria-label="收藏状态" class="w-56" />
</template>
用法
import { Listbox } from '@hina-ui/vue'
列表框把可选项常驻在页面上,与 Select 共用同一套选项数据。v-model 绑定选中的值,设置 multiple 后绑定数组。列表超过 maxHeight 时在框内滚动。未声明的属性都会传给内部的列表元素。
当前值:无
<script setup lang="ts">
import { ref } from 'vue'
import { Listbox, Stack, Text } from '@hina-ui/vue'
const type = ref<string | null>(null)
const types = [
{ value: 'gal', label: 'Galgame' },
{ value: 'ln', label: '轻小说' },
{ value: 'manga', label: '漫画' },
{ value: 'anime', label: '动画' },
]
</script>
<template>
<Stack class="w-56">
<Listbox v-model="type" :options="types" aria-label="作品类型" />
<Text tone="muted">当前值:{{ type ?? '无' }}</Text>
</Stack>
</template>
示例
多选
设置 multiple 后可以同时选中多项,再次点选取消。
已选:school
<script setup lang="ts">
import { ref } from 'vue'
import { Listbox, Stack, Text } from '@hina-ui/vue'
const tags = ref(['school'])
const options = [
{ value: 'school', label: '校园' },
{ value: 'sf', label: '科幻' },
{ value: 'romance', label: '恋爱' },
{ value: 'mystery', label: '悬疑' },
]
</script>
<template>
<Stack class="w-56">
<Listbox v-model="tags" :options="options" multiple aria-label="标签" />
<Text tone="muted">已选:{{ tags.length ? tags.join('、') : '无' }}</Text>
</Stack>
</template>
分组
分组项带 label 与 options,可以与普通选项混排。
<script setup lang="ts">
import { ref } from 'vue'
import { Listbox } 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>
<Listbox v-model="sort" :options="sorts" aria-label="排序" class="w-56" />
</template>
定制内容
option 插槽接收 { option, selected },定制每一项的内容。
组件从 options 推断完整选项类型,插槽中的 option 保留额外字段及其类型;v-model 仍绑定选项的 value,多选时为数组。类型定义见 Select。
<script setup lang="ts">
import { ref, type Component } from 'vue'
import { BookOpen, Clapperboard, Gamepad2 } from '@lucide/vue'
import { Inline, Listbox, Stack, Text } from '@hina-ui/vue'
const type = ref('gal')
const types = [
{ value: 'gal', label: 'Galgame', description: '视觉小说与冒险游戏' },
{ value: 'ln', label: '轻小说', description: '文库本与网络连载' },
{ value: 'anime', label: '动画', description: 'TV、剧场版与 OVA' },
]
const icons: Record<string, Component> = { gal: Gamepad2, ln: BookOpen, anime: Clapperboard }
</script>
<template>
<Listbox v-model="type" :options="types" aria-label="作品类型" class="w-64">
<template #option="{ option }">
<Inline as="span" gap="sm" :wrap="false">
<component :is="icons[option.value]" />
<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>
</Listbox>
</template>
尾部内容
#trailing="{ option, selected }" 接管整个行尾区域。未提供此插槽时,保留原有的勾选指示器和占位;提供后若返回空内容,该行不再保留尾部与间距,也不会恢复默认指示器。可用 <template #trailing /> 清除所有行的尾部,或用条件渲染只清除部分行。
自定义尾部的宽度由内容决定。示例通过 selected 在 Tag 与勾选图标之间切换,并对部分选项返回空内容。option 与 trailing 获取相同的选中状态,支持普通选项、分组选项、单选与多选。
<script setup lang="ts">
import { ref } from 'vue'
import { Check } from '@lucide/vue'
import { Card, Listbox, Tag, Text } from '@hina-ui/vue'
const value = ref('a')
const options = [
{ count: 128, value: 'a', label: '选项 A' },
{
label: '分组',
options: [
{ count: 2048, value: 'b', label: '选项 B' },
{ count: null, value: 'c', label: '选项 C' },
],
},
]
</script>
<template>
<Card :padded="false" class="w-64">
<Listbox
v-model="value"
:options="options"
variant="bare"
:padded="false"
aria-label="自定义尾部"
>
<template #option="{ option, selected }">
<Text as="span" :weight="selected ? 'medium' : 'normal'" truncate>{{ option.label }}</Text>
</template>
<template #trailing="{ option, selected }">
<Text
v-if="option.count != null"
as="span"
size="xs"
tone="muted"
class="flex min-w-4 shrink-0 items-center justify-end"
>
<Check v-if="selected" aria-hidden="true" />
<Tag v-else tone="accent">{{ option.count }}</Tag>
</Text>
</template>
</Listbox>
</Card>
</template>
自定义尾部不改变选项的选择行为。
滚动
maxHeight 限制列表高度,默认 20rem,超出后在框内滚动。
<script setup lang="ts">
import { ref } from 'vue'
import { Listbox } from '@hina-ui/vue'
const year = ref(2020)
const years = Array.from({ length: 30 }, (_, i) => ({ value: 1996 + i, label: `${1996 + i} 年` }))
</script>
<template>
<Listbox v-model="year" :options="years" max-height="12rem" aria-label="年份" class="w-40" />
</template>
形态
primary 带背景、边框与阴影;secondary 只有浅色背景。
bare 去除根容器的背景、边框、阴影和圆角。选项样式与滚动行为仍然保留。
<script setup lang="ts">
import { Listbox, Stack } from '@hina-ui/vue'
const options = [
{ value: 'a', label: '选项 A' },
{ value: 'b', label: '选项 B' },
]
</script>
<template>
<Stack class="w-56">
<Listbox :options="options" model-value="a" aria-label="Primary" />
<Listbox variant="secondary" :options="options" model-value="a" aria-label="Secondary" />
<Listbox variant="bare" :options="options" model-value="a" aria-label="Bare" />
</Stack>
</template>
内边距
padded 默认为 true,设为 false 可去掉内部列表外围的 4px 留白,与 variant 独立。选项和分组标题自身的内边距不变。class 仍用于根容器。
<script setup lang="ts">
import { ref } from 'vue'
import { Card, Listbox, Stack, Switch } from '@hina-ui/vue'
const padded = ref(false)
const selected = ref('a')
const options = [
{ value: 'a', label: '选项 A' },
{ label: '分组', options: [{ value: 'b', label: '选项 B' }] },
]
</script>
<template>
<Stack class="w-64">
<Switch v-model="padded" control-placement="end" block>列表外围留白</Switch>
<Card :padded="false">
<Listbox
v-model="selected"
:options="options"
variant="bare"
:padded="padded"
aria-label="列表外围留白"
/>
</Card>
</Stack>
</template>
状态
disabled 禁用整个列表,选项上的 disabled 只禁用该项。
<script setup lang="ts">
import { Listbox, 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">
<Listbox :options="options" model-value="gal" aria-label="含禁用项" />
<Listbox disabled :options="options" model-value="gal" aria-label="已禁用" />
</Stack>
</template>
在表单中
放进 FormField 后,标签通过 aria-labelledby 关联到列表,错误信息由字段渲染;校验规则与提交交给 Form。
<script setup lang="ts">
import { reactive, ref } from 'vue'
import * as v from 'valibot'
import { Button, Form, FormField, Listbox, Text } from '@hina-ui/vue'
const plans = [
{ label: '免费版', value: 'free' },
{ label: '标准版', value: 'standard' },
{ label: '专业版', value: 'pro' },
]
const schema = v.object({
plan: v.string('请选择套餐'),
})
const values = reactive({
plan: null as string | number | null | 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="plan" label="套餐" required>
<Listbox v-model="values.plan" :options="plans" />
</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 { Listbox, 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">
<Listbox
v-model="selected"
:options="options"
:virtualize="{ estimateSize: 36, overscan: 6 }"
aria-label="一万项"
/>
<Text size="sm" tone="muted">已选: {{ selected }}</Text>
</Stack>
</template>
行为
- 点选切换选中。键盘 Tab 进入列表后,方向键移动高亮,Enter 或者空格选中,禁用项会被跳过。
- 列表超出高度时在框内滚动,键盘高亮跟随滚动。
无障碍
- 列表为
role="listbox",多选时带aria-multiselectable;选项为role="option"并带aria-selected;分组为role="group"并关联其标签。 - 应当通过
aria-label或者aria-labelledby给列表命名。
API
Props
T extends SelectOption 从 options 推断,默认是 SelectOption。
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
modelValue | string | number | null | Array<string | number> | — | 选中的值,多选时为数组 |
options | SelectItems<T> | — | 选项,类型见 Select |
virtualize | VirtualizeOptions | false | 虚拟滚动;预估行高按内容,overscan 6 |
multiple | boolean | false | 是否多选 |
maxHeight | string | '20rem' | 列表的最大高度 |
padded | boolean | true | 是否保留内部列表外围留白 |
variant | 'primary' | 'secondary' | 'bare' | 'primary' | 形态 |
disabled | boolean | false | 是否禁用 |
class | string | — | 追加至根元素的类名 |
插槽
| 插槽 | 参数 | 说明 |
|---|---|---|
option | { option: T; selected: boolean } | 每一项的内容 |
trailing | { option: T; selected: boolean } | 整个行尾区域,空内容不占位 |
事件
| 事件 | 参数 | 说明 |
|---|---|---|
update:modelValue | value: string | number | Array<string | number> | 选中值变化 |
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }