Cadenza
EN

带去抖查询的搜索字段 —— 输入框、清除按钮、Escape 与 Enter 语义都由封装层自己接

搜索字段。Base UI 没有搜索字段这个部件,所以行为整套由封装层拥有:输入框是 type="search"(role="searchbox" 由此而来),Escape 清空,Enter 触发 onSubmit, 清除按钮清空并退出去抖。各个 part 靠 context 认领这些行为,写在哪都能接上。

叫 SearchField 而不是 SearchInput,因为它是一个字段:根元素持有文本、查询值、 禁用与只读,输入框只是它的一个 part。除了字段本身,我们只加一件事:去抖后的查询值。

使用

import {
  InputGroupAddon,
  InputGroupButton,
  SearchField,
  SearchFieldClear,
  SearchFieldInput,
  useSearchQuery,
} from '@gedatou/cadenza-ui'
<SearchField
  aria-label="搜索作曲家"
  placeholder="搜索作曲家..."
  onQueryValueChange={setQuery}
/>

组成

不传 children 就是默认组合(放大镜 + 输入框 + 清除按钮):

SearchField                 ← 持有文本、查询值、禁用与只读
└── InputGroup              ← 边框、圆角、焦点环都画在这一行上
    ├── InputGroupAddon     ← 放大镜
    ├── SearchFieldInput    ← type="search",Escape / Enter 在这里接
    └── SearchFieldClear    ← 自带 InputGroupAddon 外壳,空时隐藏

传了 children 就完全接管内部结构,自己摆放各个 part。摆放用的是 shadcn 的 InputGroup 一族本身,不是改了名的转发壳。下面在尾部 加了一个快捷键提示,有内容时让位给清除按钮:

各 part 靠 context 自己接上根组件,不需要往下传任何 props —— SearchFieldInput 从 context 拿文本、handler 和无障碍名,SearchFieldClear 从 context 拿清除动作 和禁用态。摆放顺序随你,接线不受影响。

SearchField 自己只导出两个 part,因为只有这两个真的加了东西:SearchFieldInput 盖掉浏览器原生的搜索清除按钮,SearchFieldClear 接了「空时隐藏」。其余结构直接用 InputGroup 一族:InputGroup / InputGroupAddon / InputGroupButton / InputGroupInput / InputGroupText / InputGroupTextarea,它们自己也是本库的公开组件。

边框、圆角和焦点环都画在 InputGroup 上:环靠 has-[[data-slot=input-group-control]:focus-visible] 感知内部控件, 控件自身的环则被 focus-visible:ring-0 压掉,整组只有一圈。 InputGroupAddon 的 align 决定放行首还是行尾。

文本输入框在鼠标点击时也会命中 :focus-visible(浏览器认定它预期要打字), 所以点一下就有环 —— 这是原生行为,不是我们额外加的。 我们没有为它们套一层改名的壳 —— 那样只会遮住它们的出处。

标签

通道是 Field 四条里最普通的那条 —— id 落在真正的 <input> 上;只是默认组合没有 id 落点:根是个 <div>,传给它的 id 就停在 那个 div 上,到不了输入框。于是两种写法:

场景写法
没有可见标签根上传 aria-label —— 它经 context 转发给输入框,不会留在根 div 上
要可见 FieldLabel走组合式,id 传给 SearchFieldInput,FieldLabel htmlFor 指过去

清除按钮自己的无障碍名是另一件事,见 SearchFieldClear。

去抖查询

打字时 value 每次按键都更新,queryValue 要等停顿 debounceMs(默认 300) 才落定,并且已归一化 —— 首尾空白去掉、空串变成 undefined,可以直接丢进 请求或写进 URL。上面 hero 里那个 demo 把这个时间差直接显示了出来。

这个拆分和 InfiniteCombobox 是同一套词汇: value 是给输入框看的,queryValue 是给数据层看的。两者分开,输入才不会每敲一个 字就打一次请求。

debounceMs 挂载时读取一次,之后改动不生效 —— 和 defaultValue 同一个约定。 清除则是显式动作,不走去抖,立即生效:点了清除还要等 300ms 才掉筛选,读起来是 卡顿而不是平滑。

去抖状态 hook

useSearchQuery() 是上面那套去抖逻辑本身,单独导出。需要把去抖查询放在别处 (驱动一张表格的筛选、和 URL 同步),而不想渲染我们这个字段时用它:

import { useSearchQuery } from '@gedatou/cadenza-ui'
 
const { value, setValue, queryValue, resetSearch } = useSearchQuery({
  debounceMs: 500,
})
字段说明
value原始文本,与输入框同步
setValue(text)写入文本并重新计时。省略 eventDetails 时 reason 是 'none'
queryValue落定后的查询值:已 trim,空白时是 undefined
resetSearch()同时清空两者,并取消尚未触发的去抖。省略 eventDetails 时 reason 是 'imperative-action'

它接的就是 SearchField 那套受控 props(SearchQueryOptions)—— 把同一批 props 传给组件是它的简写形式。

受控

value + onValueChange 把文本交给外部 state,组件只负责渲染 —— 从字段之外填入或清空就靠这个:

去抖值同样可以受控(queryValue + onQueryValueChange),两组互不影响: 只受控文本时去抖值仍由内部管,反之亦然。

两个回调的 eventDetails.cancel() 各管各的那一层:拒掉文本那次变更,去抖那次也就 不会发生;只拒去抖那次,输入框照常显示新文本、查询值停在原处。

表单

根是个 <div> 而不是 <form>:onSubmit 是我们的 Enter 回调,和原生表单提交 没有关系。它和 onChange 一样被从 div 的 props 里剔掉、永远不会到达元素 —— 同名的 原生 handler 签名对不上,留着只会让类型逼你写一个两边都满足的回调。

序列化仍然走原生,只是落点在输入框上:name 要传给 SearchFieldInput,也就是走 组合式那条路 —— 传给根的 name 只会留在那个 div 上。多数搜索框压根不参与 序列化,<form role="search"> 里才需要这一步。

禁用

disabled 整个字段不可用(清除按钮一并失效),readOnly 则保留焦点与选中、 只是不能改。只读时不渲染清除按钮 —— 值本来就改不了,清除也就无从谈起:

自己组合时记得也把只读这一档处理掉,别只用 hidden:InputGroup 对任何 disabled 后代都会整体变灰(has-disabled:opacity-50),于是一个只读字段会长得跟禁用的一样 —— 而 display: none 的元素仍然会被 :has(:disabled) 命中,藏起来没用,得不渲染。

什么时候用 SearchField

三个都是「打字」的控件,先挑对再看用法:

组件什么时候
SearchField查询是自由文本,交给服务端或某张表的筛选,没有候选列表要选中
Combobox / InfiniteCombobox打字是为了从列表里选中一项,最后拿到的是选中值而不是文本
useSearchQuery要同一套去抖查询,但输入框长在别处(表格工具条、URL 同步),不渲染这个字段

状态与 className

根元素是纯 <div>,className 就是字符串 —— 类型上不假装有函数形态。要按状态改样式 就用下表左列的 data-*(data-disabled:opacity-50,Tailwind 直接当变体写),默认组合 自己也是这么隐藏清除按钮的。只有 SearchFieldInput 的 className 是双形态,它底下是 Base UI 的 Input。

右列是同一状态在函数 children 里的名字:

状态出现时机函数 children 里的名字
data-empty字段没有内容(清除按钮靠它显隐)empty
data-disableddisableddisabled
data-readonlyreadOnlyreadOnly

封装层自渲染的三个部件各带一个 data-slot:

data-slot是什么
search-field根 div。它同时是 group/search-field,状态就是靠这个 group 名往下传 —— 清除按钮用 group-data-empty/search-field:hidden 藏起来,组合式里的快捷键提示反过来用 group-data-empty/search-field:inline 露出来
search-field-clear-addon清除按钮自带的 InputGroupAddon 外壳(align="inline-end"),显隐规则挂在它身上
search-field-clear清除按钮本身

SearchFieldInput 没有自己的 data-slot,也别给它传一个。 它身上的 data-slot="input-group-control" 是接线契约不是标记 —— InputGroup 的焦点环正是靠 has-[[data-slot=input-group-control]:focus-visible] 画出来的。而 InputGroupInput 是先写属性、后展开 props,所以外面传一个同名属性会把契约值顶掉,焦点环就此静默消失: 不报错,只是不再出现。

键盘交互

按键行为
Esc清空字段(与点击清除按钮等价,同样跳过去抖)
Enter触发 onSubmit,回调参数是当前原始文本 + eventDetails(reason: 'keyboard')
Tab清除按钮不占 Tab 停留点(tabIndex={-1},键盘用户用 Esc 清空);空时它仍在 DOM 中,由 data-empty 经 CSS 隐藏

Props

顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。 状态属性与两条 className 通道见状态与 className。

SearchField

Prop类型默认值说明
aria-labelstring—这个字段的无障碍名,没有可见 Label 时必给,见标签
defaultValuestring''非受控初始文本
defaultQueryValuestring—非受控初始查询值
valuestring—受控文本
queryValuestring—受控查询值
onValueChange(value, eventDetails) => void—每次文本变化触发。eventDetails.reason 说明来源(input-change / clear-press / escape-key / imperative-action / none),cancel() 拒绝这次变更
onQueryValueChange(value, eventDetails) => void—去抖后触发,已 trim、空白归一为 undefined。拿到的是另一份 eventDetails(reason 与 event 相同),cancel() 只挡这一层
onSubmit(value, eventDetails) => void—按 Enter 时触发,参数是当前原始文本。第二参是 SearchFieldSubmitEventDetails——generic 详情、无 cancel()(提交不写任何内部状态,没有可跳过的东西),reason 为 'keyboard'
clearablebooleantrue清除按钮总开关——false 连显式组合的 SearchFieldClear 一并关掉。Escape 清空不受它管(那是 search 输入框自己的键盘语义)
debounceMsnumber300去抖间隔。挂载时读取一次,之后改动不生效(同 defaultValue 的约定)
disabledbooleanfalse禁用整个字段
readOnlybooleanfalse只读:可聚焦、可选中,不可改
placeholderstring—默认组合里输入框的占位符
childrenReactNode | (state) => ReactNode默认组合传了就完全接管内部结构。函数形态收到状态表右列那三个状态,外加 defaultChildren(默认组合,可扩展)
classNamestring—根元素类名
其余原生 <div> 的 props(含 ref)—透传到根元素

children 是双形态的:ReactNode,或 (state) => ReactNode 的函数——参数就是 状态表右列那三个状态,外加 defaultChildren(默认组合本身)。 函数形态是完全接管:什么都不自动注入;但要扩展而不是重造时,把 defaultChildren 渲染出来再往旁边加即可:

<SearchField aria-label="搜索">
  {({ defaultChildren }) => (
    <>
      {defaultChildren}
      <InputGroupAddon align="inline-end">
        <InputGroupButton aria-label="筛选"><IconFilter /></InputGroupButton>
      </InputGroupAddon>
    </>
  )}
</SearchField>

默认组合自己也在用这套能力(只读时收起清除按钮),接管后这些状态原样到你手上。

SearchFieldInput

Prop类型默认值说明
placeholderstring—占位符
classNamestring | (state) => string—类名
其余Base UI Input 的 props(含 ref)—透传

不需要 value / onValueChange / aria-label —— 三者都从字段的 context 拿。 type="search"、Escape 清空、Enter 提交也都在这里接好,而且顶不掉 —— 两个 handler 都串在 spread 之后:你的 onChange 在字段写完文本之后跑,你的 onKeyDown 在我们之前跑, preventDefault() 了才轮不到 Escape / Enter。原生的 ::-webkit-search-cancel-button 已经隐掉,免得和我们的清除按钮重复。 data-slot 是唯一不能传的那个,原因见状态与 className。

SearchFieldClear

Prop类型默认值说明
childrenReactNode✕换图标从这里传
aria-labelstring'Clear search'按钮的无障碍名。默认值是 aria-only 的英文兜底(同 'Search' / 'Loading' 的家法),业务层传译文覆盖
classNamestring—类名
其余InputGroupButton 的 props—透传到按钮

清除行为不用接:它从字段的 context 里拿清除动作和禁用态。字段为空时它跟随根组件 的 data-empty 隐藏,只读时默认组合直接不渲染它。它也不占 Tab 停留点 (tabIndex={-1})—— 键盘用户用 Escape。clearable={false} 时它整个返回 null。

组合式用到的 InputGroup 部件,props 见 InputGroup。

八个类型一并导出:SearchFieldProps / SearchFieldState / SearchQueryOptions / SearchQueryState / SearchFieldChangeEventReason / SearchFieldChangeEventDetails / SearchFieldSubmitEventReason / SearchFieldSubmitEventDetails。