Combobox

Een combobox is een invoerveld met een bijbehorende popup. Gebruikers kiezen een waarde uit de popup. Deze popup kan een list, grid, tree of dialog zijn. In dit voorbeeld gebruik ik een lijst.

Gebruik een combobox als:

  • De waarde uit een voorgedefinieerde set moet komen
  • Je willekeurige waarden toestaat maar suggesties wilt bieden

Er zijn twee hoofdtypen combobox:

  • Select-only: gebruikers kunnen alleen een optie kiezen uit de lijst
  • Editable: gebruikers kunnen typen en krijgen gefilterde opties

Technische opbouw

  • Gebruik een <input>-element met role="combobox"
  • Geef het invoerveld een naam via <label>
  • (indien van toepassing) Geef het invoerveld autocomplete met een geldige waarde
  • Geef de popup role="listbox"
  • Geef de popup een uniek ID
  • Koppel het invoerveld en de popup via aria-controls met het ID van de bijbehorende popup
  • Voor een uitgevouwen popup:
    • Geeft het invoerveld de waarde aria-expanded="true"
  • Voor een samengevouwen popup:
    • Geeft het invoerveld de waarde aria-expanded="false"
  • Geef opties in de lijst de rol role="option"
  • Voor geselecteerde opties:
    • Geef de optie de waarde aria-selected="true"
  • Voor niet geselecteerde opties:
    • Geef de optie de waarde aria-selected="false" (of helemaal geen aria-selected)
  • Geef het invoerveld aria-activedescendant met het ID van de gefocuste optie
  • Geef het invoerveld aria-autocomplete met de juiste waarde:
    • none: suggesties veranderen niet tijdens typen
    • list: suggesties filteren op basis van getypte tekst
    • both: gefilterde suggesties en vult automatisch aan aanvulling in het invoerveld

Optionele verbeteringen

  • Geef het invoerveld aria-haspopup="listbox"

Voorbeeld combobox

HTML
<!-- Combobox code -->
<label for="land-combo">
  Kies je land:
</label>
<div class="combobox-container">
  <input type="text" id="land-combo" class="combobox" role="combobox" aria-expanded="false" aria-controls="land-list" aria-activedescendant="" aria-autocomplete="list" autocomplete="country" placeholder="Type om te zoeken...">
  <ul class="combobox-list" id="land-list" role="listbox">
    <li class="combobox-option" role="option" aria-selected="false">
      Nederland
    </li>
    <li class="combobox-option" role="option" aria-selected="false">
      België
    </li>
    <li class="combobox-option" role="option" aria-selected="false">
      Duitsland
    </li>
    <li class="combobox-option" role="option" aria-selected="false">
      Frankrijk
    </li>
  </ul>
</div>
CSS
/* Combobox stijlen */
.combobox-container {
  position: relative;
  width: 300px;
}

.combobox {
  width: 100%;
  padding: 8px 12px;
  border: 1px solid;
}

.combobox-list {
  position: absolute;
  top: 100%;
  left: 0;
  right: 0;
  margin: 0;
  padding: 0;
  background: white;
  border: 1px solid;
  border-top: none;
  max-height: 200px;
  overflow-y: auto;
  z-index: 1000;
  display: none;
}

.combobox-list.show {
  display: block;
}

.combobox-option {
  padding: 8px 12px;
  cursor: pointer;
  border-bottom: 1px solid;
}

.combobox-option:hover,
.combobox-option.focused {
  background: black;
  color: white;
}

.combobox-option[aria-selected="true"] {
  background: grey;
  color: white;
} 
JavaScript
// Combobox functionaliteit
class Combobox {
  constructor(container) {
    this.container = typeof container === 'string' ?
      document.querySelector(container) : container;

    if (!this.container) return;

    this.input = this.container.querySelector('.combobox');
    this.listbox = this.container.querySelector('.combobox-list');
    this.options = Array.from(this.container.querySelectorAll('.combobox-option'));
    this.focusIndex = -1;
    this.selectedIndex = -1;
    this.uniqueId = Date.now().toString(36);

    // Stop als essentiële elementen ontbreken
    if (!this.input || !this.listbox || this.options.length === 0) return;

    this.setupAccessibility();
    this.bindEvents();
  }

  setupAccessibility() {
    // Zet aria-attributen voor het invoerveld
    this.input.setAttribute('aria-expanded', 'false');
    this.input.setAttribute('aria-haspopup', 'listbox');
    this.input.setAttribute('aria-autocomplete', 'list');
    this.input.setAttribute('aria-controls', this.listbox.id);

    // Geef elke optie een uniek ID
    this.options.forEach((option, index) => {
      option.setAttribute('id', `opt-${this.uniqueId}-${index}`);
      option.setAttribute('aria-selected', 'false');
    });
  }

  bindEvents() {
    // Invoerveld events
    this.input.addEventListener('input', () => this.handleInput());
    this.input.addEventListener('keydown', (e) => this.handleKeydown(e));
    this.input.addEventListener('focus', () => this.openList());
    this.input.addEventListener('blur', () => {
      // Korte delay voor klik events op opties
      setTimeout(() => this.closeList(), 150);
    });

    // Optie events
    this.options.forEach((option, index) => {
      option.addEventListener('click', () => this.selectOption(index));
      option.addEventListener('mouseenter', () => this.setFocusOnOption(index));
    });
  }

  handleInput() {
    const searchText = this.input.value.toLowerCase();
    let hasVisibleOptions = false;

    // Filter opties op basis van input
    this.options.forEach(option => {
      const optionText = option.textContent.toLowerCase();
      const matches = optionText.includes(searchText);

      option.style.display = matches ? 'block' : 'none';
      if (matches) hasVisibleOptions = true;
    });

    // Reset focus en toon lijst als er opties zijn
    this.focusIndex = -1;
    this.updateFocus();

    if (hasVisibleOptions) {
      this.openList();
    } else {
      this.closeList();
    }
  }

  handleKeydown(e) {
    const visibleOptions = this.getVisibleOptions();

    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        this.focusIndex = Math.min(this.focusIndex + 1, visibleOptions.length - 1);
        this.updateFocus();
        this.openList();
        break;

      case 'ArrowUp':
        e.preventDefault();
        this.focusIndex = Math.max(this.focusIndex - 1, -1);
        this.updateFocus();
        break;

      case 'Enter':
        e.preventDefault();
        if (this.focusIndex >= 0) {
          const optionIndex = this.options.indexOf(visibleOptions[this.focusIndex]);
          this.selectOption(optionIndex);
        }
        break;

      case 'Escape':
        this.closeList();
        this.input.blur();
        break;

      case 'Tab':
        this.closeList();
        break;
    }
  }

  setFocusOnOption(index) {
    // Zet focus op specifieke optie (voor hover)
    const visibleOptions = this.getVisibleOptions();
    const visibleIndex = visibleOptions.indexOf(this.options[index]);

    if (visibleIndex >= 0) {
      this.focusIndex = visibleIndex;
      this.updateFocus();
    }
  }

  updateFocus() {
    const visibleOptions = this.getVisibleOptions();

    // Verwijder focus styling van alle opties
    this.options.forEach(option => {
      option.classList.remove('focused');
    });

    // Zet focus op actieve optie
    if (this.focusIndex >= 0 && visibleOptions[this.focusIndex]) {
      const focusedOption = visibleOptions[this.focusIndex];
      focusedOption.classList.add('focused');
      this.input.setAttribute('aria-activedescendant', focusedOption.id);
    } else {
      this.input.removeAttribute('aria-activedescendant');
    }
  }

  selectOption(index) {
    // Reset vorige selectie
    this.options.forEach(option => {
      option.setAttribute('aria-selected', 'false');
    });

    // Selecteer nieuwe optie
    const selectedOption = this.options[index];
    this.input.value = selectedOption.textContent.trim();
    selectedOption.setAttribute('aria-selected', 'true');

    this.selectedIndex = index;
    this.closeList();
    this.input.focus();

    // Trigger change event voor formulier integratie
    this.input.dispatchEvent(new Event('change', {
      bubbles: true
    }));
  }

  getVisibleOptions() {
    return this.options.filter(option => option.style.display !== 'none');
  }

  openList() {
    this.listbox.classList.add('show');
    this.input.setAttribute('aria-expanded', 'true');
  }

  closeList() {
    this.listbox.classList.remove('show');
    this.input.setAttribute('aria-expanded', 'false');
    this.focusIndex = -1;
    this.updateFocus();
  }
}

// Auto-initialisatie voor alle comboboxen
document.addEventListener('DOMContentLoaded', () => {
  document.querySelectorAll('.combobox-container').forEach(container => {
    new Combobox(container);
  });
});

Toetsenbordbediening

Invoerveld

  • Escape:
    • Bij een uitgevouwen popup: sluit de popup
  • Pijltjestoetsen:
    • Pijl omlaag: Verplaatst de focus naar het eerste element in de popup
  • Tekens typen:
    • Typt tekens

Popup

  • Enter:
    • Accepteert de gefocuste optie, sluit popup en keer terug naar de combobox
  • Escape:
    • Sluit de popup en keer terug naar combobox
  • Pijltjestoetsen:
    • Omlaag: Gaat naar de volgende optie in de popup
    • Omhoog: Gaat naar de vorige optie in de popup
    • Links/Rechts: Verplaatst de cursor in het invoerveld (als editable)

Optionele toetsen

  • Home:
    • Gaat naar de eerste optie in de popup
  • End:
    • Gaat naar de laatste optie in de popup

Focus management

  • Focus blijft altijd op de combobox
  • Houd de popup buiten de focus volgorde

Beoordeling

Component

  • (4.1.2) Het component moet een <input>-element gebruiken
  • (4.1.2) Het invoerveld moet de rol combobox hebben (role="combobox")
  • (4.1.2) Het invoerveld moet een toegankelijke naam hebben
    • (2.4.6) De toegankelijke naam moet beschrijven wat het doel van de combobox is
  • (4.1.2) De popup moet de rol listbox hebben (role="listbox")
  • (4.1.1) De listbox moet een uniek ID hebben
  • (1.3.1) Het invoerveld zou aria-controls moeten hebben met het ID van de popup
  • (4.1.2) Opties in de lijst moeten de rol option hebben (role="option")
  • (4.1.2) Geselecteerde opties moeten aria-selected="true" hebben
  • (4.1.2) Niet-geselecteerde opties moeten aria-selected="false" hebben (of geen aria-selected)
  • (1.3.1) Het invoerveld moet aria-activedescendant hebben met het ID van de gefocuste optie
  • (4.1.2) Een uitgevouwen invoerveld moet aria-expanded="true" hebben
  • (4.1.2) Een samengevouwen invoerveld moet aria-expanded="false" hebben
  • (1.3.1) Het invoerveld moet aria-autocomplete hebben met:
    • none: suggesties veranderen niet tijdens typen
    • list: suggesties filteren op getypte tekst
    • both: filtering én automatische aanvulling

Bronnen