问题表现:
el-select 下拉框不跟随页面滚动、不跟随滚动条滚动、页面滚动时位置错乱。
问题原因
el-select 是默认插入到 body 元素下面,通过绝对定位获取当前 select 的位置生成 top、left 的位置。但是当滚动的元素不是当前插入的元素时,就不会跟随页面而滚动。
可以通过:popper-append-to-body="false"
让下拉列表渲染到 select 的位置,而不是body 的最后。
但是渲染到当前 select 层级的话会撑高当前 select 所在行的高度,在某些场景下还会出现滚动条。
解决方法
核心方案是通过监听距离 el-select 最近的滚动事件,去重新计算 select poper 的位置来解决。
在项目目录components 目录下创建 select 目录,复制重写 select 中的方法。
index.js
javascript
import Select from './src/select';
Select.install = function(Vue) {
Vue.component(Select.name, Select)
Vue.prototype.$ELEMENTPOPPER = {
latestScrollableDom: '.app'
}
}
export default Select
select-dropdown.vue
vue
<template>
<div
class="el-select"
:class="[selectSize ? 'el-select--' + selectSize : '']"
@click.stop="toggleMenu"
v-clickoutside="handleClose"
>
<div
class="el-select__tags"
v-if="multiple"
ref="tags"
:style="{ 'max-width': inputWidth - 32 + 'px', width: '100%' }"
>
<span v-if="collapseTags && selected.length">
<el-tag
:closable="!selectDisabled"
:size="collapseTagSize"
:hit="selected[0].hitState"
type="info"
@close="deleteTag($event, selected[0])"
disable-transitions
>
<span class="el-select__tags-text">{{
selected[0].currentLabel
}}</span>
</el-tag>
<el-tag
v-if="selected.length > 1"
:closable="false"
:size="collapseTagSize"
type="info"
disable-transitions
>
<span class="el-select__tags-text">+ {{ selected.length - 1 }}</span>
</el-tag>
</span>
<transition-group @after-leave="resetInputHeight" v-if="!collapseTags">
<el-tag
v-for="item in selected"
:key="getValueKey(item)"
:closable="!selectDisabled"
:size="collapseTagSize"
:hit="item.hitState"
type="info"
@close="deleteTag($event, item)"
disable-transitions
>
<span class="el-select__tags-text">{{ item.currentLabel }}</span>
</el-tag>
</transition-group>
<input
type="text"
class="el-select__input"
:class="[selectSize ? `is-${selectSize}` : '']"
:disabled="selectDisabled"
:autocomplete="autoComplete || autocomplete"
@focus="handleFocus"
@blur="softFocus = false"
@keyup="managePlaceholder"
@keydown="resetInputState"
@keydown.down.prevent="handleNavigate('next')"
@keydown.up.prevent="handleNavigate('prev')"
@keydown.enter.prevent="selectOption"
@keydown.esc.stop.prevent="visible = false"
@keydown.delete="deletePrevTag"
@keydown.tab="visible = false"
@compositionstart="handleComposition"
@compositionupdate="handleComposition"
@compositionend="handleComposition"
v-model="query"
@input="debouncedQueryChange"
v-if="filterable"
:style="{
'flex-grow': '1',
width: inputLength / (inputWidth - 32) + '%',
'max-width': inputWidth - 42 + 'px'
}"
ref="input"
/>
</div>
<el-input
ref="reference"
v-model="selectedLabel"
type="text"
:placeholder="currentPlaceholder"
:name="name"
:id="id"
:autocomplete="autoComplete || autocomplete"
:size="selectSize"
:disabled="selectDisabled"
:readonly="readonly"
:validate-event="false"
:class="{ 'is-focus': visible }"
:tabindex="multiple && filterable ? '-1' : null"
@focus="handleFocus"
@blur="handleBlur"
@input="debouncedOnInputChange"
@keydown.native.down.stop.prevent="handleNavigate('next')"
@keydown.native.up.stop.prevent="handleNavigate('prev')"
@keydown.native.enter.prevent="selectOption"
@keydown.native.esc.stop.prevent="visible = false"
@keydown.native.tab="visible = false"
@compositionstart="handleComposition"
@compositionupdate="handleComposition"
@compositionend="handleComposition"
@mouseenter.native="inputHovering = true"
@mouseleave.native="inputHovering = false"
>
<template slot="prefix" v-if="$slots.prefix">
<slot name="prefix"></slot>
</template>
<template slot="suffix">
<i
v-show="!showClose"
:class="[
'el-select__caret',
'el-input__icon',
'el-icon-' + iconClass
]"
></i>
<i
v-if="showClose"
class="el-select__caret el-input__icon el-icon-circle-close"
@click="handleClearClick"
></i>
</template>
</el-input>
<transition
name="el-zoom-in-top"
@before-enter="handleMenuEnter"
@after-leave="doDestroy"
>
<el-select-menu
ref="popper"
:append-to-body="popperAppendToBody"
// 新添加
:latestScrollableDom="latestScrollableDom"
v-show="visible && emptyText !== false"
>
<el-scrollbar
tag="ul"
wrap-class="el-select-dropdown__wrap"
view-class="el-select-dropdown__list"
ref="scrollbar"
:class="{
'is-empty': !allowCreate && query && filteredOptionsCount === 0
}"
v-show="options.length > 0 && !loading"
>
<el-option :value="query" created v-if="showNewOption"> </el-option>
<slot></slot>
</el-scrollbar>
<template
v-if="
emptyText &&
(!allowCreate || loading || (allowCreate && options.length === 0))
"
>
<slot name="empty" v-if="$slots.empty"></slot>
<p class="el-select-dropdown__empty" v-else>
{{ emptyText }}
</p>
</template>
</el-select-menu>
</transition>
</div>
</template>
<script type="text/babel">
import Emitter from 'element-ui/src/mixins/emitter'
import Focus from 'element-ui/src/mixins/focus'
import Locale from 'element-ui/src/mixins/locale'
import ElInput from 'element-ui/packages/input'
import ElSelectMenu from './select-dropdown.vue'
import ElOption from 'element-ui/packages/select/src/option.vue'
import ElTag from 'element-ui/packages/tag'
import ElScrollbar from 'element-ui/packages/scrollbar'
import debounce from 'throttle-debounce/debounce'
import Clickoutside from 'element-ui/src/utils/clickoutside'
import {
addResizeListener,
removeResizeListener
} from 'element-ui/src/utils/resize-event'
import scrollIntoView from 'element-ui/src/utils/scroll-into-view'
import {
getValueByPath,
valueEquals,
isIE,
isEdge
} from 'element-ui/src/utils/util'
import NavigationMixin from 'element-ui/packages/select/src/navigation-mixin'
import { isKorean } from 'element-ui/src/utils/shared'
export default {
mixins: [Emitter, Locale, Focus('reference'), NavigationMixin],
name: 'ElSelect',
componentName: 'ElSelect',
inject: {
elForm: {
default: ''
},
elFormItem: {
default: ''
}
},
provide() {
return {
select: this
}
},
computed: {
_elFormItemSize() {
return (this.elFormItem || {}).elFormItemSize
},
readonly() {
return (
!this.filterable ||
this.multiple ||
(!isIE() && !isEdge() && !this.visible)
)
},
showClose() {
let hasValue = this.multiple
? Array.isArray(this.value) && this.value.length > 0
: this.value !== undefined && this.value !== null && this.value !== ''
let criteria =
this.clearable && !this.selectDisabled && this.inputHovering && hasValue
return criteria
},
iconClass() {
return this.remote && this.filterable
? ''
: this.visible
? 'arrow-up is-reverse'
: 'arrow-up'
},
debounce() {
return this.remote ? 300 : 0
},
emptyText() {
if (this.loading) {
return this.loadingText || this.t('el.select.loading')
} else {
if (this.remote && this.query === '' && this.options.length === 0)
return false
if (
this.filterable &&
this.query &&
this.options.length > 0 &&
this.filteredOptionsCount === 0
) {
return this.noMatchText || this.t('el.select.noMatch')
}
if (this.options.length === 0) {
return this.noDataText || this.t('el.select.noData')
}
}
return null
},
showNewOption() {
let hasExistingOption = this.options
.filter((option) => !option.created)
.some((option) => option.currentLabel === this.query)
return (
this.filterable &&
this.allowCreate &&
this.query !== '' &&
!hasExistingOption
)
},
selectSize() {
return this.size || this._elFormItemSize || (this.$ELEMENT || {}).size
},
selectDisabled() {
return this.disabled || (this.elForm || {}).disabled
},
collapseTagSize() {
return ['small', 'mini'].indexOf(this.selectSize) > -1 ? 'mini' : 'small'
},
propPlaceholder() {
return typeof this.placeholder !== 'undefined'
? this.placeholder
: this.t('el.select.placeholder')
}
},
components: {
ElInput,
ElSelectMenu,
ElOption,
ElTag,
ElScrollbar
},
directives: { Clickoutside },
props: {
name: String,
id: String,
value: {
required: true
},
autocomplete: {
type: String,
default: 'off'
},
/** @Deprecated in next major version */
autoComplete: {
type: String,
validator(val) {
process.env.NODE_ENV !== 'production' &&
console.warn(
"[Element Warn][Select]'auto-complete' property will be deprecated in next major version. please use 'autocomplete' instead."
)
return true
}
},
automaticDropdown: Boolean,
size: String,
disabled: Boolean,
clearable: Boolean,
filterable: Boolean,
allowCreate: Boolean,
loading: Boolean,
popperClass: String,
remote: Boolean,
loadingText: String,
noMatchText: String,
noDataText: String,
remoteMethod: Function,
filterMethod: Function,
multiple: Boolean,
multipleLimit: {
type: Number,
default: 0
},
placeholder: {
type: String,
required: false
},
defaultFirstOption: Boolean,
reserveKeyword: Boolean,
valueKey: {
type: String,
default: 'value'
},
collapseTags: Boolean,
popperAppendToBody: {
type: Boolean,
default: true
},
latestScrollableDom: String
},
data() {
return {
options: [],
cachedOptions: [],
createdLabel: null,
createdSelected: false,
selected: this.multiple ? [] : {},
inputLength: 20,
inputWidth: 0,
initialInputHeight: 0,
cachedPlaceHolder: '',
optionsCount: 0,
filteredOptionsCount: 0,
visible: false,
softFocus: false,
selectedLabel: '',
hoverIndex: -1,
query: '',
previousQuery: null,
inputHovering: false,
currentPlaceholder: '',
menuVisibleOnFocus: false,
isOnComposition: false,
isSilentBlur: false
}
},
watch: {
selectDisabled() {
this.$nextTick(() => {
this.resetInputHeight()
})
},
propPlaceholder(val) {
this.cachedPlaceHolder = this.currentPlaceholder = val
},
value(val, oldVal) {
if (this.multiple) {
this.resetInputHeight()
if (
(val && val.length > 0) ||
(this.$refs.input && this.query !== '')
) {
this.currentPlaceholder = ''
} else {
this.currentPlaceholder = this.cachedPlaceHolder
}
if (this.filterable && !this.reserveKeyword) {
this.query = ''
this.handleQueryChange(this.query)
}
}
this.setSelected()
if (this.filterable && !this.multiple) {
this.inputLength = 20
}
if (!valueEquals(val, oldVal)) {
this.dispatch('ElFormItem', 'el.form.change', val)
}
},
visible(val) {
if (!val) {
this.broadcast('ElSelectDropdown', 'destroyPopper')
if (this.$refs.input) {
this.$refs.input.blur()
}
this.query = ''
this.previousQuery = null
this.selectedLabel = ''
this.inputLength = 20
this.menuVisibleOnFocus = false
this.resetHoverIndex()
this.$nextTick(() => {
if (
this.$refs.input &&
this.$refs.input.value === '' &&
this.selected.length === 0
) {
this.currentPlaceholder = this.cachedPlaceHolder
}
})
if (!this.multiple) {
if (this.selected) {
if (
this.filterable &&
this.allowCreate &&
this.createdSelected &&
this.createdLabel
) {
this.selectedLabel = this.createdLabel
} else {
this.selectedLabel = this.selected.currentLabel
}
if (this.filterable) this.query = this.selectedLabel
}
if (this.filterable) {
this.currentPlaceholder = this.cachedPlaceHolder
}
}
} else {
this.broadcast('ElSelectDropdown', 'updatePopper')
if (this.filterable) {
this.query = this.remote ? '' : this.selectedLabel
this.handleQueryChange(this.query)
if (this.multiple) {
this.$refs.input.focus()
} else {
if (!this.remote) {
this.broadcast('ElOption', 'queryChange', '')
this.broadcast('ElOptionGroup', 'queryChange')
}
if (this.selectedLabel) {
this.currentPlaceholder = this.selectedLabel
this.selectedLabel = ''
}
}
}
}
this.$emit('visible-change', val)
},
options() {
if (this.$isServer) return
this.$nextTick(() => {
this.broadcast('ElSelectDropdown', 'updatePopper')
})
if (this.multiple) {
this.resetInputHeight()
}
let inputs = this.$el.querySelectorAll('input')
if ([].indexOf.call(inputs, document.activeElement) === -1) {
this.setSelected()
}
if (
this.defaultFirstOption &&
(this.filterable || this.remote) &&
this.filteredOptionsCount
) {
this.checkDefaultFirstOption()
}
}
},
methods: {
handleNavigate(direction) {
if (this.isOnComposition) return
this.navigateOptions(direction)
},
handleComposition(event) {
const text = event.target.value
if (event.type === 'compositionend') {
this.isOnComposition = false
this.$nextTick((_) => this.handleQueryChange(text))
} else {
const lastCharacter = text[text.length - 1] || ''
this.isOnComposition = !isKorean(lastCharacter)
}
},
handleQueryChange(val) {
if (this.previousQuery === val || this.isOnComposition) return
if (
this.previousQuery === null &&
(typeof this.filterMethod === 'function' ||
typeof this.remoteMethod === 'function')
) {
this.previousQuery = val
return
}
this.previousQuery = val
this.$nextTick(() => {
if (this.visible) this.broadcast('ElSelectDropdown', 'updatePopper')
})
this.hoverIndex = -1
if (this.multiple && this.filterable) {
this.$nextTick(() => {
const length = this.$refs.input.value.length * 15 + 20
this.inputLength = this.collapseTags ? Math.min(50, length) : length
this.managePlaceholder()
this.resetInputHeight()
})
}
if (this.remote && typeof this.remoteMethod === 'function') {
this.hoverIndex = -1
this.remoteMethod(val)
} else if (typeof this.filterMethod === 'function') {
this.filterMethod(val)
this.broadcast('ElOptionGroup', 'queryChange')
} else {
this.filteredOptionsCount = this.optionsCount
this.broadcast('ElOption', 'queryChange', val)
this.broadcast('ElOptionGroup', 'queryChange')
}
if (
this.defaultFirstOption &&
(this.filterable || this.remote) &&
this.filteredOptionsCount
) {
this.checkDefaultFirstOption()
}
},
scrollToOption(option) {
const target =
Array.isArray(option) && option[0] ? option[0].$el : option.$el
if (this.$refs.popper && target) {
const menu = this.$refs.popper.$el.querySelector(
'.el-select-dropdown__wrap'
)
scrollIntoView(menu, target)
}
this.$refs.scrollbar && this.$refs.scrollbar.handleScroll()
},
handleMenuEnter() {
this.$nextTick(() => this.scrollToOption(this.selected))
},
emitChange(val) {
if (!valueEquals(this.value, val)) {
this.$emit('change', val)
}
},
getOption(value) {
let option
const isObject =
Object.prototype.toString.call(value).toLowerCase() ===
'[object object]'
const isNull =
Object.prototype.toString.call(value).toLowerCase() === '[object null]'
const isUndefined =
Object.prototype.toString.call(value).toLowerCase() ===
'[object undefined]'
for (let i = this.cachedOptions.length - 1; i >= 0; i--) {
const cachedOption = this.cachedOptions[i]
const isEqual = isObject
? getValueByPath(cachedOption.value, this.valueKey) ===
getValueByPath(value, this.valueKey)
: cachedOption.value === value
if (isEqual) {
option = cachedOption
break
}
}
if (option) return option
const label = !isObject && !isNull && !isUndefined ? String(value) : ''
let newOption = {
value: value,
currentLabel: label
}
if (this.multiple) {
newOption.hitState = false
}
return newOption
},
setSelected() {
if (!this.multiple) {
let option = this.getOption(this.value)
if (option.created) {
this.createdLabel = option.currentLabel
this.createdSelected = true
} else {
this.createdSelected = false
}
this.selectedLabel = option.currentLabel
this.selected = option
if (this.filterable) this.query = this.selectedLabel
return
}
let result = []
if (Array.isArray(this.value)) {
this.value.forEach((value) => {
result.push(this.getOption(value))
})
}
this.selected = result
this.$nextTick(() => {
this.resetInputHeight()
})
},
handleFocus(event) {
if (!this.softFocus) {
if (this.automaticDropdown || this.filterable) {
if (this.filterable && !this.visible) {
this.menuVisibleOnFocus = true
}
this.visible = true
}
this.$emit('focus', event)
} else {
this.softFocus = false
}
},
blur() {
this.visible = false
this.$refs.reference.blur()
},
handleBlur(event) {
setTimeout(() => {
if (this.isSilentBlur) {
this.isSilentBlur = false
} else {
this.$emit('blur', event)
}
}, 50)
this.softFocus = false
},
handleClearClick(event) {
this.deleteSelected(event)
},
doDestroy() {
this.$refs.popper && this.$refs.popper.doDestroy()
},
handleClose() {
this.visible = false
},
toggleLastOptionHitState(hit) {
if (!Array.isArray(this.selected)) return
const option = this.selected[this.selected.length - 1]
if (!option) return
if (hit === true || hit === false) {
option.hitState = hit
return hit
}
option.hitState = !option.hitState
return option.hitState
},
deletePrevTag(e) {
if (e.target.value.length <= 0 && !this.toggleLastOptionHitState()) {
const value = this.value.slice()
value.pop()
this.$emit('input', value)
this.emitChange(value)
}
},
managePlaceholder() {
if (this.currentPlaceholder !== '') {
this.currentPlaceholder = this.$refs.input.value
? ''
: this.cachedPlaceHolder
}
},
resetInputState(e) {
if (e.keyCode !== 8) this.toggleLastOptionHitState(false)
this.inputLength = this.$refs.input.value.length * 15 + 20
this.resetInputHeight()
},
resetInputHeight() {
if (this.collapseTags && !this.filterable) return
this.$nextTick(() => {
if (!this.$refs.reference) return
let inputChildNodes = this.$refs.reference.$el.childNodes
let input = [].filter.call(
inputChildNodes,
(item) => item.tagName === 'INPUT'
)[0]
const tags = this.$refs.tags
const tagsHeight = tags
? Math.round(tags.getBoundingClientRect().height)
: 0
const sizeInMap = this.initialInputHeight || 40
input.style.height =
this.selected.length === 0
? sizeInMap + 'px'
: Math.max(
tags ? tagsHeight + (tagsHeight > sizeInMap ? 6 : 0) : 0,
sizeInMap
) + 'px'
if (this.visible && this.emptyText !== false) {
this.broadcast('ElSelectDropdown', 'updatePopper')
}
})
},
resetHoverIndex() {
setTimeout(() => {
if (!this.multiple) {
this.hoverIndex = this.options.indexOf(this.selected)
} else {
if (this.selected.length > 0) {
this.hoverIndex = Math.min.apply(
null,
this.selected.map((item) => this.options.indexOf(item))
)
} else {
this.hoverIndex = -1
}
}
}, 300)
},
handleOptionSelect(option, byClick) {
if (this.multiple) {
const value = (this.value || []).slice()
const optionIndex = this.getValueIndex(value, option.value)
if (optionIndex > -1) {
value.splice(optionIndex, 1)
} else if (
this.multipleLimit <= 0 ||
value.length < this.multipleLimit
) {
value.push(option.value)
}
this.$emit('input', value)
this.emitChange(value)
if (option.created) {
this.query = ''
this.handleQueryChange('')
this.inputLength = 20
}
if (this.filterable) this.$refs.input.focus()
} else {
this.$emit('input', option.value)
this.emitChange(option.value)
this.visible = false
}
this.isSilentBlur = byClick
this.setSoftFocus()
if (this.visible) return
this.$nextTick(() => {
this.scrollToOption(option)
})
},
setSoftFocus() {
this.softFocus = true
const input = this.$refs.input || this.$refs.reference
if (input) {
input.focus()
}
},
getValueIndex(arr = [], value) {
const isObject =
Object.prototype.toString.call(value).toLowerCase() ===
'[object object]'
if (!isObject) {
return arr.indexOf(value)
} else {
const valueKey = this.valueKey
let index = -1
arr.some((item, i) => {
if (
getValueByPath(item, valueKey) === getValueByPath(value, valueKey)
) {
index = i
return true
}
return false
})
return index
}
},
toggleMenu() {
if (!this.selectDisabled) {
if (this.menuVisibleOnFocus) {
this.menuVisibleOnFocus = false
} else {
this.visible = !this.visible
}
if (this.visible) {
;(this.$refs.input || this.$refs.reference).focus()
}
}
},
selectOption() {
if (!this.visible) {
this.toggleMenu()
} else {
if (this.options[this.hoverIndex]) {
this.handleOptionSelect(this.options[this.hoverIndex])
}
}
},
deleteSelected(event) {
event.stopPropagation()
const value = this.multiple ? [] : ''
this.$emit('input', value)
this.emitChange(value)
this.visible = false
this.$emit('clear')
},
deleteTag(event, tag) {
let index = this.selected.indexOf(tag)
if (index > -1 && !this.selectDisabled) {
const value = this.value.slice()
value.splice(index, 1)
this.$emit('input', value)
this.emitChange(value)
this.$emit('remove-tag', tag.value)
}
event.stopPropagation()
},
onInputChange() {
if (this.filterable && this.query !== this.selectedLabel) {
this.query = this.selectedLabel
this.handleQueryChange(this.query)
}
},
onOptionDestroy(index) {
if (index > -1) {
this.optionsCount--
this.filteredOptionsCount--
this.options.splice(index, 1)
}
},
resetInputWidth() {
this.inputWidth = this.$refs.reference.$el.getBoundingClientRect().width
},
handleResize() {
this.resetInputWidth()
if (this.multiple) this.resetInputHeight()
},
checkDefaultFirstOption() {
this.hoverIndex = -1
// highlight the created option
let hasCreated = false
for (let i = this.options.length - 1; i >= 0; i--) {
if (this.options[i].created) {
hasCreated = true
this.hoverIndex = i
break
}
}
if (hasCreated) return
for (let i = 0; i !== this.options.length; ++i) {
const option = this.options[i]
if (this.query) {
// highlight first options that passes the filter
if (!option.disabled && !option.groupDisabled && option.visible) {
this.hoverIndex = i
break
}
} else {
// highlight currently selected option
if (option.itemSelected) {
this.hoverIndex = i
break
}
}
}
},
getValueKey(item) {
if (
Object.prototype.toString.call(item.value).toLowerCase() !==
'[object object]'
) {
return item.value
} else {
return getValueByPath(item.value, this.valueKey)
}
}
},
created() {
this.cachedPlaceHolder = this.currentPlaceholder = this.propPlaceholder
if (this.multiple && !Array.isArray(this.value)) {
this.$emit('input', [])
}
if (!this.multiple && Array.isArray(this.value)) {
this.$emit('input', '')
}
this.debouncedOnInputChange = debounce(this.debounce, () => {
this.onInputChange()
})
this.debouncedQueryChange = debounce(this.debounce, (e) => {
this.handleQueryChange(e.target.value)
})
this.$on('handleOptionClick', this.handleOptionSelect)
this.$on('setSelected', this.setSelected)
},
mounted() {
if (this.multiple && Array.isArray(this.value) && this.value.length > 0) {
this.currentPlaceholder = ''
}
addResizeListener(this.$el, this.handleResize)
const reference = this.$refs.reference
if (reference && reference.$el) {
const sizeMap = {
medium: 36,
small: 32,
mini: 28
}
const input = reference.$el.querySelector('input')
this.initialInputHeight =
input.getBoundingClientRect().height || sizeMap[this.selectSize]
}
if (this.remote && this.multiple) {
this.resetInputHeight()
}
this.$nextTick(() => {
if (reference && reference.$el) {
this.inputWidth = reference.$el.getBoundingClientRect().width
}
})
this.setSelected()
},
beforeDestroy() {
if (this.$el && this.handleResize)
removeResizeListener(this.$el, this.handleResize)
}
}
</script>
select.vue
vue
<template>
<div
class="el-select-dropdown el-popper"
:class="[{ 'is-multiple': $parent.multiple }, popperClass]"
:style="{ minWidth: minWidth }"
>
<slot></slot>
</div>
</template>
<script type="text/babel">
import Popper from './vue-popper'
export default {
name: 'ElSelectDropdown',
componentName: 'ElSelectDropdown',
mixins: [Popper],
props: {
placement: {
default: 'bottom-start'
},
boundariesPadding: {
default: 0
},
popperOptions: {
default() {
return {
gpuAcceleration: false
}
}
},
visibleArrow: {
default: true
},
appendToBody: {
type: Boolean,
default: true
}
},
data() {
return {
minWidth: ''
}
},
computed: {
popperClass() {
return this.$parent.popperClass
}
},
watch: {
'$parent.inputWidth'() {
this.minWidth = this.$parent.$el.getBoundingClientRect().width + 'px'
}
},
mounted() {
this.referenceElm = this.$parent.$refs.reference.$el
this.$parent.popperElm = this.popperElm = this.$el
this.$on('updatePopper', () => {
if (this.$parent.visible) this.updatePopper()
})
this.$on('destroyPopper', this.destroyPopper)
}
}
</script>
vue-popper.js
javascript
import Vue from 'vue'
import { PopupManager } from 'element-ui/src/utils/popup'
const PopperJS = Vue.prototype.$isServer ? function () {} : require('element-ui/src/utils/popper')
const stop = (e) => e.stopPropagation()
/**
* @param {HTMLElement} [reference=$refs.reference] - The reference element used to position the popper.
* @param {HTMLElement} [popper=$refs.popper] - The HTML element used as popper, or a configuration used to generate the popper.
* @param {String} [placement=button] - Placement of the popper accepted values: top(-start, -end), right(-start, -end), bottom(-start, -end), left(-start, -end)
* @param {Number} [offset=0] - Amount of pixels the popper will be shifted (can be negative).
* @param {Boolean} [visible=false] Visibility of the popup element.
* @param {Boolean} [visible-arrow=false] Visibility of the arrow, no style.
*/
export default {
props: {
transformOrigin: {
type: [Boolean, String],
default: true
},
placement: {
type: String,
default: 'bottom'
},
boundariesPadding: {
type: Number,
default: 5
},
reference: {},
popper: {},
offset: {
default: 0
},
value: Boolean,
visibleArrow: Boolean,
arrowOffset: {
type: Number,
default: 35
},
appendToBody: {
type: Boolean,
default: true
},
popperOptions: {
type: Object,
default() {
return {
gpuAcceleration: false
}
}
},
latestScrollableDom: String
},
data() {
return {
showPopper: false,
currentPlacement: ''
}
},
watch: {
value: {
immediate: true,
handler(val) {
this.showPopper = val
this.$emit('input', val)
}
},
showPopper(val) {
if (this.disabled) return
val ? this.updatePopper() : this.destroyPopper()
this.$emit('input', val)
}
},
methods: {
createPopper() {
// 新添加
this.handleScroll('addEventListener')
if (this.$isServer) return
this.currentPlacement = this.currentPlacement || this.placement
if (!/^(top|bottom|left|right)(-start|-end)?$/g.test(this.currentPlacement)) {
return
}
const options = this.popperOptions
const popper = (this.popperElm = this.popperElm || this.popper || this.$refs.popper)
let reference = (this.referenceElm = this.referenceElm || this.reference || this.$refs.reference)
if (!reference && this.$slots.reference && this.$slots.reference[0]) {
reference = this.referenceElm = this.$slots.reference[0].elm
}
if (!popper || !reference) return
if (this.visibleArrow) this.appendArrow(popper)
if (this.appendToBody) document.body.appendChild(this.popperElm)
if (this.popperJS && this.popperJS.destroy) {
this.popperJS.destroy()
}
options.placement = this.currentPlacement
options.offset = this.offset
options.arrowOffset = this.arrowOffset
this.popperJS = new PopperJS(reference, popper, options)
this.popperJS.onCreate((_) => {
this.$emit('created', this)
this.resetTransformOrigin()
this.$nextTick(this.updatePopper)
})
if (typeof options.onUpdate === 'function') {
this.popperJS.onUpdate(options.onUpdate)
}
this.popperJS._popper.style.zIndex = PopupManager.nextZIndex()
this.popperElm.addEventListener('click', stop)
},
updatePopper() {
const popperJS = this.popperJS
if (popperJS) {
popperJS.update()
if (popperJS._popper) {
popperJS._popper.style.zIndex = PopupManager.nextZIndex()
}
} else {
this.createPopper()
}
},
doDestroy(forceDestroy) {
// 新添加
this.handleScroll('removeEventListener')
/* istanbul ignore if */
if (!this.popperJS || (this.showPopper && !forceDestroy)) return
this.popperJS.destroy()
this.popperJS = null
},
destroyPopper() {
// 新添加
this.handleScroll('removeEventListener')
if (this.popperJS) {
this.resetTransformOrigin()
}
},
resetTransformOrigin() {
if (!this.transformOrigin) return
let placementMap = {
top: 'bottom',
bottom: 'top',
left: 'right',
right: 'left'
}
let placement = this.popperJS._popper.getAttribute('x-placement').split('-')[0]
let origin = placementMap[placement]
this.popperJS._popper.style.transformOrigin =
typeof this.transformOrigin === 'string' ? this.transformOrigin : ['top', 'bottom'].indexOf(placement) > -1 ? `center ${origin}` : `${origin} center`
},
appendArrow(element) {
let hash
if (this.appended) {
return
}
this.appended = true
for (let item in element.attributes) {
if (/^_v-/.test(element.attributes[item].name)) {
hash = element.attributes[item].name
break
}
}
const arrow = document.createElement('div')
if (hash) {
arrow.setAttribute(hash, '')
}
arrow.setAttribute('x-arrow', '')
arrow.className = 'popper__arrow'
element.appendChild(arrow)
},
// 新添加
handleScroll(event) {
const ele = document.querySelector(this.latestScrollableDom || (this.$ELEMENTPOPPER || {}).latestScrollableDom)
if (ele) {
ele[event]('scroll', this.updatePopper)
}
}
},
beforeDestroy() {
this.doDestroy(true)
if (this.popperElm && this.popperElm.parentNode === document.body) {
this.popperElm.removeEventListener('click', stop)
document.body.removeChild(this.popperElm)
}
},
// call destroy in keep-alive mode
deactivated() {
this.$options.beforeDestroy[0].call(this)
}
}
使用
在 main.js
中引入。
javascript
import Vue from 'vue'
import Select from '@/componetns/select'
// 全局可以配置全局滚动类名或者在 select 组件上配置 latestScrollableDom 属性。
Vue.use(Select, {latestScrollableDom: '.app'})
因为是公司内部项目,修改好的 GIF 就不给大家演示了。(PS:GIF 不知道怎么打码)