5

Combobox WIP

A searchable dropdown built from a text input and the native Popover API. CSS does the presentation. A short inline script filters the list and handles the pick.

Composed of

Text Field
The search input you type in
Popover
The surface the options open in
Menu
The list of options

Works with

Field
Label and hint text

Each anchor-name must be unique per combobox on the page. Set it via an inline style and reference the same value as position-anchor on the popover.

Standalone

<link rel="stylesheet" href="https://esm.sh/@erikt/ui/combobox.css" />

Default

  • <div class="combobox">
      <input
        type="search"
        placeholder="Search fruits..."
        style="anchor-name: --my-combobox"
        onfocus="
          if (
            !this.dataset.skip &&
            !this.nextElementSibling.matches(':popover-open')
          )
            this.nextElementSibling.showPopover();
          delete this.dataset.skip;
        "
        onblur="
          if (!this.nextElementSibling.contains(event.relatedTarget))
            setTimeout(
              () =>
                this.nextElementSibling.matches(':popover-open') &&
                this.nextElementSibling.hidePopover(),
            );
        "
        onkeydown="
          if (event.key === 'ArrowDown') {
            event.preventDefault();
            const f = this.nextElementSibling.querySelector(
              'li:not([hidden]) button',
            );
            f && f.focus();
          }
        "
        oninput="
          this.nextElementSibling
            .querySelectorAll('li')
            .forEach(
              (li) =>
                (li.hidden = !li.textContent
                  .toLowerCase()
                  .includes(this.value.toLowerCase())),
            )
        "
      />
      <div
        popover="manual"
        style="position-anchor: --my-combobox"
        onpointerdown="const b=event.target.closest('button');if(b){event.preventDefault();this.previousElementSibling.value=b.textContent.trim();this.hidePopover()}"
        onkeydown="
          const btns = [...this.querySelectorAll('li:not([hidden]) button')],
            i = btns.indexOf(event.target);
          if (event.key === 'ArrowDown') {
            event.preventDefault();
            (btns[i + 1] || btns[0])?.focus();
          } else if (event.key === 'ArrowUp') {
            event.preventDefault();
            i > 0 ? btns[i - 1].focus() : this.previousElementSibling.focus();
          } else if (event.key === 'Escape') {
            this.previousElementSibling.dataset.skip = 1;
            this.hidePopover();
            this.previousElementSibling.focus();
          } else if (event.key === 'Enter') {
            event.preventDefault();
            const b = event.target.closest('button');
            if (b) {
              this.previousElementSibling.value = b.textContent.trim();
              this.hidePopover();
              this.previousElementSibling.focus();
            }
          }
        "
        onfocusout="if(!this.contains(event.relatedTarget)&&event.relatedTarget!==this.previousElementSibling)setTimeout(()=>this.matches(':popover-open')&&this.hidePopover())"
      >
        <menu>
          <li><button class="ghost">Apple</button></li>
          <li><button class="ghost">Banana</button></li>
          <li><button class="ghost">Cherry</button></li>
          <li><button class="ghost">Mango</button></li>
          <li><button class="ghost">Pineapple</button></li>
        </menu>
      </div>
    </div>

    In a field

    Wrap with a <label> to attach a visible label.

    <label class="field">
      <span>Favourite fruit</span>
      <div class="combobox">
        <input
          type="search"
          placeholder="Search..."
          style="anchor-name: --my-combobox"
          onfocus="
            if (
              !this.dataset.skip &&
              !this.nextElementSibling.matches(':popover-open')
            )
              this.nextElementSibling.showPopover();
            delete this.dataset.skip;
          "
          onblur="
            if (!this.nextElementSibling.contains(event.relatedTarget))
              setTimeout(
                () =>
                  this.nextElementSibling.matches(':popover-open') &&
                  this.nextElementSibling.hidePopover(),
              );
          "
          onkeydown="
            if (event.key === 'ArrowDown') {
              event.preventDefault();
              const f = this.nextElementSibling.querySelector(
                'li:not([hidden]) button',
              );
              f && f.focus();
            }
          "
          oninput="
            this.nextElementSibling
              .querySelectorAll('li')
              .forEach(
                (li) =>
                  (li.hidden = !li.textContent
                    .toLowerCase()
                    .includes(this.value.toLowerCase())),
              )
          "
        />
        <div
          popover="manual"
          style="position-anchor: --my-combobox"
          onpointerdown="const b=event.target.closest('button');if(b){event.preventDefault();this.previousElementSibling.value=b.textContent.trim();this.hidePopover()}"
          onkeydown="
            const btns = [...this.querySelectorAll('li:not([hidden]) button')],
              i = btns.indexOf(event.target);
            if (event.key === 'ArrowDown') {
              event.preventDefault();
              (btns[i + 1] || btns[0])?.focus();
            } else if (event.key === 'ArrowUp') {
              event.preventDefault();
              i > 0 ? btns[i - 1].focus() : this.previousElementSibling.focus();
            } else if (event.key === 'Escape') {
              this.previousElementSibling.dataset.skip = 1;
              this.hidePopover();
              this.previousElementSibling.focus();
            } else if (event.key === 'Enter') {
              event.preventDefault();
              const b = event.target.closest('button');
              if (b) {
                this.previousElementSibling.value = b.textContent.trim();
                this.hidePopover();
                this.previousElementSibling.focus();
              }
            }
          "
          onfocusout="if(!this.contains(event.relatedTarget)&&event.relatedTarget!==this.previousElementSibling)setTimeout(()=>this.matches(':popover-open')&&this.hidePopover())"
        >
          <menu>
            <li><button class="ghost">Apple</button></li>
            <li><button class="ghost">Banana</button></li>
            <li><button class="ghost">Cherry</button></li>
          </menu>
        </div>
      </div>
    </label>

    API reference

    Type Classes / attributes What it does
    Filter [hidden] Hides an option. With none left the list shows No results

    Search 5021 icons

    Type a name to find icons from the Tabler icon set.

        Move Open Esc Close