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

键盘打开下拉菜单,选「分享」------菜单关了,焦点掉到了 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。

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

  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/...

相关推荐
tryCbest3 小时前
Vue3——第 17 篇:模板引用(ref)与组件实例——操作 DOM 的正确姿势
vue.js·前端搭建
头发还在的女程序员3 小时前
Java在线租房系统源码解析:Spring Boot+Vue 3+UniApp房源管理与预约看房方案
java·vue.js·spring boot·租房小程序·租房系统·租房软件
FYKJ_20104 小时前
springboot同人周边订单管理系统48955-计算机毕业设计、课程设计
java·javascript·vue.js·spring boot·django·php·课程设计
梯度下降者13 小时前
Nuxt框架
vue.js·服务端渲染·全栈开发·混合渲染·nuxt框架
yuyuxun121 小时前
协同过滤的校园二手物品智能推荐系统的设计与实现 毕业设计-附源码40082
javascript·vue.js·spring boot·python·数据分析·django·课程设计
FungLeo1 天前
成为全栈·Vue 管理后台篇·编辑器图片上传:从文件选择到文章附件关联
前端·vue.js·编辑器·markdown·文件上传·formdata·附件管理
码云社区1 天前
智慧物业系统选型指南:房屋档案、账单管理与报修协作
前端·javascript·vue.js·智慧物业
ruanno_top2 天前
表单重置后,验证码还显示已通过:Vue 受控组件的 3 种假同步
vue.js
ruanno_top2 天前
给标签页加 roving tabindex,整组反而键盘进不去了
vue.js