@trevoreyre/autocomplete-vue
Version:
Simple autocomplete component in vanilla JS and Vue
601 lines (564 loc) • 20 kB
JavaScript
import { useId, openBlock, createElementBlock, mergeProps, renderSlot, createElementVNode, normalizeProps, guardReactiveProps, toHandlers, Fragment, renderList, toDisplayString } from '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-${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 (openBlock(), createElementBlock("div", mergeProps({ ref: "root" }, {
class: _ctx.$attrs.class,
...(_ctx.$attrs.style ? { style: _ctx.$attrs.style } : {}),
}), [
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
}, () => [
createElementVNode("div", normalizeProps(guardReactiveProps($options.rootProps)), [
createElementVNode("input", 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 */),
createElementVNode("ul", mergeProps({ ref: "resultList" }, $options.resultListProps, toHandlers($options.resultListListeners, true)), [
(openBlock(true), createElementBlock(Fragment, null, renderList($data.results, (result, index) => {
return renderSlot(_ctx.$slots, "result", {
result: result,
props: $options.resultProps[index]
}, () => [
(openBlock(), createElementBlock("li", mergeProps({
key: $options.resultProps[index].id,
ref_for: true
}, $options.resultProps[index]), 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;
export { script as default };