打开一个可搜索的下拉框,输入「杭」,列表只剩杭州。按 ↓——没有高亮;按 Enter——没有选中;按 Esc——关不掉。
鼠标点一下就能选,键盘却完全用不了。问题不在按键处理写错了,而在它挂错了 DOM 树。这篇把链路从头走一遍:事件怎么丢的、ARIA 怎么补、滚动又怎么被聚焦动作抢走。
三个键,一次都不响应
可搜索的 Select(filterable)打开后会把焦点送进搜索框。从这一刻起,下面这些键全部失效:
| 操作 | 0.2.23 的实测结果 |
|---|---|
| ↓ 两次 | 高亮停在北京(0),不动 |
| Enter | 未选中,面板保持打开 |
| Esc | 面板关不掉 |
| 关闭状态按 ↑ | 不打开 |
对照组是同一套代码的非搜索模式:焦点在触发器上,↓/Enter/Esc 全部正常。也就是说,不是键盘逻辑没写,而是换了焦点位置之后,键盘逻辑收不到事件了。
根因:两个 DOM 子树
源码里,键盘处理挂在触发器上:
<div class="rn-select__trigger" role="combobox" @keydown="onKeydown">
搜索框则在另一个地方——下拉面板被 Teleport 到了 body:
<Teleport to="body">
<div v-if="open" class="rn-select__dropdown" role="listbox">
<input ref="searchRef" class="rn-select__search-input" />
...
打开时,watch(open) 会在下一帧把焦点送进搜索框:
requestAnimationFrame(() => searchRef.value?.focus());
于是按键的事件路径变成:input → .rn-select__dropdown → body → document。它永远不会经过 .rn-select__trigger,因为这两个节点不在同一棵子树里。触发器上的 @keydown 自然一次都收不到。
这也解释了为什么非搜索模式正常:焦点还在触发器内部,事件从触发器冒泡,处理函数就在原地。
为什么单测没发现
改之前,Select 只有一条键盘用例,写法是:
await trigger.trigger("keydown", { key: "ArrowDown" });
它把事件直接打在触发器上——正好是唯一能工作的那条路径。真实用户按键盘时,事件目标是搜索框,测试里一次都没出现过。
| 路径 | 单测覆盖 | 真实使用 |
|---|---|---|
| 焦点在触发器 | 有 | 仅非搜索模式 |
| 焦点在搜索框 | 无 | filterable / remote 的默认路径 |
补测试时,我把 input.trigger("keydown", ...) 写进去,四条新用例立刻全红——bug 才第一次被固定下来。这不是测试写得少,而是测试的入口选错了:对着触发器打事件,相当于永远只走那条唯一没坏的路径。
修法一:把键盘处理挂到面板上
最小改动不是加判断,而是让处理函数出现在事件冒泡的路径上:
<div v-if="open" class="rn-select__dropdown" @keydown="onKeydown">
搜索框的 keydown 冒泡到面板,onKeydown 就能收到。触发器上的监听保留,两条路径各自工作,互不干扰。
为什么不用 document 监听?上一轮修弹层时刚踩过:document 是全局的,多层弹层同时打开时,每层都能收到 Esc,结果「一次 Esc 关掉两层」。键盘监听的范围应该等于焦点能到的范围,而不是整个文档。面板正好是这个范围的边界。
修法二:combobox 的 ARIA 不能只靠 aria-selected
视觉上高亮在动,屏幕阅读器却什么都不知道。改之前的实测:
- 8 个选项里带
id的:0 个 - 触发器上的
aria-activedescendant:无
aria-selected 只说明「这一项被选中了」,不说明「键盘现在停在哪一项」。combobox + listbox 的标准做法是给每个选项一个稳定 id,触发器用 aria-activedescendant 指向当前高亮项:
<div role="combobox" :aria-controls="listboxId" :aria-activedescendant="activeDescendant">
<div role="listbox" :id="listboxId">
<div role="option" :id="optionId(index)">
改完之后:选项 id 8/8,aria-activedescendant=rn-select-listbox-v-0-option-2 随高亮同步。
这里不能改成「把 DOM 焦点移到选项上」(roving tabindex):搜索框必须保持输入焦点,否则用户打完关键字就没法继续输入。焦点留在输入框、高亮用 activedescendant 表达,是这种组合控件唯一不打架的方案。
修法三:↑ 打开时要落在最后一项
原来的 onKeydown 只在打开状态处理 ↑;关闭状态按 ↑ 没有任何反应。补上「打开并高亮末项」之后,还要注意 nextEnabledIndex 的取模公式:
index = (from + direction + length) % length;
from = -1、direction = -1、length = 8 时,第一步算出来是 6,不是 7——从「没有高亮」往上走,会落到倒数第二项。所以过滤后(高亮被重置为 -1)按 ↑ 必须显式从 length 开始:
highlightIndex.value = nextEnabledIndex(
highlightIndex.value < 0 ? displayOptions.value.length : highlightIndex.value,
-1,
);
实测:0.2.23 关闭状态按 ↑ 完全不打开;0.2.25 打开并高亮西安(7)。
修法四:关闭之后焦点去哪
从搜索框里按 Esc 或 Enter 关闭面板时,搜索框会随面板一起卸载。如果不处理,焦点会掉到 body,键盘用户丢失位置。
修法是关闭的同时把焦点还给触发器:
case "Escape":
event.preventDefault();
close();
focusTrigger();
break;
实测 0.2.25:Esc 后 打开=关、焦点=rn-select__trigger;Enter 选中后同样是 选中=gz、焦点=rn-select__trigger。
修法五:高亮必须滚进可视区
高亮只是加一个 class,浏览器不会自动滚动。列表有 10 项、面板最高 240px 时,按 End 会发生:
| 指标 | 0.2.23 | 0.2.25 |
|---|---|---|
| 高亮项 | 西安(9) | 西安(9) |
| scrollTop | 0 | 119(max 123) |
| 末项是否可见 | 否 | 是 |
修法是监听 highlightIndex,下一帧把对应选项 scrollIntoView({ block: "nearest" })。
但这里还有第二个坑,也是本文最值得记的一条:搜索框的 focus() 会把滚动容器拉回顶部。第一次修完,↑ 打开时 400ms 读到 scrollTop=81,800ms 再读变成 0——刚滚到的高亮又被顶出可视区。原因是 focus() 默认会滚动到被聚焦元素,而搜索框在面板顶部。
改成 focus({ preventScroll: true }),并在聚焦后补一次高亮滚动,scrollTop 才稳定在 81/85,高亮项始终可见。
为什么不是「聚焦之后再 scroll 一次」?因为 focus 的默认滚动就发生在聚焦那一刻,晚一次 scroll 只是把同一个坑再踩一遍;preventScroll 从源头关掉这次滚动,顺序才稳定。
我以为是 A,实测是 B
第一处:我以为 Select 的键盘早就补完了。ROADMAP 里 Dropdown、Cascader、TreeSelect、Tree 都打了勾,Select 排在同一个「选项类组件」条目下。实测:非搜索模式能用,搜索模式四个键全死——补的是另一个组件,Select 根本没进过名单。
第二处:我以为有键盘单测就等于覆盖了键盘。实测那条用例只对触发器触发事件,搜索框路径 0 覆盖。补上后才第一次红。
第三处:我以为 aria-selected 已经够了。实测 8 个选项没有一个 id,读屏软件拿到的是一个没有「当前项」的 listbox。
第四处:我以为加完 scrollIntoView 就结束了。实测滚动位置在 800ms 被 focus() 拉回 0;preventScroll 才是缺的那一半。
换成另一种实现会怎样
| 方案 | 能解决什么 | 问题 |
|---|---|---|
| 只挂触发器(原方案) | 非搜索模式可用 | 搜索框路径完全失效 |
| 挂 document | 所有按键都能收到 | 会穿透弹层;多层弹层时 Esc 乱关(0.2.21 刚修过) |
| 挂面板(现方案) | 范围=焦点范围,搜索框冒泡可达 | 面板必须真的包含焦点元素 |
| 焦点移到选项(roving) | 读屏能读到焦点项 | 搜索框失焦,无法继续输入 |
| 只加 aria-selected | 说明选中态 | 不表达「当前高亮项」 |
第三行是这次的关键判断:事件监听应该长在焦点所在的子树上。第四行说明为什么 ARIA 用 activedescendant 而不是移焦点——组合控件里,「输入焦点」和「导航焦点」必须分开表达。
边界:多选与远程搜索
- 多选:选中后
multiple分支不关闭面板,焦点继续留在搜索框,所以「选中后还原焦点」只对单选生效;这是有意为之,否则连续勾选会被打断。 - 远程搜索:结果替换
displayOptions后走同一套键盘处理;结果回来时高亮重置为 0,按 ↓ 从第二项开始,不会跳空。 - SSR:列表与选项 id 由
useId()生成,服务端与客户端一致;aria-activedescendant只在打开时输出,关闭后不留悬空引用。
怎么验证的
做了一个最小对照页:同一个 Select(8 个城市、filterable、clearable),分别加载 npm 上的 0.2.23 和本地构建的 0.2.25。状态行实时打印五个量:是否打开、高亮项与下标、选中值、当前焦点元素、aria-activedescendant 与选项 id 数。
每个场景都用真实鼠标点击触发器,再用真实键盘按 ↓/↑/Enter/Esc;先读状态、断言符合预期,再截图。两组构建跑同一套 20 条断言:0.2.23 组按「预期坏」断言,0.2.25 组按「应该好」断言,全部通过。
结果
两次发布:
| 版本 | 内容 |
|---|---|
| 0.2.24 | 键盘处理挂面板;aria-controls / aria-activedescendant;↑ 打开取末项;Esc/选中焦点还原;scrollIntoView;新增「可搜索」示例 |
| 0.2.25 | focus({ preventScroll: true }),避免搜索框聚焦把高亮滚出可视区 |
- 单测 917 → 923(+5 键盘/ARIA/滚动用例,+1 scrollIntoView)
- 真机断言 20/20 通过(before 组按「预期坏」断言,after 组按「应该好」断言)
- npm
ruanno-ui@0.2.25= 文档站 v0.2.25
结论
「键盘处理写在组件里」不等于「键盘能用」。三条规则:
- 事件监听的范围等于焦点范围:焦点被送进 Teleport 出去的面板,监听也要跟过去;
- combobox 用
aria-activedescendant表达高亮,不要为了读屏把焦点从输入框移走; - 高亮之后要滚动,滚动之后要防止
focus()把它拉回去——preventScroll是这条链路的最后一环。
第 3 条是最容易漏的:单测里 scrollIntoView 被调用了,看起来一切正常;只有在真实浏览器里隔 400ms 再读一次 scrollTop,才会看到它被聚焦动作悄悄改回 0。滚动问题很少是一次 scrollIntoView 能收尾的,最后一次聚焦动作往往才是元凶。
组件解剖 · 系列目录
- Table:虚拟滚动的三个硬约束
- Radio / RadioGroup:ARIA 与方向键选中
- Transfer:三态复选框的三个 bug
- DatePicker:42 格日期网格的四个边界
- Select(本篇):可搜索下拉的键盘、ARIA 与滚动