mirror of
https://github.com/element-plus/element-plus.git
synced 2026-03-13 07:51:17 +08:00
* fix(components): [select] popper-options control activation of popper * fix: synchronize modifications to select-v2 * fix: update --------- Co-authored-by: rzzf <cszhjh@gmail.com>
572 lines
18 KiB
Vue
572 lines
18 KiB
Vue
<template>
|
|
<div
|
|
ref="selectRef"
|
|
v-click-outside:[popperRef]="handleClickOutside"
|
|
:class="[nsSelect.b(), nsSelect.m(selectSize)]"
|
|
@[mouseEnterEventName]="states.inputHovering = true"
|
|
@mouseleave="states.inputHovering = false"
|
|
>
|
|
<el-tooltip
|
|
ref="tooltipRef"
|
|
:visible="dropdownMenuVisible"
|
|
:placement="placement"
|
|
:teleported="teleported"
|
|
:popper-class="[nsSelect.e('popper'), popperClass]"
|
|
:popper-style="popperStyle"
|
|
:popper-options="popperOptions"
|
|
:fallback-placements="fallbackPlacements"
|
|
:effect="effect"
|
|
pure
|
|
trigger="click"
|
|
:transition="`${nsSelect.namespace.value}-zoom-in-top`"
|
|
:stop-popper-mouse-event="false"
|
|
:gpu-acceleration="false"
|
|
:persistent="persistent"
|
|
:append-to="appendTo"
|
|
:show-arrow="showArrow"
|
|
:offset="offset"
|
|
@before-show="handleMenuEnter"
|
|
@hide="states.isBeforeHide = false"
|
|
>
|
|
<template #default>
|
|
<div
|
|
ref="wrapperRef"
|
|
:class="[
|
|
nsSelect.e('wrapper'),
|
|
nsSelect.is('focused', isFocused),
|
|
nsSelect.is('hovering', states.inputHovering),
|
|
nsSelect.is('filterable', filterable),
|
|
nsSelect.is('disabled', selectDisabled),
|
|
]"
|
|
@click.prevent="toggleMenu"
|
|
>
|
|
<div
|
|
v-if="$slots.prefix"
|
|
ref="prefixRef"
|
|
:class="nsSelect.e('prefix')"
|
|
>
|
|
<slot name="prefix" />
|
|
</div>
|
|
<div
|
|
ref="selectionRef"
|
|
:class="[
|
|
nsSelect.e('selection'),
|
|
nsSelect.is(
|
|
'near',
|
|
multiple && !$slots.prefix && !!states.selected.length
|
|
),
|
|
]"
|
|
>
|
|
<slot
|
|
v-if="multiple"
|
|
name="tag"
|
|
:data="states.selected"
|
|
:delete-tag="deleteTag"
|
|
:select-disabled="selectDisabled"
|
|
>
|
|
<div
|
|
v-for="item in showTagList"
|
|
:key="getValueKey(item)"
|
|
:class="nsSelect.e('selected-item')"
|
|
>
|
|
<el-tag
|
|
:closable="!selectDisabled && !item.isDisabled"
|
|
:size="collapseTagSize"
|
|
:type="tagType"
|
|
:effect="tagEffect"
|
|
disable-transitions
|
|
:style="tagStyle"
|
|
@close="deleteTag($event, item)"
|
|
>
|
|
<span :class="nsSelect.e('tags-text')">
|
|
<slot
|
|
name="label"
|
|
:index="item.index"
|
|
:label="item.currentLabel"
|
|
:value="item.value"
|
|
>
|
|
{{ item.currentLabel }}
|
|
</slot>
|
|
</span>
|
|
</el-tag>
|
|
</div>
|
|
|
|
<el-tooltip
|
|
v-if="collapseTags && states.selected.length > maxCollapseTags"
|
|
ref="tagTooltipRef"
|
|
:disabled="dropdownMenuVisible || !collapseTagsTooltip"
|
|
:fallback-placements="['bottom', 'top', 'right', 'left']"
|
|
:effect="effect"
|
|
placement="bottom"
|
|
:popper-class="popperClass"
|
|
:popper-style="popperStyle"
|
|
:teleported="teleported"
|
|
:popper-options="popperOptions"
|
|
>
|
|
<template #default>
|
|
<div
|
|
ref="collapseItemRef"
|
|
:class="nsSelect.e('selected-item')"
|
|
>
|
|
<el-tag
|
|
:closable="false"
|
|
:size="collapseTagSize"
|
|
:type="tagType"
|
|
:effect="tagEffect"
|
|
disable-transitions
|
|
:style="collapseTagStyle"
|
|
>
|
|
<span :class="nsSelect.e('tags-text')">
|
|
+ {{ states.selected.length - maxCollapseTags }}
|
|
</span>
|
|
</el-tag>
|
|
</div>
|
|
</template>
|
|
<template #content>
|
|
<div ref="tagMenuRef" :class="nsSelect.e('selection')">
|
|
<div
|
|
v-for="item in collapseTagList"
|
|
:key="getValueKey(item)"
|
|
:class="nsSelect.e('selected-item')"
|
|
>
|
|
<el-tag
|
|
class="in-tooltip"
|
|
:closable="!selectDisabled && !item.isDisabled"
|
|
:size="collapseTagSize"
|
|
:type="tagType"
|
|
:effect="tagEffect"
|
|
disable-transitions
|
|
@close="deleteTag($event, item)"
|
|
>
|
|
<span :class="nsSelect.e('tags-text')">
|
|
<slot
|
|
name="label"
|
|
:index="item.index"
|
|
:label="item.currentLabel"
|
|
:value="item.value"
|
|
>
|
|
{{ item.currentLabel }}
|
|
</slot>
|
|
</span>
|
|
</el-tag>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</el-tooltip>
|
|
</slot>
|
|
<div
|
|
:class="[
|
|
nsSelect.e('selected-item'),
|
|
nsSelect.e('input-wrapper'),
|
|
nsSelect.is('hidden', !filterable || selectDisabled),
|
|
]"
|
|
>
|
|
<input
|
|
:id="inputId"
|
|
ref="inputRef"
|
|
v-model="states.inputValue"
|
|
type="text"
|
|
:name="name"
|
|
:class="[nsSelect.e('input'), nsSelect.is(selectSize)]"
|
|
:disabled="selectDisabled"
|
|
:autocomplete="autocomplete"
|
|
:style="inputStyle"
|
|
:tabindex="tabindex"
|
|
role="combobox"
|
|
:readonly="!filterable"
|
|
spellcheck="false"
|
|
:aria-activedescendant="hoverOption?.id || ''"
|
|
:aria-controls="contentId"
|
|
:aria-expanded="dropdownMenuVisible"
|
|
:aria-label="ariaLabel"
|
|
aria-autocomplete="none"
|
|
aria-haspopup="listbox"
|
|
@keydown="handleKeydown"
|
|
@compositionstart="handleCompositionStart"
|
|
@compositionupdate="handleCompositionUpdate"
|
|
@compositionend="handleCompositionEnd"
|
|
@input="onInput"
|
|
@click.stop="toggleMenu"
|
|
/>
|
|
<span
|
|
v-if="filterable"
|
|
ref="calculatorRef"
|
|
aria-hidden="true"
|
|
:class="nsSelect.e('input-calculator')"
|
|
v-text="states.inputValue"
|
|
/>
|
|
</div>
|
|
<div
|
|
v-if="shouldShowPlaceholder"
|
|
:class="[
|
|
nsSelect.e('selected-item'),
|
|
nsSelect.e('placeholder'),
|
|
nsSelect.is(
|
|
'transparent',
|
|
!hasModelValue || (expanded && !states.inputValue)
|
|
),
|
|
]"
|
|
>
|
|
<slot
|
|
v-if="hasModelValue"
|
|
name="label"
|
|
:index="getOption(modelValue!).index"
|
|
:label="currentPlaceholder"
|
|
:value="modelValue"
|
|
>
|
|
<span>{{ currentPlaceholder }}</span>
|
|
</slot>
|
|
<span v-else>{{ currentPlaceholder }}</span>
|
|
</div>
|
|
</div>
|
|
<div ref="suffixRef" :class="nsSelect.e('suffix')">
|
|
<el-icon
|
|
v-if="iconComponent && !showClearBtn"
|
|
:class="[nsSelect.e('caret'), nsSelect.e('icon'), iconReverse]"
|
|
>
|
|
<component :is="iconComponent" />
|
|
</el-icon>
|
|
<el-icon
|
|
v-if="showClearBtn && clearIcon"
|
|
:class="[
|
|
nsSelect.e('caret'),
|
|
nsSelect.e('icon'),
|
|
nsSelect.e('clear'),
|
|
]"
|
|
@click="handleClearClick"
|
|
>
|
|
<component :is="clearIcon" />
|
|
</el-icon>
|
|
<el-icon
|
|
v-if="validateState && validateIcon && needStatusIcon"
|
|
:class="[
|
|
nsInput.e('icon'),
|
|
nsInput.e('validateIcon'),
|
|
nsInput.is('loading', validateState === 'validating'),
|
|
]"
|
|
>
|
|
<component :is="validateIcon" />
|
|
</el-icon>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<template #content>
|
|
<el-select-menu ref="menuRef">
|
|
<div
|
|
v-if="$slots.header"
|
|
:class="nsSelect.be('dropdown', 'header')"
|
|
@click.stop
|
|
>
|
|
<slot name="header" />
|
|
</div>
|
|
<el-scrollbar
|
|
v-show="states.options.size > 0 && !loading"
|
|
:id="contentId"
|
|
ref="scrollbarRef"
|
|
tag="ul"
|
|
:wrap-class="nsSelect.be('dropdown', 'wrap')"
|
|
:view-class="nsSelect.be('dropdown', 'list')"
|
|
:class="[nsSelect.is('empty', filteredOptionsCount === 0)]"
|
|
role="listbox"
|
|
:aria-label="ariaLabel"
|
|
aria-orientation="vertical"
|
|
@scroll="popupScroll"
|
|
>
|
|
<el-option
|
|
v-if="showNewOption"
|
|
:value="states.inputValue"
|
|
:created="true"
|
|
/>
|
|
<el-options>
|
|
<slot>
|
|
<template v-for="(option, index) in options" :key="index">
|
|
<el-option-group
|
|
v-if="getOptions(option)?.length"
|
|
:label="getLabel(option)"
|
|
:disabled="getDisabled(option)"
|
|
>
|
|
<el-option
|
|
v-for="item in getOptions(option)"
|
|
:key="getValue(item)"
|
|
v-bind="getOptionProps(item)"
|
|
/>
|
|
</el-option-group>
|
|
<el-option v-else v-bind="getOptionProps(option)" />
|
|
</template>
|
|
</slot>
|
|
</el-options>
|
|
</el-scrollbar>
|
|
<div
|
|
v-if="$slots.loading && loading"
|
|
:class="nsSelect.be('dropdown', 'loading')"
|
|
>
|
|
<slot name="loading" />
|
|
</div>
|
|
<div
|
|
v-else-if="loading || filteredOptionsCount === 0"
|
|
:class="nsSelect.be('dropdown', 'empty')"
|
|
>
|
|
<slot name="empty">
|
|
<span>{{ emptyText }}</span>
|
|
</slot>
|
|
</div>
|
|
<div
|
|
v-if="$slots.footer"
|
|
:class="nsSelect.be('dropdown', 'footer')"
|
|
@click.stop
|
|
>
|
|
<slot name="footer" />
|
|
</div>
|
|
</el-select-menu>
|
|
</template>
|
|
</el-tooltip>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import {
|
|
computed,
|
|
defineComponent,
|
|
getCurrentInstance,
|
|
onBeforeUnmount,
|
|
provide,
|
|
reactive,
|
|
toRefs,
|
|
watch,
|
|
} from 'vue'
|
|
import { ClickOutside } from '@element-plus/directives'
|
|
import ElTooltip from '@element-plus/components/tooltip'
|
|
import ElScrollbar from '@element-plus/components/scrollbar'
|
|
import ElTag from '@element-plus/components/tag'
|
|
import ElIcon from '@element-plus/components/icon'
|
|
import { CHANGE_EVENT, UPDATE_MODEL_EVENT } from '@element-plus/constants'
|
|
import { flattedChildren, isArray, isObject } from '@element-plus/utils'
|
|
import { useCalcInputWidth } from '@element-plus/hooks'
|
|
import { useProps } from '@element-plus/components/select-v2/src/useProps'
|
|
import ElOption from './option.vue'
|
|
import ElSelectMenu from './select-dropdown.vue'
|
|
import { useSelect } from './useSelect'
|
|
import { selectKey } from './token'
|
|
import ElOptions from './options'
|
|
import { selectProps } from './select'
|
|
import ElOptionGroup from './option-group.vue'
|
|
|
|
import type { AppConfig, AppContext, VNode } from 'vue'
|
|
import type { SelectContext } from './type'
|
|
|
|
const COMPONENT_NAME = 'ElSelect'
|
|
|
|
type WarnHandler = AppConfig['warnHandler']
|
|
|
|
interface WarnHandlerRecord {
|
|
originalWarnHandler: WarnHandler
|
|
handler: WarnHandler
|
|
count: number
|
|
}
|
|
|
|
const warnHandlerMap = new WeakMap<AppContext, WarnHandlerRecord>()
|
|
|
|
const createSelectWarnHandler = (appContext: AppContext): WarnHandler => {
|
|
return (...args) => {
|
|
// Overrides warnings about slots not being executable outside of a render function.
|
|
// We call slot below just to simulate data when persist is false, this warning message should be ignored
|
|
const message = args[0]
|
|
if (
|
|
!message ||
|
|
(message.includes(
|
|
'Slot "default" invoked outside of the render function'
|
|
) &&
|
|
args[2]?.includes('ElTreeSelect'))
|
|
)
|
|
return
|
|
const original = warnHandlerMap.get(appContext)?.originalWarnHandler
|
|
if (original) {
|
|
original(...args)
|
|
return
|
|
}
|
|
// eslint-disable-next-line no-console
|
|
console.warn(...args)
|
|
}
|
|
}
|
|
|
|
const getWarnHandlerRecord = (appContext: AppContext): WarnHandlerRecord => {
|
|
let record = warnHandlerMap.get(appContext)
|
|
if (!record) {
|
|
record = {
|
|
originalWarnHandler: appContext.config.warnHandler,
|
|
handler: createSelectWarnHandler(appContext),
|
|
count: 0,
|
|
}
|
|
warnHandlerMap.set(appContext, record)
|
|
}
|
|
return record
|
|
}
|
|
export default defineComponent({
|
|
name: COMPONENT_NAME,
|
|
componentName: COMPONENT_NAME,
|
|
components: {
|
|
ElSelectMenu,
|
|
ElOption,
|
|
ElOptions,
|
|
ElOptionGroup,
|
|
ElTag,
|
|
ElScrollbar,
|
|
ElTooltip,
|
|
ElIcon,
|
|
},
|
|
directives: { ClickOutside },
|
|
props: selectProps,
|
|
emits: [
|
|
UPDATE_MODEL_EVENT,
|
|
CHANGE_EVENT,
|
|
'remove-tag',
|
|
'clear',
|
|
'visible-change',
|
|
'focus',
|
|
'blur',
|
|
'popup-scroll',
|
|
],
|
|
|
|
setup(props, { emit, slots }) {
|
|
const instance = getCurrentInstance()!
|
|
const warnRecord = getWarnHandlerRecord(instance.appContext)
|
|
warnRecord.count += 1
|
|
instance.appContext.config.warnHandler = warnRecord.handler
|
|
const modelValue = computed(() => {
|
|
const { modelValue: rawModelValue, multiple } = props
|
|
const fallback = multiple ? [] : undefined
|
|
// When it is array, we check if this is multi-select.
|
|
// Based on the result we get
|
|
if (isArray(rawModelValue)) {
|
|
return multiple ? rawModelValue : fallback
|
|
}
|
|
|
|
return multiple ? fallback : rawModelValue
|
|
})
|
|
|
|
const _props = reactive({
|
|
...toRefs(props),
|
|
modelValue,
|
|
})
|
|
|
|
const API = useSelect(_props, emit)
|
|
const { calculatorRef, inputStyle } = useCalcInputWidth()
|
|
const { getLabel, getValue, getOptions, getDisabled } = useProps(props)
|
|
|
|
const getOptionProps = (option: Record<string, any>) => ({
|
|
label: getLabel(option),
|
|
value: getValue(option),
|
|
disabled: getDisabled(option),
|
|
})
|
|
|
|
const flatTreeSelectData = (data: any[]) => {
|
|
return data.reduce((acc, item) => {
|
|
acc.push(item)
|
|
if (item.children && item.children.length > 0) {
|
|
acc.push(...flatTreeSelectData(item.children))
|
|
}
|
|
return acc
|
|
}, [])
|
|
}
|
|
|
|
const manuallyRenderSlots = (vnodes: VNode[] | undefined) => {
|
|
// After option rendering is completed, the useSelect internal state can collect the value of each option.
|
|
// If the persistent value is false, option will not be rendered by default, so in this case,
|
|
// manually render and load option data here.
|
|
const children = flattedChildren(vnodes || []) as VNode[]
|
|
children.forEach((item) => {
|
|
if (
|
|
isObject(item) &&
|
|
// @ts-expect-error
|
|
(item.type.name === 'ElOption' || item.type.name === 'ElTree')
|
|
) {
|
|
// @ts-expect-error
|
|
const _name = item.type.name
|
|
if (_name === 'ElTree') {
|
|
// tree-select component is a special case.
|
|
// So we need to handle it separately.
|
|
const treeData = item.props?.data || []
|
|
const flatData = flatTreeSelectData(treeData)
|
|
flatData.forEach((treeItem: any) => {
|
|
treeItem.currentLabel =
|
|
treeItem.label ||
|
|
(isObject(treeItem.value) ? '' : treeItem.value)
|
|
API.onOptionCreate(treeItem)
|
|
})
|
|
} else if (_name === 'ElOption') {
|
|
const obj = { ...item.props } as any
|
|
obj.currentLabel =
|
|
obj.label || (isObject(obj.value) ? '' : obj.value)
|
|
API.onOptionCreate(obj)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
watch(
|
|
() => [slots.default?.(), modelValue.value],
|
|
() => {
|
|
// When persistent is false and the dropdown is closed, the menu is unmounted.
|
|
// We should always re-hydrate option data from slots so labels stay in sync
|
|
// with dynamic option list updates. Skip only when persistent is true or
|
|
// when the dropdown is currently expanded (mounted options will manage themselves).
|
|
if (props.persistent || API.expanded.value) {
|
|
// If persistent is true, we don't need to manually render slots.
|
|
return
|
|
}
|
|
// Reset current options snapshot before re-collecting from slots.
|
|
API.states.options.clear()
|
|
manuallyRenderSlots(slots.default?.())
|
|
},
|
|
{
|
|
immediate: true,
|
|
}
|
|
)
|
|
|
|
provide(
|
|
selectKey,
|
|
reactive({
|
|
props: _props,
|
|
states: API.states,
|
|
selectRef: API.selectRef,
|
|
optionsArray: API.optionsArray,
|
|
setSelected: API.setSelected,
|
|
handleOptionSelect: API.handleOptionSelect,
|
|
onOptionCreate: API.onOptionCreate,
|
|
onOptionDestroy: API.onOptionDestroy,
|
|
}) satisfies SelectContext
|
|
)
|
|
|
|
const selectedLabel = computed(() => {
|
|
if (!props.multiple) {
|
|
return API.states.selectedLabel
|
|
}
|
|
return API.states.selected.map((i) => i.currentLabel as string)
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
// https://github.com/element-plus/element-plus/issues/21279
|
|
const record = warnHandlerMap.get(instance.appContext)
|
|
if (!record) return
|
|
record.count -= 1
|
|
if (record.count <= 0) {
|
|
instance.appContext.config.warnHandler = record.originalWarnHandler
|
|
warnHandlerMap.delete(instance.appContext)
|
|
}
|
|
})
|
|
|
|
return {
|
|
...API,
|
|
modelValue,
|
|
selectedLabel,
|
|
calculatorRef,
|
|
inputStyle,
|
|
getLabel,
|
|
getValue,
|
|
getOptions,
|
|
getDisabled,
|
|
getOptionProps,
|
|
}
|
|
},
|
|
})
|
|
</script>
|