mirror of
https://github.com/element-plus/element-plus.git
synced 2026-03-13 07:51:17 +08:00
1089 lines
29 KiB
TypeScript
1089 lines
29 KiB
TypeScript
import {
|
|
computed,
|
|
nextTick,
|
|
onMounted,
|
|
reactive,
|
|
ref,
|
|
watch,
|
|
watchEffect,
|
|
} from 'vue'
|
|
import { findLastIndex, get, isEqual } from 'lodash-unified'
|
|
import { useDebounceFn, useResizeObserver } from '@vueuse/core'
|
|
import {
|
|
ValidateComponentsMap,
|
|
debugWarn,
|
|
escapeStringRegexp,
|
|
getEventCode,
|
|
isArray,
|
|
isEmpty,
|
|
isFunction,
|
|
isNumber,
|
|
isObject,
|
|
isUndefined,
|
|
} from '@element-plus/utils'
|
|
import {
|
|
useComposition,
|
|
useEmptyValues,
|
|
useFocusController,
|
|
useLocale,
|
|
useNamespace,
|
|
} from '@element-plus/hooks'
|
|
import {
|
|
CHANGE_EVENT,
|
|
EVENT_CODE,
|
|
MINIMUM_INPUT_WIDTH,
|
|
UPDATE_MODEL_EVENT,
|
|
} from '@element-plus/constants'
|
|
import {
|
|
useFormDisabled,
|
|
useFormItem,
|
|
useFormItemInputId,
|
|
useFormSize,
|
|
} from '@element-plus/components/form'
|
|
import { useAllowCreate } from './useAllowCreate'
|
|
import { useProps } from './useProps'
|
|
|
|
import type { Option, OptionType, SelectStates } from './select.types'
|
|
import type { SelectV2Props } from './token'
|
|
import type { SelectV2EmitFn } from './defaults'
|
|
import type { TooltipInstance } from '@element-plus/components/tooltip'
|
|
import type { SelectDropdownInstance } from './select-dropdown'
|
|
|
|
const useSelect = (props: SelectV2Props, emit: SelectV2EmitFn) => {
|
|
// inject
|
|
const { t } = useLocale()
|
|
const nsSelect = useNamespace('select')
|
|
const nsInput = useNamespace('input')
|
|
const { form: elForm, formItem: elFormItem } = useFormItem()
|
|
const { inputId } = useFormItemInputId(props, {
|
|
formItemContext: elFormItem,
|
|
})
|
|
const { aliasProps, getLabel, getValue, getDisabled, getOptions } =
|
|
useProps(props)
|
|
const { valueOnClear, isEmptyValue } = useEmptyValues(props)
|
|
|
|
const states: SelectStates = reactive({
|
|
inputValue: '',
|
|
cachedOptions: [],
|
|
createdOptions: [],
|
|
hoveringIndex: -1,
|
|
inputHovering: false,
|
|
selectionWidth: 0,
|
|
collapseItemWidth: 0,
|
|
previousQuery: null,
|
|
previousValue: undefined,
|
|
selectedLabel: '',
|
|
menuVisibleOnFocus: false,
|
|
isBeforeHide: false,
|
|
})
|
|
|
|
// data refs
|
|
const popperSize = ref(-1)
|
|
const debouncing = ref(false)
|
|
|
|
// DOM & Component refs
|
|
const selectRef = ref<HTMLElement>()
|
|
const selectionRef = ref<HTMLElement>()
|
|
const tooltipRef = ref<TooltipInstance>()
|
|
const tagTooltipRef = ref<TooltipInstance>()
|
|
const inputRef = ref<HTMLElement>()
|
|
const prefixRef = ref<HTMLElement>()
|
|
const suffixRef = ref<HTMLElement>()
|
|
const menuRef = ref<SelectDropdownInstance>()
|
|
const tagMenuRef = ref<HTMLElement>()
|
|
const collapseItemRef = ref<HTMLElement>()
|
|
|
|
const {
|
|
isComposing,
|
|
handleCompositionStart,
|
|
handleCompositionEnd,
|
|
handleCompositionUpdate,
|
|
} = useComposition({
|
|
afterComposition: (e) => onInput(e),
|
|
})
|
|
|
|
const selectDisabled = useFormDisabled()
|
|
|
|
const { wrapperRef, isFocused, handleBlur } = useFocusController(inputRef, {
|
|
disabled: selectDisabled,
|
|
afterFocus() {
|
|
if (props.automaticDropdown && !expanded.value) {
|
|
expanded.value = true
|
|
states.menuVisibleOnFocus = true
|
|
}
|
|
},
|
|
beforeBlur(event) {
|
|
return (
|
|
tooltipRef.value?.isFocusInsideContent(event) ||
|
|
tagTooltipRef.value?.isFocusInsideContent(event)
|
|
)
|
|
},
|
|
afterBlur() {
|
|
expanded.value = false
|
|
states.menuVisibleOnFocus = false
|
|
if (props.validateEvent) {
|
|
elFormItem?.validate?.('blur').catch((err) => debugWarn(err))
|
|
}
|
|
},
|
|
})
|
|
|
|
const allOptions = computed(() => filterOptions(''))
|
|
|
|
const hasOptions = computed(() => {
|
|
if (props.loading) return false
|
|
return props.options.length > 0 || states.createdOptions.length > 0
|
|
})
|
|
|
|
const filteredOptions = ref<OptionType[]>([])
|
|
// the controller of the expanded popup
|
|
const expanded = ref(false)
|
|
|
|
const needStatusIcon = computed(() => elForm?.statusIcon ?? false)
|
|
|
|
const popupHeight = computed(() => {
|
|
const totalHeight = filteredOptions.value.length * props.itemHeight
|
|
return totalHeight > props.height ? props.height : totalHeight
|
|
})
|
|
|
|
const hasModelValue = computed(() => {
|
|
return props.multiple
|
|
? isArray(props.modelValue) && props.modelValue.length > 0
|
|
: !isEmptyValue(props.modelValue)
|
|
})
|
|
|
|
const showClearBtn = computed(() => {
|
|
return (
|
|
props.clearable &&
|
|
!selectDisabled.value &&
|
|
hasModelValue.value &&
|
|
(isFocused.value || states.inputHovering)
|
|
)
|
|
})
|
|
|
|
const iconComponent = computed(() =>
|
|
props.remote && props.filterable && !props.remoteShowSuffix
|
|
? ''
|
|
: props.suffixIcon
|
|
)
|
|
|
|
const iconReverse = computed(
|
|
() => iconComponent.value && nsSelect.is('reverse', expanded.value)
|
|
)
|
|
|
|
const validateState = computed(() => elFormItem?.validateState || '')
|
|
const validateIcon = computed(() => {
|
|
// When we use indexed access to get the type of an undeclared property,
|
|
// the unsafe type `any` will be inferred, which TypeScript throws an error to emphasize it.
|
|
// To avoid TypeScript complaining about it, we use truthiness narrowing to narrow the type of validateState.
|
|
if (!validateState.value) return
|
|
return ValidateComponentsMap[validateState.value]
|
|
})
|
|
|
|
const debounce = computed(() => (props.remote ? props.debounce : 0))
|
|
|
|
const isRemoteSearchEmpty = computed(
|
|
() => props.remote && !states.inputValue && !hasOptions.value
|
|
)
|
|
|
|
// filteredOptions includes flatten the data into one dimensional array.
|
|
const emptyText = computed(() => {
|
|
if (props.loading) {
|
|
return props.loadingText || t('el.select.loading')
|
|
} else {
|
|
if (
|
|
props.filterable &&
|
|
states.inputValue &&
|
|
hasOptions.value &&
|
|
filteredOptions.value.length === 0
|
|
) {
|
|
return props.noMatchText || t('el.select.noMatch')
|
|
}
|
|
if (!hasOptions.value) {
|
|
return props.noDataText || t('el.select.noData')
|
|
}
|
|
}
|
|
return null
|
|
})
|
|
|
|
const isFilterMethodValid = computed(
|
|
() => props.filterable && isFunction(props.filterMethod)
|
|
)
|
|
const isRemoteMethodValid = computed(
|
|
() => props.filterable && props.remote && isFunction(props.remoteMethod)
|
|
)
|
|
|
|
const filterOptions = (query: string) => {
|
|
const regexp = new RegExp(escapeStringRegexp(query), 'i')
|
|
|
|
const isValidOption = (o: Option): boolean => {
|
|
if (isFilterMethodValid.value || isRemoteMethodValid.value) return true
|
|
// when query was given, we should test on the label see whether the label contains the given query
|
|
return query ? regexp.test(getLabel(o) || '') : true
|
|
}
|
|
if (props.loading) {
|
|
return []
|
|
}
|
|
|
|
return [...states.createdOptions, ...props.options].reduce((all, item) => {
|
|
const options = getOptions(item)
|
|
|
|
if (isArray(options)) {
|
|
const filtered = options.filter(isValidOption)
|
|
|
|
if (filtered.length > 0) {
|
|
all.push(
|
|
{
|
|
label: getLabel(item),
|
|
type: 'Group',
|
|
},
|
|
...filtered
|
|
)
|
|
}
|
|
} else if (props.remote || isValidOption(item)) {
|
|
all.push(item)
|
|
}
|
|
|
|
return all
|
|
}, []) as OptionType[]
|
|
}
|
|
|
|
const updateOptions = () => {
|
|
filteredOptions.value = filterOptions(states.inputValue)
|
|
}
|
|
|
|
const allOptionsValueMap = computed(() => {
|
|
const valueMap = new Map()
|
|
|
|
allOptions.value.forEach((option, index) => {
|
|
valueMap.set(getValueKey(getValue(option)), { option, index })
|
|
})
|
|
return valueMap
|
|
})
|
|
|
|
const filteredOptionsValueMap = computed(() => {
|
|
const valueMap = new Map()
|
|
|
|
filteredOptions.value.forEach((option, index) => {
|
|
valueMap.set(getValueKey(getValue(option)), { option, index })
|
|
})
|
|
return valueMap
|
|
})
|
|
|
|
const optionsAllDisabled = computed(() =>
|
|
filteredOptions.value.every((option) => getDisabled(option))
|
|
)
|
|
|
|
const selectSize = useFormSize()
|
|
|
|
const collapseTagSize = computed(() =>
|
|
'small' === selectSize.value ? 'small' : 'default'
|
|
)
|
|
|
|
const calculatePopperSize = () => {
|
|
if (isNumber(props.fitInputWidth)) {
|
|
popperSize.value = props.fitInputWidth
|
|
return
|
|
}
|
|
const width = selectRef.value?.offsetWidth || 200
|
|
if (!props.fitInputWidth && hasOptions.value) {
|
|
nextTick(() => {
|
|
popperSize.value = Math.max(width, calculateLabelMaxWidth())
|
|
})
|
|
} else {
|
|
popperSize.value = width
|
|
}
|
|
}
|
|
|
|
// TODO Caching implementation
|
|
// 1. There is no need to calculate options that have already been calculated
|
|
// 2. Repeatedly expand and close when persistent is set to false, no need for repeated calculations
|
|
const calculateLabelMaxWidth = () => {
|
|
const canvas = document.createElement('canvas')
|
|
const ctx = canvas.getContext('2d')
|
|
const selector = nsSelect.be('dropdown', 'item')
|
|
const dom = menuRef.value?.listRef?.innerRef || document
|
|
const dropdownItemEl = dom.querySelector(`.${selector}`)
|
|
if (dropdownItemEl === null || ctx === null) return 0
|
|
const style = getComputedStyle(dropdownItemEl)
|
|
const padding =
|
|
Number.parseFloat(style.paddingLeft) +
|
|
Number.parseFloat(style.paddingRight)
|
|
ctx.font = `bold ${style.font.replace(
|
|
new RegExp(`\\b${style.fontWeight}\\b`),
|
|
''
|
|
)}`
|
|
const maxWidth = filteredOptions.value.reduce((max, option) => {
|
|
const metrics = ctx.measureText(getLabel(option))
|
|
return Math.max(metrics.width, max)
|
|
}, 0)
|
|
return maxWidth + padding
|
|
}
|
|
|
|
const getGapWidth = () => {
|
|
if (!selectionRef.value) return 0
|
|
const style = window.getComputedStyle(selectionRef.value)
|
|
return Number.parseFloat(style.gap || '6px')
|
|
}
|
|
|
|
// computed style
|
|
const tagStyle = computed(() => {
|
|
const gapWidth = getGapWidth()
|
|
const inputSlotWidth = props.filterable ? gapWidth + MINIMUM_INPUT_WIDTH : 0
|
|
const maxWidth =
|
|
collapseItemRef.value && props.maxCollapseTags === 1
|
|
? states.selectionWidth -
|
|
states.collapseItemWidth -
|
|
gapWidth -
|
|
inputSlotWidth
|
|
: states.selectionWidth - inputSlotWidth
|
|
return { maxWidth: `${maxWidth}px` }
|
|
})
|
|
|
|
const collapseTagStyle = computed(() => {
|
|
return { maxWidth: `${states.selectionWidth}px` }
|
|
})
|
|
|
|
const shouldShowPlaceholder = computed(() => {
|
|
if (isArray(props.modelValue)) {
|
|
return props.modelValue.length === 0 && !states.inputValue
|
|
}
|
|
|
|
// 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.inputValue : true
|
|
})
|
|
|
|
const currentPlaceholder = computed(() => {
|
|
const _placeholder = props.placeholder ?? t('el.select.placeholder')
|
|
return props.multiple || !hasModelValue.value
|
|
? _placeholder
|
|
: states.selectedLabel
|
|
})
|
|
|
|
// this obtains the actual popper DOM element.
|
|
const popperRef = computed(() => tooltipRef.value?.popperRef?.contentRef)
|
|
|
|
// the index with current value in options
|
|
const indexRef = computed<number>(() => {
|
|
if (props.multiple) {
|
|
const len = (props.modelValue as []).length
|
|
if (
|
|
len > 0 &&
|
|
filteredOptionsValueMap.value.has(props.modelValue[len - 1])
|
|
) {
|
|
const { index } = filteredOptionsValueMap.value.get(
|
|
props.modelValue[len - 1]
|
|
)
|
|
return index
|
|
}
|
|
} else {
|
|
if (
|
|
!isEmptyValue(props.modelValue) &&
|
|
filteredOptionsValueMap.value.has(props.modelValue)
|
|
) {
|
|
const { index } = filteredOptionsValueMap.value.get(props.modelValue)
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
})
|
|
|
|
const dropdownMenuVisible = computed({
|
|
get() {
|
|
return (
|
|
expanded.value &&
|
|
(props.loading || !isRemoteSearchEmpty.value) &&
|
|
(!debouncing.value || !isEmpty(states.previousQuery))
|
|
)
|
|
},
|
|
set(val: boolean) {
|
|
expanded.value = val
|
|
},
|
|
})
|
|
|
|
const showTagList = computed(() => {
|
|
if (!props.multiple) {
|
|
return []
|
|
}
|
|
return props.collapseTags
|
|
? states.cachedOptions.slice(0, props.maxCollapseTags)
|
|
: states.cachedOptions
|
|
})
|
|
|
|
const collapseTagList = computed(() => {
|
|
if (!props.multiple) {
|
|
return []
|
|
}
|
|
return props.collapseTags
|
|
? states.cachedOptions.slice(props.maxCollapseTags)
|
|
: []
|
|
})
|
|
|
|
// hooks
|
|
const {
|
|
createNewOption,
|
|
removeNewOption,
|
|
selectNewOption,
|
|
clearAllNewOption,
|
|
} = useAllowCreate(props, states)
|
|
|
|
// methods
|
|
const toggleMenu = (event?: Event) => {
|
|
if (
|
|
selectDisabled.value ||
|
|
(props.filterable &&
|
|
expanded.value &&
|
|
event &&
|
|
!suffixRef.value?.contains(event.target as Node))
|
|
)
|
|
return
|
|
|
|
if (states.menuVisibleOnFocus) {
|
|
// controlled by automaticDropdown
|
|
states.menuVisibleOnFocus = false
|
|
} else {
|
|
expanded.value = !expanded.value
|
|
}
|
|
}
|
|
|
|
const onInputChange = () => {
|
|
if (states.inputValue.length > 0 && !expanded.value) {
|
|
expanded.value = true
|
|
}
|
|
createNewOption(states.inputValue)
|
|
nextTick(() => {
|
|
handleQueryChange(states.inputValue)
|
|
})
|
|
}
|
|
|
|
const debouncedOnInputChange = useDebounceFn(() => {
|
|
onInputChange()
|
|
debouncing.value = false
|
|
}, debounce)
|
|
|
|
const handleQueryChange = (val: string) => {
|
|
if (states.previousQuery === val || isComposing.value) {
|
|
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)
|
|
}
|
|
if (
|
|
props.defaultFirstOption &&
|
|
(props.filterable || props.remote) &&
|
|
filteredOptions.value.length
|
|
) {
|
|
nextTick(checkDefaultFirstOption)
|
|
} else {
|
|
nextTick(updateHoveringIndex)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* find and highlight first option as default selected
|
|
* @remark
|
|
* - if the first option in dropdown list is user-created,
|
|
* it would be at the end of the optionsArray
|
|
* so find it and set hover.
|
|
* (NOTE: there must be only one user-created option in dropdown list with query)
|
|
* - if there's no user-created option in list, just find the first one as usual
|
|
* (NOTE: exclude options that are disabled or in disabled-group)
|
|
*/
|
|
const checkDefaultFirstOption = () => {
|
|
const optionsInDropdown = filteredOptions.value.filter(
|
|
(n) => !n.disabled && n.type !== 'Group'
|
|
)
|
|
const userCreatedOption = optionsInDropdown.find((n) => n.created)
|
|
const firstOriginOption = optionsInDropdown[0]
|
|
states.hoveringIndex = getValueIndex(
|
|
filteredOptions.value,
|
|
userCreatedOption || firstOriginOption
|
|
)
|
|
}
|
|
|
|
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 = props.multiple ? String(val) : val
|
|
|
|
nextTick(() => {
|
|
if (props.multiple && isArray(props.modelValue)) {
|
|
const cachedOptions = states.cachedOptions.slice()
|
|
const selectedOptions = props.modelValue.map((value) =>
|
|
getOption(value, cachedOptions)
|
|
)
|
|
|
|
if (!isEqual(states.cachedOptions, selectedOptions)) {
|
|
states.cachedOptions = selectedOptions
|
|
}
|
|
} else {
|
|
initStates(true)
|
|
}
|
|
})
|
|
}
|
|
|
|
const getValueIndex = (arr: unknown[] = [], value: unknown) => {
|
|
if (!isObject(value)) {
|
|
return arr.indexOf(value)
|
|
}
|
|
const valueKey = props.valueKey
|
|
let index = -1
|
|
arr.some((item, i) => {
|
|
if (get(item, valueKey) === get(value, valueKey)) {
|
|
index = i
|
|
return true
|
|
}
|
|
return false
|
|
})
|
|
return index
|
|
}
|
|
|
|
const getValueKey = (item: unknown) => {
|
|
return isObject(item) ? get(item, props.valueKey) : item
|
|
}
|
|
|
|
const handleResize = () => {
|
|
calculatePopperSize()
|
|
}
|
|
|
|
const resetSelectionWidth = () => {
|
|
states.selectionWidth = Number.parseFloat(
|
|
window.getComputedStyle(selectionRef.value!).width
|
|
)
|
|
}
|
|
|
|
const resetCollapseItemWidth = () => {
|
|
states.collapseItemWidth =
|
|
collapseItemRef.value!.getBoundingClientRect().width
|
|
}
|
|
|
|
const updateTooltip = () => {
|
|
tooltipRef.value?.updatePopper?.()
|
|
}
|
|
|
|
const updateTagTooltip = () => {
|
|
tagTooltipRef.value?.updatePopper?.()
|
|
}
|
|
|
|
const onSelect = (option: Option) => {
|
|
const optionValue = getValue(option)
|
|
|
|
if (props.multiple) {
|
|
let selectedOptions = (props.modelValue as any[]).slice()
|
|
|
|
const index = getValueIndex(selectedOptions, optionValue)
|
|
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, optionValue]
|
|
states.cachedOptions.push(option)
|
|
selectNewOption(option)
|
|
}
|
|
update(selectedOptions)
|
|
if (option.created) {
|
|
handleQueryChange('')
|
|
}
|
|
if (props.filterable && !props.reserveKeyword) {
|
|
states.inputValue = ''
|
|
}
|
|
} else {
|
|
states.selectedLabel = getLabel(option)
|
|
!isEqual(props.modelValue, optionValue) && update(optionValue)
|
|
expanded.value = false
|
|
selectNewOption(option)
|
|
if (!option.created) {
|
|
clearAllNewOption()
|
|
}
|
|
}
|
|
focus()
|
|
}
|
|
|
|
const deleteTag = (event: MouseEvent, option: Option) => {
|
|
let selectedOptions = (props.modelValue as any[]).slice()
|
|
|
|
const index = getValueIndex(selectedOptions, getValue(option))
|
|
|
|
if (index > -1 && !selectDisabled.value) {
|
|
selectedOptions = [
|
|
...(props.modelValue as Array<unknown>).slice(0, index),
|
|
...(props.modelValue as Array<unknown>).slice(index + 1),
|
|
]
|
|
states.cachedOptions.splice(index, 1)
|
|
update(selectedOptions)
|
|
emit('remove-tag', getValue(option))
|
|
removeNewOption(option)
|
|
}
|
|
event.stopPropagation()
|
|
focus()
|
|
}
|
|
|
|
const focus = () => {
|
|
inputRef.value?.focus()
|
|
}
|
|
|
|
const blur = () => {
|
|
if (expanded.value) {
|
|
expanded.value = false
|
|
nextTick(() => inputRef.value?.blur())
|
|
return
|
|
}
|
|
inputRef.value?.blur()
|
|
}
|
|
|
|
// keyboard handlers
|
|
const handleEsc = () => {
|
|
if (states.inputValue.length > 0) {
|
|
states.inputValue = ''
|
|
} else {
|
|
expanded.value = false
|
|
}
|
|
}
|
|
|
|
const getLastNotDisabledIndex = (value: unknown[]) =>
|
|
findLastIndex(
|
|
value,
|
|
(it) =>
|
|
!states.cachedOptions.some(
|
|
(option) => getValue(option) === it && getDisabled(option)
|
|
)
|
|
)
|
|
|
|
const handleDel = (e: KeyboardEvent) => {
|
|
const code = getEventCode(e)
|
|
if (!props.multiple) return
|
|
if (code === EVENT_CODE.delete) return
|
|
if (states.inputValue.length === 0) {
|
|
e.preventDefault()
|
|
const selected = (props.modelValue as Array<any>).slice()
|
|
const lastNotDisabledIndex = getLastNotDisabledIndex(selected)
|
|
if (lastNotDisabledIndex < 0) return
|
|
const removeTagValue = selected[lastNotDisabledIndex]
|
|
selected.splice(lastNotDisabledIndex, 1)
|
|
const option = states.cachedOptions[lastNotDisabledIndex]
|
|
states.cachedOptions.splice(lastNotDisabledIndex, 1)
|
|
removeNewOption(option)
|
|
update(selected)
|
|
emit('remove-tag', removeTagValue)
|
|
}
|
|
}
|
|
|
|
const handleClear = () => {
|
|
let emptyValue: string | any[]
|
|
if (isArray(props.modelValue)) {
|
|
emptyValue = []
|
|
} else {
|
|
emptyValue = valueOnClear.value
|
|
}
|
|
|
|
states.selectedLabel = ''
|
|
|
|
expanded.value = false
|
|
update(emptyValue)
|
|
emit('clear')
|
|
clearAllNewOption()
|
|
focus()
|
|
}
|
|
|
|
const onKeyboardNavigate = (
|
|
direction: 'forward' | 'backward',
|
|
hoveringIndex: number | undefined = undefined
|
|
): void => {
|
|
const options = filteredOptions.value
|
|
if (
|
|
!['forward', 'backward'].includes(direction) ||
|
|
selectDisabled.value ||
|
|
options.length <= 0 ||
|
|
optionsAllDisabled.value ||
|
|
isComposing.value
|
|
) {
|
|
return
|
|
}
|
|
if (!expanded.value) {
|
|
return toggleMenu()
|
|
}
|
|
if (isUndefined(hoveringIndex)) {
|
|
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 || newIndex >= options.length) {
|
|
// navigate to the last one
|
|
newIndex = options.length - 1
|
|
}
|
|
}
|
|
const option = options[newIndex]
|
|
if (getDisabled(option) || option.type === 'Group') {
|
|
// prevent dispatching multiple nextTick callbacks.
|
|
return onKeyboardNavigate(direction, newIndex)
|
|
} else {
|
|
states.hoveringIndex = newIndex
|
|
scrollToItem(newIndex)
|
|
}
|
|
}
|
|
|
|
const onKeyboardSelect = () => {
|
|
if (!expanded.value) {
|
|
return toggleMenu()
|
|
} else if (
|
|
~states.hoveringIndex &&
|
|
filteredOptions.value[states.hoveringIndex]
|
|
) {
|
|
onSelect(filteredOptions.value[states.hoveringIndex])
|
|
}
|
|
}
|
|
|
|
const onHoverOption = (idx?: number) => {
|
|
states.hoveringIndex = idx ?? -1
|
|
}
|
|
|
|
const updateHoveringIndex = () => {
|
|
if (!props.multiple) {
|
|
states.hoveringIndex = filteredOptions.value.findIndex((item) => {
|
|
return getValueKey(getValue(item)) === getValueKey(props.modelValue)
|
|
})
|
|
} else {
|
|
const length = props.modelValue.length
|
|
if (length > 0) {
|
|
const lastValue = props.modelValue[length - 1]
|
|
states.hoveringIndex = filteredOptions.value.findIndex(
|
|
(item) => getValueKey(lastValue) === getValueKey(getValue(item))
|
|
)
|
|
} else {
|
|
states.hoveringIndex = -1
|
|
}
|
|
}
|
|
}
|
|
|
|
const onInput = (event: Event) => {
|
|
states.inputValue = (event.target as HTMLInputElement).value
|
|
if (props.remote) {
|
|
debouncing.value = true
|
|
debouncedOnInputChange()
|
|
} else {
|
|
return onInputChange()
|
|
}
|
|
}
|
|
|
|
const handleClickOutside = (event: Event) => {
|
|
expanded.value = false
|
|
|
|
if (isFocused.value) {
|
|
const _event = new FocusEvent('blur', event)
|
|
handleBlur(_event)
|
|
}
|
|
}
|
|
|
|
const handleMenuEnter = () => {
|
|
states.isBeforeHide = false
|
|
return nextTick(() => {
|
|
if (~indexRef.value) {
|
|
scrollToItem(indexRef.value)
|
|
}
|
|
})
|
|
}
|
|
|
|
const scrollToItem = (index: number) => {
|
|
menuRef.value!.scrollToItem(index)
|
|
}
|
|
|
|
const getOption = (value: unknown, cachedOptions?: Option[]) => {
|
|
// match the option with the given value, if not found, create a new option
|
|
const selectValue = getValueKey(value)
|
|
|
|
if (allOptionsValueMap.value.has(selectValue)) {
|
|
const { option } = allOptionsValueMap.value.get(selectValue)
|
|
|
|
return option
|
|
}
|
|
if (cachedOptions && cachedOptions.length) {
|
|
const option = cachedOptions.find(
|
|
(option) => getValueKey(getValue(option)) === selectValue
|
|
)
|
|
if (option) {
|
|
return option
|
|
}
|
|
}
|
|
|
|
return {
|
|
[aliasProps.value.value]: value,
|
|
[aliasProps.value.label]: value,
|
|
}
|
|
}
|
|
|
|
const getIndex = (option: Option) =>
|
|
allOptionsValueMap.value.get(getValue(option))?.index ?? -1
|
|
|
|
const initStates = (needUpdateSelectedLabel = false) => {
|
|
if (props.multiple) {
|
|
if ((props.modelValue as Array<any>).length > 0) {
|
|
const cachedOptions = states.cachedOptions.slice()
|
|
states.cachedOptions.length = 0
|
|
states.previousValue = props.modelValue.toString()
|
|
|
|
for (const value of props.modelValue) {
|
|
const option = getOption(value, cachedOptions)
|
|
states.cachedOptions.push(option)
|
|
}
|
|
} else {
|
|
states.cachedOptions = []
|
|
states.previousValue = undefined
|
|
}
|
|
} else {
|
|
if (hasModelValue.value) {
|
|
states.previousValue = props.modelValue
|
|
const options = filteredOptions.value
|
|
const selectedItemIndex = options.findIndex(
|
|
(option) =>
|
|
getValueKey(getValue(option)) === getValueKey(props.modelValue)
|
|
)
|
|
if (~selectedItemIndex) {
|
|
states.selectedLabel = getLabel(options[selectedItemIndex])
|
|
} else {
|
|
if (!states.selectedLabel || needUpdateSelectedLabel) {
|
|
states.selectedLabel = getValueKey(props.modelValue)
|
|
}
|
|
}
|
|
} else {
|
|
states.selectedLabel = ''
|
|
states.previousValue = undefined
|
|
}
|
|
}
|
|
clearAllNewOption()
|
|
calculatePopperSize()
|
|
}
|
|
|
|
watch(
|
|
() => props.fitInputWidth,
|
|
() => {
|
|
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) => {
|
|
if (val) {
|
|
if (!props.persistent) {
|
|
calculatePopperSize()
|
|
}
|
|
handleQueryChange('')
|
|
} else {
|
|
states.inputValue = ''
|
|
states.previousQuery = null
|
|
states.isBeforeHide = true
|
|
createNewOption('')
|
|
}
|
|
})
|
|
|
|
watch(
|
|
() => props.modelValue,
|
|
(val, oldVal) => {
|
|
const isValEmpty = !val || (isArray(val) && val.length === 0)
|
|
|
|
if (
|
|
isValEmpty ||
|
|
(props.multiple && !isEqual(val.toString(), states.previousValue)) ||
|
|
(!props.multiple &&
|
|
getValueKey(val) !== getValueKey(states.previousValue))
|
|
) {
|
|
initStates(true)
|
|
}
|
|
if (!isEqual(val, oldVal) && props.validateEvent) {
|
|
elFormItem?.validate?.('change').catch((err) => debugWarn(err))
|
|
}
|
|
},
|
|
{
|
|
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,
|
|
flush: 'post',
|
|
}
|
|
)
|
|
|
|
// fix the problem that scrollTop is not reset in filterable mode
|
|
watch(
|
|
() => filteredOptions.value,
|
|
() => {
|
|
calculatePopperSize()
|
|
return menuRef.value && nextTick(menuRef.value.resetScrollTop)
|
|
}
|
|
)
|
|
|
|
watchEffect(() => {
|
|
// Anything could cause options changed, then update options
|
|
// If you want to control it by condition, write here
|
|
if (states.isBeforeHide) return
|
|
updateOptions()
|
|
})
|
|
|
|
watchEffect(() => {
|
|
const { valueKey, options } = props
|
|
const duplicateValue = new Map()
|
|
for (const item of options) {
|
|
const optionValue = getValue(item)
|
|
let v = optionValue
|
|
if (isObject(v)) {
|
|
v = get(optionValue, valueKey)
|
|
}
|
|
if (duplicateValue.get(v)) {
|
|
debugWarn(
|
|
'ElSelectV2',
|
|
`The option values you provided seem to be duplicated, which may cause some problems, please check.`
|
|
)
|
|
break
|
|
} else {
|
|
duplicateValue.set(v, true)
|
|
}
|
|
}
|
|
})
|
|
|
|
onMounted(() => {
|
|
initStates()
|
|
})
|
|
useResizeObserver(selectRef, handleResize)
|
|
useResizeObserver(selectionRef, resetSelectionWidth)
|
|
useResizeObserver(wrapperRef, updateTooltip)
|
|
useResizeObserver(tagMenuRef, updateTagTooltip)
|
|
useResizeObserver(collapseItemRef, resetCollapseItemWidth)
|
|
|
|
// #21498
|
|
let stop: (() => void) | undefined
|
|
watch(
|
|
() => dropdownMenuVisible.value,
|
|
(newVal) => {
|
|
if (newVal) {
|
|
stop = useResizeObserver(menuRef, updateTooltip).stop
|
|
} else {
|
|
stop?.()
|
|
stop = undefined
|
|
}
|
|
emit('visible-change', newVal)
|
|
}
|
|
)
|
|
|
|
return {
|
|
// data exports
|
|
inputId,
|
|
collapseTagSize,
|
|
currentPlaceholder,
|
|
expanded,
|
|
emptyText,
|
|
popupHeight,
|
|
debounce,
|
|
allOptions,
|
|
allOptionsValueMap,
|
|
filteredOptions,
|
|
iconComponent,
|
|
iconReverse,
|
|
tagStyle,
|
|
collapseTagStyle,
|
|
popperSize,
|
|
dropdownMenuVisible,
|
|
hasModelValue,
|
|
shouldShowPlaceholder,
|
|
selectDisabled,
|
|
selectSize,
|
|
needStatusIcon,
|
|
showClearBtn,
|
|
states,
|
|
isFocused,
|
|
nsSelect,
|
|
nsInput,
|
|
|
|
// refs items exports
|
|
inputRef,
|
|
menuRef,
|
|
tagMenuRef,
|
|
tooltipRef,
|
|
tagTooltipRef,
|
|
selectRef,
|
|
wrapperRef,
|
|
selectionRef,
|
|
prefixRef,
|
|
suffixRef,
|
|
collapseItemRef,
|
|
|
|
popperRef,
|
|
|
|
validateState,
|
|
validateIcon,
|
|
showTagList,
|
|
collapseTagList,
|
|
|
|
// methods exports
|
|
debouncedOnInputChange,
|
|
deleteTag,
|
|
getLabel,
|
|
getValue,
|
|
getDisabled,
|
|
getValueKey,
|
|
getIndex,
|
|
handleClear,
|
|
handleClickOutside,
|
|
handleDel,
|
|
handleEsc,
|
|
focus,
|
|
blur,
|
|
handleMenuEnter,
|
|
handleResize,
|
|
resetSelectionWidth,
|
|
updateTooltip,
|
|
updateTagTooltip,
|
|
updateOptions,
|
|
toggleMenu,
|
|
scrollTo: scrollToItem,
|
|
onInput,
|
|
onKeyboardNavigate,
|
|
onKeyboardSelect,
|
|
onSelect,
|
|
onHover: onHoverOption,
|
|
handleCompositionStart,
|
|
handleCompositionEnd,
|
|
handleCompositionUpdate,
|
|
}
|
|
}
|
|
|
|
export default useSelect
|