<header>
<h1>Autocomplete</h1>
<p>Demonstration of a fully accessible autocomplete/search component in vanilla JavaScript. Based on the <a href='https://www.w3.org/TR/wai-aria-practices-1.1/#combobox' target='_blank'>WAI-ARIA authoring practices 1.1</a>.</p>
</header>
<main>
<div class='container'>
<form class='autocomplete-container'>
<div
class='autocomplete'
role='combobox'
aria-expanded='false'
aria-owns='autocomplete-results'
aria-haspopup='listbox'
>
<input
class='autocomplete-input'
placeholder='Search for a fruit or vegetable'
aria-label='Search for a fruit or vegetable'
aria-autocomplete='both'
aria-controls='autocomplete-results'
>
<button type='submit' class='autocomplete-submit' aria-label='Search'>
<svg aria-hidden='true' viewBox='0 0 24 24'>
<path d='M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z' />
</svg>
</button>
</div>
<ul
id='autocomplete-results'
class='autocomplete-results hidden'
role='listbox'
aria-label='Search for a fruit or vegetable'
>
</ul>
</form>
<p class='search-result'></p>
</div>
</main>
$color-primary: #1c5b72;
main {
margin: 64px 0;
padding: 0 16px;
}
.container {
margin: 0 auto;
width: 100%;
max-width: 600px;
}
.autocomplete-container {
position: relative;
}
.autocomplete {
display: flex;
}
input,
button {
font-family: inherit;
}
.autocomplete-input {
border: 1px solid rgba(0, 0, 0, 0.54);
width: 100%;
padding: 8px;
font-size: 16px;
line-height: 1.5;
flex: 1;
}
.autocomplete-submit {
border: 1px solid $color-primary;
padding: 8px 16px;
background: $color-primary;
display: flex;
align-items: center;
justify-content: center;
}
.autocomplete-submit svg {
width: 24px;
height: 24px;
fill: #fff;
}
.autocomplete-results {
position: absolute;
margin-top: -1px;
border: 1px solid rgba(0, 0, 0, 0.54);
padding: 4px 0;
width: 100%;
z-index: 1;
background: #fff;
margin: 0;
padding: 0;
list-style: none;
transition: none;
}
.autocomplete-result {
cursor: default;
padding: 4px 8px;
}
.autocomplete-result:hover {
background: rgba(0, 0, 0, 0.12);
}
.autocomplete-result.selected {
background: rgba(0, 0, 0, 0.12);
}
.search-result {
margin-top: 64px;
text-align: center;
}
.hidden {
display: none;
}
/* General layout and typography stuff */
@import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400");
* {
box-sizing: border-box;
position: relative;
transition: all .3s ease
}
html {
font-size: 16px
}
body {
font-family: Open Sans, Verdana, sans-serif;
color: rgba(0, 0, 0, .87);
font-weight: 400;
line-height: 1.45
}
body,
header {
background: #fafafa
}
header {
padding: 40px;
min-height: 200px;
text-align: center;
color: rgba(0, 0, 0, .87)
}
header > * {
max-width: 800px;
margin-left: auto;
margin-right: auto
}
header>:last-child {
margin-bottom: 0
}
h1 {
margin-bottom: 0.5em;
font-weight: inherit;
line-height: 1.2;
color: #1c5b72;
font-size: 2.618em
}
p {
margin-bottom: 1.3em;
line-height: 1.618
}
@media (min-width:800px) {
h1 {
font-size: 4.236em;
font-weight: 300
}
p {
font-size: 1.3em
}
}
a {
color: #e03616;
text-decoration: none
}
View Compiled
console.clear()
const data = [
'Apple',
'Artichoke',
'Asparagus',
'Banana',
'Beets',
'Bell pepper',
'Broccoli',
'Brussels sprout',
'Cabbage',
'Carrot',
'Cauliflower',
'Celery',
'Chard',
'Chicory',
'Corn',
'Cucumber',
'Daikon',
'Date',
'Edamame',
'Eggplant',
'Elderberry',
'Fennel',
'Fig',
'Garlic',
'Grape',
'Honeydew melon',
'Iceberg lettuce',
'Jerusalem artichoke',
'Kale',
'Kiwi',
'Leek',
'Lemon',
'Mango',
'Mangosteen',
'Melon',
'Mushroom',
'Nectarine',
'Okra',
'Olive',
'Onion',
'Orange',
'Parship',
'Pea',
'Pear',
'Pineapple',
'Potato',
'Pumpkin',
'Quince',
'Radish',
'Rhubarb',
'Shallot',
'Spinach',
'Squash',
'Strawberry',
'Sweet potato',
'Tomato',
'Turnip',
'Ugli fruit',
'Victoria plum',
'Watercress',
'Watermelon',
'Yam',
'Zucchini'
]
class Autocomplete {
constructor({
rootNode,
inputNode,
resultsNode,
searchFn,
shouldAutoSelect = false,
onShow = () => {},
onHide = () => {}
} = {}) {
this.rootNode = rootNode
this.inputNode = inputNode
this.resultsNode = resultsNode
this.searchFn = searchFn
this.shouldAutoSelect = shouldAutoSelect
this.onShow = onShow
this.onHide = onHide
this.activeIndex = -1
this.resultsCount = 0
this.showResults = false
this.hasInlineAutocomplete = this.inputNode.getAttribute('aria-autocomplete') === 'both'
// Setup events
document.body.addEventListener('click', this.handleDocumentClick)
this.inputNode.addEventListener('keyup', this.handleKeyup)
this.inputNode.addEventListener('keydown', this.handleKeydown)
this.inputNode.addEventListener('focus', this.handleFocus)
this.resultsNode.addEventListener('click', this.handleResultClick)
}
handleDocumentClick = event => {
if (event.target === this.inputNode || this.rootNode.contains(event.target)) {
return
}
this.hideResults()
}
handleKeyup = event => {
const { key } = event
switch (key) {
case 'ArrowUp':
case 'ArrowDown':
case 'Escape':
case 'Enter':
event.preventDefault()
return
default:
this.updateResults()
}
if (this.hasInlineAutocomplete) {
switch(key) {
case 'Backspace':
return
default:
this.autocompleteItem()
}
}
}
handleKeydown = event => {
const { key } = event
let activeIndex = this.activeIndex
if (key === 'Escape') {
this.hideResults()
this.inputNode.value = ''
return
}
if (this.resultsCount < 1) {
if (this.hasInlineAutocomplete && (key === 'ArrowDown' || key === 'ArrowUp')) {
this.updateResults()
} else {
return
}
}
const prevActive = this.getItemAt(activeIndex)
let activeItem
switch(key) {
case 'ArrowUp':
if (activeIndex <= 0) {
activeIndex = this.resultsCount - 1
} else {
activeIndex -= 1
}
break
case 'ArrowDown':
if (activeIndex === -1 || activeIndex >= this.resultsCount - 1) {
activeIndex = 0
} else {
activeIndex += 1
}
break
case 'Enter':
activeItem = this.getItemAt(activeIndex)
this.selectItem(activeItem)
return
case 'Tab':
this.checkSelection()
this.hideResults()
return
default:
return
}
event.preventDefault()
activeItem = this.getItemAt(activeIndex)
this.activeIndex = activeIndex
if (prevActive) {
prevActive.classList.remove('selected')
prevActive.setAttribute('aria-selected', 'false')
}
if (activeItem) {
this.inputNode.setAttribute('aria-activedescendant', `autocomplete-result-${activeIndex}`)
activeItem.classList.add('selected')
activeItem.setAttribute('aria-selected', 'true')
if (this.hasInlineAutocomplete) {
this.inputNode.value = activeItem.innerText
}
} else {
this.inputNode.setAttribute('aria-activedescendant', '')
}
}
handleFocus = event => {
this.updateResults()
}
handleResultClick = event => {
if (event.target && event.target.nodeName === 'LI') {
this.selectItem(event.target)
}
}
getItemAt = index => {
return this.resultsNode.querySelector(`#autocomplete-result-${index}`)
}
selectItem = node => {
if (node) {
this.inputNode.value = node.innerText
this.hideResults()
}
}
checkSelection = () => {
if (this.activeIndex < 0) {
return
}
const activeItem = this.getItemAt(this.activeIndex)
this.selectItem(activeItem)
}
autocompleteItem = event => {
const autocompletedItem = this.resultsNode.querySelector('.selected')
const input = this.inputNode.value
if (!autocompletedItem || !input) {
return
}
const autocomplete = autocompletedItem.innerText
if (input !== autocomplete) {
this.inputNode.value = autocomplete
this.inputNode.setSelectionRange(input.length, autocomplete.length)
}
}
updateResults = () => {
const input = this.inputNode.value
const results = this.searchFn(input)
this.hideResults()
if (results.length === 0) {
return
}
this.resultsNode.innerHTML = results.map((result, index) => {
const isSelected = this.shouldAutoSelect && index === 0
if (isSelected) {
this.activeIndex = 0
}
return `
<li
id='autocomplete-result-${index}'
class='autocomplete-result${isSelected ? ' selected' : ''}'
role='option'
${isSelected ? "aria-selected='true'" : ''}
>
${result}
</li>
`
}).join('')
this.resultsNode.classList.remove('hidden')
this.rootNode.setAttribute('aria-expanded', true)
this.resultsCount = results.length
this.shown = true
this.onShow()
}
hideResults = () => {
this.shown = false
this.activeIndex = -1
this.resultsNode.innerHTML = ''
this.resultsNode.classList.add('hidden')
this.rootNode.setAttribute('aria-expanded', 'false')
this.resultsCount = 0
this.inputNode.setAttribute('aria-activedescendant', '')
this.onHide()
}
}
const search = input => {
if (input.length < 1) {
return []
}
return data.filter(item => item.toLowerCase().startsWith(input.toLowerCase()))
}
const autocomplete = new Autocomplete({
rootNode: document.querySelector('.autocomplete'),
inputNode: document.querySelector('.autocomplete-input'),
resultsNode: document.querySelector('.autocomplete-results'),
searchFn: search,
shouldAutoSelect: true
})
document.querySelector('form').addEventListener('submit', (event) => {
event.preventDefault()
const result = document.querySelector('.search-result')
const input = document.querySelector('.autocomplete-input')
result.innerHTML = 'Searched for: ' + input.value
})
View Compiled
This Pen doesn't use any external CSS resources.