Cadenza
EN

InfiniteSelect / InfiniteCombobox

可搜索、无限滚动的选择器家族 —— 基座面板与弹层便利层同页,Base UI Combobox 行为 + 组合式插槽

可搜索、游标分页无限滚动的选择器家族。打开后滚到底会自动加载下一页;输入会防抖 300ms 后落到 queryValue,由数据层重新请求。行为全部由 Base UI 的 Combobox 承担: 它以 inline 模式渲染(不用自己的弹层),用虚拟焦点把搜索框和列表接起来 (输入框保持 DOM 焦点,方向键在列表里移动),并提供选择语义。 客户端过滤是关掉的filter={null})—— 过滤在服务端:打字驱动 onInputValueChange,调用方重新取数,面板渲染回来的结果。大数据集可用 virtualized 开启行虚拟化,见虚拟化

分层:底下是 hooks 层(状态与行为),中间是 组合式 parts(context 接线、按书写顺序渲染),InfiniteCombobox 是把这些一把组好的便利层。日常用便利层就够;要改结构时降到 parts 层自由组合,见「组合式架构与 hooks」。

每个 demo 下方都附完整源码(View Code 展开、右上角复制);异步接口用 Promise 模拟(300–500ms 随机延时),mock 数据源在 demos/lib 里。

使用

import {
  Button,
  InfiniteCombobox,
  InfiniteSelect,
  InfiniteSelectActionsProvider,
  InfiniteSelectEmpty,
  InfiniteSelectFooter,
  InfiniteSelectInputGroup,
  InfiniteSelectList,
  InfiniteSelectLoadingMore,
  InfiniteSelectNoMore,
  useInfiniteComboboxState,
} from '@gedatou/cadenza-ui'
const state = useInfiniteComboboxState()
// 你自己的数据适配器,见「数据适配器」
const list = usePersonList(state.queryValue)
 
<InfiniteCombobox<Person>
  getOption={person => ({ id: person.id, label: person.name })}
  list={list}
  onValueChange={setPicked}
  searchPlaceholder="搜索作曲家…"
  state={state}
>
  <Button>选择作曲家</Button>
  <InfiniteSelectEmpty>没有匹配的结果</InfiniteSelectEmpty>
</InfiniteCombobox>

组成

组合方式按位置第一个 child 是触发器, 它之后的每个 child 都进面板的组合通道

InfiniteCombobox
├── <触发器>                            ← 第一个 child,任何元素都行
└── (弹层)
    └── InfiniteSelect                  ← 便利层自己组的
        ├── InfiniteSelectInputGroup    ← 便利层自己渲染,不要再写
        ├── InfiniteSelectList          ← 同上
        │   └── <选项行>
        ├── InfiniteSelectEmpty         ↑ 以下都来自组合通道
        ├── InfiniteSelectError
        │   └── InfiniteSelectRetry
        ├── InfiniteSelectLoadingOverlay  ← 标记部件,提升到列表壳
        ├── InfiniteSelectLoadingMore     ← 标记部件,提升到滚动流末尾
        ├── InfiniteSelectNoMore          ← 同一位置的另一分支
        └── InfiniteSelectFooter
            ├── InfiniteSelectClear
            ├── InfiniteSelectFooterSeparator
            ├── InfiniteSelectCancel
            └── InfiniteSelectClose
<InfiniteCombobox {/* … */}>
  <Button>选择作曲家</Button>                      {/* 触发器 */}
  <InfiniteSelectEmpty>没有结果</InfiniteSelectEmpty>  {/* 组合通道 */}
  <InfiniteSelectFooter></InfiniteSelectFooter>
</InfiniteCombobox>

触发器可以是任何元素(库里的 Button 即可)—— Base UI 的 Popover.Trigger 把 它变成触发按钮,弹层开合、 焦点归还、Esc 关闭都由 Base UI 的 Popover 接管。SearchList 两个 part 由便利层 自己渲染,不要再写进来(再写一个 InfiniteSelectList 会得到两个列表)。

children 传函数时,整个 children 就是触发器(能拿到当前状态做摘要展示),此时没有 组合通道 —— 需要两者兼得就直接在 JSX 里读你自己持有的 state

降到基座自己组弹层时,InfiniteSelectInputGroup / InfiniteSelectList 也归你写, 见「组合式架构与 hooks」。

标签

触发器是你自己传进去的元素id 没法像 Select 那样写在部件上 —— 用 triggerId 把它交给 Base UI,FieldLabel htmlFor 就有了落点:

不给 triggerId 时,Base UI 会给触发器派一个没人猜得到的生成 id。给了之后 一条通道全包了:触发器是真 <button>,原生 <label for> 既给它命名(不需要 再补 aria-label —— 没有任何 aria-labelledby 压过来),又由浏览器把点击转发过去 打开弹层。四条标签通道的总表在 Field

标签就是控件:弹层开着时再点标签是关掉它,一次干净的开合。这需要封装层补一刀 —— 非模态弹层挡不住页面,Base UI 把这次按下判成 outside-press 直接关闭,浏览器紧接着 把 click 转发给触发器、触发器再翻转一次,两下相加就是「关掉又打开」的闪烁。指向自家 触发器的 <label> 不算「外部」,封装层把这次关闭 cancel() 掉,让转发的 click 独自 完成开合。

顺带一提,aria-label prop 命名的是弹层里的列表(不传则回退到 searchPlaceholder),和触发器的名字是两回事,别混。

多选

commitOnClose 下,弹层内的勾选只是草稿,关闭弹层才提交一次 onValueChange —— 适合筛选场景(每勾一下就请求一次太吵)。footer 里的清空 / 取消 / 确定通过 context 拿到 clear / cancel / close,不需要你穿线。

草稿模式下三个动作各不相同,别混:清空提交空集取消把草稿丢掉、什么都不提交InfiniteSelectCancel),确定提交草稿(关闭即提交)。只有取消这一条路不提交: Esc、点外面这些关闭方式和「确定」同路,草稿照样提交。

多选的 onValueChange(items, ids)ids 是权威全集:预选 id 落在未加载的页上时, 它有 id 没对象,items 只回显已加载的那些。持久化请存 ids

虚拟化

默认情况下,加载了多少行就渲染多少行。开启 virtualized 后行渲染交给 TanStack Virtual:任何时刻只有视口内可见的那一小段行存在于 DOM—— 实测加载一万行时 DOM 里只有 16 个节点,滚动高度照样是完整的 320000px。 (虚拟化统一到了 TanStack 一家,DataTable 用的也是它。) 下面的 demo 一次拉满 10000 条(无翻页):

<InfiniteCombobox<Person> virtualized rowHeight={32} />

开启前要知道的取舍只有一个:

行高固定rowHeight(默认 32px)——虚拟化靠固定行高换取零测量、零跳动; 自定义 renderItem 比默认行高时必须同步调大它。

键盘导航不受影响virtualized 会同时告诉 Base UI「行不全在 DOM 里」, 它于是按值列表而不是按 DOM 顺序导航。这也是为什么这个开关在根组件上而不是在 InfiniteSelectList 上 —— 底座和虚拟化器都要知道。

读屏的列表总数(aria-setsize)只覆盖 DOM 里的那一段,这是外部虚拟化的固有代价。

加载下一页的触发时机与非虚拟模式一致(提前约 1 个视口预取,由 loadMoreScrollOffset 控制),无需关心。

插槽

基座零文案。所有可见文案都从组合通道注入 —— 也就是 InfiniteCombobox 触发器之后的那些 children,i18n 只存在于你的业务层。 状态插槽是 context 驱动的组件,按状态自渲染、互斥;footer 一族是组合式部件, 放在组合通道末尾自然落在面板底部:

InfiniteSelectEmpty:空

列表为空(且不在加载 / 错误中)时自渲染——搜索无结果也是这个插槽:

加载中:统一的磨砂覆盖层

加载没有文案插槽——无论首屏还是刷新,视觉统一是列表区内建的 LoadingOverlay,一种加载长相。只盖列表区: 搜索框在覆盖范围之外,打字正是触发刷新的动作,盖住输入框等于打断输入。

首屏isLoading 且还没有结果):列表壳给最低高度(min-block-24)防坍缩,磨砂盖在空白上:

刷新isLoading 且上一批结果还在——业务层配 react-query 的 placeholderData): 列表原地保留、被磨砂盖住。

InfiniteSelectLoadingOverlay:定制磨砂

在组合通道里放它即可定制上面那层磨砂。它是 TabsIndicator 式的标记部件—— 写在哪里都不渲染(绝对定位的覆盖层没法住在面板的文档流里),由 List 部件 提升到列表壳的位置渲染;children 替换居中的 Spinner,className 调磨砂浓度, loading 是基座的接线、不在它的 props 里。直接子级或 Fragment 内有效, 包进自定义组件会找不到(与 TabsIndicator 同一限制)。用 InfiniteCombobox 时 写在触发器后面即可:

(旧版的 InfiniteSelectLoading 文案插槽已移除,定制走 InfiniteSelectLoadingOverlay。)

InfiniteSelectError / InfiniteSelectRetry:错误与重试

isError 时自渲染;InfiniteSelectRetry 自己接好基座的 onRetry(没有则 不渲染),你只出文案。这个 demo 首次加载必定失败,点重试恢复:

InfiniteSelectFooter 是底部动作条;InfiniteSelectClear / InfiniteSelectCancel / InfiniteSelectClose 通过 context 拿到 clear / cancel / close,不需要你穿线。三者提交的东西不一样:

  • 清空清空选择并顺带关闭弹层 —— commitOnClose 下这等于提交空选择
  • 取消把草稿丢掉、还原成打开前的样子再关闭,一次 onValueChange 都不发
  • 确定关闭弹层,commitOnClose 下就是提交草稿。

InfiniteSelectFooterSeparator 是竖向分隔。自定义部件用 useInfiniteSelectActions() 同样能拿到 selectedIds / selectedItems / clear / close / cancel——下面 demo 左边的「已选 N」就是一个自定义部件 (三个动作并排是为了对照,真实场景挑一个):

InfiniteSelectStatus:状态容器

空 / 错误两个状态插槽最终都渲染成同一个 role="status" 容器(状态切换会被读屏播报; 加载没有文案插槽,走磨砂覆盖层), 它也单独导出,自定义状态视觉时可复用。

InfiniteSelectLoadingMore:加载更多

翻下一页时,滚动流末尾会出现一个加载行。默认是一个 Spinner —— 和磨砂覆盖层、 终止行同一条规矩:默认给视觉,不给文案。要文字就组合它:

<InfiniteSelectLoadingMore>加载更多…</InfiniteSelectLoadingMore>

InfiniteSelectNoMore 同款的标记部件,也占同一个位置(滚动流末尾)—— 两者是同一位置的两个分支(还有下一页 / 没有了),不会同时出现。同样是定制入口 不是开关:不写也会渲染那个 Spinner。

预取提前约 1 个视口触发,所以停在顶部的用户根本看不到这一行,只有滚到底部、 恰好在等下一页的人才会看见。

InfiniteSelectNoMore:到底了

所有页都加载完(hasNextPagefalse)且列表非空时,滚动流末尾会出现一个 终止行,让列表看起来是「结束了」而不是「停住了」—— 页很小、加载又快的时候, 用户光看列表分不清「还在加载」和「没有了」。

下面两个下拉的数据源都是整三页(每页 16 条,共 48 条)。滚到底会依次看到 第 1 页 → 加载中… → 第 2 页 → 加载中… → 第 3 页 → 终止行。左边没有组合 InfiniteSelectNoMore,仍然有终止行(默认那条线);右边组合了,线被换成文案:

默认是一条淡出的细线,不是一句话。 基座零文案,一个要发 npm 的库不该往 DOM 里塞任何一种语言 —— 这和加载态默认是 Spinner 而不是「加载中…」是同一条规矩。

它是定制入口,不是开关

<InfiniteCombobox {/* … */}>
  <Button>选择作曲家</Button>
  <InfiniteSelectEmpty>没有匹配的结果</InfiniteSelectEmpty>
  <InfiniteSelectNoMore>没有更多数据</InfiniteSelectNoMore>
</InfiniteCombobox>

不写 → 渲染默认细线。写了 → children 顶掉细线,className 叠加在终止行上。

InfiniteSelectLoadingOverlay 同款的标记部件:写在组合通道里任意位置、 原地不渲染,由 List 提升到只有它知道的位置(滚动流末尾)。直接子级或 Fragment 内有效,包进自定义组件会找不到 —— 与 TabsIndicator 同一限制。

何时不渲染

状态终止行
hasNextPagetrue不渲染(那个位置是 load-more 哨兵)
列表为空不渲染(该由 InfiniteSelectEmpty 说话)
首屏加载 / 错误不渲染(列表壳本身就没渲染)
加载完且非空渲染

终止行和加载更多指示是同一个位置的两个分支,不会同时出现。

底层

终止行、加载指示和翻页哨兵都渲染在 listbox 元素之外、滚动容器之内。 所以它们不是 option,不进 aria-setsizegetAllByRole('option') 数到的就是真实行。

翻页由一个 IntersectionObserver 哨兵触发(loadMoreScrollOffset 是它提前多少个 视口高度开火)—— 用观察器而不是滚动监听,因为要紧的是哨兵的位置而不是滚动偏移: 虚拟化之后偏移根本说明不了还剩多少行。

React Aria 那版这里有笔债:loader 行必须是不可选中的 role="option"role="listbox" 的合法子节点只有 option),DOM 上因此多出一个 option 元素, 测试里数行得绕开它。现在没有了。

动作上下文

footer 部件之所以不需要穿线,是因为面板内有一个动作上下文useInfiniteSelectActions<T>() 在组合通道里的任何部件中都可调用,返回 InfiniteSelectActions<T>

字段说明
selectedIds当前选择的权威全集(含未加载页的 id)
selectedItems已加载页的选中对象(口径与 onValueChangeitems 一致)
clear()清空选择
close()关闭弹层(commitOnClose 下即提交草稿)
cancel()丢掉草稿、还原成已提交值再关闭,不提交任何东西;没有草稿时就是 close()

InfiniteSelectClear / InfiniteSelectCancel / InfiniteSelectClose 内部就是调它; 「插槽」一节 Footer demo 里的「已选 N」自定义部件也是。两个注意点:

  • 在 provider 之外调用会直接抛错(fail loud),所以它只能出现在组合通道 子树里;
  • 上下文由 InfiniteCombobox 自动填充。只有当你不用 InfiniteCombobox、 自己拿 InfiniteSelect 基座组弹层时,才需要用 InfiniteSelectActionsProvider 亲自喂值:
{/* 草稿是 InfiniteCombobox 的能力,自己组弹层时没有草稿可丢 —— cancel 直接给 close */}
<InfiniteSelectActionsProvider value={{ selectedItems, selectedIds, clear, close, cancel: close }}>
  <InfiniteSelect {/* … */}>{/* parts 与状态插槽 */}</InfiniteSelect>
</InfiniteSelectActionsProvider>

上下文定义在基座层而不是 combobox 层,是为了避免循环导入:combobox 已经 导入基座,钩子若住在上层,基座再反向导入就成环了——业务层做同类分层时同理。

数据适配器

组件不做请求。list prop 接受任何满足 InfiniteSelectAdapterProps<T> 的对象:

interface InfiniteSelectAdapterProps<T> {
  items: T[]
  isLoading: boolean
  isFetchingNextPage: boolean
  hasNextPage: boolean
  isError: boolean
  onLoadMore: () => void
  onRetry: () => void
}

用 react-query 时它就是 useInfiniteQuery 的一层薄壳:

function usePersonList(query: string | undefined): InfiniteSelectAdapterProps<Person> {
  const q = useInfiniteQuery({
    queryKey: ['people', query],
    queryFn: ({ pageParam }) => fetchPeople({ cursor: pageParam, query }),
    initialPageParam: undefined as string | undefined,
    getNextPageParam: (last) => last.nextCursor,
  })
  return {
    items: q.data?.pages.flatMap((p) => p.items) ?? [],
    isLoading: q.isLoading,
    isFetchingNextPage: q.isFetchingNextPage,
    hasNextPage: q.hasNextPage,
    isError: q.isError,
    onLoadMore: () => void q.fetchNextPage(),
    onRetry: () => void q.refetch(),
  }
}

搜索的分工:inputValue 是输入框的即时值, queryValue 是防抖 300ms 后的请求值(可用 debounceMs 调)。把 state.queryValue 传给适配器即可;关闭弹层后再次打开会自动清空搜索。

自定义行内容

renderItem 替换的是行内容,不是行本身 —— Combobox.Item 外壳保留, 键盘导航和选中语义不受影响。签名:

renderItem?: (params: InfiniteSelectItemRenderParams<T>) => ReactNode
 
interface InfiniteSelectItemRenderParams<T> {
  /** 这一行对应的原始数据对象。 */
  item: T
  /** getOption(item) 的结果:id / label / disabled。 */
  option: InfiniteSelectOption
  /** 行在当前已加载列表中的位置,从 0 开始。语义见下方说明。 */
  index: number
  // Base UI 的条目状态词汇
  selected: boolean
  disabled: boolean
  /** 面板的选择模式(用于决定要不要自绘勾选框)。 */
  selectionMode: 'single' | 'multiple'
}

提供 renderItem 后默认行内容整体不再渲染——包括多选的勾选框和单选的对勾, 需要这些视觉时用 selected / selectionMode 自行绘制:

<InfiniteCombobox<Person>
  renderItem={({ item, index, selected }) => (
    <>
      <span className="w-8 text-right text-xs tabular-nums text-muted-foreground">
        {index + 1}.
      </span>
      <span className="flex-1 truncate">{item.name}</span>
      <span className="text-xs text-muted-foreground">{item.role}</span>
      {selected && <span className="text-xs text-primary"></span>}
    </>
  )}
  {/* … */}
/>

index 是这一行在当前已加载列表里的位置,从 0 开始,用来做序号、斑马纹这类 「第几行」的展示。两点注意:

  • 它不是稳定标识。搜索或翻页之后,同一条数据的 index 会变——要唯一标识一行, 用 option.id
  • 开了 virtualized 就别再用 CSS 的 nth-child 做奇偶条纹。那时 DOM 里只有 滚动位置附近的几十行,nth-child 数的是这几十行的相对位置,一滚动奇偶就错乱; index 数的是完整列表的位置,不受滚动影响。

键盘/指针高亮不在参数里:它归 Base UI 所有,渲染期间读不到。要跟着高亮换样式, 挂行上的 data-highlighteditemClassName 的函数形式或 Tailwind 变体都行), 见状态与 className

label 不是纯字符串也不用补什么:这里没有客户端 typeahead,过滤全在服务端 —— 打字驱动 onInputValueChange,调用方重新取数。

组合式架构与 hooks

三层结构:

  1. Hooks 层useXxxState 语义)—— useInfiniteSelectSelection 管选择:受控/非受控 ids、跨页对象缓存、 Base UI 的值 → 业务 onValueChange 的翻译;useInfiniteComboboxState 管弹层开合与搜索/防抖;useDataPaginationState 是同一模式在分页器上的应用。 全部导出,可脱离组件 headless 使用。

  2. Parts 层(组合式组件 + context 接线)——根组件 InfiniteSelect 提供 数据与选择的 context 和 Base UI Combobox.Root 外壳。不写 children 就渲染 默认组合InfiniteSelectInputGroup + InfiniteSelectList,默认在场家法); 写了 children 整条通道归你。InfiniteSelectInputGroup (搜索框)、InfiniteSelectList(列表,含虚拟化与预取)、状态插槽、footer 一族都是普通 children,按书写顺序渲染,各自带自己的 props:

    <InfiniteSelect
      {...list}
      getOption={getOption}
      selectionMode="multiple"
      value={ids}
      onValueChange={(items, nextIds) => setIds(nextIds)}
      inputValue={state.inputValue}
      onInputValueChange={state.setInputValue}
      virtualized
    >
      <InfiniteSelectInputGroup placeholder="搜索…" />
      <InfiniteSelectList />
      <InfiniteSelectEmpty>没有结果</InfiniteSelectEmpty>
    </InfiniteSelect>

    搜索框与列表之间不用手动接线——根组件的 Combobox.Root 会通过 Base UI 自己的 context 把任意深度的 Combobox.Input / Combobox.List 后代配对(虚拟焦点、 选择语义)。自定义 part 用 useInfiniteSelectContext() 读根组件提供的接线 (items / getOption / selectionMode / selectedIds / filled / hasNextPage / isFetchingNextPage / onLoadMore / virtualized / rowHeight,以及从组合通道提升 上来的 loadingOverlayProps / noMoreProps / loadingMoreProps)。

  3. 便利层 —— InfiniteCombobox 用一组扁平 props 把弹层 + parts + 插槽 组好,是业务代码的默认入口;它内部就是上面那段组合代码。

受控

三条通道各自独立,都是标准的受控 / 非受控三件套:

通道住在哪三件套
选择根组件 / 便利层value / defaultValue / onValueChange
搜索词useInfiniteComboboxState()inputValue / defaultInputValue / onInputValueChange
弹层开合useInfiniteComboboxState()open / defaultOpen / onOpenChange

受控与否的判据是 value !== undefined(Base UI 的口径):单选清空传 nullundefined 只表示「非受控」。多选的受控值是 string[],权威口径见多选

防抖后的请求值 queryValue 也可受控(queryValue / defaultQueryValue / onQueryValueChange),debounceMs 默认 300

每个变化回调的第二个参数都是 eventDetailsreason 说明这次变化的来源 (item-press / clear-press / escape-key / …,程序触发是 'none'), cancel() 拒绝这次变更、状态原地不动。

reason 全部沿用 Base UI 的词表,只有 'cancel-press' 是本库自造的:上游没有草稿模式, 自然没有「丢掉草稿」这个词。它只会出现在 onOpenChange 上 —— 取消什么都不提交, onValueChange 根本不发。

表单

家族自己不做 FormData 逻辑,只按需渲染隐藏 input:给了 name 才渲染 <input type="hidden">,多选每个选中 id 一个、单选一个;不给 name 就一个也没有。

  • InfiniteCombobox 把它们渲染在触发器旁、弹层外 —— 弹层关闭时整个卸载, 渲染在里面提交就什么都不剩。
  • commitOnClose草稿不序列化,只有已提交的选择进 FormData
  • 是普通的隐藏 input,不是 Base UI 那个可聚焦的 visually-hidden input:原生校验 和自动填充对一个服务端过滤的面板都不适用。

什么时候用 InfiniteCombobox

整份列表已经在手上、过滤只需在浏览器里做,就不必上这一家 —— 那是 Combobox,同样是 Base UI 的 Combobox,但过滤开着、 没有游标分页那一整套。分工看 Select 那页开头的四选一表

状态与 className

左列是挂在 DOM 上的 data-*,Tailwind 直接当变体写(data-selected:bg-accent); 右列是同一个状态在 renderItem 参数 / 函数形式 itemClassName 里的名字。注意 Base UI 的布尔状态写的是空字符串data-selected=""),不是 "true"。表里 没列出来的 data-* 是 Base UI 自己的内部标记,不要依赖。

InfiniteSelect(根元素,三个逻辑态;空串存在型,CSS 直接当钩子用)

状态出现时机
data-loadingisLoading 为真
data-empty不在加载 / 错误中,且 items 为空
data-errorisError 为真

弹层InfiniteComboboxdata-slot="infinite-combobox-content"

状态出现时机说明
data-side弹层实际贴的那一边,值是 bottom / top / …出场动画按方向分岔时挂这个

触发器是你自己传的 children,状态取决于你用什么渲染。用我们的 Button 的话, 它只有 data-disabled / data-pending——交互态没有 data 属性,直接用 hover: / active: / focus-visible: 伪类。

选项行data-slot="infinite-select-item",由 InfiniteSelectList 渲染的 Base UI Combobox.Item

状态出现时机renderItem 参数
data-selected该项已选中selected
data-highlighted是当前高亮项(虚拟焦点,真实焦点留在搜索框),键盘和指针都会给无——渲染期读不到,见下
data-disabledgetOption 返回了 disabled: truedisabled

renderItem 的参数是 item / option / index / selected / disabled / selectionModeselectionMode 只在参数里、DOM 上没有对应属性(自绘勾选框时用 它判断单选还是多选);反过来 data-highlighted 只在 DOM 上、参数里没有 —— 高亮归 Base UI 所有,渲染期间读不到,所以要跟着高亮换样子只能走 CSS:Tailwind 变体 data-highlighted: 或函数形式的 itemClassName

搜索框保持真实焦点、列表项拿虚拟焦点,是 Base UI Combobox.Rootinline 模式下的设计:这样打字和上下键可以同时工作。所以高亮样式要挂在 data-highlighted 上,别挂 :focus

InfiniteSelectList

状态出现时机说明
data-emptyBase UI 在列表没有条目时写上实际见不到:没有行时 InfiniteSelectList 整个不渲染,由 InfiniteSelectEmpty 接手

InfiniteSelectInputGroup 没有状态属性 —— 这一层是普通 <div>,状态都在里面的 Combobox.Input 上。

每个部件都带 data-slotinfinite-select / infinite-select-input-group / infinite-select-input / infinite-select-list / infinite-select-item / infinite-combobox-content……),需要从外部定位时当选择器用;状态插槽与 footer 各自的 data-slot下面那张表

className 的形态各部件不同:根组件、InfiniteSelectInputGroup 和状态插槽是普通 <div>,只收字符串;InfiniteSelectList 的三个类名口子(className / listClassName / itemClassName)和便利层的 contentClassName 底下是 Base UI 部件,可以传 (state) => string 的函数形式。

键盘交互

按键行为
/ 在列表中移动(输入框保持焦点)
Enter选中当前项
Esc关闭弹层(由 Base UI 的 Popover 接管;搜索词在下次打开时才清空)
打字过滤(经防抖交给数据层)

导出的类型

除组件外,@gedatou/cadenza-ui 还导出以下类型,供业务层封装和数据适配器使用:

类型说明
InfiniteSelectOptiongetOption 的返回形状:{ id, label, disabled? }
InfiniteSelectItemRenderParams<T>renderItem 的参数,见自定义行内容
InfiniteSelectAdapterProps<T>list prop 的契约——数据适配器要实现的形状,见数据适配器
ControllableSelectionProps<T>选择相关 props 的判别联合,判别键是 selectionMode(单选 value?: string / 多选 value?: string[]onValueChange 签名随之切换)
InfiniteSelectProps<T> / InfiniteComboboxProps<T>根组件与便利层的完整 props,业务层包一层时直接复用
InfiniteSelectInputGroupProps / InfiniteSelectListProps<T>两个 parts 的 props
InfiniteSelectLoadingOverlayProps / InfiniteSelectNoMoreProps / InfiniteSelectLoadingMoreProps三个标记部件的 props——第一个是去掉 loadingLoadingOverlayProps,后两个是 ComponentProps<'div'>
InfiniteSelectContextValue<T>useInfiniteSelectContext() 的返回——自定义 part 可读的接线
InfiniteSelectSelectionState<T>useInfiniteSelectSelection() 的返回:selectedIds / selectedItems / onValueChange(喂给 Base UI 的 Combobox.Root
InfiniteSelectActions<T>动作上下文的值形状(useInfiniteSelectActions() 的返回 / InfiniteSelectActionsProvider 的入参),见动作上下文
InfiniteComboboxState<T> / InfiniteComboboxStateOptionsuseInfiniteComboboxState 的返回值与入参
InfiniteComboboxChildren<T>children 的类型:ReactNode / ReactNode[](首个是触发器,其余进组合通道),或接收状态的函数
InfiniteSelectChangeEventReason / InfiniteSelectChangeEventDetailsonValueChangeuseInfiniteComboboxStateonInputValueChange 的第二参:Base UI Combobox 的 reason 词加上 'none' / 'cancel-press',可 cancel()
InfiniteComboboxOpenChangeEventReason / InfiniteComboboxOpenChangeEventDetailsonOpenChange 的第二参:Popover 的 reason 词并上前一套

Props

顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。 各部件的状态属性不在这些表里,统一见状态与 className

InfiniteCombobox

便利层:一个 props 面,内部拆开路由到根组件和各个 part。改用组合式写法时, searchPlaceholderInfiniteSelectInputGroup 上找 placeholderrenderItem / maxListHeight / scrollbars / loadMoreScrollOffsetInfiniteSelectList 上,virtualized / rowHeight根组件 InfiniteSelect 上(底座和虚拟化器都要知道,见「虚拟化」);加载更多的文案走 InfiniteSelectLoadingMore 部件。

Prop类型默认值说明
stateInfiniteComboboxState必填,来自 useInfiniteComboboxState()
listInfiniteSelectAdapterProps<T>必填,数据适配器
getOption(item: T) => InfiniteSelectOption必填,提取 id / label / disabled
childrenReactNode | ReactNode[] | (state) => ReactElement必填。第一个 child 是触发器,其后的每个 child 进面板的组合通道;任何元素都行(Base UI 的 Popover.Trigger 把它接成触发按钮);函数形式可拿到整个 state(含 open)加上 selectedItems / selectedValue / disabled
selectionMode'single' | 'multiple''single'判别式:决定下面三个的类型(与 DataTable 同名)
defaultValuestring | string[]非受控初始选择
value单选 string | null;多选 string[]受控选择。单选清空传 nullundefined 表示非受控)
onValueChange单选 (item: T | null, eventDetails) => void;多选 (items: T[], ids: string[], eventDetails) => void多选时 ids 才是权威值,items 只含已加载页的对象。eventDetails.reason 说明来源,cancel() 拒绝这次变更
disabledbooleanfalse禁用整个选择器:触发器元素会被注入 disabled,弹层不可打开
commitOnClosebooleanfalse多选草稿模式,关弹层才提交;InfiniteSelectCancel 是不提交的出口
namestring表单序列化:有 name 才渲染隐藏 input(多选每值一个),渲染在触发器旁、弹层外;草稿不序列化,见表单
closeOnSelectbooleantrue单选选中后自动关闭
modalbooleanfalse锁定页面滚动(模态感)
searchPlaceholderstring搜索框占位符,同时兼作它的无障碍名
renderItem(params: InfiniteSelectItemRenderParams<T>) => ReactNode替换行内容(Combobox.Item 外壳保留),参数见自定义行内容
loadMoreScrollOffsetnumber1预取距离:提前多少个视口高度触发 onLoadMore
virtualizedbooleanfalseTanStack Virtual 行虚拟化,大数据集用
rowHeightnumber32虚拟化行的固定高度;自定义 renderItem 更高时需同步调整
maxListHeightnumber256列表最大高度
scrollbars'always' | 'hover' | 'hidden''hover'滚动条可见性:常显 / hover 与滚动时显示 / 常隐
aria-labelstringsearchPlaceholder面板的无障碍名
selectClassNamestring弹层内根组件的类名
contentClassNamestring | (state) => string弹层本身的类名,函数形式收到 Base UI 的 popup 状态
triggerIdstring给触发器一个 id,供 FieldLabel htmlFor 指过来(触发器是调用方自己的元素,所以 id 由这里注入),见标签
popoverPropsOmit<PopoverContentProps, 'children' | 'className'>弹层定位面:side / sideOffset / align…… 接线写在展开之后、始终占上风;contentClassName 是类名出口

防抖不在这张表上 —— debounceMs(默认 300)是 useInfiniteComboboxState() 的选项。

InfiniteSelect

根组件:持有数据与选择,通过 context 接线给各个 part。它自己不渲染任何文案和 part。

Prop类型默认值说明
itemsT[]必填,当前已加载(服务端过滤后)的数据
getOption(item: T) => InfiniteSelectOption必填,提取 id / label / disabled
selectionMode'single' | 'multiple''single'判别式,同上
defaultValuestring | string[]非受控初始选择
value单选 string | null;多选 string[]受控选择。单选清空传 nullundefined 表示非受控)
onValueChangeInfiniteCombobox选择变化回调
inputValuestring受控搜索词
onInputValueChange(value, eventDetails) => void搜索词变化回调,details 是 Base UI Combobox 的原物(reason / cancel()
isLoadingbooleanfalse首屏加载中
isFetchingNextPagebooleanfalse正在取下一页
hasNextPagebooleanfalse还有下一页
isErrorbooleanfalse加载失败
searchPlaceholderstring默认组合的搜索框占位符兼无障碍名(与 InfiniteCombobox 同词)。写了 children 就忽略
onLoadMore() => void触底(或提前一个视口)时调用
onRetry() => voidInfiniteSelectRetry 用;不传则该插槽不渲染
aria-labelstring面板无障碍名,InfiniteSelectList 会回退到它
namestring表单序列化:有它才渲染隐藏 input(多选每值一个)
virtualizedbooleanfalse行虚拟化,见虚拟化
rowHeightnumber32虚拟化行高(固定值)
childrenReactNodepart / 状态插槽 / footer,按调用方书写顺序渲染
classNamestring根元素类名

InfiniteSelectInputGroup

Prop类型默认值说明
placeholderstring默认组合里输入框的占位符,同时兼作无障碍名。没有默认文案——基座零可见文案;两者都没传时无障碍名兜底为 'Search'(只进 aria,不上屏)
autoFocusbooleanfalse挂载即聚焦。默认关闭(内联面板不该抢页面焦点);InfiniteCombobox 会传 true,弹层一开就能打字
childrenReactNode图标 + 输入框传了就完全接管内部结构;里面的 Combobox.Input 由根组件的 context 自动接线,placeholder / autoFocus 只作用于默认组合
classNamestring类名。这个 part 就是一个普通 <div>,不是 Base UI 部件,只收字符串
其余原生 <div> 的 props透传到搜索行的容器。value / onValueChange 不放开 —— 那是根组件的接线

InfiniteSelectList

Prop类型默认值说明
aria-labelstring回退到根组件的列表的无障碍名
renderItem(params: InfiniteSelectItemRenderParams<T>) => ReactNode替换行内容,Combobox.Item 外壳保留
loadMoreScrollOffsetnumber1预取距离,单位是视口高度
maxListHeightnumber256最大高度
scrollbars'always' | 'hover' | 'hidden''hover'滚动条可见性
classNamestring | (state) => string外层滚动容器的类名(尺寸约束定在这里)。函数形式收到 ScrollArea 根的状态
listClassNamestring | (state) => stringlistbox 元素本体的类名,函数形式收到 Base UI 的列表状态
itemClassNamestring | (state) => string每一行的类名,函数形式收到 Base UI 的条目状态(见状态与 className 的「选项行」)

选项行

不是一个可写的组件 —— 由 InfiniteSelectList 渲染的 Base UI Combobox.Itemdata-slot="infinite-select-item"renderItem 换掉的是它的内容,外壳、 键盘导航和选择接线都还在,所以它的那些状态照常可用,见状态与 className

InfiniteSelectActionsProvider

不是插槽,是动作上下文的手动入口:把 value 喂给它,里面的 footer 部件就能拿到 useInfiniteSelectActions()。便利层已经自动提供了一份,只有把 footer 摆到弹层之外 时才需要自己写,见动作上下文

Prop类型默认值说明
valueInfiniteSelectActions<T>必填。{ selectedItems, selectedIds, clear, close, cancel };没有草稿可丢弃时 cancel 直接传 close
childrenReactNode消费这份上下文的子树

这几个部件都没有自己的 props(除了 className 和原生属性透传),文案由 children 决定,是否渲染由 context 里的状态决定 —— 详见插槽动作上下文

部件何时渲染data-slot
InfiniteSelectEmpty加载完、无错误、items 为空infinite-select-status
InfiniteSelectErrorisErrorinfinite-select-status
InfiniteSelectRetryError 内,且根上传了 onRetryinfinite-select-retry
InfiniteSelectFooter总是(由调用方决定放不放)infinite-select-footer
InfiniteSelectFooterSeparator总是infinite-select-footer-separator
InfiniteSelectLoadingOverlay标记部件:原地不渲染,props 被提升到列表壳的覆盖层上提升后为 loading-overlay
InfiniteSelectNoMore标记部件:原地不渲染,props 被提升到滚动流末尾的终止行上(不写也有默认线)提升后为 infinite-select-no-more
InfiniteSelectLoadingMore标记部件:同一位置的另一分支,翻页时渲染(不写也有默认 Spinner)提升后为 infinite-select-load-more
InfiniteSelectClear总是infinite-select-clear
InfiniteSelectCancel总是infinite-select-cancel
InfiniteSelectClose总是infinite-select-close
InfiniteSelectStatus空 / 错误两个插槽最终都渲染进它;单独导出供你复用排版infinite-select-status

空 / 错误两个状态插槽共用一个 role="status" 的容器,所以变化会被读屏播报一次, 而不是两种写法各播一次。

两个标记部件(InfiniteSelectLoadingOverlay / InfiniteSelectNoMore)是定制入口 而不是开关 —— 不组合它们,默认视觉(磨砂 Spinner / 淡出细线)照样渲染;组合它们 是为了替换 children、叠加 className。其余部件不写就是没有。