mirror of
https://github.com/element-plus/element-plus.git
synced 2025-01-06 10:38:31 +08:00
refactor(popper): use-popper-hook for popper
- Add new use popper hook into the codebase for replacing the current `use-popper`
This commit is contained in:
parent
8a9a0e38df
commit
a7a8f29a19
381
packages/hooks/use-popper/index.ts
Normal file
381
packages/hooks/use-popper/index.ts
Normal file
@ -0,0 +1,381 @@
|
||||
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 { usePopperOptions } 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,
|
||||
Options,
|
||||
Placement,
|
||||
PositioningStrategy,
|
||||
} 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'
|
||||
|
||||
const DEFAULT_FALLBACK_PLACEMENTS = []
|
||||
|
||||
export const usePopperProps = {
|
||||
// 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.
|
||||
arrowOffset: {
|
||||
type: Number,
|
||||
default: 5,
|
||||
},
|
||||
appendToBody: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
autoClose: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
boundariesPadding: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
class: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
style: Object,
|
||||
hideAfter: {
|
||||
type: Number,
|
||||
default: 200,
|
||||
},
|
||||
cutoff: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
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,
|
||||
},
|
||||
offset: {
|
||||
type: Number,
|
||||
default: 12,
|
||||
},
|
||||
placement: {
|
||||
type: String as PropType<Placement>,
|
||||
default: 'bottom' as Placement,
|
||||
},
|
||||
popperClass: {
|
||||
type: String,
|
||||
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,
|
||||
},
|
||||
showArrow: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
strategy: {
|
||||
type: String as PropType<PositioningStrategy>,
|
||||
default: 'fixed' as PositioningStrategy,
|
||||
},
|
||||
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,
|
||||
},
|
||||
gpuAcceleration: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
fallbackPlacements: {
|
||||
type: Array as PropType<Placement[]>,
|
||||
default: () => DEFAULT_FALLBACK_PLACEMENTS,
|
||||
},
|
||||
}
|
||||
|
||||
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 popperOptions = usePopperOptions(arrowRef)
|
||||
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, popperOptions.value)
|
||||
popperInstance.update()
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
|
101
packages/hooks/use-popper/use-popper-options.ts
Normal file
101
packages/hooks/use-popper/use-popper-options.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { computed, getCurrentInstance } from 'vue'
|
||||
|
||||
import type { Ref } from 'vue'
|
||||
import type { Options, Placement, StrictModifiers } from '@popperjs/core'
|
||||
|
||||
interface IUsePopperProps {
|
||||
popperOptions: Options
|
||||
arrowOffset: number
|
||||
offset: number
|
||||
placement: Placement
|
||||
gpuAcceleration: boolean
|
||||
fallbackPlacements: Array<Placement>
|
||||
}
|
||||
|
||||
export const usePopperOptions = (arrowRef: Ref<HTMLElement>) => {
|
||||
const vm = getCurrentInstance()
|
||||
|
||||
const props = vm.props as unknown as IUsePopperProps
|
||||
|
||||
return computed(() => {
|
||||
return {
|
||||
placement: props.placement,
|
||||
...props.popperOptions,
|
||||
// Avoiding overriding modifiers.
|
||||
modifiers: buildModifiers({
|
||||
arrow: arrowRef.value,
|
||||
arrowOffset: props.arrowOffset,
|
||||
offset: props.offset,
|
||||
gpuAcceleration: props.gpuAcceleration,
|
||||
fallbackPlacements: props.fallbackPlacements,
|
||||
}, props.popperOptions?.modifiers),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface ModifierProps {
|
||||
offset?: number
|
||||
arrow?: HTMLElement
|
||||
arrowOffset?: number
|
||||
gpuAcceleration?: boolean
|
||||
fallbackPlacements?: Array<Placement>
|
||||
}
|
||||
|
||||
function buildModifiers(props: ModifierProps, externalModifiers: StrictModifiers[] = []) {
|
||||
|
||||
const {
|
||||
arrow,
|
||||
arrowOffset,
|
||||
offset,
|
||||
gpuAcceleration,
|
||||
fallbackPlacements,
|
||||
} = props
|
||||
|
||||
const modifiers: Array<StrictModifiers> = [
|
||||
{
|
||||
name: 'offset',
|
||||
options: {
|
||||
offset: [0, offset ?? 12],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'preventOverflow',
|
||||
options: {
|
||||
padding: {
|
||||
top: 2,
|
||||
bottom: 2,
|
||||
left: 5,
|
||||
right: 5,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'flip',
|
||||
options: {
|
||||
padding: 5,
|
||||
fallbackPlacements: fallbackPlacements ?? [],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'computeStyles',
|
||||
options: {
|
||||
gpuAcceleration,
|
||||
adaptive: gpuAcceleration,
|
||||
},
|
||||
},
|
||||
// tippyModifier,
|
||||
]
|
||||
|
||||
if (arrow) {
|
||||
modifiers.push({
|
||||
name: 'arrow',
|
||||
options: {
|
||||
element: arrow,
|
||||
padding: arrowOffset ?? 5,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
modifiers.push(...(externalModifiers))
|
||||
return modifiers
|
||||
}
|
91
packages/hooks/use-popper/use-target-events.ts
Normal file
91
packages/hooks/use-popper/use-target-events.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { computed, getCurrentInstance } from 'vue'
|
||||
import { isArray } from '@element-plus/utils/util'
|
||||
|
||||
export type TriggerType = 'click' | 'hover' | 'focus' | 'manual'
|
||||
export type Trigger = TriggerType | TriggerType[]
|
||||
|
||||
export interface PopperEvents {
|
||||
onClick?: (e: Event) => void
|
||||
onMouseenter?: (e: Event) => void
|
||||
onMouseleave?: (e: Event) => void
|
||||
onFocus?: (e: Event) => void
|
||||
onBlur?: (e: Event) => void
|
||||
}
|
||||
|
||||
export const DEFAULT_TRIGGER = 'hover'
|
||||
|
||||
type Handler = () => void
|
||||
|
||||
export const useTargetEvents = (
|
||||
onShow: Handler,
|
||||
onHide: Handler,
|
||||
onToggle: Handler,
|
||||
) => {
|
||||
const { props } = getCurrentInstance()
|
||||
|
||||
let triggerFocused = false
|
||||
|
||||
const popperEventsHandler = (e: Event) => {
|
||||
e.stopPropagation()
|
||||
switch (e.type) {
|
||||
case 'click': {
|
||||
if (triggerFocused) {
|
||||
// reset previous focus event
|
||||
triggerFocused = false
|
||||
} else {
|
||||
onToggle()
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'mouseenter': {
|
||||
onShow()
|
||||
break
|
||||
}
|
||||
case 'mouseleave': {
|
||||
onHide()
|
||||
break
|
||||
}
|
||||
case 'focus': {
|
||||
triggerFocused = true
|
||||
onShow()
|
||||
break
|
||||
}
|
||||
case 'blur': {
|
||||
triggerFocused = false
|
||||
onHide()
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const triggerEventsMap: Partial<Record<
|
||||
TriggerType,
|
||||
(keyof PopperEvents)[]
|
||||
>> = {
|
||||
click: ['onClick'],
|
||||
hover: ['onMouseenter', 'onMouseleave'],
|
||||
focus: ['onFocus', 'onBlur'],
|
||||
}
|
||||
|
||||
const mapEvents = (t: TriggerType) => {
|
||||
const events = {} as PopperEvents
|
||||
triggerEventsMap[t].forEach(event => {
|
||||
events[event] = popperEventsHandler
|
||||
})
|
||||
|
||||
return events
|
||||
}
|
||||
|
||||
return computed(() => {
|
||||
if (isArray(props.trigger)) {
|
||||
return Object.values(props.trigger).reduce((pre, t) => {
|
||||
return {
|
||||
...pre,
|
||||
...mapEvents(t),
|
||||
}
|
||||
}, {})
|
||||
} else {
|
||||
return mapEvents(props.trigger as TriggerType)
|
||||
}
|
||||
})
|
||||
}
|
@ -20,3 +20,5 @@ export type AnyFunction<T> = (...args: any[]) => T
|
||||
export type PartialReturnType<T extends (...args: unknown[]) => unknown> = Partial<ReturnType<T>>
|
||||
|
||||
export type SFCWithInstall<T> = T & { install(app: App): void; }
|
||||
|
||||
export type RefElement = Nullable<HTMLElement>
|
||||
|
@ -1,14 +1,12 @@
|
||||
import type { Ref } from 'vue'
|
||||
import { getCurrentInstance } from 'vue'
|
||||
|
||||
import { camelize, capitalize, extend, hasOwn, hyphenate, isArray, isObject, isString, isFunction, looseEqual, toRawType } from '@vue/shared'
|
||||
|
||||
import isEqualWith from 'lodash/isEqualWith'
|
||||
|
||||
import isServer from './isServer'
|
||||
import type { AnyFunction } from './types'
|
||||
import { warn } from './error'
|
||||
|
||||
import type { ComponentPublicInstance, Ref } from 'vue'
|
||||
import type { AnyFunction } from './types'
|
||||
|
||||
// type polyfill for compat isIE method
|
||||
declare global {
|
||||
interface Document {
|
||||
@ -253,3 +251,16 @@ export function isEqualWithFunction (obj: any, other: any) {
|
||||
return isFunction(objVal) && isFunction(otherVal) ? `${objVal}` === `${otherVal}` : undefined
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate function for attach ref for the h renderer
|
||||
* @param ref Ref<HTMLElement | ComponentPublicInstance>
|
||||
* @returns (val: T) => void
|
||||
*/
|
||||
|
||||
export const refAttacher =
|
||||
<T extends (HTMLElement | ComponentPublicInstance)>(ref: Ref<T>) => {
|
||||
return (val: T) => {
|
||||
ref.value = val
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user