@use 'sprucecss/scss/spruce' as *;
.combobox {
display: flex;
flex-direction: column;
gap: spacer('xs');
&__inner {
position: relative;
}
&__control {
padding-inline-end: 2.5rem;
}
&__chevron {
block-size: 1rem;
color: color('muted');
inline-size: 1rem;
inset-block-start: 50%;
inset-inline-end: spacer('s');
pointer-events: none;
position: absolute;
translate: 0 -50%;
}
&__control[aria-expanded='true'] + &__chevron {
rotate: 180deg;
}
&__listbox,
&__empty {
background-color: color('surface');
border: config('border-width-base', $display) solid color('border');
border-radius: radius('md');
box-shadow: shadow('lg');
inset-block-start: calc(100% + #{spacer('xxs')});
inset-inline: 0;
margin: 0;
padding: spacer('xxs');
position: absolute;
z-index: z-index('dropdown');
&[hidden] {
display: none;
}
}
&__listbox {
@include clear-list;
@include scrollbar;
max-block-size: 15rem;
overflow-y: auto;
> li {
margin: 0 !important;
}
}
&__empty {
color: color('muted');
padding: spacer('xs') spacer('s');
}
&__option {
align-items: center;
border-radius: radius('sm');
cursor: pointer;
display: flex;
gap: spacer('xs');
padding: spacer('xs') spacer('s');
user-select: none;
&[hidden] {
display: none;
}
&:hover,
&.is-active {
background-color: color-alpha('primary', 0.1);
color: color('primary');
}
&.is-active {
box-shadow: inset 0 0 0 2px color('focus-ring');
}
&[aria-selected='true'] {
font-weight: 700;
}
}
&__swatch {
background-color: var(--swatch);
block-size: 1rem;
border-radius: 50%;
box-shadow: inset 0 0 0 1px hsl(0deg 0% 0% / 20%);
flex-shrink: 0;
inline-size: 1rem;
}
&__check {
block-size: 1rem;
inline-size: 1rem;
margin-inline-start: auto;
visibility: hidden;
}
&__option[aria-selected='true'] &__check {
visibility: visible;
}
&__selected {
@include clear-list;
display: flex;
flex-wrap: wrap;
gap: spacer('xs');
&:empty {
display: none;
}
> li {
margin: 0 !important;
margin-block: 0;
padding-block: 0;
}
}
&__reset {
align-self: start;
&[hidden] {
display: none;
}
}
}
.combobox-item {
align-items: center;
background-color: color-alpha('primary', 0.1);
border-radius: radius('pill');
color: color('primary');
display: flex;
font-size: config('font-size-sm', $typography);
font-weight: 600;
gap: spacer('xxs');
line-height: 1;
padding-block: spacer('xxs');
padding-inline: spacer('xs') spacer('xxs');
&__remove {
@include clear-btn;
align-items: center;
block-size: 1.5rem;
border-radius: 50%;
display: inline-flex;
inline-size: 1.5rem;
justify-content: center;
&:hover {
background-color: color-alpha('primary', 0.15);
}
&:focus-visible {
outline: 2px solid color('focus-ring');
outline-offset: 1px;
}
svg {
block-size: 0.75rem;
inline-size: 0.75rem;
}
}
}
<div class="form-group">
<label class="form-label" id="colors-label" for="colors">Colors</label>
<div class="combobox" data-combobox data-name="colors[]">
<ul class="combobox__selected" aria-label="Selected colors" data-combobox-selected></ul>
<div class="combobox__inner">
<input
class="form-control combobox__control"
id="colors"
type="text"
role="combobox"
autocomplete="off"
aria-autocomplete="list"
aria-expanded="false"
aria-controls="colors-listbox"
aria-describedby="colors-help"
placeholder="Search for a color"
>
<svg class="combobox__chevron" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m6 9 6 6 6-6"/></svg>
<ul class="combobox__listbox" id="colors-listbox" role="listbox" aria-labelledby="colors-label" aria-multiselectable="true" hidden>
<li class="combobox__option" id="colors-option-red" role="option" aria-selected="false" data-value="red">
<span class="combobox__swatch" style="--swatch: hsl(0deg 80% 55%);" aria-hidden="true"></span>
Red
<svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
</li>
<li class="combobox__option" id="colors-option-orange" role="option" aria-selected="false" data-value="orange">
<span class="combobox__swatch" style="--swatch: hsl(28deg 95% 55%);" aria-hidden="true"></span>
Orange
<svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
</li>
<li class="combobox__option" id="colors-option-yellow" role="option" aria-selected="false" data-value="yellow">
<span class="combobox__swatch" style="--swatch: hsl(48deg 95% 55%);" aria-hidden="true"></span>
Yellow
<svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
</li>
<li class="combobox__option" id="colors-option-green" role="option" aria-selected="false" data-value="green">
<span class="combobox__swatch" style="--swatch: hsl(140deg 55% 42%);" aria-hidden="true"></span>
Green
<svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
</li>
<li class="combobox__option" id="colors-option-teal" role="option" aria-selected="false" data-value="teal">
<span class="combobox__swatch" style="--swatch: hsl(180deg 60% 35%);" aria-hidden="true"></span>
Teal
<svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
</li>
<li class="combobox__option" id="colors-option-blue" role="option" aria-selected="true" data-value="blue">
<span class="combobox__swatch" style="--swatch: hsl(215deg 85% 55%);" aria-hidden="true"></span>
Blue
<svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
</li>
<li class="combobox__option" id="colors-option-purple" role="option" aria-selected="false" data-value="purple">
<span class="combobox__swatch" style="--swatch: hsl(270deg 60% 55%);" aria-hidden="true"></span>
Purple
<svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
</li>
<li class="combobox__option" id="colors-option-pink" role="option" aria-selected="false" data-value="pink">
<span class="combobox__swatch" style="--swatch: hsl(330deg 80% 70%);" aria-hidden="true"></span>
Pink
<svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
</li>
<li class="combobox__option" id="colors-option-brown" role="option" aria-selected="false" data-value="brown">
<span class="combobox__swatch" style="--swatch: hsl(25deg 45% 35%);" aria-hidden="true"></span>
Brown
<svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
</li>
<li class="combobox__option" id="colors-option-white" role="option" aria-selected="false" data-value="white">
<span class="combobox__swatch" style="--swatch: hsl(0deg 0% 100%);" aria-hidden="true"></span>
White
<svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
</li>
<li class="combobox__option" id="colors-option-black" role="option" aria-selected="false" data-value="black">
<span class="combobox__swatch" style="--swatch: hsl(0deg 0% 8%);" aria-hidden="true"></span>
Black
<svg class="combobox__check" aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M20 6 9 17l-5-5"/></svg>
</li>
</ul>
<p class="combobox__empty" hidden data-combobox-empty>No colors found.</p>
</div>
<p class="form-description" id="colors-help">Pick as many as you like. Backspace removes the last one.</p>
<button class="btn btn--outline-primary btn--sm combobox__reset" type="button" hidden data-combobox-reset>Clear all colors</button>
<p class="sr-only" aria-live="polite" data-combobox-status></p>
</div>
</div>
document.querySelectorAll('[data-combobox]').forEach((combobox) => {
const input = combobox.querySelector('[role="combobox"]');
const listbox = combobox.querySelector('[role="listbox"]');
const options = [...listbox.querySelectorAll('[role="option"]')];
const selectedList = combobox.querySelector('[data-combobox-selected]');
const empty = combobox.querySelector('[data-combobox-empty]');
const reset = combobox.querySelector('[data-combobox-reset]');
const status = combobox.querySelector('[data-combobox-status]');
const label = (option) => option.textContent.trim();
const visible = () => options.filter((option) => !option.hidden);
let active = null;
const setActive = (option) => {
options.forEach((item) => item.classList.toggle('is-active', item === option));
active = option;
if (option) {
input.setAttribute('aria-activedescendant', option.id);
option.scrollIntoView({ block: 'nearest' });
} else {
input.removeAttribute('aria-activedescendant');
}
};
const open = () => {
const results = visible().length;
listbox.hidden = results === 0;
empty.hidden = results > 0;
input.setAttribute('aria-expanded', String(results > 0));
};
const close = () => {
listbox.hidden = true;
empty.hidden = true;
input.setAttribute('aria-expanded', 'false');
setActive(null);
};
const render = () => {
const selected = options.filter((option) => option.getAttribute('aria-selected') === 'true');
selectedList.replaceChildren(...selected.map((option) => {
const item = document.createElement('li');
item.className = 'combobox-item';
item.innerHTML = '<span></span><button class="combobox-item__remove" type="button"><span class="sr-only"></span><svg aria-hidden="true" fill="none" focusable="false" stroke-linecap="round" stroke-linejoin="round" stroke-width="3.5" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="M18 6 6 18M6 6l12 12"/></svg></button><input type="hidden">';
item.querySelector('span').textContent = label(option);
item.querySelector('.sr-only').textContent = `Remove ${label(option)}`;
item.querySelector('input').name = combobox.dataset.name;
item.querySelector('input').value = option.dataset.value;
item.querySelector('button').addEventListener('click', () => {
toggle(option);
input.focus();
});
return item;
}));
reset.hidden = selected.length === 0;
};
const toggle = (option) => {
const selected = option.getAttribute('aria-selected') !== 'true';
option.setAttribute('aria-selected', String(selected));
status.textContent = `${label(option)} ${selected ? 'selected' : 'removed'}.`;
render();
combobox.dispatchEvent(new CustomEvent('combobox:change', {
bubbles: true,
detail: options.filter((item) => item.getAttribute('aria-selected') === 'true').map((item) => item.dataset.value)
}));
};
const filter = () => {
const query = input.value.trim().toLowerCase();
options.forEach((option) => { option.hidden = !label(option).toLowerCase().includes(query); });
setActive(null);
open();
const results = visible().length;
status.textContent = query ? `${results} ${results === 1 ? 'result' : 'results'} available.` : '';
};
const move = (step) => {
const items = visible();
if (listbox.hidden) open();
if (!items.length) return;
const index = items.indexOf(active);
setActive(items[index === -1 ? (step > 0 ? 0 : items.length - 1) : (index + step + items.length) % items.length]);
};
input.addEventListener('input', filter);
input.addEventListener('click', () => (listbox.hidden ? open() : close()));
input.addEventListener('keydown', (event) => {
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
if (event.altKey) { open(); } else { move(1); }
break;
case 'ArrowUp':
event.preventDefault();
move(-1);
break;
case 'Enter':
if (active) {
event.preventDefault();
toggle(active);
}
break;
case 'Escape':
if (!listbox.hidden || !empty.hidden) {
close();
} else if (input.value) {
input.value = '';
filter();
close();
}
break;
case 'Backspace': {
const last = options.filter((option) => option.getAttribute('aria-selected') === 'true').pop();
if (!input.value && last) toggle(last);
break;
}
case 'Tab':
close();
break;
}
});
options.forEach((option) => {
option.addEventListener('mousedown', (event) => event.preventDefault());
option.addEventListener('click', () => {
toggle(option);
setActive(option);
});
});
reset.addEventListener('click', () => {
options.forEach((option) => option.setAttribute('aria-selected', 'false'));
render();
status.textContent = 'All colors removed.';
input.focus();
});
document.addEventListener('click', (event) => {
if (!combobox.contains(event.target)) close();
});
render();
});