diff --git a/packages/hooks/use-popper/index.ts b/packages/hooks/use-popper/index.ts new file mode 100644 index 0000000000..a1ecf81eb6 --- /dev/null +++ b/packages/hooks/use-popper/index.ts @@ -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, + 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, + 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, + default: () => null, + }, + showArrow: { + type: Boolean, + default: true, + }, + strategy: { + type: String as PropType, + default: 'fixed' as PositioningStrategy, + }, + transition: { + type: String, + default: 'el-fade-in-linear', + }, + trigger: { + type: [String, Array] as PropType, + default: DEFAULT_TRIGGER, + }, + visible: { + type: Boolean, + default: undefined, + }, + stopPopperMouseEvent: { + type: Boolean, + default: true, + }, + gpuAcceleration: { + type: Boolean, + default: true, + }, + fallbackPlacements: { + type: Array as PropType, + default: () => DEFAULT_FALLBACK_PLACEMENTS, + }, +} + +export const usePopper = () => { + + const vm = getCurrentInstance() + const props = vm.props as ExtractPropTypes + const { slots } = vm + + const arrowRef = ref(null) + const triggerRef = ref(null) + const popperRef = ref(null) + + const popperOptions = usePopperOptions(arrowRef) + const popperStyle = ref({ zIndex: PopupManager.nextZIndex() }) + const visible = ref(false) + const isManual = computed(() => props.manualMode || props.trigger === 'manual') + + const popperId = `el-popper-${generateId()}` + let popperInstance: Nullable = 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, + } +} + + diff --git a/packages/hooks/use-popper/use-popper-options.ts b/packages/hooks/use-popper/use-popper-options.ts new file mode 100644 index 0000000000..20dc0c67a8 --- /dev/null +++ b/packages/hooks/use-popper/use-popper-options.ts @@ -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 +} + +export const usePopperOptions = (arrowRef: Ref) => { + 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 +} + +function buildModifiers(props: ModifierProps, externalModifiers: StrictModifiers[] = []) { + + const { + arrow, + arrowOffset, + offset, + gpuAcceleration, + fallbackPlacements, + } = props + + const modifiers: Array = [ + { + 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 +} diff --git a/packages/hooks/use-popper/use-target-events.ts b/packages/hooks/use-popper/use-target-events.ts new file mode 100644 index 0000000000..69056de6aa --- /dev/null +++ b/packages/hooks/use-popper/use-target-events.ts @@ -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> = { + 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) + } + }) +} diff --git a/packages/utils/types.ts b/packages/utils/types.ts index 71683381f0..116e619a46 100644 --- a/packages/utils/types.ts +++ b/packages/utils/types.ts @@ -20,3 +20,5 @@ export type AnyFunction = (...args: any[]) => T export type PartialReturnType unknown> = Partial> export type SFCWithInstall = T & { install(app: App): void; } + +export type RefElement = Nullable diff --git a/packages/utils/util.ts b/packages/utils/util.ts index 3284a30aeb..96458d0b16 100644 --- a/packages/utils/util.ts +++ b/packages/utils/util.ts @@ -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 + * @returns (val: T) => void + */ + +export const refAttacher = + (ref: Ref) => { + return (val: T) => { + ref.value = val + } + }