Design System 101 - Roving Focus

前言

在設計系統中,有許多組件都是以群集 (Group) 的方式去呈現,如 TabList, Radio Group 又或是 Checkbox 等等,而當鍵盤使用者在操作頁面時,往往都需要透過 Tab 一個個去切換聚焦又或是沒辦法對聚焦到群集裡面的項目。

Roving Focus (或又稱 Roving TabIndex) 就是在群集組件中,提供能夠讓鍵盤使用者能夠更輕易與更有效率的方式操作頁面。能夠透過 Arrow 鍵來對聚焦進行切換,當使用者想要切換到下一個區塊只需要再按一次 Tab 鍵即可。

前後對比

加了 Roving Focus 之前

使用者聚焦在群集的第一個項目後,如果想要到將聚焦移到輸入框必須連續按好幾下 Tab 鍵且無法透過 Arrow 鍵來切換項目間的聚焦。

<script type="module"></script>

<style>
  .radiogroup {
    list-style: none;
    padding: 0;
    margin: 0;
    display: flex;
    flex-direction: column;
  }

  .radiogroup li {
    display: inline-block;
    padding: 0.5rem;
    border: 1px solid #ccc;
    border-radius: 0.25rem;
    margin: 0.2rem;
    cursor: pointer;
  }

  .radiogroup li[checked] {
    background-color: #f4f4f4;
  }
</style>

<h3 id="drink-options">Drink Options</h3>
<section id="group1" class="radiogroup" role="radiogroup" aria-labelledby="drink-options">
  <div>
    <input type="radio" class="radio" id="Water" name="Water" value="Water" checked />
    <label for="Water">Water</label>
  </div>
  <div>
    <input type="radio" class="radio" id="Tea" name="Tea" value="Tea" />
    <label for="Tea">Tea</label>
  </div>
  <div>
    <input type="radio" class="radio" id="Coffee" name="Coffee" value="Coffee" />
    <label for="Coffee">Coffee</label>
  </div>
  <div>
    <input type="radio" class="radio" id="Cola" name="Cola" value="Cola" />
    <label for="Cola">Cola</label>
  </div>
  <div>
    <input type="radio" class="radio" id="Ginger Ale" name="Ginger Ale" value="Ginger Ale" />
    <label for="Ginger Ale">Ginger Ale</label>
  </div>
</section>
<input type="text" placeholder="Tab Me" />

加了 Roving Focus 之後

使用者可以透過 Arrow 鍵來切換聚焦,並且當使用者想要切換到下一個區塊只需要再按一次 Tab 鍵即可。

其概念就是監聽 group 中的 keydown 事件,當使用者按下 Arrow 鍵時,就會將 selected 移動到下一個 index, 並將 tabIndex 設為 0,以及將原本的項目 tabIndex 設為 -1,並將新的項目進行聚焦。

<script type="module">
  const group = document.querySelector('#group1');
  const buttons = Array.from(group1.querySelectorAll('.radio'));
  const buttonLength = buttons.length;
  let selected = 0;
  let focusedButton = buttons[selected];

  group.addEventListener('keydown', handleKeyDown);
  group.addEventListener('click', handleClick);

  function handleKeyDown(e) {
    switch (e.key) {
      case 'ArrowUp':
      case 'ArrowLeft': {
        e.preventDefault();

        if (selected === 0) {
          selected = buttonLength - 1;
        } else {
          selected--;
        }
        break;
      }

      case 'ArrowDown':
      case 'ArrowRight': {
        e.preventDefault();

        if (selected === buttonLength - 1) {
          selected = 0;
        } else {
          selected++;
        }
        break;
      }
    }

    changeFocus(selected);
  }

  function handleClick(e) {
    const children = e.target.parentNode.children;
    const selected = Array.from(children).indexOf(e.target);
    changeFocus(selected);
  }

  function changeFocus(idx) {
    // 將原本的 button 的 tabindex 設為 -1
    focusedButton.tabIndex = -1;
    focusedButton.removeAttribute('checked');

    // 將新的 button 的 tabindex 設為 0 以及 focus
    focusedButton = buttons[idx];
    focusedButton.tabIndex = 0;
    focusedButton.focus();
    focusedButton.setAttribute('checked', 'checked');
  }
</script>

<style>
  .radiogroup {
    list-style: none;
    padding: 0;
    margin: 0;
    display: flex;
    flex-direction: column;
  }

  .radiogroup li {
    display: inline-block;
    padding: 0.5rem;
    border: 1px solid #ccc;
    border-radius: 0.25rem;
    margin: 0.2rem;
    cursor: pointer;
  }

  .radiogroup li[checked] {
    background-color: #f4f4f4;
  }
</style>

<h3 id="drink-options">Drink Options</h3>
<section id="group1" class="radiogroup" role="radiogroup" aria-labelledby="drink-options">
  <div>
    <input type="radio" class="radio" id="Water" name="Water" value="Water" checked />
    <label for="Water">Water</label>
  </div>
  <div>
    <input type="radio" class="radio" id="Tea" name="Tea" value="Tea" />
    <label for="Tea">Tea</label>
  </div>
  <div>
    <input type="radio" class="radio" id="Coffee" name="Coffee" value="Coffee" />
    <label for="Coffee">Coffee</label>
  </div>
  <div>
    <input type="radio" class="radio" id="Cola" name="Cola" value="Cola" />
    <label for="Cola">Cola</label>
  </div>
  <div>
    <input type="radio" class="radio" id="Ginger Ale" name="Ginger Ale" value="Ginger Ale" />
    <label for="Ginger Ale">Ginger Ale</label>
  </div>
</section>
<input type="text" placeholder="Tab Me" />

React 實作

在介紹如何用 React 實作 Roving Focus 時,會使用到先前在 設計模式 x 複合組件 中 Collection API 的概念,如果還沒看過的讀者,可以先閱讀一下。

使用方式

// <GroupItem />
() => (
<RovingFocusGroup reachable loop>
<GroupItem />
</RovingFocusGroup>
);
// <Item />
() => {
const rovingFocusProps = useRovingFocus({ disabled, active: isSelected });
return <Item {...rovingFocusProps} />;
};

`RovingFocus` 的實作概念

  1. createCollection:
    • group 內可被聚焦的 item 進行收集,並透過 useContext 來取得 collection。
  2. useRovingFocus:
    • 透過 useEffect 來監聽 tabStopId 的變化,當 tabStopId 變化時,就會將 tabIndex 設為 0,並將原本的項目 tabIndex 設為 -1,以及將新的項目進行聚焦。
    • 將聚焦邏輯進行封裝,讓 group 底下的 item 能夠根據不同的鍵盤事件進行聚焦狀態的改變。
  3. <RovingFocusGroup />:
    • 主要是將 groupId, collectiontabStopId 等的狀態透過 createContext 傳遞給子組件。
import React from 'react';
import { RadioGroup, Radio } from './radioGroup.jsx';

export default () => {
  return (
    <>
      <h3 id="drink-options">Drink Options</h3>
      <RadioGroup loop>
        <Radio value="Water">Water</Radio>
        <Radio value="Tea">Tea</Radio>
        <Radio disabled value="Coffee">
          Coffee
        </Radio>
        <Radio value="Ginger Ale">Ginger Ale</Radio>
      </RadioGroup>
      <div>
        <input type="text" placeholder="Tab Me" />
      </div>
    </>
  );
};

參考資料

  1. Roving tabindex -- A11ycasts #06
  2. Radix UI - react-roving-focus
  3. A11y Solution - Roving Focus