WCAG 2.2 – Levels A and AA only, with dev notes.
Also known as dropdown inputs (select)
A fork of Sarah Higley's indepth combobox examples, but with the Tab key action enabled.
<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
Styling a select element
<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>
Git repo: Select combobox
<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>
Git repo: Editable combobox
<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>
Git repo: Multi-select combobox
/* * 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();
.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); }