APG Patterns
English
English

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');
        }
      });
    });

    テストツール

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

    リソース