mirror of
https://github.com/element-plus/element-plus.git
synced 2024-12-27 03:01:14 +08:00
c28d7b3738
* feat(hooks): redesign popper apis - Redesign use-popper APIs to make it more adaptable. * - Reorganize the popper APIs
368 lines
8.8 KiB
TypeScript
368 lines
8.8 KiB
TypeScript
import {
|
|
cloneVNode,
|
|
computed,
|
|
Fragment,
|
|
getCurrentInstance,
|
|
h,
|
|
nextTick,
|
|
toDisplayString,
|
|
toRef,
|
|
Transition,
|
|
ref,
|
|
renderSlot,
|
|
withDirectives,
|
|
} from 'vue'
|
|
import { NOOP } from '@vue/shared'
|
|
import { createPopper } from '@popperjs/core'
|
|
import { ClickOutside } from '@element-plus/directives'
|
|
import {
|
|
generateId,
|
|
isHTMLElement,
|
|
isString,
|
|
refAttacher,
|
|
} from '@element-plus/utils/util'
|
|
import { getFirstValidNode } from '@element-plus/utils/vnode'
|
|
import { stop } from '@element-plus/utils/dom'
|
|
import PopupManager from '@element-plus/utils/popup-manager'
|
|
import throwError from '@element-plus/utils/error'
|
|
|
|
import useTeleport from '../use-teleport'
|
|
import useTimeout from '../use-timeout'
|
|
import { useModelToggle } from '../use-model-toggle'
|
|
import { useTransitionFallthrough } from '../use-transition-fallthrough'
|
|
import { defaultPopperOptions, defaultModifiers } from './use-popper-options'
|
|
import { useTargetEvents, DEFAULT_TRIGGER } from './use-target-events'
|
|
|
|
import type {
|
|
CSSProperties,
|
|
ComponentPublicInstance,
|
|
ExtractPropTypes,
|
|
PropType,
|
|
} from 'vue'
|
|
import type {
|
|
Instance as PopperInstance,
|
|
StrictModifiers,
|
|
} from '@popperjs/core'
|
|
import type { RefElement } from '@element-plus/utils/types'
|
|
import type { Trigger } from './use-target-events'
|
|
|
|
export type Effect = 'light' | 'dark'
|
|
export type Offset = [number, number] | number
|
|
|
|
type ElementType = ComponentPublicInstance | HTMLElement
|
|
|
|
export const DARK_EFFECT = 'dark'
|
|
export const LIGHT_EFFECT = 'light'
|
|
|
|
export const usePopperControlProps = {
|
|
appendToBody: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
arrowOffset: {
|
|
type: Number,
|
|
},
|
|
popperOptions: defaultPopperOptions,
|
|
popperClass: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
}
|
|
|
|
export const usePopperProps = {
|
|
...usePopperControlProps,
|
|
// the arrow size is an equailateral triangle with 10px side length, the 3rd side length ~ 14.1px
|
|
// adding a offset to the ceil of 4.1 should be 5 this resolves the problem of arrow overflowing out of popper.
|
|
autoClose: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
content: {
|
|
type: String,
|
|
default: '',
|
|
},
|
|
class: String,
|
|
style: Object,
|
|
hideAfter: {
|
|
type: Number,
|
|
default: 200,
|
|
},
|
|
disabled: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
effect: {
|
|
type: String as PropType<Effect>,
|
|
default: DARK_EFFECT,
|
|
},
|
|
enterable: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
manualMode: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
showAfter: {
|
|
type: Number,
|
|
default: 0,
|
|
},
|
|
pure: {
|
|
type: Boolean,
|
|
default: false,
|
|
},
|
|
showArrow: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
transition: {
|
|
type: String,
|
|
default: 'el-fade-in-linear',
|
|
},
|
|
trigger: {
|
|
type: [String, Array] as PropType<Trigger>,
|
|
default: DEFAULT_TRIGGER,
|
|
},
|
|
visible: {
|
|
type: Boolean,
|
|
default: undefined,
|
|
},
|
|
stopPopperMouseEvent: {
|
|
type: Boolean,
|
|
default: true,
|
|
},
|
|
}
|
|
|
|
export const usePopper = () => {
|
|
|
|
const vm = getCurrentInstance()
|
|
const props = vm.props as ExtractPropTypes<typeof usePopperProps>
|
|
const { slots } = vm
|
|
|
|
const arrowRef = ref<RefElement>(null)
|
|
const triggerRef = ref<ElementType>(null)
|
|
const popperRef = ref<RefElement>(null)
|
|
|
|
const popperStyle = ref<CSSProperties>({ zIndex: PopupManager.nextZIndex() })
|
|
const visible = ref(false)
|
|
const isManual = computed(() => props.manualMode || props.trigger === 'manual')
|
|
|
|
const popperId = `el-popper-${generateId()}`
|
|
let popperInstance: Nullable<PopperInstance> = null
|
|
|
|
const {
|
|
renderTeleport,
|
|
showTeleport,
|
|
hideTeleport,
|
|
} = useTeleport(popupRenderer, toRef(props, 'appendToBody'))
|
|
|
|
const { show, hide } = useModelToggle({
|
|
indicator: visible,
|
|
onShow,
|
|
onHide,
|
|
})
|
|
|
|
const { registerTimeout, cancelTimeout } = useTimeout()
|
|
|
|
// event handlers
|
|
|
|
function onShow() {
|
|
popperStyle.value.zIndex = PopupManager.nextZIndex()
|
|
nextTick(initializePopper)
|
|
}
|
|
|
|
function onHide() {
|
|
hideTeleport()
|
|
nextTick(detachPopper)
|
|
}
|
|
|
|
/**
|
|
* The calling mechanism here is:
|
|
* when the visibility gets changed, let's say we change it to true
|
|
* the delayShow gets called which initializes a global root node for the popper content
|
|
* to insert in, then it register a timer for calling the show method, which changes the flag to
|
|
* true, then calls onShow method.
|
|
* So the order of invocation is: delayedShow -> timer(show) -> set the indicator to true -> onShow
|
|
*/
|
|
|
|
function delayShow() {
|
|
if (isManual.value || props.disabled) return
|
|
// renders out the teleport element root.
|
|
showTeleport()
|
|
registerTimeout(show, props.showAfter)
|
|
}
|
|
|
|
function delayHide() {
|
|
if (isManual.value) return
|
|
registerTimeout(hide, props.hideAfter)
|
|
}
|
|
|
|
function onToggle() {
|
|
if (visible.value) {
|
|
delayShow()
|
|
} else {
|
|
delayHide()
|
|
}
|
|
}
|
|
|
|
function detachPopper() {
|
|
popperInstance?.destroy?.()
|
|
popperInstance = null
|
|
}
|
|
|
|
function onPopperMouseEnter() {
|
|
// if trigger is click, user won't be able to close popper when
|
|
// user tries to move the mouse over popper contents
|
|
if (props.enterable && props.trigger !== 'click') {
|
|
cancelTimeout()
|
|
}
|
|
}
|
|
|
|
function onPopperMouseLeave() {
|
|
const { trigger } = props
|
|
const shouldPrevent =
|
|
(isString(trigger) && (trigger === 'click' || trigger === 'focus')) ||
|
|
// we'd like to test array type trigger here, but the only case we need to cover is trigger === 'click' or
|
|
// trigger === 'focus', because that when trigger is string
|
|
// trigger.length === 1 and trigger[0] === 5 chars string is mutually exclusive.
|
|
// so there will be no need to test if trigger is array type.
|
|
(trigger.length === 1 &&
|
|
(trigger[0] === 'click' || trigger[0] === 'focus'))
|
|
if (shouldPrevent) return
|
|
delayHide()
|
|
}
|
|
|
|
function initializePopper() {
|
|
if (!visible.value || popperInstance !== null) {
|
|
return
|
|
}
|
|
const unwrappedTrigger = triggerRef.value
|
|
const $el = isHTMLElement(unwrappedTrigger)
|
|
? unwrappedTrigger
|
|
: (unwrappedTrigger as ComponentPublicInstance).$el
|
|
|
|
popperInstance = createPopper($el, popperRef.value, buildPopperOptions())
|
|
popperInstance.update()
|
|
}
|
|
|
|
function buildPopperOptions() {
|
|
const modifiers = [
|
|
...defaultModifiers,
|
|
...props.popperOptions.modifiers,
|
|
]
|
|
|
|
if (props.showArrow) {
|
|
modifiers.push({
|
|
name: 'arrow',
|
|
options: {
|
|
padding: props.arrowOffset || 5,
|
|
element: arrowRef.value,
|
|
},
|
|
} as StrictModifiers)
|
|
}
|
|
|
|
return {
|
|
...props.popperOptions,
|
|
modifiers,
|
|
}
|
|
}
|
|
|
|
const {
|
|
onAfterEnter,
|
|
onAfterLeave,
|
|
onBeforeEnter,
|
|
onBeforeLeave,
|
|
} = useTransitionFallthrough()
|
|
|
|
const events = useTargetEvents(delayShow, delayHide, onToggle)
|
|
|
|
const arrowRefAttacher = refAttacher(arrowRef)
|
|
const popperRefAttacher = refAttacher(popperRef)
|
|
const triggerRefAttacher = refAttacher(triggerRef)
|
|
|
|
// renderers
|
|
function popupRenderer() {
|
|
const mouseUpAndDown = props.stopPopperMouseEvent ? stop : NOOP
|
|
return h(
|
|
Transition,
|
|
{
|
|
name: props.transition,
|
|
onAfterEnter,
|
|
onAfterLeave,
|
|
onBeforeEnter,
|
|
onBeforeLeave,
|
|
},
|
|
{
|
|
default: () => () => visible.value ? h('div',
|
|
{
|
|
'aria-hidden': false,
|
|
class: [
|
|
props.popperClass,
|
|
'el-popper',
|
|
`is-${props.effect}`,
|
|
props.pure ? 'is-pure' : '',
|
|
],
|
|
style: popperStyle.value,
|
|
id: popperId,
|
|
ref: popperRefAttacher,
|
|
role: 'tooltip',
|
|
onMouseenter: onPopperMouseEnter,
|
|
onMouseleave: onPopperMouseLeave,
|
|
onClick: stop,
|
|
onMousedown: mouseUpAndDown,
|
|
onMouseup: mouseUpAndDown,
|
|
},
|
|
[
|
|
renderSlot(slots, 'default', {}, () => [toDisplayString(props.content)]),
|
|
arrowRenderer(),
|
|
],
|
|
) : null,
|
|
},
|
|
)
|
|
}
|
|
|
|
function arrowRenderer() {
|
|
return props.showArrow
|
|
? h(
|
|
'div',
|
|
{
|
|
ref: arrowRefAttacher,
|
|
class: 'el-popper__arrow',
|
|
'data-popper-arrow': '',
|
|
},
|
|
null,
|
|
)
|
|
: null
|
|
}
|
|
|
|
function triggerRenderer(triggerProps) {
|
|
const trigger = slots.trigger?.()
|
|
const firstElement = getFirstValidNode(trigger, 1)
|
|
if (!firstElement) throwError('renderTrigger', 'trigger expects single rooted node')
|
|
return cloneVNode(firstElement, triggerProps, true)
|
|
}
|
|
|
|
function render() {
|
|
|
|
const trigger = triggerRenderer({
|
|
'aria-describedby': popperId,
|
|
class: props.class,
|
|
style: props.style,
|
|
ref: triggerRefAttacher,
|
|
...events,
|
|
})
|
|
return h(Fragment, null, [
|
|
isManual.value
|
|
? trigger
|
|
: withDirectives(trigger, [[ClickOutside, delayHide]]),
|
|
renderTeleport(),
|
|
])
|
|
}
|
|
|
|
return {
|
|
render,
|
|
}
|
|
}
|
|
|
|
|