使用 focus-fly 管理和控制焦点,实现一个键盘可访问的用户界面,为用户带来流畅的键盘体验。focus-fly(v2.0.1)的压缩体积为 6.2kB。
键盘可访问的用户界面,会在用户丧失或暂时丧失使用鼠标能力的时候,依然保有用户使用键盘的能力。对于有能力同时使用鼠标和键盘的用户,他们可以自由地切换访问界面的设备。您可以任意选择鼠标和键盘来访问这个使用 focus-fly 的范例网站。
网页程序里有很多需要管理和控制焦点的场景,例如弹窗、菜单、选项卡、抽屉等等。焦点往往在多个元素之间相互关联,并且要有符合预期的移动轨迹。进行业务或组件开发的时候,如果不考虑焦点的影响,处处关联的焦点可能让程序变得混乱,如果遗漏处理某些情况,反应到用户界面上,则是不符预期的意外行为。
下面的文档会使用到几个关键词,分别是入口、封面、列表和出口,引入项目之后可以运用这几个关键词,来描述“焦点如何通过入口进入列表,进入列表后如何移动焦点,以及如何让焦点通过出口退出列表”。
在开发无障碍组件的时候需要控制焦点。
例如开发一个模态对话框,对话框的背景应该对所有用户隐藏,对于鼠标用户,鼠标不能访问背景元素,对于键盘用户,键盘不能访问背景元素,对于使用辅助设备的用户,辅助设备也不能访问背景元素。
focus-fly 可以控制从“打开”按钮开始、到对话框内导航、到“关闭”按钮结束,这个流程中焦点的路径,通过确定的焦点路径,避免聚焦到背景元素上。
npm 安装(yarn 则使用 yarn add focus-fly
进行安装):
npm install focus-fly
focus-fly 支持 ESM 和 CJS 导入,如果希望直接通过浏览器标签导入,可以下载本项目 output/
文件夹内的 UMD 文件 进行 <script>
标签引入。
添加下面这两行代码后,焦点将会在一个列表之间陷入循环,这个列表的头元素是 #firstTabbableNode
,尾元素是 #lastTabbableNode
:
import fFocus from "focus-fly"; // ESM 导入方式
// const focus = require("focus-fly"); // CJS 导入方式
fFocus(["#firstTabbableNode", "#lastTabbableNode"]);
这是最简洁的调用,以这种方式调用,焦点可以通过键盘(Tab)进入列表,但是无法通过键盘退出列表,下面对调用参数稍加修改,加入元素 #entryBtn
作为入口,元素 #lastTabbableNode
作为出口,这样焦点就能够通过键盘在入口、列表和出口间流动:
fFocus(["#firstTabbableNode", "#lastTabbableNode"], {
entry: "#entryBtn",
exit: "#lastTabbableNode",
onEscape: true
});
上面的代码执行后,在浏览器中将会有这样的行为:在 #entryBtn
上按下 Enter,#firstTabbableNode
成为焦点,按住 Tab,焦点在 #firstTabbableNode
和 #lastTabbableNode
之间循环,在 #lastTabbableNode
上按下 Enter,或者在列表内的任意元素上按下 Esc,#entryBtn
成为焦点。查看一个在线范例。
这是一个简单,但是是比较完整的用法,解释了入口、列表和出口对焦点的管理与控制,继续阅读,查看关于延迟注册列表事件、触发焦点移动的钩子、自定义焦点矫正目标等更多特性的详细介绍。
调用函数 focusFly
时可以传递 2 个参数,list
表示焦点列表,第二个入参 options
是可选的,用于设定若干选项,例如设定入口、封面、列表和出口相关的详细配置。
调用函数 focusFly
时可以传递 3 个参数,root
是 list
内各元素的公共祖先元素,将会被用来监听键盘(keydown)之类的事件,如果不提供 root
,focus-fly 将会通过 list
找到最小公共祖先元素,第二个入参 list
表示列表,第三个 options
是可选的,用于设定若干选项。
查看一种使用范例,范例演示了如何通过 focus-fly 完成对话框组件的焦点管理。
import fFocus from "focus-fly"; // esm 方式引入
// const fFocus = require("focus-fly"); // cjs 方式引入
const dialog = document.getElementById("dialog");
// 循环焦点的根元素,对话框
fFocus(dialog, ["#head", "#tail"], { // 根元素 root 是 #dialog,根元素用来监听诸如 keydown 之类的事件,列表 list 的范围是从 #head 到 #tail,焦点如果进入列表,就会在这个范围循环
// 入口配置
entry: {
// 入口的选择器字符串,例如“打开”按钮
node: "#open",
// 点击 #open 后的行为
on: onEntry,
},
// 出口配置
exit: {
// 退出列表的出口元素,例如“关闭”按钮
node: "#close",
// 点击 #close 后的行为
on: onExit,
},
// 按下 Esc 的行为
onEscape: true,
});
/** 设置触发入口的行为 */
function onEntry() {
dialog.classList.add("opened");
dialog.classList.remove("closed");
}
/** 设置触发出口的行为 */
function onExit() {
dialog.classList.add("closed");
dialog.classList.remove("opened");
}
您也可以进入范例文件夹,通过运行范例文件夹,进行本地预览:
cd examples/cjs
npm i
npm run start
root,string | Element
,可以是一个 Element 对象,也可以是一个 DOMString(如 #container
)。
根元素 root
是列表内各元素的公共祖先元素,将被用于监听键盘(keydown)事件以及和列表有关的其它事件,默认会监听按键 Tab 来控制焦点循环聚焦,如果开启了 options.onEscape
,也会监听 Esc。
如果不提供这个参数,focus-fly 会取得列表 list
内元素的最小公共祖先作为根元素 root
。
list,(string | Element)[]
,是一个数组,数组内的元素可以是 Element 对象,也可以是 DOMString。
这个参数表示列表,文档里提到的“列表”都是指这里的 list,默认情况下,数组 list
表示范围,只需要两个元素,一个可聚焦的头元素,一个可聚焦的尾元素,如果传入的数组长度大于 2,将只取头和尾。
设置 options.sequence
为 true 后,list
表示序列,是一个长度大于 2 的数组,这时按下 Tab 后,焦点将以 list
中元素的顺序进行导航。在设置 options.next
或 options.prev
后,原来的 Tab 被自定义导航键取代,同时 options.sequence
被默认设为 true。
通过入口进入列表,如果有封面,则入口进入封面,封面再进入列表。通过出口退出列表,回到入口,如果有封面,出口会退出列表,回到封面,再通过封面回到入口。
下面的选项,除了 trigger
、entry
、exit
和 cover
,其它选项基本都和列表相关。下面的每一个选项都是可选的。
Name | Type | Default | Desc |
---|---|---|---|
sequence | boolean | false | 是否指定焦点导航的序列,设置 true 则按顺序聚焦列表内每项元素 |
loop | boolean | true | 是否循环聚焦,设置为 false,锁住焦点,焦点将停止在第一个和最后一个元素 |
next | isKey | listForward | null | 自定义前进按键,可传入函数和字符串,设置后,sequence 将默认为 true |
prev | isKey | listBackward | null | 自定义后退按键,可传入函数和字符串,设置后,sequence 将默认为 true |
trigger | element | null | 入口元素,用于退出列表时聚焦使用,如果在其它地方设置,可以忽略,例如设置 entry.node 后,不用设置 trigger |
entry | element | element[] | entry | entry[] | null | 入口相关配置,进入列表,可以直接设置为一个元素,也可以设置数组,表示多个入口 |
exit | element | element[] | exit | exit[] | null | 出口相关配置,退出列表,回到入口,如果存在封面,则是回到封面,可以直接设置为一个元素,也可以设置数组,表示多个出口 |
onEscape | false | handleKeydown | null | 按下 Esc 的行为,如果未设置,默认取第一个 options.exit.on |
onClick | handleClick | null | 点击列表里的某一项后的行为 |
onMove | handleMoveListItem | null | 移动的时候触发,包括进入列表时,移动列表时,以及退出列表时,sequence 为 true 才会触发 |
cover | boolean | cover | false | 封面相关配置,设置为 true,则是默认封面,默认把根元素 root 作为封面,当焦点在封面上,默认 Enter 进入列表,默认 Tab 聚焦列表的后一个元素 |
initialActive | number | -1 | 默认的初始的焦点在列表中的位置,可能会被用于修改当前和上一个聚焦元素的样式时使用 |
correctionTarget | boolean | getTarget | true | 焦点矫正,默认从非入口的空白区域进入列表,也将聚焦上一次退出前焦点在列表中的位置,设置为 false 则不进行矫正 |
delayToFocus | boolean | promiseDelay | callbackDelay | null | 延迟聚焦,执行完 options.entry.on 后,等待执行 delayToFocus 完成后聚焦,延迟聚焦的本意是等待列表渲染完成后再聚焦,延迟聚焦意味延迟添加列表相关的事件,也即在触发入口前,没有列表相关的事件,如果设为 true,则会在触发入口后立刻添加列表相关的事件,可用于性能优化 |
delayToBlur | promiseDelay | callbackDelay | null | 延迟失列表的焦,触发出口后等待执行 delayToBlur 完成后失焦,和 delayToFocus 类似 |
stopPropagation | boolean | false | 阻止(列表移动)冒泡或捕获 |
preventDefault | boolean | false | 阻止(列表移动)默认行为 |
manual | boolean | false | 手动添加监听事件,入口、列表、出口的监听事件,通过调用的返回值手动添加各事件 |
关于组合键,可以通过字符串便捷地设置,展开查看具体说明。
关于组合键的设置,上面和下面的表格中,类型 Type
为 isKey
的,有便捷的字符串的设置方式:
- 直接传入字符串,例如
"Control-n"
,表示同时按下 Control 和 n; - 也可配合数组,用于多种按键组合完成同一个任务,例如
["Control-n", 'j', "ArrowRight", "ArrowDown"]
,表示按下 Control 和 n、按下 j、按下右方向键、按下向下方向键,这四种组合的功能都是一样的。
如果需要传入函数,也可将函数传入数组中,函数和字符串能够混合使用。
为了不影响排版阅读,下面 4 个名称过长的选项被单独制成一张表格:
Name | Type | Default | Desc |
---|---|---|---|
removeListenersEachExit | boolean | true | 每次退出列表回到入口是否移除列表事件 |
removeListenersEachEnter | boolean | false | 每次进入列表后是否移除入口事件 |
addEntryListenersEachExit | boolean | true | 每次退出列表是否添加入口监听事件 |
allowSafariToFocusAfterMousedown | boolean | true | 用于抹平 Safari 不同于其它浏览器,点击后 button 之类的元素不会被聚焦的问题,设置为 true,Safari 中 将会在列表的 mousedown 事件里执行 focus() |
Name | Type | isRequired | Default | Desc |
---|---|---|---|---|
key | isKey | N | null | 自定义在列表前进的组合键,可传入函数和字符串,如果是函数,则返回 true 代表应用这个组合键 |
on | handleNextOrPrev | N | null | 前进时被执行,前进时的行为 |
Name | Type | isRequired | Default | Desc |
---|---|---|---|---|
key | isKey | N | null | 自定义在列表后退的组合键,可传入函数和字符串,如果是函数,则返回 true 代表应用这个组合键 |
on | handleNextOrPrev | N | null | 后退时被执行,后退时的行为 |
查看自定义按键聚焦的范例。
下面的代码演示了使用 →
、↓
和 ctrl-n
完成前进焦点(字符串形式),使用 ←
、↑
和 ctrl-p
完成后退焦点(函数形式):
import fFocus from "focus-fly";
const dialog = document.getElementById("dialog");
const isBackward = e => (
(e.ctrlKey && e.key === "p") ||
e.key === "ArrowTop" ||
e.key === "ArrowLeft");
fFocus(dialog, ["#head", "#second", "#tail"], {
entry: {
node: "#open",
on() {
dialog.classList.add("openedDialog");
dialog.classList.remove("closedDialog");
},
},
exit: {
node: "#close",
on() {
dialog.classList.remove("openedDialog");
dialog.classList.add("closedDialog");
},
},
next: ["Control-n", "ArrowRight", "ArrowDown"], // <----- 自定义*前进*焦点的配置项
prev: isBackward, // <---- 自定义*后退*焦点的配置项
});
这些选项和入口相关,描述了如何通过入口进入封面或列表。下面的选项可以在一个对象里,也可以在由这个对象组成的数组里。下面的每一个选项都是可选的。
如果已经通过入口进入列表,则在退出列表前,不能再次触发入口进入列表。通过直接点击列表,也被算作进入列表。
Name | Type | Default | Desc |
---|---|---|---|
node | element | element[] | null | 入口元素,将用于监听点击事件,用于退出列表时聚焦使用 |
key | isKey | null | 自定义进入列表组合键,可传入字符串和函数 |
on | handleKeydown | null | 进入时被调用,进入列表前的行为,如果列表或封面在这里才开始渲染,需要设置 options.delayToFocus 来延迟聚焦,否则不能聚焦不存在的元素 |
type | enterType | enterType[] | null | 入口的监听方式,如果 options.entry 设置了 node 选项,则默认为 "click" ,如果还设置了 key 选项,则默认为 ["click", "keydown"] ,另外还支持 "focus" 类型用于聚焦触发入口,"invoke" 类型用于返回值 Return.enter 触发入口 |
target | boolean | element | getTarget | null | 进入到哪个元素?默认将聚焦列表第一个元素,设置为 false 将不改变焦点 |
delay | false | promiseDelay | callbackDelay | null | 延迟聚焦,触发 node 后等待执行 delay 完成后聚焦,如果没有设置,将取 options.delayToFocus |
if | ef | null | 触发入口的条件,如果不符合条件,将不被认为是进入了列表 |
stopPropagation | boolean | false | 阻止(入口)冒泡或捕获 |
preventDefault | boolean | true | 阻止(入口)默认行为 |
onExit | true | handleExit | null | 指定当前入口同时也是出口,作为出口的行为,设为 true,则行为取 options.entry.on ,该选项类似表明这个元素是个开关 |
入口定义的方式非常自由,例如 entry: "#entryBtn"
,entry: { node: "#entryBtn", type: "click" }
,entry: ["#entryBtn"]
,entry: ["#entryBtn", { node: "#btn2", type: "click" }]
,这四种写法都是允许的。
这些选项和出口相关,描述了焦点如何从列表回到封面或入口。
和入口类似,在下次进入列表前,不能够重复触发出口退出列表。通过点击非列表的空白区域,也被算作退出列表。
Name | Type | Default | Desc |
---|---|---|---|
node | element | element[] | getExit | null | 出口元素,将用于监听点击事件,用于退出列表时聚焦使用 |
key | isKey | null | 自定义退出列表组合键,可传入字符串和函数 |
on | handleKeydown | null | 退出时被调用,退出列表前的行为,如果有封面就退出至封面,如果没有就退出至入口,设置该选项后,按键按下 esc 同样生效 |
type | exitType | exitType[] | ["keydown", "click"] | 出口的事件类型,和 options.entry.type 类似,但是多了 "outlist" 类型,用于聚焦空白区域、非列表区域时触发出口,这常用于弹窗的半透明蒙版 |
target | boolean | element | getTarget | null | 退出至哪个元素?默认将聚焦第一个入口,设置为 false 将不改变焦点 |
delay | false | promiseDelay | callbackDelay | null | 延迟失焦,触发 node 后等待执行 delay 完成后失焦,如果没有设置,将取 options.delayToBlur |
stopPropagation | boolean | false | 阻止(出口)冒泡或捕获 |
preventDefault | boolean | true | 阻止(出口)默认行为 |
if | ef | null | 触发出口的条件,如果不符合条件,将不被认为是退出了列表 |
出口定义的方式和入口一样,例如 exit: "#exitBtn"
,exit: { node: "#exitBtn", type: "click" }
,exit: ["#exitBtn"]
,entry: ["#exitBtn", { node: "#btn2", type: "click" }]
,这四种写法都是允许的。
这些选项和封面有关,每个选项都是可选且默认值为空。
如果存在封面,焦点将通过入口进入封面,焦点又通过封面进入列表,焦点通过出口退出至封面,最后焦点通过封面退出至入口。也就是说,在进入列表的阶段时,封面在入口和列表之间,在退出列表的阶段,封面在出口和入口之间。
Name | Type | Desc |
---|---|---|
node | element | 封面元素,如果不指定,默认将取根元素 root |
exit | isKey | exitCover | exitCover[] | 退出封面,可以直接设置退出封面的组合键,如果不设置,Tab 将作为默认退出封面的按键,并且退出至列表的后一个元素 |
enterKey | isKey | 自定义进入列表的组合键,如果不设置,默认为 Enter,可传入函数和字符串 |
onEnter | handleKeydown | 进入列表时的行为 |
options.cover.exit
是一个有若干选项的对象,也可以是一个包含这类对象的数组。下面是 options.cover.exit
的所有选项,每一个选项都是可选的,且默认值为空:
Name | Type | Desc |
---|---|---|
key | isKey | 自定义退出封面的组合键,可传入函数和字符串 |
on | handleKeydown | 退出封面时的行为 |
target | element | 退出到哪个元素? |
下面是调用函数 focusFly 后返回的属性。
Name | Type | Desc |
---|---|---|
enter | (entry: ReturnEntry) => Promise<void> | 进入列表,如果自己管理入口元素的点击监听器,可以使用该方法 |
exit | (exit: ReturnExit) => Promise<void> | 退出列表,如果自己管理退出入口元素的点击监听器,可以使用该方法 |
removeListeners | () => void | 移除所有的监听事件 |
addEntryListeners | () => void | 添加入口的监听事件 |
removeEntryListeners | () => void | 移除入口事件 |
addListRelatedListeners | () => void | 添加列表相关(封面、列表、出口)的监听事件 |
removeListRelatedListeners | () => void | 移除列表相关的事件 |
addForward | (id: string, forward: forward | getForward) => void | 添加转发,转发用于不涉及入口、列表、出口、封面的焦点转移 |
removeForward | (id: string) => void | 移除转发 |
updateList | (newList: element[]) => void | 更新列表 |
i | (newI?: number) => number | 获取和设置当前焦点的编号,设置新的编号之后,会聚焦对应编号的焦点,并触发 options.onMove |
查看使用 enter 和 exit 的一个范例。
import fFocus from "focus-fly";
const dialog = document.getElementById("dialog");
const openBtn = document.getElementById("#open");
const closeBtn = document.getElementById("#close");
const bagel = fFocus(dialog, ["#head", "#tail"]);
openBtn.addEventListener("click", e => {
dialog.classList.add("openedDialog");
dialog.classList.remove("closedDialog");
bagel.enter(); // 聚焦 #head
})
closeBtn.addEventListener("click", e => {
dialog.classList.remove("openedDialog");
dialog.classList.add("closedDialog");
bagel.exit(); // 聚焦 #dialog
})
查看使用 addForward
的一个范例,这个范例中,#grid_wrapper
是一个中转节点,通过按下 Tab 和反向 Tab,焦点中转到 #more_from
。
查看和运行范例:
cd examples/cjs # 进入使用 cjs 模块的范例文件夹
npm i # 安装依赖
npm run start # 本地运行
进行项目开发:
npm i
npm run start
运行之后,修改根目录的 index.js(focus-fly 主文件)和 examples/run-start
下的文件,即可在浏览器看到实时修改结果。开发后,提交时请编写相应的单元测试。
npm i
npm run test
有些情况,通过 onMove、onNext、onPrev、entry.on 等钩子回调,不能完成样式修改。
focus-fly 的主要任务是管理和控制焦点,如果有钩子不能满足需求,可以考虑在业务开发中自行监听事件,处理样式的变化。
查看原理。
查看更新日志。
查看语义化版本 2.0.0。
查看 MIT License。
请随意 Issue、PR 和 Star,您也可以支付该项目,支付金额由您从该项目中获得的收益自行决定。
focus-fly 支持的特性:
- 集中管理焦点;
- 通过指定范围或序列循环焦点;
- 按需监听、移除事件;
- 矫正不是从入口进入列表的焦点;
- 提供钩子函数完成诸如样式修改的任务。
假设准备开发一个弹窗,进行焦点管理,需要有下面的流程、考虑下面几种情况:
- 在“打开”按钮上按下 Enter,弹窗内第一个元素获得焦点;
- 在弹窗的内部按住 Tab,焦点(中幻术)不能逃出弹窗;
- 点击弹窗的空白区域,按下反向 Tab,弹窗内的最后一个元素获得焦点;
- 在“关闭”按钮上按下 Enter,“打开”按钮获得焦点;
- 按下 Esc,或者点击弹窗背后的半透明蒙层,“打开”按钮获得焦点;
- 管理弹窗、半透明蒙版、“打开”按钮、“关闭”按钮的点击和键盘事件。
相关链接: