mirror of
https://github.com/element-plus/element-plus.git
synced 2025-01-30 11:16:12 +08:00
refactor(components): [autocomplete] refactor autocomplete (#6067)
Co-authored-by: 三咲智子 <sxzz@sxzz.moe>
This commit is contained in:
parent
5e0bdf758d
commit
cde87c5590
@ -1,8 +1,8 @@
|
||||
import { nextTick } from 'vue'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { NOOP } from '@vue/shared'
|
||||
import { POPPER_CONTAINER_SELECTOR } from '@element-plus/hooks'
|
||||
import Autocomplete from '../src/index.vue'
|
||||
import Autocomplete from '../src/autocomplete.vue'
|
||||
|
||||
jest.unmock('lodash')
|
||||
|
||||
@ -10,12 +10,9 @@ jest.useFakeTimers()
|
||||
|
||||
const _mount = (payload = {}) =>
|
||||
mount({
|
||||
components: {
|
||||
'el-autocomplete': Autocomplete,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
state: '',
|
||||
setup() {
|
||||
const state = reactive({
|
||||
value: '',
|
||||
list: [
|
||||
{ value: 'Java', tag: 'java' },
|
||||
{ value: 'Go', tag: 'go' },
|
||||
@ -23,27 +20,30 @@ const _mount = (payload = {}) =>
|
||||
{ value: 'Python', tag: 'python' },
|
||||
],
|
||||
payload,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
querySearch(queryString, cb) {
|
||||
})
|
||||
|
||||
const querySearch = (
|
||||
queryString: string,
|
||||
cb: (arg: typeof state.list) => void
|
||||
) => {
|
||||
cb(
|
||||
queryString
|
||||
? this.list.filter(
|
||||
? state.list.filter(
|
||||
(i) => i.value.indexOf(queryString.toLowerCase()) === 0
|
||||
)
|
||||
: this.list
|
||||
: state.list
|
||||
)
|
||||
}
|
||||
|
||||
return () => (
|
||||
<Autocomplete
|
||||
ref="autocomplete"
|
||||
v-model={state.value}
|
||||
fetch-suggestions={querySearch}
|
||||
{...state.payload}
|
||||
/>
|
||||
)
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<el-autocomplete
|
||||
ref="autocomplete"
|
||||
v-model="state"
|
||||
:fetch-suggestions="querySearch"
|
||||
v-bind="payload"
|
||||
/>
|
||||
`,
|
||||
})
|
||||
|
||||
describe('Autocomplete.vue', () => {
|
||||
@ -91,20 +91,20 @@ describe('Autocomplete.vue', () => {
|
||||
|
||||
await wrapper.setProps({ popperClass: 'error' })
|
||||
expect(
|
||||
document.body.querySelector('.el-popper').classList.contains('error')
|
||||
document.body.querySelector('.el-popper')?.classList.contains('error')
|
||||
).toBe(true)
|
||||
|
||||
await wrapper.setProps({ popperClass: 'success' })
|
||||
expect(
|
||||
document.body.querySelector('.el-popper').classList.contains('error')
|
||||
document.body.querySelector('.el-popper')?.classList.contains('error')
|
||||
).toBe(false)
|
||||
expect(
|
||||
document.body.querySelector('.el-popper').classList.contains('success')
|
||||
document.body.querySelector('.el-popper')?.classList.contains('success')
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('popperAppendToBody', async () => {
|
||||
_mount({ popperAppendToBody: false })
|
||||
test('teleported', async () => {
|
||||
_mount({ teleported: false })
|
||||
expect(document.body.querySelector('.el-popper__mask')).toBeNull()
|
||||
})
|
||||
|
||||
@ -137,16 +137,19 @@ describe('Autocomplete.vue', () => {
|
||||
test('valueKey / modelValue', async () => {
|
||||
const wrapper = _mount()
|
||||
await nextTick()
|
||||
const target = wrapper.findComponent({ ref: 'autocomplete' })
|
||||
.vm as InstanceType<typeof Autocomplete>
|
||||
|
||||
await target.select({ value: 'Go', tag: 'go' })
|
||||
expect(wrapper.vm.state).toBe('Go')
|
||||
const target = wrapper.getComponent(Autocomplete).vm as InstanceType<
|
||||
typeof Autocomplete
|
||||
>
|
||||
|
||||
await target.handleSelect({ value: 'Go', tag: 'go' })
|
||||
|
||||
expect(target.modelValue).toBe('Go')
|
||||
|
||||
await wrapper.setProps({ valueKey: 'tag' })
|
||||
|
||||
await target.select({ value: 'Go', tag: 'go' })
|
||||
expect(wrapper.vm.state).toBe('go')
|
||||
await target.handleSelect({ value: 'Go', tag: 'go' })
|
||||
expect(target.modelValue).toBe('go')
|
||||
})
|
||||
|
||||
test('hideLoading', async () => {
|
||||
@ -166,20 +169,21 @@ describe('Autocomplete.vue', () => {
|
||||
})
|
||||
|
||||
test('selectWhenUnmatched', async () => {
|
||||
const wrapper = mount(Autocomplete, {
|
||||
props: {
|
||||
const wrapper = _mount({
|
||||
selectWhenUnmatched: true,
|
||||
debounce: 10,
|
||||
},
|
||||
})
|
||||
await nextTick()
|
||||
const target = wrapper.getComponent(Autocomplete).vm as InstanceType<
|
||||
typeof Autocomplete
|
||||
>
|
||||
|
||||
wrapper.vm.highlightedIndex = 0
|
||||
wrapper.vm.handleKeyEnter()
|
||||
target.highlightedIndex = 0
|
||||
target.handleKeyEnter()
|
||||
jest.runAllTimers()
|
||||
await nextTick()
|
||||
|
||||
expect(wrapper.vm.highlightedIndex).toBe(-1)
|
||||
expect(target.highlightedIndex).toBe(-1)
|
||||
})
|
||||
|
||||
test('highlightFirstItem', async () => {
|
||||
@ -211,7 +215,7 @@ describe('Autocomplete.vue', () => {
|
||||
|
||||
await nextTick()
|
||||
expect(
|
||||
document.body.querySelector(POPPER_CONTAINER_SELECTOR).innerHTML
|
||||
document.body.querySelector(POPPER_CONTAINER_SELECTOR)?.innerHTML
|
||||
).not.toBe('')
|
||||
})
|
||||
|
||||
@ -223,7 +227,7 @@ describe('Autocomplete.vue', () => {
|
||||
|
||||
await nextTick()
|
||||
expect(
|
||||
document.body.querySelector(POPPER_CONTAINER_SELECTOR).innerHTML
|
||||
document.body.querySelector(POPPER_CONTAINER_SELECTOR)?.innerHTML
|
||||
).toBe('')
|
||||
})
|
||||
})
|
@ -1,12 +1,8 @@
|
||||
import Autocomplete from './src/index.vue'
|
||||
import type { App } from 'vue'
|
||||
import type { SFCWithInstall } from '@element-plus/utils'
|
||||
import { withInstall } from '@element-plus/utils'
|
||||
import Autocomplete from './src/autocomplete.vue'
|
||||
|
||||
Autocomplete.install = (app: App): void => {
|
||||
app.component(Autocomplete.name, Autocomplete)
|
||||
}
|
||||
export const ElAutocomplete = withInstall(Autocomplete)
|
||||
|
||||
const _Autocomplete = Autocomplete as SFCWithInstall<typeof Autocomplete>
|
||||
export default ElAutocomplete
|
||||
|
||||
export default _Autocomplete
|
||||
export const ElAutocomplete = _Autocomplete
|
||||
export * from './src/autocomplete'
|
||||
|
84
packages/components/autocomplete/src/autocomplete.ts
Normal file
84
packages/components/autocomplete/src/autocomplete.ts
Normal file
@ -0,0 +1,84 @@
|
||||
import { NOOP } from '@vue/shared'
|
||||
import {
|
||||
isString,
|
||||
isObject,
|
||||
buildProps,
|
||||
definePropType,
|
||||
} from '@element-plus/utils'
|
||||
import { useTooltipContentProps } from '@element-plus/components/tooltip'
|
||||
import { UPDATE_MODEL_EVENT } from '@element-plus/constants'
|
||||
import type { ExtractPropTypes } from 'vue'
|
||||
import type Autocomplete from './autocomplete.vue'
|
||||
import type { Placement } from '@element-plus/components/popper'
|
||||
|
||||
export const autocompleteProps = buildProps({
|
||||
valueKey: {
|
||||
type: String,
|
||||
default: 'value',
|
||||
},
|
||||
modelValue: {
|
||||
type: [String, Number],
|
||||
default: '',
|
||||
},
|
||||
debounce: {
|
||||
type: Number,
|
||||
default: 300,
|
||||
},
|
||||
placement: {
|
||||
type: definePropType<Placement>(String),
|
||||
values: [
|
||||
'top',
|
||||
'top-start',
|
||||
'top-end',
|
||||
'bottom',
|
||||
'bottom-start',
|
||||
'bottom-end',
|
||||
],
|
||||
default: 'bottom-start',
|
||||
},
|
||||
fetchSuggestions: {
|
||||
type: definePropType<
|
||||
(queryString: string, cb: (data: any[]) => void) => void
|
||||
>(Function),
|
||||
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: undefined,
|
||||
},
|
||||
teleported: useTooltipContentProps.teleported,
|
||||
highlightFirstItem: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
} as const)
|
||||
export type AutocompleteProps = ExtractPropTypes<typeof autocompleteProps>
|
||||
|
||||
export const autocompleteEmits = {
|
||||
[UPDATE_MODEL_EVENT]: (value: string) => isString(value),
|
||||
input: (value: string) => isString(value),
|
||||
change: (value: string) => isString(value),
|
||||
focus: (evt: FocusEvent) => evt instanceof FocusEvent,
|
||||
blur: (evt: FocusEvent) => evt instanceof FocusEvent,
|
||||
clear: () => true,
|
||||
select: (item: { value: any }) => isObject(item),
|
||||
}
|
||||
export type AutocompleteEmits = typeof autocompleteEmits
|
||||
|
||||
export type AutocompleteInstance = InstanceType<typeof Autocomplete>
|
316
packages/components/autocomplete/src/autocomplete.vue
Normal file
316
packages/components/autocomplete/src/autocomplete.vue
Normal file
@ -0,0 +1,316 @@
|
||||
<template>
|
||||
<el-tooltip
|
||||
ref="popperRef"
|
||||
v-model:visible="suggestionVisible"
|
||||
:placement="placement"
|
||||
:fallback-placements="['bottom-start', 'top-start']"
|
||||
:popper-class="[ns.e('popper'), popperClass]"
|
||||
:teleported="compatTeleported"
|
||||
:gpu-acceleration="false"
|
||||
pure
|
||||
manual-mode
|
||||
effect="light"
|
||||
trigger="click"
|
||||
:transition="`${ns.namespace.value}-zoom-in-top`"
|
||||
persistent
|
||||
@show="onSuggestionShow"
|
||||
>
|
||||
<div
|
||||
ref="listboxRef"
|
||||
:class="[ns.b(), $attrs.class]"
|
||||
:style="styles"
|
||||
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="ns.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="handleSelect(item)"
|
||||
>
|
||||
<slot :item="item">{{ item[valueKey] }}</slot>
|
||||
</li>
|
||||
</template>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</template>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
nextTick,
|
||||
useAttrs as useCompAttrs,
|
||||
} from 'vue'
|
||||
import { debounce } from 'lodash-unified'
|
||||
import { onClickOutside } from '@vueuse/core'
|
||||
import { useAttrs, useNamespace } from '@element-plus/hooks'
|
||||
import { generateId, isArray, throwError } from '@element-plus/utils'
|
||||
import { UPDATE_MODEL_EVENT } from '@element-plus/constants'
|
||||
import ElInput from '@element-plus/components/input'
|
||||
import ElScrollbar from '@element-plus/components/scrollbar'
|
||||
import ElTooltip from '@element-plus/components/tooltip'
|
||||
import { useDeprecateAppendToBody } from '@element-plus/components/popper'
|
||||
import ElIcon from '@element-plus/components/icon'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { autocompleteProps, autocompleteEmits } from './autocomplete'
|
||||
import type { TooltipInstance } from '@element-plus/components/tooltip'
|
||||
import type { StyleValue } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'ElAutocomplete',
|
||||
inheritAttrs: false,
|
||||
})
|
||||
|
||||
const COMPONENT_NAME = 'ElAutocomplete'
|
||||
|
||||
const props = defineProps(autocompleteProps)
|
||||
const emit = defineEmits(autocompleteEmits)
|
||||
|
||||
const ns = useNamespace('autocomplete')
|
||||
const { compatTeleported } = useDeprecateAppendToBody(
|
||||
COMPONENT_NAME,
|
||||
'popperAppendToBody'
|
||||
)
|
||||
const attrs = useAttrs()
|
||||
const compAttrs = useCompAttrs()
|
||||
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
|
||||
}>()
|
||||
const regionRef = ref<HTMLElement>()
|
||||
const popperRef = ref<TooltipInstance>()
|
||||
const listboxRef = ref<HTMLElement>()
|
||||
|
||||
const id = computed(() => {
|
||||
return ns.b(String(generateId()))
|
||||
})
|
||||
const styles = computed(() => compAttrs.style as StyleValue)
|
||||
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`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
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(COMPONENT_NAME, 'autocomplete suggestions must be an array')
|
||||
}
|
||||
})
|
||||
}
|
||||
const debouncedGetData = debounce(getData, props.debounce)
|
||||
const handleInput = (value: string) => {
|
||||
emit('input', value)
|
||||
emit(UPDATE_MODEL_EVENT, value)
|
||||
suggestionDisabled.value = false
|
||||
if (!props.triggerOnFocus && !value) {
|
||||
suggestionDisabled.value = true
|
||||
suggestions.value = []
|
||||
return
|
||||
}
|
||||
debouncedGetData(value)
|
||||
}
|
||||
const handleChange = (value: string) => {
|
||||
emit('change', value)
|
||||
}
|
||||
const handleFocus = (evt: FocusEvent) => {
|
||||
activated.value = true
|
||||
emit('focus', evt)
|
||||
if (props.triggerOnFocus) {
|
||||
debouncedGetData(String(props.modelValue))
|
||||
}
|
||||
}
|
||||
const handleBlur = (evt: FocusEvent) => {
|
||||
emit('blur', evt)
|
||||
}
|
||||
const handleClear = () => {
|
||||
activated.value = false
|
||||
emit(UPDATE_MODEL_EVENT, '')
|
||||
emit('clear')
|
||||
}
|
||||
const handleKeyEnter = () => {
|
||||
if (
|
||||
suggestionVisible.value &&
|
||||
highlightedIndex.value >= 0 &&
|
||||
highlightedIndex.value < suggestions.value.length
|
||||
) {
|
||||
handleSelect(suggestions.value[highlightedIndex.value])
|
||||
} else if (props.selectWhenUnmatched) {
|
||||
emit('select', { value: props.modelValue })
|
||||
nextTick(() => {
|
||||
suggestions.value = []
|
||||
highlightedIndex.value = -1
|
||||
})
|
||||
}
|
||||
}
|
||||
const close = () => {
|
||||
activated.value = false
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
inputRef.value?.focus()
|
||||
}
|
||||
|
||||
const handleSelect = (item: any) => {
|
||||
emit('input', item[props.valueKey])
|
||||
emit(UPDATE_MODEL_EVENT, item[props.valueKey])
|
||||
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<HTMLElement>(
|
||||
`.${ns.be('suggestion', 'list')} li`
|
||||
)!
|
||||
const highlightItem = suggestionList[index]
|
||||
const scrollTop = suggestion.scrollTop
|
||||
const { offsetTop, scrollHeight } = highlightItem
|
||||
|
||||
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}`
|
||||
)
|
||||
}
|
||||
|
||||
onClickOutside(listboxRef, close)
|
||||
|
||||
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}`
|
||||
)
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
/** @description the index of the currently highlighted item */
|
||||
highlightedIndex,
|
||||
/** @description autocomplete whether activated */
|
||||
activated,
|
||||
/** @description remote search loading status */
|
||||
loading,
|
||||
/** @description el-input component instance */
|
||||
inputRef,
|
||||
/** @description el-tooltip component instance */
|
||||
popperRef,
|
||||
/** @description fetch suggestions result */
|
||||
suggestions,
|
||||
/** @description triggers when a suggestion is clicked */
|
||||
handleSelect,
|
||||
/** @description handle keyboard enter event */
|
||||
handleKeyEnter,
|
||||
/** @description focus the input element */
|
||||
focus,
|
||||
/** @description close suggestion */
|
||||
close,
|
||||
/** @description highlight an item in a suggestion */
|
||||
highlight,
|
||||
})
|
||||
</script>
|
@ -1,394 +0,0 @@
|
||||
<template>
|
||||
<el-tooltip
|
||||
ref="popper"
|
||||
v-model:visible="suggestionVisible"
|
||||
:placement="placement"
|
||||
:fallback-placements="['bottom-start', 'top-start']"
|
||||
:popper-class="`${ns.e('popper')} ${popperClass}`"
|
||||
:teleported="compatTeleported"
|
||||
: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-unified'
|
||||
import { useAttrs, useNamespace } from '@element-plus/hooks'
|
||||
import { ClickOutside } from '@element-plus/directives'
|
||||
import { generateId, isArray, throwError } from '@element-plus/utils'
|
||||
import { UPDATE_MODEL_EVENT } from '@element-plus/constants'
|
||||
import ElInput from '@element-plus/components/input'
|
||||
import ElScrollbar from '@element-plus/components/scrollbar'
|
||||
import ElTooltip, {
|
||||
useTooltipContentProps,
|
||||
} from '@element-plus/components/tooltip'
|
||||
import { useDeprecateAppendToBody } from '@element-plus/components/popper'
|
||||
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'
|
||||
|
||||
const COMPONENT_NAME = 'ElAutocomplete'
|
||||
export default defineComponent({
|
||||
name: COMPONENT_NAME,
|
||||
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: undefined,
|
||||
},
|
||||
teleported: useTooltipContentProps.teleported,
|
||||
highlightFirstItem: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: [
|
||||
UPDATE_MODEL_EVENT,
|
||||
'input',
|
||||
'change',
|
||||
'focus',
|
||||
'blur',
|
||||
'clear',
|
||||
'select',
|
||||
],
|
||||
setup(props, ctx) {
|
||||
const ns = useNamespace('autocomplete')
|
||||
const { compatTeleported } = useDeprecateAppendToBody(
|
||||
COMPONENT_NAME,
|
||||
'popperAppendToBody'
|
||||
)
|
||||
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,
|
||||
|
||||
// deprecation in 2.1.0
|
||||
compatTeleported,
|
||||
|
||||
getData,
|
||||
handleInput,
|
||||
handleChange,
|
||||
handleFocus,
|
||||
handleBlur,
|
||||
handleClear,
|
||||
handleKeyEnter,
|
||||
close,
|
||||
focus,
|
||||
select,
|
||||
highlight,
|
||||
onSuggestionShow,
|
||||
ns,
|
||||
}
|
||||
},
|
||||
})
|
||||
</script>
|
@ -7,6 +7,7 @@ import {
|
||||
useDelayedToggleProps,
|
||||
POPPER_CONTAINER_SELECTOR,
|
||||
} from '@element-plus/hooks'
|
||||
import type Tooltip from '../src/tooltip.vue'
|
||||
|
||||
import type { ExtractPropTypes } from 'vue'
|
||||
|
||||
@ -90,3 +91,5 @@ export type ElTooltipTriggerProps = ExtractPropTypes<
|
||||
export type ElTooltipProps = ExtractPropTypes<typeof useTooltipProps> &
|
||||
ElTooltipContentProps &
|
||||
ElTooltipTriggerProps
|
||||
|
||||
export type TooltipInstance = InstanceType<typeof Tooltip>
|
||||
|
Loading…
Reference in New Issue
Block a user