UNPKG

access-nyc-patterns

Version:

User Interface Patterns for Benefits Access

459 lines (372 loc) 11.6 kB
/* eslint-env browser */ 'use strict'; import jaroWinkler from './jaro-winkler'; import memoize from './memoize'; /** * Autocomplete for autocomplete. * Forked and modified from https://github.com/xavi/miss-plete */ class Autocomplete { /** * @param {object} settings Configuration options * @return {this} The class * @constructor */ constructor(settings = {}) { this.settings = { 'selector': settings.selector, // required 'options': settings.options, // required 'classname': settings.classname, // required 'selected': (settings.hasOwnProperty('selected')) ? settings.selected : false, 'score': (settings.hasOwnProperty('score')) ? settings.score : memoize(Autocomplete.score), 'listItem': (settings.hasOwnProperty('listItem')) ? settings.listItem : Autocomplete.listItem, 'getSiblingIndex': (settings.hasOwnProperty('getSiblingIndex')) ? settings.getSiblingIndex : Autocomplete.getSiblingIndex }; this.scoredOptions = null; this.container = null; this.ul = null; this.highlighted = -1; this.SELECTORS = Autocomplete.selectors; this.STRINGS = Autocomplete.strings; this.MAX_ITEMS = Autocomplete.maxItems; window.addEventListener('keydown', (e) => { this.keydownEvent(e); }); window.addEventListener('keyup', (e) => { this.keyupEvent(e); }); window.addEventListener('input', (e) => { this.inputEvent(e); }); let body = document.querySelector('body'); body.addEventListener('focus', (e) => { this.focusEvent(e); }, true); body.addEventListener('blur', (e) => { this.blurEvent(e); }, true); return this; } /** * EVENTS */ /** * The input focus event * @param {object} event The event object */ focusEvent(event) { if (!event.target.matches(this.settings.selector)) return; this.input = event.target; if (this.input.value === '') this.message('INIT'); } /** * The input keydown event * @param {object} event The event object */ keydownEvent(event) { if (!event.target.matches(this.settings.selector)) return; this.input = event.target; if (this.ul) switch (event.keyCode) { case 13: this.keyEnter(event); break; case 27: this.keyEscape(event); break; case 40: this.keyDown(event); break; case 38: this.keyUp(event); break; } } /** * The input keyup event * @param {object} event The event object */ keyupEvent(event) { if (!event.target.matches(this.settings.selector)) return; this.input = event.target; } /** * The input event * @param {object} event The event object */ inputEvent(event) { if (!event.target.matches(this.settings.selector)) return; this.input = event.target; if (this.input.value.length > 0) this.scoredOptions = this.settings.options .map((option) => this.settings.score(this.input.value, option)) .sort((a, b) => b.score - a.score); else this.scoredOptions = []; this.dropdown(); } /** * The input blur event * @param {object} event The event object */ blurEvent(event) { if (event.target === window || !event.target.matches(this.settings.selector)) return; this.input = event.target; if (this.input.dataset.persistDropdown === 'true') return; this.remove(); this.highlighted = -1; } /** * KEY INPUT EVENTS */ /** * What happens when the user presses the down arrow * @param {object} event The event object * @return {object} The Class */ keyDown(event) { event.preventDefault(); this.highlight((this.highlighted < this.ul.children.length - 1) ? this.highlighted + 1 : -1 ); return this; } /** * What happens when the user presses the up arrow * @param {object} event The event object * @return {object} The Class */ keyUp(event) { event.preventDefault(); this.highlight((this.highlighted > -1) ? this.highlighted - 1 : this.ul.children.length - 1 ); return this; } /** * What happens when the user presses the enter key * @param {object} event The event object * @return {object} The Class */ keyEnter(event) { this.selected(); return this; } /** * What happens when the user presses the escape key * @param {object} event The event object * @return {object} The Class */ keyEscape(event) { this.remove(); return this; } /** * STATIC */ /** * It must return an object with at least the properties 'score' * and 'displayValue.' Default is a Jaro–Winkler similarity function. * @param {array} value * @param {array} synonyms * @return {int} Score or displayValue */ static score(value, synonyms) { let closestSynonym = null; synonyms.forEach((synonym) => { let similarity = jaroWinkler( synonym.trim().toLowerCase(), value.trim().toLowerCase() ); if (closestSynonym === null || similarity > closestSynonym.similarity) { closestSynonym = {similarity, value: synonym}; if (similarity === 1) return; } }); return { score: closestSynonym.similarity, displayValue: synonyms[0] }; } /** * List item for dropdown list. * @param {Number} scoredOption * @param {Number} index * @return {string} The a list item <li>. */ static listItem(scoredOption, index) { const li = (index > this.MAX_ITEMS) ? null : document.createElement('li'); li.setAttribute('role', 'option'); li.setAttribute('tabindex', '-1'); li.setAttribute('aria-selected', 'false'); li && li.appendChild(document.createTextNode(scoredOption.displayValue)); return li; } /** * Get index of previous element. * @param {array} node * @return {number} index of previous element. */ static getSiblingIndex(node) { let index = -1; let n = node; do { index++; n = n.previousElementSibling; } while (n); return index; } /** * PUBLIC METHODS */ /** * Display options as a list. * @return {object} The Class */ dropdown() { const documentFragment = document.createDocumentFragment(); this.scoredOptions.every((scoredOption, i) => { const listItem = this.settings.listItem(scoredOption, i); listItem && documentFragment.appendChild(listItem); return !!listItem; }); this.remove(); this.highlighted = -1; if (documentFragment.hasChildNodes()) { const newUl = document.createElement('ul'); newUl.setAttribute('role', 'listbox'); newUl.setAttribute('tabindex', '0'); newUl.setAttribute('id', this.SELECTORS.OPTIONS); newUl.addEventListener('mouseover', (event) => { if (event.target.tagName === 'LI') this.highlight(this.settings.getSiblingIndex(event.target)); }); newUl.addEventListener('mousedown', (event) => event.preventDefault()); newUl.addEventListener('click', (event) => { if (event.target.tagName === 'LI') this.selected(); }); newUl.appendChild(documentFragment); // See CSS to understand why the <ul> has to be wrapped in a <div> const newContainer = document.createElement('div'); newContainer.className = this.settings.classname; newContainer.appendChild(newUl); this.input.setAttribute('aria-expanded', 'true'); // Inserts the dropdown just after the <input> element this.input.parentNode.insertBefore(newContainer, this.input.nextSibling); this.container = newContainer; this.ul = newUl; this.message('TYPING', this.settings.options.length); } return this; } /** * Highlight new option selected. * @param {Number} newIndex * @return {object} The Class */ highlight(newIndex) { if (newIndex > -1 && newIndex < this.ul.children.length) { // If any option already selected, then unselect it if (this.highlighted !== -1) { this.ul.children[this.highlighted].classList .remove(this.SELECTORS.HIGHLIGHT); this.ul.children[this.highlighted].removeAttribute('aria-selected'); this.ul.children[this.highlighted].removeAttribute('id'); this.input.removeAttribute('aria-activedescendant'); } this.highlighted = newIndex; if (this.highlighted !== -1) { this.ul.children[this.highlighted].classList .add(this.SELECTORS.HIGHLIGHT); this.ul.children[this.highlighted] .setAttribute('aria-selected', 'true'); this.ul.children[this.highlighted] .setAttribute('id', this.SELECTORS.ACTIVE_DESCENDANT); this.input.setAttribute('aria-activedescendant', this.SELECTORS.ACTIVE_DESCENDANT); } } return this; } /** * Selects an option from a list of items. * @return {object} The Class */ selected() { if (this.highlighted !== -1) { this.input.value = this.scoredOptions[this.highlighted].displayValue; this.remove(); this.message('SELECTED', this.input.value); if (window.innerWidth <= 768) this.input.scrollIntoView(true); } // User provided callback method for selected option. if (this.settings.selected) this.settings.selected(this.input.value, this); return this; } /** * Remove dropdown list once a list item is selected. * @return {object} The Class */ remove() { this.container && this.container.remove(); this.input.setAttribute('aria-expanded', 'false'); this.container = null; this.ul = null; return this; } /** * Messaging that is passed to the screen reader * @param {string} key The Key of the message to write * @param {string} variable A variable to provide to the string. * @return {object} The Class */ message(key = false, variable = '') { if (!key) return this; let messages = { 'INIT': () => this.STRINGS.DIRECTIONS_TYPE, 'TYPING': () => ([ this.STRINGS.OPTION_AVAILABLE.replace('{{ NUMBER }}', variable), this.STRINGS.DIRECTIONS_REVIEW ].join('. ')), 'SELECTED': () => ([ this.STRINGS.OPTION_SELECTED.replace('{{ VALUE }}', variable), this.STRINGS.DIRECTIONS_TYPE ].join('. ')) }; document.querySelector(`#${this.input.getAttribute('aria-describedby')}`) .innerHTML = messages[key](); return this; } } /** Selectors for the Autocomplete class. */ Autocomplete.selectors = { 'HIGHLIGHT': 'input-autocomplete__highlight', 'OPTIONS': 'input-autocomplete__options', 'ACTIVE_DESCENDANT': 'input-autocomplete__selected', 'SCREEN_READER_ONLY': 'sr-only' }; /** */ Autocomplete.strings = { 'DIRECTIONS_TYPE': 'Start typing to generate a list of potential input options', 'DIRECTIONS_REVIEW': [ 'Keyboard users can use the up and down arrows to ', 'review options and press enter to select an option' ].join(''), 'OPTION_AVAILABLE': '{{ NUMBER }} options available', 'OPTION_SELECTED': '{{ VALUE }} selected' }; /** Maximum amount of results to be returned. */ Autocomplete.maxItems = 5; export default Autocomplete;