Feat/implementing trigger for popper (#214)

* feat/implementing-trigger-for-popper
- Implementing trigger for popper
- Refactors against click-outside

* feat(popper): implemented trigger for popper
This commit is contained in:
jeremywu 2020-08-28 10:47:02 +08:00 committed by GitHub
parent 9ca93eaa40
commit d0b37cdf5f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 275 additions and 109 deletions

View File

@ -3,7 +3,7 @@ import { on } from '@element-plus/utils/dom'
import type { DirectiveBinding, ObjectDirective, ComponentPublicInstance } from 'vue'
type DocumentHandler = <T extends Event>(mouseup: T, mousedown: T) => void;
type DocumentHandler = <T extends MouseEvent>(mouseup: T, mousedown: T) => void;
type FlushList = Map<
HTMLElement,
@ -15,11 +15,11 @@ type FlushList = Map<
const nodeList: FlushList = new Map()
let startClick: Event
let startClick: MouseEvent
if (!isServer) {
on(document, 'mousedown', e => (startClick = e))
on(document, 'mouseup', e => {
on(document, 'mousedown', (e: MouseEvent) => (startClick = e))
on(document, 'mouseup', (e: MouseEvent) => {
for (const { documentHandler } of nodeList.values()) {
documentHandler(e, startClick)
}
@ -30,21 +30,44 @@ function createDocumentHandler(
el: HTMLElement,
binding: DirectiveBinding,
): DocumentHandler {
let excludes: HTMLElement[] = []
if (Array.isArray(binding.arg)) {
excludes = binding.arg
} else {
// due to current implementation on binding type is wrong the type casting is necessary here
excludes.push(binding.arg as unknown as HTMLElement)
}
return function(mouseup, mousedown) {
const popperRef = (binding.instance as ComponentPublicInstance<{
popperRef: Nullable<HTMLElement>
}>).popperRef
const mouseUpTarget = mouseup.target as Node
const mouseDownTarget = mousedown.target as Node
const isBound = !binding || !binding.instance
const isTargetExists = !mouseUpTarget || !mouseDownTarget
const isContainedByEl = el.contains(mouseUpTarget) || el.contains(mouseDownTarget)
const isSelf = el === mouseUpTarget
const isTargetExcluded =
( excludes.length &&
excludes.some(item => item?.contains(mouseUpTarget))
) || (
excludes.length && excludes.includes(mouseDownTarget as HTMLElement)
)
const isContainedByPopper = (
popperRef &&
(
popperRef.contains(mouseUpTarget) ||
popperRef.contains(mouseDownTarget)
)
)
if (
!binding ||
!binding.instance ||
!mouseup.target ||
!mousedown.target ||
el.contains(mouseup.target as Node) ||
el.contains(mousedown.target as Node) ||
el === mouseup.target ||
(popperRef &&
(popperRef.contains(mouseup.target as Node) ||
popperRef.contains(mousedown.target as Node)))
isBound ||
isTargetExists ||
isContainedByEl ||
isSelf ||
isTargetExcluded ||
isContainedByPopper
) {
return
}

View File

@ -3,21 +3,22 @@ import { on, off } from '@element-plus/utils/dom'
import type { Ref } from 'vue'
type Events = Array<{
export type Event = {
name: string
handler: (...args: any[]) => any
}>
}
export default (el: Ref<HTMLElement>, events: Events) => {
events.map(({ name, handler }) => {
on(el.value, name, handler)
})
export default (el: Ref<HTMLElement>, events: Event[]) => {
watch(el, (_, __, onCleanup) => {
onCleanup(() => {
watch(el, val => {
if (val) {
events.map(({ name, handler }) => {
on(el.value, name, handler)
})
} else {
events.map(({ name, handler }) => {
off(el.value, name, handler)
})
})
}
})
}

View File

@ -17,6 +17,9 @@ const selector = '[role="tooltip"]'
const TEST_TRIGGER = 'test-trigger'
const MOUSE_ENTER_EVENT = 'mouseenter'
const MOUSE_LEAVE_EVENT = 'mouseleave'
const CLICK_EVENT = 'click'
const FOCUS_EVENT = 'focus'
const BLUR_EVENT = 'blur'
const DISPLAY_NONE = 'display: none'
const Wrapped = (props: UnKnownProps, { slots }) => h('div', h(ElPopper, props, slots))
@ -81,7 +84,6 @@ describe('Popper.vue', () => {
test('append to body', () => {
let wrapper = _mount()
const selector = '[role="tooltip"]'
expect(wrapper.find(selector).exists()).toBe(false)
/**
* Current layout of `ElPopper`
@ -199,4 +201,113 @@ describe('Popper.vue', () => {
// the only way to test this is by providing an error handler to catch it
expect(errorHandler).toHaveBeenCalledTimes(1)
})
describe('trigger', () => {
test('should work with click trigger', async () => {
const wrapper = _mount({
trigger: ['click'],
appendToBody: false,
})
await nextTick()
const trigger = wrapper.find(`.${TEST_TRIGGER}`)
const popper = wrapper.findComponent(ElPopper)
expect(popper.vm.visible).toBe(false)
// for now triggering event on element via DOMWrapper is not available so we need to apply
// old way
await trigger.trigger(CLICK_EVENT)
expect(popper.vm.visible).toBe(true)
await trigger.trigger(MOUSE_ENTER_EVENT)
expect(popper.vm.visible).toBe(true)
await trigger.trigger(FOCUS_EVENT)
expect(popper.vm.visible).toBe(true)
})
test('should work with hover trigger', async () => {
const wrapper = _mount({
trigger: ['hover'],
appendToBody: false,
})
await nextTick()
const trigger = wrapper.find(`.${TEST_TRIGGER}`)
const popper = wrapper.findComponent(ElPopper)
expect(popper.vm.visible).toBe(false)
// for now triggering event on element via DOMWrapper is not available so we need to apply
// old way
await trigger.trigger(MOUSE_ENTER_EVENT)
expect(popper.vm.visible).toBe(true)
await trigger.trigger(MOUSE_LEAVE_EVENT)
expect(popper.vm.visible).toBe(false)
await trigger.trigger(FOCUS_EVENT)
expect(popper.vm.visible).toBe(false)
await trigger.trigger(CLICK_EVENT)
expect(popper.vm.visible).toBe(false)
})
test('should work with focus trigger', async () => {
const wrapper = _mount({
trigger: [FOCUS_EVENT],
appendToBody: false,
})
await nextTick()
const trigger = wrapper.find(`.${TEST_TRIGGER}`)
const popper = wrapper.findComponent(ElPopper)
expect(popper.vm.visible).toBe(false)
// for now triggering event on element via DOMWrapper is not available so we need to apply
// old way
await trigger.trigger(FOCUS_EVENT)
expect(popper.vm.visible).toBe(true)
await trigger.trigger(BLUR_EVENT)
expect(popper.vm.visible).toBe(false)
await trigger.trigger(MOUSE_ENTER_EVENT)
expect(popper.vm.visible).toBe(false)
await trigger.trigger(CLICK_EVENT)
expect(popper.vm.visible).toBe(false)
})
test('combined trigger', async () => {
const wrapper = _mount({
trigger: [FOCUS_EVENT, CLICK_EVENT, 'hover'],
appendToBody: false,
})
await nextTick()
const trigger = wrapper.find(`.${TEST_TRIGGER}`)
const popper = wrapper.findComponent(ElPopper)
expect(popper.vm.visible).toBe(false)
// for now triggering event on element via DOMWrapper is not available so we need to apply
// old way
await trigger.trigger(CLICK_EVENT)
expect(popper.vm.visible).toBe(true)
await trigger.trigger(BLUR_EVENT)
expect(popper.vm.visible).toBe(false)
await trigger.trigger(MOUSE_ENTER_EVENT)
expect(popper.vm.visible).toBe(true)
await trigger.trigger(CLICK_EVENT)
expect(popper.vm.visible).toBe(false)
await trigger.trigger(FOCUS_EVENT)
expect(popper.vm.visible).toBe(true)
await trigger.trigger(CLICK_EVENT)
expect(popper.vm.visible).toBe(false)
})
})
})

View File

@ -153,31 +153,7 @@ export default defineComponent({
// this is a reference that we need to pass down to child component
// to obtain the child instance
const {
arrowRef,
clickMask,
doDestroy,
onShow,
onHide,
popperInstance,
popperId,
popperRef,
initializePopper,
visible,
} = usePopper(props as IPopperOptions, ctx)
return {
arrowRef,
clickMask,
popperId,
doDestroy,
onShow,
onHide,
popperRef,
popperInstance,
initializePopper,
visible,
}
return usePopper(props as IPopperOptions)
},
deactivated() {
this.doDestroy()
@ -186,6 +162,7 @@ export default defineComponent({
this.initializePopper()
},
render() {
const { $slots } = this
const arrow = this.showArrow
? h(
'div',
@ -225,7 +202,7 @@ export default defineComponent({
onClick: stop,
},
[
this.$slots.default ? this.$slots.default() : this.content,
($slots.default?.()) || this.content,
arrow,
],
),
@ -235,11 +212,13 @@ export default defineComponent({
),
},
)
const _t = $slots.trigger?.()
return h(
Fragment,
null,
[
this.$slots.trigger?.(),
_t,
this.appendToBody
? h(
Teleport,
@ -251,11 +230,10 @@ export default defineComponent({
'div',
{
class: 'el-popper__mask',
onClick: this.clickMask,
},
popper,
),
[[ClickOutside, () => this.$emit(UPDATE_VALUE_EVENT, false)]],
[[ClickOutside, this.onHide, [this.excludes] as any]],
),
)
: popper,
@ -268,16 +246,12 @@ export default defineComponent({
<style>
.el-popper__mask {
font-size: 14px;
font-weight: 400;
position: fixed;
z-index: 1000000;
position: absolute;
top: 0px;
left: 0px;
bottom: 0px;
right: 0px;
visibility: hidden;
width: 100%;
}
.el-popper {
position: absolute;
border-radius: 4px;

View File

@ -29,5 +29,6 @@ export type IPopperOptions = {
showArrow: boolean
strategy: PositioningStrategy
tabIndex: string
trigger: TriggerType[]
value: boolean
}

View File

@ -3,22 +3,23 @@ import { debounce } from 'lodash'
import { createPopper } from '@popperjs/core'
import { generateId } from '@element-plus/utils/util'
import { off, addClass } from '@element-plus/utils/dom'
import { addClass } from '@element-plus/utils/dom'
import throwError from '@element-plus/utils/error'
import useEvents from '@element-plus/hooks/use-events'
import { default as useEvents } from '@element-plus/hooks/use-events'
import useModifier from './useModifier'
import type { Ref } from 'vue'
import type { SetupContext } from '@vue/runtime-core'
import type { IPopperOptions, RefElement, PopperInstance } from './popper'
export const DEFAULT_TRIGGER = ['click', 'hover', 'focus', 'contextMenu']
export const DEFAULT_TRIGGER = ['hover']
export const UPDATE_VALUE_EVENT = 'updateValue'
const clearTimer = (timer: Ref<Nullable<NodeJS.Timeout>>) => {
clearTimeout(timer.value)
if (timer.value) {
clearTimeout(timer.value)
}
timer.value = null
}
@ -45,9 +46,9 @@ const getTrigger = () => {
return trigger
}
export default <T extends IPopperOptions>(props: T, ctx: SetupContext) => {
export default <T extends IPopperOptions>(props: T) => {
const arrowRef = ref<RefElement>(null)
const trigger = ref<RefElement>(null)
const triggerRef = ref<RefElement>(null)
const exceptionState = ref(false)
const popperInstance = ref<Nullable<PopperInstance>>(null)
const popperId = ref(`el-popper-${generateId()}`)
@ -55,6 +56,8 @@ export default <T extends IPopperOptions>(props: T, ctx: SetupContext) => {
const show = ref(false)
const timeout = ref<NodeJS.Timeout>(null)
const timeoutPending = ref<NodeJS.Timeout>(null)
const excludes = computed(() => triggerRef.value)
const triggerFocused = ref(false)
const popperOptions = computed(() => {
return {
@ -97,17 +100,18 @@ export default <T extends IPopperOptions>(props: T, ctx: SetupContext) => {
}
}
const closePopper = debounce(() => {
const close = () => {
if (props.enterable && exceptionState.value) return
clearTimer(timeout)
if (timeoutPending.value !== null) {
clearTimer(timeoutPending)
}
clearTimer(timeoutPending)
show.value = false
if (props.disabled) {
doDestroy(true)
}
}, props.closeDelay)
}
const closePopper = props.closeDelay
? debounce(close, props.closeDelay)
: close
function onShow() {
setExpectionState(true)
@ -128,7 +132,7 @@ export default <T extends IPopperOptions>(props: T, ctx: SetupContext) => {
function initializePopper() {
const _trigger = getTrigger()
trigger.value = _trigger
triggerRef.value = _trigger
popperInstance.value = createPopper(_trigger, popperRef.value,
props.popperOptions !== null
@ -144,26 +148,6 @@ export default <T extends IPopperOptions>(props: T, ctx: SetupContext) => {
_trigger.setAttribute('aria-describedby', popperId.value)
_trigger.setAttribute('tabindex', props.tabIndex)
addClass(_trigger, props.class)
const events = [
{
name: 'mouseenter',
handler: onShow,
},
{
name: 'mouseleave',
handler: onHide,
},
{
name: 'focus',
handler: onShow,
},
{
name: 'blur',
handler: onHide,
},
]
useEvents(trigger, events)
}
function doDestroy(forceDestroy: boolean) {
@ -172,21 +156,83 @@ export default <T extends IPopperOptions>(props: T, ctx: SetupContext) => {
detachPopper()
}
function clickMask() {
if (props.manualMode) {
ctx.emit(UPDATE_VALUE_EVENT, false)
}
}
function detachPopper() {
popperInstance.value.destroy()
popperInstance.value = null
const _trigger = trigger.value
off(_trigger, 'mouseenter', onShow)
off(_trigger, 'mouseleave', onHide)
off(_trigger, 'focus', onShow)
off(_trigger, 'blur', onHide)
trigger.value = null
triggerRef.value = null
}
if (!props.manualMode) {
const toggleState = () => {
if (visible.value) {
onHide()
} else {
onShow()
}
}
const handlePopperEvents = (e: Event) => {
e.stopImmediatePropagation()
switch (e.type) {
case 'click': {
if (triggerFocused.value) {
// reset previous focus event
triggerFocused.value = false
}
toggleState()
break
}
case 'mouseenter': {
onShow()
break
}
case 'mouseleave': {
onHide()
break
}
case 'focus': {
triggerFocused.value = true
onShow()
break
}
case 'blur': {
triggerFocused.value = false
onHide()
break
}
}
}
const events = []
const handler = handlePopperEvents
if (props.trigger.includes('click')) {
events.push({
name: 'click',
handler,
})
}
if (props.trigger.includes('hover')) {
events.push({
name: 'mouseenter',
handler,
},
{
name: 'mouseleave',
handler,
})
}
if (props.trigger.includes('focus')) {
events.push({
name: 'focus',
handler,
},
{
name: 'blur',
handler,
})
}
useEvents(triggerRef, events)
}
watch(popperOptions, val => {
@ -219,7 +265,7 @@ export default <T extends IPopperOptions>(props: T, ctx: SetupContext) => {
onUpdated(() => {
const _trigger = getTrigger()
if (_trigger !== trigger.value && popperInstance.value) {
if (_trigger !== triggerRef.value && popperInstance.value) {
detachPopper()
}
if (popperInstance.value) {
@ -230,15 +276,16 @@ export default <T extends IPopperOptions>(props: T, ctx: SetupContext) => {
})
return {
clickMask,
doDestroy,
onShow,
onHide,
initializePopper,
arrowRef,
excludes,
popperId,
popperInstance,
popperRef,
triggerRef,
visible,
}
}

View File

@ -15,7 +15,9 @@
class="item"
effect="dark"
content="Top Center prompts info"
:append-to-body="false"
placement="top"
:trigger="['click', 'focus', 'hover']"
>
<el-button>top</el-button>
</el-tooltip>
@ -24,6 +26,7 @@
effect="dark"
content="Top Right prompts info"
placement="top-end"
:trigger="['click']"
>
<el-button>top-end</el-button>
</el-tooltip>

View File

@ -87,6 +87,10 @@ export default defineComponent({
type: String,
default: 'el-fade-in-linear',
},
trigger: {
type: [String, Array] as PropType<string | string[]>,
default: () => ['hover'],
},
visibleArrow: {
type: Boolean,
default: true,
@ -124,8 +128,9 @@ export default defineComponent({
placement,
popperOptions,
showAfter,
transition,
tabindex,
transition,
trigger,
visibleArrow,
} = this
const popper = h(
@ -141,8 +146,9 @@ export default defineComponent({
placement,
showAfter: openDelay || showAfter, // this is for mapping API due to we decided to rename the current openDelay API to showAfter for better readability,
showArrow: visibleArrow,
transition,
tabIndex: String(tabindex),
transition,
trigger,
popperOptions, // Breakings!: Once popperOptions is provided, the whole popper is under user's control, ElPopper nolonger generates the default options for popper, this is by design if the user wants the full contorl on @PopperJS, read the doc @https://popper.js.org/docs/v2/
value: this.modelValue,
onUpdateValue,