mirror of
https://github.com/element-plus/element-plus.git
synced 2025-01-12 10:45:10 +08:00
384 lines
10 KiB
Vue
384 lines
10 KiB
Vue
<template>
|
|
<el-tooltip
|
|
ref="popper"
|
|
v-model:visible="suggestionVisible"
|
|
:placement="placement"
|
|
:fallback-placements="['bottom-start', 'top-start']"
|
|
:popper-class="`${ns.e('popper')} ${popperClass}`"
|
|
:append-to-body="popperAppendToBody"
|
|
:gpu-acceleration="false"
|
|
pure
|
|
manual-mode
|
|
effect="light"
|
|
trigger="click"
|
|
:transition="`${ns.namespace.value}-zoom-in-top`"
|
|
persistent
|
|
@show="onSuggestionShow"
|
|
>
|
|
<div
|
|
v-clickoutside="close"
|
|
:class="[ns.b(), $attrs.class]"
|
|
:style="$attrs.style"
|
|
role="combobox"
|
|
aria-haspopup="listbox"
|
|
:aria-expanded="suggestionVisible"
|
|
:aria-owns="id"
|
|
>
|
|
<el-input
|
|
ref="inputRef"
|
|
v-bind="attrs"
|
|
:model-value="modelValue"
|
|
@input="handleInput"
|
|
@change="handleChange"
|
|
@focus="handleFocus"
|
|
@blur="handleBlur"
|
|
@clear="handleClear"
|
|
@keydown.up.prevent="highlight(highlightedIndex - 1)"
|
|
@keydown.down.prevent="highlight(highlightedIndex + 1)"
|
|
@keydown.enter="handleKeyEnter"
|
|
@keydown.tab="close"
|
|
>
|
|
<template v-if="$slots.prepend" #prepend>
|
|
<slot name="prepend"></slot>
|
|
</template>
|
|
<template v-if="$slots.append" #append>
|
|
<slot name="append"></slot>
|
|
</template>
|
|
<template v-if="$slots.prefix" #prefix>
|
|
<slot name="prefix"></slot>
|
|
</template>
|
|
<template v-if="$slots.suffix" #suffix>
|
|
<slot name="suffix"></slot>
|
|
</template>
|
|
</el-input>
|
|
</div>
|
|
<template #content>
|
|
<div
|
|
ref="regionRef"
|
|
:class="[ns.b('suggestion'), ns.is('loading', suggestionLoading)]"
|
|
:style="{ minWidth: dropdownWidth, outline: 'none' }"
|
|
role="region"
|
|
>
|
|
<el-scrollbar
|
|
:id="id"
|
|
tag="ul"
|
|
:wrap-class="ns.be('suggestion', 'wrap')"
|
|
:view-class="ns.be('suggestion', 'list')"
|
|
role="listbox"
|
|
>
|
|
<li v-if="suggestionLoading">
|
|
<el-icon class="is-loading"><loading /></el-icon>
|
|
</li>
|
|
<template v-else>
|
|
<li
|
|
v-for="(item, index) in suggestions"
|
|
:id="`${id}-item-${index}`"
|
|
:key="index"
|
|
:class="{ highlighted: highlightedIndex === index }"
|
|
role="option"
|
|
:aria-selected="highlightedIndex === index"
|
|
@click="select(item)"
|
|
>
|
|
<slot :item="item">{{ item[valueKey] }}</slot>
|
|
</li>
|
|
</template>
|
|
</el-scrollbar>
|
|
</div>
|
|
</template>
|
|
</el-tooltip>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import { defineComponent, ref, computed, onMounted, nextTick } from 'vue'
|
|
import { NOOP } from '@vue/shared'
|
|
import debounce from 'lodash/debounce'
|
|
import { useAttrs, useNamespace } from '@element-plus/hooks'
|
|
import { ClickOutside } from '@element-plus/directives'
|
|
import { generateId, isArray } from '@element-plus/utils/util'
|
|
import { UPDATE_MODEL_EVENT } from '@element-plus/utils/constants'
|
|
import { throwError } from '@element-plus/utils/error'
|
|
import ElInput from '@element-plus/components/input'
|
|
import ElScrollbar from '@element-plus/components/scrollbar'
|
|
import ElTooltip from '@element-plus/components/tooltip'
|
|
import ElIcon from '@element-plus/components/icon'
|
|
import { Loading } from '@element-plus/icons-vue'
|
|
|
|
import type { Placement } from '@element-plus/components/popper'
|
|
import type { PropType } from 'vue'
|
|
|
|
export default defineComponent({
|
|
name: 'ElAutocomplete',
|
|
components: {
|
|
ElTooltip,
|
|
ElInput,
|
|
ElScrollbar,
|
|
ElIcon,
|
|
Loading,
|
|
},
|
|
directives: {
|
|
clickoutside: ClickOutside,
|
|
},
|
|
inheritAttrs: false,
|
|
props: {
|
|
valueKey: {
|
|
type: String,
|
|
default: 'value',
|
|
},
|
|
modelValue: {
|
|
type: [String, Number],
|
|
default: '',
|
|
},
|
|
debounce: {
|
|
type: Number,
|
|
default: 300,
|
|
},
|
|
placement: {
|
|
type: String as PropType<Placement>,
|
|
validator: (val: string): boolean => {
|
|
return [
|
|
'top',
|
|
'top-start',
|
|
'top-end',
|
|
'bottom',
|
|
'bottom-start',
|
|
'bottom-end',
|
|
].includes(val)
|
|
},
|
|
default: 'bottom-start',
|
|
},
|
|
fetchSuggestions: {
|
|
type: Function as PropType<
|
|
(queryString: string, cb: (data: any[]) => void) => void
|
|
>,
|
|
default: NOOP,
|
|
},
|
|
popperClass: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
triggerOnFocus: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
selectWhenUnmatched: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
hideLoading: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
popperAppendToBody: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
highlightFirstItem: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
},
|
|
emits: [
|
|
UPDATE_MODEL_EVENT,
|
|
'input',
|
|
'change',
|
|
'focus',
|
|
'blur',
|
|
'clear',
|
|
'select',
|
|
],
|
|
setup(props, ctx) {
|
|
const ns = useNamespace('autocomplete')
|
|
const attrs = useAttrs()
|
|
const suggestions = ref<any[]>([])
|
|
const highlightedIndex = ref(-1)
|
|
const dropdownWidth = ref('')
|
|
const activated = ref(false)
|
|
const suggestionDisabled = ref(false)
|
|
const loading = ref(false)
|
|
const inputRef = ref<{
|
|
inputOrTextarea: HTMLInputElement | HTMLTextAreaElement
|
|
focus: () => void
|
|
$el: HTMLElement
|
|
} | null>(null)
|
|
const regionRef = ref<HTMLElement | null>(null)
|
|
const popper = ref(null)
|
|
|
|
const id = computed(() => {
|
|
return ns.b(String(generateId()))
|
|
})
|
|
const suggestionVisible = computed(() => {
|
|
const isValidData =
|
|
isArray(suggestions.value) && suggestions.value.length > 0
|
|
return (isValidData || loading.value) && activated.value
|
|
})
|
|
const suggestionLoading = computed(() => {
|
|
return !props.hideLoading && loading.value
|
|
})
|
|
|
|
const onSuggestionShow = () => {
|
|
nextTick(() => {
|
|
if (suggestionVisible.value) {
|
|
dropdownWidth.value = `${inputRef.value!.$el.offsetWidth}px`
|
|
}
|
|
})
|
|
}
|
|
|
|
onMounted(() => {
|
|
inputRef.value!.inputOrTextarea.setAttribute('role', 'textbox')
|
|
inputRef.value!.inputOrTextarea.setAttribute('aria-autocomplete', 'list')
|
|
inputRef.value!.inputOrTextarea.setAttribute('aria-controls', 'id')
|
|
inputRef.value!.inputOrTextarea.setAttribute(
|
|
'aria-activedescendant',
|
|
`${id.value}-item-${highlightedIndex.value}`
|
|
)
|
|
})
|
|
|
|
const getData = (queryString: string) => {
|
|
if (suggestionDisabled.value) {
|
|
return
|
|
}
|
|
loading.value = true
|
|
props.fetchSuggestions(queryString, (suggestionsArg) => {
|
|
loading.value = false
|
|
if (suggestionDisabled.value) {
|
|
return
|
|
}
|
|
if (isArray(suggestionsArg)) {
|
|
suggestions.value = suggestionsArg
|
|
highlightedIndex.value = props.highlightFirstItem ? 0 : -1
|
|
} else {
|
|
throwError(
|
|
'ElAutocomplete',
|
|
'autocomplete suggestions must be an array'
|
|
)
|
|
}
|
|
})
|
|
}
|
|
const debouncedGetData = debounce(getData, props.debounce)
|
|
const handleInput = (value: string) => {
|
|
ctx.emit('input', value)
|
|
ctx.emit(UPDATE_MODEL_EVENT, value)
|
|
suggestionDisabled.value = false
|
|
if (!props.triggerOnFocus && !value) {
|
|
suggestionDisabled.value = true
|
|
suggestions.value = []
|
|
return
|
|
}
|
|
debouncedGetData(value)
|
|
}
|
|
const handleChange = (value) => {
|
|
ctx.emit('change', value)
|
|
}
|
|
const handleFocus = (e) => {
|
|
activated.value = true
|
|
ctx.emit('focus', e)
|
|
if (props.triggerOnFocus) {
|
|
debouncedGetData(String(props.modelValue))
|
|
}
|
|
}
|
|
const handleBlur = (e) => {
|
|
ctx.emit('blur', e)
|
|
}
|
|
const handleClear = () => {
|
|
activated.value = false
|
|
ctx.emit(UPDATE_MODEL_EVENT, '')
|
|
ctx.emit('clear')
|
|
}
|
|
const handleKeyEnter = () => {
|
|
if (
|
|
suggestionVisible.value &&
|
|
highlightedIndex.value >= 0 &&
|
|
highlightedIndex.value < suggestions.value.length
|
|
) {
|
|
select(suggestions.value[highlightedIndex.value])
|
|
} else if (props.selectWhenUnmatched) {
|
|
ctx.emit('select', { value: props.modelValue })
|
|
nextTick(() => {
|
|
suggestions.value = []
|
|
highlightedIndex.value = -1
|
|
})
|
|
}
|
|
}
|
|
const close = () => {
|
|
activated.value = false
|
|
}
|
|
const focus = () => {
|
|
inputRef.value?.focus()
|
|
}
|
|
const select = (item) => {
|
|
ctx.emit('input', item[props.valueKey])
|
|
ctx.emit(UPDATE_MODEL_EVENT, item[props.valueKey])
|
|
ctx.emit('select', item)
|
|
nextTick(() => {
|
|
suggestions.value = []
|
|
highlightedIndex.value = -1
|
|
})
|
|
}
|
|
const highlight = (index: number) => {
|
|
if (!suggestionVisible.value || loading.value) {
|
|
return
|
|
}
|
|
if (index < 0) {
|
|
highlightedIndex.value = -1
|
|
return
|
|
}
|
|
if (index >= suggestions.value.length) {
|
|
index = suggestions.value.length - 1
|
|
}
|
|
const suggestion = regionRef.value!.querySelector(
|
|
`.${ns.be('suggestion', 'wrap')}`
|
|
)!
|
|
const suggestionList = suggestion.querySelectorAll(
|
|
`.${ns.be('suggestion', 'list')} li`
|
|
)!
|
|
const highlightItem = suggestionList[index]
|
|
const scrollTop = suggestion.scrollTop
|
|
const { offsetTop, scrollHeight } = highlightItem as HTMLElement
|
|
|
|
if (offsetTop + scrollHeight > scrollTop + suggestion.clientHeight) {
|
|
suggestion.scrollTop += scrollHeight
|
|
}
|
|
if (offsetTop < scrollTop) {
|
|
suggestion.scrollTop -= scrollHeight
|
|
}
|
|
highlightedIndex.value = index
|
|
inputRef.value!.inputOrTextarea.setAttribute(
|
|
'aria-activedescendant',
|
|
`${id.value}-item-${highlightedIndex.value}`
|
|
)
|
|
}
|
|
|
|
return {
|
|
attrs,
|
|
suggestions,
|
|
highlightedIndex,
|
|
dropdownWidth,
|
|
activated,
|
|
suggestionDisabled,
|
|
loading,
|
|
inputRef,
|
|
regionRef,
|
|
popper,
|
|
|
|
id,
|
|
suggestionVisible,
|
|
suggestionLoading,
|
|
|
|
getData,
|
|
handleInput,
|
|
handleChange,
|
|
handleFocus,
|
|
handleBlur,
|
|
handleClear,
|
|
handleKeyEnter,
|
|
close,
|
|
focus,
|
|
select,
|
|
highlight,
|
|
onSuggestionShow,
|
|
ns,
|
|
}
|
|
},
|
|
})
|
|
</script>
|