<div class="js-combobox" data-options='[{"title":"American beech","slug":"american-beech"},{"title":"American sycamore","slug":"american-sycamore"},{"title":"Northern red oak","slug":"northern-red-oak"},{"title":"Southern red oak","slug":"southern-red-oak"},{"title":"White oak","slug":"white-oak"},{"title":"Willow oak","slug":"willow-oak"},{"title":"Red maple","slug":"red-maple"},{"title":"Sugar maple","slug":"sugar-maple"},{"title":"Eastern redbud","slug":"eastern-redbud"},{"title":"Littleleaf linden","slug":"littleleaf-linden"}]' data-listbox-heading="Search suggestions:" data-filter-options="false"></div>
<link media="all" rel="stylesheet" href="/combobox/combobox.css" />
<script src="/combobox/combobox.js"></script>
<div class="js-combobox"
data-options='{{ options|json_encode() }}'
data-listbox-heading="Search suggestions:"
data-filter-options="{{ false|json_encode() }}"
></div>
{% import "_macros.twig" as h %}
{{ h.componentAssets('combobox') }}
{
"options": [
{
"title": "American beech",
"slug": "american-beech"
},
{
"title": "American sycamore",
"slug": "american-sycamore"
},
{
"title": "Northern red oak",
"slug": "northern-red-oak"
},
{
"title": "Southern red oak",
"slug": "southern-red-oak"
},
{
"title": "White oak",
"slug": "white-oak"
},
{
"title": "Willow oak",
"slug": "willow-oak"
},
{
"title": "Red maple",
"slug": "red-maple"
},
{
"title": "Sugar maple",
"slug": "sugar-maple"
},
{
"title": "Eastern redbud",
"slug": "eastern-redbud"
},
{
"title": "Littleleaf linden",
"slug": "littleleaf-linden"
}
]
}
<script setup>
//modeled on https://www.w3.org/WAI/ARIA/apg/patterns/combobox/examples/grid-combo/
import {
reactive,
ref,
useTemplateRef,
useId,
onMounted,
watch,
computed,
onUnmounted,
nextTick,
} from "vue";
import slugify from "slugify";
const props = defineProps({
label: {
type: String,
default: "Select Option",
},
idPrefix: {
type: String,
default: () => useId(),
},
inputName: {
type: String,
default: "s",
},
filterOptions: {
type: Boolean,
default: true,
},
listboxHeading: {
type: String,
},
noResultsText: {
type: String,
default: "No results match your search.",
},
options: {
type: Array,
required: true,
},
optionsSearchableKeys: {
type: Array,
default: () => ["title"],
},
placeholder: {
type: String,
default: "",
},
required: {
type: Boolean,
default: false,
},
showInputLabel: {
type: Boolean,
default: true,
},
srOnlyLabel: {
type: Boolean,
default: false,
},
teleport: {
type: Boolean,
default: false,
},
teleportTarget: {
type: String,
default: "body",
},
});
const query = defineModel({ type: String, default: "" });
const state = reactive({
open: false,
focused: null,
focusedIndex: -1,
selected: null,
});
const optionSelectedObserver = ref(null);
const listboxResizeObserver = ref(null);
const comboboxEl = useTemplateRef("combobox-el");
const inputEl = useTemplateRef("input-el");
const listboxEl = useTemplateRef("listbox-el");
const emit = defineEmits(["select"]);
//Lifecycle
onMounted(() => {
document.addEventListener("click", handleClickOutside);
//if visual focus on a combobox option is not visible, scroll the <ul>
optionSelectedObserver.value = new MutationObserver((mutations) => {
for (const m of mutations) {
const selected = m.target.getAttribute("aria-selected");
if (selected) {
_scrollIntoViewIfNeeded(m.target);
}
}
});
optionSelectedObserver.value.observe(listboxEl.value, {
subtree: true,
attributes: true,
attributeFilter: ["aria-selected"],
});
//scroll listener to reposition listbox if teleported
if (props.teleport) {
document.addEventListener("scroll", setListboxPosition);
listboxResizeObserver.value = new ResizeObserver((entries) => {
setListboxPosition();
});
listboxResizeObserver.value.observe(document.body);
}
});
onUnmounted(() => {
document.removeEventListener("click", handleClickOutside);
optionSelectedObserver.value.disconnect();
if (props.teleport) {
document.removeEventListener("scroll", setListboxPosition);
listboxResizeObserver.value.unobserve(document.body);
}
});
//Methods
function setSelected(item) {
state.selected = item;
emit("select", item);
query.value = item[props.optionsSearchableKeys[0]];
nextTick(() => {
state.open = false;
});
}
//if teleporting the listbox add some js to attach it to the input
//replace with https://developer.mozilla.org/en-US/docs/Web/CSS/anchor-name when standard
function setListboxPosition() {
if (!props.teleport || !listboxIsOpen.value) return;
//get width and left and top offsets of input and set on listbox
const inputBox = inputEl.value.getBoundingClientRect();
listboxEl.value.style.setProperty(
"--dropdownMaxWidth",
inputBox.width + "px"
);
listboxEl.value.style.top = inputBox.bottom + window.scrollY + "px";
listboxEl.value.style.left = inputBox.left + window.scrollX + "px";
}
//Key Events
function onComboArrowDown() {
if (!listboxIsOpen.value) return;
if (state.focusedIndex > -1) {
if (state.focusedIndex === filteredOptions.value.length - 1)
state.focusedIndex = 0;
else state.focusedIndex++;
} else {
state.focusedIndex = 0;
}
}
function onComboArrowUp() {
if (!listboxIsOpen.value) return;
if (state.focusedIndex > -1) {
if (state.focusedIndex === 0)
state.focusedIndex = filteredOptions.value.length - 1;
else state.focusedIndex--;
} else {
state.focusedIndex = filteredOptions.value.length - 1;
}
}
function onComboEsc() {
if (listboxIsOpen.value) state.open = false;
else query.value = "";
}
function onComboEnter($event) {
if (listboxIsOpen.value && state.focusedIndex > -1) {
$event.preventDefault();
setSelected(filteredOptions.value[state.focusedIndex]);
}
}
function onComboHome() {
//clear focus and set cursor to beginning of input
state.focusedIndex = -1;
inputEl.value.setSelectionRange(0, 0);
}
function onComboEnd() {
//clear focus and set cursor to end of input
state.focusedIndex = -1;
inputEl.value.setSelectionRange(9999, 9999);
}
function onComboType($event) {
const key = $event.key;
if (["ArrowUp", "ArrowDown", "Home", "End", "Esc", "Enter"].includes(key))
return;
state.focusedIndex = -1;
}
function handleClickOutside(event) {
if (!comboboxEl.value.contains(event.target)) {
state.open = false;
}
}
//Computed
const filteredOptions = computed(() => {
return !props.filterOptions
? props.options
: props.options.filter((option) => {
return props.optionsSearchableKeys.some((key) => {
return option[key].toLowerCase().includes(query.value.toLowerCase());
});
});
});
const listboxIsOpen = computed(() => {
return state.open && query.value && query.value.length > 1;
});
const activeDescendant = computed(() => {
return filteredOptions.value[state.focusedIndex]
? idify(
filteredOptions.value[state.focusedIndex][
props.optionsSearchableKeys[0]
]
)
: null;
});
//Watchers
watch(query, (value) => {
if (value.length > 1) state.open = true;
});
watch(listboxIsOpen, (value) => {
if (value) setListboxPosition();
});
//Helpers
function idify(id) {
return slugify(`${props.idPrefix}__${id}`);
}
function accessObjectVal(key, obj) {
return key.split(".").reduce((p, c) => (p && p[c]) || null, obj);
}
function highlightOption(option) {
const retval = {};
for (const key in option) {
if (props.optionsSearchableKeys.includes(key))
retval[key] = highlight(query.value, option[key]);
else retval[key] = option[key];
}
return retval;
}
function highlight(needle, haystack, highlighterClass = idify("highlight")) {
const replace = "(" + needle + ")+";
const re = new RegExp(replace, "gi");
return haystack.replace(
re,
"<span class='listbox__itemResultHighlight'>$1</span>"
);
}
function _scrollIntoViewIfNeeded(element) {
const rect = element.getBoundingClientRect();
const containerRect = listboxEl.value.getBoundingClientRect();
const isVisible =
rect.top >= containerRect.top &&
rect.left >= containerRect.left &&
rect.bottom <= containerRect.bottom &&
rect.right <= containerRect.right;
if (!isVisible) {
element.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}
</script>
<template>
<div class="combobox" ref="combobox-el">
<label
v-if="showInputLabel"
:for="idify('input')"
:id="idify('label')"
class="combobox-label"
:class="{ 'u-srOnly': srOnlyLabel }"
>{{ label }}</label
>
<input
type="search"
v-model="query"
ref="input-el"
:id="idify('input')"
class="input input--text combobox__input"
autocomplete="off"
:placeholder="placeholder"
:name="inputName"
:required="required"
role="combobox"
aria-haspopup="listbox"
aria-autocomplete="list"
:aria-expanded="listboxIsOpen"
:aria-controls="idify('popup')"
:aria-activedescendant="activeDescendant"
v-on:focusin="state.open = true"
v-on:keydown.down.prevent="onComboArrowDown"
v-on:keydown.up.prevent="onComboArrowUp"
v-on:keydown.esc.prevent="onComboEsc"
v-on:keydown.enter="onComboEnter"
v-on:keydown.home.prevent="onComboHome"
v-on:keydown.end.prevent="onComboEnd"
v-on:keydown="onComboType"
/>
<Teleport :to="teleportTarget" :disabled="!teleport">
<div
class="combobox__listbox listbox"
:class="{ 'listbox--noHeading': !listboxHeading }"
:id="idify('popup')"
ref="listbox-el"
v-show="listboxIsOpen"
>
<span
v-if="listboxHeading"
class="listbox__heading"
:id="idify('listboxHeading')"
>{{ listboxHeading }}</span
>
<ul
class="listbox__items"
role="listbox"
:aria-labelledby="
listboxHeading ? idify('listboxHeading') : idify('label')
"
>
<li
v-for="(option, index) in filteredOptions"
:id="idify(option[props.optionsSearchableKeys[0]])"
class="listbox__item listbox__item--clickable"
:class="{
'listbox__item--focused': state.focusedIndex === index,
}"
:key="option[props.optionsSearchableKeys[0]]"
role="option"
:aria-selected="state.focusedIndex === index"
v-on:click="setSelected(option)"
>
<slot name="option" :option="highlightOption(option)"
><span
v-html="highlightOption(option)[optionsSearchableKeys[0]]"
></span
></slot>
</li>
<li
v-show="filteredOptions.length === 0"
key="no-results"
class="listbox__item listbox__item--solo"
>
{{ noResultsText }}
</li>
</ul>
</div>
</Teleport>
</div>
</template>
<style scoped lang="scss">
@use "../_layout/mixins";
@use "../panel/panel";
.combobox {
position: relative;
display: flex;
flex-direction: column;
padding-block: 0;
}
.combobox__input {
flex: 1;
width: 100%;
}
.listbox {
padding: var(--panelPadding);
border: 3px solid var(--blue--ultraDark);
border-block-start: 0;
background-color: var(--neutral--ultraLight);
box-shadow: 0 4px 6px 0 rgba(0, 0, 0, 0.25);
position: absolute;
left: 0;
right: 0;
top: 100%;
z-index: calc(var(--z) * 30);
max-width: var(--dropdownMaxWidth, 80vw);
box-sizing: border-box;
max-height: min(var(--dropdownMaxHeight, 500px), 80vh);
overflow: auto;
}
.listbox--noHeading {
--panelPadding: 0;
}
.listbox__heading {
font-weight: var(--bold-weight);
}
.listbox__items {
padding: 0;
}
.listbox__item {
list-style: none;
text-align: start;
margin-block-start: 0.2em;
color: var(--blue);
}
.listbox__item--solo {
padding: 0.3em 0.4em;
background-color: var(--neutral--ultraLight);
width: 100%;
}
@mixin listboxItemFocused {
--highlightColor: var(--blue--ultraDark);
--button-bg: var(--blue);
--button-border-color: var(--blue);
--button-color: var(--white);
}
.listbox__item--clickable {
--highlightColor: var(--blue--ultraLight);
--button-size: var(--step-0);
--button-padding: 0.3em 0.4em;
--button-border: none;
--button-color: var(--blue);
--button-bg: transparent;
@include mixins.button();
font-weight: var(--normal-weight);
width: 100%;
box-sizing: border-box;
transition: background-color 0.2s ease-out, color 0.2s ease-out;
text-align: start;
cursor: pointer;
&:hover,
&--focus {
@include listboxItemFocused;
}
}
.listbox__item--focused {
@include listboxItemFocused;
}
</style>
<style>
.listbox__itemResultHighlight {
font-weight: var(--bold-weight);
transition: background-color 0.2s ease-out;
}
</style>
import { createApp } from "vue";
import Combobox from "./Combobox.vue";
const els = document.querySelectorAll(".js-combobox");
els.forEach((el) => {
const app = createApp(Combobox, {
listboxHeading: el.dataset.listboxHeading,
options: JSON.parse(el.dataset.options),
filterOptions: JSON.parse(el.dataset.filterOptions),
});
app.mount(el);
});
An editable combobox with dropdown (list autocomplete).
By default, the listbox <ul> is a sibling to the input in the DOM. This typically works fine to display the listbox directly below the input using absolute positioning. However, sometimes the combobox might be in a container with hidden overflow or position: sticky, in which case the listbox will not overflow the container’s bounds and be cut off or force scrolling. To fix this, pass :teleport="true" prop and the listbox will be appended to the <body> tag where a resize observer will automatically handle sizing and positioning directly below the input. You can also choose where the listbox teleports to (default is <body>) by passing :teleportTarget="#listbox-container". See Vue’s documentation on Teleport.
The optionsSearchableKeys prop accepts an array of keys that should correspond to the object keys in the options prop array. These values are the only ones that will be searched and highlighted.
<Combobox
:options="[
{title:'Bald Eagle',latin:'Haliaeetus leucocephalus', category: 'birds'},
{title:'Baltimore Checkerspot',latin:'Euphydryas phaeton', category: 'insects'},
{title:'Baltimore Oriole',latin:'Icterus galbula', category: 'birds'},
{title:'Barn Owl',latin:'Tyto alba', category: 'birds'}
]"
:optionsSearchableKeys="['title', 'latin']"
></Combobox>The above example will use only title and latin key in the options array to search based on the user’s input.
The listbox options <li> defaults to displaying the value from the first key from optionsSearchableKeys. Continuing with the above example, the listbox options will only display the title key’s value with the user’s query highlighted. However, this can be overridden with the option slot. The entire option object is passed as a prop and the values from all the keys in optionsSearchableKeys are highlighted:
<Combobox>
<template v-slot:option="props">
<!-- title and latin are put in v-html because they contain <span> tags for the highlighting -->
<span v-html="props.option.title"></span><br>
<i v-html="props.option.latin" lang="la"></i><br>
<span>{{ props.option.category }}</span>
</template>
</Combobox>Note that props.option.category is still available to the slot template even though it wasn’t specified in the optionsSearchableKeys prop and isn’t highlighted.
Highlighted text is surrounded with span.listbox__itemResultHighlight.
Modeled on the pattern provided by Editable Combobox With List Autocomplete from W3C. Makes use of visual focus while retaining browser focus on the input element the entire time. See the keyboard support section for the keyboard interface.
Uses aria-activedescendant and aria-selected to tell screen readers which option has visual focus. A mutation observer keeps the option with visual focus in view of the scrollable listbox.