UNPKG

element-plus

Version:

A Component Library for Vue3.0

738 lines (667 loc) 20.7 kB
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, } }