mirror of
https://github.com/element-plus/element-plus.git
synced 2026-03-13 07:51:17 +08:00
* feat(components): [select]add collapse-tags-tooltip * feat(components): [select] collapse-tags-tooltip set width * feat(components): [select] collapse-tags-tooltip set fallback-placements * feat(components): [select] resolve conflicts * feat(components): [select] add v-if on tooltip
660 lines
18 KiB
Vue
660 lines
18 KiB
Vue
<template>
|
|
<div
|
|
ref="selectWrapper"
|
|
v-click-outside:[popperPaneRef]="handleClose"
|
|
:class="wrapperKls"
|
|
@click.stop="toggleMenu"
|
|
>
|
|
<el-tooltip
|
|
ref="tooltipRef"
|
|
v-model:visible="dropMenuVisible"
|
|
placement="bottom-start"
|
|
:teleported="compatTeleported"
|
|
:popper-class="[nsSelect.e('popper'), popperClass]"
|
|
:fallback-placements="['bottom-start', 'top-start', 'right', 'left']"
|
|
:effect="effect"
|
|
pure
|
|
trigger="click"
|
|
:transition="`${nsSelect.namespace.value}-zoom-in-top`"
|
|
:stop-popper-mouse-event="false"
|
|
:gpu-acceleration="false"
|
|
:persistent="persistent"
|
|
@show="handleMenuEnter"
|
|
>
|
|
<template #default>
|
|
<div class="select-trigger">
|
|
<div
|
|
v-if="multiple"
|
|
ref="tags"
|
|
:class="nsSelect.e('tags')"
|
|
:style="selectTagsStyle"
|
|
>
|
|
<span
|
|
v-if="collapseTags && selected.length"
|
|
:class="[
|
|
nsSelect.b('tags-wrapper'),
|
|
{ 'has-prefix': prefixWidth && selected.length },
|
|
]"
|
|
>
|
|
<el-tag
|
|
:closable="!selectDisabled && !selected[0].isDisabled"
|
|
:size="collapseTagSize"
|
|
:hit="selected[0].hitState"
|
|
:type="tagType"
|
|
disable-transitions
|
|
@close="deleteTag($event, selected[0])"
|
|
>
|
|
<span
|
|
:class="nsSelect.e('tags-text')"
|
|
:style="{ maxWidth: inputWidth - 123 + 'px' }"
|
|
>{{ selected[0].currentLabel }}</span
|
|
>
|
|
</el-tag>
|
|
<el-tag
|
|
v-if="selected.length > 1"
|
|
:closable="false"
|
|
:size="collapseTagSize"
|
|
:type="tagType"
|
|
disable-transitions
|
|
>
|
|
<el-tooltip
|
|
v-if="collapseTagsTooltip"
|
|
:disabled="dropMenuVisible"
|
|
:fallback-placements="['bottom', 'top', 'right', 'left']"
|
|
:effect="effect"
|
|
placement="bottom"
|
|
:teleported="false"
|
|
>
|
|
<template #default>
|
|
<span :class="nsSelect.e('tags-text')"
|
|
>+ {{ selected.length - 1 }}</span
|
|
>
|
|
</template>
|
|
<template #content>
|
|
<div :class="nsSelect.e('collapse-tags')">
|
|
<div
|
|
v-for="(item, idx) in selected"
|
|
:key="idx"
|
|
:class="nsSelect.e('collapse-tag')"
|
|
>
|
|
<el-tag
|
|
:key="getValueKey(item)"
|
|
class="in-tooltip"
|
|
:closable="!selectDisabled && !item.isDisabled"
|
|
:size="collapseTagSize"
|
|
:hit="item.hitState"
|
|
:type="tagType"
|
|
disable-transitions
|
|
:style="{ margin: '2px' }"
|
|
@close="deleteTag($event, item)"
|
|
>
|
|
<span
|
|
:class="nsSelect.e('tags-text')"
|
|
:style="{
|
|
maxWidth: inputWidth - 75 + 'px',
|
|
}"
|
|
>{{ item.currentLabel }}</span
|
|
>
|
|
</el-tag>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</el-tooltip>
|
|
<span v-else :class="nsSelect.e('tags-text')"
|
|
>+ {{ selected.length - 1 }}</span
|
|
>
|
|
</el-tag>
|
|
</span>
|
|
<!-- <div> -->
|
|
<transition v-if="!collapseTags" @after-leave="resetInputHeight">
|
|
<span
|
|
:class="[
|
|
nsSelect.b('tags-wrapper'),
|
|
{ 'has-prefix': prefixWidth && selected.length },
|
|
]"
|
|
>
|
|
<el-tag
|
|
v-for="item in selected"
|
|
:key="getValueKey(item)"
|
|
:closable="!selectDisabled && !item.isDisabled"
|
|
:size="collapseTagSize"
|
|
:hit="item.hitState"
|
|
:type="tagType"
|
|
disable-transitions
|
|
@close="deleteTag($event, item)"
|
|
>
|
|
<span
|
|
:class="nsSelect.e('tags-text')"
|
|
:style="{ maxWidth: inputWidth - 75 + 'px' }"
|
|
>{{ item.currentLabel }}</span
|
|
>
|
|
</el-tag>
|
|
</span>
|
|
</transition>
|
|
<!-- </div> -->
|
|
<input
|
|
v-if="filterable"
|
|
ref="input"
|
|
v-model="query"
|
|
type="text"
|
|
:class="[nsSelect.e('input'), nsSelect.is(selectSize)]"
|
|
:disabled="selectDisabled"
|
|
:autocomplete="autocomplete"
|
|
:style="{
|
|
marginLeft:
|
|
(prefixWidth && !selected.length) || tagInMultiLine
|
|
? `${prefixWidth}px`
|
|
: '',
|
|
flexGrow: 1,
|
|
width: `${inputLength / (inputWidth - 32)}%`,
|
|
maxWidth: `${inputWidth - 42}px`,
|
|
}"
|
|
@focus="handleFocus"
|
|
@blur="handleBlur"
|
|
@keyup="managePlaceholder"
|
|
@keydown="resetInputState"
|
|
@keydown.down.prevent="navigateOptions('next')"
|
|
@keydown.up.prevent="navigateOptions('prev')"
|
|
@keydown.esc.stop.prevent="visible = false"
|
|
@keydown.enter.stop.prevent="selectOption"
|
|
@keydown.delete="deletePrevTag"
|
|
@keydown.tab="visible = false"
|
|
@compositionstart="handleComposition"
|
|
@compositionupdate="handleComposition"
|
|
@compositionend="handleComposition"
|
|
@input="debouncedQueryChange"
|
|
/>
|
|
</div>
|
|
<el-input
|
|
:id="id"
|
|
ref="reference"
|
|
v-model="selectedLabel"
|
|
type="text"
|
|
:placeholder="currentPlaceholder"
|
|
:name="name"
|
|
:autocomplete="autocomplete"
|
|
:size="selectSize"
|
|
:disabled="selectDisabled"
|
|
:readonly="readonly"
|
|
:validate-event="false"
|
|
:class="[nsSelect.is('focus', visible)]"
|
|
:tabindex="multiple && filterable ? -1 : undefined"
|
|
@focus="handleFocus"
|
|
@blur="handleBlur"
|
|
@input="debouncedOnInputChange"
|
|
@paste="debouncedOnInputChange"
|
|
@compositionstart="handleComposition"
|
|
@compositionupdate="handleComposition"
|
|
@compositionend="handleComposition"
|
|
@keydown.down.stop.prevent="navigateOptions('next')"
|
|
@keydown.up.stop.prevent="navigateOptions('prev')"
|
|
@keydown.enter.stop.prevent="selectOption"
|
|
@keydown.esc.stop.prevent="visible = false"
|
|
@keydown.tab="visible = false"
|
|
@mouseenter="inputHovering = true"
|
|
@mouseleave="inputHovering = false"
|
|
>
|
|
<template v-if="$slots.prefix" #prefix>
|
|
<div
|
|
style="
|
|
height: 100%;
|
|
display: flex;
|
|
justify-content: center;
|
|
align-items: center;
|
|
"
|
|
>
|
|
<slot name="prefix"></slot>
|
|
</div>
|
|
</template>
|
|
<template #suffix>
|
|
<el-icon
|
|
v-if="iconComponent"
|
|
v-show="!showClose"
|
|
:class="[nsSelect.e('caret'), nsSelect.e('icon'), iconReverse]"
|
|
>
|
|
<component :is="iconComponent" />
|
|
</el-icon>
|
|
<el-icon
|
|
v-if="showClose && clearIcon"
|
|
:class="[nsSelect.e('caret'), nsSelect.e('icon')]"
|
|
@click="handleClearClick"
|
|
>
|
|
<component :is="clearIcon" />
|
|
</el-icon>
|
|
</template>
|
|
</el-input>
|
|
</div>
|
|
</template>
|
|
<template #content>
|
|
<el-select-menu>
|
|
<el-scrollbar
|
|
v-show="options.size > 0 && !loading"
|
|
ref="scrollbar"
|
|
tag="ul"
|
|
:wrap-class="nsSelect.be('dropdown', 'wrap')"
|
|
:view-class="nsSelect.be('dropdown', 'list')"
|
|
:class="[
|
|
nsSelect.is(
|
|
'empty',
|
|
!allowCreate && query && filteredOptionsCount === 0
|
|
),
|
|
]"
|
|
>
|
|
<el-option v-if="showNewOption" :value="query" :created="true" />
|
|
<slot></slot>
|
|
</el-scrollbar>
|
|
<template
|
|
v-if="
|
|
emptyText &&
|
|
(!allowCreate || loading || (allowCreate && options.size === 0))
|
|
"
|
|
>
|
|
<slot v-if="$slots.empty" name="empty"></slot>
|
|
<p v-else :class="nsSelect.be('dropdown', 'empty')">
|
|
{{ emptyText }}
|
|
</p>
|
|
</template>
|
|
</el-select-menu>
|
|
</template>
|
|
</el-tooltip>
|
|
</div>
|
|
</template>
|
|
|
|
<script lang="ts">
|
|
import {
|
|
toRefs,
|
|
defineComponent,
|
|
onMounted,
|
|
onBeforeUnmount,
|
|
nextTick,
|
|
reactive,
|
|
provide,
|
|
computed,
|
|
unref,
|
|
} from 'vue'
|
|
import { ClickOutside } from '@element-plus/directives'
|
|
import { useFocus, useLocale, useNamespace } from '@element-plus/hooks'
|
|
import ElInput from '@element-plus/components/input'
|
|
import ElTooltip, {
|
|
useTooltipContentProps,
|
|
} from '@element-plus/components/tooltip'
|
|
import ElScrollbar from '@element-plus/components/scrollbar'
|
|
import ElTag, { tagProps } from '@element-plus/components/tag'
|
|
import ElIcon from '@element-plus/components/icon'
|
|
import { useDeprecateAppendToBody } from '@element-plus/components/popper'
|
|
import { UPDATE_MODEL_EVENT, CHANGE_EVENT } from '@element-plus/constants'
|
|
import {
|
|
addResizeListener,
|
|
removeResizeListener,
|
|
isValidComponentSize,
|
|
} from '@element-plus/utils'
|
|
import { CircleClose, ArrowUp } from '@element-plus/icons-vue'
|
|
import ElOption from './option.vue'
|
|
import ElSelectMenu from './select-dropdown.vue'
|
|
import { useSelect, useSelectStates } from './useSelect'
|
|
import { selectKey } from './token'
|
|
|
|
import type { PropType, Component } from 'vue'
|
|
import type { ComponentSize } from '@element-plus/constants'
|
|
import type { SelectContext } from './token'
|
|
|
|
const COMPONENT_NAME = 'ElSelect'
|
|
export default defineComponent({
|
|
name: COMPONENT_NAME,
|
|
componentName: COMPONENT_NAME,
|
|
components: {
|
|
ElInput,
|
|
ElSelectMenu,
|
|
ElOption,
|
|
ElTag,
|
|
ElScrollbar,
|
|
ElTooltip,
|
|
ElIcon,
|
|
},
|
|
directives: { ClickOutside },
|
|
props: {
|
|
name: String,
|
|
id: String,
|
|
modelValue: {
|
|
type: [Array, String, Number, Boolean, Object],
|
|
default: undefined,
|
|
},
|
|
autocomplete: {
|
|
type: String,
|
|
default: 'off',
|
|
},
|
|
automaticDropdown: Boolean,
|
|
size: {
|
|
type: String as PropType<ComponentSize>,
|
|
validator: isValidComponentSize,
|
|
},
|
|
effect: {
|
|
type: String as PropType<'light' | 'dark' | string>,
|
|
default: 'light',
|
|
},
|
|
disabled: Boolean,
|
|
clearable: Boolean,
|
|
filterable: Boolean,
|
|
allowCreate: Boolean,
|
|
loading: Boolean,
|
|
popperClass: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
remote: Boolean,
|
|
loadingText: String,
|
|
noMatchText: String,
|
|
noDataText: String,
|
|
remoteMethod: Function,
|
|
filterMethod: Function,
|
|
multiple: Boolean,
|
|
multipleLimit: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
placeholder: {
|
|
type: String,
|
|
},
|
|
defaultFirstOption: Boolean,
|
|
reserveKeyword: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
valueKey: {
|
|
type: String,
|
|
default: 'value',
|
|
},
|
|
collapseTags: Boolean,
|
|
collapseTagsTooltip: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
popperAppendToBody: {
|
|
type: Boolean,
|
|
default: undefined,
|
|
},
|
|
teleported: useTooltipContentProps.teleported,
|
|
persistent: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
clearIcon: {
|
|
type: [String, Object] as PropType<string | Component>,
|
|
default: CircleClose,
|
|
},
|
|
fitInputWidth: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
suffixIcon: {
|
|
type: [String, Object] as PropType<string | Component>,
|
|
default: ArrowUp,
|
|
},
|
|
// eslint-disable-next-line vue/require-prop-types
|
|
tagType: { ...tagProps.type, default: 'info' },
|
|
},
|
|
emits: [
|
|
UPDATE_MODEL_EVENT,
|
|
CHANGE_EVENT,
|
|
'remove-tag',
|
|
'clear',
|
|
'visible-change',
|
|
'focus',
|
|
'blur',
|
|
],
|
|
|
|
setup(props, ctx) {
|
|
const nsSelect = useNamespace('select')
|
|
const nsInput = useNamespace('input')
|
|
const { t } = useLocale()
|
|
const states = useSelectStates(props)
|
|
const {
|
|
optionsArray,
|
|
selectSize,
|
|
readonly,
|
|
handleResize,
|
|
collapseTagSize,
|
|
debouncedOnInputChange,
|
|
debouncedQueryChange,
|
|
deletePrevTag,
|
|
deleteTag,
|
|
deleteSelected,
|
|
handleOptionSelect,
|
|
scrollToOption,
|
|
setSelected,
|
|
resetInputHeight,
|
|
managePlaceholder,
|
|
showClose,
|
|
selectDisabled,
|
|
iconComponent,
|
|
iconReverse,
|
|
showNewOption,
|
|
emptyText,
|
|
toggleLastOptionHitState,
|
|
resetInputState,
|
|
handleComposition,
|
|
onOptionCreate,
|
|
onOptionDestroy,
|
|
handleMenuEnter,
|
|
handleFocus,
|
|
blur,
|
|
handleBlur,
|
|
handleClearClick,
|
|
handleClose,
|
|
toggleMenu,
|
|
selectOption,
|
|
getValueKey,
|
|
navigateOptions,
|
|
dropMenuVisible,
|
|
|
|
reference,
|
|
input,
|
|
tooltipRef,
|
|
tags,
|
|
selectWrapper,
|
|
scrollbar,
|
|
queryChange,
|
|
groupQueryChange,
|
|
} = useSelect(props, states, ctx)
|
|
|
|
const { focus } = useFocus(reference)
|
|
|
|
const {
|
|
inputWidth,
|
|
selected,
|
|
inputLength,
|
|
filteredOptionsCount,
|
|
visible,
|
|
softFocus,
|
|
selectedLabel,
|
|
hoverIndex,
|
|
query,
|
|
inputHovering,
|
|
currentPlaceholder,
|
|
menuVisibleOnFocus,
|
|
isOnComposition,
|
|
isSilentBlur,
|
|
options,
|
|
cachedOptions,
|
|
optionsCount,
|
|
prefixWidth,
|
|
tagInMultiLine,
|
|
} = toRefs(states)
|
|
|
|
const wrapperKls = computed(() => {
|
|
const classList = [nsSelect.b()]
|
|
const _selectSize = unref(selectSize)
|
|
if (_selectSize) {
|
|
classList.push(nsSelect.m(_selectSize))
|
|
}
|
|
if (props.disabled) {
|
|
classList.push(nsSelect.m('disabled'))
|
|
}
|
|
return classList
|
|
})
|
|
|
|
const selectTagsStyle = computed(() => ({
|
|
maxWidth: `${unref(inputWidth) - 32}px`,
|
|
width: '100%',
|
|
}))
|
|
|
|
provide(
|
|
selectKey,
|
|
reactive({
|
|
props,
|
|
options,
|
|
optionsArray,
|
|
cachedOptions,
|
|
optionsCount,
|
|
filteredOptionsCount,
|
|
hoverIndex,
|
|
handleOptionSelect,
|
|
onOptionCreate,
|
|
onOptionDestroy,
|
|
selectWrapper,
|
|
selected,
|
|
setSelected,
|
|
queryChange,
|
|
groupQueryChange,
|
|
}) as unknown as SelectContext
|
|
)
|
|
|
|
onMounted(() => {
|
|
states.cachedPlaceHolder = currentPlaceholder.value =
|
|
props.placeholder || t('el.select.placeholder')
|
|
if (
|
|
props.multiple &&
|
|
Array.isArray(props.modelValue) &&
|
|
props.modelValue.length > 0
|
|
) {
|
|
currentPlaceholder.value = ''
|
|
}
|
|
addResizeListener(selectWrapper.value as any, handleResize)
|
|
if (reference.value && reference.value.$el) {
|
|
const sizeMap = {
|
|
large: 36,
|
|
default: 32,
|
|
small: 28,
|
|
}
|
|
const input = reference.value.input as HTMLInputElement
|
|
states.initialInputHeight =
|
|
input.getBoundingClientRect().height || sizeMap[selectSize.value]
|
|
}
|
|
if (props.remote && props.multiple) {
|
|
resetInputHeight()
|
|
}
|
|
nextTick(() => {
|
|
if (!reference.value) return
|
|
if (reference.value.$el) {
|
|
inputWidth.value = reference.value.$el.getBoundingClientRect().width
|
|
}
|
|
if (ctx.slots.prefix) {
|
|
const inputChildNodes = reference.value.$el.childNodes
|
|
const input = (Array.from(inputChildNodes) as HTMLElement[]).find(
|
|
(item) => item.tagName === 'INPUT'
|
|
)
|
|
const prefix = reference.value.$el.querySelector(
|
|
`.${nsInput.e('prefix')}`
|
|
)
|
|
prefixWidth.value = Math.max(
|
|
prefix.getBoundingClientRect().width + 5,
|
|
30
|
|
)
|
|
if (states.prefixWidth) {
|
|
input.style.paddingLeft = `${Math.max(states.prefixWidth, 30)}px`
|
|
}
|
|
}
|
|
})
|
|
setSelected()
|
|
})
|
|
|
|
onBeforeUnmount(() => {
|
|
removeResizeListener(selectWrapper.value as any, handleResize)
|
|
})
|
|
|
|
if (props.multiple && !Array.isArray(props.modelValue)) {
|
|
ctx.emit(UPDATE_MODEL_EVENT, [])
|
|
}
|
|
if (!props.multiple && Array.isArray(props.modelValue)) {
|
|
ctx.emit(UPDATE_MODEL_EVENT, '')
|
|
}
|
|
|
|
const popperPaneRef = computed(() => {
|
|
return tooltipRef.value?.popperRef?.contentRef
|
|
})
|
|
|
|
const { compatTeleported } = useDeprecateAppendToBody(
|
|
COMPONENT_NAME,
|
|
'popperAppendToBody'
|
|
)
|
|
|
|
return {
|
|
tagInMultiLine,
|
|
prefixWidth,
|
|
selectSize,
|
|
readonly,
|
|
handleResize,
|
|
collapseTagSize,
|
|
debouncedOnInputChange,
|
|
debouncedQueryChange,
|
|
deletePrevTag,
|
|
deleteTag,
|
|
deleteSelected,
|
|
handleOptionSelect,
|
|
scrollToOption,
|
|
inputWidth,
|
|
selected,
|
|
inputLength,
|
|
filteredOptionsCount,
|
|
visible,
|
|
softFocus,
|
|
selectedLabel,
|
|
hoverIndex,
|
|
query,
|
|
inputHovering,
|
|
currentPlaceholder,
|
|
menuVisibleOnFocus,
|
|
isOnComposition,
|
|
isSilentBlur,
|
|
options,
|
|
resetInputHeight,
|
|
managePlaceholder,
|
|
showClose,
|
|
selectDisabled,
|
|
iconComponent,
|
|
iconReverse,
|
|
showNewOption,
|
|
emptyText,
|
|
toggleLastOptionHitState,
|
|
resetInputState,
|
|
handleComposition,
|
|
handleMenuEnter,
|
|
handleFocus,
|
|
blur,
|
|
handleBlur,
|
|
handleClearClick,
|
|
handleClose,
|
|
toggleMenu,
|
|
selectOption,
|
|
getValueKey,
|
|
navigateOptions,
|
|
dropMenuVisible,
|
|
focus,
|
|
|
|
reference,
|
|
input,
|
|
tooltipRef,
|
|
popperPaneRef,
|
|
tags,
|
|
selectWrapper,
|
|
scrollbar,
|
|
|
|
wrapperKls,
|
|
selectTagsStyle,
|
|
compatTeleported,
|
|
nsSelect,
|
|
}
|
|
},
|
|
})
|
|
</script>
|