APG Patterns
English
English

リストボックス

ユーザーが選択肢のリストから1つまたは複数のアイテムを選択できるウィジェット。

デモ

単一選択(デフォルト)

選択がフォーカスに追従します。矢印キーで移動・選択できます。

  • Apple
  • Banana
  • Cherry
  • Date
  • Elderberry
  • Fig
  • Grape

複数選択

フォーカスと選択は独立しています。Spaceで切り替え、Shift+矢印キーで選択を拡張します。

  • Red
  • Orange
  • Yellow
  • Green
  • Blue
  • Indigo
  • Purple

ヒント: Spaceで切り替え、Shift+矢印キーで選択を拡張、Ctrl+Aで全選択

水平方向

左右の矢印キーで移動します。

  • Apple
  • Banana
  • Cherry
  • Date
  • Elderberry

デモのみ表示 →

アクセシビリティ

WAI-ARIA ロール

ロール対象要素説明
listboxコンテナ(<ul>リストから1つまたは複数のアイテムを選択するためのウィジェット
option各アイテム(<li>リストボックス内の選択可能なオプション

WAI-ARIA プロパティ

aria-label

リストボックスのアクセシブル名

String
必須
はい*

aria-labelledby

ラベル要素への参照

ID reference
必須
はい*

aria-multiselectable

複数選択モードを有効にする

true
必須
いいえ

aria-orientation

ナビゲーションの方向(デフォルト: vertical)

vertical | horizontal
必須
いいえ

WAI-ARIA ステート

aria-selected

対象要素
option
true | false
必須
はい
変更トリガー

クリック、矢印キー(単一選択)、Space(複数選択)

aria-disabled

対象要素
option
true
必須
いいえ
変更トリガー
無効時のみ

キーボードサポート

共通ナビゲーション

キーアクション
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すべてのオプションを選択
  • 単一選択: 選択がフォーカスに追従(矢印キーで選択が変更される)
  • 複数選択: フォーカスと選択は独立(Spaceで選択をトグル)

フォーカス管理

イベント振る舞い
フォーカス中のオプション常に1つのオプションのみが tabindex="0" を持つ(ローヴィングタブインデックス)
フォーカスされていないオプション他のオプションは tabindex="-1" を持つ
矢印ナビゲーション矢印キーでオプション間のフォーカスを移動
無効化されたオプション無効化されたオプションはナビゲーション中にスキップされる
端の動作フォーカスは端で折り返さない(端で停止)

参考資料

ソースコード

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>

使い方

Example
---
import Listbox from '@patterns/listbox/Listbox.astro';

const options = [
  { id: 'apple', label: 'Apple' },
  { id: 'banana', label: 'Banana' },
  { id: 'cherry', label: 'Cherry' },
];
---

<!-- Single-select -->
<Listbox
  options={options}
  aria-label="Choose a fruit"
/>

<!-- Multi-select -->
<Listbox
  options={options}
  multiselectable
  aria-label="Choose fruits"
/>

<!-- Listen for selection changes -->
<script>
  document.querySelector('apg-listbox')?.addEventListener('selectionchange', (e) => {
    console.log('Selected:', e.detail.selectedIds);
  });
</script>

API

プロパティ デフォルト 説明
options ListboxOption[] required オプションの配列
multiselectable boolean false 複数選択モードを有効化
orientation 'vertical' | 'horizontal' 'vertical' リストボックスの方向
defaultSelectedIds string[] [] 初期選択されたオプション ID

Custom Events

イベント Detail 説明
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');
    }
  });
});

テストツール

完全なドキュメントは testing-strategy.md (opens in new tab) を参照してください。

リソース