可搜索的下拉框,键盘按不动:焦点进了下拉,事件还挂在触发器上

1 阅读9分钟

打开一个可搜索的下拉框,输入「杭」,列表只剩杭州。按 ↓——没有高亮;按 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.230.2.25
高亮项西安(9)西安(9)
scrollTop0119(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.25focus({ preventScroll: true }),避免搜索框聚焦把高亮滚出可视区
  • 单测 917 → 923(+5 键盘/ARIA/滚动用例,+1 scrollIntoView)
  • 真机断言 20/20 通过(before 组按「预期坏」断言,after 组按「应该好」断言)
  • npm ruanno-ui@0.2.25 = 文档站 v0.2.25

0.2.23:打开后焦点在搜索框,高亮北京,activedescendant=无、选项 id 0/8

0.2.23 按两次 ↓:高亮还停在北京

0.2.23 按 Enter:没选中,面板还开着

0.2.25:同样的操作,activedescendant 指向 option-0、选项 id 8/8

0.2.25 按两次 ↓:高亮走到广州(2),activedescendant 同步

0.2.25 按 Enter:选中广州、面板关闭、焦点回到触发器

0.2.23:输入「杭」过滤出 1 项,但没有高亮;再按 ↓/Enter 也没反应

0.2.25:过滤后按 ↓ 高亮杭州,Enter 选中并关闭

0.2.23 按 Esc:面板关不掉,焦点还在搜索框

0.2.25 按 Esc:面板关闭,焦点回到触发器

0.2.23 关闭状态按 ↑:没有反应

0.2.25 关闭状态按 ↑:打开并高亮最后一项西安(7),列表滚到底

0.2.23 长列表按 End:高亮跳到西安,但列表停在顶部,看不见

0.2.25 按 End:列表滚到底部(scrollTop 119/123),西安可见

线上示例(0.2.25):可搜索下拉打开,选项 8 个

输入「杭」过滤到 1 项,高亮由 activedescendant 表达

Enter 后选中杭州,当前值 hz,焦点回到触发器

结论

「键盘处理写在组件里」不等于「键盘能用」。三条规则:

  1. 事件监听的范围等于焦点范围:焦点被送进 Teleport 出去的面板,监听也要跟过去;
  2. combobox 用 aria-activedescendant 表达高亮,不要为了读屏把焦点从输入框移走;
  3. 高亮之后要滚动,滚动之后要防止 focus() 把它拉回去——preventScroll 是这条链路的最后一环。

第 3 条是最容易漏的:单测里 scrollIntoView 被调用了,看起来一切正常;只有在真实浏览器里隔 400ms 再读一次 scrollTop,才会看到它被聚焦动作悄悄改回 0。滚动问题很少是一次 scrollIntoView 能收尾的,最后一次聚焦动作往往才是元凶。


组件解剖 · 系列目录

  • Table:虚拟滚动的三个硬约束
  • Radio / RadioGroup:ARIA 与方向键选中
  • Transfer:三态复选框的三个 bug
  • DatePicker:42 格日期网格的四个边界
  • Select(本篇):可搜索下拉的键盘、ARIA 与滚动

在线示例:ui.ruanno.top/components/… 源码:gitee.com/ruanno-top/…