MultiSelect 多选选择器

从列表中选择多项。

'use client'

import { useState } from 'react'
import { MultiSelect } from '@hina-ui/react'

const options = [
  { value: 'school', label: '校园' },
  { value: 'sf', label: '科幻' },
  { value: 'romance', label: '恋爱' },
  { value: 'mystery', label: '悬疑' },
  { value: 'fantasy', label: '奇幻' },
]

export default function Demo() {
  const [tags, setTags] = useState<Array<string | number>>(['school', 'sf'])

  return (
    <MultiSelect
      value={tags}
      onValueChange={setTags}
      options={options}
      aria-label="标签"
      className="w-72"
    />
  )
}
tsx

用法

import { MultiSelect } from '@hina-ui/react'
ts

多选框与 Select 共用同一套选项数据与列表,value / onValueChange 绑定选中值的数组。已选项以标签显示在触发器里,每个标签可以单独移除;设置 clearable 后末尾出现清除按钮,一次清空全部。选择后列表保持展开,方便连续勾选。

当前值:无

'use client'

import { useState } from 'react'
import { MultiSelect, Stack, Text } from '@hina-ui/react'

const options = [
  { value: 'windows', label: 'Windows' },
  { value: 'switch', label: 'Switch' },
  { value: 'ps5', label: 'PS5' },
  { value: 'android', label: 'Android' },
  { value: 'ios', label: 'iOS' },
]

export default function Demo() {
  const [platforms, setPlatforms] = useState<Array<string | number>>([])

  return (
    <Stack className="w-72">
      <MultiSelect
        value={platforms}
        onValueChange={setPlatforms}
        options={options}
        placeholder="选择平台"
        aria-label="平台"
      />
      <Text tone="muted">当前值:{platforms.length ? platforms.join('、') : '无'}</Text>
    </Stack>
  )
}
tsx

示例

分组

分组项带 label 与 options,可以与普通选项混排。

'use client'

import { useState } from 'react'
import { MultiSelect } from '@hina-ui/react'

const options = [
  { value: 'zh', label: '简体中文' },
  {
    label: '日文',
    options: [
      { value: 'ja', label: '日文原版' },
      { value: 'ja-tl', label: '日文(附翻译)' },
    ],
  },
  {
    label: '其他',
    options: [
      { value: 'en', label: '英文' },
      { value: 'ko', label: '韩文' },
    ],
  },
]

export default function Demo() {
  const [languages, setLanguages] = useState<Array<string | number>>(['zh'])

  return (
    <MultiSelect
      value={languages}
      onValueChange={setLanguages}
      options={options}
      aria-label="语言"
      className="w-72"
    />
  )
}
tsx

显示数量

触发器高度固定,只显示前 maxVisible 个标签,其余折成「+N」。

'use client'

import { useState } from 'react'
import { MultiSelect, Stack } from '@hina-ui/react'

const options = [
  { value: 'school', label: '校园' },
  { value: 'sf', label: '科幻' },
  { value: 'romance', label: '恋爱' },
  { value: 'mystery', label: '悬疑' },
  { value: 'fantasy', label: '奇幻' },
]

export default function Demo() {
  const [tags, setTags] = useState<Array<string | number>>(['school', 'sf', 'romance', 'mystery'])

  return (
    <Stack className="w-96">
      <MultiSelect
        value={tags}
        onValueChange={setTags}
        options={options}
        maxVisible={1}
        aria-label="最多一个"
      />
      <MultiSelect
        value={tags}
        onValueChange={setTags}
        options={options}
        maxVisible={3}
        aria-label="最多三个"
      />
    </Stack>
  )
}
tsx

定制内容

renderOption 属性定制列表中每一项的内容。

组件从 options 推断完整选项类型,渲染函数中的 option 保留额外字段及其类型;value / onValueChange 仍绑定选项的 value 数组。类型定义见 Select。

'use client'

import { useState } from 'react'
import { BookOpen, Clapperboard, Gamepad2, type LucideIcon } from 'lucide-react'
import { Inline, MultiSelect } from '@hina-ui/react'

const options = [
  { value: 'gal', label: 'Galgame' },
  { value: 'ln', label: '轻小说' },
  { value: 'anime', label: '动画' },
]
const icons: Record<string, LucideIcon> = { gal: Gamepad2, ln: BookOpen, anime: Clapperboard }

export default function Demo() {
  const [types, setTypes] = useState<Array<string | number>>(['gal'])

  return (
    <MultiSelect
      value={types}
      onValueChange={setTypes}
      options={options}
      aria-label="作品类型"
      className="w-72"
      renderOption={({ option }) => {
        const Icon = icons[option.value]!
        return (
          <Inline as="span" gap="sm" wrap={false}>
            <Icon />
            {option.label}
          </Inline>
        )
      }}
    />
  )
}
tsx

可清除

clearable 在末尾加一个按钮,点击后清空全部。每个标签自带移除按钮,所以默认不显示它。

'use client'

import { useState } from 'react'
import { MultiSelect } from '@hina-ui/react'

const options = [
  { value: 'school', label: '校园' },
  { value: 'sf', label: '科幻' },
  { value: 'romance', label: '恋爱' },
  { value: 'mystery', label: '悬疑' },
]

export default function Demo() {
  const [tags, setTags] = useState<Array<string | number>>(['school', 'sf'])

  return (
    <MultiSelect
      value={tags}
      onValueChange={setTags}
      options={options}
      clearable
      aria-label="标签"
      className="w-72"
    />
  )
}
tsx

尺寸

三档尺寸与输入框相同,标签随之缩放。

import { MultiSelect, Stack } from '@hina-ui/react'

const options = [
  { value: 'school', label: '校园' },
  { value: 'sf', label: '科幻' },
  { value: 'romance', label: '恋爱' },
]

export default function Demo() {
  return (
    <Stack className="w-72">
      <MultiSelect size="sm" options={options} value={['school', 'sf']} aria-label="小号" />
      <MultiSelect size="md" options={options} value={['school', 'sf']} aria-label="中号" />
      <MultiSelect size="lg" options={options} value={['school', 'sf']} aria-label="大号" />
    </Stack>
  )
}
tsx

形态

primary 直接放在页面底色上,带边框与阴影;secondary 放在卡片等表面内,只有一层浅色底。

import { Card, MultiSelect, Stack } from '@hina-ui/react'

const options = [
  { value: 'school', label: '校园' },
  { value: 'sf', label: '科幻' },
]

export default function Demo() {
  return (
    <Stack className="w-72">
      <MultiSelect options={options} placeholder="直接放在页面上" aria-label="页面上的多选框" />
      <Card>
        <MultiSelect
          variant="secondary"
          options={options}
          placeholder="放在卡片内"
          aria-label="卡片内的多选框"
        />
      </Card>
    </Stack>
  )
}
tsx

状态

invalid 标出校验未通过,disabled 禁用整个多选框。

import { MultiSelect, Stack } from '@hina-ui/react'

const options = [
  { value: 'school', label: '校园' },
  { value: 'sf', label: '科幻' },
  { value: 'romance', label: '恋爱' },
]

export default function Demo() {
  return (
    <Stack className="w-72">
      <MultiSelect
        invalid
        options={options}
        placeholder="至少选择一个标签"
        aria-label="校验未通过"
      />
      <MultiSelect disabled options={options} value={['school']} aria-label="已禁用" />
    </Stack>
  )
}
tsx

在表单中

放进 FormField 后,标签指向触发器,说明与错误信息由字段渲染;校验规则与提交交给 Form。值是数组,数量限制写在数组层。

一到三个

'use client'

import { useState } from 'react'
import * as v from 'valibot'
import { Button, Form, FormField, MultiSelect, Text } from '@hina-ui/react'

const genres = [
  { label: '恋爱', value: 'romance' },
  { label: '悬疑', value: 'mystery' },
  { label: '奇幻', value: 'fantasy' },
  { label: '日常', value: 'slice-of-life' },
  { label: '科幻', value: 'sci-fi' },
]

const schema = v.object({
  genres: v.pipe(
    v.array(v.string('请选择题材')),
    v.minLength(1, '至少选择一个题材'),
    v.maxLength(3, '最多选择三个题材'),
  ),
})

export default function Demo() {
  const [values, setValues] = useState({ genres: [] as Array<string | number> })
  const [saved, setSaved] = useState('')

  async function save(data: unknown) {
    await new Promise(resolve => setTimeout(resolve, 600))
    setSaved(JSON.stringify(data))
  }

  return (
    <Form values={values} rules={schema} className="w-80" onSubmit={save}>
      {({ submitting }) => (
        <>
          <FormField name="genres" label="题材" description="一到三个" required>
            <MultiSelect
              value={values.genres}
              onValueChange={next => setValues({ ...values, genres: next })}
              options={genres}
            />
          </FormField>
          <Button type="submit" loading={submitting} className="self-start">
            保存
          </Button>
          {saved && (
            <Text tone="muted" size="sm">
              已保存:{saved}
            </Text>
          )}
        </>
      )}
    </Form>
  )
}
tsx

虚拟滚动

virtualize 按需渲染可见范围附近的条目,与 VirtualList 共用测量与滚动底层。默认关闭;可传 { estimateSize, overscan } 调整预估行高和两侧预渲染数量,行高会按实际内容测量。键盘导航覆盖完整数据,禁用项会跳过。条目离开渲染范围后会卸载;renderOption 渲染的内容中需要持久保留的状态应按唯一 value 存在外部。

已选: 1

'use client'

import { useState } from 'react'
import { MultiSelect, Stack, Text } from '@hina-ui/react'

const options = Array.from({ length: 10000 }, (_, index) => ({
  value: index,
  label: `条目 ${String(index + 1).padStart(5, '0')}`,
  disabled: index % 97 === 0,
}))

export default function Demo() {
  const [selected, setSelected] = useState<Array<string | number>>([7890])

  return (
    <Stack gap="sm" className="w-72 max-w-full">
      <MultiSelect
        value={selected}
        onValueChange={setSelected}
        options={options}
        virtualize={{ estimateSize: 36, overscan: 6 }}
        aria-label="一万项"
      />
      <Text size="sm" tone="muted">
        已选: {selected.length}
      </Text>
    </Stack>
  )
}
tsx

行为

  • 点选或者按 Enter 勾选一项后列表保持展开,再次点选取消勾选。
  • 点击标签上的移除按钮与末尾的清除按钮不会打开列表。
  • 清空按钮的出现与消失有过渡。
  • 浮层贴着触发器展开,宽度与触发器相同,列表超出高度时在浮层内滚动。
  • 打开期间页面锁定滚动,点击外部或者按 Esc 关闭。

无障碍

  • 触发器为 role="combobox",列表为 role="listbox",选项为 role="option" 并带 aria-selected。
  • 标签的移除按钮与清除按钮都有本地化名称,可以用 Tab 到达。
  • 应当配合 label 元素或者 aria-label 提供名称。invalid 同时设置 aria-invalid。

API

Props

T extends SelectOption 从 options 推断,默认是 SelectOption。

属性
类型
默认值
说明
value
Array<string | number>
[]
选中的值
options
SelectItems<T>
—
选项,类型见 Select
virtualize
VirtualizeOptions
false
虚拟滚动;预估行高按内容,overscan 6
placeholder
string
语言包
无值时显示的文字
maxVisible
number
2
触发器里最多显示的标签数
clearable
boolean
false
是否显示清空全部的按钮
open
boolean
false
浮层是否打开,可受控
variant
'primary' | 'secondary'
'primary'
形态
size
'sm' | 'md' | 'lg'
'md'
尺寸
invalid
boolean
false
是否校验未通过
name
string
—
原生表单字段名
required
boolean
false
设置 name 后启用原生必填校验
autoComplete
string
—
原生表单自动填充提示
disabled
boolean
false
是否禁用
className
string
—
追加至触发器的类名

内容属性

属性
参数
说明
renderOption
{ option: T }
列表中每一项的内容

回调

回调
参数
说明
onValueChange
value: Array<string | number>
选中值变化
onOpenChange
open: boolean
浮层开合变化
onClear
—
全部清空
type VirtualizeOptions = boolean | { estimateSize?: number; overscan?: number }
ts