可搜索、游标分页无限滚动的选择器家族。打开后滚到底会自动加载下一页;输入会防抖
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 接管。Search 和 List 两个 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 首次加载必定失败,点重试恢复:
Footer 一族:清空 / 取消 / 确定 / 分隔 / 自定义部件
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:到底了
所有页都加载完(hasNextPage 为 false)且列表非空时,滚动流末尾会出现一个
终止行,让列表看起来是「结束了」而不是「停住了」—— 页很小、加载又快的时候,
用户光看列表分不清「还在加载」和「没有了」。
下面两个下拉的数据源都是整三页(每页 16 条,共 48 条)。滚到底会依次看到
第 1 页 → 加载中… → 第 2 页 → 加载中… → 第 3 页 → 终止行。左边没有组合
InfiniteSelectNoMore,仍然有终止行(默认那条线);右边组合了,线被换成文案:
默认是一条淡出的细线,不是一句话。 基座零文案,一个要发 npm 的库不该往 DOM 里塞任何一种语言 —— 这和加载态默认是 Spinner 而不是「加载中…」是同一条规矩。
它是定制入口,不是开关:
<InfiniteCombobox {/* … */}>
<Button>选择作曲家</Button>
<InfiniteSelectEmpty>没有匹配的结果</InfiniteSelectEmpty>
<InfiniteSelectNoMore>没有更多数据</InfiniteSelectNoMore>
</InfiniteCombobox>不写 → 渲染默认细线。写了 → children 顶掉细线,className 叠加在终止行上。
和 InfiniteSelectLoadingOverlay 同款的标记部件:写在组合通道里任意位置、
原地不渲染,由 List 提升到只有它知道的位置(滚动流末尾)。直接子级或 Fragment
内有效,包进自定义组件会找不到 —— 与 TabsIndicator 同一限制。
何时不渲染
终止行和加载更多指示是同一个位置的两个分支,不会同时出现。
底层
终止行、加载指示和翻页哨兵都渲染在 listbox 元素之外、滚动容器之内。
所以它们不是 option,不进 aria-setsize,getAllByRole('option') 数到的就是真实行。
翻页由一个 IntersectionObserver 哨兵触发(loadMoreScrollOffset 是它提前多少个
视口高度开火)—— 用观察器而不是滚动监听,因为要紧的是哨兵的位置而不是滚动偏移:
虚拟化之后偏移根本说明不了还剩多少行。
React Aria 那版这里有笔债:loader 行必须是不可选中的
role="option"(role="listbox"的合法子节点只有 option),DOM 上因此多出一个 option 元素, 测试里数行得绕开它。现在没有了。
动作上下文
footer 部件之所以不需要穿线,是因为面板内有一个动作上下文。
useInfiniteSelectActions<T>() 在组合通道里的任何部件中都可调用,返回
InfiniteSelectActions<T>:
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-highlighted(itemClassName 的函数形式或 Tailwind 变体都行),
见状态与 className。
label 不是纯字符串也不用补什么:这里没有客户端 typeahead,过滤全在服务端 ——
打字驱动 onInputValueChange,调用方重新取数。
组合式架构与 hooks
三层结构:
-
Hooks 层(
useXxxState语义)——useInfiniteSelectSelection管选择:受控/非受控 ids、跨页对象缓存、 Base UI 的值 → 业务onValueChange的翻译;useInfiniteComboboxState管弹层开合与搜索/防抖;useDataPaginationState是同一模式在分页器上的应用。 全部导出,可脱离组件 headless 使用。 -
Parts 层(组合式组件 + context 接线)——根组件
InfiniteSelect提供 数据与选择的 context 和 Base UICombobox.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)。 -
便利层 ——
InfiniteCombobox用一组扁平 props 把弹层 + parts + 插槽 组好,是业务代码的默认入口;它内部就是上面那段组合代码。
受控
三条通道各自独立,都是标准的受控 / 非受控三件套:
受控与否的判据是 value !== undefined(Base UI 的口径):单选清空传 null,
undefined 只表示「非受控」。多选的受控值是 string[],权威口径见多选。
防抖后的请求值 queryValue 也可受控(queryValue / defaultQueryValue /
onQueryValueChange),debounceMs 默认 300。
每个变化回调的第二个参数都是 eventDetails:reason 说明这次变化的来源
(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 直接当钩子用)
弹层(InfiniteCombobox 的 data-slot="infinite-combobox-content")
触发器是你自己传的 children,状态取决于你用什么渲染。用我们的 Button 的话,
它只有 data-disabled / data-pending——交互态没有 data 属性,直接用
hover: / active: / focus-visible: 伪类。
选项行(data-slot="infinite-select-item",由 InfiniteSelectList 渲染的
Base UI Combobox.Item)
renderItem 的参数是 item / option / index / selected / disabled /
selectionMode。selectionMode 只在参数里、DOM 上没有对应属性(自绘勾选框时用
它判断单选还是多选);反过来 data-highlighted 只在 DOM 上、参数里没有 ——
高亮归 Base UI 所有,渲染期间读不到,所以要跟着高亮换样子只能走 CSS:Tailwind 变体
data-highlighted: 或函数形式的 itemClassName。
搜索框保持真实焦点、列表项拿虚拟焦点,是 Base UI Combobox.Root 在 inline
模式下的设计:这样打字和上下键可以同时工作。所以高亮样式要挂在
data-highlighted 上,别挂 :focus。
InfiniteSelectList
InfiniteSelectInputGroup 没有状态属性 —— 这一层是普通 <div>,状态都在里面的
Combobox.Input 上。
每个部件都带 data-slot(infinite-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 的函数形式。
键盘交互
导出的类型
除组件外,@gedatou/cadenza-ui 还导出以下类型,供业务层封装和数据适配器使用:
Props
顺序规则:必填 → 非受控默认值 → 受控值 → 回调 → 行为开关 → 外观 → className。 各部件的状态属性不在这些表里,统一见状态与 className。
InfiniteCombobox
便利层:一个 props 面,内部拆开路由到根组件和各个 part。改用组合式写法时,
searchPlaceholder 去 InfiniteSelectInputGroup 上找 placeholder,
renderItem / maxListHeight / scrollbars / loadMoreScrollOffset 在
InfiniteSelectList 上,virtualized / rowHeight 在根组件 InfiniteSelect
上(底座和虚拟化器都要知道,见「虚拟化」);加载更多的文案走
InfiniteSelectLoadingMore 部件。
防抖不在这张表上 —— debounceMs(默认 300)是 useInfiniteComboboxState() 的选项。
InfiniteSelect
根组件:持有数据与选择,通过 context 接线给各个 part。它自己不渲染任何文案和 part。
InfiniteSelectInputGroup
InfiniteSelectList
选项行
不是一个可写的组件 —— 由 InfiniteSelectList 渲染的 Base UI Combobox.Item,
data-slot="infinite-select-item"。renderItem 换掉的是它的内容,外壳、
键盘导航和选择接线都还在,所以它的那些状态照常可用,见状态与 className。
InfiniteSelectActionsProvider
不是插槽,是动作上下文的手动入口:把 value 喂给它,里面的 footer 部件就能拿到
useInfiniteSelectActions()。便利层已经自动提供了一份,只有把 footer 摆到弹层之外
时才需要自己写,见动作上下文。
状态插槽与 footer
这几个部件都没有自己的 props(除了 className 和原生属性透传),文案由 children
决定,是否渲染由 context 里的状态决定 —— 详见插槽与动作上下文。
空 / 错误两个状态插槽共用一个 role="status" 的容器,所以变化会被读屏播报一次,
而不是两种写法各播一次。
两个标记部件(InfiniteSelectLoadingOverlay / InfiniteSelectNoMore)是定制入口
而不是开关 —— 不组合它们,默认视觉(磨砂 Spinner / 淡出细线)照样渲染;组合它们
是为了替换 children、叠加 className。其余部件不写就是没有。