键盘打开下拉菜单,选「分享」------菜单关了,焦点掉到了
body。再按一次 Tab,焦点从页面最顶部的链接重新开始,刚选到哪一行全忘了。日期选择、时间选择也一样:选完一项,面板消失,焦点跟着消失。这不是三个组件的三个 bug,是同一个原因。
三个组件,同一个现象
真机实测(同一份组件,0.2.31 / 0.2.32,实测结果如下):
| 操作 | 0.2.31 焦点 | 0.2.32 焦点 |
|---|---|---|
| 下拉:↓ + Enter 选中「分享」 | body | ddTrigger |
| 日期:点选 20 号 | body | rn-date-picker__trigger |
| 时间:点选 09:30 | body | rn-time-select__trigger |
| 日期:面板内按 Esc | body | rn-date-picker__trigger |
选中值都正确:分享、2026-11-20、09:30。错的只是焦点。
为什么焦点会掉到 body
这三个弹层的选项都是真实的焦点元素:
- 下拉菜单项是
tabindex="-1"的span,打开时程序化聚焦第一项。-1意味着它只能被脚本聚焦,不会进入 Tab 序列------这是菜单的标准做法,也意味着焦点完全由组件管理; - 日期格子是
<button>; - 时间选项是
<button>。
关闭时,下拉用 v-show 把菜单 display: none,日期/时间用 v-if 把面板从 DOM 移除。当持有焦点的元素被隐藏或移除,浏览器会把焦点移到 body------不是移到「逻辑上的父元素」,也不是移到「上一个焦点」。
实测这个切换有时不是瞬间的:日期面板按 Esc 后,500ms 时焦点还在即将消失的日按钮上,750ms 后才落到 body。面板的离场动画期间,元素还在 DOM 里,焦点也还在;动画结束、节点移除,焦点才丢。
焦点不是浏览器维护的一个「历史栈」。它只记录「当前哪个元素是 activeElement」,没有「上一个焦点是谁」。元素没了,activeElement 就回落到 body,不会自动找「打开面板的那个按钮」。所以还原必须由组件自己记录触发器。
源码:三条关闭路径,都没还焦点
下拉的选中回调:
ts
function onSelect(item: DropdownItem) {
if (item.disabled) return;
closeNow(); // 菜单 v-show=false,焦点元素被隐藏
emit("select", item);
}
日期选择的关闭:
ts
function close() {
open.value = false; // 面板 v-if=false,日按钮被移除
resetPicking();
}
时间选择的选中:
ts
function onPick(item: TimeOption) {
if (item.disabled) return;
emit("update:modelValue", item.value);
emit("change", item.value);
close(); // 选项按钮被移除
}
三条路径都只关心「面板关掉、值发出去」,没有一条把焦点送回去。
焦点丢失的代价不只是「看不见光标」:Tab 会从页面最顶部的链接重新开始,屏幕阅读器会重新播报整页导航,表单里刚填到一半的上下文全断。对键盘用户来说,这相当于每选一次就重开一次页面。
修法:关闭时把焦点还给触发器
下拉最简单------菜单项选中即关闭,直接还给触发器:
ts
function onSelect(item: DropdownItem) {
if (item.disabled) return;
closeNow();
slotTrigger()?.focus(); // 触发器里的 button / a / tabindex=0
emit("select", item);
}
日期与时间需要区分「为什么关」:
ts
function close(restoreFocus = false) {
open.value = false;
resetPicking();
if (restoreFocus) nextTick(() => triggerRef.value?.focus());
}
为什么用 nextTick:open=false 之后面板才开始卸载,触发器在另一个子树里,等一次渲染再聚焦更稳。触发器本身有 tabindex="0",可以直接 focus()。
更稳妥的还原函数会检查节点还在不在文档里:
ts
function restoreFocus() {
nextTick(() => {
const el = triggerRef.value;
if (el?.isConnected) el.focus({ preventScroll: true });
});
}
isConnected 防止组件在关闭的同时被卸载(比如弹层里点了「删除并关闭」),preventScroll 防止聚焦把页面滚一下。
哪些路径传 true:
- 选中日期 / 月份 / 年份、快捷项、「今天」、datetime 的「确定」;
- Esc 关闭(包括
useEscapeStack的回调:useEscapeStack(open, () => close(true))); - 不清空、不选中、只是点面板外------不传。
时间选择同理:
ts
function onPick(item: TimeOption) {
if (item.disabled) return;
emit("update:modelValue", item.value);
emit("change", item.value);
close();
nextTick(() => triggerRef.value?.focus());
}
为什么不在所有关闭路径都还原
三种关闭方式,焦点归属不一样:
| 关闭方式 | 焦点应该去哪 | 原因 |
|---|---|---|
| 选中一项 | 触发器 | 操作完成,用户还要继续用这个控件 |
| Esc | 触发器 | 取消操作,回到打开它的地方 |
| 点击面板外 | 用户点的那个元素 | 用户已经用鼠标选了新目标,抢回焦点反而打断 |
| Tab 离开 | 下一个可聚焦元素 | 键盘用户在往前走,不能拉回来 |
所以「还原」只挂在选中 和Esc 两条路径上;outside click 和 Tab 不碰焦点。这也是为什么不能简单地在 close() 里无条件 focus()。
三个组件没有抽一个公共 composable,是因为「触发器」的形态不同:下拉是插槽里的任意按钮,日期/时间是组件自己的 div。公共部分只有「nextTick + isConnected + focus」三行,抽出来反而要多传一个 ref;等第四个、第五个组件出现同样需求时再抽更合适。
测试怎么写
jsdom 不会自动把焦点移到 body,但移除节点后 activeElement 会变成 body,所以可以直接断言:
ts
it("选中菜单项后焦点回到触发器", async () => {
const wrapper = mount(Dropdown, {
props: { items },
slots: { default: '<button id="dd-trigger">更多</button>' },
attachTo: document.body,
});
const trigger = wrapper.find("#dd-trigger");
(trigger.element as HTMLElement).focus();
await wrapper.trigger("keydown", { key: "ArrowDown" });
await nextTick();
const first = wrapper.findAll(".rn-dropdown__item")[0];
(first.element as HTMLElement).focus();
await first.trigger("keydown", { key: "Enter" });
await nextTick();
expect(document.activeElement).toBe(trigger.element);
});
日期面板的 Esc 用例要先聚焦一个日按钮,再在 document 上派发 Escape(Esc 栈监听在 document 上),断言面板消失且焦点回到触发器。
这类用例必须 attachTo: document.body;不挂载到文档上,document.activeElement 永远是 body,断言没有意义。
Esc 路径在 document 上派发事件,断言面板消失与焦点还原:
ts
it("面板内按 Esc 关闭后焦点回到触发器", async () => {
const wrapper = mount(DatePicker, {
props: { modelValue: "2026-11-15" },
attachTo: document.body,
});
const trigger = wrapper.find(".rn-date-picker__trigger");
await trigger.trigger("click");
const day = wrapper.findAll(".rn-date-picker__day")[10];
(day.element as HTMLElement).focus();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
await nextTick();
expect(wrapper.find(".rn-date-picker__panel").exists()).toBe(false);
expect(document.activeElement).toBe(trigger.element);
});
焦点类用例至少覆盖四种:键盘选中、鼠标点选、Esc 取消、点击外部(后者断言焦点不被抢回)。
我以为是 A,实测是 B
第一处 :我以为 Esc 路径早就修过了。上一轮确实给 Dropdown 的 Esc 加了 slotTrigger().focus(),但 DatePicker 的 Esc 只调 close()------同一个交互,两个组件两种行为。实测日期面板里按 Esc,焦点直接掉 body。
第二处 :我以为面板一关焦点就立刻丢。实测 500ms 时焦点还在日按钮上,750ms 才到 body:离场动画期间节点还在,焦点也还在。「焦点丢失」发生的时间点比「面板关闭」晚,所以截图/断言要等节点真正移除。
第三处:我以为鼠标用户不受影响。实测点击日按钮会先让按钮获得焦点,面板移除后同样掉 body;鼠标用户接着按 Tab,也会从页面顶部重新开始。这是键盘和鼠标共有的问题,只是键盘用户更依赖它。鼠标用户可以用眼睛重新找到位置,键盘用户只能靠 Tab 数数。
第四处 :我以为可以直接复用 useFocusTrap。它是给模态弹层用的:进入时抢焦点、Tab 循环、卸载时还原。下拉/日期/时间是非模态的,焦点应该能自由移到页面其它地方;套上焦点陷阱会把用户困在面板里。
换成另一种实现
| 方案 | 焦点行为 | 适用 |
|---|---|---|
| 什么都不做(原方案) | 元素移除后掉 body | 鼠标为主、无所谓键盘 |
| 关闭时还原(现方案) | 选中/Esc 回触发器 | 非模态弹层、combobox |
useFocusTrap |
进入抢焦点、Tab 循环、卸载还原 | Dialog / Drawer / MessageBox |
aria-activedescendant |
DOM 焦点始终在输入框/触发器 | 可搜索下拉、combobox |
| 选项保持挂载、只隐藏 | 焦点留在隐藏元素上 | 不可取:隐藏元素仍可聚焦,Tab 会掉进黑洞 |
第四行值得展开:可搜索下拉(Select)的搜索框一直持有焦点,选项高亮用 aria-activedescendant 表达,关闭时只需把焦点从搜索框还给触发器------不需要「选项按钮」这一层。我们上一轮已经这么改了。能不能用 activedescendant,取决于控件里有没有一个始终存在的输入焦点;日期格子没有,所以只能还原。
边界
- 禁用项:跳过,不还原(本来就没有聚焦)。
- 面板里还有输入框(如 datetime 的时间列):Esc 还原到触发器,不要把焦点留在已卸载的时间按钮上。
- 异步关闭(确认弹窗后才关):还原要放在真正关闭的回调里,不能放在「点了确定」的同步路径。
- 多个触发器共用一个面板:还原目标要按「谁打开的」记录,不能写死第一个;日期范围选择的两个输入框就是这种情况。
- 关闭即卸载 :弹层里点「删除并关闭」时,触发器可能已经不在文档里,
isConnected检查会跳过这次聚焦。 - 面板在离场动画中 :
nextTick后触发器已可聚焦,即使面板节点还在,抢回焦点也是安全的;动画结束不会再把焦点带走。 - 面板内部滚动位置:还原只处理焦点,不处理滚动;日期面板翻到 2027 年再关掉,下次打开是否回到当前月,是另一个状态问题。
结果
0.2.32 发布:
- Dropdown 选中菜单项后焦点回触发器;
- DatePicker 选择日期/月份/年份、快捷项、今天、datetime 确认、Esc 后焦点回触发器;
- TimeSelect 选中时间、Esc 后焦点回触发器;
- 单测 939 → 943,新增 4 条焦点用例;
- 真机 10/10 (共 10 张修复前后对照图):0.2.31 三种选中后
focus=body,0.2.32 分别回到ddTrigger、日期触发器、时间触发器; - 文档三个组件的示例都能直接试:选完一项后按 Tab,焦点从触发器继续往后走,而不是回到页首。










结论
弹层关闭时,焦点不会自动回到打开它的地方 。持有焦点的元素被隐藏或移除,浏览器只会把焦点丢给 body。
三条规则,按重要性排序:
- 选中与 Esc 关闭时,把焦点还给触发器;outside click 和 Tab 不要抢;
- 还原放在真正关闭的路径上,必要时
nextTick等一次渲染; - 能保持输入焦点时优先用
aria-activedescendant,没有输入焦点再用还原。
第 1 条里的「不要抢」和「要还」同样重要:无条件 focus() 会让鼠标用户点了别处又被拉回来。焦点还原不是「关了就还」,而是「按关闭方式决定还不还」。
它也不是给键盘用户的「礼貌」:焦点是键盘和读屏唯一的坐标。坐标丢了,用户就得从头再走一遍。
组件解剖 · 系列目录
- Table:虚拟滚动的三个硬约束
- Transfer:三态复选框的三个 bug
- Select:可搜索下拉的键盘、ARIA 与滚动
- Form / FormItem:异步校验的竞态与序号
- 弹层(本篇):选中之后,焦点回到哪
在线示例:ui.ruanno.top/components/... 源码:gitee.com/ruanno-top/...