UNPKG

@trevoreyre/autocomplete-vue

Version:

Simple autocomplete component in vanilla JS and Vue

603 lines (565 loc) 20 kB
'use strict'; var vue = require('vue'); function _classCallCheck(a, n) { if (!(a instanceof n)) throw new TypeError("Cannot call a class as a function"); } function _createClass(e, r, t) { return 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; }); // 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 }; } }; var script = { name: 'Autocomplete', inheritAttrs: false, props: { search: { type: Function, required: true, }, baseClass: { type: String, default: 'autocomplete', }, autoSelect: { type: Boolean, default: false, }, getResultValue: { type: Function, default: (result) => result, }, defaultValue: { type: String, default: '', }, debounceTime: { type: Number, default: 0, }, resultListLabel: { type: String, default: undefined, }, submitOnEnter: { type: Boolean, default: false, }, }, emits: ['update', 'submit'], data() { const core = new AutocompleteCore({ search: this.search, autoSelect: this.autoSelect, setValue: this.setValue, onUpdate: this.handleUpdate, onSubmit: this.handleSubmit, onShow: this.handleShow, onHide: this.handleHide, onLoading: this.handleLoading, onLoaded: this.handleLoaded, submitOnEnter: this.submitOnEnter, }); if (this.debounceTime > 0) { core.handleInput = debounce(core.handleInput, this.debounceTime); } return { core, value: this.defaultValue, resultListId: `${this.baseClass}-result-list-${vue.useId()}`, results: [], selectedIndex: -1, expanded: false, loading: false, position: 'below', resetPosition: true, } }, computed: { rootProps() { return { class: this.baseClass, style: { position: 'relative' }, 'data-expanded': this.expanded, 'data-loading': this.loading, 'data-position': this.position, } }, inputProps() { const { class: classAttribute, style, ...attrsWithoutClassAndStyle } = this.$attrs; return { class: `${this.baseClass}-input`, value: this.value, role: 'combobox', autocomplete: 'off', autocapitalize: 'off', autocorrect: 'off', spellcheck: 'false', 'aria-autocomplete': 'list', 'aria-haspopup': 'listbox', 'aria-owns': this.resultListId, 'aria-expanded': this.expanded ? 'true' : 'false', 'aria-activedescendant': this.selectedIndex > -1 ? this.resultProps[this.selectedIndex].id : '', ...attrsWithoutClassAndStyle, } }, inputListeners() { return { input: this.handleInput, keydown: this.core.handleKeyDown, focus: this.core.handleFocus, blur: this.core.handleBlur, } }, resultListProps() { const yPosition = this.position === 'below' ? 'top' : 'bottom'; const ariaLabel = getAriaLabel(this.resultListLabel); return { id: this.resultListId, class: `${this.baseClass}-result-list`, role: 'listbox', [ariaLabel?.attribute]: ariaLabel?.content, style: { position: 'absolute', zIndex: 1, width: '100%', visibility: this.expanded ? 'visible' : 'hidden', pointerEvents: this.expanded ? 'auto' : 'none', [yPosition]: '100%', }, } }, resultListListeners() { return { mousedown: this.core.handleResultMouseDown, click: this.core.handleResultClick, } }, resultProps() { return this.results.map((result, index) => ({ id: `${this.baseClass}-result-${index}`, class: `${this.baseClass}-result`, 'data-result-index': index, role: 'option', ...(this.selectedIndex === index ? { 'aria-selected': 'true' } : {}), })) }, }, mounted() { document.body.addEventListener('click', this.handleDocumentClick); }, beforeUnmount() { document.body.removeEventListener('click', this.handleDocumentClick); }, updated() { if (!this.$refs.input || !this.$refs.resultList) { return } if (this.resetPosition && this.results.length > 0) { this.resetPosition = false; this.position = getRelativePosition( this.$refs.input, this.$refs.resultList ); } this.core.checkSelectedResultVisible(this.$refs.resultList); }, methods: { setValue(result) { this.value = result ? this.getResultValue(result) : ''; }, handleUpdate(results, selectedIndex) { this.results = results; this.selectedIndex = selectedIndex; this.$emit('update', results, selectedIndex); }, handleShow() { this.expanded = true; }, handleHide() { this.expanded = false; this.resetPosition = true; }, handleLoading() { this.loading = true; }, handleLoaded() { this.loading = false; }, handleInput(event) { this.value = event.target.value; this.core.handleInput(event); }, handleSubmit(selectedResult) { this.$emit('submit', selectedResult); }, handleDocumentClick(event) { if (this.$refs.root.contains(event.target)) { return } this.core.hideResults(); }, }, }; function render(_ctx, _cache, $props, $setup, $data, $options) { return (vue.openBlock(), vue.createElementBlock("div", vue.mergeProps({ ref: "root" }, { class: _ctx.$attrs.class, ...(_ctx.$attrs.style ? { style: _ctx.$attrs.style } : {}), }), [ vue.renderSlot(_ctx.$slots, "default", { rootProps: $options.rootProps, inputProps: $options.inputProps, inputListeners: $options.inputListeners, resultListProps: $options.resultListProps, resultListListeners: $options.resultListListeners, results: $data.results, resultProps: $options.resultProps }, () => [ vue.createElementVNode("div", vue.normalizeProps(vue.guardReactiveProps($options.rootProps)), [ vue.createElementVNode("input", vue.mergeProps({ ref: "input" }, $options.inputProps, { onInput: _cache[0] || (_cache[0] = (...args) => ($options.handleInput && $options.handleInput(...args))), onKeydown: _cache[1] || (_cache[1] = (...args) => ($data.core.handleKeyDown && $data.core.handleKeyDown(...args))), onFocus: _cache[2] || (_cache[2] = (...args) => ($data.core.handleFocus && $data.core.handleFocus(...args))), onBlur: _cache[3] || (_cache[3] = (...args) => ($data.core.handleBlur && $data.core.handleBlur(...args))) }), null, 16 /* FULL_PROPS */), vue.createElementVNode("ul", vue.mergeProps({ ref: "resultList" }, $options.resultListProps, vue.toHandlers($options.resultListListeners, true)), [ (vue.openBlock(true), vue.createElementBlock(vue.Fragment, null, vue.renderList($data.results, (result, index) => { return vue.renderSlot(_ctx.$slots, "result", { result: result, props: $options.resultProps[index] }, () => [ (vue.openBlock(), vue.createElementBlock("li", vue.mergeProps({ key: $options.resultProps[index].id, ref_for: true }, $options.resultProps[index]), vue.toDisplayString($props.getResultValue(result)), 17 /* TEXT, FULL_PROPS */)) ]) }), 256 /* UNKEYED_FRAGMENT */)) ], 16 /* FULL_PROPS */) ], 16 /* FULL_PROPS */) ]) ], 16 /* FULL_PROPS */)) } script.render = render; script.__file = "packages/autocomplete-vue/Autocomplete.vue"; function install(Vue) { if (install.installed) { return; } install.installed = true; Vue.component('Autocomplete', script); } var plugin = { install: install }; // Auto install if Vue is found var GlobalVue; if (typeof window !== 'undefined') { GlobalVue = window.Vue; } else if (typeof global !== 'undefined') { GlobalVue = global.Vue; } if (GlobalVue) { GlobalVue.use(plugin); } // Inject install function into component. Allows component to be registered via // Vue.use() as well as Vue.component() script.install = install; module.exports = script;