问题提出
需要说明的是,题目中所说的 Modal,指的是所有由前端开发者自定义的对话框,如通常用到的 Alert、Prompt、Confirm 等等,经常伴随着一个半透明的灰黑色全局 mask。
事情源自某天使用某网站的页面,出现一个自定义的 Comfirm,习惯性按下回车确认,等了很久也不见弹出层关闭。于是很绝望。继而发现,真不是这一个网站的问题。
看看一般浏览器原生的 alert、prompt、confirm 可以发现,它们基本上都提供默认操作项(eg.确认/取消),通过回车键可以直接完成。传统软件如 PhotoShop 也是如此,否则真不知那些无需鼠标的 PS 大神是怎样练出来的。然而不少自定义的插件,却并未提供这样的功能,往往不得不将手挪到鼠标或触摸板上操作,极其不便。
主要存在两个方面的问题,一是根本无法直接通过键盘操作对话框;二是虽然可以操作,但视觉提示并不明显;三是整个应用对焦点的控制不够,容易导致成本可能极高误操作。下面先简单谈谈这几个问题,然后结合实际例子说明。
键盘、鼠标切换成本
先谈第一个问题。键盘、鼠标之间的切换成本应该是很高的,尤其是当应用响应与用户心理预期不符时,造成的焦虑感或挫败感很影响用户体验。
因此在使用弹出 Modal 时,需要根据业务场景,设置一个默认选项(尤其是确认、取消、提交等操作),用户通过回车键或鼠标点击就可以轻松完成任务。不过,另外一种更好的方式是提供 Tab 和 (Shift + Tab) 两种操作,通过键盘可以自由地在弹出层不同选项之间按照顺序切换。
明确的焦点提示
接着转到第二个,视觉提示的问题。没有明确是视觉提示,会造成用户的疑惑。
有默认选项的时候,无论是通过闪动的光标、背景色的变化,还是边框、outline 等形式,都需要明确提示用户,Modal 弹出时默认操作项是什么。而当用户使用键盘操作,焦点发生变化后,当前焦点在哪一项上,必须给出明确的视觉提示,否则极容易带来困难,甚至误操作。
应用对焦点的控制
第三个问题实际是第二个问题的延续。前面提到焦点的视觉提示问题,其实还有一种可能性,可能导致严重后果,这就是用户通过 Tab 操作,将焦点移动到弹出层 mask 背后的应用中。
这时候,一方面没有给出清晰的提示,一方面没有对焦点进行很好的控制,万一焦点在一个敏感位置(如一个外部跳转链接),按下回车键,这时候页面将刷新,用户此前的数据面临荡然无存的境地。脑洞再大一点,万一 mask 背后获得焦点的是巨额交易乃至核按钮(233333),那么恭喜你,攻城狮生涯就此结束。
我们知道,当网页弹出原生 alert 时,整个页面其他部分处于不可操作状态。使用类似的自定义组件替代这些功能时,纵然我们不能阻塞页面其他所有部分,但也得采取一定的控制,防止意外发生,最简便的方法便是将 Tab 的控制权掌握在开发者手中。
当然,相比于第三点,前两点应该是必须项,第三项可以算是加分项,在一些普通应用中不实现影响也不大。
举栗子
我们来看下一些实现。
首先是 Github 的 Fork。如图所示:
首先 fork 操作不提供默认选项。但使用 Tab 和 (Shift + Tab) 时,焦点只会在整个弹出层中用紫色框线标出的四个部分中依次切换。回车键按下时,则会 fork 到相应账号,或者取消操作、关闭弹出层。
接着看 SweetAlert,以第一个 Confirm 为例:
Confirm 弹出后,默认选项是右边的 “Yes, delete it!”,点击回车键,就会产生确认反馈。通过 Tab 操作几次发现,整个页面的焦点,只能够在图中两个按钮之间切换。同时仔细观察还能,获得焦点状态(:focus)的按钮,会有一个不太容易察觉的 box-shadow。
再来看看 Vue 组件库 Element 的 MessageBox 组件 中的 “确认消息” Demo。
先说结果,嗯,有提供默认选项,切换到不同选项时,也会有视觉提示(“取消”按钮的颜色和边框色会变,“确认”按钮背景亮度有明显变化)。然而有两点做得还不够:第一,无法通过 Tab 轻易切换到“❎”关闭按钮;第二点,也是最致命的是,根本就没有获取到 Tab 的控制权,以至于多按几次 Tab 然后回车,页面跳到主页,而弹框依然存在(如图所示)。
和 Element 类似,Weui 中的 Dialog 也存在类似问题,但 Weui 主要面向移动端,倒也情有可原。
如何实现控制
一开始我想到了 tabIndex 这个属性。但这货控制起来,十分不方便,在本文场景中使用它,简直是惹火烧身。
那么我能想到最简单的实现,莫过于手动调用 focus 事件了。 Simple Demo 。代码示例如下:
var $ = (sel) => document.querySelector(sel);
$('#js-show-alert').addEventListener('click', () => {
$('#js-prompt').style.display = 'block';
$('#js-confirm').focus();
});
$('#js-cancel').addEventListener('click', () => {
if (confirm('Are your sure?')) {
$('#js-prompt').style.display = 'none';
}
});
$('#js-confirm').addEventListener('click', () => {
$('#js-prompt').style.display = 'none';
alert('Confirmed! The Prompt will disappear');
});
样式方面,按钮使用的是 button,整体并未 reset,所以按钮获取焦点时,是有 outline 的。这也提醒我们,可以直接通过 :focus
伪类来实现 tab 选中时的样式,这样做好懂又靠谱。
还存在另外一点问题,上面的代码中,#js-confirm
元素是默认获取焦点的。那么,假如我们不需要任何默认焦点,而又需要用户按下 Tab 时,让 #js-prompt
中的按钮依次获得焦点呢?
这里提一点不成熟的想法(23333333)。只要让容器元素 #js-prompt
获得焦点即可:$('#js-prompt').focus();
。在控制台上打印下 HTMLElement.prototype.focus
,就会明白我的意思了。但这么做,可能是野路子(我还没细看 HTML 中元素获得焦点相关的规范)。
但是,上面 Demo 的实现,没有考虑到 Tab 控制权的接管。要接管 Tab,就得使用事件绑定来完成。下面看下 Github 是如何实现的。
如何取得控制权
找到 Github 对应的源码,又通过开发者工具发现弹出层容器有个值为 facebox
的 id。
接下来,开始 Ctrl + F 大法。利用长久积累的火眼金睛,找到下面这样两段代码:
就是它了。
先看第二段,整理后如下,重点直接划在注释中:
// 弹出层出现
document.addEventListener("facebox:reveal", function() {
var t = document.getElementById("facebox");
setTimeout(function() {
e(t)
}, 0);
// 为 document 绑定 keydown 事件
// 事件回调函数 n 就是第一张截图的函数
r(document).on("keydown", n);
});
// 弹出层关闭后
document.addEventListener("facebox:afterClose", function() {
// 移除出现时候为 document
// 绑定的 keydown 事件
r(document).off("keydown", n);
// 弹出中已经获得焦点的元素
// 强行失去焦点
r("#facebox :focus").blur()
});
// ....
再来看看函数 n 做了什么。
function n(e) {
var t = void 0;
// hotkey 是自定义的
// 这里开始控制 tab 和 shift+tab 操作
if ("tab" === (t = e.hotkey) || "shift+tab" === t) {
// 阻止默认事件
e.preventDefault();
// 弹出层
var n = r("#facebox"),
// 变量 i 存储弹出层中可以获得焦点的、可见的、可用的
// input, button, .btn, textarea 等元素集合
// filter 用到的函数 o 用于过滤不可见的相关元素
// o = require("github/visible")["default"]
i = r(Array.from(n.find("input, button, .btn, textarea")).filter(o)).filter(function() {
return !this.disabled
}),
// tab 方向
s = "shift+tab" === e.hotkey ? -1 : 1,
// i 集合中当前获得焦点的元素的 index
a = i.index(i.filter(":focus")),
// i 集合中下一个获得焦点的元素的位置
u = a + s;
// 如果下一个获得焦点的位置超出 i 集合范围
// 或者当前没有任何元素获得焦点、并且使用的是向前的 tab 键
// 则将焦点置于 i 集合中的第一个元素上
if (u === i.length || -1 === a && "tab" === e.hotkey) {
i.first().focus()
}
// 当前没有任何元素获得焦点
// 并且使用 shift+tab 向前
// 则将焦点置于 i 集合中最后一个元素
else if(-1 === a){
i.last().focus();
}
// 正常行为
// 将焦点置于下一个元素
else {
i.get(u).focus();
}
}
}
好了,本文也就结束了。
嗯,今天(2016-12-04)我想起来那个完全无法使用的例子了 🙂: