Last active
August 16, 2020 16:38
-
-
Save illai/ea03cb863200caa6af5d2ef2c3e1a3f4 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
.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); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!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" | |
> | |
▾ | |
</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> |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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