原生 popover 做帮助面板:自动关闭、顶层显示与状态事件
一个帮助面板只需要点击按钮打开、点击外部或按 Escape 关闭,为它引入整套模态弹窗逻辑通常太重。原生 popover 已经提供这类轻量显示行为,但它也没有顺便把面板变成菜单、对话框或权限边界。
先决定自己要的是哪种交互:背景仍然可以操作、面板能被轻松收起,就适合考察 popover="auto"。必须先完成当前任务才能回到页面,则更接近模态 dialog 的需求。不要只因为两者都浮在页面上,就用同一套组件承诺它们完全一样。
先用声明式按钮把面板跑起来
下面的页面没有手写 document 点击监听器,也没有自行判断点击坐标是否落在面板外。保存为 popover-demo.html,使用支持原生 Popover API 的现代浏览器打开。
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<title>原生 popover 帮助面板</title>
<style>
body { max-width: 42rem; margin: 2rem auto; padding: 1rem; font: 16px sans-serif; }
[popover] { max-width: 24rem; padding: 1rem; border: 1px solid #777; }
[popover]:popover-open { border-width: 2px; }
button { margin: .3rem; }
</style>
<button id="help-button" popovertarget="help">打开帮助</button>
<button id="other-button" popovertarget="other">打开另一面板</button>
<button id="outside" type="button">背景动作</button>
<p id="outside-result"></p>
<section id="help" popover="auto" aria-labelledby="help-title">
<h2 id="help-title">导出说明</h2>
<p>导出包含当前筛选结果,生成后可在任务列表中下载。</p>
<a href="#task-list">查看任务列表</a>
<button popovertarget="help" popovertargetaction="hide">关闭帮助</button>
</section>
<section id="other" popover="auto" aria-labelledby="other-title">
<h2 id="other-title">另一份说明</h2>
<p>这是与帮助面板平级的另一个自动 popover。</p>
</section>
<p id="task-list">任务列表位置</p>
<p id="status" role="status">帮助面板关闭</p>
<pre id="events"></pre>
<script>
const help = document.querySelector('#help');
const status = document.querySelector('#status');
const events = document.querySelector('#events');
if (typeof help.showPopover !== 'function') {
document.querySelector('#help-button').disabled = true;
document.querySelector('#other-button').disabled = true;
status.textContent = '此浏览器缺少原生 Popover API';
}
help.addEventListener('toggle', event => {
status.textContent = help.matches(':popover-open')
? '帮助面板打开' : '帮助面板关闭';
events.textContent += `${event.oldState} -> ${event.newState}\n`;
});
let count = 0;
document.querySelector('#outside').onclick = () => {
document.querySelector('#outside-result').textContent = `背景动作 ${++count} 次`;
};
</script>
</html>
popovertarget 引用目标元素的 id,默认动作是切换显示。关闭按钮明确指定 hide,因此重复触发也表达“关闭”,不依赖当前开关状态。按钮保持普通 button 语义,不需要手工同步一个与浏览器状态可能脱节的显示变量。
先打开帮助,再点背景动作:面板会因外部交互收起,背景动作也能执行。再打开帮助后按 Escape,应同样关闭。打开帮助以后切到另一平级自动面板,原面板应收起。这三条路径都不需要在业务脚本里复制一套全局点击处理。
自动关闭与模态限制完全是两回事
auto popover 在顶层呈现,可以越过普通页面的层叠关系,但不会让整个背景变成不可交互。背景动作仍然执行,正是例子预期的行为。如果产品要求背景完全不可操作,就不应该再给 auto popover 补一圈补丁假装它天生是模态窗口。
顶层呈现也不等于定位已经完成。例子接受浏览器默认的面板位置,生产组件若要紧贴触发按钮,需要另外设计定位与窄屏边界。不要因为面板没被 overflow 裁掉,就认为它自动知道每个触发器应该贴在哪一侧。
多个自动 popover 的关闭关系还会受嵌套关系影响。例子使用两个平级面板,便于观察互相切换;嵌套子菜单不能只拿这个平级结论推导。复杂菜单层级应根据实际DOM与焦点路径验证。
把属性改为 manual,可以得到不依赖同一套自动轻量关闭规则的面板。它适合由业务明确控制关闭的场景,但你也接过了更多退出责任。选择 manual 之前,应先写清楚用户从哪些入口能够离开,不能只为了“点击外部别关”就删掉所有自然出口。
toggle 事件报告状态,不是业务次数计数器
示例在 toggle 时重新读取 :popover-open,使用真实状态更新提示。不要只维护 opened = !opened,因为面板还可以被 Escape、其他自动面板或声明式按钮关闭,业务变量很容易漏掉这些入口。
短时间内连续改变状态,toggle 事件可能被合并。它适合通知“状态变成什么”,不适合当成严格的一次调用对应一次事件的审计流水。确实要记录用户执行了几次业务动作,应在动作发生的入口记录,而不是从显示事件数反推。
打开前的 beforetoggle 事件还有另一份合同:可以在允许的阶段阻止打开。若组件用它做延迟准备,应避免让打开状态与异步加载互相打架;不要在已经完成显示以后才猜自己能否取消刚才那次打开。
页面需要可访问名称时,仍要给内容提供合适标题与关联。Popover API 不会把任意一块div自动变成符合所有键盘约定的菜单。给内容写上 role="menu",也不会产生上下方向键移动、高亮项目和合理的关闭回焦。只是几段帮助文字和链接时,保留普通文档语义往往更准确。
同样,不要把完整表单放进去以后,就以为它会拥有dialog的模态和焦点行为。组件名字应该来自用户任务,而不是来自“它看上去浮起来了”这一个视觉特征。
脚本控制时,继续使用同一份状态
需要从代码打开时,可以调用 showPopover;需要关闭则调用 hidePopover。调用前应检查元素是否仍连接在文档、是否是有效的popover目标,以及当前状态是否允许该动作,避免生命周期中已经卸载的组件仍接受迟到回调。
组件被网络请求驱动时,还要确认这个请求仍属于当前触发动作。用户已经收起帮助,迟到结果不应该无条件把面板重新打开。请求完成与用户愿意继续看,是两份状态,不能共用一个布尔值偷懒。
打开帮助后按 Escape,再重新打开,使用内部关闭按钮关闭。重复几轮,提示应始终与 :popover-open 一致。只用键盘从触发器进入链接时,还应确认内容顺序和关闭出口都可达;鼠标能点开面板,只覆盖了其中一条路径。