Combobox

  • No results found.
@use 'sprucecss/scss/spruce' as *;

.combobox {
  @include generate-variables($form-control, $include: ('border-width', 'border-radius'));
  display: flex;
  flex-direction: column;
  gap: spacer('xs');

  &__inner {
    position: relative;
  }

  &__selected-items {
    align-items: center;
    display: flex;
    flex-wrap: wrap;
    gap: spacer('xs');
  }

  &__toggle {
    inset: 0 0 0 auto;
    pointer-events: none;
    position: absolute;
  }

  &__reset {
    align-self: start;
  }

  &__no-results {
    padding-inline: spacer('xs');
  }

  &__control {
    @include field-icon(config('select', $form-icon, false), color('select-foreground', 'form', true));
    background-position: center right config('icon-right-offset', $form-select, false);
    background-repeat: no-repeat;
    background-size: config('icon-inline-size', $form-select, false) auto;
    padding-inline-end: config('padding-inline-end', $form-select, false);
  }

  &__dropdown {
    background-color: color('background', 'form');
    border: config('border-width', $form-control) solid color('border', 'form');
    border-radius: config('border-radius', $form-control);
    inset: calc(100% + #{spacer('xs')}) 0 auto 0;
    padding: spacer('xs');
    position: absolute;
    z-index: 5;
  }

  [role='listbox'] {
    @include clear-list;
    @include scrollbar;
    display: flex;
    flex-direction: column;
    gap: spacer('xs');
    max-block-size: 10rem;
    overflow-y: auto;
    padding-inline-end: spacer('xs');

    > * {
      margin-block-start: 0;
    }
  }

  [role='option'] {
    align-items: center;
    border-radius: config('border-radius', $form-control);
    display: flex;
    justify-content: space-between;
    padding-block: spacer('xxs');
    padding-inline: spacer('xs');
    user-select: none;

    &[aria-selected='true'] {
      background-color: color('light-background', 'btn');
      color: color('light-foreground', 'btn');
    }

    &:hover,
    &:focus,
    &.highlighted {
      background-color: color('primary-background', 'btn');
      color: color('primary-foreground', 'btn');
    }

    svg {
      --size: 0.85em;
      block-size: var(--size);
      inline-size: var(--size);
    }
  }
}

.combobox-item {
  align-items: center;
  background-color: color('item-background', 'combobox');
  border-radius: 1em;
  color: color('item-foreground', 'combobox');
  display: flex;
  font-size: config('font-size-sm', $typography);
  gap: spacer('xxs');
  line-height: 1;
  padding-block: spacer('xxs');
  padding-inline: spacer('xs') spacer('xxs');

  .btn--sm {
    @include set-css-variable((
      --icon-padding: 0.25em,
      --border-radius: 1em,
    ));
  }
}
<div class="form-group">
    <label class="form-label" for="colors">Choose colors</label>
    <div
        class="combobox"
        x-cloak
        x-data="combobox"
        x-id="['dropdown', 'list-item']"
        x-on:focusin.window="! $refs.panel.contains($event.target) && close()"
        x-on:keydown.escape.prevent.stop="close($refs.input)"
    >
        <div
            class="combobox__selected-items"
            x-show="selectedItems().length > 0"
        >
            <template x-for="(item, index) in selectedItems" :key="item.id">
                <span
                    class="combobox-item"
                    x-show="item.selected"
                >
                    <span x-text="item.label"></span>
                    <button
                    :aria-label="`Remove ${item.label}`"
                        @click="unselectItemById(item.id)"
                        class="btn btn--primary btn--sm btn--icon"
                    >
                        <svg class="btn__icon" aria-hidden="true" fill="none" focusable="false" height="24" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5" stroke="currentColor" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
                            <line x1="18" y1="6" x2="6" y2="18"></line>
                            <line x1="6" y1="6" x2="18" y2="18"></line>
                        </svg>
                    </button>
                </span>
            </template>
        </div>
        <div
            class="combobox__inner"
            @click.outside="close($refs.input)"
            x-ref="panel"
        >
            <input
                :aria-activedescendant="highlightedItemId()"
                :aria-controls="$id('dropdown')"
                :aria-expanded="open"
                @click="toggle()"
                aria-autocomplete="list"
                class="form-control combobox__control"
                id="colors"
                placeholder="Search for a color"
                role="combobox"
                type="text"
                x-model="search"
                x-on:keyup.down.prevent="highlightNextItem"
                x-on:keyup.enter.prevent="toggleFromKeyboard"
                x-on:keyup.up.prevent="highlightPreviousItem"
                x-on:keyup="if ($event.key !== 'Escape' && $event.key !== 'Tab' && $event.key !== 'Shift') { open = true }"
                x-ref="input"
            >
            <div
                class="combobox__dropdown"
                x-show="open || search.length > 0"
            >
                <ul
                    :id="$id('dropdown')"
                    aria-label="Colors"
                    aria-multiselectable="true"
                    role="listbox"
                    tabindex="-1"
                    x-ref="listbox"
                >
                    <template x-for="(item, index) in filteredItems" :key="item.id">
                        <li
                            :aria-selected="item.selected"
                            :class="{'highlighted': index === highlightedItemIndex}"
                            :id="$id('list-item', item.id)"
                            @click.prevent="item.selected = ! item.selected; highlightedItemIndex = null;"
                            role="option"
                        >
                            <span x-text="item.label"></span>
                            <span x-show="item.selected">
                                <svg aria-hidden="true" fill="none" focusable="false" height="24" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" width="24" xmlns="http://www.w3.org/2000/svg">
                                    <polyline points="20 6 9 17 4 12"></polyline>
                                </svg>
                            </span>
                        </li>
                    </template>
                    <li
                        class="combobox__no-results"
                        x-show="search !== '' && filteredItems().length === 0"
                    >
                        No results found.
                    </li>
                </ul>
            </div>
        </div>
        <button
            @click="deselectAllItems"
            class="btn btn--outline-primary btn--sm combobox__reset"
            x-show="selectedItems().length > 0"
        >
            Deselect all colors
        </button>
    </div>
</div>
document.addEventListener('alpine:init', () => {
  window.Alpine.data('combobox', () => ({
    items: [{
      id: 'red',
      label: 'Red',
      selected: false,
    },
    {
      id: 'orange',
      label: 'Orange',
      selected: false,
    },
    {
      id: 'yellow',
      label: 'Yellow',
      selected: false,
    },
    {
      id: 'green',
      label: 'Green',
      selected: false,
    },
    {
      id: 'blue',
      label: 'Blue',
      selected: false,
    },
    {
      id: 'purple',
      label: 'Purple',
      selected: false,
    },
    {
      id: 'hot-pink',
      label: 'Hot pink',
      selected: false,
    },
    {
      id: 'light-pink',
      label: 'Light pink',
      selected: false,
    },
    {
      id: 'white',
      label: 'White',
      selected: false,
    },
    {
      id: 'black',
      label: 'Black',
      selected: false,
    },
    {
      id: 'brown',
      label: 'Brown',
      selected: false,
    },
    ],
    open: false,
    search: '',
    highlightedItemIndex: null,
    selectedItems() {
      return this.items.filter((item) => item.selected);
    },
    unselectItemById(id) {
      this.items.find((item) => item.id === id).selected = false;
    },
    filteredItems() {
      return this.items.filter((item) => item.label.toLowerCase().includes(this.search.toLowerCase()));
    },
    toggle() {
      if (this.open) {
        return this.close();
      }

      this.$refs.input.focus();
      this.open = true;
      this.highlightedItemIndex = null;
    },
    close(focusAfter) {
      if (!this.open) return;

      this.open = false;
      this.search = '';
      this.highlightedItemIndex = null;

      focusAfter && focusAfter.focus();
    },
    highlightNextItem() {
      this.open = true;

      if (this.highlightedItemIndex === null) {
        this.highlightedItemIndex = 0;
        return;
      }

      this.highlightedItemIndex++;

      if (this.highlightedItemIndex >= this.filteredItems().length) {
        this.highlightedItemIndex = 0;
      }

      this.$refs.listbox.children[this.highlightedItemIndex + 1].scrollIntoView({
        behavior: 'smooth',
        block: 'nearest',
      });
    },
    highlightPreviousItem() {
      this.open = true;
      this.highlightedItemIndex--;

      if (this.highlightedItemIndex < 0) {
        this.highlightedItemIndex = this.filteredItems().length - 1;
      }

      this.$refs.listbox.children[this.highlightedItemIndex + 1].scrollIntoView({
        behavior: 'smooth',
        block: 'nearest',
      });
    },
    highlightedItemId() {
      const highlightedItem = this.filteredItems()[this.highlightedItemIndex];
      return highlightedItem ? this.$id('list-item', highlightedItem.id) : null;
    },
    toggleFromKeyboard() {
      if (this.highlightedItemIndex === null) {
        return;
      }

      this.filteredItems()[this.highlightedItemIndex].selected = !this.filteredItems()[this.highlightedItemIndex].selected;
    },
    deselectAllItems() {
      this.items.forEach((item) => item.selected = false);
    },
  }));
});

Combobox

A fully functional combobox for multi-selecting items.

Technical Details

  • Right now, this is the best version of a combobox we can come up with. It's not perfect, but it's pretty good and accessible.
  • You can fully operate it with the keyboard.

Dependencies

  • Alpine.js - Some or all of the components require Alpine.js for functionality.
  • Alpine.js Focus Plugin - You should also consider adding the focus plugin for better focus management.

Colors

$colors: (
  'combobox': (
    'item-background': hsl(262deg 71% 98%),
    'item-foreground': hsl(262deg 71% 49%),
  ),
);
// Dark mode addition to color the chevron icon.
[data-theme-mode='dark'] {
  color-scheme: dark;

  .combobox__control {
    @include field-icon(
      config('select', $form-icon, false),
      color('select-foreground', 'form', true, $dark-colors),
    );
  }
}

Documentation

Learn about Spruce CSS through our extensive documentation.

Components

Explore our extensive UI library built with Spruce CSS.

Blog

Read about front-end development and concepts of Spruce CSS.

Find us on GitHub

Did you find a bug? Have an idea or a question? Please open an issue to help us develop the project.