概要
Zustand Test の TabBar は最小実装でも動きますが、a11y を満たすにはもう一段 必要です。WAI-ARIA の Tabs パターンに沿って次の 4 点を整えます。
| 項目 | 役割 |
|---|---|
role の三役 | tablist / tab / tabpanel を正しく当てる |
aria-selected | 現在のタブを補助技術に伝える |
aria-controls / id | タブ ↔ パネルを id で紐付ける |
| キーボード操作 | ←→ で移動、Home / End で端、Enter / Space で確定 |
state は uiStore 編 と同じ activeTab / setTab をそのまま使います。
インストール
npm install zustand
role の三役を当てる
<nav> に role="tablist"、<button> に role="tab"、表示エリアに role="tabpanel" を付けます。<button> は デフォルトでフォーカス可能 なので追加属性は要りません。
// src/components/tabs/TabBar.tsx
"use client";
import { useUiStore, type Tab } from "@/store/uiStore";
const tabs: ReadonlyArray<{ id: Tab; label: string }> = [
{ id: "form", label: "フォーム" },
{ id: "detail", label: "詳細" },
{ id: "result", label: "結果表示" },
];
export function TabBar() {
const activeTab = useUiStore((s) => s.activeTab);
const setTab = useUiStore((s) => s.setTab);
return (
<div role="tablist" aria-label="設定タブ" className="flex gap-2 border-b">
{tabs.map((t) => (
<button
key={t.id}
role="tab"
id={`tab-${t.id}`}
aria-selected={activeTab === t.id}
aria-controls={`panel-${t.id}`}
tabIndex={activeTab === t.id ? 0 : -1}
onClick={() => setTab(t.id)}
className={activeTab === t.id ? "font-bold" : ""}
>
{t.label}
</button>
))}
</div>
);
}
ポイントは tabIndex={activeTab === t.id ? 0 : -1}。Tabs パターンでは tab 一覧の中で「フォーカス可能なのは現在のタブだけ」 にし、矢印キーで明示的に移動する仕様です。
tabpanel 側の id 紐付け
role="tabpanel" の側にも id と aria-labelledby を付けて、対応するタブと結びつけます。
// src/components/tabs/TabPanels.tsx
"use client";
import { useUiStore } from "@/store/uiStore";
import { FormTab } from "./FormTab";
import { DetailTab } from "./DetailTab";
import { ResultTab } from "./ResultTab";
export function TabPanels() {
const activeTab = useUiStore((s) => s.activeTab);
return (
<>
<section
role="tabpanel"
id="panel-form"
aria-labelledby="tab-form"
hidden={activeTab !== "form"}
>
<FormTab />
</section>
<section
role="tabpanel"
id="panel-detail"
aria-labelledby="tab-detail"
hidden={activeTab !== "detail"}
>
<DetailTab />
</section>
<section
role="tabpanel"
id="panel-result"
aria-labelledby="tab-result"
hidden={activeTab !== "result"}
>
<ResultTab />
</section>
</>
);
}
非表示タブは DOM から削除する か hidden 属性で隠す かを選べます。hidden を使うと、フォーム入力中などに状態が保持されたまま切り替えられます。
キーボード操作を実装する
矢印キーでアクティブを移動、Home / End で両端、Enter / Space で確定する挙動を onKeyDown で書きます。
"use client";
import { type KeyboardEvent, useRef } from "react";
import { useUiStore, type Tab } from "@/store/uiStore";
const order: Tab[] = ["form", "detail", "result"];
export function TabBar() {
const activeTab = useUiStore((s) => s.activeTab);
const setTab = useUiStore((s) => s.setTab);
const refs = useRef<Record<Tab, HTMLButtonElement | null>>({
form: null, detail: null, result: null,
});
const handleKey = (e: KeyboardEvent<HTMLButtonElement>) => {
const idx = order.indexOf(activeTab);
let next: Tab | null = null;
if (e.key === "ArrowRight") next = order[(idx + 1) % order.length];
if (e.key === "ArrowLeft") next = order[(idx - 1 + order.length) % order.length];
if (e.key === "Home") next = order[0];
if (e.key === "End") next = order[order.length - 1];
if (e.key === "Enter" || e.key === " ") {
// クリック扱い。aria-selected はすでに activeTab に追従済み
return;
}
if (next) {
e.preventDefault();
setTab(next);
refs.current[next]?.focus();
}
};
return (
<div role="tablist" aria-label="設定タブ" className="flex gap-2 border-b">
{order.map((id) => (
<button
key={id}
ref={(el) => { refs.current[id] = el; }}
role="tab"
id={`tab-${id}`}
aria-selected={activeTab === id}
aria-controls={`panel-${id}`}
tabIndex={activeTab === id ? 0 : -1}
onClick={() => setTab(id)}
onKeyDown={handleKey}
>
{/* label は省略 */}
</button>
))}
</div>
);
}
矢印キーで移動したあと、focus() も明示的に当てる のが要点です。tabIndex は setTab 経由で activeTab が変わると自動で追従しますが、フォーカスは React が動かしてくれません。
hidden と display:none の違い
タブパネルを hidden 属性で隠した場合、DOM に残ったまま CSS の display: none になります。子コンポーネントの state は保持されます。
完全に破棄したい ケース(重い再レンダリングを避けたい・初期化からやり直したい)は条件分岐で出し分けます。
{activeTab === "form" && <FormTab />}
Playground の場合、フォーム入力中の値を保持したいので hidden を選んでいます。
自動切り替え (manual / automatic)
WAI-ARIA Tabs には 「フォーカスが当たったら自動でアクティブを切り替える」 モード (automatic activation) もあります。Playground ではタブごとに副作用があり得るため、Enter / Space で確定する manual モード を採用しています。
矢印キー単体では setTab を呼ばず、Enter / Space で確定する形に変えるなら、上のコードの Enter / Space 分岐で setTab(focusedTab) を呼ぶだけで切り替えられます。
ポイント
roleはtablist/tab/tabpanelの三役。<nav>ではなく<div role="tablist">でも構わない- 現在のタブだけ
tabIndex={0}、他はtabIndex={-1}にし、矢印キーで明示的に移動 させる aria-selectedを boolean で当て、id↔aria-controls↔aria-labelledbyで タブとパネルを紐付け る- 矢印キー移動後は
setTabだけでなくref.focus()を 明示的に呼ぶ - パネルの非表示は
hidden属性で state 保持、条件分岐で 破棄。要件で使い分ける
関連記事
セットで読むと文脈がつながる関連記事は次のとおりです。
- uiStore 編 —
activeTab/setTabの最小設計 - エラー UI / aria 編 — フォーム側の a11y 対応
- アクション受け渡し編 —
setTabを子コンポーネントに渡すパターン - Zustand Test (Build) — 完成例と外部デモ