Angularの@angular/ariaがある前提で自作listboxをレビューする観点
Angularの@angular/ariaがある前提で自作listboxをレビューする観点
選択肢の一覧を自作する作業は、<ul> に role="listbox" を書いた時点では半分も終わっていない。矢印キーでの移動、Home と End、先頭文字での検索、focus の持ち方、選択範囲の拡張、項目が消えたときの後始末が残る。Angular 22.2 の時点では、この部分を持つパッケージが @angular/aria として配布されている。
accordion / combobox / grid / listbox / menu / tabs / toolbar / tree
(各サブパスに /testing が対になっている。ほかに . と ./private) peer は @angular/cdk 22.2.1 と @angular/core ^22.0.0 || ^23.0.0 である。自作の一覧をレビューするとき、比較の対象は「ARIA の仕様」ではなく、この8つが実際に持っている処理になる。
ホスト要素に付く属性と購読
ngListbox が自分で書き込む内容は、ディレクティブのホスト定義に全部ある。
host: {
'role': 'listbox',
'[attr.id]': 'id()',
'[attr.tabindex]': 'tabIndex() !== undefined ? tabIndex() : _pattern.tabIndex()',
'[attr.aria-readonly]': '_pattern.readonly()',
'[attr.aria-disabled]': '_pattern.disabled()',
'[attr.aria-orientation]': '_pattern.orientation()',
'[attr.aria-multiselectable]': '_pattern.multi()',
'[attr.aria-activedescendant]': '_pattern.activeDescendant()',
'(keydown)': '_pattern.onKeydown($event)',
'(click)': '_pattern.onClick($event)',
'(focusin)': '_pattern.onFocusIn()'
}ngOption 側は role="option" と、id・tabindex・aria-selected・aria-disabled・data-active である。この一覧に無いものは自分で書く。たとえば aria-label と aria-labelledby はパッケージのどこにも現れないため、listbox 自身の名前付けは利用者の仕事になる(ngOption の label 入力は後述のとおり別の用途を持つ)。
jsdom 上で @angular/core 22.2.1 と組み合わせて描画すると、既定のまま次の属性が付く。
1) listbox host attrs: listbox | tabindex= -1 | aria-multiselectable= false
3) option[0] role/tabindex/aria-selected: option 0 true
4) option[1] tabindex/aria-selected: -1 falsetabindex がリスト側で -1、先頭の項目で 0 になっている。これが roving tabindex の形である。
既定値が初期状態を書き換える経路
上の出力で option[0] の aria-selected が true になっているのは、何も選択していないからではない。selectionMode の既定が 'follow' で、focus の移動に選択が追従する設定だからである。
2) 初期描画後の value: ["a"]value は model<V[]>([]) で、空配列を渡したにもかかわらず先頭の値が入っている。機構は afterRenderEffect に置かれた初期状態の設定である。
setDefaultState() {
let firstItem = null;
for (const item of this.inputs.items()) {
if (this.listBehavior.isFocusable(item)) {
if (!firstItem) { firstItem = item; }
if (item.selected()) { this.inputs.activeItem.set(item); return; }
}
}
if (firstItem) {
this.inputs.activeItem.set(firstItem);
if (this.followFocus()) { this.listBehavior.select(); }
}
}選択済みの項目があればそこを active にして戻る。無ければ先頭を active にし、followFocus() が真のときは選択まで行う。この経路は描画の後に走るので、親が持つ value の signal は、利用者が触っていないのに値を1つ持った状態になる。「未選択」を区別したい画面では selectionMode="explicit" を選ぶ判断が要る。レビューでは、既定のまま置いた listbox の初期値がフォームの送信内容に入っていないかを見る。
自作側に残る処理の量
キーボードの対応表は ListboxPattern.keydown に集まっている。selectionMode・multi・readonly の組み合わせで別の表を作る形で、単一選択かつ focus 追従の場合でも、前後の移動・Home・End・1文字の検索が入る。複数選択ではここに Shift 付きの範囲選択、Ctrl+A の全選択、Ctrl+Home と Ctrl+End が足される。
options = { ignoreRepeat: true, preventDefault: true, stopPropagation: true };一致した設定ごとに preventDefault() と stopPropagation() を呼ぶため、同じ要素の外側に自分の (keydown) を足しても矢印キーは届かない。移動の処理を独自コードで二重に持つ構成は、ここで噛み合わなくなる。
先頭文字の検索には前提がある。ngOption の検索語は label 入力そのものである。
_pattern = new OptionPattern({
...this,
id: this.id,
value: this.value,
listbox: this._listboxPattern,
element: () => this.element,
searchTerm: () => this.label() ?? ''
});label を書かなければ検索語は空文字列になり、searchTerm().toLowerCase().startsWith(query) が偽を返し続ける。実行して確かめると、打鍵しても何も起きない。
9) label 無しで b を打鍵した後の value: ["a"]警告は出ない。ngTreeItem の側は this.label() ?? this.element.textContent で要素の文字列に落ちるのに対し、ngOption にその代替は無い。自作と乗せ替えのどちらでも、表示している文字列と検索語が別管理になっていないかはレビューの対象になる。
roleを上書きしたときに外れる処理
role は属性の束縛ではなく静的なホスト属性である。同じ要素にテンプレート側で role を書くと、どちらが残るかは属性の合成順で決まる。
resolveDirectives(tView, lView, tNode, ..., directiveMatcher);
tNode.mergedAttrs = mergeHostAttrs(tNode.mergedAttrs, tNode.attrs);resolveDirectives の中でディレクティブの hostAttrs が mergedAttrs に積まれ、そのあとテンプレートの属性が合成される。mergeHostAttribute は同じキーを見つけると dst[i + 1] = value で値を置き換えるため、テンプレート側が後勝ちになる。実際に <li ngOption role="menuitem"> と書くと、描画後の属性は menuitem である。
5) role を上書きした listbox の option[0] role: menuitem
7) role を上書きした listbox をクリックした後の value2: ["a"]7行目は、3番目の項目をクリックしたあとの値である。初期状態の ["a"] から動いていない。クリックの処理が項目を引けないためで、該当するのは次の1行である。
const element = target.closest('[role="option"]');
return this.inputs.items().find(i => i.element() === element);項目の特定が role="option" のセレクタに依存している。role を別の値にすると、支援技術から見た役割だけでなく、ポインタ操作の経路も同時に外れる。型検査もテンプレートの検査も通り、開発モードの警告も出ない。role を書き換えている箇所を見つけたら、まず意図を聞く位置にある。
focusの持ち方と無効な項目
focus の形は focusMode で決まり、属性の値は ListFocus の3つのメソッドが返す。
| 状態 | リストの tabindex |
項目の tabindex |
aria-activedescendant |
|---|---|---|---|
roving(既定) |
-1 |
active な項目だけ 0、ほかは -1 |
付かない |
activedescendant |
0 |
全項目 -1 |
active な項目の id |
| リスト全体が無効 | 0 |
-1 |
付かない |
3行目の「リスト全体が無効」は、disabled を渡した場合と、全項目が disabled の場合の両方である(isListDisabled() が this.inputs.disabled() || this.inputs.items().every(i => i.disabled()))。リスト自身が tabindex="0" を保つので、読み上げの対象から外れずに残る。
無効な項目に focus を当てるかどうかは softDisabled が決め、既定は true である。つまり既定では無効な項目にも矢印で移動でき、選択だけができない。この既定は ARIA の慣行に沿っているが、自作のコードから乗せ替えるときに挙動が変わる箇所になる。
選択済みの項目が無効になっている場合は、単一選択が固まる。
5) disabled=q が選択済みの単一選択リストで ArrowDown 後の value: ["q"] / data-active: false,false,true
6) Space 後の value: ["q"]selectOne() は deselectAll() を呼んでから新しい値を入れるが、deselect() は無効な項目を飛ばす。残った値があると if (this.inputs.value().length > 0 && !this.inputs.multi()) return; で戻るため、別の項目を選ぶ操作が無効になる。無効化の条件と選択の初期値を同じデータから作っている画面では、この組み合わせが起きうる。
一覧が変わったときの選択の扱い
listbox は項目の増減を2つの afterRenderEffect で見ている。片方は active な項目が消えた場合に unfocus() と setDefaultState() を呼び、もう片方は値の配列から項目の無い値を取り除く。
afterRenderEffect({
write: () => {
const items = inputs.items();
const value = untracked(() => this.value());
if (items && value.some(v => !items.some(i => i.value() === v))) {
this.value.set(value.filter(v => items.some(i => i.value() === v)));
}
}
});絞り込みで選択中の項目が一覧から外れると、値はその場で捨てられる。さらに selectionMode="follow" のままなら、active の作り直しで別の項目が選択される。
10) 選択中の項目を一覧から外した後の value: ["b"]検索語を変えるだけで選択が別の値に移る挙動は、保存の操作と組み合わさると実害になる。自作の一覧でも同じ判断が要る論点で、乗せ替えたときに「勝手に直る」のではなく「勝手に動く」ほうを選んだことになる。項目の並びは SortedCollection が compareDocumentPosition と MutationObserver で DOM 順に保つため、テンプレートの記述順ではなく描画後の順序が基準になる。
@Reviewer: 絞り込みの入力と `ngListbox` の `[(value)]` が同じ画面の state を共有しているので、検索語を変えた時点で選択が別の値へ移ります。`ngListbox` は一覧から消えた値を `afterRenderEffect` で捨て、`selectionMode="follow"` の既定で次の項目を選び直します。選択を保持したいなら `selectionMode="explicit"` を指定し、絞り込みの対象から選択済みの項目を外さない形にしてください。開発モードだけで走る検査
ngListbox は3項目の自己検査を持つ。
validate() {
const violations = [];
if (!this.inputs.multi() && this.inputs.value().length > 1) {
violations.push(`A single-select listbox should not have multiple selected options. Selected options: ${this.inputs.value().join(', ')}`);
}
const values = this.inputs.items().map(o => o.value());
const duplicates = values.filter((val, idx) => values.indexOf(val) !== idx);
if (duplicates.length > 0) {
violations.push(`Duplicate option value '${duplicates[0]}' detected inside ngListbox.`);
}
const ids = this.inputs.items().map(o => o.id());
const duplicateIds = ids.filter((id, idx) => ids.indexOf(id) !== idx);
if (duplicateIds.length > 0) {
violations.push(`Duplicate option ID '${duplicateIds[0]}' detected inside ngListbox.`);
}
return violations;
}呼び出しは afterRenderEffect の中で、typeof ngDevMode === 'undefined' || ngDevMode に囲われている。出力は console.warn だけで、状態は直らない。
1) 開発モードの警告:
Violations found on element: %o: [el]
Duplicate option value 'a' detected inside ngListbox.
Violations found on element: %o: [el]
A single-select listbox should not have multiple selected options. Selected options: x, y
2) 単一選択に2値を渡した state: ["x","y"]本番ビルドではこの検査が走らないため、値の重複は読み上げと選択の対応が崩れた状態のまま配信される。「開発モードの警告だけで本番では無言」という形は、@ngrx/signals の重複メンバー検査や NG0950 のメッセージ組み立てと同じで、本シリーズで繰り返し現れる。警告の有無をレビューの代わりにしない根拠になる。
テストから見るときは、各サブパスの testing にハーネスがある。ListboxHarness は getOrientation・isMulti・isDisabled・getOptions・getActiveDescendantId を持ち、ListboxOptionHarness の isSelected は aria-selected 属性を読む。属性名を直接書いたセレクタでテストを組むと、ここで挙げた属性の決まり方(focusMode による tabindex の入れ替えなど)に追従できない。
レビュー観点チェックリスト
- listbox / combobox / menu / tabs / tree / grid / accordion / toolbar を手書きしていないか
- 手書きする判断の理由が、
@angular/ariaに無い要件として書けるか roleをテンプレート側で書き換えていないか(後勝ちで置き換わり、クリックの経路も外れる)- 矢印キー・Home・End の処理を独自コードで二重に持っていないか
ngOptionにlabelを渡しているか(無いと先頭文字の検索が無言で止まる)- listbox 自身の
aria-labelまたはaria-labelledbyを自分で付けているか selectionModeの既定(follow)が、初期描画でモデルに値を入れてよい画面かfocusModeの選択が、仮想スクロールや項目の入れ替えと噛み合っているかsoftDisabledの既定(true)で無効な項目に focus が当たる挙動を意図しているか- 選択済みの項目が無効になる経路があるか(単一選択が固まる)
- 絞り込みで選択中の値が消えるときの扱いが決まっているか
- テストを
testingのハーネス経由で書いているか
おわりに
@angular/aria に乗せると、キーボード操作と focus 管理と属性の更新は既定で揃う。代わりに、既定値がアプリの状態を書き換える経路が増える。初期描画で先頭が選択されるのは selectionMode の既定であり、絞り込みで選択が移るのは項目の増減を見る afterRenderEffect である。どちらも挙動としては妥当だが、画面の要件と一致するかは別に確かめることになる。
自作を続ける判断もありうる。その場合に比較の基準になるのは、ListboxPattern が持っている処理の一覧である。キーボードの表、typeahead の時間切れ、範囲選択の anchor、項目が消えたときの active の作り直し、無効な項目の扱い。自作のコードにこれらがどこまであるかを数えれば、議論は好みの話から外れる。
role の上書きだけは、どちらを選んでも避ける位置にある。テンプレートの属性がディレクティブのホスト属性を置き換える仕組みのうえに、項目の特定が role="option" のセレクタで書かれているためで、読み上げとポインタ操作が同時に外れる。
アクセシビリティの観点の全体はAngular 22のコンポーネント設計をレビュー視点で整理する100観点の「アクセシビリティと @angular/aria」の群に並べてある。@defer で遅延させた領域に対話要素を置く場合の判断はAngularの@deferが永久に読み込まれない条件をレビューで見抜くで扱った。