APG Patterns
English GitHub
English GitHub

Listbox

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

🤖 AI Implementation Guide

デモ

単一選択(デフォルト)

選択はフォーカスに追従します。矢印キーでナビゲートして選択します。

  • りんご
  • バナナ
  • さくらんぼ
  • デーツ
  • エルダーベリー
  • いちじく
  • ぶどう

複数選択

フォーカスと選択は独立しています。Space でトグル、Shift+矢印キーで選択範囲を拡張します。

  • オレンジ

ヒント: Space でトグル、Shift+矢印キーで選択拡張、Ctrl+A で全選択

水平方向

左/右矢印キーでナビゲーションします。

  • りんご
  • バナナ
  • さくらんぼ
  • デーツ
  • エルダーベリー

アクセシビリティ

WAI-ARIA ロール

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

WAI-ARIA listbox role (opens in new tab)

WAI-ARIA プロパティ

属性 対象 必須 説明
aria-label listbox String はい* リストボックスのアクセシブル名
aria-labelledby listbox ID参照 はい* ラベル要素への参照
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 最後のオプションにフォーカスを移動
文字入力 先行入力: 入力した文字で始まるオプションにフォーカス

単一選択(選択がフォーカスに追従)

キー アクション
矢印キー フォーカスと選択を同時に移動
Space / Enter 現在の選択を確定

複数選択

キー アクション
矢印キー フォーカスのみ移動(選択は変更なし)
Space フォーカス中のオプションの選択をトグル
Shift + 矢印 フォーカスを移動し選択範囲を拡張
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?.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準拠を検証します。

テストカテゴリ

高優先度: APG キーボード操作

テスト 説明
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 属性

テスト 説明
role="listbox" コンテナがlistboxロールを持つ
role="option" 各オプションがoptionロールを持つ
aria-selected 選択されたオプションが aria-selected="true" を持つ
aria-multiselectable 複数選択が有効な場合にリストボックスが属性を持つ
aria-orientation 水平/垂直方向を反映
aria-disabled 無効化されたオプションが aria-disabled="true" を持つ
aria-label/labelledby リストボックスがアクセシブル名を持つ

高優先度: フォーカス管理(ローヴィングタブインデックス)

テスト 説明
tabIndex=0 フォーカス中のオプションがtabIndex=0を持つ
tabIndex=-1 フォーカスされていないオプションがtabIndex=-1を持つ
Disabled tabIndex 無効化されたオプションがtabIndex=-1を持つ
Focus restoration 再入時に正しいオプションにフォーカスが戻る

中優先度: アクセシビリティ

テスト 説明
axe violations WCAG 2.1 AA違反がないこと(jest-axe経由)

テストツール

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

リソース