MultiCombobox 多选组合框

输入搜索并选择多项。

Key
'use client'

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

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' },
]

export default function Demo() {
  const [studios, setStudios] = useState<Array<string | number>>([1])

  return (
    <MultiCombobox
      value={studios}
      onValueChange={setStudios}
      options={options}
      aria-label="制作公司"
      className="w-80"
    />
  )
}
tsx

用法

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

多选组合框是 Combobox 的多值形态:在同一个输入面里输入文字缩小范围,从列表中选择多项,已选项以标签排在输入区前面,放不下时换行。value / onValueChange 绑定选中值的数组,options 的类型见 Select。未声明的属性都会传给内部的文本输入,请用 aria-label 或者 aria-labelledby 命名。

还没有选择

'use client'

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

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

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

  return (
    <Stack gap="sm" className="w-full max-w-sm">
      <MultiCombobox
        value={genres}
        onValueChange={setGenres}
        options={options}
        placeholder="输入题材"
        aria-label="题材"
      />
      <Text tone="muted" size="sm">
        {genres.length ? genres.join('、') : '还没有选择'}
      </Text>
    </Stack>
  )
}
tsx

它与 MultiSelect 的区别在于能否输入:多选选择器只能从固定的选项里挑选,浮层打开期间锁定页面滚动;多选组合框通过输入文字缩小范围,浮层不锁定页面滚动,输入区始终可以输入。与 TagsInput 的区别在于值的来源:标签输入框接受任意文字,这里的值必须来自选项。

示例

远程搜索

ignoreFilter 关闭本地筛选,search / onSearchChange 提供输入文字,远程搜索结果直接传给 options。selectedOptions 单独提供已选项资料,Chip 按 value 中的值解析名称;这些资料不会自动加入下拉列表,也不会增加选中项。

组件会记住选项名称,替换或清空搜索结果后,已选标签仍显示名称;外部资料异步到达或名称更新时同步显示。同一值同时出现在两份资料中时,标签名称以 selectedOptions 为准。搜索结果如果包含已选值,该行正常显示勾选状态。

示例预先回填三个标签,再请求搜索接口。请求的防抖与取消由调用方负责,loading 为真时展开箭头显示加载指示器。

示例请求随文档发布的静态 JSON,并在调用方模拟筛选,无需服务端代理。接入实际接口时替换请求地址与结果映射。

轻小说長月達平穿越

已选:3 · 候选:0

'use client'

import { useEffect, useState } from 'react'
import { Inline, MultiCombobox, Stack, Text } from '@hina-ui/react'

interface TagPage {
  data: { items: Array<{ id: number; name: string; nameEn: string }> }
}

const selectedTags = [
  { value: 31, label: '轻小说' },
  { value: 32, label: '長月達平' },
  { value: 33, label: '穿越' },
]

export default function Demo() {
  const [selected, setSelected] = useState<Array<string | number>>(
    selectedTags.map(tag => tag.value),
  )
  const [search, setSearch] = useState('')
  const [keyword, setKeyword] = useState('')
  const [data, setData] = useState<TagPage>()
  const [fetching, setFetching] = useState(false)

  useEffect(() => {
    const timer = setTimeout(() => setKeyword(search), 300)
    return () => clearTimeout(timer)
  }, [search])

  useEffect(() => {
    const controller = new AbortController()
    setFetching(true)
    fetch(`/demo/tags.json?${new URLSearchParams({ search: keyword })}`, {
      signal: controller.signal,
    })
      .then(response => response.json() as Promise<TagPage>)
      .then(page => setData(page))
      .catch(() => {})
      .finally(() => {
        if (!controller.signal.aborted) setFetching(false)
      })
    return () => controller.abort()
  }, [keyword])

  const options =
    data?.data.items
      .filter(tag => tag.name.toLocaleLowerCase().includes(keyword.trim().toLocaleLowerCase()))
      .slice(0, 10)
      .map(tag => ({ value: tag.id, label: tag.name })) ?? []

  return (
    <Stack className="w-80">
      <MultiCombobox
        value={selected}
        onValueChange={setSelected}
        search={search}
        onSearchChange={setSearch}
        options={options}
        selectedOptions={selectedTags}
        loading={fetching}
        ignoreFilter
        placeholder="搜索标签"
        aria-label="标签"
        renderOption={({ option }) => (
          <Inline gap="sm" align="center" wrap={false} className="min-w-0">
            <Text as="span" className="truncate">
              {option.label}
            </Text>
            <Text as="span" tone="muted" size="xs" className="ms-auto shrink-0 font-mono">
              #{option.value}
            </Text>
          </Inline>
        )}
      />
      <Text tone="muted">
        已选:{selected.length} · 候选:{options.length}
      </Text>
    </Stack>
  )
}
tsx

定制内容

renderOption 属性定制列表中每一项的内容,例如加上封面与编号。

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

校园
'use client'

import { useState } from 'react'
import { Inline, MultiCombobox, Text } from '@hina-ui/react'

const options = [
  { value: 12, label: '校园', description: '1 204 部作品' },
  { value: 34, label: '科幻', description: '388 部作品' },
  { value: 56, label: '恋爱', description: '2 019 部作品' },
  { value: 78, label: '悬疑', description: '271 部作品' },
]

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

  return (
    <MultiCombobox
      value={tags}
      onValueChange={setTags}
      options={options}
      aria-label="标签"
      className="w-80"
      renderOption={({ option }) => (
        <Inline gap="sm" align="center" wrap={false} className="min-w-0">
          <Text as="span" className="truncate">
            {option.label}
          </Text>
          <Text as="span" tone="muted" size="xs" className="ms-auto shrink-0 font-mono">
            #{option.value}
          </Text>
        </Inline>
      )}
    />
  )
}
tsx

可清除

clearable 在末尾加一个清除按钮,点击后清空全部并把焦点交回输入区。每个标签都有移除按钮,所以默认不显示清除按钮。

KeyType-Moon
'use client'

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

const options = [
  { value: 1, label: 'Key' },
  { value: 2, label: 'Type-Moon' },
  { value: 3, label: 'Nitroplus' },
]

export default function Demo() {
  const [studios, setStudios] = useState<Array<string | number>>([1, 2])

  return (
    <MultiCombobox
      value={studios}
      onValueChange={setStudios}
      options={options}
      clearable
      aria-label="制作公司"
      className="w-80"
    />
  )
}
tsx

尺寸

size 有 sm、md、lg 三档,没有已选项时的高度与同档输入框相等。

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

const options = [
  { value: 1, label: 'Key' },
  { value: 2, label: 'Type-Moon' },
]

export default function Demo() {
  return (
    <Stack gap="sm" className="w-full max-w-sm">
      <MultiCombobox size="sm" options={options} defaultValue={[1]} aria-label="小号" />
      <MultiCombobox size="md" options={options} defaultValue={[1]} aria-label="中号" />
      <MultiCombobox size="lg" options={options} defaultValue={[1]} aria-label="大号" />
    </Stack>
  )
}
tsx

状态

invalid 给输入面加上警示色,disabled 禁用整组。variant="secondary" 是放在 surface 之内的扁平形态。

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

const options = [
  { value: 1, label: 'Key' },
  { value: 2, label: 'Type-Moon' },
]

export default function Demo() {
  return (
    <Stack gap="sm" className="w-full max-w-sm">
      <MultiCombobox options={options} defaultValue={[1]} invalid aria-label="校验未通过" />
      <MultiCombobox options={options} defaultValue={[1]} disabled aria-label="已禁用" />
      <MultiCombobox
        options={options}
        defaultValue={[1]}
        variant="secondary"
        aria-label="扁平形态"
      />
    </Stack>
  )
}
tsx

在表单中

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

一到三个

'use client'

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

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, '最多选择三个标签'),
  ),
})

export default function Demo() {
  const [values, setValues] = useState({ tags: [] 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="tags" label="标签" description="一到三个" required>
            <MultiCombobox
              value={values.tags}
              onValueChange={next => setValues({ ...values, tags: next })}
              options={tags}
            />
          </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 存在外部。

条目 07891

已选: 1

'use client'

import { useState } from 'react'
import { MultiCombobox, 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-80 max-w-full">
      <MultiCombobox
        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。
  • 通过 aria-label 或者 aria-labelledby 为文本输入命名;标签的移除按钮、清除按钮与展开按钮都有本地化名称。
  • invalid 会同时在文本输入上设置 aria-invalid。

API

Props

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

属性
类型
默认值
说明
value
Array<string | number>
[]
选中的值
options
SelectItems<T>
—
选项,类型见 Select
virtualize
VirtualizeOptions
false
虚拟滚动;预估行高按内容,overscan 6
selectedOptions
T[]
—
已选项资料,仅用于标签回显,不加入候选列表
placeholder
string
语言包
无已选项时输入区的占位文字
search
string
''
输入区的文字,可受控
ignoreFilter
boolean
false
是否关闭本地筛选,交给调用方远程搜索
loading
boolean
false
是否把展开箭头换成加载指示器
clearable
boolean
false
是否显示清空全部的按钮
name
string
—
表单字段名
open
boolean
false
浮层是否打开,可受控
variant
'primary' | 'secondary'
'primary'
形态
size
'sm' | 'md' | 'lg'
'md'
尺寸
disabled
boolean
false
是否禁用
invalid
boolean
false
是否处于校验未通过状态
className
string
—
追加至根元素的类名

内容属性

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

回调

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