modal.js 用法
view/vendor/modal.js 包含两套弹窗组件的全局函数:Modal 和 Offcanvas。
Modal(模态框)
基于 Pico.css 的模态框实现,使用 <dialog> 风格的 <article> 元素。
打开模态框:
openModal(modalElement)
- 参数:DOM 元素(不是 ID)
- 效果:添加
modal-is-open 和 modal-is-opening class,设置焦点陷阱,400ms 动画后聚焦第一个输入框
关闭模态框:
closeModal(modalElement)
- 参数:DOM 元素(不是 ID)
- 效果:添加
modal-is-closing class,400ms 动画后移除所有状态 class,恢复焦点到触发元素
切换模态框:
<button data-target="modal-id" onclick="toggleModal(event)">打开</button>
自动行为:
- 点击模态框外部(
<article> 以外)自动关闭
- 按 Esc 键自动关闭
- Tab 键焦点陷阱(循环聚焦模态框内的可交互元素)
Offcanvas(底部滑出面板)
自定义实现,不依赖 Bootstrap JS。用于回帖框等需要从底部滑出的面板。
打开面板:
showOffcanvas(id)
- 参数:元素 ID 字符串,如
'S5_post_reply_modal'
- 效果:添加
aether-offcanvas-show class,300ms 后聚焦第一个可交互元素
关闭面板:
hideOffcanvas(id)
- 参数:元素 ID 字符串
- 效果:移除
aether-offcanvas-show class,恢复焦点到触发元素
HTML 结构:
<aside class="aether-offcanvas aether-offcanvas-bottom" id="your_id">
<div class="aether-offcanvas-backdrop" onclick="hideOffcanvas('your_id')"></div>
<div class="aether-offcanvas-header">...</div>
<div class="aether-offcanvas-body">...</div>
</aside>
触发方式:
<button onclick="showOffcanvas('your_id')">打开</button>
<button onclick="hideOffcanvas('your_id')">关闭</button>
关闭方式(只有这三种,不会意外关闭):
- 点击关闭按钮 →
onclick="hideOffcanvas(...)"
- 点击遮罩层 →
onclick="hideOffcanvas(...)"
- 代码调用 →
hideOffcanvas('S5_post_reply_modal')
CSS 类名:
.aether-offcanvas — 基础样式(fixed 定位、transform 隐藏)
.aether-offcanvas-show — 显示状态(transform 归零)
.aether-offcanvas-backdrop — 遮罩层(仅 show 时可见)
body.aether-offcanvas-open — 阻止背景滚动
关键设计:不挂任何 resize / viewport 侦听器。 这不是偷懒,这是刻意为之——见下文。
回帖框 Bug 修复血泪史
问题描述
手机端,帖子详情页,点击回帖框后,输入法弹出,回帖框会自动关闭。用户无法输入任何内容。
就这么一个看似简单的问题,从 A8 版本开始,一直修到 B5 版本,跨越了将近四个月。
第一次修复(A8)
A8 版本的更新记录写着"再次修复了帖子详情回帖框点击后会收起的问题"。
怎么修的呢?我已经记不太清了,大概是给 Bootstrap Offcanvas 加了一些事件拦截之类的。反正修了,但没完全修好。
第二次修复(A9)
A9 版本的更新记录写着"再次修复了帖子详情回帖框点击后会收起的问题,但这次是点击回帖框外面的地方完全不会收起"。
注意这个措辞——"但这次是点击回帖框外面的地方完全不会收起"。这其实是一种妥协:既然无法区分"输入法导致的关闭"和"用户点击外部导致的关闭",那就干脆把"点击外部关闭"也禁用了。
这当然不是理想状态,但至少用户能打字了。
第三次修复(B3)
B3 版本的更新记录写着"修复:再次修复了帖子详情回帖框点击后会收起的问题,这次是专为TinyMCE编辑器设计的修复"。
这次我写了一个完整的"Offcanvas 抑制功能"(initOffcanvasSuppression),大概 100 行代码,做了这些事:
- 监听
resize 事件,检测虚拟键盘弹出/收起(通过判断高度变化是否超过 150px)
- 监听
hide.bs.offcanvas 事件,如果检测到键盘刚刚弹出(300ms 内),就 event.preventDefault() 阻止关闭
- 监听关闭按钮和背景点击,设置
allowHide 标志来区分"用户主动关闭"和"意外关闭"
听起来很完美对吧?但实际测试发现:完全没生效。
为什么?因为 Bootstrap Offcanvas 内部的关闭逻辑不是通过 hide.bs.offcanvas 事件触发的——或者说,当 viewport 变化时,Bootstrap 的关闭走的是另一条代码路径,根本不经过可以被 preventDefault 拦截的事件。
就像你精心在门上装了一个门闩,但小偷是从窗户进来的。
第四次修复(B5)—— 彻底重写
在 B3 的修复被证实无效后,我意识到一个残酷的事实:
Bootstrap Offcanvas 的设计哲学与移动端输入法场景根本不兼容。
Bootstrap Offcanvas 的核心假设是:当 viewport 大小变化时,offcanvas 应该重新评估自己的状态。这在桌面端是合理的(窗口缩放时调整布局),但在移动端,输入法弹出导致的 viewport 变化完全不是用户意图,Bootstrap 无法区分这两者。
而且,如果你激进地解决这个问题(比如禁用所有 viewport 相关的监听),所有合理的关闭方式也会失效——因为 Bootstrap 内部的关闭机制和 viewport 监听是耦合在一起的。
所以,唯一的出路是:另起炉灶。
方案设计
参考了项目中已有的 modal.js(基于 Pico.css 的模态框实现),它的设计哲学非常简洁:
- 用 class 控制显示/隐藏
- 用全局函数控制开关
- 不挂任何自动侦听器(除了 Esc 键和点击外部)
我把同样的哲学应用到 Offcanvas 上:
- 隐藏状态:
transform: translateY(100%)(面板在视口下方)
- 显示状态:添加
.aether-offcanvas-show → transform: translateY(0)
- 不挂任何 resize / viewport 侦听器
- 只通过
onclick 显式控制
就这么简单。没有 100 行的"抑制功能",没有 event.preventDefault(),没有 allowHide 标志,没有 300ms 的时间窗口判断。
两个函数,一个 class,完事。
function showOffcanvas(id) {
var el = document.getElementById(id);
if (!el) return;
el.classList.add('aether-offcanvas-show');
document.body.classList.add('aether-offcanvas-open');
}
function hideOffcanvas(id) {
var el = document.getElementById(id);
if (!el) return;
el.classList.remove('aether-offcanvas-show');
document.body.classList.remove('aether-offcanvas-open');
}
输入法弹出?viewport 变化?关我什么事,我又没在监听。
教训
-
当你发现自己在给一个框架打越来越多的补丁时,应该考虑换框架(或自己写)。从 A8 到 B3,我一直在 Bootstrap Offcanvas 的框架内修修补补,每次都觉得"这次应该能行了",每次都不行。直到 B5 彻底抛弃它,问题才真正解决。
-
简单性是可靠性前提。自定义 Offcanvas 的代码量不到 Bootstrap Offcanvas 的 1/10,但它不会出这个 bug,因为它根本就没有出 bug 的机会——它不监听 viewport 变化,所以 viewport 变化不会触发任何行为。
-
"不做什么"比"做什么"更重要。这个 bug 的根因不是缺少了什么功能,而是多了一个不该有的行为(viewport 变化时自动关闭)。解决方案不是添加更多的逻辑来"抑制"这个行为,而是从一开始就不让这个行为存在。
写于 2026年5月12日。从A8到B5,四个月,四次修复,终于画上了句号。