@use 'sprucecss/scss/spruce' as *;
.autocomplete {
@include layout-stack('xs');
&__control {
position: relative;
}
&__listbox {
@include clear-list;
background-color: color('surface');
border: config('border-width-base', $display) solid color('border');
border-radius: radius('md');
box-shadow: shadow('lg');
inline-size: 100%;
inset-block-start: calc(100% + #{spacer('xxs')});
inset-inline-start: 0;
max-block-size: 15rem;
overflow-y: auto;
padding: spacer('xxs');
position: absolute;
z-index: z-index('dropdown');
li {
border-radius: radius('sm');
cursor: pointer;
margin: 0 !important;
padding: spacer('xs') spacer('s');
&[aria-selected='true'] {
background-color: color-alpha('primary', 0.12);
color: color('primary');
}
mark {
background-color: transparent;
color: inherit;
font-weight: 700;
padding: 0;
}
}
}
}
<div class="autocomplete" data-autocomplete>
<label class="form-label" id="autocomplete-label" for="autocomplete-input">Country</label>
<div class="autocomplete__control">
<input class="form-control" id="autocomplete-input" type="text" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="autocomplete-listbox" autocomplete="off" placeholder="Start typing…" data-autocomplete-input>
<ul class="autocomplete__listbox" id="autocomplete-listbox" role="listbox" aria-labelledby="autocomplete-label" hidden data-autocomplete-listbox>
<li role="option" id="autocomplete-option-0" aria-selected="false">Argentina</li>
<li role="option" id="autocomplete-option-1" aria-selected="false">Australia</li>
<li role="option" id="autocomplete-option-2" aria-selected="false">Austria</li>
<li role="option" id="autocomplete-option-3" aria-selected="false">Belgium</li>
<li role="option" id="autocomplete-option-4" aria-selected="false">Brazil</li>
<li role="option" id="autocomplete-option-5" aria-selected="false">Canada</li>
<li role="option" id="autocomplete-option-6" aria-selected="false">Denmark</li>
<li role="option" id="autocomplete-option-7" aria-selected="false">Finland</li>
<li role="option" id="autocomplete-option-8" aria-selected="false">France</li>
<li role="option" id="autocomplete-option-9" aria-selected="false">Germany</li>
<li role="option" id="autocomplete-option-10" aria-selected="false">Hungary</li>
<li role="option" id="autocomplete-option-11" aria-selected="false">Ireland</li>
<li role="option" id="autocomplete-option-12" aria-selected="false">Italy</li>
<li role="option" id="autocomplete-option-13" aria-selected="false">Japan</li>
<li role="option" id="autocomplete-option-14" aria-selected="false">Mexico</li>
<li role="option" id="autocomplete-option-15" aria-selected="false">Netherlands</li>
<li role="option" id="autocomplete-option-16" aria-selected="false">Norway</li>
<li role="option" id="autocomplete-option-17" aria-selected="false">Portugal</li>
<li role="option" id="autocomplete-option-18" aria-selected="false">Spain</li>
<li role="option" id="autocomplete-option-19" aria-selected="false">Sweden</li>
<li role="option" id="autocomplete-option-20" aria-selected="false">Switzerland</li>
<li role="option" id="autocomplete-option-21" aria-selected="false">United Kingdom</li>
<li role="option" id="autocomplete-option-22" aria-selected="false">United States</li>
</ul>
</div>
<p class="sr-only" aria-live="polite" data-autocomplete-status></p>
</div>
(() => {
const escape = (text) => text.replace(/[&<>"]/g, (char) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[char]);
document.querySelectorAll('[data-autocomplete]').forEach((component) => {
const input = component.querySelector('[data-autocomplete-input]');
const listbox = component.querySelector('[data-autocomplete-listbox]');
const status = component.querySelector('[data-autocomplete-status]');
const options = [...listbox.querySelectorAll('[role="option"]')];
const labels = options.map((option) => option.textContent);
let visible = [];
let active = -1;
const close = () => {
listbox.hidden = true;
input.setAttribute('aria-expanded', 'false');
input.removeAttribute('aria-activedescendant');
active = -1;
};
const highlight = (index) => {
options.forEach((option) => option.setAttribute('aria-selected', 'false'));
active = (index + visible.length) % visible.length;
visible[active].setAttribute('aria-selected', 'true');
visible[active].scrollIntoView({ block: 'nearest' });
input.setAttribute('aria-activedescendant', visible[active].id);
};
const open = () => {
const query = input.value.trim().toLowerCase();
visible = [];
options.forEach((option, index) => {
const label = labels[index];
const position = label.toLowerCase().indexOf(query);
const isMatch = position > -1;
option.hidden = !isMatch;
option.setAttribute('aria-selected', 'false');
if (isMatch) {
option.innerHTML = query
? `${escape(label.slice(0, position))}<mark>${escape(label.slice(position, position + query.length))}</mark>${escape(label.slice(position + query.length))}`
: escape(label);
visible.push(option);
}
});
active = -1;
listbox.hidden = visible.length === 0;
input.setAttribute('aria-expanded', String(visible.length > 0));
status.textContent = `${visible.length} ${visible.length === 1 ? 'suggestion' : 'suggestions'}`;
};
const select = (option) => {
input.value = option.textContent;
close();
};
input.addEventListener('input', open);
input.addEventListener('focus', () => input.value && open());
input.addEventListener('keydown', (event) => {
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault();
if (listbox.hidden) open();
if (visible.length) highlight(active + (event.key === 'ArrowDown' ? 1 : -1));
} else if (event.key === 'Enter' && active > -1) {
event.preventDefault();
select(visible[active]);
} else if (event.key === 'Escape') {
if (listbox.hidden) input.value = '';
close();
}
});
options.forEach((option) => {
option.addEventListener('mousedown', (event) => event.preventDefault());
option.addEventListener('click', () => select(option));
});
document.addEventListener('click', (event) => {
if (!component.contains(event.target)) close();
});
});
})();