UNPKG

element-plus

Version:

A Component Library for Vue3.0

711 lines (617 loc) 19.1 kB
import { computed, watch, ref, reactive, nextTick, inject, onMounted, onBeforeMount, } from 'vue' import { isArray, isFunction, isObject, } from '@vue/shared' import isEqual from 'lodash/isEqual' import lodashDebounce from 'lodash/debounce' import { addResizeListener, removeResizeListener } from '@element-plus/utils/resize-event' import { UPDATE_MODEL_EVENT, CHANGE_EVENT } from '@element-plus/utils/constants' import { t } from '@element-plus/locale' import { elFormKey, elFormItemKey } from '@element-plus/form' import { getValueByPath, useGlobalConfig, } from '@element-plus/utils/util' import { SelectProps } from './defaults' import { flattenOptions } from './util' import type { ExtractPropTypes, CSSProperties } from 'vue' import type { ElFormContext, ElFormItemContext } from '@element-plus/form' import type { OptionType, Option } from './select.types' const DEFAULT_INPUT_PLACEHOLDER = '' const MINIMUM_INPUT_WIDTH = 4 const useSelect = (props: ExtractPropTypes<typeof SelectProps>, emit) => { // inject const elForm = inject(elFormKey, {} as ElFormContext) const elFormItem = inject(elFormItemKey, {} as ElFormItemContext) const $ELEMENT = useGlobalConfig() const states = reactive({ inputValue: DEFAULT_INPUT_PLACEHOLDER, displayInputValue: DEFAULT_INPUT_PLACEHOLDER, calculatedWidth: 0, cachedPlaceholder: '', cachedOptions: [] as Option[], createdOptions: [] as Option[], createdLabel: '', createdSelected: false, currentPlaceholder: '', hoveringIndex: -1, comboBoxHovering: false, isOnComposition: false, isSilentBlur: false, isComposing: false, inputLength: 20, inputWidth: 240, initialInputHeight: 0, previousQuery: null, query: '', selectedLabel: '', softFocus: false, tagInMultiLine: false, }) // data refs const selectedIndex = ref(-1) // DOM & Component refs const controlRef = ref(null) const inputRef = ref(null) // el-input ref const menuRef = ref(null) const popper = ref(null) const selectRef = ref(null) const selectionRef = ref(null) // tags ref const calculatorRef = ref<HTMLElement>(null) // the controller of the expanded popup const expanded = ref(false) const selectDisabled = computed(() => props.disabled || elForm.disabled) const popupHeight = computed(() => { const totalHeight = filteredOptions.value.length * 34 return totalHeight > props.height ? props.height : totalHeight }) const showClearBtn = 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.comboBoxHovering && hasValue return criteria }) const iconClass = computed(() => props.remote && props.filterable ? '' : (expanded.value ? 'arrow-up is-reverse' : 'arrow-up')) const debounce = computed(() => props.remote ? 300 : 0) // filteredOptions includes flatten the data into one dimensional array. const emptyText = computed(() => { const options = filteredOptions.value if (props.loading) { return props.loadingText || t('el.select.loading') } else { if (props.remote && states.query === '' && options.length === 0) return false if (props.filterable && states.query && options.length > 0) { return props.noMatchText || t('el.select.noMatch') } if (options.length === 0) { return props.noDataText || t('el.select.noData') } } return null }) const filteredOptions = computed(() => { const isValidOption = (o: Option): boolean => { // fill the conditions here. const query = states.inputValue // when query was given, we should test on the label see whether the label contains the given query const containsQueryString = query ? o.label.includes(query) : true return containsQueryString } return flattenOptions((props.options as OptionType[]).concat(states.createdOptions).map(v => { if (isArray(v.options)) { const filtered = v.options.filter(isValidOption) if (filtered.length > 0) { return { ...v, options: filtered, } } } else { if (isValidOption(v as Option)) { return v } } return null }).filter(v => v !== null)) }) const selectSize = computed(() => props.size || elFormItem.size || $ELEMENT.size) const collapseTagSize = computed(() => selectSize.value) const popperSize = computed(() => { return selectRef.value?.getBoundingClientRect?.()?.width || 200 }) // const readonly = computed(() => !props.filterable || props.multiple || (!isIE() && !isEdge() && !expanded.value)) const inputWrapperStyle = computed(() => { return { width: `${ // 7 represents the margin-left value states.calculatedWidth === 0 ? MINIMUM_INPUT_WIDTH : Math.ceil(states.calculatedWidth) + MINIMUM_INPUT_WIDTH }px`, } as CSSProperties }) const shouldShowPlaceholder = computed(() => { if (isArray(props.modelValue)) { return props.modelValue.length === 0 && !states.displayInputValue } // when it's not multiple mode, we only determine this flag based on filterable and expanded // when filterable flag is true, which means we have input box on the screen return props.filterable ? states.displayInputValue.length === 0 : true }) const currentPlaceholder = computed(() => { return props.multiple ? props.placeholder : states.selectedLabel || props.placeholder }) // this obtains the actual popper DOM element. const popperRef = computed(() => popper.value?.popperRef) // the index with current value in options const indexRef = computed<number>(() => { if (props.multiple) { if ((props.modelValue as Array<any>).length > 0) { return filteredOptions.value.findIndex(o => o.value === props.modelValue[0]) } } else { if (props.modelValue) { return filteredOptions.value.findIndex(o => o.value === props.modelValue) } } return -1 }) // methods const focusAndUpdatePopup = () => { inputRef.value.focus?.() popper.value.update?.() } const toggleMenu = () => { if (props.automaticDropdown) return if (!selectDisabled.value) { // if (states.menuVisibleOnFocus) { // states.menuVisibleOnFocus = false // } else { // if (expanded.value) { // expanded.value = false // } expanded.value = !expanded.value states.softFocus = true inputRef.value?.focus?.() // } } } const handleQueryChange = (val: string) => { if (states.previousQuery === val || states.isOnComposition) return if ( states.previousQuery === null && (isFunction(props.filterMethod) || isFunction(props.remoteMethod)) ) { states.previousQuery = val return } states.previousQuery = val nextTick(() => { if (expanded.value) popper.value?.update?.() }) states.hoveringIndex = -1 if (props.multiple && props.filterable) { nextTick(() => { const length = inputRef.value.value.length * 15 + 20 states.inputLength = props.collapseTags ? Math.min(50, length) : length resetInputHeight() }) } if (props.remote && isFunction(props.remoteMethod)) { states.hoveringIndex = -1 props.remoteMethod(val) } else if (isFunction(props.filterMethod)) { props.filterMethod(val) // states.selectEmitter.emit('elOptionGroupQueryChange') } else { // states.selectEmitter.emit('elOptionQueryChange', val) // states.selectEmitter.emit('elOptionGroupQueryChange') } if (props.defaultFirstOption && (props.filterable || props.remote)) { // checkDefaultFirstOption() } } // 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 onInputChange = () => { if (props.filterable && states.inputValue !== 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: any | any[]) => { if (!isEqual(props.modelValue, val)) { emit(CHANGE_EVENT, val) } } const update = (val: any) => { emit(UPDATE_MODEL_EVENT, val) emitChange(val) } const getValueIndex = (arr = [], value: unknown) => { 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 getValueKey = (item: unknown) => { return isObject(item) ? getValueByPath(item, props.valueKey) : item } // if the selected item is item then we get label via indexing // otherwise it should be string we simply return the item itself. const getLabel = (item: unknown) => { return isObject(item) ? item.label : item } const resetInputHeight = () => { if (props.collapseTags && !props.filterable) return nextTick(() => { if (!inputRef.value) return const selection = selectionRef.value selectRef.value.height = selection.offsetHeight if (expanded.value && emptyText.value !== false) { popper.value?.update?.() } }) } const handleResize = () => { resetInputWidth() popper.value?.update?.() if (props.multiple) resetInputHeight() } const resetInputWidth = () => { if (inputRef.value) { states.inputWidth = inputRef.value.getBoundingClientRect().width } } const onSelect = (option: Option, idx: number, byClick = true) => { if (props.multiple) { let selectedOptions = (props.modelValue as any[]).slice() const index = getValueIndex(selectedOptions, option.value) if (index > -1) { selectedOptions = [ ...selectedOptions.slice(0, index), ...selectedOptions.slice(index + 1), ] states.cachedOptions.splice(index, 1) } else if (props.multipleLimit <= 0 || selectedOptions.length < props.multipleLimit) { selectedOptions = [...selectedOptions, option.value] states.cachedOptions.push(option) } update(selectedOptions) if (option.created) { states.query = '' handleQueryChange('') states.inputLength = 20 } if (props.filterable) { inputRef.value.focus?.() onUpdateInputValue('') } if (props.filterable) { states.calculatedWidth = calculatorRef.value.getBoundingClientRect().width } resetInputHeight() } else { selectedIndex.value = idx states.selectedLabel = option.label update(option.value) expanded.value = false } states.isComposing = false states.isSilentBlur = byClick // setSoftFocus() if (expanded.value) return nextTick(() => { // scrollToOption(option) }) } const deleteTag = (event: MouseEvent, tag: Option) => { const index = (props.modelValue as Array<any>).indexOf(tag.value) if (index > -1 && !selectDisabled.value) { const value = [ ...(props.modelValue as Array<unknown>).slice(0, index), ...(props.modelValue as Array<unknown>).slice(index + 1), ] states.cachedOptions.splice(index, 1) update(value) emit('remove-tag', tag.value) states.softFocus = true nextTick(focusAndUpdatePopup) } event.stopPropagation() } const handleInputBoxClick = () => { if (states.displayInputValue.length === 0 && expanded.value) { expanded.value = false } } const handleFocus = (event: FocusEvent) => { states.isComposing = true if (!states.softFocus) { if (props.automaticDropdown || props.filterable) { expanded.value = true // if (props.filterable) { // states.menuVisibleOnFocus = true // } } emit('focus', event) } else { states.softFocus = false } } const handleBlur = () => { if (props.filterable) { if (props.allowCreate) { // create new item to the list } } states.isComposing = false states.softFocus = false // reset input value when blurred // https://github.com/ElemeFE/element/pull/10822 nextTick(() => { inputRef.value?.blur?.() if (calculatorRef.value) { states.calculatedWidth = calculatorRef.value.getBoundingClientRect().width } if (states.isSilentBlur) { states.isSilentBlur = false } else { if (states.isComposing) { emit('blur') } } }) } // keyboard handlers const handleEsc = () => { if (states.displayInputValue.length > 0) { onUpdateInputValue('') } else { expanded.value = false } } const handleDel = (e: KeyboardEvent) => { if (states.displayInputValue.length === 0) { e.preventDefault() const selected = (props.modelValue as Array<any>).slice() selected.pop() states.cachedOptions.pop() update(selected) } } const handleClear = () => { let emptyValue: string | any[] if (isArray(props.modelValue)) { emptyValue = [] } else { emptyValue = '' } states.softFocus = true if (props.multiple) { states.cachedOptions = [] } else { states.selectedLabel = '' } expanded.value = false update(emptyValue) emit('clear') nextTick(focusAndUpdatePopup) } const onUpdateInputValue = (val: string) => { states.displayInputValue = val states.inputValue = val } const onKeyboardNavigate = (direction: 'forward' | 'backward') => { if (selectDisabled.value) return if (props.multiple) { expanded.value = true return } let newIndex: number if (props.options.length === 0 || filteredOptions.value.length === 0) return if (filteredOptions.value.length > 0) { // only two ways: forward or backward if (direction === 'forward') { newIndex = selectedIndex.value + 1 if (newIndex > filteredOptions.value.length - 1) { newIndex = 0 } // states.hoveringIndex++ // if (states.hoveringIndex === props.options.length) { // states.hoveringIndex = 0 // } } else { newIndex = selectedIndex.value - 1 if (newIndex < 0) { newIndex = filteredOptions.value.length - 1 } } selectedIndex.value = newIndex const option = filteredOptions.value[newIndex] if (option.disabled || option.type === 'Group') { onKeyboardNavigate(direction) // prevent dispatching multiple nextTick callbacks. return } emit(UPDATE_MODEL_EVENT, filteredOptions.value[newIndex]) emitChange(filteredOptions.value[newIndex]) } } const onKeyboardSelect = () => { if (!expanded.value) { toggleMenu() } else { onSelect(filteredOptions.value[states.hoveringIndex], states.hoveringIndex, false) } } const onInput = () => { if (states.displayInputValue.length > 0 && !expanded.value) { expanded.value = true } states.calculatedWidth = calculatorRef.value.getBoundingClientRect().width if (props.multiple) { resetInputHeight() } debouncedOnInputChange() } const onCompositionUpdate = (e: CompositionEvent) => { onUpdateInputValue(states.displayInputValue += e.data) onInput() } const handleClickOutside = () => { expanded.value = false handleBlur() } const handleMenuEnter = () => { states.inputValue = states.displayInputValue nextTick(() => { if (~indexRef.value) { scrollToItem(indexRef.value) } }) } const scrollToItem = (index: number) => { menuRef.value.scrollToItem(index) } const initStates = () => { if (props.multiple) { if ((props.modelValue as Array<any>).length > 0) { states.cachedOptions.length = 0; (props.modelValue as Array<any>).map(selected => { const item = filteredOptions.value.find(option => option.value === selected) if (item) { states.cachedOptions.push(item as Option) } }) } } else { if (props.modelValue) { const selectedItem = filteredOptions.value.find(o => o.value === props.modelValue) if (selectedItem) { states.selectedLabel = selectedItem.label } else { states.selectedLabel = '' } } else { states.selectedLabel = '' } } } // in order to track these individually, we need to turn them into refs instead of watching the entire // reactive object which could cause perf penalty when unnecessary field gets changed the watch method will // be invoked. watch(expanded, val => { emit('visible-change', val) if (val) { popper.value.update?.() // the purpose of this function is to differ the blur event trigger mechanism } else { states.displayInputValue = '' } }) watch([() => props.modelValue, () => props.options], () => { initStates() }, { deep: true, }) onMounted(() => { initStates() addResizeListener(selectRef.value, handleResize) }) onBeforeMount(() => { removeResizeListener(selectRef.value, handleResize) }) return { // data exports collapseTagSize, currentPlaceholder, expanded, emptyText, popupHeight, debounce, filteredOptions, iconClass, inputWrapperStyle, popperSize, // readonly, shouldShowPlaceholder, selectDisabled, selectSize, showClearBtn, states, // refs items exports calculatorRef, controlRef, inputRef, menuRef, popper, selectRef, selectionRef, popperRef, // methods exports debouncedOnInputChange, debouncedQueryChange, deleteTag, getLabel, getValueKey, handleBlur, handleClear, handleClickOutside, handleDel, handleEsc, handleFocus, handleInputBoxClick, handleMenuEnter, toggleMenu, scrollTo: scrollToItem, onCompositionUpdate, onInput, onKeyboardNavigate, onKeyboardSelect, onSelect, onUpdateInputValue, } } export default useSelect