fix(popper): fix popper's mechanism (#177)

- Rewrite popper's render method \
This commit is contained in:
jeremywu 2020-08-24 00:00:20 +08:00 committed by GitHub
parent 24f2a83680
commit 44d7cc2426
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 193 additions and 111 deletions

View File

@ -80,16 +80,11 @@ describe('Popper.vue', () => {
}) })
test('append to body', () => { test('append to body', () => {
const { appendChild } = document.body
document.body.appendChild = jest.fn(child => {
return appendChild.call(document.body, child)
})
let wrapper = _mount() let wrapper = _mount()
expect(wrapper.find('[role="tooltip"]').exists()).toBe(false) const selector = '[role="tooltip"]'
expect(document.body.appendChild).toHaveBeenCalled() expect(wrapper.find(selector).exists()).toBe(false)
// Due to the parent node of popper is Transition so we should match the grandparent
document.body.appendChild = appendChild expect(document.querySelector(selector).parentElement.parentElement).toBe(document.body)
wrapper = _mount({ wrapper = _mount({
appendToBody: false, appendToBody: false,
}) })

View File

@ -1,7 +1,10 @@
<template> <template>
<div> <div class="popper-container">
<div>referrer</div> <el-popper
<el-popper :placement="placement" :disabled="disabled"> :placement="placement"
:disabled="disabled"
effect="light"
>
<template #default>content</template> <template #default>content</template>
<template #trigger> <template #trigger>
<el-button v-if="showButton" @click="disabled = !disabled"> <el-button v-if="showButton" @click="disabled = !disabled">
@ -21,6 +24,7 @@
<script lang="ts"> <script lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { getRandomInt } from '@element-plus/utils/util'
const placements = ['top','top-start','top-end','bottom','bottom-start','bottom-end','left','left-start','left-end','right','right-start', 'right-end'] const placements = ['top','top-start','top-end','bottom','bottom-start','bottom-end','left','left-start','left-end','right','right-start', 'right-end']
export default { export default {
@ -32,10 +36,19 @@ export default {
referrer: ref(null), referrer: ref(null),
placement, placement,
toggle: () => { toggle: () => {
const random = Math.floor(Math.random() * 13) const random = getRandomInt(12) // range [0, 11]
placement.value = placements[random] placement.value = placements[random]
}, },
} }
}, },
} }
</script> </script>
<style scoped>
.popper-container {
width: 100%;
height: 100%;
padding: 150px;
}
</style>

View File

@ -1,41 +1,19 @@
<template>
<slot name="trigger"></slot>
<transition :name="transition">
<div
v-show="visible"
:id="popperId"
ref="popperRef"
role="tooltip"
:aria-hidden="visible ? 'false' : 'true'"
:class="['el-popper', 'is-' + effect, popperClass]"
@mouseenter="_show"
@mouseleave="_hide"
>
<!-- mark this parent container un-indexable to disable it from being indexed -->
<slot>
<div>{{ content }}</div>
</slot>
<div
v-if="showArrow"
ref="arrowRef"
class="el-popper__arrow"
data-popper-arrow
></div>
</div>
</transition>
</template>
<script lang="ts"> <script lang="ts">
import { import {
computed, computed,
defineComponent, defineComponent,
getCurrentInstance, getCurrentInstance,
h,
ref, ref,
onBeforeUnmount, onBeforeUnmount,
onMounted, onMounted,
onUpdated, onUpdated,
watch, watch,
Fragment, Fragment,
Teleport,
Transition,
withDirectives,
vShow,
} from 'vue' } from 'vue'
import { isArray } from '@vue/shared' import { isArray } from '@vue/shared'
import { debounce } from 'lodash' import { debounce } from 'lodash'
@ -45,26 +23,34 @@ import { generateId } from '@element-plus/utils/util'
import { on, off } from '@element-plus/utils/dom' import { on, off } from '@element-plus/utils/dom'
import throwError from '@element-plus/utils/error' import throwError from '@element-plus/utils/error'
import useModifer from './useModifier'
import type { PropType, Ref } from 'vue'
import type {
Placement,
Instance as PopperInstance,
PositioningStrategy,
} from '@popperjs/core'
import useModifier from './useModifier' import useModifier from './useModifier'
type Effect = 'dark' | 'light'; import type { PropType, Ref } from 'vue'
type RefElement = Nullable<HTMLElement>
import type { Effect, Offset, Placement, PopperInstance, PositioningStrategy, RefElement, Options } from './popper'
const stop = (e: Event) => e.stopPropagation() const stop = (e: Event) => e.stopPropagation()
const getTrigger = () => { const getTrigger = () => {
const { const { subTree: { children } } = getCurrentInstance()
subTree: { dynamicChildren }, // SubTree is formed by <slot name="trigger"/><popper />
} = getCurrentInstance() // So that the trigger element is within the slot, we need to take it out of the slot in order to attach
return dynamicChildren[0].children // events on top of it
const targetSlot = children[0]
if (targetSlot.length > 1) {
console.warn('Popper will only be attached to the first child')
}
// This indicates if the slot is rendered with directives (e.g. v-if) or templates (e.g. <template />)
// if it's true, then the children needs to be taken by accessing targetSlots.children to get it
const trigger: HTMLElement = targetSlot.type === Fragment
? targetSlot.children[0].el
: targetSlot.el
if (!trigger) {
throwError(compName, 'Cannot find referrer to attach popper to')
}
return trigger
} }
const compName = 'ElPopper' const compName = 'ElPopper'
@ -124,9 +110,9 @@ export default defineComponent({
default: 0, default: 0,
}, },
offset: { offset: {
type: [Number, Array] as PropType<[number, number] | number>, type: [Number, Array] as PropType<Offset>,
default: [0, 12] as [number, number], default: [0, 12] as Offset,
validator: (val: [number, number] | number): boolean => { validator: (val: Offset): boolean => {
return (isArray(val) && val.length === 2) || typeof val === 'number' return (isArray(val) && val.length === 2) || typeof val === 'number'
}, },
}, },
@ -138,6 +124,15 @@ export default defineComponent({
type: String, type: String,
default: '', default: '',
}, },
pure: {
type: Boolean,
default: false,
},
// Once this option were given, the entire popper is under the users' control, top priority
popperOptions: {
type: Object as PropType<Options>,
default: () => null,
},
referrer: { referrer: {
type: HTMLElement as PropType<Nullable<HTMLElement>>, type: HTMLElement as PropType<Nullable<HTMLElement>>,
default: null as Nullable<HTMLElement>, default: null as Nullable<HTMLElement>,
@ -160,7 +155,7 @@ export default defineComponent({
}, },
value: { value: {
type: Boolean, type: Boolean,
default: true, default: false,
}, },
}, },
setup(props, { slots }) { setup(props, { slots }) {
@ -209,10 +204,6 @@ export default defineComponent({
/* istanbul ignore if */ /* istanbul ignore if */
if (!popperInstance.value || (visible.value && !forceDestroy)) return if (!popperInstance.value || (visible.value && !forceDestroy)) return
detach() detach()
if (popperRef.value && props.appendToBody) {
off(popperRef.value, 'click', stop)
document.body.removeChild(popperRef.value)
}
} }
function detach() { function detach() {
@ -265,6 +256,7 @@ export default defineComponent({
} }
exceptionState.value = state exceptionState.value = state
} }
function _show() { function _show() {
setExpectionState(true) setExpectionState(true)
showPopper() showPopper()
@ -284,38 +276,28 @@ export default defineComponent({
} }
function initializePopper() { function initializePopper() {
const subTree = getTrigger() const _trigger = getTrigger()
if (subTree.length > 1) {
console.warn('Popper will only be attached to the first child')
}
let referenceElement: HTMLElement
if (subTree[0].type === Fragment) {
referenceElement = subTree[0].children[0].el
} else {
referenceElement = subTree[0].el
}
if (!referenceElement) {
throwError(compName, 'Cannot find referrer to attach popper to')
}
trigger.value = referenceElement
const modifiers = useModifer(popperOptions.value.modifierOptions)
popperInstance.value = createPopper(referenceElement, popperRef.value, { trigger.value = _trigger
placement: popperOptions.value.placement,
onFirstUpdate: () => { popperInstance.value = createPopper(_trigger, popperRef.value,
popperInstance.value.forceUpdate() props.popperOptions !== null
}, ? props.popperOptions
strategy: popperOptions.value.strategy, : {
modifiers, placement: popperOptions.value.placement,
}) onFirstUpdate: () => {
referenceElement.setAttribute('aria-describedby', popperId.value) popperInstance.value.forceUpdate()
referenceElement.setAttribute('tabindex', props.tabIndex) },
on(referenceElement, 'mouseenter', _show) strategy: popperOptions.value.strategy,
on(referenceElement, 'mouseleave', _hide) modifiers: useModifier(popperOptions.value.modifierOptions),
on(referenceElement, 'focus', handleFocus) })
on(referenceElement, 'blur', handleBlur) _trigger.setAttribute('aria-describedby', popperId.value)
on(popperRef.value, 'click', stop) _trigger.setAttribute('tabindex', props.tabIndex)
on(_trigger, 'mouseenter', _show)
on(_trigger, 'mouseleave', _hide)
on(_trigger, 'focus', handleFocus)
on(_trigger, 'blur', handleBlur)
} }
watch( watch(
@ -330,6 +312,7 @@ export default defineComponent({
) )
watch(() => popperOptions.value, val => { watch(() => popperOptions.value, val => {
if (!popperInstance.value) return
popperInstance.value.setOptions({ popperInstance.value.setOptions({
placement: val.placement, placement: val.placement,
strategy: val.strategy, strategy: val.strategy,
@ -340,9 +323,6 @@ export default defineComponent({
onMounted(() => { onMounted(() => {
initializePopper() initializePopper()
if (props.appendToBody && popperRef.value) {
document.body.appendChild(popperRef.value)
}
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@ -350,8 +330,8 @@ export default defineComponent({
}) })
onUpdated(() => { onUpdated(() => {
const subTree = getTrigger() const _trigger = getTrigger()
if (subTree[0].el !== trigger.value && popperInstance.value) { if (_trigger !== trigger.value && popperInstance.value) {
detach() detach()
} }
if (popperInstance.value) { if (popperInstance.value) {
@ -378,6 +358,73 @@ export default defineComponent({
activated() { activated() {
this.initializePopper() this.initializePopper()
}, },
render() {
const arrow = this.showArrow
? h(
'div',
{
ref: 'arrowRef',
class: 'el-popper__arrow',
'data-popper-arrow': '',
},
)
: null
const popper = h(
Transition,
{
name: this.transition,
},
{
default: () =>
withDirectives(
h(
'div',
{
ariaHidden: this.visible ? 'false' : 'true',
class: [
'el-popper',
'is-' + this.effect,
this.popperClass,
this.pure
? 'el-popper__pure'
: '',
],
id: this.popperId,
ref: 'popperRef',
role: 'tooltip',
onMouseEnter: this._show,
onMouseLeave: this._hide,
onClick: stop,
},
[
this.$slots.default ? this.$slots.default() : this.content,
arrow,
],
),
[
[vShow, this.visible],
],
),
},
)
return h(
Fragment,
null,
[
this.$slots.trigger?.(),
this.appendToBody
? h(
Teleport,
{
to: 'body',
},
popper,
)
: popper,
],
)
},
}) })
</script> </script>
@ -396,8 +443,8 @@ export default defineComponent({
.el-popper__arrow, .el-popper__arrow,
.el-popper__arrow::before { .el-popper__arrow::before {
position: absolute; position: absolute;
width: 8px; width: 10px;
height: 8px; height: 10px;
z-index: -1; z-index: -1;
} }
@ -409,19 +456,19 @@ export default defineComponent({
} }
.el-popper[data-popper-placement^="top"] > .el-popper__arrow { .el-popper[data-popper-placement^="top"] > .el-popper__arrow {
bottom: -4px; bottom: -5px;
} }
.el-popper[data-popper-placement^="bottom"] > .el-popper__arrow { .el-popper[data-popper-placement^="bottom"] > .el-popper__arrow {
top: -4px; top: -5px;
} }
.el-popper[data-popper-placement^="left"] > .el-popper__arrow { .el-popper[data-popper-placement^="left"] > .el-popper__arrow {
right: -4px; right: -5px;
} }
.el-popper[data-popper-placement^="right"] > .el-popper__arrow { .el-popper[data-popper-placement^="right"] > .el-popper__arrow {
left: -4px; left: -5px;
} }
.el-popper.is-dark { .el-popper.is-dark {
@ -443,22 +490,31 @@ export default defineComponent({
} }
.el-popper.is-light[data-popper-placement^="top"] .el-popper__arrow::before { .el-popper.is-light[data-popper-placement^="top"] .el-popper__arrow::before {
border-top: none; border-top-color: transparent;
border-left: none; border-left-color: transparent;
} }
.el-popper.is-light[data-popper-placement^="bottom"] .el-popper__arrow::before { .el-popper.is-light[data-popper-placement^="bottom"] .el-popper__arrow::before {
border-top: none; border-bottom-color: transparent;
border-left: none; border-right-color: transparent;
} }
.el-popper.is-light[data-popper-placement^="left"] .el-popper__arrow::before { .el-popper.is-light[data-popper-placement^="left"] .el-popper__arrow::before {
border-left: none; border-left-color: transparent;
border-bottom: none; border-bottom-color: transparent;
} }
.el-popper.is-light[data-popper-placement^="right"] .el-popper__arrow::before { .el-popper.is-light[data-popper-placement^="right"] .el-popper__arrow::before {
border-top: none; border-top-color: transparent;
border-right: none; border-right-color: transparent;
}
.el-popper.el-popper__pure {
padding: 0;
border: none;
}
.el-popper.el-popper__pure .el-popper__arrow::before {
border: none;
} }
</style> </style>

10
packages/popper/src/popper.d.ts vendored Normal file
View File

@ -0,0 +1,10 @@
export type Effect = 'dark' | 'light';
export type RefElement = Nullable<HTMLElement>
export type Offset = [number, number] | number
export type {
Placement,
Instance as PopperInstance,
PositioningStrategy,
Options,
} from '@popperjs/core'

View File

@ -147,4 +147,12 @@ export function rafThrottle(fn: (args: Record<string, unknown>) => unknown): (..
export const objToArray = castArray export const objToArray = castArray
/**
* Generating a random int in range (0, max - 1)
* @param max {number}
*/
export function getRandomInt(max: number) {
return Math.floor(Math.random() * Math.floor(max))
}
export { isVNode } from 'vue' export { isVNode } from 'vue'