UNPKG

element-plus

Version:

A Component Library for Vue 3

796 lines (715 loc) 20.8 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 { elFormKey, elFormItemKey } from '@element-plus/tokens' import { useLocaleInject } from '@element-plus/hooks' import { UPDATE_MODEL_EVENT, CHANGE_EVENT } from '@element-plus/utils/constants' import { addResizeListener, removeResizeListener, } from '@element-plus/utils/resize-event' import { getValueByPath, useGlobalConfig } from '@element-plus/utils/util' import { Effect } from '@element-plus/components/popper' import { useAllowCreate } from './useAllowCreate' import { flattenOptions } from './util' import { useInput } from './useInput' import type { SelectProps } from './defaults' import type { ExtractPropTypes, CSSProperties } from 'vue' import type { ElFormContext, ElFormItemContext } from '@element-plus/tokens' import type { OptionType, Option } from './select.types' const DEFAULT_INPUT_PLACEHOLDER = '' const MINIMUM_INPUT_WIDTH = 11 const TAG_BASE_WIDTH = { small: 42, mini: 33, } const useSelect = (props: ExtractPropTypes<typeof SelectProps>, emit) => { // inject const { t } = useLocaleInject() 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, selectWidth: 200, initialInputHeight: 0, previousQuery: null, previousValue: '', query: '', selectedLabel: '', softFocus: false, tagInMultiLine: false, }) // data refs const selectedIndex = ref(-1) const popperSize = 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.inputValue === '' && options.length === 0) return false if (props.filterable && states.inputValue && 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 } if (props.loading) { return [] } 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 (props.remote || isValidOption(v as Option)) { return v } } return null }) .filter((v) => v !== null) ) }) const optionsAllDisabled = computed(() => filteredOptions.value.every((option) => option.disabled) ) const selectSize = computed( () => props.size || elFormItem.size || $ELEMENT.size ) const collapseTagSize = computed(() => ['small', 'mini'].indexOf(selectSize.value) > -1 ? 'mini' : 'small' ) const tagMaxWidth = computed(() => { const select = selectionRef.value const size = collapseTagSize.value const paddingLeft = select ? parseInt(getComputedStyle(select).paddingLeft) : 0 const paddingRight = select ? parseInt(getComputedStyle(select).paddingRight) : 0 return ( states.selectWidth - paddingRight - paddingLeft - TAG_BASE_WIDTH[size] ) }) const calculatePopperSize = () => { popperSize.value = selectRef.value?.getBoundingClientRect?.()?.width || 200 } const inputWrapperStyle = computed(() => { return { width: `${ 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(() => { const _placeholder = props.placeholder || t('el.select.placeholder') return props.multiple ? _placeholder : states.selectedLabel || _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) { const len = (props.modelValue as []).length if ((props.modelValue as Array<any>).length > 0) { return filteredOptions.value.findIndex( (o) => o.value === props.modelValue[len - 1] ) } } else { if (props.modelValue) { return filteredOptions.value.findIndex( (o) => o.value === props.modelValue ) } } return -1 }) const dropdownMenuVisible = computed(() => { return expanded.value && emptyText.value !== false }) // hooks const { createNewOption, removeNewOption, selectNewOption, clearAllNewOption, } = useAllowCreate(props, states) const { handleCompositionStart, handleCompositionUpdate, handleCompositionEnd, } = useInput((e) => onInput(e)) // methods const focusAndUpdatePopup = () => { inputRef.value.focus?.() popper.value.update?.() } const toggleMenu = () => { if (props.automaticDropdown) return if (!selectDisabled.value) { if (states.isComposing) states.softFocus = true return nextTick(() => { expanded.value = !expanded.value inputRef.value?.focus?.() }) } } const onInputChange = () => { if (props.filterable && states.inputValue !== states.selectedLabel) { states.query = states.selectedLabel } handleQueryChange(states.inputValue) return nextTick(() => { createNewOption(states.inputValue) }) } const debouncedOnInputChange = lodashDebounce(onInputChange, debounce.value) const handleQueryChange = (val: string) => { if (states.previousQuery === val) { return } states.previousQuery = val if (props.filterable && isFunction(props.filterMethod)) { props.filterMethod(val) } else if ( props.filterable && props.remote && isFunction(props.remoteMethod) ) { props.remoteMethod(val) } } 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) states.previousValue = val.toString() } 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 } 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() calculatePopperSize() popper.value?.update?.() if (props.multiple) { return resetInputHeight() } } const resetInputWidth = () => { const select = selectionRef.value if (select) { states.selectWidth = select.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) removeNewOption(option) } else if ( props.multipleLimit <= 0 || selectedOptions.length < props.multipleLimit ) { selectedOptions = [...selectedOptions, option.value] states.cachedOptions.push(option) selectNewOption(option) updateHoveringIndex(idx) } 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() setSoftFocus() } else { selectedIndex.value = idx states.selectedLabel = option.label update(option.value) expanded.value = false states.isComposing = false states.isSilentBlur = byClick selectNewOption(option) if (!option.created) { clearAllNewOption() } updateHoveringIndex(idx) } } 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 removeNewOption(tag) return nextTick(focusAndUpdatePopup) } event.stopPropagation() } const handleFocus = (event: FocusEvent) => { const focused = states.isComposing states.isComposing = true if (!states.softFocus) { // If already in the focus state, shouldn't trigger event if (!focused) emit('focus', event) } else { states.softFocus = false } } const handleBlur = () => { states.softFocus = false // reset input value when blurred // https://github.com/ElemeFE/element/pull/10822 return 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') } } states.isComposing = false }) } // 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() removeNewOption(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') clearAllNewOption() return nextTick(focusAndUpdatePopup) } const onUpdateInputValue = (val: string) => { states.displayInputValue = val states.inputValue = val } const onKeyboardNavigate = ( direction: 'forward' | 'backward', hoveringIndex: number = undefined ) => { const options = filteredOptions.value if ( !['forward', 'backward'].includes(direction) || selectDisabled.value || options.length <= 0 || optionsAllDisabled.value ) { return } if (!expanded.value) { return toggleMenu() } if (hoveringIndex === undefined) { hoveringIndex = states.hoveringIndex } let newIndex = -1 if (direction === 'forward') { newIndex = hoveringIndex + 1 if (newIndex >= options.length) { // return to the first option newIndex = 0 } } else if (direction === 'backward') { newIndex = hoveringIndex - 1 if (newIndex < 0) { // navigate to the last one newIndex = options.length - 1 } } const option = options[newIndex] if (option.disabled || option.type === 'Group') { // prevent dispatching multiple nextTick callbacks. return onKeyboardNavigate(direction, newIndex) } else { updateHoveringIndex(newIndex) scrollToItem(newIndex) } } const onKeyboardSelect = () => { if (!expanded.value) { return toggleMenu() } else if (~states.hoveringIndex) { onSelect( filteredOptions.value[states.hoveringIndex], states.hoveringIndex, false ) } } const updateHoveringIndex = (idx: number) => { states.hoveringIndex = idx } const resetHoveringIndex = () => { states.hoveringIndex = -1 } const setSoftFocus = () => { const _input = inputRef.value if (_input) { _input.focus?.() } } const onInput = (event) => { const value = event.target.value onUpdateInputValue(value) if (states.displayInputValue.length > 0 && !expanded.value) { expanded.value = true } states.calculatedWidth = calculatorRef.value.getBoundingClientRect().width if (props.multiple) { resetInputHeight() } if (props.remote) { debouncedOnInputChange() } else { return onInputChange() } } const handleClickOutside = () => { expanded.value = false return handleBlur() } const handleMenuEnter = () => { states.inputValue = states.displayInputValue return nextTick(() => { if (~indexRef.value) { updateHoveringIndex(indexRef.value) scrollToItem(states.hoveringIndex) } }) } const scrollToItem = (index: number) => { menuRef.value.scrollToItem(index) } const initStates = () => { resetHoveringIndex() if (props.multiple) { if ((props.modelValue as Array<any>).length > 0) { let initHovering = false states.cachedOptions.length = 0 ;(props.modelValue as Array<any>).map((selected) => { const itemIndex = filteredOptions.value.findIndex( (option) => option.value === selected ) if (~itemIndex) { states.cachedOptions.push( filteredOptions.value[itemIndex] as Option ) if (!initHovering) { updateHoveringIndex(itemIndex) } initHovering = true } }) } else { states.cachedOptions = [] } } else { if (props.modelValue) { const options = filteredOptions.value const selectedItemIndex = options.findIndex( (o) => o.value === props.modelValue ) if (~selectedItemIndex) { states.selectedLabel = options[selectedItemIndex].label updateHoveringIndex(selectedItemIndex) } else { states.selectedLabel = `${props.modelValue}` } } else { states.selectedLabel = '' } } calculatePopperSize() } // 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 = '' createNewOption('') } }) watch( () => props.modelValue, (val) => { if (!val || val.toString() !== states.previousValue) { initStates() } }, { deep: true, } ) watch( () => props.options, () => { const input = inputRef.value // filter or remote-search scenarios are not initialized if (!input || (input && document.activeElement !== input)) { initStates() } }, { deep: true, } ) // fix the problem that scrollTop is not reset in filterable mode watch(filteredOptions, () => { return nextTick(menuRef.value.resetScrollTop) }) onMounted(() => { initStates() addResizeListener(selectRef.value, handleResize) }) onBeforeMount(() => { removeResizeListener(selectRef.value, handleResize) }) return { // data exports collapseTagSize, currentPlaceholder, expanded, emptyText, popupHeight, debounce, filteredOptions, iconClass, inputWrapperStyle, popperSize, dropdownMenuVisible, // readonly, shouldShowPlaceholder, selectDisabled, selectSize, showClearBtn, states, tagMaxWidth, // refs items exports calculatorRef, controlRef, inputRef, menuRef, popper, selectRef, selectionRef, popperRef, Effect, // methods exports debouncedOnInputChange, deleteTag, getLabel, getValueKey, handleBlur, handleClear, handleClickOutside, handleDel, handleEsc, handleFocus, handleMenuEnter, handleResize, toggleMenu, scrollTo: scrollToItem, onInput, onKeyboardNavigate, onKeyboardSelect, onSelect, onHover: updateHoveringIndex, onUpdateInputValue, handleCompositionStart, handleCompositionEnd, handleCompositionUpdate, } } export default useSelect