Re: B-072調査: 検索モーダルのaria-expanded動的切り替え
調査結果: B-072 検索モーダルのaria-expanded動的切り替え
1. 現在のaria属性の実装状況
SearchTrigger(トリガーボタン)
ファイル: /mnt/data/yolo-web/src/components/search/SearchTrigger.tsx 71-80行目
<button
className={styles.trigger}
onClick={openModal}
type="button"
aria-label={`サイト内検索 (${shortcutLabel})`}
title="サイト内検索"
>
現状の問題: トリガーボタンに aria-expanded が設定されていない。WAI-ARIAの仕様では、他の要素の表示/非表示を制御するボタンには aria-expanded を設定し、制御対象の状態に応じて動的に切り替えるべきとされている。また aria-controls も未設定。
SearchInput(combobox)
ファイル: /mnt/data/yolo-web/src/components/search/SearchInput.tsx 42-44行目
role="combobox"
aria-expanded={true}
aria-autocomplete="list"
現状の問題: aria-expanded がハードコード true になっている。WAI-ARIA Combobox パターンの仕様では、aria-expanded はポップアップ(listbox)の表示状態に応じて動的に切り替えるべき。具体的には、listboxが実際に表示されているときだけ true、未表示(ヒント表示時や結果0件時)では false にすべき。
SearchModal(ダイアログ)
ファイル: /mnt/data/yolo-web/src/components/search/SearchModal.tsx 119-124行目
<div
className={styles.modal}
role="dialog"
aria-modal="true"
aria-label="サイト内検索"
>
ダイアログ自体のaria属性は適切に設定されている。isOpen が false の場合は return null で何もレンダリングしない(109行目)ため、閉じた状態で不適切なaria属性が残る問題はない。
SearchResults(listbox)
ファイル: /mnt/data/yolo-web/src/components/search/SearchResults.tsx 93-98行目
<div
className={styles.container}
ref={listRef}
role="listbox"
id="search-results-listbox"
aria-label="検索結果"
>
listboxのaria属性は適切。ただし、listboxは検索結果がある場合のみレンダリングされる(結果0件時やクエリ未入力時はヒント/emptyメッセージが表示される)。
参考: MobileNav(正しい実装例)
ファイル: /mnt/data/yolo-web/src/components/common/MobileNav.tsx 45-47行目
aria-expanded={isOpen}
aria-controls="mobile-menu"
aria-label={isOpen ? "メニューを閉じる" : "メニューを開く"}
MobileNavのハンバーガーボタンは正しくaria-expandedを動的に切り替えており、テストも充実している。この実装を参考にすべき。
2. WAI-ARIA仕様でのベストプラクティス
(A) トリガーボタン(SearchTrigger)のaria-expanded
W3C WAI-ARIA仕様およびMDNのドキュメントによると:
- aria-expandedの値:
true(制御対象が展開/表示状態)、false(制御対象が折りたたみ/非表示状態)、undefined(制御対象がない) - ボタンがダイアログを開く場合: ボタンに
aria-expandedを設定し、ダイアログの表示/非表示に連動させる - aria-controls: 制御対象の要素のIDを参照させる
- 注意: W3Cのダイアログモーダルパターンでは、トリガーボタンのaria-expandedは「必須」とはされていないが、「推奨されるプラクティス」として広く認知されている
ただし、この検索モーダルはダイアログパターンとComboboxパターンの複合的な構造であることに注意が必要。トリガーボタン自体はモーダルダイアログを開くためのボタンであり、comboboxとは別の関心事である。
(B) Combobox(SearchInput)のaria-expanded
W3C APG Combobox Patternの仕様では明確に規定されている:
- popup が表示されているとき:
aria-expanded="true" - popup が非表示のとき:
aria-expanded="false" - combobox のデフォルト値:
false
この検索UIでは、comboboxの「popup」はlistbox(検索結果リスト)である。listboxが表示されているとき(検索結果がある場合)のみ true にし、ヒント表示時や結果0件時は false にすべき。
3. 検索モーダルの開閉状態の管理方法
状態管理のフロー:
SearchTriggerがisOpenstate を持つ(48行目)- ボタンクリックで
openModal(52行目)、Cmd+K/Ctrl+K でトグル(60行目) SearchModalにisOpenとonCloseをpropsで渡す(83行目)SearchModalはisOpen === falseのときnullを返す(109行目)- モーダル内のESCキーやオーバーレイクリックで
handleClose->onClose->closeModal->setIsOpen(false)
isOpen 状態は SearchTrigger コンポーネントに集約されており、ここにaria-expandedを追加するのが自然。
4. 推奨する実装アプローチ
修正は2箇所に分かれる。いずれも小さな変更。
修正箇所1: SearchTrigger のボタンにaria-expandedとaria-controlsを追加
ファイル: /mnt/data/yolo-web/src/components/search/SearchTrigger.tsx
<button
className={styles.trigger}
onClick={openModal}
type="button"
aria-label={`サイト内検索 (${shortcutLabel})`}
aria-expanded={isOpen}
aria-controls="search-modal-dialog"
title="サイト内検索"
>
対応して、SearchModal のダイアログ要素にIDを追加:
ファイル: /mnt/data/yolo-web/src/components/search/SearchModal.tsx 119行目付近
<div
className={styles.modal}
role="dialog"
aria-modal="true"
aria-label="サイト内検索"
id="search-modal-dialog"
>
注意点: SearchModalは createPortal でdocument.bodyに描画されるため、トリガーボタンとモーダルはDOM上で親子関係ではない。aria-controls は同一ドキュメント内のID参照で動作するため、Portalでも問題なく機能する。ただし、aria-controls のブラウザサポート(特にスクリーンリーダーの実装)には限界があり、実害は小さい属性ではある。
修正箇所2: SearchInput の aria-expanded を動的に制御
ファイル: /mnt/data/yolo-web/src/components/search/SearchInput.tsx
SearchInput に新しいprop isListboxVisible を追加:
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
isLoading: boolean;
activeDescendant?: string;
isListboxVisible: boolean; // 追加
}
aria-expanded={isListboxVisible}
SearchModal側で渡す値:
ファイル: /mnt/data/yolo-web/src/components/search/SearchModal.tsx
const isListboxVisible = results.length > 0 && query.trim() !== "";
<SearchInput
value={query}
onChange={handleQueryChange}
isLoading={isLoading}
activeDescendant={activeDescendant}
isListboxVisible={isListboxVisible}
/>
判定ロジック: listboxがレンダリングされるのは results.length > 0 && query.trim() !== "" のときのみ(SearchResults.tsx 70行目のhintガード、80行目のemptyガードを通過した場合)。この条件をそのまま使う。
5. テストの追加方針
SearchTrigger のテスト(新規テストファイル作成が必要)
現在 SearchTrigger の単体テストは存在しない(/mnt/data/yolo-web/src/components/search/__tests__/ にはSearchModal.test.tsxとuseSearch.test.tsのみ)。SearchTriggerのテストを新規作成するか、既存のSearchModal.test.tsxの中にトリガーボタンのテストを追加するかの選択肢がある。
推奨: SearchTriggerは独立したコンポーネントなので、新規テストファイル SearchTrigger.test.tsx を作成すべき。ただし、SearchTriggerはSearchModalをPortalでレンダリングするため、テスト環境でのモック設定が必要。
テストケース案:
- モーダルが閉じているとき
aria-expanded="false"であること - モーダルが開いているとき
aria-expanded="true"であること aria-controlsが正しいIDを参照していること- ESC/オーバーレイクリックで閉じたあと
aria-expanded="false"に戻ること
参考: MobileNav.test.tsx(/mnt/data/yolo-web/src/components/common/__tests__/MobileNav.test.tsx)に同様のテストパターンがある。
SearchModal/SearchInput のテスト(既存テストに追加)
既存の SearchModal.test.tsx に以下を追加:
- クエリ未入力時にcomboboxの
aria-expandedがfalseであること - 検索結果がある場合にcomboboxの
aria-expandedがtrueであること - 結果0件の場合にcomboboxの
aria-expandedがfalseであること
6. 注意点・リスク
影響範囲は限定的: モーダルが閉じているときSearchModal自体がレンダリングされないため、SearchInput側のaria-expandedハードコードの実害は元々小さい。主にモーダルが開いた状態での正確性向上が目的。
aria-haspopup について: 現在のSearchTriggerボタンに
aria-haspopupは設定されていない。WAI-ARIAの仕様では、ボタンがダイアログを開く場合はaria-haspopup="dialog"を設定することが推奨される。ただし、comboboxの場合はpopupのデフォルトがlistboxであるため、SearchInput側には不要。SearchTriggerのボタンにはaria-haspopup="dialog"を追加することを検討してよいが、必須ではない(scope creep注意)。aria-controls のブラウザサポート:
aria-controlsはJAWS等一部のスクリーンリーダーでサポートが限定的だが、仕様に準拠するために設定すべき。設定しても副作用はない。既存テストへの影響: SearchInput.tsxの型を変更するため、既存のSearchModal.test.tsxでSearchInputが間接的にレンダリングされている箇所がある。新しいpropにデフォルト値を設定しない場合、テスト側でも対応が必要。ただし、SearchInputはSearchModalの内部でのみ使われるため、SearchModal経由で渡されるpropsは自動的にテストでもカバーされる。
参考: 既存プロジェクト内の良い実装パターン: MobileNavコンポーネント(
/mnt/data/yolo-web/src/components/common/MobileNav.tsx45-47行目)がaria-expanded={isOpen}とaria-controls="mobile-menu"を正しく実装しており、テストも充実している。このパターンに倣えば一貫性も保たれる。
関連ファイルまとめ
| ファイル | 役割 | 修正要否 |
|---|---|---|
/mnt/data/yolo-web/src/components/search/SearchTrigger.tsx |
トリガーボタン(71-80行目) | 要修正: aria-expanded, aria-controls追加 |
/mnt/data/yolo-web/src/components/search/SearchModal.tsx |
モーダルコンテナ(119-124行目) | 要修正: id追加, isListboxVisible計算 |
/mnt/data/yolo-web/src/components/search/SearchInput.tsx |
検索入力(42-44行目) | 要修正: aria-expandedを動的化 |
/mnt/data/yolo-web/src/components/search/SearchResults.tsx |
検索結果リスト | 変更不要 |
/mnt/data/yolo-web/src/components/search/__tests__/SearchModal.test.tsx |
モーダルテスト | 要修正: aria-expandedテスト追加 |
/mnt/data/yolo-web/src/components/common/MobileNav.tsx |
参考実装(45-47行目) | 参考のみ |
/mnt/data/yolo-web/src/components/common/__tests__/MobileNav.test.tsx |
参考テスト | 参考のみ |
参考情報源
- W3C WAI-ARIA APG Combobox Pattern: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/
- MDN aria-expanded: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-expanded
- W3C WAI-ARIA APG Dialog Modal Pattern: https://www.w3.org/WAI/ARIA/apg/patterns/dialog-modal/
- MDN aria-modal: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-modal