element-plus
Version:
A Component Library for Vue3.0
738 lines (667 loc) • 20.7 kB
text/typescript
import {
inject,
nextTick,
computed,
watch,
ref,
reactive,
} from 'vue'
import mitt from 'mitt'
import { UPDATE_MODEL_EVENT, CHANGE_EVENT } from '@element-plus/utils/constants'
import { EVENT_CODE } from '@element-plus/utils/aria'
import { t } from '@element-plus/locale'
import isServer from '@element-plus/utils/isServer'
import scrollIntoView from '@element-plus/utils/scroll-into-view'
import lodashDebounce from 'lodash/debounce'
import { isKorean } from '@element-plus/utils/isDef'
import {
getValueByPath,
isIE,
isEdge,
useGlobalConfig,
} from '@element-plus/utils/util'
import { elFormKey, elFormItemKey } from '@element-plus/form'
import isEqual from 'lodash/isEqual'
import { isObject, toRawType } from '@vue/shared'
import type { ComponentPublicInstance } from 'vue'
import type { ElFormContext, ElFormItemContext } from '@element-plus/form'
export function useSelectStates(props) {
const selectEmitter = mitt()
return reactive({
options: [],
cachedOptions: [],
createdLabel: null,
createdSelected: false,
selected: props.multiple ? [] : {} as any,
inputLength: 20,
inputWidth: 0,
initialInputHeight: 0,
optionsCount: 0,
filteredOptionsCount: 0,
visible: false,
softFocus: false,
selectedLabel: '',
hoverIndex: -1,
query: '',
previousQuery: null,
inputHovering: false,
cachedPlaceHolder: '',
currentPlaceholder: t('el.select.placeholder'),
menuVisibleOnFocus: false,
isOnComposition: false,
isSilentBlur: false,
selectEmitter,
})
}
type States = ReturnType<typeof useSelectStates>
export const useSelect = (props, states: States, ctx) => {
const ELEMENT = useGlobalConfig()
// template refs
const reference = ref(null)
const input = ref(null)
const popper = ref(null)
const tags = ref(null)
const selectWrapper = ref<HTMLElement | null>(null)
const scrollbar = ref(null)
const hoverOption = ref(-1)
// inject
const elForm = inject(elFormKey, {} as ElFormContext)
const elFormItem = inject(elFormItemKey, {} as ElFormItemContext)
const readonly = computed(() => !props.filterable || props.multiple || (!isIE() && !isEdge() && !states.visible))
const selectDisabled = computed(() => props.disabled || elForm.disabled)
const showClose = computed(() => {
const hasValue = props.multiple
? Array.isArray(props.modelValue) && props.modelValue.length > 0
: props.modelValue !== undefined && props.modelValue !== null && props.modelValue !== ''
const criteria =
props.clearable &&
!selectDisabled.value &&
states.inputHovering &&
hasValue
return criteria
})
const iconClass = computed(() => props.remote && props.filterable ? '' : (states.visible ? 'arrow-up is-reverse' : 'arrow-up'))
const debounce = computed(() => props.remote ? 300 : 0)
const emptyText = computed(() => {
if (props.loading) {
return props.loadingText || t('el.select.loading')
} else {
if (props.remote && states.query === '' && states.options.length === 0) return false
if (props.filterable && states.query && states.options.length > 0 && states.filteredOptionsCount === 0) {
return props.noMatchText || t('el.select.noMatch')
}
if (states.options.length === 0) {
return props.noDataText || t('el.select.noData')
}
}
return null
})
const showNewOption = computed(() => {
const hasExistingOption = states.options.filter(option => {
return !option.created
}).some(option => {
return option.currentLabel === states.query
})
return props.filterable && props.allowCreate && states.query !== '' && !hasExistingOption
})
const selectSize = computed(() => props.size || elFormItem.size || ELEMENT.size)
const collapseTagSize = computed(() => ['small', 'mini'].indexOf(selectSize.value) > -1 ? 'mini' : 'small')
const dropMenuVisible = computed(() => states.visible && emptyText.value !== false)
// watch
watch(() => selectDisabled.value, () => {
nextTick(() => {
resetInputHeight()
})
})
watch(() => props.placeholder, val => {
states.cachedPlaceHolder = states.currentPlaceholder = val
})
watch(() => props.modelValue, (val, oldVal) => {
if (props.multiple) {
resetInputHeight()
if ((val && val.length > 0) || (input.value && states.query !== '')) {
states.currentPlaceholder = ''
} else {
states.currentPlaceholder = states.cachedPlaceHolder
}
if (props.filterable && !props.reserveKeyword) {
states.query = ''
handleQueryChange(states.query)
}
}
setSelected()
if (props.filterable && !props.multiple) {
states.inputLength = 20
}
if (!isEqual(val, oldVal)) {
elFormItem.formItemMitt?.emit('el.form.change', val)
}
}, {
flush: 'post',
})
watch(() => states.visible, val => {
if (!val) {
input.value && input.value.blur()
states.query = ''
states.previousQuery = null
states.selectedLabel = ''
states.inputLength = 20
states.menuVisibleOnFocus = false
resetHoverIndex()
nextTick(() => {
if (input.value && input.value.value === '' && states.selected.length === 0) {
states.currentPlaceholder = states.cachedPlaceHolder
}
})
if (!props.multiple) {
if (states.selected) {
if (props.filterable && props.allowCreate && states.createdSelected && states.createdLabel) {
states.selectedLabel = states.createdLabel
} else {
states.selectedLabel = states.selected.currentLabel
}
if (props.filterable) states.query = states.selectedLabel
}
if (props.filterable) {
states.currentPlaceholder = states.cachedPlaceHolder
}
}
} else {
popper.value?.update?.()
if (props.filterable) {
states.query = props.remote ? '' : states.selectedLabel
if (props.multiple) {
input.value.focus()
} else {
if (states.selectedLabel) {
states.currentPlaceholder = states.selectedLabel
states.selectedLabel = ''
}
}
if (!props.multiple && !props.remote) {
states.selectEmitter.emit('elOptionQueryChange', '')
states.selectEmitter.emit('elOptionGroupQueryChange')
} else {
handleQueryChange(states.query)
}
}
}
ctx.emit('visible-change', val)
})
watch(
// fix `Array.prototype.push/splice/..` cannot trigger non-deep watcher
// https://github.com/vuejs/vue-next/issues/2116
() => ([...states.options]),
() => {
if (isServer) return
popper.value?.update?.()
if (props.multiple) {
resetInputHeight()
}
const inputs = selectWrapper.value?.querySelectorAll('input') || []
if ([].indexOf.call(inputs, document.activeElement) === -1) {
setSelected()
}
if (props.defaultFirstOption && (props.filterable || props.remote) && states.filteredOptionsCount) {
checkDefaultFirstOption()
}
},
)
watch(() => states.hoverIndex, val => {
if (typeof val === 'number' && val > -1) {
hoverOption.value = states.options[val] || {}
}
states.options.forEach(option => {
option.hover = hoverOption.value === option
})
})
// methods
const resetInputHeight = () => {
if (props.collapseTags && !props.filterable) return
nextTick(() => {
if (!reference.value) return
const inputChildNodes = reference.value.$el.childNodes
const input = [].filter.call(inputChildNodes, item => item.tagName === 'INPUT')[0]
const _tags = tags.value
const sizeInMap = states.initialInputHeight || 40
input.style.height = states.selected.length === 0
? sizeInMap + 'px'
: Math.max(
_tags ? (_tags.clientHeight + (_tags.clientHeight > sizeInMap ? 6 : 0)) : 0,
sizeInMap) + 'px'
if (states.visible && emptyText.value !== false) {
popper.value?.update?.()
}
})
}
const handleQueryChange = val => {
if (states.previousQuery === val || states.isOnComposition) return
if (
states.previousQuery === null &&
(typeof props.filterMethod === 'function' || typeof props.remoteMethod === 'function')
) {
states.previousQuery = val
return
}
states.previousQuery = val
nextTick(() => {
if (states.visible) popper.value?.update?.()
})
states.hoverIndex = -1
if (props.multiple && props.filterable) {
nextTick(() => {
const length = input.value.length * 15 + 20
states.inputLength = props.collapseTags ? Math.min(50, length) : length
managePlaceholder()
resetInputHeight()
})
}
if (props.remote && typeof props.remoteMethod === 'function') {
states.hoverIndex = -1
props.remoteMethod(val)
} else if (typeof props.filterMethod === 'function') {
props.filterMethod(val)
states.selectEmitter.emit('elOptionGroupQueryChange')
} else {
states.filteredOptionsCount = states.optionsCount
states.selectEmitter.emit('elOptionQueryChange', val)
states.selectEmitter.emit('elOptionGroupQueryChange')
}
if (props.defaultFirstOption && (props.filterable || props.remote) && states.filteredOptionsCount) {
checkDefaultFirstOption()
}
}
const managePlaceholder = () => {
if (states.currentPlaceholder !== '') {
states.currentPlaceholder = input.value ? '' : states.cachedPlaceHolder
}
}
const checkDefaultFirstOption = () => {
states.hoverIndex = -1
// highlight the created option
let hasCreated = false
for (let i = states.options.length - 1; i >= 0; i--) {
if (states.options[i].created) {
hasCreated = true
states.hoverIndex = i
break
}
}
if (hasCreated) return
for (let i = 0; i !== states.options.length; ++i) {
const option = states.options[i]
if (states.query) {
// highlight first options that passes the filter
if (!option.disabled && !option.groupDisabled && option.visible) {
states.hoverIndex = i
break
}
} else {
// highlight currently selected option
if (option.itemSelected) {
states.hoverIndex = i
break
}
}
}
}
const setSelected = () => {
if (!props.multiple) {
const option = getOption(props.modelValue)
if (option.props?.created) {
states.createdLabel = option.props.value
states.createdSelected = true
} else {
states.createdSelected = false
}
states.selectedLabel = option.currentLabel
states.selected = option
if (props.filterable) states.query = states.selectedLabel
return
}
const result = []
if (Array.isArray(props.modelValue)) {
props.modelValue.forEach(value => {
result.push(getOption(value))
})
}
states.selected = result
nextTick(() => {
resetInputHeight()
})
}
const getOption = value => {
let option
const isObjectValue = toRawType(value).toLowerCase() === 'object'
const isNull = toRawType(value).toLowerCase() === 'null'
const isUndefined = toRawType(value).toLowerCase() === 'undefined'
for (let i = states.cachedOptions.length - 1; i >= 0; i--) {
const cachedOption = states.cachedOptions[i]
const isEqualValue = isObjectValue
? getValueByPath(cachedOption.value, props.valueKey) === getValueByPath(value, props.valueKey)
: cachedOption.value === value
if (isEqualValue) {
option = {
value,
currentLabel: cachedOption.currentLabel,
}
break
}
}
if (option) return option
const label = (!isObjectValue && !isNull && !isUndefined) ? value : ''
const newOption = {
value,
currentLabel: label,
}
if (props.multiple) {
(newOption as any).hitState = false
}
return newOption
}
const resetHoverIndex = () => {
setTimeout(() => {
if (!props.multiple) {
states.hoverIndex = states.options.indexOf(states.selected)
} else {
if (states.selected.length > 0) {
states.hoverIndex = Math.min.apply(null, states.selected.map(item => states.options.indexOf(item)))
} else {
states.hoverIndex = -1
}
}
}, 300)
}
const handleResize = () => {
resetInputWidth()
popper.value?.update?.()
if (props.multiple) resetInputHeight()
}
const resetInputWidth = () => {
states.inputWidth = reference.value?.$el.getBoundingClientRect().width
}
const onInputChange = () => {
if (props.filterable && states.query !== states.selectedLabel) {
states.query = states.selectedLabel
handleQueryChange(states.query)
}
}
const debouncedOnInputChange = lodashDebounce(() => {
onInputChange()
}, debounce.value)
const debouncedQueryChange = lodashDebounce(e => {
handleQueryChange(e.target.value)
}, debounce.value)
const emitChange = val => {
if (!isEqual(props.modelValue, val)) {
ctx.emit(CHANGE_EVENT, val)
}
}
const deletePrevTag = e => {
if (e.target.value.length <= 0 && !toggleLastOptionHitState()) {
const value = props.modelValue.slice()
value.pop()
ctx.emit(UPDATE_MODEL_EVENT, value)
emitChange(value)
}
}
const deleteTag = (event, tag) => {
const index = states.selected.indexOf(tag)
if (index > -1 && !selectDisabled.value) {
const value = props.modelValue.slice()
value.splice(index, 1)
ctx.emit(UPDATE_MODEL_EVENT, value)
emitChange(value)
ctx.emit('remove-tag', tag.value)
}
event.stopPropagation()
}
const deleteSelected = event => {
event.stopPropagation()
const value = props.multiple ? [] : ''
ctx.emit(UPDATE_MODEL_EVENT, value)
emitChange(value)
states.visible = false
ctx.emit('clear')
}
const handleOptionSelect = (option, byClick) => {
if (props.multiple) {
const value = (props.modelValue || []).slice()
const optionIndex = getValueIndex(value, option.value)
if (optionIndex > -1) {
value.splice(optionIndex, 1)
} else if (props.multipleLimit <= 0 || value.length < props.multipleLimit) {
value.push(option.value)
}
ctx.emit(UPDATE_MODEL_EVENT, value)
emitChange(value)
if (option.created) {
states.query = ''
handleQueryChange('')
states.inputLength = 20
}
if (props.filterable) input.value.focus()
} else {
ctx.emit(UPDATE_MODEL_EVENT, option.value)
emitChange(option.value)
states.visible = false
}
states.isSilentBlur = byClick
setSoftFocus()
if (states.visible) return
nextTick(() => {
scrollToOption(option)
})
}
const getValueIndex = (arr = [], value) => {
if (!isObject(value)) return arr.indexOf(value)
const valueKey = props.valueKey
let index = -1
arr.some((item, i) => {
if (getValueByPath(item, valueKey) === getValueByPath(value, valueKey)) {
index = i
return true
}
return false
})
return index
}
const setSoftFocus = () => {
states.softFocus = true
const _input = input.value || reference.value
if (_input) {
_input.focus()
}
}
const getEl = option => {
const options = states.options.filter(item => item.value === option.value)
if (options.length > 0) {
return options[0].$el
}
}
const scrollToOption = option => {
const target = Array.isArray(option) ? getEl(option[0]) : getEl(option)
if (popper.value && target) {
const menu = popper.value?.popperRef?.querySelector?.('.el-select-dropdown__wrap')
if (menu) {
scrollIntoView(menu, target)
}
}
scrollbar.value?.handleScroll()
}
const onOptionCreate = (vm: ComponentPublicInstance) => {
states.optionsCount++
states.filteredOptionsCount++
states.options.push(vm)
states.cachedOptions.push(vm)
}
const onOptionDestroy = (index: number) => {
if (index > -1) {
states.optionsCount--
states.filteredOptionsCount--
states.options.splice(index, 1)
}
}
const resetInputState = (e: KeyboardEvent) => {
if (e.code !== EVENT_CODE.backspace) toggleLastOptionHitState(false)
states.inputLength = input.value.length * 15 + 20
resetInputHeight()
}
const toggleLastOptionHitState = (hit?: boolean) => {
if (!Array.isArray(states.selected)) return
const option = states.selected[states.selected.length - 1]
if (!option) return
if (hit === true || hit === false) {
option.hitState = hit
return hit
}
option.hitState = !option.hitState
return option.hitState
}
const handleComposition = event => {
const text = event.target.value
if (event.type === 'compositionend') {
states.isOnComposition = false
nextTick(() => handleQueryChange(text))
} else {
const lastCharacter = text[text.length - 1] || ''
states.isOnComposition = !isKorean(lastCharacter)
}
}
const handleMenuEnter = () => {
nextTick(() => scrollToOption(states.selected))
}
const handleFocus = event => {
if (!states.softFocus) {
if (props.automaticDropdown || props.filterable) {
states.visible = true
if (props.filterable) {
states.menuVisibleOnFocus = true
}
}
ctx.emit('focus', event)
} else {
states.softFocus = false
}
}
const blur = () => {
states.visible = false
reference.value.blur()
}
const handleBlur = (event: Event) => {
// https://github.com/ElemeFE/element/pull/10822
nextTick(() => {
if (states.isSilentBlur) {
states.isSilentBlur = false
} else {
ctx.emit('blur', event)
}
})
states.softFocus = false
}
const handleClearClick = (event: Event) => {
deleteSelected(event)
}
const handleClose = () => {
states.visible = false
}
const toggleMenu = () => {
if (props.automaticDropdown) return
if (!selectDisabled.value) {
if (states.menuVisibleOnFocus) {
states.menuVisibleOnFocus = false
} else {
states.visible = !states.visible
}
if (states.visible) {
(input.value || reference.value).focus()
}
}
}
const selectOption = () => {
if (!states.visible) {
toggleMenu()
} else {
if (states.options[states.hoverIndex]) {
handleOptionSelect(states.options[states.hoverIndex], undefined)
}
}
}
const getValueKey = item => {
return isObject(item.value)
? getValueByPath(item.value, props.valueKey)
: item.value
}
const optionsAllDisabled = computed(() => states.options.filter(option => option.visible).every(option => option.disabled))
const navigateOptions = direction => {
if (!states.visible) {
states.visible = true
return
}
if (states.options.length === 0 || states.filteredOptionsCount === 0) return
if (!optionsAllDisabled.value) {
if (direction === 'next') {
states.hoverIndex++
if (states.hoverIndex === states.options.length) {
states.hoverIndex = 0
}
} else if (direction === 'prev') {
states.hoverIndex--
if (states.hoverIndex < 0) {
states.hoverIndex = states.options.length - 1
}
}
const option = states.options[states.hoverIndex]
if (option.disabled === true ||
option.groupDisabled === true ||
!option.visible) {
navigateOptions(direction)
}
nextTick(() => scrollToOption(hoverOption.value))
}
}
return {
selectSize,
handleResize,
debouncedOnInputChange,
debouncedQueryChange,
deletePrevTag,
deleteTag,
deleteSelected,
handleOptionSelect,
scrollToOption,
readonly,
resetInputHeight,
showClose,
iconClass,
showNewOption,
collapseTagSize,
setSelected,
managePlaceholder,
selectDisabled,
emptyText,
toggleLastOptionHitState,
resetInputState,
handleComposition,
onOptionCreate,
onOptionDestroy,
handleMenuEnter,
handleFocus,
blur,
handleBlur,
handleClearClick,
handleClose,
toggleMenu,
selectOption,
getValueKey,
navigateOptions,
dropMenuVisible,
// DOM ref
reference,
input,
popper,
tags,
selectWrapper,
scrollbar,
}
}