Accessible Combobox Input Selects
Also known as dropdown inputs (select)
A fork of Sarah Higley's
Examples
HTML
<div class="combo-wrap">
<!-- Basic HTML select element - recommended wherever possible -->
<label for="html-select" class="combo-label">Native HTML Select Element</label>
<div class="combo-select">
<select id="html-select" class="combo-input html-select">
<!-- <option>Apple</option> Injected from JS data -->
</select>
</div>
</div>
Note: id's must be unique
Apple
HTML
<div class="combo-wrap">
<label id="combo1-label" class="combo-label">Select Combobox Example <small>(select element equivalent)</small></label>
<div class="combo js-select">
<div
aria-activedescendant="combo1-value"
aria-autocomplete="none"
aria-controls="listbox1"
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="combo1-label"
id="combo1"
class="combo-input"
role="combobox"
tabindex="0"
>
<span class="combo1-value" id="combo1-value">
<!-- Selected option value injected by JavaScript-->
</span>
</div>
<div class="combo-menu" role="listbox" id="listbox1">
<!-- Options injected by JavaScript from data -->
</div>
</div>
</div>
Note: id's must be unique
Git repo:
HTML
<div class="combo-wrap">
<label for="combo2" class="combo-label">Editable Combobox Example</label>
<div class="combo js-combobox">
<input
aria-activedescendant=""
aria-autocomplete="none"
aria-controls="listbox2"
aria-expanded="false"
aria-haspopup="listbox"
id="combo2"
class="combo-input"
role="combobox"
type="text"
/>
<div class="combo-menu" role="listbox" id="listbox2">
<!-- Options injected by JavaScript from data -->
</div>
</div>
</div>
Note: id's must be unique
Git repo:
HTML
<div class="combo-wrap">
<label id="combo3-label" class="combo-label">Multi-select Combobox Example</label>
<!-- Used as descriptive text for option buttons; if used within the button text itself, it ends up being read with the input name -->
<span id="combo3-remove" style="display: none">remove</span>
<ul class="selected-options" id="combo3-selected">
<!-- Selected options injected by JavaScript -->
</ul>
<div class="combo js-multiselect">
<input
aria-activedescendant=""
aria-autocomplete="none"
aria-controls="listbox3"
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="combo3-label combo3-selected"
id="combo3"
class="combo-input"
role="combobox"
type="text"
/>
<div class="combo-menu" role="listbox" id="listbox3">
<!-- Options injected by JavaScript from data -->
</div>
</div>
</div>
Note: id's must be unique
Git repo:
The rest of the code
JavaScript
/*
* Helper constants and functions
*/
// make it easier for ourselves by putting some values in objects
// in TypeScript, these would be enums
const Keys = {
Backspace: 'Backspace',
Clear: 'Clear',
Down: 'ArrowDown',
End: 'End',
Enter: 'Enter',
Escape: 'Escape',
Home: 'Home',
Left: 'ArrowLeft',
PageDown: 'PageDown',
PageUp: 'PageUp',
Right: 'ArrowRight',
Space: ' ',
Tab: 'Tab',
Up: 'ArrowUp'
}
const MenuActions = {
Close: 0,
CloseSelect: 1,
First: 2,
Last: 3,
Next: 4,
Open: 5,
Previous: 6,
Select: 7,
Space: 8,
Type: 9
}
// filter an array of options against an input string
// returns an array of options that begin with the filter string, case-independent
function filterOptions(options = [], filter, exclude = []) {
return options.filter((option) => {
const matches = option.toLowerCase().indexOf(filter.toLowerCase()) === 0;
return matches && exclude.indexOf(option) < 0;
});
}
// return an array of exact option name matches from a comma-separated string
function findMatches(options, search) {
const names = search.split(',');
return names.map((name) => {
const match = options.filter((option) => name.trim().toLowerCase() === option.toLowerCase());
return match.length > 0 ? match[0] : null;
})
.filter((option) => option !== null);
}
// return combobox action from key press
function getActionFromKey(event, menuOpen) {
const { key, altKey, ctrlKey, metaKey } = event;
// handle opening when closed
if (!menuOpen && (key === Keys.Down || key === Keys.Enter || key === Keys.Space)) {
return MenuActions.Open;
}
// handle keys when open
if (key === Keys.Down) {
return MenuActions.Next;
}
else if (key === Keys.Up) {
return MenuActions.Previous;
}
else if (key === Keys.Home) {
return MenuActions.First;
}
else if (key === Keys.End) {
return MenuActions.Last;
}
else if (key === Keys.Escape) {
return MenuActions.Close;
}
else if (key === Keys.Enter) {
return MenuActions.CloseSelect;
}
else if (key === Keys.Space) {
return MenuActions.Space;
}
// MJF added to replicate tab key usage when option selected
else if (key === Keys.Tab) {
if (menuOpen) return MenuActions.CloseSelect;
}
else if (key === Keys.Backspace || key === Keys.Clear || (key.length === 1 && !altKey && !ctrlKey && !metaKey)) {
return MenuActions.Type;
}
}
// get index of option that matches a string
// if the filter is multiple iterations of the same letter (e.g "aaa"),
// then return the nth match of the single letter
function getIndexByLetter(options, filter) {
const firstMatch = filterOptions(options, filter)[0];
const allSameLetter = (array) => array.every((letter) => letter === array[0]);
console.log('testing string', filter);
if (firstMatch) {
return options.indexOf(firstMatch);
}
else if (allSameLetter(filter.split(''))) {
const matches = filterOptions(options, filter[0]);
const matchIndex = (filter.length - 1) % matches.length;
return options.indexOf(matches[matchIndex]);
}
else {
return -1;
}
}
// get updated option index
function getUpdatedIndex(current, max, action) {
switch(action) {
case MenuActions.First:
return 0;
case MenuActions.Last:
return max;
case MenuActions.Previous:
return Math.max(0, current - 1);
case MenuActions.Next:
return Math.min(max, current + 1);
default:
return current;
}
}
// check if an element is currently scrollable
function isScrollable(element) {
return element && element.clientHeight < element.scrollHeight;
}
// ensure given child element is within the parent's visible scroll area
function maintainScrollVisibility(activeElement, scrollParent) {
const { offsetHeight, offsetTop } = activeElement;
const { offsetHeight: parentOffsetHeight, scrollTop } = scrollParent;
const isAbove = offsetTop < scrollTop;
const isBelow = (offsetTop + offsetHeight) > (scrollTop + parentOffsetHeight);
if (isAbove) {
scrollParent.scrollTo(0, offsetTop);
}
else if (isBelow) {
scrollParent.scrollTo(0, offsetTop - parentOffsetHeight + offsetHeight);
}
}
/*
* Editable Combobox code
*/
const Combobox = function(el, options) {
// element refs
this.el = el;
this.inputEl = el.querySelector('input');
this.listboxEl = el.querySelector('[role=listbox]');
// data
this.idBase = this.inputEl.id;
this.options = options;
// state
this.activeIndex = 0;
this.open = false;
}
Combobox.prototype.init = function() {
this.inputEl.value = options[0];
this.inputEl.addEventListener('input', this.onInput.bind(this));
this.inputEl.addEventListener('blur', this.onInputBlur.bind(this));
this.inputEl.addEventListener('click', () => this.updateMenuState(true));
this.inputEl.addEventListener('keydown', this.onInputKeyDown.bind(this));
this.options.map((option, index) => {
const optionEl = document.createElement('div');
optionEl.setAttribute('role', 'option');
optionEl.id = `${this.idBase}-${index}`;
optionEl.className = index === 0 ? 'combo-option option-current' : 'combo-option';
optionEl.setAttribute('aria-selected', `${index === 0}`);
optionEl.innerText = option;
optionEl.addEventListener('click', () => { this.onOptionClick(index); });
optionEl.addEventListener('mousedown', this.onOptionMouseDown.bind(this));
this.listboxEl.appendChild(optionEl);
});
}
Combobox.prototype.onInput = function() {
const curValue = this.inputEl.value;
const matches = filterOptions(this.options, curValue);
// set activeIndex to first matching option
// (or leave it alone, if the active option is already in the matching set)
const filterCurrentOption = matches.filter((option) => option === this.options[this.activeIndex]);
if (matches.length > 0 && !filterCurrentOption.length) {
this.onOptionChange(this.options.indexOf(matches[0]));
}
const menuState = this.options.length > 0;
if (this.open !== menuState) {
this.updateMenuState(menuState, false);
}
}
Combobox.prototype.onInputKeyDown = function(event) {
const max = this.options.length - 1;
const action = getActionFromKey(event, this.open);
switch(action) {
case MenuActions.Next:
case MenuActions.Last:
case MenuActions.First:
case MenuActions.Previous:
event.preventDefault();
return this.onOptionChange(getUpdatedIndex(this.activeIndex, max, action));
case MenuActions.CloseSelect:
event.preventDefault();
this.selectOption(this.activeIndex);
return this.updateMenuState(false);
case MenuActions.Close:
event.preventDefault();
return this.updateMenuState(false);
case MenuActions.Open:
return this.updateMenuState(true);
}
}
Combobox.prototype.onInputBlur = function() {
if (this.ignoreBlur) {
this.ignoreBlur = false;
return;
}
if (this.open) {
this.selectOption(this.activeIndex);
this.updateMenuState(false, false);
}
}
Combobox.prototype.onOptionChange = function(index) {
this.activeIndex = index;
this.inputEl.setAttribute('aria-activedescendant', `${this.idBase}-${index}`);
// update active style
const options = this.el.querySelectorAll('[role=option]');
[...options].forEach((optionEl) => {
optionEl.classList.remove('option-current');
});
options[index].classList.add('option-current');
if (this.open && isScrollable(this.listboxEl)) {
maintainScrollVisibility(options[index], this.listboxEl);
}
}
Combobox.prototype.onOptionClick = function(index) {
this.onOptionChange(index);
this.selectOption(index);
this.updateMenuState(false);
}
Combobox.prototype.onOptionMouseDown = function() {
this.ignoreBlur = true;
}
Combobox.prototype.selectOption = function(index) {
const selected = this.options[index];
this.inputEl.value = selected;
this.activeIndex = index;
// update aria-selected
const options = this.el.querySelectorAll('[role=option]');
[...options].forEach((optionEl) => {
optionEl.setAttribute('aria-selected', 'false');
});
options[index].setAttribute('aria-selected', 'true');
}
Combobox.prototype.updateMenuState = function(open, callFocus = true) {
this.open = open;
this.inputEl.setAttribute('aria-expanded', `${open}`);
open ? this.el.classList.add('open') : this.el.classList.remove('open');
callFocus && this.inputEl.focus();
}
// init combo
const comboEl = document.querySelector('.js-combobox');
const options = [
'Apple', 'Banana', 'Blueberry', 'Boysenberry',
'Cherry', 'Durian', 'Eggplant', 'Fig', 'Grape',
'Guava', 'Huckleberry'
];
const comboComponent = new Combobox(comboEl, options);
comboComponent.init();
// init HTML select element (just creates options from a data set)
const htmlSelect = document.getElementById('html-select');
for (const optionValue of options) {
const option = document.createElement('option');
option.textContent = optionValue;
htmlSelect.appendChild(option)
}
/*
* Read-only select code
*/
const Select = function(el, options) {
// element refs
this.el = el;
this.comboEl = el.querySelector('[role=combobox]');
this.valueEl = this.comboEl.querySelector('span');
this.listboxEl = el.querySelector('[role=listbox]');
// data
this.idBase = this.comboEl.id;
this.options = options;
// state
this.activeIndex = 0;
this.open = false;
this.searchString = '';
this.searchTimeout = null;
}
Select.prototype.init = function() {
this.valueEl.innerHTML = options[0];
this.comboEl.addEventListener('blur', this.onComboBlur.bind(this));
this.comboEl.addEventListener('click', () => this.updateMenuState(true));
this.comboEl.addEventListener('keydown', this.onComboKeyDown.bind(this));
this.options.map((option, index) => {
const optionEl = document.createElement('div');
optionEl.setAttribute('role', 'option');
optionEl.id = `${this.idBase}-${index}`;
optionEl.className = index === 0 ? 'combo-option option-current' : 'combo-option';
optionEl.setAttribute('aria-selected', `${index === 0}`);
optionEl.innerText = option;
optionEl.addEventListener('click', (event) => {
event.stopPropagation();
this.onOptionClick(index);
});
optionEl.addEventListener('mousedown', this.onOptionMouseDown.bind(this));
this.listboxEl.appendChild(optionEl);
});
}
Select.prototype.getSearchString = function(char) {
if (typeof this.searchTimeout === 'number') {
window.clearTimeout(this.searchTimeout);
}
this.searchTimeout = window.setTimeout(() => {
this.searchString = '';
}, 1000);
this.searchString += char;
return this.searchString;
}
Select.prototype.onComboKeyDown = function(event) {
const { key } = event;
const max = this.options.length - 1;
const action = getActionFromKey(event, this.open);
switch(action) {
case MenuActions.Next:
case MenuActions.Last:
case MenuActions.First:
case MenuActions.Previous:
event.preventDefault();
return this.onOptionChange(getUpdatedIndex(this.activeIndex, max, action));
case MenuActions.CloseSelect:
case MenuActions.Space:
event.preventDefault();
this.selectOption(this.activeIndex);
case MenuActions.Close:
event.preventDefault();
return this.updateMenuState(false);
case MenuActions.Type:
this.updateMenuState(true);
var searchString = this.getSearchString(key);
return this.onOptionChange(Math.max(0, getIndexByLetter(this.options, searchString)));
case MenuActions.Open:
event.preventDefault();
return this.updateMenuState(true);
}
}
Select.prototype.onComboBlur = function() {
if (this.ignoreBlur) {
this.ignoreBlur = false;
return;
}
if (this.open) {
this.selectOption(this.activeIndex);
this.updateMenuState(false, false);
}
}
Select.prototype.onOptionChange = function(index) {
this.activeIndex = index;
this.comboEl.setAttribute('aria-activedescendant', `${this.idBase}-${index}`);
// update active style
const options = this.el.querySelectorAll('[role=option]');
[...options].forEach((optionEl) => {
optionEl.classList.remove('option-current');
});
options[index].classList.add('option-current');
if (isScrollable(this.listboxEl)) {
maintainScrollVisibility(options[index], this.listboxEl);
}
}
Select.prototype.onOptionClick = function(index) {
this.onOptionChange(index);
this.selectOption(index);
this.updateMenuState(false);
}
Select.prototype.onOptionMouseDown = function() {
this.ignoreBlur = true;
}
Select.prototype.selectOption = function(index) {
const selected = this.options[index];
this.valueEl.innerHTML = selected;
this.activeIndex = index;
// update aria-selected
const options = this.el.querySelectorAll('[role=option]');
[...options].forEach((optionEl) => {
optionEl.setAttribute('aria-selected', 'false');
});
options[index].setAttribute('aria-selected', 'true');
}
Select.prototype.updateMenuState = function(open, callFocus = true) {
this.open = open;
this.comboEl.setAttribute('aria-expanded', `${open}`);
open ? this.el.classList.add('open') : this.el.classList.remove('open');
callFocus && this.comboEl.focus();
// update activedescendant
const activeID = open ? `${this.idBase}-${this.activeIndex}` : this.valueEl.id;
this.comboEl.setAttribute('aria-activedescendant', activeID);
}
// init select
const selectEl = document.querySelector('.js-select');
const selectComponent = new Select(selectEl, options);
selectComponent.init();
/*
* Multiselect code
*/
const Multiselect = function(el, options) {
// element refs
this.el = el;
this.inputEl = el.querySelector('input');
this.listboxEl = el.querySelector('[role=listbox]');
this.idBase = this.inputEl.id;
this.selectedEl = document.getElementById(`${this.idBase}-selected`);
// data
this.options = options;
// state
this.activeIndex = 0;
this.open = false;
}
Multiselect.prototype.init = function() {
this.inputEl.addEventListener('input', this.onInput.bind(this));
this.inputEl.addEventListener('blur', this.onInputBlur.bind(this));
this.inputEl.addEventListener('click', () => this.updateMenuState(true));
this.inputEl.addEventListener('keydown', this.onInputKeyDown.bind(this));
this.listboxEl.addEventListener('blur', this.onInputBlur.bind(this));
this.options.map((option, index) => {
const optionEl = document.createElement('div');
optionEl.setAttribute('role', 'option');
optionEl.id = `${this.idBase}-${index}`;
optionEl.className = index === 0 ? 'combo-option option-current' : 'combo-option';
optionEl.setAttribute('aria-selected', 'false');
optionEl.innerText = option;
optionEl.addEventListener('click', () => { this.onOptionClick(index); });
optionEl.addEventListener('mousedown', this.onOptionMouseDown.bind(this));
this.listboxEl.appendChild(optionEl);
});
}
Multiselect.prototype.onInput = function() {
const curValue = this.inputEl.value;
const matches = filterOptions(this.options, curValue);
// set activeIndex to first matching option
// (or leave it alone, if the active option is already in the matching set)
const filterCurrentOption = matches.filter((option) => option === this.options[this.activeIndex]);
if (matches.length > 0 && !filterCurrentOption.length) {
this.onOptionChange(this.options.indexOf(matches[0]));
}
const menuState = this.options.length > 0;
if (this.open !== menuState) {
this.updateMenuState(menuState, false);
}
}
Multiselect.prototype.onInputKeyDown = function(event) {
const max = this.options.length - 1;
const action = getActionFromKey(event, this.open);
switch(action) {
case MenuActions.Next:
case MenuActions.Last:
case MenuActions.First:
case MenuActions.Previous:
event.preventDefault();
return this.onOptionChange(getUpdatedIndex(this.activeIndex, max, action));
case MenuActions.CloseSelect:
event.preventDefault();
return this.updateOption(this.activeIndex);
// return this.updateMenuState(false);
case MenuActions.Close:
event.preventDefault();
return this.updateMenuState(false);
case MenuActions.Open:
return this.updateMenuState(true);
}
}
Multiselect.prototype.onInputBlur = function() {
if (this.ignoreBlur) {
this.ignoreBlur = false;
return;
}
if (this.open) {
this.updateMenuState(false, false);
}
}
Multiselect.prototype.onOptionChange = function(index) {
this.activeIndex = index;
this.inputEl.setAttribute('aria-activedescendant', `${this.idBase}-${index}`);
// update active style
const options = this.el.querySelectorAll('[role=option]');
[...options].forEach((optionEl) => {
optionEl.classList.remove('option-current');
});
options[index].classList.add('option-current');
if (this.open && isScrollable(this.listboxEl)) {
maintainScrollVisibility(options[index], this.listboxEl);
}
}
Multiselect.prototype.onOptionClick = function(index) {
this.onOptionChange(index);
this.updateOption(index);
this.inputEl.focus();
}
Multiselect.prototype.onOptionMouseDown = function() {
this.ignoreBlur = true;
}
Multiselect.prototype.removeOption = function(index) {
const option = this.options[index];
// update aria-selected
const options = this.el.querySelectorAll('[role=option]');
options[index].setAttribute('aria-selected', 'false');
options[index].classList.remove('option-selected');
// remove button
const buttonEl = document.getElementById(`${this.idBase}-remove-${index}`);
this.selectedEl.removeChild(buttonEl.parentElement);
}
Multiselect.prototype.selectOption = function(index) {
const selected = this.options[index];
this.activeIndex = index;
// update aria-selected
const options = this.el.querySelectorAll('[role=option]');
options[index].setAttribute('aria-selected', 'true');
options[index].classList.add('option-selected');
// add remove option button
const buttonEl = document.createElement('button');
const listItem = document.createElement('li');
buttonEl.className = 'remove-option';
buttonEl.type = 'button';
buttonEl.id = `${this.idBase}-remove-${index}`;
buttonEl.setAttribute('aria-describedby', `${this.idBase}-remove`);
buttonEl.addEventListener('click', () => { this.removeOption(index); });
buttonEl.innerHTML = selected + ' ';
listItem.appendChild(buttonEl);
this.selectedEl.appendChild(listItem);
}
Multiselect.prototype.updateOption = function(index) {
const option = this.options[index];
const optionEl = this.el.querySelectorAll('[role=option]')[index];
const isSelected = optionEl.getAttribute('aria-selected') === 'true';
if (isSelected) {
this.removeOption(index);
} else {
this.selectOption(index);
}
this.inputEl.value = '';
}
Multiselect.prototype.updateMenuState = function(open, callFocus = true) {
this.open = open;
this.inputEl.setAttribute('aria-expanded', `${open}`);
open ? this.el.classList.add('open') : this.el.classList.remove('open');
callFocus && this.inputEl.focus();
}
// init multiselect
const multiselectEl = document.querySelector('.js-multiselect');
const multiselectComponent = new Multiselect(multiselectEl, options);
multiselectComponent.init();
CSS
.combo-wrap {
/* Component scoped colour variables
- values from the global scope or default */
--_combo-border: var(--border, #ccc);
--_combo-background: var(--accent-bg, current);
--_combo-color: var(--text, currentColor);
--_combo-background-highlight: var(--bg, current);
}
/* HTML Select */
.html-select {
appearance: none;
}
.combo {
display: block;
max-width: 400px;
position: relative;
margin: 0.25rem 0;
}
/* Fancy technique to use border as a dropdown icon
- use an icon instead! */
.combo::after {
border: 2px solid var(--_combo-border-color);
border-width: 0 2px 2px 0;
content: "";
display: block;
height: 12px;
pointer-events: none;
position: absolute;
right: 16px;
top: 50%;
transform: translate(0, -65%) rotate(45deg);
width: 12px;
}
.combo-input {
background-color: var(--_combo-background);
border: 2px solid var(--_combo-border-color);
border-radius: 4px;
display: block;
padding: .25em .5em;
text-align: left;
width: 100%;
font: inherit;
letter-spacing: inherit;
word-spacing: inherit;
}
.open .combo-input {
border-radius: 4px 4px 0 0;
}
.combo-label {
display: block;
line-height: 1.3;
}
.combo-menu {
background-color: var(--_combo-background);
border: 1px solid var(--_combo-border-color);
border-radius: 0 0 4px 4px;
display: none;
max-height: 300px;
overflow-y: scroll;
left: 0;
position: absolute;
top: 100%;
width: 100%;
z-index: 100;
margin-top:4px;
box-shadow: 4px 4px 4px #000c;
}
.open .combo-menu {
display: block;
}
.combo-option {
margin: 1px 0 0;
padding: 10px 12px 12px;
position: relative;
}
.combo-option::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: var(--_combo-background-highlight);
opacity: 0;
z-index: -1;
}
.combo-option.option-current::before,
.combo-option:hover::before {
opacity: 1;
}
.combo-option.option-selected {
padding-right: 30px;
position: relative;
}
/* Fancy technique to use border as a tick icon
- use an icon instead! */
.combo-option[aria-selected=true]::after {
border: 2px solid var(--_combo-color);
border-width: 0 2px 2px 0;
content: "";
height: 16px;
position: absolute;
right: 15px;
top: 50%;
transform: translate(0, -50%) rotate(45deg);
width: 8px;
}
/* multiselect list of selected options */
.selected-options {
list-style-type: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
ul.selected-options li + li {
margin:0 !important;
}
button.remove-option {
font-size: 0.75em;
font-weight: bold;
padding: 0.25em 1.75em 0.25em 0.25em;
position: relative;
}
/* Fancy technique to use border as a cross icon
- use an icon instead! */
button.remove-option::before,
button.remove-option::after {
border-right: 2px solid currentColor;
content: "";
height: 1em;
right: 0.75em;
position: absolute;
top: 50%;
width: 0;
}
button.remove-option::before {
transform: translate(0, -50%) rotate(45deg);
}
button.remove-option::after {
transform: translate(0, -50%) rotate(-45deg);
}