APG Patterns
日本語 GitHub
日本語 GitHub

Listbox

A widget that allows the user to select one or more items from a list of choices.

🤖 AI Implementation Guide

Demo

Single-Select (Default)

Selection follows focus. Use arrow keys to navigate and select.

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

Multi-Select

Focus and selection are independent. Use Space to toggle, Shift+Arrow to extend selection.

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

Tip: Use Space to toggle, Shift+Arrow to extend selection, Ctrl+A to select all

Horizontal Orientation

Use Left/Right arrow keys for navigation.

  • Apple
  • Banana
  • Cherry
  • Date
  • Elderberry

Accessibility Features

WAI-ARIA Roles

Role Target Element Description
listbox Container (<ul>) Widget for selecting one or more items from a list
option Each item (<li>) Selectable option within the listbox

WAI-ARIA listbox role (opens in new tab)

WAI-ARIA Properties

Attribute Target Values Required Description
aria-label listbox String Yes* Accessible name for the listbox
aria-labelledby listbox ID reference Yes* References the labeling element
aria-multiselectable listbox true No Enables multi-select mode
aria-orientation listbox "vertical" | "horizontal" No Navigation direction (default: vertical)

* Either aria-label or aria-labelledby is required

WAI-ARIA States

aria-selected

Indicates whether an option is selected.

Target option
Values true | false
Required Yes
Change Trigger Click, Arrow keys (single-select), Space (multi-select)
Reference aria-selected (opens in new tab)

aria-disabled

Indicates that an option is not selectable.

Target option
Values true
Required No (only when disabled)
Reference aria-disabled (opens in new tab)

Keyboard Support

Common Navigation

Key Action
Down Arrow / Up Arrow Move focus (vertical orientation)
Right Arrow / Left Arrow Move focus (horizontal orientation)
Home Move focus to first option
End Move focus to last option
Type character Type-ahead: focus option starting with typed character(s)

Single-Select (Selection Follows Focus)

Key Action
Arrow keys Move focus and selection simultaneously
Space / Enter Confirm current selection

Multi-Select

Key Action
Arrow keys Move focus only (selection unchanged)
Space Toggle selection of focused option
Shift + Arrow Move focus and extend selection range
Shift + Home Select from anchor to first option
Shift + End Select from anchor to last option
Ctrl + A Select all options

Focus Management

This component uses the Roving Tabindex pattern for focus management:

  • Only one option has tabindex="0" at a time
  • Other options have tabindex="-1"
  • Arrow keys move focus between options
  • Disabled options are skipped during navigation
  • Focus does not wrap (stops at edges)

Selection Model

  • Single-select: Selection follows focus (arrow keys change selection)
  • Multi-select: Focus and selection are independent (Space toggles selection)

Source Code

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>

Usage

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

Props

Prop Type Default Description
options ListboxOption[] required Array of options
multiselectable boolean false Enable multi-select mode
orientation 'vertical' | 'horizontal' 'vertical' Listbox orientation
defaultSelectedIds string[] [] Initially selected option IDs

Custom Events

Event Detail Description
selectionchange { selectedIds: string[] } Fired when selection changes

Testing

Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements.

Test Categories

High Priority: APG Keyboard Interaction

Test Description
ArrowDown/Up Moves focus between options (vertical orientation)
ArrowRight/Left Moves focus between options (horizontal orientation)
Home/End Moves focus to first/last option
Disabled skip Skips disabled options during navigation
Selection follows focus Single-select: arrow keys change selection
Space toggle Multi-select: Space toggles option selection
Shift+Arrow Multi-select: extends selection range
Shift+Home/End Multi-select: selects from anchor to first/last
Ctrl+A Multi-select: selects all options
Type-ahead Character input focuses matching option
Type-ahead cycle Repeated same character cycles through matches

High Priority: APG ARIA Attributes

Test Description
role="listbox" Container has listbox role
role="option" Each option has option role
aria-selected Selected options have aria-selected="true"
aria-multiselectable Listbox has attribute when multi-select enabled
aria-orientation Reflects horizontal/vertical orientation
aria-disabled Disabled options have aria-disabled="true"
aria-label/labelledby Listbox has accessible name

High Priority: Focus Management (Roving Tabindex)

Test Description
tabIndex=0 Focused option has tabIndex=0
tabIndex=-1 Non-focused options have tabIndex=-1
Disabled tabIndex Disabled options have tabIndex=-1
Focus restoration Focus returns to correct option on re-entry

Medium Priority: Accessibility

Test Description
axe violations No WCAG 2.1 AA violations (via jest-axe)

Testing Tools

See testing-strategy.md (opens in new tab) for full documentation.

Resources