Focus-no-Jutsu 范例

该范例页面使用 Focus-no-Jutsuno-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 ArrowRight Arrow 访问每一个选项卡,有的选项卡也支持使用 HomeEnd 聚焦第一个和最后一个选项卡。

查看源码。
          
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 ArrowDown Arrow 来选中;听到动听的歌曲,我们会把这首歌曲收藏起来,当焦点回到歌曲,按下 Left Arrow 或者 Right Arrow 找到“收藏”按钮,就能进行后续的操作了。

NARUTO -ナルト- オリジナルサウンドトラック
专辑

NARUTO -ナルト- オリジナルサウンドトラック

Toshio Masuda MUSASHI PROJECT 2003 20 首歌曲, 39 分钟 54 秒
2003年3月19日
查看源码。
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
  }
}

        

查看完整的滚动加载源码