UNPKG

@trevoreyre/autocomplete-js

Version:

Simple autocomplete component in vanilla JS and Vue

529 lines (514 loc) 21.1 kB
function _classCallCheck(a, n) { if (!(a instanceof n)) throw new TypeError("Cannot call a class as a function"); } function _defineProperties(e, r) { for (var t = 0; t < r.length; t++) { var o = r[t]; o.enumerable = o.enumerable || !1, o.configurable = !0, "value" in o && (o.writable = !0), Object.defineProperty(e, _toPropertyKey(o.key), o); } } function _createClass(e, r, t) { return r && _defineProperties(e.prototype, r), Object.defineProperty(e, "prototype", { writable: !1 }), e; } function _defineProperty(e, r, t) { return (r = _toPropertyKey(r)) in e ? Object.defineProperty(e, r, { value: t, enumerable: !0, configurable: !0, writable: !0 }) : e[r] = t, e; } function _toPrimitive(t, r) { if ("object" != typeof t || !t) return t; var e = t[Symbol.toPrimitive]; if (void 0 !== e) { var i = e.call(t, r || "default"); if ("object" != typeof i) return i; throw new TypeError("@@toPrimitive must return a primitive value."); } return ("string" === r ? String : Number)(t); } function _toPropertyKey(t) { var i = _toPrimitive(t, "string"); return "symbol" == typeof i ? i : i + ""; } // Polyfill for element.matches, to support Internet Explorer. It's a relatively // simple polyfill, so we'll just include it rather than require the user to // include the polyfill themselves. Adapted from // https://developer.mozilla.org/en-US/docs/Web/API/Element/matches#Polyfill var matches = function matches(element, selector) { return element.matches ? element.matches(selector) : element.msMatchesSelector ? element.msMatchesSelector(selector) : element.webkitMatchesSelector ? element.webkitMatchesSelector(selector) : null; }; // Polyfill for element.closest, to support Internet Explorer. It's a relatively // simple polyfill, so we'll just include it rather than require the user to // include the polyfill themselves. Adapted from // https://developer.mozilla.org/en-US/docs/Web/API/Element/closest#Polyfill var closestPolyfill = function closestPolyfill(el, selector) { var element = el; while (element && element.nodeType === 1) { if (matches(element, selector)) { return element; } element = element.parentNode; } return null; }; var closest = function closest(element, selector) { return element.closest ? element.closest(selector) : closestPolyfill(element, selector); }; // Returns true if the value has a "then" function. Adapted from // https://github.com/graphql/graphql-js/blob/499a75939f70c4863d44149371d6a99d57ff7c35/src/jsutils/isPromise.js var isPromise = function isPromise(value) { return Boolean(value && typeof value.then === 'function'); }; var AutocompleteCore = /*#__PURE__*/_createClass(function AutocompleteCore() { var _this = this; var _ref = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}, search = _ref.search, _ref$autoSelect = _ref.autoSelect, autoSelect = _ref$autoSelect === void 0 ? false : _ref$autoSelect, _ref$setValue = _ref.setValue, setValue = _ref$setValue === void 0 ? function () {} : _ref$setValue, _ref$setAttribute = _ref.setAttribute, setAttribute = _ref$setAttribute === void 0 ? function () {} : _ref$setAttribute, _ref$onUpdate = _ref.onUpdate, onUpdate = _ref$onUpdate === void 0 ? function () {} : _ref$onUpdate, _ref$onSubmit = _ref.onSubmit, onSubmit = _ref$onSubmit === void 0 ? function () {} : _ref$onSubmit, _ref$onShow = _ref.onShow, onShow = _ref$onShow === void 0 ? function () {} : _ref$onShow, _ref$autocorrect = _ref.autocorrect, autocorrect = _ref$autocorrect === void 0 ? false : _ref$autocorrect, _ref$onHide = _ref.onHide, onHide = _ref$onHide === void 0 ? function () {} : _ref$onHide, _ref$onLoading = _ref.onLoading, onLoading = _ref$onLoading === void 0 ? function () {} : _ref$onLoading, _ref$onLoaded = _ref.onLoaded, onLoaded = _ref$onLoaded === void 0 ? function () {} : _ref$onLoaded, _ref$submitOnEnter = _ref.submitOnEnter, submitOnEnter = _ref$submitOnEnter === void 0 ? false : _ref$submitOnEnter; _classCallCheck(this, AutocompleteCore); _defineProperty(this, "value", ''); _defineProperty(this, "searchCounter", 0); _defineProperty(this, "results", []); _defineProperty(this, "selectedIndex", -1); _defineProperty(this, "selectedResult", null); _defineProperty(this, "destroy", function () { _this.search = null; _this.setValue = null; _this.setAttribute = null; _this.onUpdate = null; _this.onSubmit = null; _this.autocorrect = null; _this.onShow = null; _this.onHide = null; _this.onLoading = null; _this.onLoaded = null; }); _defineProperty(this, "handleInput", function (event) { var value = event.target.value; _this.updateResults(value); _this.value = value; }); _defineProperty(this, "handleKeyDown", function (event) { var key = event.key; switch (key) { case 'Up': // IE/Edge case 'Down': // IE/Edge case 'ArrowUp': case 'ArrowDown': { var selectedIndex = key === 'ArrowUp' || key === 'Up' ? _this.selectedIndex - 1 : _this.selectedIndex + 1; event.preventDefault(); _this.handleArrows(selectedIndex); break; } case 'Tab': { _this.selectResult(); break; } case 'Enter': { var isListItemSelected = event.target.getAttribute('aria-activedescendant').length > 0; _this.selectedResult = _this.results[_this.selectedIndex] || _this.selectedResult; _this.selectResult(); if (_this.submitOnEnter) { _this.selectedResult && _this.onSubmit(_this.selectedResult); } else { if (isListItemSelected) { event.preventDefault(); } else { _this.selectedResult && _this.onSubmit(_this.selectedResult); _this.selectedResult = null; } } break; } case 'Esc': // IE/Edge case 'Escape': { _this.hideResults(); _this.setValue(); break; } default: return; } }); _defineProperty(this, "handleFocus", function (event) { var value = event.target.value; _this.updateResults(value); _this.value = value; }); _defineProperty(this, "handleBlur", function () { _this.hideResults(); }); // The mousedown event fires before the blur event. Calling preventDefault() when // the results list is clicked will prevent it from taking focus, firing the // blur event on the input element, and closing the results list before click fires. _defineProperty(this, "handleResultMouseDown", function (event) { event.preventDefault(); }); _defineProperty(this, "handleResultClick", function (event) { var target = event.target; var result = closest(target, '[data-result-index]'); if (result) { _this.selectedIndex = parseInt(result.dataset.resultIndex, 10); var selectedResult = _this.results[_this.selectedIndex]; _this.selectResult(); _this.onSubmit(selectedResult); } }); _defineProperty(this, "handleArrows", function (selectedIndex) { // Loop selectedIndex back to first or last result if out of bounds var resultsCount = _this.results.length; _this.selectedIndex = (selectedIndex % resultsCount + resultsCount) % resultsCount; // Update results and aria attributes _this.onUpdate(_this.results, _this.selectedIndex); }); _defineProperty(this, "selectResult", function () { var selectedResult = _this.results[_this.selectedIndex]; if (selectedResult) { _this.setValue(selectedResult); } _this.hideResults(); }); _defineProperty(this, "updateResults", function (value) { var currentSearch = ++_this.searchCounter; _this.onLoading(); _this.search(value).then(function (results) { if (currentSearch !== _this.searchCounter) { return; } _this.results = results; _this.onLoaded(); if (_this.results.length === 0) { _this.hideResults(); return; } _this.selectedIndex = _this.autoSelect ? 0 : -1; _this.onUpdate(_this.results, _this.selectedIndex); _this.showResults(); }); }); _defineProperty(this, "showResults", function () { _this.setAttribute('aria-expanded', true); _this.onShow(); }); _defineProperty(this, "hideResults", function () { _this.selectedIndex = -1; _this.results = []; _this.setAttribute('aria-expanded', false); _this.setAttribute('aria-activedescendant', ''); _this.onUpdate(_this.results, _this.selectedIndex); _this.onHide(); }); // Make sure selected result isn't scrolled out of view _defineProperty(this, "checkSelectedResultVisible", function (resultsElement) { var selectedResultElement = resultsElement.querySelector("[data-result-index=\"".concat(_this.selectedIndex, "\"]")); if (!selectedResultElement) { return; } var resultsPosition = resultsElement.getBoundingClientRect(); var selectedPosition = selectedResultElement.getBoundingClientRect(); if (selectedPosition.top < resultsPosition.top) { // Element is above viewable area resultsElement.scrollTop -= resultsPosition.top - selectedPosition.top; } else if (selectedPosition.bottom > resultsPosition.bottom) { // Element is below viewable area resultsElement.scrollTop += selectedPosition.bottom - resultsPosition.bottom; } }); this.search = isPromise(search) ? search : function (value) { return Promise.resolve(search(value)); }; this.autoSelect = autoSelect; this.setValue = setValue; this.setAttribute = setAttribute; this.onUpdate = onUpdate; this.onSubmit = onSubmit; this.autocorrect = autocorrect; this.onShow = onShow; this.onHide = onHide; this.onLoading = onLoading; this.onLoaded = onLoaded; this.submitOnEnter = submitOnEnter; }); // Generates a unique ID, with optional prefix. Adapted from // https://github.com/lodash/lodash/blob/61acdd0c295e4447c9c10da04e287b1ebffe452c/uniqueId.js var idCounter = 0; var uniqueId = function uniqueId() { var prefix = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : ''; return "".concat(prefix).concat(++idCounter); }; // Calculates whether element2 should be above or below element1. Always // places element2 below unless all of the following: // 1. There isn't enough visible viewport below to fit element2 // 2. There is more room above element1 than there is below // 3. Placing elemen2 above 1 won't overflow window var getRelativePosition = function getRelativePosition(element1, element2) { var position1 = element1.getBoundingClientRect(); var position2 = element2.getBoundingClientRect(); var positionAbove = /* 1 */position1.bottom + position2.height > window.innerHeight && /* 2 */window.innerHeight - position1.bottom < position1.top && /* 3 */window.pageYOffset + position1.top - position2.height > 0; return positionAbove ? 'above' : 'below'; }; // Credit David Walsh (https://davidwalsh.name/javascript-debounce-function) // Returns a function, that, as long as it continues to be invoked, will not // be triggered. The function will be called after it stops being called for // N milliseconds. If `immediate` is passed, trigger the function on the // leading edge, instead of the trailing. var debounce = function debounce(func, wait, immediate) { var timeout; return function executedFunction() { var context = this; var args = arguments; var later = function later() { timeout = null; func.apply(context, args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }; /** * @typedef {Object} LabelObj * @property {string} attribute - `aria-label` | `aria-labelledby` * @property {string} content - content of attribute */ /** * @param {string} labelStr - content for `aria-label` or – if it starts with `#` – ID for `aria-labelledby` * @returns {LabelObj} Object with label attribute and its content */ var getAriaLabel = function getAriaLabel(labelStr) { if (labelStr !== null && labelStr !== void 0 && labelStr.length) { var isLabelId = labelStr.startsWith('#'); return { attribute: isLabelId ? 'aria-labelledby' : 'aria-label', content: isLabelId ? labelStr.substring(1) : labelStr }; } }; // Creates a props object with overridden toString function. toString returns an attributes // string in the format: `key1="value1" key2="value2"` for easy use in an HTML string. var Props = /*#__PURE__*/function () { function Props(index, selectedIndex, baseClass) { _classCallCheck(this, Props); this.id = "".concat(baseClass, "-result-").concat(index); this["class"] = "".concat(baseClass, "-result"); this['data-result-index'] = index; this.role = 'option'; if (index === selectedIndex) { this['aria-selected'] = 'true'; } } return _createClass(Props, [{ key: "toString", value: function toString() { var _this = this; return Object.keys(this).reduce(function (str, key) { return "".concat(str, " ").concat(key, "=\"").concat(_this[key], "\""); }, ''); } }]); }(); var Autocomplete = /*#__PURE__*/_createClass(function Autocomplete(root) { var _this2 = this; var _ref = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}, search = _ref.search, _ref$onSubmit = _ref.onSubmit, onSubmit = _ref$onSubmit === void 0 ? function () {} : _ref$onSubmit, _ref$onUpdate = _ref.onUpdate, onUpdate = _ref$onUpdate === void 0 ? function () {} : _ref$onUpdate, _ref$baseClass = _ref.baseClass, baseClass = _ref$baseClass === void 0 ? 'autocomplete' : _ref$baseClass, _ref$autocorrect = _ref.autocorrect, autocorrect = _ref$autocorrect === void 0 ? false : _ref$autocorrect, autoSelect = _ref.autoSelect, _ref$getResultValue = _ref.getResultValue, getResultValue = _ref$getResultValue === void 0 ? function (result) { return result; } : _ref$getResultValue, renderResult = _ref.renderResult, _ref$debounceTime = _ref.debounceTime, debounceTime = _ref$debounceTime === void 0 ? 0 : _ref$debounceTime, resultListLabel = _ref.resultListLabel, _ref$submitOnEnter = _ref.submitOnEnter, submitOnEnter = _ref$submitOnEnter === void 0 ? false : _ref$submitOnEnter; _classCallCheck(this, Autocomplete); _defineProperty(this, "expanded", false); _defineProperty(this, "loading", false); _defineProperty(this, "position", {}); _defineProperty(this, "resetPosition", true); // Set up aria attributes and events _defineProperty(this, "initialize", function () { _this2.root.style.position = 'relative'; _this2.input.setAttribute('role', 'combobox'); _this2.input.setAttribute('autocomplete', 'off'); _this2.input.setAttribute('autocapitalize', 'off'); if (_this2.autocorrect) { _this2.input.setAttribute('autocorrect', 'on'); } _this2.input.setAttribute('spellcheck', 'false'); _this2.input.setAttribute('aria-autocomplete', 'list'); _this2.input.setAttribute('aria-haspopup', 'listbox'); _this2.input.setAttribute('aria-expanded', 'false'); _this2.resultList.setAttribute('role', 'listbox'); var resultListAriaLabel = getAriaLabel(_this2.resultListLabel); resultListAriaLabel && _this2.resultList.setAttribute(resultListAriaLabel.attribute, resultListAriaLabel.content); _this2.resultList.style.position = 'absolute'; _this2.resultList.style.zIndex = '1'; _this2.resultList.style.width = '100%'; _this2.resultList.style.boxSizing = 'border-box'; // Generate ID for results list if it doesn't have one if (!_this2.resultList.id) { _this2.resultList.id = uniqueId("".concat(_this2.baseClass, "-result-list-")); } _this2.input.setAttribute('aria-owns', _this2.resultList.id); document.body.addEventListener('click', _this2.handleDocumentClick); _this2.input.addEventListener('input', _this2.core.handleInput); _this2.input.addEventListener('keydown', _this2.core.handleKeyDown); _this2.input.addEventListener('focus', _this2.core.handleFocus); _this2.input.addEventListener('blur', _this2.core.handleBlur); _this2.resultList.addEventListener('mousedown', _this2.core.handleResultMouseDown); _this2.resultList.addEventListener('click', _this2.core.handleResultClick); _this2.updateStyle(); }); _defineProperty(this, "destroy", function () { document.body.removeEventListener('click', _this2.handleDocumentClick); _this2.input.removeEventListener('input', _this2.core.handleInput); _this2.input.removeEventListener('keydown', _this2.core.handleKeyDown); _this2.input.removeEventListener('focus', _this2.core.handleFocus); _this2.input.removeEventListener('blur', _this2.core.handleBlur); _this2.resultList.removeEventListener('mousedown', _this2.core.handleResultMouseDown); _this2.resultList.removeEventListener('click', _this2.core.handleResultClick); _this2.root = null; _this2.input = null; _this2.resultList = null; _this2.getResultValue = null; _this2.onUpdate = null; _this2.renderResult = null; _this2.core.destroy(); _this2.core = null; }); _defineProperty(this, "setAttribute", function (attribute, value) { _this2.input.setAttribute(attribute, value); }); _defineProperty(this, "setValue", function (result) { _this2.input.value = result ? _this2.getResultValue(result) : ''; }); _defineProperty(this, "renderResult", function (result, props) { return "<li ".concat(props, ">").concat(_this2.getResultValue(result), "</li>"); }); _defineProperty(this, "handleUpdate", function (results, selectedIndex) { _this2.resultList.innerHTML = ''; results.forEach(function (result, index) { var props = new Props(index, selectedIndex, _this2.baseClass); var resultHTML = _this2.renderResult(result, props); if (typeof resultHTML === 'string') { _this2.resultList.insertAdjacentHTML('beforeend', resultHTML); } else { _this2.resultList.insertAdjacentElement('beforeend', resultHTML); } }); _this2.input.setAttribute('aria-activedescendant', selectedIndex > -1 ? "".concat(_this2.baseClass, "-result-").concat(selectedIndex) : ''); if (_this2.resetPosition) { _this2.resetPosition = false; _this2.position = getRelativePosition(_this2.input, _this2.resultList); _this2.updateStyle(); } _this2.core.checkSelectedResultVisible(_this2.resultList); _this2.onUpdate(results, selectedIndex); }); _defineProperty(this, "handleShow", function () { _this2.expanded = true; _this2.updateStyle(); }); _defineProperty(this, "handleHide", function () { _this2.expanded = false; _this2.resetPosition = true; _this2.updateStyle(); }); _defineProperty(this, "handleLoading", function () { _this2.loading = true; _this2.updateStyle(); }); _defineProperty(this, "handleLoaded", function () { _this2.loading = false; _this2.updateStyle(); }); _defineProperty(this, "handleDocumentClick", function (event) { if (_this2.root.contains(event.target)) { return; } _this2.core.hideResults(); }); _defineProperty(this, "updateStyle", function () { _this2.root.dataset.expanded = _this2.expanded; _this2.root.dataset.loading = _this2.loading; _this2.root.dataset.position = _this2.position; _this2.resultList.style.visibility = _this2.expanded ? 'visible' : 'hidden'; _this2.resultList.style.pointerEvents = _this2.expanded ? 'auto' : 'none'; if (_this2.position === 'below') { _this2.resultList.style.bottom = null; _this2.resultList.style.top = '100%'; } else { _this2.resultList.style.top = null; _this2.resultList.style.bottom = '100%'; } }); this.root = typeof root === 'string' ? document.querySelector(root) : root; this.input = this.root.querySelector('input'); this.resultList = this.root.querySelector('ul'); this.baseClass = baseClass; this.autocorrect = autocorrect; this.getResultValue = getResultValue; this.onUpdate = onUpdate; if (typeof renderResult === 'function') { this.renderResult = renderResult; } this.resultListLabel = resultListLabel; this.submitOnEnter = submitOnEnter; var core = new AutocompleteCore({ search: search, autoSelect: autoSelect, setValue: this.setValue, setAttribute: this.setAttribute, onUpdate: this.handleUpdate, autocorrect: this.autocorrect, onSubmit: onSubmit, onShow: this.handleShow, onHide: this.handleHide, onLoading: this.handleLoading, onLoaded: this.handleLoaded, submitOnEnter: this.submitOnEnter }); if (debounceTime > 0) { core.handleInput = debounce(core.handleInput, debounceTime); } this.core = core; this.initialize(); }); export { Autocomplete as default };