Impact 的对话框基类:一个 DialogElement 派生出所有浮层
本篇基于 Impact 7.2.0,涉及 assets/theme.js 中的 DialogElement(第 1372 行)、Drawer(第 1581 行)、Popover(第 1669 行),以及 snippets/shadow-dom-templates.liquid。
继承关系
Impact 中所有“打开后覆盖页面”的组件都继承同一个基类:
DialogElement
├── Drawer
│ ├── CartDrawer、CartNotificationDrawer、QuickBuyDrawer
│ ├── SearchDrawer、FacetDrawer、NavigationDrawer
├── Popover
├── FacetDialog、CartNoteDialog
└── ……
基类负责所有浮层都必须正确处理的部分:状态、无障碍、焦点、滚动锁定、外部点击和 Theme Editor 联动。子类只需要覆盖几个 getter 和两个动画方法。
open 属性是唯一的状态来源
show() 和 hide() 只做一件事:增加或删除 open 属性。真正的处理全部放在 attributeChangedCallback 里(简化):
show(animate = true) {
if (this.open) return;
this.setAttribute("open", animate ? "" : "immediate");
return waitForEvent(this, "dialog:after-show");
}
attributeChangedCallback(name, oldValue, newValue) {
if (name !== "open") return;
this.controls.forEach((c) => c.setAttribute("aria-expanded", newValue === null ? "false" : "true"));
if (oldValue === null && newValue !== null) {
this.removeAttribute("inert");
// 必要时移到 body 末尾、播放进入动画、激活焦点陷阱、锁定滚动
} else if (oldValue !== null && newValue === null) {
this.setAttribute("inert", "");
// 播放退出动画、还原位置、释放焦点陷阱、解锁滚动
}
}
这样做有三个好处:
- 无论从哪里改变状态,结果都一致。 调用
show()、在 Liquid 中直接输出open、在开发者工具里手动加属性,都会走同一条路径。 - CSS 可以直接用
[open]写样式。 open="immediate"表示“打开但跳过动画”。 Theme Editor 重新加载 section 时用它,避免每次刷新都播放一遍动画。
show() 和 hide() 返回一个 Promise,在 dialog:after-show 或 dialog:after-hide 事件触发时完成。调用方可以 await drawer.hide(),再执行依赖关闭动画结束的操作。
自动关联触发按钮
基类在 document.body 上委托监听 [aria-controls="<自己的 id>"] 的点击。任何地方的按钮,只要写上 aria-controls="cart-drawer",就能打开或关闭购物车抽屉,并自动获得正确的 aria-expanded。按钮不需要任何 JS,Liquid 中也不需要额外的属性。
焦点与滚动
焦点陷阱使用第三方库 focus-trap,有几个细节:
- 只在指针精确的设备(
pointer: fine)上设置初始焦点。触屏设备上,自动聚焦到输入框会弹出软键盘,体验很差。 - 焦点陷阱的激活要等进入动画完成(
checkCanFocusTrap返回动画 Promise);退出时,焦点要等退出动画结束后才还给原来的按钮。 - 开启
getShadowRoot: true,让 Shadow DOM 里的关闭按钮也能被 Tab 键选中。
滚动锁定使用一个静态私有计数器:
static #lockLayerCount = 0;
// 打开时
DialogElement.#lockLayerCount += 1;
document.documentElement.classList.add("lock");
// 关闭时
document.documentElement.classList.toggle("lock", --DialogElement.#lockLayerCount > 0);
在抽屉里再打开一个弹层,然后关闭弹层时,页面不会提前解锁。元素在打开状态下被移出 DOM 时,disconnectedCallback 也会把计数减回去,避免页面永远处于锁定状态。
外部点击:区分滑动和点击
点击浮层外部时关闭,在鼠标上很容易实现,但在触屏上会误判:用户在遮罩上滑动一下,touchstart 就落在了外部。Impact 的触屏处理是在 touchstart 时不立即决定,而是等到 touchend,用手指最后的位置判断:
_allowOutsideClickTouch(event) {
event.target.addEventListener("touchend", (e) => {
const end = document.elementFromPoint(e.changedTouches[0].clientX, e.changedTouches[0].clientY);
if (!this.contains(end)) this.hide();
}, { once: true });
return false;
}
Theme Editor 联动
基类在 Shopify.designMode 下注册了几个事件:
shopify:block:select和shopify:block:deselect:商家在侧栏选中浮层里的 block 时自动打开,取消选中时关闭;- 带
handle-section-events属性时,对shopify:section:select和shopify:section:deselect做同样的处理; shopify:section:unload:section 被删除或重新加载时,浮层把自己从 DOM 中移除。浮层打开时可能已被移到body末尾,不在原 section 内,不手动移除就会残留在页面上。
所有监听都用 AbortController 注册,disconnectedCallback 中一次性取消。
用 Shadow DOM 模板派生 Drawer 和 Popover
Drawer 的结构不是写在 JS 里,而是来自 layout/theme.liquid 渲染的 snippets/shadow-dom-templates.liquid:
<template id="drawer-default-template">
<button part="outside-close-button" is="close-button" aria-label="{{ 'general.accessibility.close' | t | escape }}">…</button>
<div part="overlay"></div>
<div part="content">
<header part="header"><slot name="header"></slot><button part="close-button" is="close-button" …>…</button></header>
<div part="body"><slot></slot></div>
<footer part="footer"><slot name="footer"></slot></footer>
</div>
</template>
这样安排有几个好处:
- 模板在 Liquid 里,关闭按钮的
aria-label可以走主题的翻译文件。 - 对外暴露
part,主题 CSS 用::part(content)、::part(close-button)等写样式(theme.css中有 56 处::part(),不需要穿透 Shadow DOM。 - 可以按实例换模板: 元素上写
template="..."即可使用另一份模板。 - 没有内容的区域会自动隐藏: 监听
slotchange,某个 slot 没有分配到元素时,把它的父容器设为hidden。没有底部内容的抽屉就不会留出一块空白。
Drawer 覆盖的 getter:
shouldLock为true;shouldAppendToBody为true,打开时移到body末尾,避开父级的overflow和z-index,关闭后放回原处;openFrom在小屏幕上强制为bottom,变成底部弹出面板。
Popover 在宽度不超过 999px 时同样锁定滚动、移到 body 并从底部弹出;在大屏上则相对触发按钮定位,不锁定滚动。
两者的动画都用 clip-path: inset(...) 实现展开,并根据 document.dir 处理从左到右和从右到左两种排版方向。设置了减少动态效果(prefers-reduced-motion: reduce),或在主题设置中开启 reduceDrawerAnimation 时,抽屉动画退化为淡入淡出。
值得借鉴的点
- 以属性作为唯一状态,所有副作用集中在
attributeChangedCallback。 - 无障碍的细节放在基类里:
aria-expanded、inert、焦点陷阱、焦点归还、触屏不自动聚焦。子类不会遗漏。 - 滚动锁定用计数,而不是布尔值,支持多层浮层叠加。
- 把 Theme Editor 联动当作基类职责,而不是每个 section 各写一遍。
局限
- 动画关键帧有笔误。 第 1611 行抽屉从右侧打开时的结束值、第 1711 行弹层在移动端的结束值,
inset(...)都少了右括号。CSS 解析在值末尾会自动补全未闭合的函数,所以没有出现可见问题。第 1644 行减少动态效果时的关闭动画写的是visibility: ["visibility", "hidden"],"visibility"不是合法的 visibility 值。 - 移动节点会重跑生命周期。
shouldAppendToBody用document.body.append(this)移动元素,浏览器会依次调用disconnectedCallback和connectedCallback,关闭时放回原处又会再来一次。基类的监听都能安全地重新注册,但子类的connectedCallback也会跟着多执行几次。新增子类时,connectedCallback必须能重复执行而不产生副作用,比如不要在其中重复创建请求或累加监听。