Skip to content

Instantly share code, notes, and snippets.

@illai
Last active August 16, 2020 16:38
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save illai/ea03cb863200caa6af5d2ef2c3e1a3f4 to your computer and use it in GitHub Desktop.
Save illai/ea03cb863200caa6af5d2ef2c3e1a3f4 to your computer and use it in GitHub Desktop.
.combo-label {
display: block;
margin: 0 0 1.5rem;
}
.combo-wrapper {
display: inline-block;
font-size: 1.6rem;
position: relative;
width: 100%;
}
.combo-wrapper .combo-controls {
display: flex;
font-size: 0;
position: relative;
}
.combo-wrapper input {
background: transparent;
border: 0.2rem solid var(--brand-light);
border-radius: 0.6rem;
box-shadow: 0 0 0 0 transparent;
color: white;
font-family: var(--primary-font);
font-size: 2rem;
height: 4.8rem;
line-height: 2rem;
padding: 2%;
transition: all 300ms ease-in-out;
width: 100%;
vertical-align: middle;
}
.combo-wrapper.classic input {
width: calc(100% - 4rem);
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
.combo-wrapper button {
background: var(--brand-dark);
border: 0.2rem solid var(--brand-light);
border-left: 0 none;
border-top-right-radius: 0.6rem;
border-bottom-right-radius: 0.6rem;
box-shadow: 0 0 0 0.2rem transparent;
color: white;
cursor: pointer;
font-size: 2rem;
height: 4.8rem;
transition: all 300ms ease-in-out;
width: 4rem;
vertical-align: middle;
}
.combo-wrapper button:hover,
.combo-wrapper button:focus {
background: var(--brand-light);
color: var(--bg-color);
}
.combo-wrapper button:focus {
box-shadow: 0.1rem 0 0 0.2rem white;
color: var(--brand-dark);
outline: none;
}
.combo-wrapper input:focus {
background: white;
box-shadow: 1px 1px 2px 2px var(--brand-light);
color: var(--brand-dark);
outline: none;
}
.listbox {
background-color: var(--bg-color);
border: 0.2rem solid var(--brand-light);
border-radius: 0.6rem;
list-style: none;
margin: 0;
max-width: 100%;
padding: 0;
position: absolute;
top: calc(100% + 2rem);
width: 100%;
z-index: 1;
}
.listbox:not(.hidden)::before {
background-color: var(--bg-color);
border-right: 0.2rem solid var(--brand-light);
border-top: 0.2rem solid var(--brand-light);
border-radius: 0.2rem;
content: '';
height: 2rem;
left: calc(50% - 1rem);
position: absolute;
top: -1.1rem;
transform: rotate(-45deg);
width: 2rem;
z-index: 0;
}
.listbox.hidden {
animation-name: hide-results;
animation-duration: 300ms;
animation-fill-mode: forwards;
animation-timing-function: ease-in-out;
}
.listbox:not(.hidden) {
animation-name: display-results;
animation-duration: 300ms;
animation-fill-mode: forwards;
animation-timing-function: ease-in-out;
}
.listbox .result {
align-items: center;
color: var(--brand-light);
cursor: pointer;
display: flex;
margin: 0;
padding: 1rem 1.5rem;
position: relative;
transition: all 300ms ease-in-out;
}
.result-item-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-item-thumb {
border: 0.1rem solid var(--bg-color);
border-radius: 0.2rem;
margin-right: 1.5rem;
transform: scale(0.8);
transition: transform 300ms ease-out;
}
.listbox .result:hover,
.listbox .result.focused {
background-color: var(--brand-dark);
color: white;
}
.listbox .result:hover .result-item-thumb,
.listbox .result.focused .result-item-thumb {
transform: scale(1);
}
@keyframes display-results {
0% {
display: none;
opacity: 0;
transform: translateY(-2rem);
}
5% {
display: block;
opacity: 0;
transform: translateY(-2rem);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes hide-results {
0% {
display: block;
opacity: 1;
transform: translateY(0);
}
95% {
display: block;
opacity: 0;
transform: translateY(-2rem);
}
100% {
display: none;
opacity: 0;
transform: translateY(-2rem);
}
}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>Accessible combobox example</title>
<link type="text/css" rel="stylesheet" href="base.css" />
<link type="text/css" rel="stylesheet" href="accessible-combobox-example.css" />
</head>
<body>
<main>
<div class="example-box">
<h2>Classic Combobox</h2>
<label for="ex-cl-input" id="ex-cl-label" class="combo-label">
Type or pick an option
</label>
<div class="combo-wrapper classic" id="classic-combo">
<div class="combo-controls">
<input
aria-autocomplete="list"
aria-controls="ex-cl-listbox"
aria-expanded="false"
id="ex-cl-input"
aria-haspopup="listbox"
role="combobox"
type="text"
/>
<button
aria-controls="ex-cl-listbox"
aria-expanded="false"
aria-haspopup="listbox"
aria-labelledby="ex-cl-label"
tabindex="-1"
>
&dtrif;
</button>
</div>
<ul
aria-labelledby="ex-cl-label"
role="listbox"
id="ex-cl-listbox"
class="listbox hidden"
></ul>
</div>
</div>
<div class="example-box">
<h2>Autocomplete Combobox</h2>
<label for="ex-auto-input" id="ex-auto-label" class="combo-label">
Type or pick an option
</label>
<div class="combo-wrapper autocomplete" id="autocomplete-combo">
<div class="combo-controls">
<input
aria-autocomplete="both"
aria-controls="ex-auto-listbox"
aria-expanded="false"
aria-haspopup="listbox"
id="ex-auto-input"
role="combobox"
type="text"
/>
</div>
<ul
aria-labelledby="ex1-auto-label"
role="listbox"
id="ex-auto-listbox"
class="listbox hidden"
></ul>
</div>
</div>
</main>
<script src="accessible-combobox-object.js"></script>
<script src="accessible-combobox-example.js"></script>
</body>
</html>
const KEY_CODE = {
BACKSPACE: 8,
DELETE: 46,
DOWN: 40,
ESC: 27,
LEFT: 37,
RETURN: 13,
RIGHT: 39,
SPACE: 32,
TAB: 9,
UP: 38
};
function comboListElement(itemData, i) {
function createThumb() {
if (itemData.thumb) {
let thumb = document.createElement('img');
thumb.src = itemData.thumb;
thumb.className = 'result-item-thumb';
thumb.setAttribute('aria-hidden', 'true');
return thumb;
}
}
function createText() {
if (itemData.name) {
let txt = document.createElement('span');
txt.className = 'result-item-text';
txt.innerText = itemData.name;
return txt;
}
}
let resultItem = document.createElement('li');
resultItem.id = `result-item-${i}`;
resultItem.className = 'result';
resultItem.setAttribute('role', 'option');
resultItem.setAttribute('aria-selected', 'false');
if (createThumb()) resultItem.appendChild(createThumb());
if (createText()) resultItem.appendChild(createText());
return resultItem;
}
/**
* @constant DATA
* @desc expected to be an array of objects, the serchTerm value should match a key name on these objects
*/
const classicComboRoot = document.getElementById('classic-combo');
const classicComboConfig = {
input: classicComboRoot.querySelector('[type="text"]'),
list: classicComboRoot.querySelector('ul'),
listToggleBtn: classicComboRoot.querySelector('button'),
data: DATA,
listItemElement: comboListElement,
searchTerm: 'name'
};
const classicCombo = new Combobox();
classicCombo.init(classicComboConfig);
const autocompleteComboRoot = document.getElementById('autocomplete-combo');
const autocompleteComboConfig = {
input: autocompleteComboRoot.querySelector('[type="text"]'),
list: autocompleteComboRoot.querySelector('ul'),
data: DATA,
listItemElement: comboListElement,
searchTerm: 'name'
};
const autocompleteCombo = new Combobox();
autocompleteCombo.init(autocompleteComboConfig);
class Combobox {
constructor() {
this.listIsExpanded = false;
this.activeIndex = -1;
this.totalResults = 0;
this.query = '';
}
init({ data, input, list, listItemElement, listToggleBtn, searchTerm }) {
this.input = input;
this.list = list;
this.listToggleBtn = listToggleBtn;
this.listItemElement = listItemElement;
this.searchTerm = searchTerm;
this.data = data;
this.inputAutocomplete = this.input.getAttribute('aria-autocomplete') === 'both';
this.setEventHandlers();
}
setEventHandlers() {
document.body.addEventListener('click', this.closeWithoutSelect.bind(this));
this.input.addEventListener('keydown', this.preventKeysDefaults.bind(this));
this.input.addEventListener('keyup', this.handleInputKeystroke.bind(this));
this.input.addEventListener('focus', this.displayCurrentQuery.bind(this));
this.input.addEventListener('blur', this.clearComboList.bind(this));
if (this.listToggleBtn) {
this.listToggleBtn.addEventListener('click', this.toggleList.bind(this));
}
this.list.addEventListener('click', this.makeSelection.bind(this));
}
isValidKey(keyCode) {
return (
(keyCode > 47 && keyCode < 58) || // number keys
keyCode == 32 || // space
(keyCode > 64 && keyCode < 91) || // letter keys
(keyCode > 95 && keyCode < 112) || // numpad keys
(keyCode > 185 && keyCode < 193) || // ;=,-./`
(keyCode > 218 && keyCode < 223)
); // [\]'
}
escapeStrForRegExp(text) {
return text.replace(/[-[\]{}()*+?.,\\/^$|#\s]/g, '\\$&');
}
filterFunc(query) {
return this.data.filter((item) =>
new RegExp(`^${this.escapeStrForRegExp(query)}`, 'i').test(item[this.searchTerm])
);
}
handleInputKeystroke(e) {
e.preventDefault();
const key = e.keyCode;
switch (key) {
case KEY_CODE.UP:
case KEY_CODE.DOWN:
if (this.listIsExpanded) {
this.traverseList(key);
} else {
this.updateComboList();
this.traverseList(key);
}
break;
case KEY_CODE.ESC:
this.clearComboList();
this.input.value = '';
this.query = '';
break;
case KEY_CODE.RETURN:
if (this.activeIndex < 0) return;
this.makeSelection();
break;
case KEY_CODE.BACKSPACE:
case KEY_CODE.DELETE:
this.handleDelete(e);
break;
case KEY_CODE.RIGHT:
case KEY_CODE.LEFT:
case KEY_CODE.TAB:
return;
default:
if (this.isValidKey(key)) {
this.query += e.key;
}
this.updateComboList(true);
}
}
preventKeysDefaults(e) {
const key = e.keyCode;
switch (key) {
case KEY_CODE.UP:
case KEY_CODE.DOWN:
case KEY_CODE.ESC:
case KEY_CODE.RETURN:
case KEY_CODE.BACKSPACE:
case KEY_CODE.DELETE:
e.preventDefault();
break;
default:
return;
}
}
handleDelete(e) {
e.preventDefault();
const selectionStart = this.input.selectionStart;
const selectionEnd = this.input.selectionEnd;
if (selectionStart === this.query.length) {
this.query = this.query.slice(0, -1);
this.input.value = this.query;
} else if (selectionStart < selectionEnd) {
const selectionSubStr = this.input.value.slice(selectionStart, selectionEnd);
this.input.value = this.input.value.replace(selectionSubStr, '');
this.query = this.input.value;
this.input.setSelectionRange(selectionStart, selectionStart);
} else if (selectionStart === selectionEnd) {
if (selectionStart === 0) return;
const strStart = this.input.value.substring(0, selectionStart - 1);
const strEnd = this.input.value.substring(selectionStart, this.input.value.length);
this.input.value = strStart + strEnd;
this.query = this.input.value;
this.input.setSelectionRange(selectionStart - 1, selectionStart - 1);
}
this.updateComboList(false);
}
toggleList(e) {
if (e.keyCode) {
return;
}
this.listIsExpanded ? this.clearComboList() : this.input.focus();
}
updateComboList(shouldAutoComplete) {
const filteredData = this.filterFunc(this.query.trim());
this.clearComboList();
if (filteredData.length > 0) {
filteredData.forEach((result, index) => {
this.list.appendChild(this.listItemElement(result, index));
});
}
this.totalResults = filteredData.length;
if (this.totalResults > 0) {
this.list.classList.remove('hidden');
this.input.setAttribute('aria-expanded', 'true');
this.listIsExpanded = true;
}
if (this.inputAutocomplete && shouldAutoComplete) {
this.autocompleteItem(0);
}
}
autocompleteItem(itemIndex) {
const autocompletedItem = this.list.querySelector(`#result-item-${itemIndex}`);
if (autocompletedItem) {
autocompletedItem.classList.add('focused');
autocompletedItem.setAttribute('aria-selected', 'true');
this.input.setAttribute('aria-activedescendant', `#result-item-${itemIndex}`);
let inputText = this.query;
if (!autocompletedItem || !inputText) {
return;
}
this.activeIndex = itemIndex;
let autocomplete = autocompletedItem.innerText;
if (inputText !== autocomplete) {
this.input.value = autocomplete;
this.input.setSelectionRange(inputText.length, autocomplete.length);
}
}
}
makeSelection(e) {
e ? this.handleMouseSelection(e) : this.handleKeyboardSelection();
}
handleMouseSelection(e) {
let listItem = e.target.nodeName === 'LI' ? e.target : e.target.parentNode;
this.query = listItem.innerText;
this.input.value = this.query;
this.clearComboList();
}
handleKeyboardSelection() {
if (this.activeIndex < 0) {
this.clearComboList();
} else {
let activeItem = document.getElementById(`result-item-${this.activeIndex}`);
this.query = activeItem.innerText;
this.input.value = this.query;
this.clearComboList();
}
}
displayCurrentQuery() {
if (this.query.trim().length > 0) {
this.input.value = query;
this.updateComboList(true);
}
}
traverseList(key) {
let prevActiveIndex = this.activeIndex;
switch (key) {
case KEY_CODE.UP:
if (this.activeIndex <= 0) {
this.activeIndex = this.totalResults - 1;
} else {
this.activeIndex--;
}
break;
case KEY_CODE.DOWN:
if (this.activeIndex === -1 || this.activeIndex >= this.totalResults - 1) {
this.activeIndex = 0;
} else {
this.activeIndex++;
}
break;
}
let prevActive = document.getElementById(`result-item-${prevActiveIndex}`);
let activeItem = document.getElementById(`result-item-${this.activeIndex}`);
if (prevActive) {
prevActive.classList.remove('focused');
prevActive.setAttribute('aria-selected', 'false');
}
if (activeItem) {
this.input.setAttribute('aria-activedescendant', `result-item-${this.activeIndex}`);
activeItem.classList.add('focused');
activeItem.setAttribute('aria-selected', 'true');
if (this.hasInlineAutocomplete) {
this.input.value = activeItem.innerText;
}
} else {
this.input.setAttribute('aria-activedescendant', '');
}
if (this.inputAutocomplete) {
this.autocompleteItem(this.activeIndex);
}
}
clearComboList() {
this.list.innerHTML = '';
this.list.classList.add('hidden');
this.input.setAttribute('aria-expanded', 'false');
this.input.setAttribute('aria-activedescendant', '');
this.listIsExpanded = false;
this.totalResults = 0;
this.activeIndex = -1;
}
closeWithoutSelect(e) {
if (
(e.target === this.input && e.keyCode !== KEY_CODE.TAB) ||
e.target === this.listToggleBtn
) {
return;
}
this.clearComboList();
}
}
@import url('https://fonts.googleapis.com/css2?family=Lato&family=Quicksand&display=swap');
:root {
/* Color */
--bg-color: #1b1d21;
--brand-dark: #00615a;
--brand-light: #26bfb5;
/* Typography */
--primary-font: 'Lato', 'sans-serif';
--headings-font: 'Quicksand', 'sans-serif';
}
html,
body {
font-family: var(--primary-font);
font-size: 62.5%;
}
html {
box-sizing: border-box;
}
*,
*:before,
*:after {
box-sizing: inherit;
}
body {
background-color: var(--bg-color);
color: white;
height: 100vh;
margin: 0;
}
main {
align-items: center;
justify-content: space-evenly;
display: flex;
max-width: 60%;
margin: 0 auto;
min-height: 50vh;
}
@media only screen and (max-width: 768px) {
main {
max-width: 90%;
}
}
h2 {
color: var(--brand-light);
font-size: 4rem;
font-family: var(--headings-font);
font-weight: 400;
}
.example-box {
max-width: 30rem;
}
label {
font-size: 1.8rem;
vertical-align: middle;
}
footer {
display: flex;
padding: 3rem 0;
justify-content: center;
}
footer a {
color: white;
font-size: 1.6rem;
text-decoration: underline;
transition: all 300ms ease-in-out;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment