EN
Shopify 知识库 · 概念

Impact 的对话框基类:一个 DialogElement 派生出所有浮层

Impact 7.2.0 以 open 属性作为唯一状态来源,在 DialogElement 中统一处理 aria-expanded、inert、焦点陷阱、多层滚动锁定、触屏外部点击与 Theme Editor 事件,再由 Shadow DOM 模板派生抽屉和弹层。
历史资料
请结合文中的适用版本和来源阅读。

本篇基于 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 时,抽屉动画退化为淡入淡出。

值得借鉴的点

  1. 以属性作为唯一状态,所有副作用集中在 attributeChangedCallback。
  2. 无障碍的细节放在基类里:aria-expanded、inert、焦点陷阱、焦点归还、触屏不自动聚焦。子类不会遗漏。
  3. 滚动锁定用计数,而不是布尔值,支持多层浮层叠加。
  4. 把 Theme Editor 联动当作基类职责,而不是每个 section 各写一遍。

局限

  • 动画关键帧有笔误。 第 1611 行抽屉从右侧打开时的结束值、第 1711 行弹层在移动端的结束值,inset(...) 都少了右括号。CSS 解析在值末尾会自动补全未闭合的函数,所以没有出现可见问题。第 1644 行减少动态效果时的关闭动画写的是 visibility: ["visibility", "hidden"],"visibility" 不是合法的 visibility 值。
  • 移动节点会重跑生命周期。 shouldAppendToBody 用 document.body.append(this) 移动元素,浏览器会依次调用 disconnectedCallback 和 connectedCallback,关闭时放回原处又会再来一次。基类的监听都能安全地重新注册,但子类的 connectedCallback 也会跟着多执行几次。新增子类时,connectedCallback 必须能重复执行而不产生副作用,比如不要在其中重复创建请求或累加监听。