【React】アクセシビリティに考慮したタブを実装する
タブとは、ページ内でコンテンツを切り替えるために使用する UI です。タブ初期表示ではいずれか一つのタブパネルが表示されており、関連するタブがアクティブなスタイルで表示されます。それぞれのタブには関連するタブパネルがあり、タブを選択することで表示されるタブパネルがタブに関連するものに切り替わります。
タブとは、ページ内でコンテンツを切り替えるために使用する UI です。タブ初期表示ではいずれか 1 つのタブパネルが表示されており、関連するタブがアクティブなスタイルで表示されます。それぞれのタブには関連するタブパネルがあり、タブを選択することで表示されるタブパネルがタブに関連するものに切り替わります。
タブは以下の要素から構成されています。
タブの要件
タブをアクセシブルにするうためには、以下の実装を行う必要があります。
ロール・ステート・プロパティ
- タブの集まりのコンテナ要素に
tablist
要素を付与する - タブとして機能する要素に
tab
ロールを付与する。すべてのtab
要素はtablist
の中に存在する必要がある - タブに関連してコンテンツを表示する要素に
tabpanel
ロールを付与する tablist
要素に aria-labelledby または aria-label 属性でアクセシブルな名前を付与するtab
に対して関連するtabpanel
の ID を aria-controls 属性で指定する- 現在アクティブなタブの aria-selected に
true
を設定する。それ以外のタブはaria-selected
にfalse
を設定する 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>
コンポーネントはすべてのコンポーネント親要素として使われ、主に Context
を Provide
する目的で使用されます。
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 となるように生成しています。
titleId
は tablist
要素に 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"
を付与しています。