element-plus/packages/components/autocomplete/src/index.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>