autocomplete/src/autocomplete.vue
<template> <div class="el-autocomplete" v-clickoutside="close" aria-haspopup="listbox" role="combobox" :aria-expanded="suggestionVisible" :aria-owns="id" > <el-input ref="input" v-bind="[$props, $attrs]" @input="handleChange" @focus="handleFocus" @blur="handleBlur" @clear="handleClear" @keydown.up.native.prevent="highlight(highlightedIndex - 1)" @keydown.down.native.prevent="highlight(highlightedIndex + 1)" @keydown.enter.native="handleKeyEnter" @keydown.native.tab="close" > <template slot="prepend" v-if="$slots.prepend"> <slot name="prepend"></slot> </template> <template slot="append" v-if="$slots.append"> <slot name="append"></slot> </template> <template slot="prefix" v-if="$slots.prefix"> <slot name="prefix"></slot> </template> <template slot="suffix" v-if="$slots.suffix"> <slot name="suffix"></slot> </template> </el-input> <el-autocomplete-suggestions visible-arrow :class="[popperClass ? popperClass : '']" :popper-options="popperOptions" :append-to-body="popperAppendToBody" ref="suggestions" :placement="placement" :id="id"> <li v-for="(item, index) in suggestions" :key="index" :class="{'highlighted': highlightedIndex === index}" @click="select(item)" :id="`${id}-item-${index}`" role="option" :aria-selected="highlightedIndex === index" > <slot :item="item"> {{ item[valueKey] }} </slot> </li> </el-autocomplete-suggestions> </div> </template> <script> import debounce from 'throttle-debounce/debounce'; import ElInput from 'element-ui/packages/input'; import Clickoutside from 'element-ui/src/utils/clickoutside'; import ElAutocompleteSuggestions from './autocomplete-suggestions.vue'; import Emitter from 'element-ui/src/mixins/emitter'; import Migrating from 'element-ui/src/mixins/migrating'; import { generateId } from 'element-ui/src/utils/util'; import Focus from 'element-ui/src/mixins/focus'; export default { name: 'ElAutocomplete', mixins: [Emitter, Focus('input'), Migrating], inheritAttrs: false, componentName: 'ElAutocomplete', components: { ElInput, ElAutocompleteSuggestions }, directives: { Clickoutside }, props: { valueKey: { type: String, default: 'value' }, popperClass: String, popperOptions: Object, placeholder: String, clearable: { type: Boolean, default: false }, disabled: Boolean, name: String, size: String, value: String, maxlength: Number, minlength: Number, autofocus: Boolean, fetchSuggestions: Function, triggerOnFocus: { type: Boolean, default: true }, customItem: String, selectWhenUnmatched: { type: Boolean, default: false }, prefixIcon: String, suffixIcon: String, label: String, debounce: { type: Number, default: 300 }, placement: { type: String, default: 'bottom-start' }, hideLoading: Boolean, popperAppendToBody: { type: Boolean, default: true }, highlightFirstItem: { type: Boolean, default: false } }, data() { return { activated: false, suggestions: [], loading: false, highlightedIndex: -1, suggestionDisabled: false }; }, computed: { suggestionVisible() { const suggestions = this.suggestions; let isValidData = Array.isArray(suggestions) && suggestions.length > 0; return (isValidData || this.loading) && this.activated; }, id() { return `el-autocomplete-${generateId()}`; } }, watch: { suggestionVisible(val) { let $input = this.getInput(); if ($input) { this.broadcast('ElAutocompleteSuggestions', 'visible', [val, $input.offsetWidth]); } } }, methods: { getMigratingConfig() { return { props: { 'custom-item': 'custom-item is removed, use scoped slot instead.', 'props': 'props is removed, use value-key instead.' } }; }, getData(queryString) { if (this.suggestionDisabled) { return; } this.loading = true; this.fetchSuggestions(queryString, (suggestions) => { this.loading = false; if (this.suggestionDisabled) { return; } if (Array.isArray(suggestions)) { this.suggestions = suggestions; this.highlightedIndex = this.highlightFirstItem ? 0 : -1; } else { console.error('[Element Error][Autocomplete]autocomplete suggestions must be an array'); } }); }, handleChange(value) { this.$emit('input', value); this.suggestionDisabled = false; if (!this.triggerOnFocus && !value) { this.suggestionDisabled = true; this.suggestions = []; return; } this.debouncedGetData(value); }, handleFocus(event) { this.activated = true; this.$emit('focus', event); if (this.triggerOnFocus) { this.debouncedGetData(this.value); } }, handleBlur(event) { this.$emit('blur', event); }, handleClear() { this.activated = false; this.$emit('clear'); }, close(e) { this.activated = false; }, handleKeyEnter(e) { if (this.suggestionVisible && this.highlightedIndex >= 0 && this.highlightedIndex < this.suggestions.length) { e.preventDefault(); this.select(this.suggestions[this.highlightedIndex]); } else if (this.selectWhenUnmatched) { this.$emit('select', {value: this.value}); this.$nextTick(_ => { this.suggestions = []; this.highlightedIndex = -1; }); } }, select(item) { this.$emit('input', item[this.valueKey]); this.$emit('select', item); this.$nextTick(_ => { this.suggestions = []; this.highlightedIndex = -1; }); }, highlight(index) { if (!this.suggestionVisible || this.loading) { return; } if (index < 0) { this.highlightedIndex = -1; return; } if (index >= this.suggestions.length) { index = this.suggestions.length - 1; } const suggestion = this.$refs.suggestions.$el.querySelector('.el-autocomplete-suggestion__wrap'); const suggestionList = suggestion.querySelectorAll('.el-autocomplete-suggestion__list li'); let highlightItem = suggestionList[index]; let scrollTop = suggestion.scrollTop; let offsetTop = highlightItem.offsetTop; if (offsetTop + highlightItem.scrollHeight > (scrollTop + suggestion.clientHeight)) { suggestion.scrollTop += highlightItem.scrollHeight; } if (offsetTop < scrollTop) { suggestion.scrollTop -= highlightItem.scrollHeight; } this.highlightedIndex = index; let $input = this.getInput(); $input.setAttribute('aria-activedescendant', `${this.id}-item-${this.highlightedIndex}`); }, getInput() { return this.$refs.input.getInput(); } }, mounted() { this.debouncedGetData = debounce(this.debounce, this.getData); this.$on('item-click', item => { this.select(item); }); let $input = this.getInput(); $input.setAttribute('role', 'textbox'); $input.setAttribute('aria-autocomplete', 'list'); $input.setAttribute('aria-controls', 'id'); $input.setAttribute('aria-activedescendant', `${this.id}-item-${this.highlightedIndex}`); }, beforeDestroy() { this.$refs.suggestions.$destroy(); } }; </script>
autocomplete/src/autocomplete-suggestion.vue
<template> <transition name="el-zoom-in-top" @after-leave="doDestroy"> <div v-show="showPopper" class="el-autocomplete-suggestion el-popper" :class="{ 'is-loading': !parent.hideLoading && parent.loading }" :style="{ width: dropdownWidth }" role="region"> <el-scrollbar tag="ul" wrap-class="el-autocomplete-suggestion__wrap" view-class="el-autocomplete-suggestion__list"> <li v-if="!parent.hideLoading && parent.loading"><i class="el-icon-loading"></i></li> <slot v-else> </slot> </el-scrollbar> </div> </transition> </template> <script> import Popper from 'element-ui/src/utils/vue-popper'; import Emitter from 'element-ui/src/mixins/emitter'; import ElScrollbar from 'element-ui/packages/scrollbar'; export default { components: { ElScrollbar }, mixins: [Popper, Emitter], componentName: 'ElAutocompleteSuggestions', data() { return { parent: this.$parent, dropdownWidth: '' }; }, props: { options: { default() { return { gpuAcceleration: false }; } }, id: String }, methods: { select(item) { this.dispatch('ElAutocomplete', 'item-click', item); } }, updated() { this.$nextTick(_ => { this.popperJS && this.updatePopper(); }); }, mounted() { this.$parent.popperElm = this.popperElm = this.$el; this.referenceElm = this.$parent.$refs.input.$refs.input; this.referenceList = this.$el.querySelector('.el-autocomplete-suggestion__list'); this.referenceList.setAttribute('role', 'listbox'); this.referenceList.setAttribute('id', this.id); }, created() { this.$on('visible', (val, inputWidth) => { this.dropdownWidth = inputWidth + 'px'; this.showPopper = val; }); } }; </script>