@use '../config/config';
@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-right', $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 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' class='btn__icon'>
<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' class='icon'>
<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);
},
}));
});