Files
element-plus/packages/components/select/src/select.vue
Alan Wang 57d08f9a78 feat(components): [select]add collapse-tags-tooltip (#6245)
* 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
2022-03-11 15:56:23 +08:00

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>