【React】アクセシビリティに考慮したタブを実装する

タブとは、ページ内でコンテンツを切り替えるために使用する UI です。タブ初期表示ではいずれか 1 つのタブパネルが表示されており、関連するタブがアクティブなスタイルで表示されます。それぞれのタブには関連するタブパネルがあり、タブを選択することで表示されるタブパネルがタブに関連するものに切り替わります。

タブは以下の要素から構成されています。

  • tab:関連するタブパネルの表示・非表示を切り替える機能を持つ要素
  • tablisttab 要素の集まり
  • tabpaneltab と関連するコンテンツを持つ要素

タブの要件

タブをアクセシブルにするうためには、以下の実装を行う必要があります。

ロール・ステート・プロパティ

  • タブの集まりのコンテナ要素に tablist 要素を付与する
  • タブとして機能する要素に tab ロールを付与する。すべての tab 要素は tablist の中に存在する必要がある
  • タブに関連してコンテンツを表示する要素に tabpanel ロールを付与する
  • tablist 要素に aria-labelledby または aria-label 属性でアクセシブルな名前を付与する
  • tab に対して関連する tabpanel の ID を aria-controls 属性で指定する
  • 現在アクティブなタブの aria-selectedtrue を設定する。それ以外のタブは aria-selectedfalse を設定する
  • tabpanel に対して関連する tab の ID を aria-labelledby 属性で指定する
  • もしタブが垂直に表示される場合、tablist ロールを付与した要素に aria-orientation 属性に vertical を設定する(デフォルトは horizontal 水平)

キーボード操作

  • Tab
    • フォーカスが tablist の外にある場合、フォーカスをアクティブなタブに移動する
    • フォーカスがアクティブなタブにある場合、フォーカスをキーボードフォーカスの順序の次の要素(理想的にはアクティブなタブに関連付けられた tabpanel に移動する)
  • タブリストの次の要素にフォーカスし、アクティブ化する。現在のタブがタブリストの最後のタブである場合、最初のタブにフォーカスする
  • タブリストの前のタブにフォーカスし、アクティブ化する。現在のタブがタブリストの最初のタブである場合、最後のタブをアクティブ化する

実装

それでは、前述した要件を満たしたリストボックスの実装を考えてみます。以下のように Tab コンポーネントとして実装しました。

import React, {
  createContext,
  useContext,
  useId,
  useState,
  Children,
  useEffect,
  useRef,
} from "react";

type TabContextType = {
  activeIndex: number;
  setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
  titleId: string;
  tabIdPrefix: string;
  panelIdPrefix: string;
};

const TabContext = createContext<TabContextType | null>(null);

const useTabContext = () => {
  const context = useContext(TabContext);
  if (!context) {
    throw new Error("TabContext must be used within a TabProvider");
  }
  return context;
};

type GroupProps = {
  children: React.ReactNode;
  activeIndex?: number;
};

const Group = ({
  activeIndex: defaultActiveIndex = 0,
  children,
}: GroupProps) => {
  const [activeIndex, setActiveIndex] = useState(defaultActiveIndex);
  const titleId = `tab-title-${useId()}`;
  const tabIdPrefix = `tab-${useId()}`;
  const panelIdPrefix = `panel-${useId()}`;

  return (
    <TabContext.Provider
      value={{
        activeIndex,
        setActiveIndex,
        titleId,
        tabIdPrefix,
        panelIdPrefix,
      }}
    >
      {children}
    </TabContext.Provider>
  );
};

const Title = ({ children }: { children: React.ReactNode }) => {
  const { titleId } = useTabContext();

  return <div id={titleId}>{children}</div>;
};

const useKeyboardNavigation = (
  tabListRef: React.RefObject<HTMLDivElement>,
  TabCount: number
) => {
  const { setActiveIndex, tabIdPrefix } = useTabContext();
  const focusTab = (index: number) => {
    const tab = tabListRef.current?.querySelector(
      `[id="${tabIdPrefix}-${index}"]`
    );
    if (tab) {
      (tab as HTMLElement).focus();
    }
  };

  const handleKeyDown = (event: KeyboardEvent) => {
    switch (event.key) {
      case "ArrowLeft":
        event.preventDefault();
        setActiveIndex((prevIndex: number) => {
          if (prevIndex === 0) {
            const nextIndex = TabCount - 1;
            focusTab(nextIndex);
            return nextIndex;
          } else {
            const nextIndex = prevIndex - 1;
            focusTab(nextIndex);
            return nextIndex;
          }
        });

        break;
      case "ArrowRight":
        event.preventDefault();
        setActiveIndex((prevIndex: number) => {
          if (prevIndex === TabCount - 1) {
            const nextIndex = 0;
            focusTab(nextIndex);
            return nextIndex;
          } else {
            const nextIndex = prevIndex + 1;
            focusTab(nextIndex);
            return nextIndex;
          }
        });
        break;
      default:
        break;
    }
  };
  useEffect(() => {
    const tabList = tabListRef.current;
    tabList?.addEventListener("keydown", handleKeyDown);

    return () => {
      tabList?.removeEventListener("keydown", handleKeyDown);
    };
  }, [tabListRef, handleKeyDown]);
};

const List = ({ children }: { children: React.ReactNode }) => {
  const { titleId } = useTabContext();
  const tabListRef = useRef<HTMLDivElement>(null);
  useKeyboardNavigation(tabListRef, Children.count(children));

  return (
    <div role="tablist" aria-labelledby={titleId} ref={tabListRef}>
      {Children.map(children, (child, index) => {
        return React.cloneElement(child as React.ReactElement, { index });
      })}
    </div>
  );
};

const PanelList = ({ children }: { children: React.ReactNode }) => {
  return (
    <div>
      {Children.map(children, (child, index) => {
        return React.cloneElement(child as React.ReactElement, { index });
      })}
    </div>
  );
};

type TabProps = {
  children: React.ReactNode;
  index?: number;
};

const Pabel = ({ children, index }: TabProps) => {
  const { activeIndex, tabIdPrefix, panelIdPrefix } = useTabContext();
  if (index === undefined) {
    throw new Error("Tab must have an index prop");
  }

  return (
    <div
      role="tabpanel"
      aria-labelledby={`${tabIdPrefix}-${index}`}
      id={`${panelIdPrefix}-${index}`}
      className={index === activeIndex ? "" : "hidden"}
      tabIndex={0}
    >
      {children}
    </div>
  );
};

const Tab = ({ children, index }: TabProps) => {
  const { activeIndex, setActiveIndex, tabIdPrefix, panelIdPrefix } =
    useTabContext();

  if (index === undefined) {
    throw new Error("Tab must have an index prop");
  }

  return (
    <button
      role="tab"
      aria-controls={`${panelIdPrefix}-${index}`}
      aria-selected={index === activeIndex}
      id={`${tabIdPrefix}-${index}`}
      onClick={() => setActiveIndex(index)}
      tabIndex={index === activeIndex ? 0 : -1}
    >
      {children}
    </button>
  );
};

Tab.Group = Group;
Tab.Title = Title;
Tab.List = List;
Tab.Pabel = Pabel;
Tab.PanelList = PanelList;

export default Tab;

Tab コンポーネントは Compound Components として実装しています。Compound Components とはコンポーネントが肘する状態を暗黙的に子のコンポーネントに共有するパターンです。コンポーネントを利用する側は次のように使用します。

function App() {
  return (
    <Tab.Group>
      <Tab.Title>タブテスト</Tab.Title>
      <Tab.List>
        <Tab>タブ1</Tab>
        <Tab>タブ2</Tab>
        <Tab>タブ3</Tab>
      </Tab.List>
      <Tab.PanelList>
        <Tab.Pabel>タブ1の内容</Tab.Pabel>
        <Tab.Pabel>タブ2の内容</Tab.Pabel>
        <Tab.Pabel>タブ3の内容</Tab.Pabel>
      </Tab.PanelList>
    </Tab.Group>
  );
}

<Tab.Group>

それではそれぞれの要素にフォーカスして実装を確認してみましょう。<Tab.Group> コンポーネントはすべてのコンポーネント親要素として使われ、主に ContextProvide する目的で使用されます。

type TabContextType = {
  activeIndex: number;
  setActiveIndex: React.Dispatch<React.SetStateAction<number>>;
  titleId: string;
  tabIdPrefix: string;
  panelIdPrefix: string;
};

const TabContext = createContext<TabContextType | null>(null);

const useTabContext = () => {
  const context = useContext(TabContext);
  if (!context) {
    throw new Error("TabContext must be used within a TabProvider");
  }
  return context;
};

type GroupProps = {
  children: React.ReactNode;
  activeIndex?: number;
};

const Group = ({
  activeIndex: defaultActiveIndex = 0,
  children,
}: GroupProps) => {
  const [activeIndex, setActiveIndex] = useState(defaultActiveIndex);
  const titleId = `tab-title-${useId()}`;
  const tabIdPrefix = `tab-${useId()}`;
  const panelIdPrefix = `panel-${useId()}`;

  return (
    <TabContext.Provider
      value={{
        activeIndex,
        setActiveIndex,
        titleId,
        tabIdPrefix,
        panelIdPrefix,
      }}
    >
      {children}
    </TabContext.Provider>
  );
};

現在アクティブなタブを設定するために、タブのインデックスを使用して管理しています。またアクセシビリティ上の要件から tab 要素と tabpanel 要素にはユニークな ID を付与する必要があるので、ここで useId() でユニークな ID となるように生成しています。

titleIdtablist 要素に aria-labelledby でアクセシブルな名前を付与するために使用しています。

<Tab.Title>

<Tab.Title> は前述のとおり tablist にアクセシブルな名前を付与するための要素で、特別な実装はありません。

const Title = ({ children }: { children: React.ReactNode }) => {
  const { titleId } = useTabContext();

  return <div id={titleId}>{children}</div>;
};

<Tab.List>

<Tab.List> はタブの一覧をまとめる要素です。この要素には tablist ロールを付与するのと、aria-labelledby でアクセシブルな名前を付与する必要があります。

const List = ({ children }: { children: React.ReactNode }) => {
  const { titleId } = useTabContext();
  const tabListRef = useRef<HTMLDivElement>(null);
  useKeyboardNavigation(tabListRef, Children.count(children));

  return (
    <div role="tablist" aria-labelledby={titleId} ref={tabListRef}>
      {Children.map(children, (child, index) => {
        return React.cloneElement(child as React.ReactElement, { index });
      })}
    </div>
  );
};

また <Tab.List> の直近の子要素に <Tab> 要素が来る想定で実装しています。<Tab> 要素は自身のインデックスを知る必要があるので、Children.map で子要素を配列として扱いインデックスを振り分けるようにしています。

タブリストではキーボード操作を制御する必要があるので、useKeyboardNavigation フックでキーボード操作を制御しています。

const useKeyboardNavigation = (
  tabListRef: React.RefObject<HTMLDivElement>,
  TabCount: number
) => {
  const { setActiveIndex, tabIdPrefix } = useTabContext();
  const focusTab = (index: number) => {
    const tab = tabListRef.current?.querySelector(
      `[id="${tabIdPrefix}-${index}"]`
    );
    if (tab) {
      (tab as HTMLElement).focus();
    }
  };

  const handleKeyDown = (event: KeyboardEvent) => {
    switch (event.key) {
      case "ArrowLeft":
        event.preventDefault();
        setActiveIndex((prevIndex: number) => {
          if (prevIndex === 0) {
            const nextIndex = TabCount - 1;
            focusTab(nextIndex);
            return nextIndex;
          } else {
            const nextIndex = prevIndex - 1;
            focusTab(nextIndex);
            return nextIndex;
          }
        });

        break;
      case "ArrowRight":
        event.preventDefault();
        setActiveIndex((prevIndex: number) => {
          if (prevIndex === TabCount - 1) {
            const nextIndex = 0;
            focusTab(nextIndex);
            return nextIndex;
          } else {
            const nextIndex = prevIndex + 1;
            focusTab(nextIndex);
            return nextIndex;
          }
        });
        break;
      default:
        break;
    }
  };
  useEffect(() => {
    const tabList = tabListRef.current;
    tabList?.addEventListener("keydown", handleKeyDown);

    return () => {
      tabList?.removeEventListener("keydown", handleKeyDown);
    };
  }, [tabListRef, handleKeyDown]);
};

handleKeyDown 関数でキーボード操作をハンドリングしています。 キーがクリックされたときは case "ArrowLeft です。前回のインデックスが 0 の場合にはタブリストの最初のタブなので、TabCount - 1 つまり最後のタブのインデックスをアクティブにし、focusTab 関数で対象のタブにフォーカスしています。それ以外の場合は - 1 して同様のことを行います。

キーがクリックされたときは case "ArrowRight です。ここで行っていることは キーがクリックされたときとほぼ変わりません。前回のインデックスが TabCount - 1 の時はタブリストの最後のタブなので最初のタブのインデックス =0 に移動します。

<Tab.PanelList>

<Tab.PanelList>tablist の一覧をまとめる要素です。<Tab.List> と同様に <Tab.Panel> 要素にインデックスを振り分ける目的で存在します。

const PanelList = ({ children }: { children: React.ReactNode }) => {
  return (
    <div>
      {Children.map(children, (child, index) => {
        return React.cloneElement(child as React.ReactElement, { index });
      })}
    </div>
  );
};

<Tab>

<Tab> はタブ要素を担当します。Props の index<Tab.TabList> から与えられる想定です。

const Tab = ({ children, index }: TabProps) => {
  const { activeIndex, setActiveIndex, tabIdPrefix, panelIdPrefix } =
    useTabContext();

  if (index === undefined) {
    throw new Error("Tab must have an index prop");
  }

  return (
    <button
      role="tab"
      aria-controls={`${panelIdPrefix}-${index}`}
      aria-selected={index === activeIndex}
      id={`${tabIdPrefix}-${index}`}
      onClick={() => setActiveIndex(index)}
      tabIndex={index === activeIndex ? 0 : -1}
    >
      {children}
    </button>
  );
};

タブがクリックされたときの動作を簡単に処理するためにタブには <button> 要素を使用しています。アクセシビリティの要件通りに role="tab",aria-controls,aria-selected 属性を付与します。自身の ID は ${tabIdPrefix}-${index} という形式で割り当てます。

現在アクティブなタブを視覚的に表示するために、次のような CSS を定義しています。

[role="tab"][aria-selected="true"] {
  background-color: snow;
  border-bottom-color: snow;
}

また現在アクティブなタブ以外のはフォーカスが当たらないようにする必要があります。そのため、現在アクティブなタブ以外には tabIndex="-1" になるように設定してキーボード操作からはフォーカスが当たらないようにしています。

<Tab.Panel>

<Tab.Panel>tabpanel 要素を担当します。Props のインデックスは <Tab.PanelList> から割り当てられる想定です。

const Pabel = ({ children, index }: TabProps) => {
  const { activeIndex, tabIdPrefix, panelIdPrefix } = useTabContext();
  if (index === undefined) {
    throw new Error("Tab must have an index prop");
  }

  return (
    <div
      role="tabpanel"
      aria-labelledby={`${tabIdPrefix}-${index}`}
      id={`${panelIdPrefix}-${index}`}
      className={index === activeIndex ? "" : "hidden"}
      tabIndex={0}
    >
      {children}
    </div>
  );
};

アクセシビリティの要件通りに role="tabpanel",aria-labelledby を設定しています。現在アクティブなではない場合コンテンツを非表示にするため hidden クラスを付与しています。このクラスは次のように CSS で定義されています。

.hidden {
  display: none;
}

またそれぞれのタブパネルは現在アクティブなタブにおいて Tab キーが押されたときにフォーカスが移るように実装する必要があります。そのため、tabpanel 要素には tabIndex="0" を付与しています。

参考

Contributors

> GitHub で修正を提案する
この記事をシェアする
Twitterで共有
Hatena

関連記事