Combobox 组合框

边输入边筛选的选择框。

'use client'

import { useState } from 'react'
import { Combobox, type ComboboxValue } from '@hina-ui/react'

const works = [
  { value: 'summer-pockets', label: 'Summer Pockets' },
  { value: 'clannad', label: 'CLANNAD' },
  { value: 'little-busters', label: 'Little Busters!' },
  { value: 'rewrite', label: 'Rewrite' },
  { value: 'air', label: 'AIR' },
  { value: 'kanon', label: 'Kanon' },
]

export default function Demo() {
  const [work, setWork] = useState<ComboboxValue>(null)

  return (
    <Combobox
      value={work}
      onValueChange={setWork}
      options={works}
      placeholder="搜索作品"
      aria-label="作品"
      className="w-64"
    />
  )
}
tsx

用法

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

组合框由输入框与浮层列表组成,与 Select 共用同一套选项数据。value / onValueChange 绑定选中的值,输入文字即按选项文字筛选列表,末尾的按钮展开或者收起列表。输入区与输入框共用同一副输入面,未声明的属性都会传给内部的 input。

当前值:key

'use client'

import { useState } from 'react'
import { Combobox, Stack, Text, type ComboboxValue } from '@hina-ui/react'

const studios = [
  { value: 'key', label: 'Key' },
  { value: 'type-moon', label: 'TYPE-MOON' },
  { value: 'august', label: 'August' },
  { value: 'saga-planets', label: 'SAGA PLANETS' },
  { value: 'yuzusoft', label: 'ゆずソフト' },
]

export default function Demo() {
  const [studio, setStudio] = useState<ComboboxValue>('key')

  return (
    <Stack className="w-64">
      <Combobox value={studio} onValueChange={setStudio} options={studios} aria-label="制作商" />
      <Text tone="muted">当前值:{studio ?? '无'}</Text>
    </Stack>
  )
}
tsx

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

示例

分组

分组项带 label 与 options,筛选时空的分组会一并隐藏。

'use client'

import { useState } from 'react'
import { Combobox, type ComboboxValue } from '@hina-ui/react'

const tags = [
  {
    label: '题材',
    options: [
      { value: 'school', label: '校园' },
      { value: 'sf', label: '科幻' },
      { value: 'fantasy', label: '奇幻' },
    ],
  },
  {
    label: '形式',
    options: [
      { value: 'kinetic', label: '线性剧情' },
      { value: 'branch', label: '多线分支' },
    ],
  },
]

export default function Demo() {
  const [tag, setTag] = useState<ComboboxValue>(null)

  return (
    <Combobox
      value={tag}
      onValueChange={setTag}
      options={tags}
      placeholder="搜索标签"
      aria-label="标签"
      className="w-64"
    />
  )
}
tsx

远程数据

ignoreFilter 关闭本地筛选,search / onSearchChange 提供输入文字,调用方将搜索结果直接传给 options。下拉列表只渲染这份结果。

selectedOption 单独提供已选项资料,value 与 value / onValueChange 匹配时用于显示名称,不会加入候选列表,也不会改变选中值。组件会记住选项名称,搜索结果替换或清空后仍能回显。资料异步到达或名称更新时同步显示,正在输入的搜索词不会被覆盖;关闭列表后恢复最新名称。同一值同时出现在两份资料中时,回显名称以 selectedOption 为准。

loading 将展开按钮中的箭头切换为加载指示,并保留输入、选择和清除操作。请求状态与防抖由调用方控制。

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

候选:0 · 选中值:31

'use client'

import { useEffect, useState } from 'react'
import { Combobox, Stack, Text, type ComboboxValue } from '@hina-ui/react'

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

const selectedOption = { value: 31, label: '轻小说' }

export default function Demo() {
  const [selected, setSelected] = useState<ComboboxValue>(selectedOption.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-64">
      <Combobox
        value={selected}
        onValueChange={setSelected}
        search={search}
        onSearchChange={setSearch}
        options={options}
        selectedOption={selectedOption}
        loading={fetching}
        ignoreFilter
        clearable
        placeholder="搜索标签"
        aria-label="标签"
      />
      <Text tone="muted">
        候选:{options.length} · 选中值:{selected ?? '无'}
      </Text>
    </Stack>
  )
}
tsx

可清除

clearable 在有值时显示清除按钮,点击后清空值与输入文字。

'use client'

import { useState } from 'react'
import { Combobox, type ComboboxValue } from '@hina-ui/react'

const works = [
  { value: 'summer-pockets', label: 'Summer Pockets' },
  { value: 'clannad', label: 'CLANNAD' },
  { value: 'rewrite', label: 'Rewrite' },
]

export default function Demo() {
  const [work, setWork] = useState<ComboboxValue>('clannad')

  return (
    <Combobox
      value={work}
      onValueChange={setWork}
      options={works}
      clearable
      aria-label="作品"
      className="w-72"
    />
  )
}
tsx

尺寸

三档尺寸与输入框相同。

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

const options = [
  { value: 'gal', label: 'Galgame' },
  { value: 'ln', label: '轻小说' },
]

export default function Demo() {
  return (
    <Stack className="w-64">
      <Combobox size="sm" options={options} defaultValue="gal" aria-label="小号" />
      <Combobox size="md" options={options} defaultValue="gal" aria-label="中号" />
      <Combobox size="lg" options={options} defaultValue="gal" aria-label="大号" />
    </Stack>
  )
}
tsx

形态

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

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

const options = [
  { value: 'gal', label: 'Galgame' },
  { value: 'ln', label: '轻小说' },
]

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

状态

invalid 标出校验未通过,disabled 禁用整个组合框,选项上的 disabled 只禁用该项。

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

const options = [
  { value: 'gal', label: 'Galgame' },
  { value: 'ln', label: '轻小说' },
  { value: 'manga', label: '漫画', disabled: true },
]

export default function Demo() {
  return (
    <Stack className="w-64">
      <Combobox invalid options={options} placeholder="请选择类型" aria-label="校验未通过" />
      <Combobox disabled options={options} defaultValue="gal" aria-label="已禁用" />
    </Stack>
  )
}
tsx

在表单中

放进 FormField 后,标签指向输入区,说明与错误信息由字段渲染并关联到控件;校验规则与提交交给 Form。

'use client'

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

const cities = [
  { label: '东京', value: 'tokyo' },
  { label: '大阪', value: 'osaka' },
  { label: '京都', value: 'kyoto' },
  { label: '札幌', value: 'sapporo' },
  { label: '福冈', value: 'fukuoka' },
]

const schema = v.object({
  city: v.pipe(v.string('请选择城市'), v.nonEmpty('请选择城市')),
})

export default function Demo() {
  const [values, setValues] = useState({ city: null as string | number | null })
  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="city" label="所在城市" required>
            <Combobox
              value={values.city}
              onValueChange={city => setValues({ ...values, city: city ?? null })}
              options={cities}
            />
          </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 存在外部。

已选: 7890

'use client'

import { useState } from 'react'
import { Combobox, Stack, Text, type ComboboxValue } 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<ComboboxValue>(7890)

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

行为

  • 点击输入区或者按下方向键打开列表,输入文字时按选项文字筛选,无匹配时显示提示。
  • 选中后列表关闭,输入框显示选项文字;失焦时未选中的输入文字恢复为已选项,把文字删干净则清除选中值。
  • 浮层贴着输入区展开,宽度与输入区相同,列表超出高度时在浮层内滚动。
  • 展开按钮与清除按钮不会让输入区失焦。

无障碍

  • 输入区为 role="combobox" 并带 aria-autocomplete="list",列表为 role="listbox",选项为 role="option" 并带 aria-selected。
  • 展开按钮不进入 Tab 序列,名称随语言包本地化。
  • 加载时输入面设置 aria-busy="true"。
  • 应当配合 label 元素或者 aria-label 提供名称。invalid 同时设置 aria-invalid。

API

Props

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

属性
类型
默认值
说明
value
string | number | null
—
选中的值
options
SelectItems<T>
—
选项,类型见 Select
virtualize
VirtualizeOptions
false
虚拟滚动;预估行高按内容,overscan 6
selectedOption
T | null
—
已选项资料,仅用于回显,不加入候选列表
search
string
''
当前输入的文字,可受控
placeholder
string
语言包
无值时显示的文字
ignoreFilter
boolean
false
是否交由调用方筛选
loading
boolean
false
是否显示加载指示
clearable
boolean
false
是否显示清除按钮
open
boolean
false
浮层是否打开,可受控
variant
'primary' | 'secondary'
'primary'
形态
size
'sm' | 'md' | 'lg'
'md'
尺寸
invalid
boolean
false
是否校验未通过
disabled
boolean
false
是否禁用
className
string
—
追加至根元素的类名

内容属性

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

回调

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