Listbox
ユーザーが選択肢のリストから1つまたは複数のアイテムを選択できるウィジェット。
デモ
単一選択(デフォルト)
選択はフォーカスに追従します。矢印キーでナビゲートして選択します。
- りんご
- バナナ
- さくらんぼ
- デーツ
- エルダーベリー
- いちじく
- ぶどう
複数選択
フォーカスと選択は独立しています。Space でトグル、Shift+矢印キーで選択範囲を拡張します。
- 赤
- オレンジ
- 黄
- 緑
- 青
- 藍
- 紫
ヒント: Space でトグル、Shift+矢印キーで選択拡張、Ctrl+A で全選択
水平方向
左/右矢印キーでナビゲーションします。
- りんご
- バナナ
- さくらんぼ
- デーツ
- エルダーベリー
アクセシビリティ
WAI-ARIA ロール
| ロール | 対象要素 | 説明 |
|---|---|---|
listbox | コンテナ(`
| リストから1つまたは複数のアイテムを選択するためのウィジェット |
option | 各アイテム(` | リストボックス内の選択可能なオプション |
WAI-ARIA listbox role (opens in new tab)
WAI-ARIA プロパティ
| 属性 | 対象 | 値 | 必須 | 説明 |
|---|---|---|---|---|
aria-label | listbox | String | はい* | リストボックスのアクセシブル名 |
aria-labelledby | listbox | ID reference | はい* | ラベル要素への参照 |
aria-multiselectable | listbox | `true` | いいえ | 複数選択モードを有効にする |
aria-orientation | listbox | `"vertical"` | `"horizontal"` | いいえ | ナビゲーションの方向(デフォルト: vertical) |
* aria-label または aria-labelledby のいずれかが必須
WAI-ARIA ステート
aria-selected
オプションが選択されているかどうかを示します。
| 対象 | option |
| 値 | `true` | `false` |
| 必須 | はい |
| 変更トリガー | クリック、矢印キー(単一選択)、Space(複数選択) |
| リファレンス | aria-selected (opens in new tab) |
aria-disabled
オプションが選択不可であることを示します。
| 対象 | option |
| 値 | `true` |
| 必須 | いいえ(無効時のみ) |
| 変更トリガー | 無効時のみ |
| リファレンス | aria-disabled (opens in new tab) |
キーボードサポート
共通ナビゲーション
| キー | アクション |
|---|---|
| Down Arrow / Up Arrow | フォーカスを移動(垂直方向) |
| Right Arrow / Left Arrow | フォーカスを移動(水平方向) |
| Home | 最初のオプションにフォーカスを移動 |
| End | 最後のオプションにフォーカスを移動 |
| Type character | 先行入力: 入力した文字で始まるオプションにフォーカス |
単一選択(選択がフォーカスに追従)
| キー | アクション |
|---|---|
| Arrow keys | フォーカスと選択を同時に移動 |
| Space / Enter | 現在の選択を確定 |
複数選択
| キー | アクション |
|---|---|
| Arrow keys | フォーカスのみ移動(選択は変更なし) |
| Space | フォーカス中のオプションの選択をトグル |
| Shift + Arrow | フォーカスを移動し選択範囲を拡張 |
| Shift + Home | アンカーから最初のオプションまで選択 |
| Shift + End | アンカーから最後のオプションまで選択 |
| Ctrl + A | すべてのオプションを選択 |
フォーカス管理
このコンポーネントは、フォーカス管理にローヴィングタブインデックスパターンを使用します:
- 常に1つのオプションのみが `tabindex="0"` を持つ(ローヴィングタブインデックス)
- 他のオプションは `tabindex="-1"` を持つ
- 矢印キーでオプション間のフォーカスを移動
- 無効化されたオプションはナビゲーション中にスキップされる
- フォーカスは端で折り返さない(端で停止)
選択モデル
- **単一選択:** 選択がフォーカスに追従(矢印キーで選択が変更される)
- **複数選択:** フォーカスと選択は独立(Spaceで選択をトグル)
ソースコード
Listbox.astro
---
/**
* APG Listbox Pattern - Astro Implementation
*
* A widget that allows the user to select one or more items from a list of choices.
*
* @see https://www.w3.org/WAI/ARIA/apg/patterns/listbox/
*/
export interface ListboxOption {
id: string;
label: string;
disabled?: boolean;
}
export interface Props {
/** Array of options */
options: ListboxOption[];
/** Enable multi-select mode */
multiselectable?: boolean;
/** Direction of the listbox */
orientation?: 'vertical' | 'horizontal';
/** Initially selected option IDs */
defaultSelectedIds?: string[];
/** Accessible label for the listbox */
'aria-label'?: string;
/** ID of element that labels the listbox */
'aria-labelledby'?: string;
/** Type-ahead timeout in ms */
typeAheadTimeout?: number;
/** Additional CSS class */
class?: string;
}
const {
options = [],
multiselectable = false,
orientation = 'vertical',
defaultSelectedIds = [],
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledby,
typeAheadTimeout = 500,
class: className = '',
} = Astro.props;
const instanceId = `listbox-${Math.random().toString(36).slice(2, 11)}`;
const initialSelectedSet = new Set(defaultSelectedIds);
// For single-select, if no default selection, select first available option
const availableOptions = options.filter((opt) => !opt.disabled);
if (!multiselectable && initialSelectedSet.size === 0 && availableOptions.length > 0) {
initialSelectedSet.add(availableOptions[0].id);
}
const containerClass =
`apg-listbox ${orientation === 'horizontal' ? 'apg-listbox--horizontal' : ''} ${className}`.trim();
function getOptionClass(option: ListboxOption): string {
const classes = ['apg-listbox-option'];
if (initialSelectedSet.has(option.id)) {
classes.push('apg-listbox-option--selected');
}
if (option.disabled) {
classes.push('apg-listbox-option--disabled');
}
return classes.join(' ');
}
// Find initial focus index
const initialFocusId = [...initialSelectedSet][0];
const initialFocusIndex = initialFocusId
? availableOptions.findIndex((opt) => opt.id === initialFocusId)
: 0;
// If no available options, listbox itself needs tabIndex for keyboard access
const listboxTabIndex = availableOptions.length === 0 ? 0 : undefined;
---
<apg-listbox
data-multiselectable={multiselectable ? 'true' : undefined}
data-orientation={orientation}
data-type-ahead-timeout={typeAheadTimeout}
data-initial-selected={JSON.stringify([...initialSelectedSet])}
data-initial-focus-index={initialFocusIndex}
>
<ul
role="listbox"
aria-multiselectable={multiselectable || undefined}
aria-orientation={orientation}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledby}
tabindex={listboxTabIndex}
class={containerClass}
>
{
options.map((option) => {
const availableIndex = availableOptions.findIndex((opt) => opt.id === option.id);
const isFocusTarget = availableIndex === initialFocusIndex;
const tabIndex = option.disabled ? -1 : isFocusTarget ? 0 : -1;
return (
<li
role="option"
id={`${instanceId}-option-${option.id}`}
data-option-id={option.id}
aria-selected={initialSelectedSet.has(option.id)}
aria-disabled={option.disabled || undefined}
tabindex={tabIndex}
class={getOptionClass(option)}
>
<span class="apg-listbox-option-icon" aria-hidden="true">
<svg viewBox="0 0 12 12" xmlns="http://www.w3.org/2000/svg">
<path
d="M10.28 2.28a.75.75 0 00-1.06-1.06L4.5 5.94 2.78 4.22a.75.75 0 00-1.06 1.06l2.25 2.25a.75.75 0 001.06 0l5.25-5.25z"
fill="currentColor"
/>
</svg>
</span>
{option.label}
</li>
);
})
}
</ul>
</apg-listbox>
<script>
class ApgListbox extends HTMLElement {
private listbox: HTMLElement | null = null;
private rafId: number | null = null;
private focusedIndex = 0;
private selectionAnchor = 0;
private selectedIds: Set<string> = new Set();
private typeAheadBuffer = '';
private typeAheadTimeoutId: number | null = null;
private observer: MutationObserver | null = null;
connectedCallback() {
this.rafId = requestAnimationFrame(() => this.initialize());
}
private initialize() {
this.rafId = null;
this.listbox = this.querySelector('[role="listbox"]');
if (!this.listbox) {
console.warn('apg-listbox: listbox element not found');
return;
}
// Initialize selected IDs from data attribute
const initialSelected = this.dataset.initialSelected;
if (initialSelected) {
try {
const ids = JSON.parse(initialSelected);
this.selectedIds = new Set(ids);
} catch {
this.selectedIds = new Set();
}
}
// Initialize focus index and anchor from data attribute
const initialFocusIndex = parseInt(this.dataset.initialFocusIndex || '0', 10);
this.focusedIndex = initialFocusIndex;
this.selectionAnchor = initialFocusIndex;
this.listbox.addEventListener('keydown', this.handleKeyDown);
this.listbox.addEventListener('click', this.handleClick);
this.listbox.addEventListener('focusin', this.handleFocus);
// Observe DOM changes
this.observer = new MutationObserver(() => this.updateTabIndices());
this.observer.observe(this.listbox, { childList: true, subtree: true });
this.updateTabIndices();
}
disconnectedCallback() {
if (this.rafId !== null) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
if (this.typeAheadTimeoutId !== null) {
clearTimeout(this.typeAheadTimeoutId);
this.typeAheadTimeoutId = null;
}
this.observer?.disconnect();
this.observer = null;
this.listbox?.removeEventListener('keydown', this.handleKeyDown);
this.listbox?.removeEventListener('click', this.handleClick);
this.listbox?.removeEventListener('focusin', this.handleFocus);
this.listbox = null;
}
private get isMultiselectable(): boolean {
return this.dataset.multiselectable === 'true';
}
private get orientation(): string {
return this.dataset.orientation || 'vertical';
}
private get typeAheadTimeout(): number {
return parseInt(this.dataset.typeAheadTimeout || '500', 10);
}
private getOptions(): HTMLLIElement[] {
if (!this.listbox) return [];
return Array.from(this.listbox.querySelectorAll<HTMLLIElement>('[role="option"]'));
}
private getAvailableOptions(): HTMLLIElement[] {
return this.getOptions().filter((opt) => opt.getAttribute('aria-disabled') !== 'true');
}
private updateTabIndices() {
const options = this.getAvailableOptions();
if (options.length === 0) return;
if (this.focusedIndex >= options.length) {
this.focusedIndex = options.length - 1;
}
options.forEach((opt, index) => {
opt.tabIndex = index === this.focusedIndex ? 0 : -1;
});
}
private updateSelection(optionId: string | null, action: 'toggle' | 'set' | 'range' | 'all') {
const options = this.getOptions();
if (action === 'all') {
const availableOptions = this.getAvailableOptions();
this.selectedIds = new Set(
availableOptions.map((opt) => opt.dataset.optionId).filter(Boolean) as string[]
);
} else if (action === 'range' && optionId) {
const availableOptions = this.getAvailableOptions();
const start = Math.min(this.selectionAnchor, this.focusedIndex);
const end = Math.max(this.selectionAnchor, this.focusedIndex);
for (let i = start; i <= end; i++) {
const opt = availableOptions[i];
if (opt?.dataset.optionId) {
this.selectedIds.add(opt.dataset.optionId);
}
}
} else if (optionId) {
if (this.isMultiselectable) {
if (this.selectedIds.has(optionId)) {
this.selectedIds.delete(optionId);
} else {
this.selectedIds.add(optionId);
}
} else {
this.selectedIds = new Set([optionId]);
}
}
// Update aria-selected and classes
options.forEach((opt) => {
const id = opt.dataset.optionId;
const isSelected = id ? this.selectedIds.has(id) : false;
opt.setAttribute('aria-selected', String(isSelected));
opt.classList.toggle('apg-listbox-option--selected', isSelected);
});
// Dispatch custom event
this.dispatchEvent(
new CustomEvent('selectionchange', {
detail: { selectedIds: [...this.selectedIds] },
bubbles: true,
})
);
}
private focusOption(index: number) {
const options = this.getAvailableOptions();
if (index >= 0 && index < options.length) {
this.focusedIndex = index;
this.updateTabIndices();
options[index].focus();
}
}
private handleTypeAhead(char: string) {
const options = this.getAvailableOptions();
// Guard: no options to search
if (options.length === 0) return;
if (this.typeAheadTimeoutId !== null) {
clearTimeout(this.typeAheadTimeoutId);
}
this.typeAheadBuffer += char.toLowerCase();
const buffer = this.typeAheadBuffer;
const isSameChar = buffer.length > 1 && buffer.split('').every((c) => c === buffer[0]);
let startIndex = this.focusedIndex;
if (isSameChar) {
this.typeAheadBuffer = buffer[0];
startIndex = (this.focusedIndex + 1) % options.length;
}
for (let i = 0; i < options.length; i++) {
const index = (startIndex + i) % options.length;
const option = options[index];
const label = option.textContent?.trim().toLowerCase() || '';
const searchStr = isSameChar ? buffer[0] : this.typeAheadBuffer;
if (label.startsWith(searchStr)) {
this.focusOption(index);
// Update anchor for shift-selection
this.selectionAnchor = index;
if (!this.isMultiselectable) {
const optionId = option.dataset.optionId;
if (optionId) {
this.updateSelection(optionId, 'set');
}
}
break;
}
}
this.typeAheadTimeoutId = window.setTimeout(() => {
this.typeAheadBuffer = '';
this.typeAheadTimeoutId = null;
}, this.typeAheadTimeout);
}
private handleClick = (event: MouseEvent) => {
const target = event.target as HTMLElement;
const option = target.closest('[role="option"]') as HTMLLIElement | null;
if (!option || option.getAttribute('aria-disabled') === 'true') return;
const options = this.getAvailableOptions();
const index = options.indexOf(option);
if (index === -1) return;
this.focusOption(index);
const optionId = option.dataset.optionId;
if (optionId) {
this.updateSelection(optionId, 'toggle');
this.selectionAnchor = index;
}
};
private handleFocus = (event: FocusEvent) => {
const options = this.getAvailableOptions();
const target = event.target as HTMLElement;
const targetIndex = options.findIndex((opt) => opt === target);
if (targetIndex !== -1 && targetIndex !== this.focusedIndex) {
this.focusedIndex = targetIndex;
this.updateTabIndices();
}
};
private handleKeyDown = (event: KeyboardEvent) => {
const options = this.getAvailableOptions();
if (options.length === 0) return;
const { key, shiftKey, ctrlKey, metaKey } = event;
const nextKey = this.orientation === 'vertical' ? 'ArrowDown' : 'ArrowRight';
const prevKey = this.orientation === 'vertical' ? 'ArrowUp' : 'ArrowLeft';
const invalidKeys =
this.orientation === 'vertical' ? ['ArrowLeft', 'ArrowRight'] : ['ArrowUp', 'ArrowDown'];
if (invalidKeys.includes(key)) {
return;
}
let newIndex = this.focusedIndex;
let shouldPreventDefault = false;
switch (key) {
case nextKey:
if (this.focusedIndex < options.length - 1) {
newIndex = this.focusedIndex + 1;
}
shouldPreventDefault = true;
if (this.isMultiselectable && shiftKey) {
this.focusOption(newIndex);
const option = options[newIndex];
if (option?.dataset.optionId) {
this.updateSelection(option.dataset.optionId, 'range');
}
event.preventDefault();
return;
}
break;
case prevKey:
if (this.focusedIndex > 0) {
newIndex = this.focusedIndex - 1;
}
shouldPreventDefault = true;
if (this.isMultiselectable && shiftKey) {
this.focusOption(newIndex);
const option = options[newIndex];
if (option?.dataset.optionId) {
this.updateSelection(option.dataset.optionId, 'range');
}
event.preventDefault();
return;
}
break;
case 'Home':
newIndex = 0;
shouldPreventDefault = true;
if (this.isMultiselectable && shiftKey) {
this.focusOption(newIndex);
const option = options[newIndex];
if (option?.dataset.optionId) {
this.updateSelection(option.dataset.optionId, 'range');
}
event.preventDefault();
return;
}
break;
case 'End':
newIndex = options.length - 1;
shouldPreventDefault = true;
if (this.isMultiselectable && shiftKey) {
this.focusOption(newIndex);
const option = options[newIndex];
if (option?.dataset.optionId) {
this.updateSelection(option.dataset.optionId, 'range');
}
event.preventDefault();
return;
}
break;
case ' ':
shouldPreventDefault = true;
if (this.isMultiselectable) {
const option = options[this.focusedIndex];
if (option?.dataset.optionId) {
this.updateSelection(option.dataset.optionId, 'toggle');
this.selectionAnchor = this.focusedIndex;
}
}
event.preventDefault();
return;
case 'Enter':
shouldPreventDefault = true;
event.preventDefault();
return;
case 'a':
case 'A':
if ((ctrlKey || metaKey) && this.isMultiselectable) {
shouldPreventDefault = true;
this.updateSelection(null, 'all');
event.preventDefault();
return;
}
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== this.focusedIndex) {
this.focusOption(newIndex);
if (!this.isMultiselectable) {
const option = options[newIndex];
if (option?.dataset.optionId) {
this.updateSelection(option.dataset.optionId, 'set');
}
} else {
this.selectionAnchor = newIndex;
}
}
return;
}
// Type-ahead
if (key.length === 1 && !ctrlKey && !metaKey) {
event.preventDefault();
this.handleTypeAhead(key);
}
};
}
if (!customElements.get('apg-listbox')) {
customElements.define('apg-listbox', ApgListbox);
}
</script> 使い方
使用例
---
import Listbox from '@patterns/listbox/Listbox.astro';
const options = [
{ id: 'apple', label: 'りんご' },
{ id: 'banana', label: 'バナナ' },
{ id: 'cherry', label: 'さくらんぼ' },
];
---
<!-- 単一選択 -->
<Listbox
options={options}
aria-label="フルーツを選択"
/>
<!-- 複数選択 -->
<Listbox
options={options}
multiselectable
aria-label="フルーツを選択"
/>
<!-- 選択変更をリッスン -->
<script>
document.querySelector('apg-listbox')?.addEventListener('selectionchange', (e) => {
console.log('選択:', e.detail.selectedIds);
});
</script> API
プロパティ
| プロパティ | 型 | デフォルト | 説明 |
|---|---|---|---|
options | ListboxOption[] | 必須 | オプションの配列 |
multiselectable | boolean | false | 複数選択モードを有効化 |
orientation | 'vertical' | 'horizontal' | 'vertical' | リストボックスの方向 |
defaultSelectedIds | string[] | [] | 初期選択されたオプション ID |
カスタムイベント
| イベント | 詳細 | 説明 |
|---|---|---|
selectionchange | { selectedIds: string[] } | 選択が変更されたときに発火 |
テスト
テストは、ARIA属性、キーボード操作、選択動作、アクセシビリティ要件全般にわたってAPG準拠を検証します。Listboxコンポーネントは2層のテスト戦略を採用しています。
テスト戦略
ユニットテスト(Testing Library)
フレームワーク固有のテストライブラリを使用して、コンポーネントのレンダリング出力を検証します。正しいHTML構造とARIA属性を確認します。
- ARIA属性(role、aria-selected、aria-multiselectable など)
- キーボード操作(矢印キー、Space、Home/End など)
- 選択動作(単一選択、複数選択)
- jest-axeによるアクセシビリティ
E2Eテスト(Playwright)
全フレームワークで実際のブラウザ環境でコンポーネントの動作を検証します。インタラクションとクロスフレームワークの一貫性をカバーします。
- キーボードナビゲーション(単一選択、複数選択、水平方向)
- マウス操作(クリック選択、トグル)
- ライブブラウザでのARIA構造
- ローヴィングタブインデックスによるフォーカス管理
- タイプアヘッド文字ナビゲーション
- axe-coreアクセシビリティスキャン
- クロスフレームワーク一貫性チェック
テストカテゴリ
高優先度: APG キーボード操作 (Unit + E2E)
テスト 説明 ArrowDown/Up オプション間でフォーカスを移動(垂直方向) ArrowRight/Left オプション間でフォーカスを移動(水平方向) Home/End 最初/最後のオプションにフォーカスを移動 Disabled skip ナビゲーション中に無効化されたオプションをスキップ Selection follows focus 単一選択: 矢印キーで選択が変更される Space toggle 複数選択: Spaceでオプションの選択をトグル Shift+Arrow 複数選択: 選択範囲を拡張 Shift+Home/End 複数選択: アンカーから最初/最後まで選択 Ctrl+A 複数選択: すべてのオプションを選択 Type-ahead 文字入力で一致するオプションにフォーカス Type-ahead cycle 同じ文字の繰り返し入力で一致項目を巡回
高優先度: APG ARIA 属性 (Unit + E2E)
テスト 説明 role="listbox" コンテナがlistboxロールを持つ role="option" 各オプションがoptionロールを持つ aria-selected 選択されたオプションが `aria-selected="true"` を持つ aria-multiselectable 複数選択が有効な場合にリストボックスが属性を持つ aria-orientation 水平/垂直方向を反映 aria-disabled 無効化されたオプションが `aria-disabled="true"` を持つ aria-label/labelledby リストボックスがアクセシブル名を持つ
高優先度: フォーカス管理 - ローヴィングタブインデックス (Unit + E2E)
テスト 説明 tabIndex=0 フォーカス中のオプションがtabIndex=0を持つ tabIndex=-1 フォーカスされていないオプションがtabIndex=-1を持つ Disabled tabIndex 無効化されたオプションがtabIndex=-1を持つ Focus restoration 再入時に正しいオプションにフォーカスが戻る
中優先度: アクセシビリティ (Unit + E2E)
テスト 説明 axe violations WCAG 2.1 AA違反がないこと(jest-axe/axe-core経由)
中優先度: マウス操作 (E2E)
テスト 説明 Click option クリックでオプションを選択(単一選択) Click toggle クリックで選択をトグル(複数選択) Click disabled 無効化されたオプションは選択できない
低優先度: クロスフレームワーク一貫性 (E2E)
テスト 説明 All frameworks have listbox React、Vue、Svelte、Astro全てがlistbox要素をレンダリング Consistent ARIA 全フレームワークで一貫したARIA構造 Select on click 全フレームワークでクリック時に正しく選択 Keyboard navigation 全フレームワークでキーボードナビゲーションが一貫して動作
テストコード例
以下は実際のE2Eテストファイル(e2e/listbox.spec.ts)です。
e2e/listbox.spec.ts import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
/**
* E2E Tests for Listbox Pattern
*
* A widget that allows the user to select one or more items from a list of choices.
* Supports single-select (selection follows focus) and multi-select modes.
*
* APG Reference: https://www.w3.org/WAI/ARIA/apg/patterns/listbox/
*/
const frameworks = ['react', 'vue', 'svelte', 'astro'] as const;
// Helper to get all listboxes
const getListboxes = (page: import('@playwright/test').Page) => {
return page.locator('[role="listbox"]');
};
// Helper to get listbox by index (0=single-select, 1=multi-select, 2=horizontal)
const getListboxByIndex = (page: import('@playwright/test').Page, index: number) => {
return page.locator('[role="listbox"]').nth(index);
};
// Helper to get available (non-disabled) options in a listbox
const getAvailableOptions = (listbox: import('@playwright/test').Locator) => {
return listbox.locator('[role="option"]:not([aria-disabled="true"])');
};
// Helper to get selected options in a listbox
const getSelectedOptions = (listbox: import('@playwright/test').Locator) => {
return listbox.locator('[role="option"][aria-selected="true"]');
};
for (const framework of frameworks) {
test.describe(`Listbox (${framework})`, () => {
test.beforeEach(async ({ page }) => {
await page.goto(`patterns/listbox/${framework}/demo/`);
await page.waitForLoadState('networkidle');
});
// =========================================================================
// High Priority: ARIA Structure
// =========================================================================
test.describe('APG: ARIA Structure', () => {
test('has role="listbox" on container', async ({ page }) => {
const listboxes = getListboxes(page);
const count = await listboxes.count();
expect(count).toBe(3); // single-select, multi-select, horizontal
for (let i = 0; i < count; i++) {
await expect(listboxes.nth(i)).toHaveAttribute('role', 'listbox');
}
});
test('options have role="option"', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const options = listbox.locator('[role="option"]');
const count = await options.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(options.nth(i)).toHaveAttribute('role', 'option');
}
});
test('has accessible name via aria-labelledby', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const ariaLabelledby = await listbox.getAttribute('aria-labelledby');
expect(ariaLabelledby).toBeTruthy();
const label = page.locator(`#${ariaLabelledby}`);
const labelText = await label.textContent();
expect(labelText?.trim().length).toBeGreaterThan(0);
});
test('single-select listbox does not have aria-multiselectable', async ({ page }) => {
const singleSelectListbox = getListboxByIndex(page, 0);
const ariaMultiselectable = await singleSelectListbox.getAttribute('aria-multiselectable');
expect(ariaMultiselectable).toBeFalsy();
});
test('multi-select listbox has aria-multiselectable="true"', async ({ page }) => {
const multiSelectListbox = getListboxByIndex(page, 1);
await expect(multiSelectListbox).toHaveAttribute('aria-multiselectable', 'true');
});
test('horizontal listbox has aria-orientation="horizontal"', async ({ page }) => {
const horizontalListbox = getListboxByIndex(page, 2);
await expect(horizontalListbox).toHaveAttribute('aria-orientation', 'horizontal');
});
test('selected options have aria-selected="true"', async ({ page }) => {
const singleSelectListbox = getListboxByIndex(page, 0);
const selectedOptions = getSelectedOptions(singleSelectListbox);
const count = await selectedOptions.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
await expect(selectedOptions.nth(i)).toHaveAttribute('aria-selected', 'true');
}
});
test('disabled options have aria-disabled="true"', async ({ page }) => {
const multiSelectListbox = getListboxByIndex(page, 1);
const disabledOptions = multiSelectListbox.locator('[role="option"][aria-disabled="true"]');
const count = await disabledOptions.count();
expect(count).toBeGreaterThan(0);
});
});
// =========================================================================
// High Priority: Single-Select Keyboard Navigation
// =========================================================================
test.describe('APG: Single-Select Keyboard Navigation', () => {
test('ArrowDown moves focus and selection to next option', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
await firstOption.focus();
await expect(firstOption).toBeFocused();
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
await firstOption.press('ArrowDown');
await expect(secondOption).toHaveAttribute('tabindex', '0');
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
await expect(firstOption).toHaveAttribute('aria-selected', 'false');
});
test('ArrowUp moves focus and selection to previous option', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
// Click to set initial state, then navigate down to second option
await firstOption.click();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowDown');
await expect(secondOption).toHaveAttribute('tabindex', '0');
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
// Now navigate up
await expect(secondOption).toBeFocused();
await secondOption.press('ArrowUp');
await expect(firstOption).toHaveAttribute('tabindex', '0');
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
});
test('Home moves focus and selection to first option', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowDown');
const secondOption = options.nth(1);
await expect(secondOption).toBeFocused();
await secondOption.press('ArrowDown');
const thirdOption = options.nth(2);
await expect(thirdOption).toBeFocused();
await thirdOption.press('Home');
await expect(firstOption).toHaveAttribute('tabindex', '0');
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
});
test('End moves focus and selection to last option', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const lastOption = options.last();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('End');
await expect(lastOption).toHaveAttribute('tabindex', '0');
await expect(lastOption).toHaveAttribute('aria-selected', 'true');
});
test('focus does not wrap at boundaries', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const options = getAvailableOptions(listbox);
const lastOption = options.last();
await lastOption.focus();
await expect(lastOption).toBeFocused();
await lastOption.press('End'); // Ensure we're at the end
await expect(lastOption).toBeFocused();
await lastOption.press('ArrowDown');
// Should still be on last option
await expect(lastOption).toHaveAttribute('tabindex', '0');
});
// Note: disabled option skip test is in Multi-Select section since the multi-select
// listbox has disabled options (Green) while single-select doesn't
});
// =========================================================================
// High Priority: Multi-Select Keyboard Navigation
// =========================================================================
test.describe('APG: Multi-Select Keyboard Navigation', () => {
test('ArrowDown moves focus only (no selection change)', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
await firstOption.focus();
await expect(firstOption).toBeFocused();
// Initially no selection in multi-select
const initialSelected = await getSelectedOptions(listbox).count();
await firstOption.press('ArrowDown');
await expect(secondOption).toHaveAttribute('tabindex', '0');
// Selection should not have changed
const afterSelected = await getSelectedOptions(listbox).count();
expect(afterSelected).toBe(initialSelected);
});
test('ArrowUp moves focus only (no selection change)', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
// Click to set initial state, then navigate down to second option
await firstOption.click();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowDown');
await expect(secondOption).toHaveAttribute('tabindex', '0');
// Navigate up should move focus but not change selection
await expect(secondOption).toBeFocused();
await secondOption.press('ArrowUp');
await expect(firstOption).toHaveAttribute('tabindex', '0');
});
test('Space toggles selection of focused option (select)', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const firstOption = getAvailableOptions(listbox).first();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await expect(firstOption).not.toHaveAttribute('aria-selected', 'true');
await firstOption.press('Space');
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
});
test('Space toggles selection of focused option (deselect)', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const firstOption = getAvailableOptions(listbox).first();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('Space'); // Select
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
await expect(firstOption).toBeFocused();
await firstOption.press('Space'); // Deselect
await expect(firstOption).toHaveAttribute('aria-selected', 'false');
});
test('Shift+ArrowDown extends selection range', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('Space'); // Select first as anchor
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
await expect(firstOption).toBeFocused();
await firstOption.press('Shift+ArrowDown');
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
});
test('Shift+ArrowUp extends selection range', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
// Click second option to set it as anchor (click toggles selection and sets anchor)
await secondOption.click();
await expect(secondOption).toBeFocused();
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
await secondOption.press('Shift+ArrowUp');
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
});
test('Shift+Home selects from anchor to first option', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const thirdOption = options.nth(2);
// Focus third option, select it as anchor
await thirdOption.focus();
await expect(thirdOption).toBeFocused();
await thirdOption.press('Space'); // Select third as anchor
await expect(thirdOption).toBeFocused();
await thirdOption.press('Shift+Home');
// All options from first to anchor should be selected
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
});
test('Shift+End selects from anchor to last option', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const lastOption = options.last();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('Space'); // Select first as anchor
await expect(firstOption).toBeFocused();
await firstOption.press('Shift+End');
// All options from anchor to last should be selected
await expect(lastOption).toHaveAttribute('aria-selected', 'true');
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
});
test('Ctrl+A selects all available options', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const availableOptions = getAvailableOptions(listbox);
const firstOption = availableOptions.first();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('Control+a');
const count = await availableOptions.count();
for (let i = 0; i < count; i++) {
await expect(availableOptions.nth(i)).toHaveAttribute('aria-selected', 'true');
}
});
test('disabled options are skipped during navigation', async ({ page }) => {
// Multi-select listbox has disabled options (Green at index 3)
const listbox = getListboxByIndex(page, 1);
const availableOptions = getAvailableOptions(listbox);
// Get Yellow (index 2 in available options) and Blue (index 3 after skip)
const yellowOption = availableOptions.nth(2); // Red, Orange, Yellow
const blueOption = availableOptions.nth(3); // Blue (Green is skipped)
// Click to focus Yellow first (ensures proper component state)
await yellowOption.click();
await expect(yellowOption).toBeFocused();
await yellowOption.press('ArrowDown');
// Should skip Green and land on Blue
await expect(blueOption).toHaveAttribute('tabindex', '0');
});
});
// =========================================================================
// High Priority: Horizontal Listbox
// =========================================================================
test.describe('APG: Horizontal Listbox', () => {
test('ArrowRight moves to next option', async ({ page }) => {
const listbox = getListboxByIndex(page, 2);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowRight');
await expect(secondOption).toHaveAttribute('tabindex', '0');
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
});
test('ArrowLeft moves to previous option', async ({ page }) => {
const listbox = getListboxByIndex(page, 2);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
// Click to set initial state, then navigate right to second option
await firstOption.click();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowRight');
await expect(secondOption).toHaveAttribute('tabindex', '0');
// Now navigate left
await expect(secondOption).toBeFocused();
await secondOption.press('ArrowLeft');
await expect(firstOption).toHaveAttribute('tabindex', '0');
});
test('ArrowUp/ArrowDown are ignored in horizontal mode', async ({ page }) => {
const listbox = getListboxByIndex(page, 2);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowDown');
// Should still be on first option
await expect(firstOption).toHaveAttribute('tabindex', '0');
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowUp');
await expect(firstOption).toHaveAttribute('tabindex', '0');
});
test('Home moves to first option', async ({ page }) => {
const listbox = getListboxByIndex(page, 2);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowRight');
const secondOption = options.nth(1);
await expect(secondOption).toBeFocused();
await secondOption.press('ArrowRight');
const thirdOption = options.nth(2);
await expect(thirdOption).toBeFocused();
await thirdOption.press('Home');
await expect(firstOption).toHaveAttribute('tabindex', '0');
});
test('End moves to last option', async ({ page }) => {
const listbox = getListboxByIndex(page, 2);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const lastOption = options.last();
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('End');
await expect(lastOption).toHaveAttribute('tabindex', '0');
});
});
// =========================================================================
// High Priority: Focus Management (Roving Tabindex)
// =========================================================================
test.describe('APG: Focus Management', () => {
test('focused option has tabindex="0"', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const focusedOption = listbox.locator('[role="option"][tabindex="0"]');
const count = await focusedOption.count();
expect(count).toBe(1);
});
test('other options have tabindex="-1"', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const allOptions = listbox.locator('[role="option"]');
const count = await allOptions.count();
let tabindexZeroCount = 0;
for (let i = 0; i < count; i++) {
const tabindex = await allOptions.nth(i).getAttribute('tabindex');
if (tabindex === '0') tabindexZeroCount++;
}
expect(tabindexZeroCount).toBe(1);
});
test('tabindex updates on navigation', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const secondOption = options.nth(1);
await firstOption.focus();
await expect(firstOption).toBeFocused();
await expect(firstOption).toHaveAttribute('tabindex', '0');
await expect(secondOption).toHaveAttribute('tabindex', '-1');
await firstOption.press('ArrowDown');
await expect(firstOption).toHaveAttribute('tabindex', '-1');
await expect(secondOption).toHaveAttribute('tabindex', '0');
});
test('Tab exits listbox', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const firstOption = listbox.locator('[role="option"][tabindex="0"]');
await firstOption.focus();
await page.keyboard.press('Tab');
// Focus should have moved out of listbox
const focusedElement = page.locator(':focus');
const isInListbox = await focusedElement.evaluate(
(el, listboxEl) => listboxEl?.contains(el),
await listbox.elementHandle()
);
expect(isInListbox).toBeFalsy();
});
test('focus returns to last focused option on re-entry', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const options = getAvailableOptions(listbox);
const firstOption = options.first();
const thirdOption = options.nth(2);
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowDown');
const secondOption = options.nth(1);
await expect(secondOption).toBeFocused();
await secondOption.press('ArrowDown');
await expect(thirdOption).toHaveAttribute('tabindex', '0');
// Tab out and back (page-level navigation)
await page.keyboard.press('Tab');
await page.keyboard.press('Shift+Tab');
// Should return to the third option
await expect(thirdOption).toHaveAttribute('tabindex', '0');
});
});
// =========================================================================
// High Priority: Type-ahead
// =========================================================================
test.describe('APG: Type-ahead', () => {
test('single character focuses matching option', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const grapeOption = listbox.locator('[role="option"]', { hasText: 'Grape' });
const firstOption = listbox.locator('[role="option"][tabindex="0"]');
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('g');
await expect(grapeOption).toHaveAttribute('tabindex', '0');
});
test('multiple characters match prefix', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const cherryOption = listbox.locator('[role="option"]', { hasText: 'Cherry' });
const firstOption = listbox.locator('[role="option"][tabindex="0"]');
await firstOption.focus();
await page.keyboard.type('ch', { delay: 50 });
await expect(cherryOption).toHaveAttribute('tabindex', '0');
});
test('repeated same character cycles through matches', async ({ page }) => {
// With fruit options: Apple, Apricot, Banana, Cherry, Date, Elderberry, Fig, Grape
// Apple and Apricot both start with 'a', so we can test cycling
const listbox = getListboxByIndex(page, 0);
const firstOption = listbox.locator('[role="option"][tabindex="0"]');
await firstOption.click();
await expect(firstOption).toBeFocused();
// Use id attribute pattern (works across frameworks: id ends with -option-{id} or data-option-id)
const appleOption = listbox.locator(
'[role="option"][id$="-option-apple"], [role="option"][data-option-id="apple"]'
);
const apricotOption = listbox.locator(
'[role="option"][id$="-option-apricot"], [role="option"][data-option-id="apricot"]'
);
// Press 'a' - should stay on Apple (first match)
await firstOption.press('a');
await expect(appleOption).toHaveAttribute('tabindex', '0');
// Press 'a' again - should cycle to Apricot (next match)
await expect(appleOption).toBeFocused();
await appleOption.press('a');
await expect(apricotOption).toHaveAttribute('tabindex', '0');
// Press 'a' again - should cycle back to Apple
await expect(apricotOption).toBeFocused();
await apricotOption.press('a');
await expect(appleOption).toHaveAttribute('tabindex', '0');
});
test('type-ahead buffer clears after timeout', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const firstOption = listbox.locator('[role="option"][tabindex="0"]');
const cherryOption = listbox.locator('[role="option"]', { hasText: 'Cherry' });
const dateOption = listbox.locator('[role="option"]', { hasText: 'Date' });
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('c'); // Focus Cherry
await expect(cherryOption).toHaveAttribute('tabindex', '0');
// Wait for buffer to clear (default 500ms + margin)
await page.waitForTimeout(600);
await expect(cherryOption).toBeFocused();
await cherryOption.press('d'); // Should focus Date, not search for "cd"
await expect(dateOption).toHaveAttribute('tabindex', '0');
});
test('type-ahead updates selection in single-select', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const grapeOption = listbox.locator('[role="option"]', { hasText: 'Grape' });
const firstOption = listbox.locator('[role="option"][tabindex="0"]');
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('g');
// In single-select, selection follows focus
await expect(grapeOption).toHaveAttribute('aria-selected', 'true');
});
});
// =========================================================================
// Medium Priority: Mouse Interaction
// =========================================================================
test.describe('Mouse Interaction', () => {
test('clicking option selects it (single-select)', async ({ page }) => {
const listbox = getListboxByIndex(page, 0);
const secondOption = listbox.locator('[role="option"]').nth(1);
await secondOption.click();
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
await expect(secondOption).toHaveAttribute('tabindex', '0');
});
test('clicking option toggles selection (multi-select)', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const firstOption = getAvailableOptions(listbox).first();
// First click - select
await firstOption.click();
await expect(firstOption).toHaveAttribute('aria-selected', 'true');
// Second click - deselect
await firstOption.click();
await expect(firstOption).toHaveAttribute('aria-selected', 'false');
});
test('clicking disabled option does nothing', async ({ page }) => {
const listbox = getListboxByIndex(page, 1);
const disabledOption = listbox.locator('[role="option"][aria-disabled="true"]').first();
const selectedCountBefore = await getSelectedOptions(listbox).count();
await disabledOption.click({ force: true });
const selectedCountAfter = await getSelectedOptions(listbox).count();
expect(selectedCountAfter).toBe(selectedCountBefore);
});
});
// =========================================================================
// Medium Priority: Accessibility
// =========================================================================
test.describe('Accessibility', () => {
test('has no axe-core violations', async ({ page }) => {
const results = await new AxeBuilder({ page }).include('[role="listbox"]').analyze();
expect(results.violations).toEqual([]);
});
});
});
}
// =============================================================================
// Cross-framework Consistency Tests
// =============================================================================
test.describe('Listbox - Cross-framework Consistency', () => {
test('all frameworks have listbox elements', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/listbox/${framework}/demo/`);
await page.waitForLoadState('networkidle');
const listboxes = page.locator('[role="listbox"]');
const count = await listboxes.count();
expect(count).toBe(3); // single-select, multi-select, horizontal
}
});
test('all frameworks have consistent ARIA structure', async ({ page }) => {
const ariaStructures: Record<
string,
{
hasAriaLabelledby: boolean;
ariaMultiselectable: string | null;
ariaOrientation: string | null;
optionCount: number;
}[]
> = {};
for (const framework of frameworks) {
await page.goto(`patterns/listbox/${framework}/demo/`);
await page.waitForLoadState('networkidle');
ariaStructures[framework] = await page.evaluate(() => {
const listboxes = document.querySelectorAll('[role="listbox"]');
return Array.from(listboxes).map((listbox) => ({
hasAriaLabelledby: listbox.hasAttribute('aria-labelledby'),
ariaMultiselectable: listbox.getAttribute('aria-multiselectable'),
ariaOrientation: listbox.getAttribute('aria-orientation'),
optionCount: listbox.querySelectorAll('[role="option"]').length,
}));
});
}
// All frameworks should have the same structure
const reactStructure = ariaStructures['react'];
for (const framework of frameworks) {
expect(ariaStructures[framework].length).toBe(reactStructure.length);
for (let i = 0; i < reactStructure.length; i++) {
expect(ariaStructures[framework][i].hasAriaLabelledby).toBe(
reactStructure[i].hasAriaLabelledby
);
expect(ariaStructures[framework][i].ariaMultiselectable).toBe(
reactStructure[i].ariaMultiselectable
);
expect(ariaStructures[framework][i].ariaOrientation).toBe(
reactStructure[i].ariaOrientation
);
expect(ariaStructures[framework][i].optionCount).toBe(reactStructure[i].optionCount);
}
}
});
test('all frameworks select correctly on click', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/listbox/${framework}/demo/`);
await page.waitForLoadState('networkidle');
// Test single-select listbox
const singleSelectListbox = page.locator('[role="listbox"]').first();
const secondOption = singleSelectListbox.locator('[role="option"]').nth(1);
await secondOption.click();
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
}
});
test('all frameworks handle keyboard navigation consistently', async ({ page }) => {
for (const framework of frameworks) {
await page.goto(`patterns/listbox/${framework}/demo/`);
await page.waitForLoadState('networkidle');
const listbox = page.locator('[role="listbox"]').first();
const options = listbox.locator('[role="option"]:not([aria-disabled="true"])');
const firstOption = options.first();
const secondOption = options.nth(1);
await firstOption.focus();
await expect(firstOption).toBeFocused();
await firstOption.press('ArrowDown');
// Second option should now be focused and selected
await expect(secondOption).toHaveAttribute('tabindex', '0');
await expect(secondOption).toHaveAttribute('aria-selected', 'true');
}
});
});
テストツール
- Vitest (opens in new tab)
- ユニットテスト用テストランナー
- Testing Library (opens in new tab)
- フレームワーク固有のテストユーティリティ(React、Vue、Svelte)
- Playwright (opens in new tab)
- E2Eテスト用ブラウザ自動化
- axe-core/playwright (opens in new tab)
- E2Eでの自動アクセシビリティテスト
完全なドキュメントは
testing-strategy.md
(opens in new tab) を参照してください。
リソース
-
WAI-ARIA APG: Listbox パターン
(opens in new tab)
-
AI Implementation Guide (llm.md)
(opens in new tab) - ARIA specs, keyboard support, test checklist