@19h47/combobox

Editable combobox with list autocomplete (manual selection), illustrated with an AD&D 2nd edition bestiary — managed, async fetch, and external rich results.

Undermountain bestiary

mode: 'managed' (default) — synchronous search over a local list, with an optional open button (tabindex="-1").

    const combobox = new Combobox($input, $listbox, {
      button,
      render: (result, props) =>
        `<li class="list-group-item"${props}>${result}</li>`,
      search: (input, { signal }) => {
        if (input.length < 1) return monsters;
        return monsters.filter(name =>
          name.toLowerCase().startsWith(input.toLowerCase()),
        );
      },
    });
    
    combobox.init();

    Scroll of summoning

    Async search with fetch, AbortSignal, and event log (loading / loaded / submit / update).

      Idle

      const combobox = new Combobox($input, $listbox, {
        render: (result, props) =>
          `<li class="list-group-item"${props}>${result}</li>`,
        search: async (input, { signal }) => {
          const response = await fetch(monstersUrl, { signal });
          const monsters = await response.json();
          if (input.length < 1) return monsters;
          return monsters.filter(name =>
            name.toLowerCase().startsWith(input.toLowerCase()),
          );
        },
      });
      
      $input.addEventListener('combobox:submit', ({ detail }) => {
        console.log(detail.value, detail.option);
      });

      Compendium search (external)

      mode: 'external' + selectMode: 'custom' — grouped HTML, debounce, minLength, openOnEmpty, and combobox:empty. Enter on a focused option follows the link; Enter without focus would submit a parent form.

      Idle — try “be”, “mi”, or “xx”.

      const combobox = new Combobox($input, $listbox, {
        mode: 'external',
        selectMode: 'custom',
        debounce: 280,
        minLength: 2,
        openOnEmpty: true,
        search: async (query, { signal }) => {
          const html = await renderGroupedResults(query, signal);
          return { html };
        },
        onSelect: ({ option }) => {
          option?.element.querySelector('a')?.click();
        },
      });
      
      $input.addEventListener('combobox:empty', ({ detail }) => {
        showNoResults(detail.value);
      });

      Keyboard (APG list autocomplete)

      Key Action
      Open list and move to first option
      Alt + / Open list without selecting
      Open list and move to last option
      Enter Accept focused option; without focus, close and allow form submit
      Tab Accept focused option, otherwise close
      Escape Close list, or clear field if already closed

      ARIA checklist

      • role="combobox" + aria-autocomplete="list" on the input
      • aria-controls / aria-expanded on input (and button)
      • Listbox has a stable id; options use aria-selected when visually focused
      • aria-activedescendant on the input while navigating (attribute removed when inactive)
      • aria-busy on the listbox while search is in flight
      • In external mode, group headers are not options — only [role="option"] participate; each option needs a stable id