Focus-no-Jutsu
范例该范例页面使用 Focus-no-Jutsu
(no-Jutsu
的发音为 /ˈnɔˌjutsu/)管理和控制焦点,是一个键盘可访问的界面,您可以任意选择鼠标和键盘访问该页面。
网页程序里有很多需要管理和控制的场景,例如弹窗、菜单、选项卡、抽屉等等,在我们按下键盘的 Tab ,焦点进入某个场景,我们希望焦点被施加一个幻术,让焦点陷入一个循环,或者被卡在首尾元素之间的秘密空间,直到我们按下 Esc 或者点击“返回”,解开幻术。
接下来我们将施展 Focus-no-Jutsu
,完成和焦点有关的一系列任务!🥷
这个简单的例子演示了 focus-no-jutsu 基本的用法。首先通过 Tab 访问 entryBtn,然后按下 Enter,firstTabbableNode 成为焦点,按住 Tab,焦点将在 firstTabbableNode 和 lastTabbableNode 之间循环,最后,当焦点落在 lastTabbableNode 时按下 Enter,或者在列表内的任意元素上按下 Esc,焦点回到 entryBtn。
focusNoJutsu(["#firstTabbableNode", "#lastTabbableNode"], {
entry: "#entryBtn",
exit: "#lastTabbableNode",
onEscape: true,
delayToFocus: true,
});
对话框通常是一个弹窗,当 Tab 访问到“打开”按钮,焦点聚焦按钮,Enter(或者 Space 和鼠标点击)触发“打开”之后,弹出对话框,焦点聚焦对话框的首个可聚焦元素,这时如果继续 Tab,焦点只能访问对话框内的元素,不能逃出对话框或者回到“打开”按钮,最后,我们按下 Esc 或者触发“返回”按钮,焦点回到“打开”按钮。
爱吃糖果的小可爱,你的糖果已送达,请注意查收。不需冷藏,无需加热,一颗提神醒脑,两颗永不疲劳,三颗长生不老~
const dialog = document.getElementById("dialog");
const mask = document.getElementById("dialog_mask");
const entry = document.getElementById("open");
// 6~19 行为焦点管理的部分,管理了焦点的入口、出口,以及焦点在列表内移动的范围
focusNoJutsu(dialog, ["#firstFocusBtn", "#close"], { // L:6
loop: false,
entry: {
node: entry,
on: openDialog,
target: ({ list }) => list[0],
},
exit: {
node: ["#close", "#firstFocusBtn", "#confirm"],
on: closeDialog,
type: ["click", "outlist"]
},
onEscape: true,
}); // L:19
// 下面的两个函数和焦点无关,和样式或其它逻辑有关,这些代码在实际开发中,可以和上面的焦点部分分开,或者可以像本例中,把这些代码集成到焦点管理中
/** 打开弹窗,设置打开样式,设置 ARIA */
function openDialog() {
dialog.classList.add("openedDialog");
dialog.classList.remove("closedDialog");
mask.classList.remove("closed_mask");
entry.ariaExpanded = true;
}
/** 关闭弹窗,设置关闭样式,设置 ARIA */
function closeDialog() {
/** 这里的代码省略,代码内容类似上面的 openDialog */
}
查看完整的对话框源码。
导航栏是很常见的组件,很多时候会出现在网站的顶部。导航栏上是图标、按钮、链接和一些像主题切换的功能,这些都是这个网站最重要的部分,导航栏提供了最快的访问路径。下面的导航栏包含一个“目录”按钮,当“目录”获得焦点,按下 Enter,焦点会进入目录面板,落到第一个元素上,通过 Tab 导航至目录面板最后一个元素,再次按下 Tab,焦点将退出目录面板,并落到“Focus-no-Jutsu”的链接上。当焦点在目录面板中,按下 Esc 或者点击空白区域,焦点将回到目录按钮。
const menuBtn = document.getElementById("menu_btn");
const menuBody = document.getElementById("menu");
// 5~29 行为焦点管理和控制的部分,包含了触发入口和出口时的行为——切换菜单状态,toggleMenu
focusNoJutsu(menuBody, ["#hot_anchor", "#scroll_anchor"], { // L:5
onEscape: toggleMenu,
entry: {
node: menuBtn,
on: toggleMenu,
onExit: true,
target: ({ list }) => list[0],
},
exit: [{
node: ({ head }) => head,
type: "keydown",
key: e => e.key === "Tab" && e.shiftKey,
on: toggleMenu,
}, {
node: ({ tail }) => tail,
type: "keydown",
key: e => e.key === "Tab" && !e.shiftKey,
on: toggleMenu,
target: "#nav_link",
}, {
type: "outlist",
on: toggleMenu,
target: ({ e }) => e?.relatedTarget?.id?.includes("h-") ? false : menuBtn,
}],
}); // L:29
/** 切换菜单时的样式变化 */
function toggleMenu() {
menuBtn.classList.toggle("opened");
menuBody.classList.toggle("opened");
menuBtn.ariaExpanded = menuBtn.classList.contains("opened") ? true : false;
}
查看完整的导航栏源码。
选项卡包含一个选项卡列表和一个面板,切换选项卡的时候,改变选项卡面板的内容。Tab 首先访问选项卡列表,焦点落入选项卡列表,再次 Tab 焦点将离开选项卡。在选项卡列表里,使用 Left Arrow 和 Right Arrow 访问每一个选项卡,有的选项卡也支持使用 Home 和 End 聚焦第一个和最后一个选项卡。
const tabList = ["#tab_1", "#tab_2", "#tab_3", "#tab_4", "#tab_5"];
// 4~17 行为焦点管理的部分,管理了焦点的入口、出口,以及焦点在列表内的移动
focusNoJutsu("#tab_list", tabList, { // L:4
next: e => e.key === "ArrowRight",
prev: e => e.key === "ArrowLeft",
exit: [{
key: e => e.key === "Tab" && !e.shiftKey,
target: "#tags_code",
}, {
key: e => e.key === "Tab" && e.shiftKey,
target: "#navigation_code",
}],
initialActive: 1,
removeListenersEachExit: false,
onMove,
}); // L:17
// 下面的函数和焦点无关,和样式或其它逻辑有关,这些代码在实际开发中,可以和上面的焦点部分分开,或者可以像本例中,把这些代码集成到焦点管理中
/** 进行样式修改,当焦点移动时,本次聚焦添加样式,上一次聚焦移除样式,更新 ARIA */
function onMove({ prev, cur, prevI, curI }) {
if (prevI === -1 || curI === -1) return;
prev.setAttribute("aria-selected", "false");
cur.setAttribute("aria-selected", "true");
const prevPanel = document.getElementById("tabpanel_" + (prevI + 1));
const curPanel = document.getElementById("tabpanel_" + (curI + 1));
prevPanel.classList.add('is-hidden');
curPanel.classList.remove('is-hidden');
}
查看完整的选项卡源码。
这个“播放列表”是一个真实例子,是 Spotify 的专辑界面,Spotify 网页有很好的键盘体验。如果有一类元素组成了列表,这类元素的每一个内部又包含很多可聚焦的子元素,这时使用 Tab 从头到尾的访问体验是崩溃的,因为元素太多、花费时间太长了。
Spotify 是这样做的。一首专辑有多首单曲,首先 Tab 会聚焦整个专辑列表,继续 Tab,焦点将退出整个专辑列表,聚焦专辑列表的后一个元素;如果正在播放专辑中的某一首歌曲,首先 Tab 会聚焦整个专辑列表,继续 Tab,焦点不会退出专辑,而是会聚焦歌曲;退出专辑之后,回过头如果想要播放某一首歌,只要让焦点回到专辑列表,然后通过 Up Arrow 和 Down Arrow 来选中;听到动听的歌曲,我们会把这首歌曲收藏起来,当焦点回到歌曲,按下 Left Arrow 或者 Right Arrow 找到“收藏”按钮,就能进行后续的操作了。
const songs = ["#song_1", "#song_2", "#song_3", "#song_4", "#song_5", "#song_6", "#song_7"];
// 3~38 行为播放列表的焦点管理部分,管理了焦点的入口、出口,以及焦点在列表内的移动
const playerBagel = focusNoJutsu("#songs_wrapper", songs, { // L:3
next: e => e.key === "ArrowDown",
prev: e => e.key === "ArrowUp",
exit: [{
key: e => e.key === "Tab" && !e.shiftKey,
target: "#more_from",
}, {
key: e => e.key === "Tab" && e.shiftKey,
target: "#grid_wrapper",
}, {
type: "outlist",
target: false,
}],
entry: {
node: "#grid_wrapper",
key: (e, active) => (
(e.key === "Tab" && !e.shiftKey && active > -1) ||
e.key === "ArrowDown" ||
e.key === "ArrowUp"
) && e.target.id === "grid_wrapper",
type: "keydown",
},
onMove: onMovePlayer,
correctionTarget({ lastI, last }) {
if (lastI === -1)
return "#grid_wrapper";
return last;
},
removeListenersEachExit: false,
});
playerBagel.addForward("grid", {
node: "#grid_wrapper",
key: (e, _, active) => (e.key === "Tab" && !e.shiftKey && active === -1),
target: "#more_from",
}); // L:38
/** 一首歌曲最后的聚焦焦点的序号 */
let songLastActiveIdx = 0;
/** 管理第 curI + 1 首歌曲的焦点 */
function initSongBagel(curI, lastActive) {
const idx = curI + 1;
const list = [`#s${idx}_play`, `#s${idx}_a`, `#s${idx}_like`, `#s${idx}_more`];
// 47~65 行为播放列表内每一首歌曲的焦点管理部分,管理了焦点的入口、出口,以及焦点在列表内的移动
return focusNoJutsu(`#song_${idx}`, list, { // L: 47
entry: [{
node: `#song_${idx}`,
type: "focus",
}, {
node: `#song_${idx}`,
key: e => e.key === "ArrowRight" || e.key === "ArrowLeft",
type: "keydown",
}],
next: e => e.key === "ArrowRight",
prev: e => e.key === "ArrowLeft",
exit: {
type: "outlist",
target: false,
},
initialActive: songLastActiveIdx,
correctionTarget: false,
addEntryListenersEachExit: false,
}); // L:65
}
let lastSong = null;
/** 焦点在播放列表内移动时的样式变化 */
function onMovePlayer({ cur, prev, curI }) {
prev?.classList.remove("focused");
cur?.classList.add("focused");
if (lastSong != null) {
songLastActiveIdx = lastSong.i() === -1 ? 0 : lastSong.i();
}
lastSong?.removeListeners(); // 移除这首歌曲和焦点有关的事件
if (curI > -1) {
lastSong = initSongBagel(curI, songLastActiveIdx); // 对这首歌的焦点进行管理
}
}
查看完整的播放列表源码。
商城首页的商品列表、图片搜索的结果页面,都能看到滚动加载的应用。通常会滚动加载一个列表,例如一个商品列表,商品内会有商品图片、商品详情和“加入购物车”按钮,如果逐个访问,会很耗时;在商品列表的右下角,通常也会提供“回到顶部”按钮用来方便操作,如果不对这个按钮适配键盘,焦点将始终处于不停加载的列表中,而无法通过键盘访问到“回到顶部”按钮。
假设下面是一个商城首页,通过键盘访问的时候,会有这样的预期。首先 Tab 聚焦商品列表的最外层,继续 Tab,焦点将跳过整个商品列表;回到上一步,当焦点处于外层根元素时,按下 Enter,第一个商品获得焦点,按下 Tab,焦点会在第一个和最后一个商品之间循环,循环的期间,按下 Esc,焦点又会回到商品列表最外层;当焦点处于某个商品上时,按下 Enter,焦点会进入商品内部,聚焦商品图片、商品详情和“加入购物车”按钮,按下 Esc,焦点又会回到商品上。
焦点在商品列表的时候,通过滚动(鼠标滚轮或 Arrow Down)加载更多的商品,按下 Left Arrow,焦点会从某一商品转移到“禁止滚动加载”按钮上,按下 Enter 确认按钮后,加载模式将从滚动加载变为按钮点按加载;连续按下 Right Arrow 两次,焦点会依次转移到商品和“回到顶部”按钮上,确认按钮后,视图将滚动至列表顶部,同时焦点聚焦第一个商品。
下面的源码做了一些简化,简化了和业务有关、和焦点控制无关的部分。1~83 行为主要的焦点管理部分,其它的,例如在滚动之后,需要对列表更新,会在相应的滚动加载的地方调用 .updateList()
,例如第 122 行。
const scrollContent = document.getElementById("scroll_content"); // L:1
const bagel = focusNoJutsu(
scrollContent, // 根元素
[...document.getElementsByClassName("scroll_item")], // 列表
{
sequence: true, // 打开序列模式
entry: [{
node: "#scroll_wrapper", // 从最外层回车进入列表
type: "keydown",
key: e => e.key === "Enter",
if: ({ e: { target: { id } } }) => id === "scroll_wrapper",
}, {
node: "#scroll_type", // 从切换加载方式按钮按下“右方向键”和“Tab”进入列表
type: "keydown",
key: ({ key }) => ["ArrowRight", "Tab"].includes(key),
stopPropagation: true,
}, {
node: "#scroll_top", // 从“回到顶部”按钮按下“左方向键”、“Tab”、回车和空格进入列表
type: "keydown",
key: ({ key }) => ["ArrowLeft", "Tab", "Enter", ' '].includes(key),
stopPropagation: true, // 阻止冒泡
target({ e: { key }, list }) {
if (key === "Enter" || key === ' ') return list[0]; // 按下回车或空格,聚焦列表第一个元素
},
on({ key }) {
if (key === "Enter" || key === ' ') scrollToTop(); // 点击,或者按下了回车或空格时,滚至顶部
},
}],
exit: [{
key: ({ key }) => key === "ArrowLeft", // 按下“左方向键”退出列表
target: "#scroll_type", // 从列表退出到“切换加载方式的按钮”
}, {
key: ({ key }) => key === "ArrowRight", // 按下“右方向键”退出列表
target: "#scroll_top", // 从列表退出到“回到顶部”按钮
}, {
node: ["#scroll_type", "#scroll_top"], // 点击“切换加载”和“回到顶部”,退出列表
type: "click",
target: false, // 退出后焦点保持原位
on({ target }) {
if (target.id === "scroll_top") scrollToTop();
}
}],
stopPropagation: true, // 阻止列表的事件冒泡
onEscape: true, // 按下 Esc 退出列表
onMove: createOrRemoveItemFocus, // 在列表内移动时,调用 createOrRemoveItemFocus
removeListenersEachExit: false, // 退出后不移除列表有关的事件
correctionTarget({ e: { relatedTarget } }) {
if (relatedTarget == null || !scrollContent.contains(relatedTarget))
return "#scroll_wrapper"; // 焦点从外部进入列表时,焦点重新矫正到 #scroll_wrapper 上,其它情况,矫正到上一次聚焦的列表元素
}
}
);
bagel.addForward("next_wrapper", { // 转发
node: "#scroll_wrapper",
key: ({ key, shiftKey }) => (key === "Tab" && !shiftKey),
target: "#scroll_code",
});
let lastItemFocus = null;
/** 创建或移除列表内单个元素的焦点管理 */
function createOrRemoveItemFocus({ cur }) {
lastItemFocus?.removeListeners();
if (cur && cur.id !== "scroll_more") {
const itemFirst = cur.getElementsByClassName("item_top")[0];
const itemLast = cur.getElementsByClassName("item_bottom")[0];
lastItemFocus = focusNoJutsu([itemFirst, itemLast], {
entry: {
node: cur,
type: "keydown",
key: e => e.key === "Enter",
},
exit: {
key: e => e.key === "Escape",
stopPropagation: true,
},
delayToFocus: true,
stopPropagation: true,
});
}
} // L:83
const scrollItems = document.getElementById("scroll_items");
scrollContent.addEventListener("scroll", onScrollBottom); // 滚动至底部,加载
scroll_type.addEventListener("click", function() {
/** 这里省略了切换加载方式的代码…… */
if (isClickType) { /** 是否已禁止滚动,这里省略了 isClickType 的计算…… */
scrollContent.removeEventListener("scroll", onScrollBottom); // 移除滚动加载
bagel.updateList([...document.getElementsByClassName("scroll_item"), document.getElementById("scroll_more")]);
}
else {
scrollContent.addEventListener("scroll", onScrollBottom); // 滚动至底部,加载
bagel.updateList([...document.getElementsByClassName("scroll_item")]);
}
});
// 点击触发“加载更多”
scroll_more.addEventListener("click", function(e) {
const items = generateItems(); /** 生成更多内容,这里省略了代码实现…… */
scrollItems.appendChild(items);
const newList = [...document.getElementsByClassName("scroll_item"), document.getElementById("scroll_more")];
bagel.updateList(newList);
e.stopPropagation(); // 阻止冒泡,阻止冒泡至 #scroll_content,因为 #scroll_content 已被 focus-no-jutsu 管理,冒泡后会导致焦点不在上一次聚焦的元素上
});
scroll_top.addEventListener("click", function(e) {
scrollToTop();
e.stopPropagation();
});
function onScrollBottom() {
if (isBottom) { /** 是否已经触底,这里省略了 isBottom 的计算方式…… */
const items = generateItems();
scrollItems.appendChild(items);
let newList = [...document.getElementsByClassName("scroll_item")];
bagel.updateList(newList); // L:122
}
}
查看完整的滚动加载源码。