选中一项后,焦点掉回了页面顶部:三个弹层的焦点还原

0 阅读9分钟

键盘打开下拉菜单,选「分享」——菜单关了,焦点掉到了 body。再按一次 Tab,焦点从页面最顶部的链接重新开始,刚选到哪一行全忘了。

日期选择、时间选择也一样:选完一项,面板消失,焦点跟着消失。这不是三个组件的三个 bug,是同一个原因。

三个组件,同一个现象

真机实测(同一份组件,0.2.31 / 0.2.32,实测结果如下):

操作0.2.31 焦点0.2.32 焦点
下拉:↓ + Enter 选中「分享」bodyddTrigger
日期:点选 20 号bodyrn-date-picker__trigger
时间:点选 09:30bodyrn-time-select__trigger
日期:面板内按 Escbodyrn-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,不会自动找「打开面板的那个按钮」。所以还原必须由组件自己记录触发器。

源码:三条关闭路径,都没还焦点

下拉的选中回调:

function onSelect(item: DropdownItem) {
  if (item.disabled) return;
  closeNow();          // 菜单 v-show=false,焦点元素被隐藏
  emit("select", item);
}

日期选择的关闭:

function close() {
  open.value = false;  // 面板 v-if=false,日按钮被移除
  resetPicking();
}

时间选择的选中:

function onPick(item: TimeOption) {
  if (item.disabled) return;
  emit("update:modelValue", item.value);
  emit("change", item.value);
  close();             // 选项按钮被移除
}

三条路径都只关心「面板关掉、值发出去」,没有一条把焦点送回去。

焦点丢失的代价不只是「看不见光标」:Tab 会从页面最顶部的链接重新开始,屏幕阅读器会重新播报整页导航,表单里刚填到一半的上下文全断。对键盘用户来说,这相当于每选一次就重开一次页面。

修法:关闭时把焦点还给触发器

下拉最简单——菜单项选中即关闭,直接还给触发器:

function onSelect(item: DropdownItem) {
  if (item.disabled) return;
  closeNow();
  slotTrigger()?.focus();   // 触发器里的 button / a / tabindex=0
  emit("select", item);
}

日期与时间需要区分「为什么关」:

function close(restoreFocus = false) {
  open.value = false;
  resetPicking();
  if (restoreFocus) nextTick(() => triggerRef.value?.focus());
}

为什么用 nextTick:open=false 之后面板才开始卸载,触发器在另一个子树里,等一次渲染再聚焦更稳。触发器本身有 tabindex="0",可以直接 focus()。

更稳妥的还原函数会检查节点还在不在文档里:

function restoreFocus() {
  nextTick(() => {
    const el = triggerRef.value;
    if (el?.isConnected) el.focus({ preventScroll: true });
  });
}

isConnected 防止组件在关闭的同时被卸载(比如弹层里点了「删除并关闭」),preventScroll 防止聚焦把页面滚一下。

哪些路径传 true:

  • 选中日期 / 月份 / 年份、快捷项、「今天」、datetime 的「确定」;
  • Esc 关闭(包括 useEscapeStack 的回调:useEscapeStack(open, () => close(true)));
  • 不清空、不选中、只是点面板外——不传。

时间选择同理:

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,所以可以直接断言:

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 上派发事件,断言面板消失与焦点还原:

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-activedescendantDOM 焦点始终在输入框/触发器可搜索下拉、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,焦点从触发器继续往后走,而不是回到页首。

0.2.31:三个弹层初始状态,焦点在 body

下拉选中「分享」:值对了,焦点掉到 body

日期选中 20 号:值对了,焦点掉到 body

时间选中 09:30:值对了,焦点掉到 body

日期面板内按 Esc:面板关闭,焦点掉到 body

0.2.32:同样的初始状态

下拉选中「分享」:焦点回到「更多操作」触发器

日期选中 20 号:焦点回到日期触发器

时间选中 09:30:焦点回到时间触发器

日期面板内按 Esc:焦点回到日期触发器

结论

弹层关闭时,焦点不会自动回到打开它的地方。持有焦点的元素被隐藏或移除,浏览器只会把焦点丢给 body。

三条规则,按重要性排序:

  1. 选中与 Esc 关闭时,把焦点还给触发器;outside click 和 Tab 不要抢;
  2. 还原放在真正关闭的路径上,必要时 nextTick 等一次渲染;
  3. 能保持输入焦点时优先用 aria-activedescendant,没有输入焦点再用还原。

第 1 条里的「不要抢」和「要还」同样重要:无条件 focus() 会让鼠标用户点了别处又被拉回来。焦点还原不是「关了就还」,而是「按关闭方式决定还不还」。

它也不是给键盘用户的「礼貌」:焦点是键盘和读屏唯一的坐标。坐标丢了,用户就得从头再走一遍。


组件解剖 · 系列目录

  • Table:虚拟滚动的三个硬约束
  • Transfer:三态复选框的三个 bug
  • Select:可搜索下拉的键盘、ARIA 与滚动
  • Form / FormItem:异步校验的竞态与序号
  • 弹层(本篇):选中之后,焦点回到哪

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