Autocomplete 自动补全

保留自由文本,支持光标处片段补全。

Enter

输入 sta,选择 status: 后继续选择 error。没有高亮候选时,回车应用整条查询。

已应用:entry:http

'use client'

import { useState } from 'react'
import { Search } from 'lucide-react'
import {
  Autocomplete,
  Kbd,
  Stack,
  Tag,
  Text,
  type AutocompleteOption,
  type CompletionContext,
  type CompletionEdit,
} from '@hina-ui/react'

const queryValues: Record<string, string[]> = {
  entry: ['http', 'rpc', 'job', 'consumer'],
  status: ['ok', 'error', 'timeout'],
  duration: ['>500ms', '>1s', '>5s'],
  service: ['gateway', 'catalog', 'checkout', 'worker'],
}

function queryToken(context: CompletionContext) {
  const { text, selectionStart, selectionEnd } = context
  const start = selectionStart > 0 ? text.lastIndexOf(' ', selectionStart - 1) + 1 : 0
  const nextSpace = text.indexOf(' ', selectionEnd)
  const end = nextSpace < 0 ? text.length : nextSpace
  const colon = text.indexOf(':', start)
  const hasKey = colon >= start && colon < selectionStart
  const rangeStart = hasKey ? colon + 1 : start
  return {
    key: hasKey ? text.slice(start, colon) : '',
    prefix: text.slice(rangeStart, selectionStart).toLowerCase(),
    range: [rangeStart, !hasKey && colon >= start && colon < end ? colon + 1 : end] as [
      number,
      number,
    ],
  }
}

const descriptions: Record<string, string> = {
  entry: '入口类型',
  status: '请求状态',
  duration: '请求耗时',
  service: '服务名称',
}

function complete(option: AutocompleteOption, context: CompletionContext): CompletionEdit {
  const token = queryToken(context)
  return { range: token.range, text: option.label, keepOpen: !token.key }
}

export default function Demo() {
  const [text, setText] = useState('entry:http ')
  const [applied, setApplied] = useState('entry:http ')
  const [options, setOptions] = useState<AutocompleteOption[]>([])

  function query(context: CompletionContext) {
    const token = queryToken(context)
    const values = token.key ? (queryValues[token.key] ?? []) : Object.keys(queryValues)
    setOptions(
      values
        .filter(value => value.startsWith(token.prefix))
        .map(value => ({
          value,
          label: token.key ? value : `${value}:`,
          description: token.key ? undefined : descriptions[value],
        })),
    )
  }

  return (
    <Stack className="w-full max-w-lg">
      <Autocomplete
        value={text}
        onValueChange={setText}
        options={options}
        getCompletion={complete}
        selectOnTab
        aria-label="请求查询"
        placeholder="entry:http status:error duration:>500ms"
        onQuery={query}
        onSubmit={setApplied}
        leading={<Search />}
        trailing={
          text !== applied ? (
            <Tag size="sm" tone="warning" className="mx-2 whitespace-nowrap">
              待应用
            </Tag>
          ) : (
            <Kbd className="mx-2">Enter</Kbd>
          )
        }
      />
      <Text tone="muted" size="sm">
        输入 sta,选择 status: 后继续选择 error。没有高亮候选时,回车应用整条查询。
      </Text>
      <Text size="sm" className="break-all">
        已应用:{applied || '—'}
      </Text>
    </Stack>
  )
}
tsx

用法

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

value / onValueChange 始终是输入框里的完整字符串。选择候选可以修改这段文本,失焦不会回滚或清空。需要把值限定在选项集合内时,使用 Combobox。

options 原样作为候选列表,组件不额外筛选。未提供 getCompletion 时,选择候选会用 option.label 替换整段文本并关闭列表。键入任意内容也有效。

选择建议,也可以保留自己输入的文字。

文本:—

'use client'

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

const candidates = ['Vue', 'React', 'Svelte', 'Solid', 'Angular']

export default function Demo() {
  const [text, setText] = useState('')
  const options = candidates
    .filter(label => label.toLowerCase().includes(text.toLowerCase()))
    .map(label => ({ value: label, label }))

  return (
    <Stack className="w-full max-w-sm">
      <FormField label="框架" description="选择建议,也可以保留自己输入的文字。">
        <Autocomplete
          value={text}
          onValueChange={setText}
          options={options}
          placeholder="输入框架名称"
        />
      </FormField>
      <Text tone="muted" size="sm">
        文本:{text || '—'}
      </Text>
    </Stack>
  )
}
tsx

示例

光标处连续补全

首个示例演示查询片段补全。输入 sta 后用方向键选中 status:,回车确认;列表保持打开,继续提供 ok、error、timeout。选择值后关闭列表,再按回车应用整条查询。也可以把光标移回已有片段进行替换,后面的文本会保留。

onQuery 提供当前文本与选区,输入、移动光标、改变选区和完成补全时都会通知。getCompletion(option, context) 同步返回替换范围与插入文本;范围采用原生输入框的 UTF-16 偏移,左闭右开。组件完成替换后把光标放到插入文本末尾。

function complete(option, context) {
  const range = locateToken(context)
  return {
    range,
    text: option.label,
    keepOpen: option.kind === 'key',
  }
}
ts

locateToken、键值判断和候选生成由应用提供。keepOpen: true 可连续补全,完成后新的 onQuery 回调携带更新后的文本与光标。不要缓存旧选区后再用于替换,应使用 getCompletion 本次收到的 context。范围超出当前文本时抛出 RangeError。

远程候选

loading 显示加载指示和列表中的状态提示,保留输入文字。异步替换 options 会清除旧高亮,新结果不会自动选中第一项。无候选时可以通过 empty 自定义提示;加载文案使用 loadingContent。

请求、防抖、取消和过期响应处理由调用方负责。示例请求随文档部署的静态 JSON,在调用方筛选结果;改为实际搜索接口时保留相同的数据流即可。

输入时请求候选,也允许保留候选之外的文本。

文本:—

'use client'

import { useEffect, useState } from 'react'
import { Search } from 'lucide-react'
import {
  Autocomplete,
  FormField,
  Stack,
  Text,
  type AutocompleteOption,
  type CompletionContext,
} from '@hina-ui/react'

interface TagPage {
  data: { items: { id: number; name: string; nameEn: string }[] }
}

export default function Demo() {
  const [text, setText] = useState('')
  const [search, setSearch] = useState('')
  const [keyword, setKeyword] = useState('')
  const [options, setOptions] = useState<AutocompleteOption[]>([])
  const [loading, setLoading] = useState(false)
  const [error, setError] = useState(false)

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

  function query(context: CompletionContext) {
    if (search === context.text) return
    setSearch(context.text)
    setOptions([])
    setLoading(!!context.text.trim())
    setError(false)
  }

  useEffect(() => {
    const controller = new AbortController()
    if (!keyword.trim()) {
      setOptions([])
      setLoading(false)
      return () => controller.abort()
    }
    setLoading(true)
    setError(false)
    fetch('/demo/tags.json', { signal: controller.signal })
      .then(async response => {
        if (!response.ok) throw new Error(String(response.status))
        const result = (await response.json()) as TagPage
        if (controller.signal.aborted) return
        setOptions(
          result.data.items
            .filter(tag => tag.name.toLowerCase().includes(keyword.trim().toLowerCase()))
            .slice(0, 10)
            .map(tag => ({ value: tag.id, label: tag.name })),
        )
      })
      .catch(() => {
        if (!controller.signal.aborted) setError(true)
      })
      .finally(() => {
        if (!controller.signal.aborted) setLoading(false)
      })
    return () => controller.abort()
  }, [keyword])

  return (
    <Stack className="w-full max-w-sm">
      <FormField label="标签搜索" description="输入时请求候选,也允许保留候选之外的文本。">
        <Autocomplete
          value={text}
          onValueChange={setText}
          options={options}
          loading={loading}
          placeholder="试试「小说」或「音乐」"
          onQuery={query}
          leading={<Search />}
          empty={
            error
              ? '候选加载失败,修改文字后重试。'
              : text.trim()
                ? '没有建议,仍可使用当前文本。'
                : '输入文字搜索标签。'
          }
        />
      </FormField>
      <Text tone="muted" size="sm">
        文本:{text || '—'}
      </Text>
    </Stack>
  )
}
tsx

尺寸与状态

输入面沿用 Input 的尺寸与形态。放进 FormField 后自动关联标签、说明、错误和禁用状态。未声明的属性(如 name、maxLength、aria-label)传给内部输入框。

请输入状态值。

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

const options = [
  { value: 'http', label: 'entry:http' },
  { value: 'rpc', label: 'entry:rpc' },
]

export default function Demo() {
  return (
    <Stack className="w-full max-w-sm" gap="lg">
      <FormField label="小号">
        <Autocomplete options={options} size="sm" placeholder="输入查询" />
      </FormField>
      <FormField label="次级形态">
        <Autocomplete options={options} variant="secondary" placeholder="输入查询" />
      </FormField>
      <FormField label="大号">
        <Autocomplete options={options} size="lg" placeholder="输入查询" />
      </FormField>
      <FormField label="校验失败" error="请输入状态值。">
        <Autocomplete options={[]} defaultValue="status:" />
      </FormField>
      <FormField label="只读">
        <Autocomplete options={options} defaultValue="entry:http" readonly />
      </FormField>
      <FormField label="禁用" disabled>
        <Autocomplete options={options} defaultValue="entry:http" />
      </FormField>
    </Stack>
  )
}
tsx

在表单中

通过 FormField 的 name 关联校验规则,错误与提交期间的禁用状态会自动传给输入框。候选只提供补全建议,用户输入的其他文本也可以提交。

在 onSubmit 中调用 Form ref 的 submit():回车有高亮候选时只完成补全,没有高亮候选时才触发表单校验与提交;保存按钮也走同一套校验。

选择建议,也可以填写其他框架。

'use client'

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

const candidates = ['Vue', 'React', 'Svelte', 'Solid', 'Angular']

const schema = v.object({
  framework: v.pipe(v.string(), v.trim(), v.nonEmpty('请输入框架名称')),
})

export default function Demo() {
  const form = useRef<FormHandle>(null)
  const [values, setValues] = useState({ framework: '' })
  const [saved, setSaved] = useState('')
  const options = candidates
    .filter(label => label.toLowerCase().includes(values.framework.toLowerCase()))
    .map(label => ({ value: label, label }))

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

  return (
    <Form ref={form} values={values} rules={schema} className="w-full max-w-sm" onSubmit={save}>
      {({ submitting }) => (
        <>
          <FormField
            name="framework"
            label="主要框架"
            description="选择建议,也可以填写其他框架。"
            required
          >
            <Autocomplete
              value={values.framework}
              onValueChange={framework => setValues({ ...values, framework })}
              options={options}
              name="framework"
              placeholder="选择或输入框架名称"
              onSubmit={() => void form.current?.submit()}
            />
          </FormField>
          <Button type="submit" loading={submitting} className="self-start">
            保存
          </Button>
          {saved && (
            <Text role="status" tone="muted" size="sm">
              已保存:{saved}
            </Text>
          )}
        </>
      )}
    </Form>
  )
}
tsx

键盘与焦点

  • 聚焦、点击或输入时打开列表,保持输入框焦点,不自动高亮首项。
  • 上下方向键移动高亮并跳过禁用项,列表内部滚动到当前项,不滚动外层页面。
  • 回车有高亮时仅接受候选;没有高亮时关闭列表并调用 onSubmit(text),不会同时触发浏览器表单提交。
  • selectOnTab 默认关闭。开启后,Tab 只在有高亮候选时接受它并保留输入焦点;没有高亮、Shift+Tab 仍正常移动焦点。
  • Esc 先关闭列表;列表已关闭时清空文本并调用 onClear。关闭和清空都不会应用查询。
  • 中文等输入法组词期间不处理候选选择、提交或清除,组词完成后再更新建议。
  • 失焦保留文本。readOnly 可聚焦、选择和复制文本,但不打开建议;disabled 禁止交互。

输入框、列表和候选分别使用 combobox、listbox、option 语义,活动项由 aria-activedescendant 关联。为组件提供 FormField、关联的标签或 aria-label。renderOption 返回的内容用于展示,不应嵌入按钮、链接等独立交互控件。

API

Props

T extends AutocompleteOption 从 options 推断,额外业务字段在回调和渲染函数中保留类型。

属性
类型
默认值
说明
value
string
''
完整文本,可受控
open
boolean
false
浮层状态,可受控
options
readonly T[]
—
当前候选,value 在列表中唯一且稳定
getCompletion
(option: T, context: CompletionContext) => CompletionEdit
—
返回同步文本编辑;默认整段替换为 label
loading
boolean
false
加载状态;已有候选仍可选,新请求需清除过期候选时由调用方置空
selectOnTab
boolean
false
Tab 接受高亮候选
placeholder
string
—
占位文字
variant
'primary' | 'secondary'
'primary'
输入面形态
size
'sm' | 'md' | 'lg'
'md'
尺寸
disabled
boolean
false
禁用
readOnly
boolean
false
只读
invalid
boolean
false
校验失败
className
string
—
输入面样式

内容属性

属性
参数
说明
leading
—
左侧图标等附加内容
trailing
—
右侧快捷键提示或状态标记;RTL 下随逻辑方向排列
renderOption
{ option: T, active: boolean }
候选展示内容,行的交互与无障碍由组件负责
empty
—
无候选提示
loadingContent
—
列表内加载提示

回调

回调
参数
说明
onValueChange
string
文本变化
onOpenChange
boolean
浮层开关变化
onQuery
CompletionContext
聚焦或重新打开、文本或选区变化、补全完成
onSelect
AutocompleteSelection<T>
候选被接受,包含编辑前上下文和所应用的编辑
onSubmit
string
没有高亮候选时回车应用完整文本
onClear
—
列表关闭时按 Esc 清空非空文本

Ref

ref 的 input 指向内部 HTMLInputElement,可使用 setSelectionRange() 操作选区;focus()、blur() 分别聚焦和失焦。ref 仅在挂载后可用。

类型

interface AutocompleteOption {
  value: string | number
  label: string
  description?: string
  disabled?: boolean
}

interface CompletionContext {
  text: string
  selectionStart: number
  selectionEnd: number
}

interface CompletionEdit {
  range: [number, number]
  text: string
  keepOpen?: boolean
}

interface AutocompleteSelection<T extends AutocompleteOption = AutocompleteOption> {
  option: T
  context: CompletionContext
  edit: CompletionEdit
}
ts