Skip to content

Instantly share code, notes, and snippets.

@jdanyow
Last active February 19, 2019 21:14
Show Gist options
  • Save jdanyow/acf8253329939b2e046cd0e3394351fe to your computer and use it in GitHub Desktop.
Save jdanyow/acf8253329939b2e046cd0e3394351fe to your computer and use it in GitHub Desktop.
Aurelia Accessible Autocomplete with Filtering
<template>
<require from="./autocomplete"></require>
<form>
<label class="form-component">
Country:<br/>
<autocomplete service.bind="suggestionService.country"
value.bind="model.country"
placeholder="Enter country..."
change.delegate="model.city = null">
</autocomplete>
</label>
<label class="form-component">
City${model.country ? ' (' + countryIndex[model.country].length + ' choices)' : ''}:<br/>
<autocomplete service.bind="suggestionService.city"
value.bind="model.city"
placeholder="Enter city...">
<template replace-part="suggestion">
<span style="font-style: italic">${suggestion}</span>
</template>
</autocomplete>
</label>
</form>
</template>
export class App {
model = {
country: null,
city: null
};
suggestionService = null;
countryIndex = null;
activate() {
return fetch('https://rawgit.com/jdanyow/97c8bdfaf7c7e0a2b8920e8789b46309/raw/d68abc0a90a6eb1a7e16362cdb7bf8832738665f/countries.json')
.then(response => response.json())
.then(countries => {
this.countryIndex = countries;
this.suggestionService = new SuggestionService(this.model, countries);
});
}
}
export class SuggestionService {
constructor(model, countries) {
this.model = model;
this.countryIndex = countries;
this.countries = Object.keys(countries);
}
country = {
suggest: value => {
if (value === '') {
return Promise.resolve([]);
}
value = value.toLowerCase();
const suggestions = this.countries
.filter(x => x.toLowerCase().indexOf(value) === 0)
.sort();
return Promise.resolve(suggestions);
},
getName: suggestion => suggestion
};
city = {
suggest: value => {
if (value === '' || this.model.country === null) {
return Promise.resolve([]);
}
value = value.toLowerCase();
let suggestions = this.countryIndex[this.model.country]
.filter(x => x.toLowerCase().indexOf(value) === 0);
suggestions = suggestions.filter((x, i) => suggestions.indexOf(x) === i)
.sort();
return Promise.resolve(suggestions);
},
getName: suggestion => suggestion
};
}
autocomplete {
display: inline-block;
}
autocomplete .suggestions {
list-style-type: none;
cursor: default;
padding: 0;
margin: 0;
border: 1px solid #ccc;
background: #fff;
box-shadow: -1px 1px 3px rgba(0,0,0,.1);
position: absolute;
z-index: 9999;
max-height: 15rem;
overflow: hidden;
overflow-y: auto;
box-sizing: border-box;
}
autocomplete .suggestion {
padding: 0 .3rem;
line-height: 1.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #333;
}
autocomplete .suggestion:hover,
autocomplete .suggestion.selected {
background: #f0f0f0;
}
<template>
<require from="./autocomplete.css"></require>
<input type="text" autocomplete="off"
aria-autocomplete="list"
aria-expanded.bind="expanded"
aria-owns.one-time="'au-autocomplate-' + id + '-suggestions'"
aria-activedescendant.bind="index >= 0 ? 'au-autocomplate-' + id + '-suggestion-' + index : ''"
id.one-time="'au-autocomplete-' + id"
placeholder.bind="placeholder"
value.bind="inputValue & debounce:delay"
keydown.delegate="keydown($event.which)"
blur.trigger="blur()">
<ul class="suggestions" role="listbox"
if.bind="expanded"
id.one-time="'au-autocomplate-' + id + '-suggestions'"
ref="suggestionsUL">
<li repeat.for="suggestion of suggestions"
id.one-time="'au-autocomplate-' + id + '-suggestion-' + $index"
role="option"
class-name.bind="($index === index ? 'selected' : '') + ' suggestion'"
mousedown.delegate="suggestionClicked(suggestion)">
<template replaceable part="suggestion">
${suggestion}
</template>
</li>
</ul>
</template>
import {bindingMode, observable} from 'aurelia-binding';
import {bindable} from 'aurelia-templating';
import {inject} from 'aurelia-dependency-injection';
import {DOM} from 'aurelia-pal';
let nextID = 0;
@inject(Element)
export class Autocomplete {
@bindable service;
@bindable({ defaultBindingMode: bindingMode.twoWay }) value;
@bindable placeholder = '';
@bindable delay = 300;
id = nextID++;
expanded = false;
@observable inputValue = '';
updatingInput = false;
suggestions = [];
index = -1;
suggestionsUL = null;
userInput = '';
constructor(element) {
this.element = element;
}
display(name) {
this.updatingInput = true;
this.inputValue = name;
this.updatingInput = false;
}
getName(suggestion) {
if (suggestion == null) {
return '';
}
return this.service.getName(suggestion);
}
collapse() {
this.expanded = false;
this.index = -1;
}
select(suggestion, notify) {
this.value = suggestion;
const name = this.getName(this.value);
this.userInput = name;
this.display(name);
this.collapse();
if (notify) {
const event = DOM.createCustomEvent('change', { bubbles: true });
event.value = suggestion;
event.autocomplete = this;
this.element.dispatchEvent(event);
}
}
valueChanged() {
this.select(this.value, false);
}
inputValueChanged(value) {
if (this.updatingInput) {
return;
}
this.userInput = value;
if (value === '') {
this.value = null;
this.collapse();
return;
}
this.service.suggest(value)
.then(suggestions => {
this.index = -1;
this.suggestions.splice(0, this.suggestions.length, ...suggestions);
if (suggestions.length === 1 && suggestions[0] !== this.value) {
this.select(suggestions[0], true);
} else if (suggestions.length === 0) {
this.collapse();
} else {
this.expanded = true;
}
});
}
scroll() {
const ul = this.suggestionsUL;
const li = ul.children.item(this.index === -1 ? 0 : this.index);
if (li.offsetTop + li.offsetHeight > ul.offsetHeight) {
ul.scrollTop += li.offsetHeight;
} else if(li.offsetTop < ul.scrollTop) {
ul.scrollTop = li.offsetTop;
}
}
keydown(key) {
if (!this.expanded) {
return true;
}
// down
if (key === 40) {
if (this.index < this.suggestions.length - 1) {
this.index++;
this.display(this.getName(this.suggestions[this.index]));
} else {
this.index = -1;
this.display(this.userInput);
}
this.scroll();
return;
}
// up
if (key === 38) {
if (this.index === -1) {
this.index = this.suggestions.length - 1;
this.display(this.getName(this.suggestions[this.index]));
} else if (this.index > 0) {
this.index--;
this.display(this.getName(this.suggestions[this.index]));
} else {
this.index = -1;
this.display(this.userInput);
}
this.scroll();
return;
}
// escape
if (key === 27) {
this.display(this.userInput);
this.collapse();
return;
}
// enter
if (key === 13) {
if (this.index >= 0) {
this.select(this.suggestions[this.index], true);
}
return;
}
return true;
}
blur() {
this.select(this.value, false);
this.element.dispatchEvent(DOM.createCustomEvent('blur'));
}
suggestionClicked(suggestion) {
this.select(suggestion, true);
}
focus() {
this.element.firstElementChild.focus();
}
}
// aria-activedescendant
// https://webaccessibility.withgoogle.com/unit?unit=6&lesson=13
// https://www.w3.org/TR/wai-aria/states_and_properties#aria-autocomplete
// https://www.w3.org/TR/wai-aria/roles#combobox
<!doctype html>
<html>
<head>
<title>Aurelia</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/meyer-reset/2.0/reset.min.css">
<style>
body {
padding: 20px;
}
.form-component {
display: block;
margin-bottom: 20px;
}
</style>
</head>
<body aurelia-app>
<h1>Loading...</h1>
<script src="https://jdanyow.github.io/rjs-bundle/node_modules/requirejs/require.js"></script>
<script src="https://jdanyow.github.io/rjs-bundle/config.js"></script>
<script src="https://jdanyow.github.io/rjs-bundle/bundles/aurelia.js"></script>
<script src="https://jdanyow.github.io/rjs-bundle/bundles/babel.js"></script>
<script>
require(['aurelia-bootstrapper']);
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment