diff --git a/components/_util/transition.tsx b/components/_util/transition.tsx index 140e31713..5299c0ecb 100644 --- a/components/_util/transition.tsx +++ b/components/_util/transition.tsx @@ -1,20 +1,27 @@ -import type { BaseTransitionProps, CSSProperties, Ref } from 'vue'; +import type { + BaseTransitionProps, + CSSProperties, + Ref, + TransitionGroupProps, + TransitionProps, +} from 'vue'; import { onBeforeUpdate } from 'vue'; import { getCurrentInstance } from 'vue'; import { defineComponent, nextTick, Transition as T, TransitionGroup as TG } from 'vue'; -export const getTransitionProps = (transitionName: string, opt: object = {}) => { +export const getTransitionProps = (transitionName: string, opt: TransitionProps = {}) => { if (process.env.NODE_ENV === 'test') { return opt; } - const transitionProps = transitionName + const transitionProps: TransitionProps = transitionName ? { appear: true, + type: 'animation', // appearFromClass: `${transitionName}-appear ${transitionName}-appear-prepare`, // appearActiveClass: `antdv-base-transtion`, - appearToClass: `${transitionName}-appear ${transitionName}-appear-active`, + // appearToClass: `${transitionName}-appear ${transitionName}-appear-active`, enterFromClass: `${transitionName}-enter ${transitionName}-enter-prepare`, - // enterActiveClass: `antdv-base-transtion`, + // enterActiveClass: `${transitionName}-enter ${transitionName}-enter-active`, enterToClass: `${transitionName}-enter ${transitionName}-enter-active`, leaveFromClass: ` ${transitionName}-leave`, leaveActiveClass: `${transitionName}-leave ${transitionName}-leave-active`, @@ -25,8 +32,8 @@ export const getTransitionProps = (transitionName: string, opt: object = {}) => return transitionProps; }; -export const getTransitionGroupProps = (transitionName: string, opt: object = {}) => { - const transitionProps = transitionName +export const getTransitionGroupProps = (transitionName: string, opt: TransitionProps = {}) => { + const transitionProps: TransitionGroupProps = transitionName ? { appear: true, // appearFromClass: `${transitionName}-appear ${transitionName}-appear-prepare`, diff --git a/components/vc-align/Align.tsx b/components/vc-align/Align.tsx index 6dd82e847..9cb7252e3 100644 --- a/components/vc-align/Align.tsx +++ b/components/vc-align/Align.tsx @@ -8,6 +8,7 @@ import isVisible from '../vc-util/Dom/isVisible'; import { isSamePoint, restoreFocus, monitorResize } from './util'; import type { AlignType, AlignResult, TargetType, TargetPoint } from './interface'; import useBuffer from './hooks/useBuffer'; +import isEqual from 'lodash-es/isEqual'; type OnAlign = (source: HTMLElement, result: AlignResult) => void; @@ -53,11 +54,12 @@ export default defineComponent({ props: alignProps, emits: ['align'], setup(props, { expose, slots }) { - const cacheRef = ref<{ element?: HTMLElement; point?: TargetPoint }>({}); + const cacheRef = ref<{ element?: HTMLElement; point?: TargetPoint; align?: AlignType }>({}); const nodeRef = ref(); const forceAlignPropsRef = computed(() => ({ disabled: props.disabled, target: props.target, + align: props.align, onAlign: props.onAlign, })); @@ -66,10 +68,11 @@ export default defineComponent({ const { disabled: latestDisabled, target: latestTarget, + align: latestAlign, onAlign: latestOnAlign, } = forceAlignPropsRef.value; - if (!latestDisabled && latestTarget && nodeRef.value && nodeRef.value.$el) { - const source = nodeRef.value.$el; + if (!latestDisabled && latestTarget && nodeRef.value) { + const source = nodeRef.value; let result: AlignResult; const element = getElement(latestTarget); @@ -77,19 +80,32 @@ export default defineComponent({ cacheRef.value.element = element; cacheRef.value.point = point; - + cacheRef.value.align = latestAlign; // IE lose focus after element realign // We should record activeElement and restore later - const { activeElement } = document; - + // const { activeElement } = document; + // console.log( + // '🌹', + // source.style.display, + // source.style.left, + // source.style.top, + // source.className, + // ); // We only align when element is visible if (element && isVisible(element)) { - result = alignElement(source, element, props.align); + result = alignElement(source, element, latestAlign); } else if (point) { - result = alignPoint(source, point, props.align); + result = alignPoint(source, point, latestAlign); } - restoreFocus(activeElement, source); + // console.log( + // '😸', + // source.style.display, + // source.style.left, + // source.style.top, + // source.className, + // ); + // restoreFocus(activeElement, source); if (latestOnAlign && result) { latestOnAlign(source, result); @@ -118,13 +134,17 @@ export default defineComponent({ const element = getElement(target); const point = getPoint(target); - if (nodeRef.value && nodeRef.value.$el !== sourceResizeMonitor.value.element) { + if (nodeRef.value !== sourceResizeMonitor.value.element) { sourceResizeMonitor.value.cancel(); - sourceResizeMonitor.value.element = nodeRef.value.$el; - sourceResizeMonitor.value.cancel = monitorResize(nodeRef.value.$el, forceAlign); + sourceResizeMonitor.value.element = nodeRef.value; + sourceResizeMonitor.value.cancel = monitorResize(nodeRef.value, forceAlign); } - if (cacheRef.value.element !== element || !isSamePoint(cacheRef.value.point, point)) { + if ( + cacheRef.value.element !== element || + !isSamePoint(cacheRef.value.point, point) || + !isEqual(cacheRef.value.align, props.align) + ) { forceAlign(); // Add resize observer diff --git a/components/vc-align/Align1.tsx b/components/vc-align/Align1.tsx new file mode 100644 index 000000000..6dd82e847 --- /dev/null +++ b/components/vc-align/Align1.tsx @@ -0,0 +1,196 @@ +import type { PropType } from 'vue'; +import { defineComponent, ref, computed, onMounted, onUpdated, watch, onUnmounted } from 'vue'; +import { alignElement, alignPoint } from 'dom-align'; +import addEventListener from '../vc-util/Dom/addEventListener'; +import { cloneElement } from '../_util/vnode'; +import isVisible from '../vc-util/Dom/isVisible'; + +import { isSamePoint, restoreFocus, monitorResize } from './util'; +import type { AlignType, AlignResult, TargetType, TargetPoint } from './interface'; +import useBuffer from './hooks/useBuffer'; + +type OnAlign = (source: HTMLElement, result: AlignResult) => void; + +export interface AlignProps { + align: AlignType; + target: TargetType; + onAlign?: OnAlign; + monitorBufferTime?: number; + monitorWindowResize?: boolean; + disabled?: boolean; +} + +const alignProps = { + align: Object as PropType, + target: [Object, Function] as PropType, + onAlign: Function as PropType, + monitorBufferTime: Number, + monitorWindowResize: Boolean, + disabled: Boolean, +}; + +interface MonitorRef { + element?: HTMLElement; + cancel: () => void; +} + +export interface RefAlign { + forceAlign: () => void; +} + +function getElement(func: TargetType) { + if (typeof func !== 'function') return null; + return func(); +} + +function getPoint(point: TargetType) { + if (typeof point !== 'object' || !point) return null; + return point; +} + +export default defineComponent({ + name: 'Align', + props: alignProps, + emits: ['align'], + setup(props, { expose, slots }) { + const cacheRef = ref<{ element?: HTMLElement; point?: TargetPoint }>({}); + const nodeRef = ref(); + const forceAlignPropsRef = computed(() => ({ + disabled: props.disabled, + target: props.target, + onAlign: props.onAlign, + })); + + const [forceAlign, cancelForceAlign] = useBuffer( + () => { + const { + disabled: latestDisabled, + target: latestTarget, + onAlign: latestOnAlign, + } = forceAlignPropsRef.value; + if (!latestDisabled && latestTarget && nodeRef.value && nodeRef.value.$el) { + const source = nodeRef.value.$el; + + let result: AlignResult; + const element = getElement(latestTarget); + const point = getPoint(latestTarget); + + cacheRef.value.element = element; + cacheRef.value.point = point; + + // IE lose focus after element realign + // We should record activeElement and restore later + const { activeElement } = document; + + // We only align when element is visible + if (element && isVisible(element)) { + result = alignElement(source, element, props.align); + } else if (point) { + result = alignPoint(source, point, props.align); + } + + restoreFocus(activeElement, source); + + if (latestOnAlign && result) { + latestOnAlign(source, result); + } + + return true; + } + + return false; + }, + computed(() => props.monitorBufferTime), + ); + + // ===================== Effect ===================== + // Listen for target updated + const resizeMonitor = ref({ + cancel: () => {}, + }); + // Listen for source updated + const sourceResizeMonitor = ref({ + cancel: () => {}, + }); + + const goAlign = () => { + const target = props.target; + const element = getElement(target); + const point = getPoint(target); + + if (nodeRef.value && nodeRef.value.$el !== sourceResizeMonitor.value.element) { + sourceResizeMonitor.value.cancel(); + sourceResizeMonitor.value.element = nodeRef.value.$el; + sourceResizeMonitor.value.cancel = monitorResize(nodeRef.value.$el, forceAlign); + } + + if (cacheRef.value.element !== element || !isSamePoint(cacheRef.value.point, point)) { + forceAlign(); + + // Add resize observer + if (resizeMonitor.value.element !== element) { + resizeMonitor.value.cancel(); + resizeMonitor.value.element = element; + resizeMonitor.value.cancel = monitorResize(element, forceAlign); + } + } + }; + + onMounted(() => { + goAlign(); + }); + + onUpdated(() => { + goAlign(); + }); + + // Listen for disabled change + watch( + () => props.disabled, + disabled => { + if (!disabled) { + forceAlign(); + } else { + cancelForceAlign(); + } + }, + { flush: 'post' }, + ); + + // Listen for window resize + const winResizeRef = ref<{ remove: Function }>(null); + + watch( + () => props.monitorWindowResize, + monitorWindowResize => { + if (monitorWindowResize) { + if (!winResizeRef.value) { + winResizeRef.value = addEventListener(window, 'resize', forceAlign); + } + } else if (winResizeRef.value) { + winResizeRef.value.remove(); + winResizeRef.value = null; + } + }, + { flush: 'post' }, + ); + onUnmounted(() => { + resizeMonitor.value.cancel(); + sourceResizeMonitor.value.cancel(); + if (winResizeRef.value) winResizeRef.value.remove(); + cancelForceAlign(); + }); + + expose({ + forceAlign: () => forceAlign(true), + }); + + return () => { + const child = slots?.default(); + if (child) { + return cloneElement(child[0], { ref: nodeRef }, true, true); + } + return child && child[0]; + }; + }, +}); diff --git a/components/vc-trigger/Popup/Mask.tsx b/components/vc-trigger/Popup/Mask.tsx new file mode 100644 index 000000000..2f48a5485 --- /dev/null +++ b/components/vc-trigger/Popup/Mask.tsx @@ -0,0 +1,39 @@ +import { Transition } from 'vue'; +import type { TransitionNameType, AnimationType } from '../interface'; +import { getMotion } from '../utils/motionUtil'; + +export interface MaskProps { + prefixCls: string; + visible?: boolean; + zIndex?: number; + mask?: boolean; + maskAnimation?: AnimationType; + maskTransitionName?: TransitionNameType; +} + +export default function Mask(props: MaskProps) { + const { prefixCls, visible, zIndex, mask, maskAnimation, maskTransitionName } = props; + + if (!mask) { + return null; + } + + let motion = {}; + + if (maskTransitionName || maskAnimation) { + motion = { + ...getMotion({ + prefixCls, + transitionName: maskTransitionName, + animation: maskAnimation, + }), + }; + } + + return ( + +
+ + ); +} +Mask.displayName = 'Mask'; diff --git a/components/vc-trigger/Popup/MobilePopupInner.tsx b/components/vc-trigger/Popup/MobilePopupInner.tsx new file mode 100644 index 000000000..c2235e173 --- /dev/null +++ b/components/vc-trigger/Popup/MobilePopupInner.tsx @@ -0,0 +1,56 @@ +import { CSSProperties, defineComponent, ref, Transition } from 'vue'; +import { flattenChildren } from 'ant-design-vue/es/_util/props-util'; +import classNames from 'ant-design-vue/es/_util/classNames'; +import { MobilePopupProps, mobileProps } from './interface'; + +export default defineComponent({ + props: mobileProps, + emits: ['mouseenter', 'mouseleave', 'mousedown', 'touchstart', 'align'], + inheritAttrs: false, + name: 'MobilePopupInner', + setup(props, { expose, slots }) { + const elementRef = ref(); + + expose({ + forceAlign: () => {}, + getElement: () => elementRef.value, + }); + + return () => { + const { + zIndex, + visible, + prefixCls, + mobile: { popupClassName, popupStyle, popupMotion = {}, popupRender } = {}, + } = props as MobilePopupProps; + // ======================== Render ======================== + const mergedStyle: CSSProperties = { + zIndex, + ...popupStyle, + }; + + let childNode: any = flattenChildren(slots.default?.()); + + // Wrapper when multiple children + if (childNode.length > 1) { + childNode =
{childNode}
; + } + + // Mobile support additional render + if (popupRender) { + childNode = popupRender(childNode); + } + + const mergedClassName = classNames(prefixCls, popupClassName); + return ( + + {visible ? ( +
+ {childNode} +
+ ) : null} +
+ ); + }; + }, +}); diff --git a/components/vc-trigger/Popup/PopupInner.tsx b/components/vc-trigger/Popup/PopupInner.tsx new file mode 100644 index 000000000..7e22f690f --- /dev/null +++ b/components/vc-trigger/Popup/PopupInner.tsx @@ -0,0 +1,187 @@ +import type { AlignType } from '../interface'; +import useVisibleStatus from './useVisibleStatus'; +import useStretchStyle from './useStretchStyle'; +import { + computed, + CSSProperties, + defineComponent, + nextTick, + ref, + toRef, + Transition, + watch, + withModifiers, +} from 'vue'; +import Align, { RefAlign } from 'ant-design-vue/es/vc-align/Align'; +import { getMotion } from '../utils/motionUtil'; +import { flattenChildren } from 'ant-design-vue/es/_util/props-util'; +import classNames from 'ant-design-vue/es/_util/classNames'; +import { innerProps, PopupInnerProps } from './interface'; +import { getTransitionProps } from 'ant-design-vue/es/_util/transition'; +import supportsPassive from 'ant-design-vue/es/_util/supportsPassive'; + +export default defineComponent({ + props: innerProps, + name: 'PopupInner', + emits: ['mouseenter', 'mouseleave', 'mousedown', 'touchstart', 'align'], + inheritAttrs: false, + setup(props, { expose, attrs, slots }) { + const alignRef = ref(); + const elementRef = ref(); + const alignedClassName = ref(); + // ======================= Measure ======================== + const [stretchStyle, measureStretchStyle] = useStretchStyle(toRef(props, 'stretch')); + + const doMeasure = () => { + if (props.stretch) { + measureStretchStyle(props.getRootDomNode()); + } + }; + + // ======================== Status ======================== + const [status, goNextStatus] = useVisibleStatus(toRef(props, 'visible'), doMeasure); + + // ======================== Aligns ======================== + const prepareResolveRef = ref<(value?: unknown) => void>(); + + // `target` on `rc-align` can accept as a function to get the bind element or a point. + // ref: https://www.npmjs.com/package/rc-align + const getAlignTarget = () => { + if (props.point) { + return props.point; + } + return props.getRootDomNode; + }; + + const forceAlign = () => { + alignRef.value?.forceAlign(); + }; + + const onInternalAlign = (popupDomNode: HTMLElement, matchAlign: AlignType) => { + const nextAlignedClassName = props.getClassNameFromAlign(matchAlign); + if (alignedClassName.value !== nextAlignedClassName) { + nextTick(() => { + alignedClassName.value = nextAlignedClassName; + }); + } + if (status.value === 'align') { + // Repeat until not more align needed + if (alignedClassName.value !== nextAlignedClassName) { + Promise.resolve().then(() => { + forceAlign(); + }); + } else { + goNextStatus(() => { + prepareResolveRef.value?.(); + }); + } + + props.onAlign?.(popupDomNode, matchAlign); + } + }; + + // ======================== Motion ======================== + const motion = computed(() => { + const m = { ...getMotion(props) }; + ['onAfterEnter', 'onAfterLeave'].forEach(eventName => { + m[eventName] = () => { + goNextStatus(); + }; + }); + return m; + }); + + const onShowPrepare = () => { + return new Promise(resolve => { + prepareResolveRef.value = resolve; + }); + }; + + watch( + [toRef(motion.value, 'name'), status], + () => { + if (!motion.value.name && status.value === 'motion') { + goNextStatus(); + } + }, + { immediate: true }, + ); + + expose({ + forceAlign, + getElement: () => elementRef.value, + }); + return () => { + const { + zIndex, + visible, + align, + prefixCls, + destroyPopupOnHide, + onMouseenter, + onMouseleave, + onTouchstart, + onMousedown, + } = props as PopupInnerProps; + const statusValue = status.value; + // ======================== Render ======================== + const mergedStyle: CSSProperties = { + ...stretchStyle.value, + zIndex, + opacity: statusValue === 'motion' || statusValue === 'stable' || !visible ? undefined : 0, + pointerEvents: statusValue === 'stable' ? undefined : 'none', + ...(attrs.style as object), + }; + + // Align statusValue + let alignDisabled = true; + if (align?.points && (statusValue === 'align' || statusValue === 'stable')) { + alignDisabled = false; + } + + let childNode: any = flattenChildren(slots.default?.()); + + // Wrapper when multiple children + if (childNode.length > 1) { + childNode =
{childNode}
; + } + const mergedClassName = classNames(prefixCls, attrs.class, alignedClassName.value); + const transitionProps = getTransitionProps(motion.value.name, motion.value); + return ( + + {!destroyPopupOnHide ? ( + +
+ {childNode} +
+
+ ) : null} +
+ ); + }; + }, +}); diff --git a/components/vc-trigger/Popup/index.tsx b/components/vc-trigger/Popup/index.tsx new file mode 100644 index 000000000..41671e546 --- /dev/null +++ b/components/vc-trigger/Popup/index.tsx @@ -0,0 +1,46 @@ +import { defineComponent, ref, watch } from 'vue'; +import { popupProps } from './interface'; +import Mask from './Mask'; +import MobilePopupInner from './MobilePopupInner'; +import PopupInner from './PopupInner'; + +export default defineComponent({ + props: popupProps, + inheritAttrs: false, + name: 'Popup', + setup(props, { attrs, slots }) { + const innerVisible = ref(false); + const inMobile = ref(false); + const popupRef = ref(); + watch( + [() => props.visible, () => props.mobile], + () => { + innerVisible.value = props.visible; + if (props.visible && props.mobile) { + inMobile.value = true; + } + }, + { immediate: true, flush: 'post' }, + ); + return () => { + const cloneProps = { ...props, ...attrs, visible: innerVisible.value }; + const popupNode = inMobile.value ? ( + + ) : ( + + ); + + return ( +
+ + {popupNode} +
+ ); + }; + }, +}); diff --git a/components/vc-trigger/Popup/interface.ts b/components/vc-trigger/Popup/interface.ts new file mode 100644 index 000000000..1de9cf024 --- /dev/null +++ b/components/vc-trigger/Popup/interface.ts @@ -0,0 +1,55 @@ +import type { Point, AlignType, StretchType, MobileConfig } from '../interface'; +import type { ExtractPropTypes, PropType } from 'vue'; + +export const innerProps = { + visible: Boolean, + + prefixCls: String, + zIndex: Number, + + destroyPopupOnHide: Boolean, + forceRender: Boolean, + + // Legacy Motion + animation: String, + transitionName: String, + + // Measure + stretch: { type: String as PropType }, + + // Align + align: { type: Object as PropType }, + point: { type: Object as PropType }, + getRootDomNode: { type: Function as PropType<() => HTMLElement> }, + getClassNameFromAlign: { type: Function as PropType<(align: AlignType) => string> }, + onMouseenter: { type: Function as PropType<(align: MouseEvent) => void> }, + onMouseleave: { type: Function as PropType<(align: MouseEvent) => void> }, + onMousedown: { type: Function as PropType<(align: MouseEvent) => void> }, + onTouchstart: { type: Function as PropType<(align: MouseEvent) => void> }, +}; +export type PopupInnerProps = Partial> & { + align?: AlignType; +}; + +export const mobileProps = { + ...innerProps, + mobile: { type: Object as PropType }, +}; + +export type MobilePopupProps = Partial> & { + align?: AlignType; + mobile: MobileConfig; +}; + +export const popupProps = { + ...innerProps, + mask: Boolean, + mobile: { type: Object as PropType }, + maskAnimation: String, + maskTransitionName: String, +}; + +export type PopupProps = Partial> & { + align?: AlignType; + mobile: MobileConfig; +}; diff --git a/components/vc-trigger/Popup/useStretchStyle.ts b/components/vc-trigger/Popup/useStretchStyle.ts new file mode 100644 index 000000000..c808ced27 --- /dev/null +++ b/components/vc-trigger/Popup/useStretchStyle.ts @@ -0,0 +1,41 @@ +import type { ComputedRef, CSSProperties, Ref } from 'vue'; +import { computed, ref } from 'vue'; +import type { StretchType } from '../interface'; + +export default ( + stretch?: Ref, +): [ComputedRef, (element: HTMLElement) => void] => { + const targetSize = ref({ width: 0, height: 0 }); + + function measureStretch(element: HTMLElement) { + targetSize.value = { + width: element.offsetWidth, + height: element.offsetHeight, + }; + } + + // Merge stretch style + const style = computed(() => { + const sizeStyle: CSSProperties = {}; + + if (stretch.value) { + const { width, height } = targetSize.value; + + // Stretch with target + if (stretch.value.indexOf('height') !== -1 && height) { + sizeStyle.height = `${height}px`; + } else if (stretch.value.indexOf('minHeight') !== -1 && height) { + sizeStyle.minHeight = `${height}px`; + } + if (stretch.value.indexOf('width') !== -1 && width) { + sizeStyle.width = `${width}px`; + } else if (stretch.value.indexOf('minWidth') !== -1 && width) { + sizeStyle.minWidth = `${width}px`; + } + } + + return sizeStyle; + }); + + return [style, measureStretch]; +}; diff --git a/components/vc-trigger/Popup/useVisibleStatus.ts b/components/vc-trigger/Popup/useVisibleStatus.ts new file mode 100644 index 000000000..31a0bdb43 --- /dev/null +++ b/components/vc-trigger/Popup/useVisibleStatus.ts @@ -0,0 +1,96 @@ +import type { Ref } from 'vue'; +import { onBeforeUnmount } from 'vue'; +import { ref, watch } from 'vue'; +import raf from '../../_util/raf'; + +/** + * Popup should follow the steps for each component work correctly: + * measure - check for the value stretch size + * align - let component align the position + * aligned - re-align again in case additional className changed the size + * afterAlign - choice next step is trigger motion or finished + * beforeMotion - should reset motion to invisible so that CSSMotion can do normal motion + * motion - play the motion + * stable - everything is done + */ +type PopupStatus = null | 'measure' | 'align' | 'aligned' | 'motion' | 'stable'; + +type Func = () => void; + +const StatusQueue: PopupStatus[] = ['measure', 'align', null, 'motion']; + +export default ( + visible: Ref, + doMeasure: Func, +): [Ref, (callback?: () => void) => void] => { + const status = ref(null); + const rafRef = ref(); + const destroyRef = ref(false); + + function setStatus(nextStatus: PopupStatus) { + if (!destroyRef.value) { + status.value = nextStatus; + } + } + + function cancelRaf() { + raf.cancel(rafRef.value); + } + + function goNextStatus(callback?: () => void) { + cancelRaf(); + rafRef.value = raf(() => { + // Only align should be manually trigger + let newStatus = status.value; + switch (status.value) { + case 'align': + newStatus = 'motion'; + break; + case 'motion': + newStatus = 'stable'; + break; + default: + } + setStatus(newStatus); + + callback?.(); + }); + } + + watch( + visible, + () => { + status.value = 'measure'; + }, + { immediate: true }, + ); + // Go next status + watch( + status, + () => { + switch (status.value) { + case 'measure': + doMeasure(); + break; + default: + } + + if (status.value) { + rafRef.value = raf(async () => { + const index = StatusQueue.indexOf(status.value); + const nextStatus = StatusQueue[index + 1]; + if (nextStatus && index !== -1) { + setStatus(nextStatus); + } + }); + } + }, + { immediate: true }, + ); + onBeforeUnmount(() => { + destroyRef.value = true; + cancelRaf(); + }); + + return [status, goNextStatus]; +}; diff --git a/components/vc-trigger/Popup.jsx b/components/vc-trigger/Popup1.jsx similarity index 100% rename from components/vc-trigger/Popup.jsx rename to components/vc-trigger/Popup1.jsx diff --git a/components/vc-trigger/Trigger.jsx b/components/vc-trigger/Trigger.jsx index cbc2f14e4..a02cf0bdd 100644 --- a/components/vc-trigger/Trigger.jsx +++ b/components/vc-trigger/Trigger.jsx @@ -1,6 +1,7 @@ -import { defineComponent, inject, provide } from 'vue'; +import { computed, defineComponent, inject, provide, ref } from 'vue'; import PropTypes from '../_util/vue-types'; import contains from '../vc-util/Dom/contains'; +import raf from '../_util/raf'; import { hasProp, getComponent, @@ -24,7 +25,10 @@ function returnEmptyString() { return ''; } -function returnDocument() { +function returnDocument(element) { + if (element) { + return element.ownerDocument; + } return window.document; } const ALL_HANDLERS = [ @@ -76,18 +80,29 @@ export default defineComponent({ maskAnimation: PropTypes.string, stretch: PropTypes.string, alignPoint: PropTypes.looseBool, // Maybe we can support user pass position in the future + autoDestroy: PropTypes.looseBool.def(false), + mobile: Object, }, - setup() { + setup(props) { + const align = computed(() => { + const { popupPlacement, popupAlign, builtinPlacements } = props; + if (popupPlacement && builtinPlacements) { + return getAlignFromPlacement(builtinPlacements, popupPlacement, popupAlign); + } + return popupAlign; + }); return { vcTriggerContext: inject('vcTriggerContext', {}), - savePopupRef: inject('savePopupRef', noop), dialogContext: inject('dialogContext', null), + popupRef: ref(null), + triggerRef: ref(null), + align, }; }, data() { const props = this.$props; let popupVisible; - if (hasProp(this, 'popupVisible')) { + if (this.popupVisible !== undefined) { popupVisible = !!props.popupVisible; } else { popupVisible = !!props.defaultPopupVisible; @@ -97,12 +112,12 @@ export default defineComponent({ this.fireEvents(h, e); }; }); - this._component = null; this.focusTime = null; this.clickOutsideHandler = null; this.contextmenuOutsideHandler1 = null; this.contextmenuOutsideHandler2 = null; this.touchOutsideHandler = null; + this.attachId = null; return { prevPopupVisible: popupVisible, sPopupVisible: popupVisible, @@ -139,6 +154,7 @@ export default defineComponent({ this.clearDelayTimer(); this.clearOutsideHandler(); clearTimeout(this.mouseDownTimeout); + raf.cancel(this.attachId); }, methods: { updatedCal() { @@ -152,7 +168,7 @@ export default defineComponent({ if (state.sPopupVisible) { let currentDocument; if (!this.clickOutsideHandler && (this.isClickToHide() || this.isContextmenuToShow())) { - currentDocument = props.getDocument(); + currentDocument = props.getDocument(this.getRootDomNode()); this.clickOutsideHandler = addEventListener( currentDocument, 'mousedown', @@ -161,7 +177,7 @@ export default defineComponent({ } // always hide on mobile if (!this.touchOutsideHandler) { - currentDocument = currentDocument || props.getDocument(); + currentDocument = currentDocument || props.getDocument(this.getRootDomNode()); this.touchOutsideHandler = addEventListener( currentDocument, 'touchstart', @@ -171,7 +187,7 @@ export default defineComponent({ } // close popup when trigger type contains 'onContextmenu' and document is scrolling. if (!this.contextmenuOutsideHandler1 && this.isContextmenuToShow()) { - currentDocument = currentDocument || props.getDocument(); + currentDocument = currentDocument || props.getDocument(this.getRootDomNode()); this.contextmenuOutsideHandler1 = addEventListener( currentDocument, 'scroll', @@ -215,9 +231,7 @@ export default defineComponent({ e && e.relatedTarget && !e.relatedTarget.setTimeout && - this._component && - this._component.getPopupDomNode && - contains(this._component.getPopupDomNode(), e.relatedTarget) + contains(this.popupRef?.getPopupDomNode(), e.relatedTarget) ) { return; } @@ -323,19 +337,37 @@ export default defineComponent({ return; } const target = event.target; - const root = findDOMNode(this); - if (!contains(root, target) && !this.hasPopupMouseDown) { + const root = this.getRootDomNode(); + const popupNode = this.getPopupDomNode(); + if ( + // mousedown on the target should also close popup when action is contextMenu. + // https://github.com/ant-design/ant-design/issues/29853 + (!contains(root, target) || this.isContextMenuOnly()) && + !contains(popupNode, target) && + !this.hasPopupMouseDown + ) { this.close(); } }, getPopupDomNode() { - if (this._component && this._component.getPopupDomNode) { - return this._component.getPopupDomNode(); - } - return null; + // for test + return this.popupRef?.getElement() || null; }, getRootDomNode() { + const { getTriggerDOMNode } = this.$props; + if (getTriggerDOMNode) { + return getTriggerDOMNode(this.triggerRef); + } + + try { + const domNode = findDOMNode(this.triggerRef); + if (domNode) { + return domNode; + } + } catch (err) { + // Do nothing + } return findDOMNode(this); }, @@ -366,10 +398,6 @@ export default defineComponent({ } return popupAlign; }, - savePopup(node) { - this._component = node; - this.savePopupRef(node); - }, getComponent() { const self = this; const mouseProps = {}; @@ -386,7 +414,6 @@ export default defineComponent({ prefixCls, destroyPopupOnHide, popupClassName, - action, popupAnimation, popupTransitionName, popupStyle, @@ -396,16 +423,16 @@ export default defineComponent({ zIndex, stretch, alignPoint, + mobile, + forceRender, } = self.$props; const { sPopupVisible, point } = this.$data; - const align = this.getPopupAlign(); const popupProps = { prefixCls, destroyPopupOnHide, visible: sPopupVisible, point: alignPoint ? point : null, - action, - align, + align: this.align, animation: popupAnimation, getClassNameFromAlign: handleGetPopupClassFromAlign, stretch, @@ -417,28 +444,53 @@ export default defineComponent({ maskTransitionName, getContainer, popupClassName, - popupStyle, + style: popupStyle, onAlign: $attrs.onPopupAlign || noop, ...mouseProps, - ref: this.savePopup, + ref: 'popupRef', + mobile, + forceRender, }; return {getComponent(self, 'popup')}; }, + attachParent(popupContainer) { + raf.cancel(this.attachId); + + const { getPopupContainer, getDocument } = this.$props; + const domNode = this.getRootDomNode(); + + let mountNode; + if (!getPopupContainer) { + mountNode = getDocument(this.getRootDomNode()).body; + } else if (domNode || getPopupContainer.length === 0) { + // Compatible for legacy getPopupContainer with domNode argument. + // If no need `domNode` argument, will call directly. + // https://codesandbox.io/s/eloquent-mclean-ss93m?file=/src/App.js + mountNode = getPopupContainer(domNode); + } + + if (mountNode) { + mountNode.appendChild(popupContainer); + } else { + // Retry after frame render in case parent not ready + this.attachId = raf(() => { + this.attachParent(popupContainer); + }); + } + }, + getContainer() { - const { $props: props, dialogContext } = this; - const popupContainer = document.createElement('div'); + const { $props: props } = this; + const { getDocument } = props; + const popupContainer = getDocument(this.getRootDomNode()).createElement('div'); // Make sure default popup container will never cause scrollbar appearing // https://github.com/react-component/trigger/issues/41 popupContainer.style.position = 'absolute'; popupContainer.style.top = '0'; popupContainer.style.left = '0'; popupContainer.style.width = '100%'; - const mountNode = props.getPopupContainer - ? props.getPopupContainer(findDOMNode(this), dialogContext) - : props.getDocument().body; - mountNode.appendChild(popupContainer); - this.popupContainer = popupContainer; + this.attachParent(popupContainer); return popupContainer; }, @@ -455,7 +507,7 @@ export default defineComponent({ onPopupVisibleChange && onPopupVisibleChange(sPopupVisible); } // Always record the point position since mouseEnterDelay will delay the show - if (alignPoint && event) { + if (alignPoint && event && sPopupVisible) { this.setPoint(event); } }, @@ -534,6 +586,11 @@ export default defineComponent({ return action.indexOf('click') !== -1 || showAction.indexOf('click') !== -1; }, + isContextMenuOnly() { + const { action } = this.$props; + return action === 'contextmenu' || (action.length === 1 && action[0] === 'contextmenu'); + }, + isContextmenuToShow() { const { action, showAction } = this.$props; return action.indexOf('contextmenu') !== -1 || showAction.indexOf('contextmenu') !== -1; @@ -564,8 +621,8 @@ export default defineComponent({ return action.indexOf('focus') !== -1 || hideAction.indexOf('blur') !== -1; }, forcePopupAlign() { - if (this.$data.sPopupVisible && this._component && this._component.alignInstance) { - this._component.alignInstance.forceAlign(); + if (this.$data.sPopupVisible) { + this.popupRef?.forceAlign(); } }, fireEvents(type, e) { @@ -585,7 +642,7 @@ export default defineComponent({ render() { const { sPopupVisible, $attrs } = this; const children = filterEmpty(getSlot(this)); - const { forceRender, alignPoint } = this.$props; + const { forceRender, alignPoint, autoDestroy } = this.$props; if (children.length > 1) { warning(false, 'Trigger children just support only one default', true); @@ -641,10 +698,10 @@ export default defineComponent({ if (childrenClassName) { newChildProps.class = childrenClassName; } - const trigger = cloneElement(child, newChildProps); + const trigger = cloneElement(child, { ...newChildProps, ref: 'triggerRef' }, true, true); let portal; // prevent unmounting after it's rendered - if (sPopupVisible || this._component || forceRender) { + if (sPopupVisible || this.popupRef || forceRender) { portal = ( ); } - return [portal, trigger]; + if (!sPopupVisible && autoDestroy) { + portal = null; + } + return ( + <> + {portal} + {trigger} + + ); }, }); diff --git a/components/vc-trigger/Trigger2.jsx b/components/vc-trigger/Trigger2.jsx new file mode 100644 index 000000000..cbc2f14e4 --- /dev/null +++ b/components/vc-trigger/Trigger2.jsx @@ -0,0 +1,659 @@ +import { defineComponent, inject, provide } from 'vue'; +import PropTypes from '../_util/vue-types'; +import contains from '../vc-util/Dom/contains'; +import { + hasProp, + getComponent, + getEvents, + filterEmpty, + getSlot, + findDOMNode, +} from '../_util/props-util'; +import { requestAnimationTimeout, cancelAnimationTimeout } from '../_util/requestAnimationTimeout'; +import addEventListener from '../vc-util/Dom/addEventListener'; +import warning from '../_util/warning'; +import Popup from './Popup'; +import { getAlignFromPlacement, getAlignPopupClassName, noop } from './utils'; +import BaseMixin from '../_util/BaseMixin'; +import Portal from '../_util/Portal'; +import classNames from '../_util/classNames'; +import { cloneElement } from '../_util/vnode'; +import supportsPassive from '../_util/supportsPassive'; + +function returnEmptyString() { + return ''; +} + +function returnDocument() { + return window.document; +} +const ALL_HANDLERS = [ + 'onClick', + 'onMousedown', + 'onTouchstart', + 'onMouseenter', + 'onMouseleave', + 'onFocus', + 'onBlur', + 'onContextmenu', +]; + +export default defineComponent({ + name: 'Trigger', + mixins: [BaseMixin], + inheritAttrs: false, + props: { + action: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]).def([]), + showAction: PropTypes.any.def([]), + hideAction: PropTypes.any.def([]), + getPopupClassNameFromAlign: PropTypes.any.def(returnEmptyString), + onPopupVisibleChange: PropTypes.func.def(noop), + afterPopupVisibleChange: PropTypes.func.def(noop), + popup: PropTypes.any, + popupStyle: PropTypes.object.def(() => ({})), + prefixCls: PropTypes.string.def('rc-trigger-popup'), + popupClassName: PropTypes.string.def(''), + popupPlacement: PropTypes.string, + builtinPlacements: PropTypes.object, + popupTransitionName: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + popupAnimation: PropTypes.any, + mouseEnterDelay: PropTypes.number.def(0), + mouseLeaveDelay: PropTypes.number.def(0.1), + zIndex: PropTypes.number, + focusDelay: PropTypes.number.def(0), + blurDelay: PropTypes.number.def(0.15), + getPopupContainer: PropTypes.func, + getDocument: PropTypes.func.def(returnDocument), + forceRender: PropTypes.looseBool, + destroyPopupOnHide: PropTypes.looseBool.def(false), + mask: PropTypes.looseBool.def(false), + maskClosable: PropTypes.looseBool.def(true), + // onPopupAlign: PropTypes.func.def(noop), + popupAlign: PropTypes.object.def(() => ({})), + popupVisible: PropTypes.looseBool, + defaultPopupVisible: PropTypes.looseBool.def(false), + maskTransitionName: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + maskAnimation: PropTypes.string, + stretch: PropTypes.string, + alignPoint: PropTypes.looseBool, // Maybe we can support user pass position in the future + }, + setup() { + return { + vcTriggerContext: inject('vcTriggerContext', {}), + savePopupRef: inject('savePopupRef', noop), + dialogContext: inject('dialogContext', null), + }; + }, + data() { + const props = this.$props; + let popupVisible; + if (hasProp(this, 'popupVisible')) { + popupVisible = !!props.popupVisible; + } else { + popupVisible = !!props.defaultPopupVisible; + } + ALL_HANDLERS.forEach(h => { + this[`fire${h}`] = e => { + this.fireEvents(h, e); + }; + }); + this._component = null; + this.focusTime = null; + this.clickOutsideHandler = null; + this.contextmenuOutsideHandler1 = null; + this.contextmenuOutsideHandler2 = null; + this.touchOutsideHandler = null; + return { + prevPopupVisible: popupVisible, + sPopupVisible: popupVisible, + point: null, + }; + }, + watch: { + popupVisible(val) { + if (val !== undefined) { + this.prevPopupVisible = this.sPopupVisible; + this.sPopupVisible = val; + } + }, + }, + created() { + provide('vcTriggerContext', this); + }, + deactivated() { + this.setPopupVisible(false); + }, + mounted() { + this.$nextTick(() => { + this.updatedCal(); + }); + }, + + updated() { + this.$nextTick(() => { + this.updatedCal(); + }); + }, + + beforeUnmount() { + this.clearDelayTimer(); + this.clearOutsideHandler(); + clearTimeout(this.mouseDownTimeout); + }, + methods: { + updatedCal() { + const props = this.$props; + const state = this.$data; + + // We must listen to `mousedown` or `touchstart`, edge case: + // https://github.com/ant-design/ant-design/issues/5804 + // https://github.com/react-component/calendar/issues/250 + // https://github.com/react-component/trigger/issues/50 + if (state.sPopupVisible) { + let currentDocument; + if (!this.clickOutsideHandler && (this.isClickToHide() || this.isContextmenuToShow())) { + currentDocument = props.getDocument(); + this.clickOutsideHandler = addEventListener( + currentDocument, + 'mousedown', + this.onDocumentClick, + ); + } + // always hide on mobile + if (!this.touchOutsideHandler) { + currentDocument = currentDocument || props.getDocument(); + this.touchOutsideHandler = addEventListener( + currentDocument, + 'touchstart', + this.onDocumentClick, + supportsPassive ? { passive: false } : false, + ); + } + // close popup when trigger type contains 'onContextmenu' and document is scrolling. + if (!this.contextmenuOutsideHandler1 && this.isContextmenuToShow()) { + currentDocument = currentDocument || props.getDocument(); + this.contextmenuOutsideHandler1 = addEventListener( + currentDocument, + 'scroll', + this.onContextmenuClose, + ); + } + // close popup when trigger type contains 'onContextmenu' and window is blur. + if (!this.contextmenuOutsideHandler2 && this.isContextmenuToShow()) { + this.contextmenuOutsideHandler2 = addEventListener( + window, + 'blur', + this.onContextmenuClose, + ); + } + } else { + this.clearOutsideHandler(); + } + }, + onMouseenter(e) { + const { mouseEnterDelay } = this.$props; + this.fireEvents('onMouseenter', e); + this.delaySetPopupVisible(true, mouseEnterDelay, mouseEnterDelay ? null : e); + }, + + onMouseMove(e) { + this.fireEvents('onMousemove', e); + this.setPoint(e); + }, + + onMouseleave(e) { + this.fireEvents('onMouseleave', e); + this.delaySetPopupVisible(false, this.$props.mouseLeaveDelay); + }, + + onPopupMouseenter() { + this.clearDelayTimer(); + }, + + onPopupMouseleave(e) { + if ( + e && + e.relatedTarget && + !e.relatedTarget.setTimeout && + this._component && + this._component.getPopupDomNode && + contains(this._component.getPopupDomNode(), e.relatedTarget) + ) { + return; + } + this.delaySetPopupVisible(false, this.$props.mouseLeaveDelay); + }, + + onFocus(e) { + this.fireEvents('onFocus', e); + // incase focusin and focusout + this.clearDelayTimer(); + if (this.isFocusToShow()) { + this.focusTime = Date.now(); + this.delaySetPopupVisible(true, this.$props.focusDelay); + } + }, + + onMousedown(e) { + this.fireEvents('onMousedown', e); + this.preClickTime = Date.now(); + }, + + onTouchstart(e) { + this.fireEvents('onTouchstart', e); + this.preTouchTime = Date.now(); + }, + + onBlur(e) { + if (!contains(e.target, e.relatedTarget || document.activeElement)) { + this.fireEvents('onBlur', e); + this.clearDelayTimer(); + if (this.isBlurToHide()) { + this.delaySetPopupVisible(false, this.$props.blurDelay); + } + } + }, + + onContextmenu(e) { + e.preventDefault(); + this.fireEvents('onContextmenu', e); + this.setPopupVisible(true, e); + }, + + onContextmenuClose() { + if (this.isContextmenuToShow()) { + this.close(); + } + }, + + onClick(event) { + this.fireEvents('onClick', event); + // focus will trigger click + if (this.focusTime) { + let preTime; + if (this.preClickTime && this.preTouchTime) { + preTime = Math.min(this.preClickTime, this.preTouchTime); + } else if (this.preClickTime) { + preTime = this.preClickTime; + } else if (this.preTouchTime) { + preTime = this.preTouchTime; + } + if (Math.abs(preTime - this.focusTime) < 20) { + return; + } + this.focusTime = 0; + } + this.preClickTime = 0; + this.preTouchTime = 0; + // Only prevent default when all the action is click. + // https://github.com/ant-design/ant-design/issues/17043 + // https://github.com/ant-design/ant-design/issues/17291 + if ( + this.isClickToShow() && + (this.isClickToHide() || this.isBlurToHide()) && + event && + event.preventDefault + ) { + event.preventDefault(); + } + if (event && event.domEvent) { + event.domEvent.preventDefault(); + } + const nextVisible = !this.$data.sPopupVisible; + if ((this.isClickToHide() && !nextVisible) || (nextVisible && this.isClickToShow())) { + this.setPopupVisible(!this.$data.sPopupVisible, event); + } + }, + onPopupMouseDown(...args) { + const { vcTriggerContext = {} } = this; + this.hasPopupMouseDown = true; + + clearTimeout(this.mouseDownTimeout); + this.mouseDownTimeout = setTimeout(() => { + this.hasPopupMouseDown = false; + }, 0); + + if (vcTriggerContext.onPopupMouseDown) { + vcTriggerContext.onPopupMouseDown(...args); + } + }, + + onDocumentClick(event) { + if (this.$props.mask && !this.$props.maskClosable) { + return; + } + const target = event.target; + const root = findDOMNode(this); + if (!contains(root, target) && !this.hasPopupMouseDown) { + this.close(); + } + }, + getPopupDomNode() { + if (this._component && this._component.getPopupDomNode) { + return this._component.getPopupDomNode(); + } + return null; + }, + + getRootDomNode() { + return findDOMNode(this); + }, + + handleGetPopupClassFromAlign(align) { + const className = []; + const props = this.$props; + const { + popupPlacement, + builtinPlacements, + prefixCls, + alignPoint, + getPopupClassNameFromAlign, + } = props; + if (popupPlacement && builtinPlacements) { + className.push(getAlignPopupClassName(builtinPlacements, prefixCls, align, alignPoint)); + } + if (getPopupClassNameFromAlign) { + className.push(getPopupClassNameFromAlign(align)); + } + return className.join(' '); + }, + + getPopupAlign() { + const props = this.$props; + const { popupPlacement, popupAlign, builtinPlacements } = props; + if (popupPlacement && builtinPlacements) { + return getAlignFromPlacement(builtinPlacements, popupPlacement, popupAlign); + } + return popupAlign; + }, + savePopup(node) { + this._component = node; + this.savePopupRef(node); + }, + getComponent() { + const self = this; + const mouseProps = {}; + if (this.isMouseEnterToShow()) { + mouseProps.onMouseenter = self.onPopupMouseenter; + } + if (this.isMouseLeaveToHide()) { + mouseProps.onMouseleave = self.onPopupMouseleave; + } + mouseProps.onMousedown = this.onPopupMouseDown; + mouseProps[supportsPassive ? 'onTouchstartPassive' : 'onTouchstart'] = this.onPopupMouseDown; + const { handleGetPopupClassFromAlign, getRootDomNode, getContainer, $attrs } = self; + const { + prefixCls, + destroyPopupOnHide, + popupClassName, + action, + popupAnimation, + popupTransitionName, + popupStyle, + mask, + maskAnimation, + maskTransitionName, + zIndex, + stretch, + alignPoint, + } = self.$props; + const { sPopupVisible, point } = this.$data; + const align = this.getPopupAlign(); + const popupProps = { + prefixCls, + destroyPopupOnHide, + visible: sPopupVisible, + point: alignPoint ? point : null, + action, + align, + animation: popupAnimation, + getClassNameFromAlign: handleGetPopupClassFromAlign, + stretch, + getRootDomNode, + mask, + zIndex, + transitionName: popupTransitionName, + maskAnimation, + maskTransitionName, + getContainer, + popupClassName, + popupStyle, + onAlign: $attrs.onPopupAlign || noop, + ...mouseProps, + ref: this.savePopup, + }; + return {getComponent(self, 'popup')}; + }, + + getContainer() { + const { $props: props, dialogContext } = this; + const popupContainer = document.createElement('div'); + // Make sure default popup container will never cause scrollbar appearing + // https://github.com/react-component/trigger/issues/41 + popupContainer.style.position = 'absolute'; + popupContainer.style.top = '0'; + popupContainer.style.left = '0'; + popupContainer.style.width = '100%'; + const mountNode = props.getPopupContainer + ? props.getPopupContainer(findDOMNode(this), dialogContext) + : props.getDocument().body; + mountNode.appendChild(popupContainer); + this.popupContainer = popupContainer; + return popupContainer; + }, + + setPopupVisible(sPopupVisible, event) { + const { alignPoint, sPopupVisible: prevPopupVisible, onPopupVisibleChange } = this; + this.clearDelayTimer(); + if (prevPopupVisible !== sPopupVisible) { + if (!hasProp(this, 'popupVisible')) { + this.setState({ + sPopupVisible, + prevPopupVisible, + }); + } + onPopupVisibleChange && onPopupVisibleChange(sPopupVisible); + } + // Always record the point position since mouseEnterDelay will delay the show + if (alignPoint && event) { + this.setPoint(event); + } + }, + + setPoint(point) { + const { alignPoint } = this.$props; + if (!alignPoint || !point) return; + + this.setState({ + point: { + pageX: point.pageX, + pageY: point.pageY, + }, + }); + }, + handlePortalUpdate() { + if (this.prevPopupVisible !== this.sPopupVisible) { + this.afterPopupVisibleChange(this.sPopupVisible); + } + }, + delaySetPopupVisible(visible, delayS, event) { + const delay = delayS * 1000; + this.clearDelayTimer(); + if (delay) { + const point = event ? { pageX: event.pageX, pageY: event.pageY } : null; + this.delayTimer = requestAnimationTimeout(() => { + this.setPopupVisible(visible, point); + this.clearDelayTimer(); + }, delay); + } else { + this.setPopupVisible(visible, event); + } + }, + + clearDelayTimer() { + if (this.delayTimer) { + cancelAnimationTimeout(this.delayTimer); + this.delayTimer = null; + } + }, + + clearOutsideHandler() { + if (this.clickOutsideHandler) { + this.clickOutsideHandler.remove(); + this.clickOutsideHandler = null; + } + + if (this.contextmenuOutsideHandler1) { + this.contextmenuOutsideHandler1.remove(); + this.contextmenuOutsideHandler1 = null; + } + + if (this.contextmenuOutsideHandler2) { + this.contextmenuOutsideHandler2.remove(); + this.contextmenuOutsideHandler2 = null; + } + + if (this.touchOutsideHandler) { + this.touchOutsideHandler.remove(); + this.touchOutsideHandler = null; + } + }, + + createTwoChains(event) { + let fn = () => {}; + const events = getEvents(this); + if (this.childOriginEvents[event] && events[event]) { + return this[`fire${event}`]; + } + fn = this.childOriginEvents[event] || events[event] || fn; + return fn; + }, + + isClickToShow() { + const { action, showAction } = this.$props; + return action.indexOf('click') !== -1 || showAction.indexOf('click') !== -1; + }, + + isContextmenuToShow() { + const { action, showAction } = this.$props; + return action.indexOf('contextmenu') !== -1 || showAction.indexOf('contextmenu') !== -1; + }, + + isClickToHide() { + const { action, hideAction } = this.$props; + return action.indexOf('click') !== -1 || hideAction.indexOf('click') !== -1; + }, + + isMouseEnterToShow() { + const { action, showAction } = this.$props; + return action.indexOf('hover') !== -1 || showAction.indexOf('mouseenter') !== -1; + }, + + isMouseLeaveToHide() { + const { action, hideAction } = this.$props; + return action.indexOf('hover') !== -1 || hideAction.indexOf('mouseleave') !== -1; + }, + + isFocusToShow() { + const { action, showAction } = this.$props; + return action.indexOf('focus') !== -1 || showAction.indexOf('focus') !== -1; + }, + + isBlurToHide() { + const { action, hideAction } = this.$props; + return action.indexOf('focus') !== -1 || hideAction.indexOf('blur') !== -1; + }, + forcePopupAlign() { + if (this.$data.sPopupVisible && this._component && this._component.alignInstance) { + this._component.alignInstance.forceAlign(); + } + }, + fireEvents(type, e) { + if (this.childOriginEvents[type]) { + this.childOriginEvents[type](e); + } + const event = this.$props[type] || this.$attrs[type]; + if (event) { + event(e); + } + }, + + close() { + this.setPopupVisible(false); + }, + }, + render() { + const { sPopupVisible, $attrs } = this; + const children = filterEmpty(getSlot(this)); + const { forceRender, alignPoint } = this.$props; + + if (children.length > 1) { + warning(false, 'Trigger children just support only one default', true); + } + const child = children[0]; + this.childOriginEvents = getEvents(child); + const newChildProps = { + key: 'trigger', + }; + + if (this.isContextmenuToShow()) { + newChildProps.onContextmenu = this.onContextmenu; + } else { + newChildProps.onContextmenu = this.createTwoChains('onContextmenu'); + } + + if (this.isClickToHide() || this.isClickToShow()) { + newChildProps.onClick = this.onClick; + newChildProps.onMousedown = this.onMousedown; + newChildProps[supportsPassive ? 'onTouchstartPassive' : 'onTouchstart'] = this.onTouchstart; + } else { + newChildProps.onClick = this.createTwoChains('onClick'); + newChildProps.onMousedown = this.createTwoChains('onMousedown'); + newChildProps[supportsPassive ? 'onTouchstartPassive' : 'onTouchstart'] = + this.createTwoChains('onTouchstart'); + } + if (this.isMouseEnterToShow()) { + newChildProps.onMouseenter = this.onMouseenter; + if (alignPoint) { + newChildProps.onMousemove = this.onMouseMove; + } + } else { + newChildProps.onMouseenter = this.createTwoChains('onMouseenter'); + } + if (this.isMouseLeaveToHide()) { + newChildProps.onMouseleave = this.onMouseleave; + } else { + newChildProps.onMouseleave = this.createTwoChains('onMouseleave'); + } + + if (this.isFocusToShow() || this.isBlurToHide()) { + newChildProps.onFocus = this.onFocus; + newChildProps.onBlur = this.onBlur; + } else { + newChildProps.onFocus = this.createTwoChains('onFocus'); + newChildProps.onBlur = e => { + if (e && (!e.relatedTarget || !contains(e.target, e.relatedTarget))) { + this.createTwoChains('onBlur')(e); + } + }; + } + const childrenClassName = classNames(child && child.props && child.props.class, $attrs.class); + if (childrenClassName) { + newChildProps.class = childrenClassName; + } + const trigger = cloneElement(child, newChildProps); + let portal; + // prevent unmounting after it's rendered + if (sPopupVisible || this._component || forceRender) { + portal = ( + + ); + } + return [portal, trigger]; + }, +}); diff --git a/components/vc-trigger/Trigger2.tsx b/components/vc-trigger/Trigger2.tsx new file mode 100644 index 000000000..f17f0e23f --- /dev/null +++ b/components/vc-trigger/Trigger2.tsx @@ -0,0 +1,716 @@ +import { defineComponent, inject, provide, ref } from 'vue'; +import PropTypes from '../_util/vue-types'; +import contains from '../vc-util/Dom/contains'; +import raf from '../_util/raf'; +import { + hasProp, + getComponent, + getEvents, + filterEmpty, + getSlot, + findDOMNode, +} from '../_util/props-util'; +import { requestAnimationTimeout, cancelAnimationTimeout } from '../_util/requestAnimationTimeout'; +import addEventListener from '../vc-util/Dom/addEventListener'; +import warning from '../_util/warning'; +import Popup from './Popup'; +import { getAlignFromPlacement, getAlignPopupClassName, noop } from './utils'; +import BaseMixin from '../_util/BaseMixin'; +import Portal from '../_util/Portal'; +import classNames from '../_util/classNames'; +import { cloneElement } from '../_util/vnode'; +import supportsPassive from '../_util/supportsPassive'; + +function returnEmptyString() { + return ''; +} + +function returnDocument(element) { + if (element) { + return element.ownerDocument; + } + return window.document; +} +const ALL_HANDLERS = [ + 'onClick', + 'onMousedown', + 'onTouchstart', + 'onMouseenter', + 'onMouseleave', + 'onFocus', + 'onBlur', + 'onContextmenu', +]; + +export default defineComponent({ + name: 'Trigger', + mixins: [BaseMixin], + inheritAttrs: false, + props: { + action: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]).def([]), + showAction: PropTypes.any.def([]), + hideAction: PropTypes.any.def([]), + getPopupClassNameFromAlign: PropTypes.any.def(returnEmptyString), + onPopupVisibleChange: PropTypes.func.def(noop), + afterPopupVisibleChange: PropTypes.func.def(noop), + popup: PropTypes.any, + popupStyle: PropTypes.object.def(() => ({})), + prefixCls: PropTypes.string.def('rc-trigger-popup'), + popupClassName: PropTypes.string.def(''), + popupPlacement: PropTypes.string, + builtinPlacements: PropTypes.object, + popupTransitionName: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + popupAnimation: PropTypes.any, + mouseEnterDelay: PropTypes.number.def(0), + mouseLeaveDelay: PropTypes.number.def(0.1), + zIndex: PropTypes.number, + focusDelay: PropTypes.number.def(0), + blurDelay: PropTypes.number.def(0.15), + getPopupContainer: PropTypes.func, + getDocument: PropTypes.func.def(returnDocument), + forceRender: PropTypes.looseBool, + destroyPopupOnHide: PropTypes.looseBool.def(false), + mask: PropTypes.looseBool.def(false), + maskClosable: PropTypes.looseBool.def(true), + // onPopupAlign: PropTypes.func.def(noop), + popupAlign: PropTypes.object.def(() => ({})), + popupVisible: PropTypes.looseBool, + defaultPopupVisible: PropTypes.looseBool.def(false), + maskTransitionName: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + maskAnimation: PropTypes.string, + stretch: PropTypes.string, + alignPoint: PropTypes.looseBool, // Maybe we can support user pass position in the future + autoDestroy: PropTypes.looseBool.def(false), + }, + setup() { + return { + vcTriggerContext: inject('vcTriggerContext', {}), + dialogContext: inject('dialogContext', null), + popupRef: ref(null), + triggerRef: ref(null), + }; + }, + data() { + const props = this.$props; + let popupVisible; + if (this.popupVisible !== undefined) { + popupVisible = !!props.popupVisible; + } else { + popupVisible = !!props.defaultPopupVisible; + } + ALL_HANDLERS.forEach(h => { + this[`fire${h}`] = e => { + this.fireEvents(h, e); + }; + }); + this.focusTime = null; + this.clickOutsideHandler = null; + this.contextmenuOutsideHandler1 = null; + this.contextmenuOutsideHandler2 = null; + this.touchOutsideHandler = null; + this.attachId = null; + return { + prevPopupVisible: popupVisible, + sPopupVisible: popupVisible, + point: null, + }; + }, + watch: { + popupVisible(val) { + if (val !== undefined) { + this.prevPopupVisible = this.sPopupVisible; + this.sPopupVisible = val; + } + }, + }, + created() { + provide('vcTriggerContext', this); + }, + deactivated() { + this.setPopupVisible(false); + }, + mounted() { + this.$nextTick(() => { + this.updatedCal(); + }); + }, + + updated() { + this.$nextTick(() => { + this.updatedCal(); + }); + }, + + beforeUnmount() { + this.clearDelayTimer(); + this.clearOutsideHandler(); + clearTimeout(this.mouseDownTimeout); + raf.cancel(this.attachId); + }, + methods: { + updatedCal() { + const props = this.$props; + const state = this.$data; + + // We must listen to `mousedown` or `touchstart`, edge case: + // https://github.com/ant-design/ant-design/issues/5804 + // https://github.com/react-component/calendar/issues/250 + // https://github.com/react-component/trigger/issues/50 + if (state.sPopupVisible) { + let currentDocument; + if (!this.clickOutsideHandler && (this.isClickToHide() || this.isContextmenuToShow())) { + currentDocument = props.getDocument(this.getRootDomNode()); + this.clickOutsideHandler = addEventListener( + currentDocument, + 'mousedown', + this.onDocumentClick, + ); + } + // always hide on mobile + if (!this.touchOutsideHandler) { + currentDocument = currentDocument || props.getDocument(this.getRootDomNode()); + this.touchOutsideHandler = addEventListener( + currentDocument, + 'touchstart', + this.onDocumentClick, + supportsPassive ? { passive: false } : false, + ); + } + // close popup when trigger type contains 'onContextmenu' and document is scrolling. + if (!this.contextmenuOutsideHandler1 && this.isContextmenuToShow()) { + currentDocument = currentDocument || props.getDocument(this.getRootDomNode()); + this.contextmenuOutsideHandler1 = addEventListener( + currentDocument, + 'scroll', + this.onContextmenuClose, + ); + } + // close popup when trigger type contains 'onContextmenu' and window is blur. + if (!this.contextmenuOutsideHandler2 && this.isContextmenuToShow()) { + this.contextmenuOutsideHandler2 = addEventListener( + window, + 'blur', + this.onContextmenuClose, + ); + } + } else { + this.clearOutsideHandler(); + } + }, + onMouseenter(e) { + const { mouseEnterDelay } = this.$props; + this.fireEvents('onMouseenter', e); + this.delaySetPopupVisible(true, mouseEnterDelay, mouseEnterDelay ? null : e); + }, + + onMouseMove(e) { + this.fireEvents('onMousemove', e); + this.setPoint(e); + }, + + onMouseleave(e) { + this.fireEvents('onMouseleave', e); + this.delaySetPopupVisible(false, this.$props.mouseLeaveDelay); + }, + + onPopupMouseenter() { + this.clearDelayTimer(); + }, + + onPopupMouseleave(e) { + if ( + e && + e.relatedTarget && + !e.relatedTarget.setTimeout && + contains(this.popupRef?.getPopupDomNode(), e.relatedTarget) + ) { + return; + } + this.delaySetPopupVisible(false, this.$props.mouseLeaveDelay); + }, + + onFocus(e) { + this.fireEvents('onFocus', e); + // incase focusin and focusout + this.clearDelayTimer(); + if (this.isFocusToShow()) { + this.focusTime = Date.now(); + this.delaySetPopupVisible(true, this.$props.focusDelay); + } + }, + + onMousedown(e) { + this.fireEvents('onMousedown', e); + this.preClickTime = Date.now(); + }, + + onTouchstart(e) { + this.fireEvents('onTouchstart', e); + this.preTouchTime = Date.now(); + }, + + onBlur(e) { + if (!contains(e.target, e.relatedTarget || document.activeElement)) { + this.fireEvents('onBlur', e); + this.clearDelayTimer(); + if (this.isBlurToHide()) { + this.delaySetPopupVisible(false, this.$props.blurDelay); + } + } + }, + + onContextmenu(e) { + e.preventDefault(); + this.fireEvents('onContextmenu', e); + this.setPopupVisible(true, e); + }, + + onContextmenuClose() { + if (this.isContextmenuToShow()) { + this.close(); + } + }, + + onClick(event) { + this.fireEvents('onClick', event); + // focus will trigger click + if (this.focusTime) { + let preTime; + if (this.preClickTime && this.preTouchTime) { + preTime = Math.min(this.preClickTime, this.preTouchTime); + } else if (this.preClickTime) { + preTime = this.preClickTime; + } else if (this.preTouchTime) { + preTime = this.preTouchTime; + } + if (Math.abs(preTime - this.focusTime) < 20) { + return; + } + this.focusTime = 0; + } + this.preClickTime = 0; + this.preTouchTime = 0; + // Only prevent default when all the action is click. + // https://github.com/ant-design/ant-design/issues/17043 + // https://github.com/ant-design/ant-design/issues/17291 + if ( + this.isClickToShow() && + (this.isClickToHide() || this.isBlurToHide()) && + event && + event.preventDefault + ) { + event.preventDefault(); + } + if (event && event.domEvent) { + event.domEvent.preventDefault(); + } + const nextVisible = !this.$data.sPopupVisible; + if ((this.isClickToHide() && !nextVisible) || (nextVisible && this.isClickToShow())) { + this.setPopupVisible(!this.$data.sPopupVisible, event); + } + }, + onPopupMouseDown(...args) { + const { vcTriggerContext = {} } = this; + this.hasPopupMouseDown = true; + + clearTimeout(this.mouseDownTimeout); + this.mouseDownTimeout = setTimeout(() => { + this.hasPopupMouseDown = false; + }, 0); + + if (vcTriggerContext.onPopupMouseDown) { + vcTriggerContext.onPopupMouseDown(...args); + } + }, + + onDocumentClick(event) { + if (this.$props.mask && !this.$props.maskClosable) { + return; + } + const target = event.target; + const root = this.getRootDomNode(); + const popupNode = this.getPopupDomNode(); + if ( + // mousedown on the target should also close popup when action is contextMenu. + // https://github.com/ant-design/ant-design/issues/29853 + (!contains(root, target) || this.isContextMenuOnly()) && + !contains(popupNode, target) && + !this.hasPopupMouseDown + ) { + this.close(); + } + }, + getPopupDomNode() { + // for test + return this.popupRef?.getElement() || null; + }, + + getRootDomNode() { + const { getTriggerDOMNode } = this.$props; + if (getTriggerDOMNode) { + return getTriggerDOMNode(this.triggerRef); + } + + try { + const domNode = findDOMNode(this.triggerRef); + if (domNode) { + return domNode; + } + } catch (err) { + // Do nothing + } + return findDOMNode(this); + }, + + handleGetPopupClassFromAlign(align) { + const className = []; + const props = this.$props; + const { + popupPlacement, + builtinPlacements, + prefixCls, + alignPoint, + getPopupClassNameFromAlign, + } = props; + if (popupPlacement && builtinPlacements) { + className.push(getAlignPopupClassName(builtinPlacements, prefixCls, align, alignPoint)); + } + if (getPopupClassNameFromAlign) { + className.push(getPopupClassNameFromAlign(align)); + } + return className.join(' '); + }, + + getPopupAlign() { + const props = this.$props; + const { popupPlacement, popupAlign, builtinPlacements } = props; + if (popupPlacement && builtinPlacements) { + return getAlignFromPlacement(builtinPlacements, popupPlacement, popupAlign); + } + return popupAlign; + }, + getComponent() { + const self = this; + const mouseProps = {}; + if (this.isMouseEnterToShow()) { + mouseProps.onMouseenter = self.onPopupMouseenter; + } + if (this.isMouseLeaveToHide()) { + mouseProps.onMouseleave = self.onPopupMouseleave; + } + mouseProps.onMousedown = this.onPopupMouseDown; + mouseProps[supportsPassive ? 'onTouchstartPassive' : 'onTouchstart'] = this.onPopupMouseDown; + const { handleGetPopupClassFromAlign, getRootDomNode, getContainer, $attrs } = self; + const { + prefixCls, + destroyPopupOnHide, + popupClassName, + popupAnimation, + popupTransitionName, + popupStyle, + mask, + maskAnimation, + maskTransitionName, + zIndex, + stretch, + alignPoint, + mobile, + forceRender, + } = self.$props; + const { sPopupVisible, point } = this.$data; + const align = this.getPopupAlign(); + const popupProps = { + prefixCls, + destroyPopupOnHide, + visible: sPopupVisible, + point: alignPoint ? point : null, + align, + animation: popupAnimation, + getClassNameFromAlign: handleGetPopupClassFromAlign, + stretch, + getRootDomNode, + mask, + zIndex, + transitionName: popupTransitionName, + maskAnimation, + maskTransitionName, + getContainer, + popupClassName, + popupStyle, + onAlign: $attrs.onPopupAlign || noop, + ...mouseProps, + ref: 'popupRef', + mobile, + forceRender, + }; + return {getComponent(self, 'popup')}; + }, + + attachParent(popupContainer) { + raf.cancel(this.attachId); + + const { getPopupContainer, getDocument } = this.$props; + const domNode = this.getRootDomNode(); + + let mountNode; + if (!getPopupContainer) { + mountNode = getDocument(this.getRootDomNode()).body; + } else if (domNode || getPopupContainer.length === 0) { + // Compatible for legacy getPopupContainer with domNode argument. + // If no need `domNode` argument, will call directly. + // https://codesandbox.io/s/eloquent-mclean-ss93m?file=/src/App.js + mountNode = getPopupContainer(domNode); + } + + if (mountNode) { + mountNode.appendChild(popupContainer); + } else { + // Retry after frame render in case parent not ready + this.attachId = raf(() => { + this.attachParent(popupContainer); + }); + } + }, + + getContainer() { + const { $props: props } = this; + const { getDocument } = props; + const popupContainer = getDocument(this.getRootDomNode()).createElement('div'); + // Make sure default popup container will never cause scrollbar appearing + // https://github.com/react-component/trigger/issues/41 + popupContainer.style.position = 'absolute'; + popupContainer.style.top = '0'; + popupContainer.style.left = '0'; + popupContainer.style.width = '100%'; + this.attachParent(popupContainer); + return popupContainer; + }, + + setPopupVisible(sPopupVisible, event) { + const { alignPoint, sPopupVisible: prevPopupVisible, onPopupVisibleChange } = this; + this.clearDelayTimer(); + if (prevPopupVisible !== sPopupVisible) { + if (!hasProp(this, 'popupVisible')) { + this.setState({ + sPopupVisible, + prevPopupVisible, + }); + } + onPopupVisibleChange && onPopupVisibleChange(sPopupVisible); + } + // Always record the point position since mouseEnterDelay will delay the show + if (alignPoint && event && sPopupVisible) { + this.setPoint(event); + } + }, + + setPoint(point) { + const { alignPoint } = this.$props; + if (!alignPoint || !point) return; + + this.setState({ + point: { + pageX: point.pageX, + pageY: point.pageY, + }, + }); + }, + handlePortalUpdate() { + if (this.prevPopupVisible !== this.sPopupVisible) { + this.afterPopupVisibleChange(this.sPopupVisible); + } + }, + delaySetPopupVisible(visible, delayS, event) { + const delay = delayS * 1000; + this.clearDelayTimer(); + if (delay) { + const point = event ? { pageX: event.pageX, pageY: event.pageY } : null; + this.delayTimer = requestAnimationTimeout(() => { + this.setPopupVisible(visible, point); + this.clearDelayTimer(); + }, delay); + } else { + this.setPopupVisible(visible, event); + } + }, + + clearDelayTimer() { + if (this.delayTimer) { + cancelAnimationTimeout(this.delayTimer); + this.delayTimer = null; + } + }, + + clearOutsideHandler() { + if (this.clickOutsideHandler) { + this.clickOutsideHandler.remove(); + this.clickOutsideHandler = null; + } + + if (this.contextmenuOutsideHandler1) { + this.contextmenuOutsideHandler1.remove(); + this.contextmenuOutsideHandler1 = null; + } + + if (this.contextmenuOutsideHandler2) { + this.contextmenuOutsideHandler2.remove(); + this.contextmenuOutsideHandler2 = null; + } + + if (this.touchOutsideHandler) { + this.touchOutsideHandler.remove(); + this.touchOutsideHandler = null; + } + }, + + createTwoChains(event) { + let fn = () => {}; + const events = getEvents(this); + if (this.childOriginEvents[event] && events[event]) { + return this[`fire${event}`]; + } + fn = this.childOriginEvents[event] || events[event] || fn; + return fn; + }, + + isClickToShow() { + const { action, showAction } = this.$props; + return action.indexOf('click') !== -1 || showAction.indexOf('click') !== -1; + }, + + isContextMenuOnly() { + const { action } = this.$props; + return action === 'contextmenu' || (action.length === 1 && action[0] === 'contextmenu'); + }, + + isContextmenuToShow() { + const { action, showAction } = this.$props; + return action.indexOf('contextmenu') !== -1 || showAction.indexOf('contextmenu') !== -1; + }, + + isClickToHide() { + const { action, hideAction } = this.$props; + return action.indexOf('click') !== -1 || hideAction.indexOf('click') !== -1; + }, + + isMouseEnterToShow() { + const { action, showAction } = this.$props; + return action.indexOf('hover') !== -1 || showAction.indexOf('mouseenter') !== -1; + }, + + isMouseLeaveToHide() { + const { action, hideAction } = this.$props; + return action.indexOf('hover') !== -1 || hideAction.indexOf('mouseleave') !== -1; + }, + + isFocusToShow() { + const { action, showAction } = this.$props; + return action.indexOf('focus') !== -1 || showAction.indexOf('focus') !== -1; + }, + + isBlurToHide() { + const { action, hideAction } = this.$props; + return action.indexOf('focus') !== -1 || hideAction.indexOf('blur') !== -1; + }, + forcePopupAlign() { + if (this.$data.sPopupVisible) { + this.popupRef?.forceAlign(); + } + }, + fireEvents(type, e) { + if (this.childOriginEvents[type]) { + this.childOriginEvents[type](e); + } + const event = this.$props[type] || this.$attrs[type]; + if (event) { + event(e); + } + }, + + close() { + this.setPopupVisible(false); + }, + }, + render() { + const { sPopupVisible, $attrs } = this; + const children = filterEmpty(getSlot(this)); + const { forceRender, alignPoint, autoDestroy } = this.$props; + + if (children.length > 1) { + warning(false, 'Trigger children just support only one default', true); + } + const child = children[0]; + this.childOriginEvents = getEvents(child); + const newChildProps = { + key: 'trigger', + }; + + if (this.isContextmenuToShow()) { + newChildProps.onContextmenu = this.onContextmenu; + } else { + newChildProps.onContextmenu = this.createTwoChains('onContextmenu'); + } + + if (this.isClickToHide() || this.isClickToShow()) { + newChildProps.onClick = this.onClick; + newChildProps.onMousedown = this.onMousedown; + newChildProps[supportsPassive ? 'onTouchstartPassive' : 'onTouchstart'] = this.onTouchstart; + } else { + newChildProps.onClick = this.createTwoChains('onClick'); + newChildProps.onMousedown = this.createTwoChains('onMousedown'); + newChildProps[supportsPassive ? 'onTouchstartPassive' : 'onTouchstart'] = + this.createTwoChains('onTouchstart'); + } + if (this.isMouseEnterToShow()) { + newChildProps.onMouseenter = this.onMouseenter; + if (alignPoint) { + newChildProps.onMousemove = this.onMouseMove; + } + } else { + newChildProps.onMouseenter = this.createTwoChains('onMouseenter'); + } + if (this.isMouseLeaveToHide()) { + newChildProps.onMouseleave = this.onMouseleave; + } else { + newChildProps.onMouseleave = this.createTwoChains('onMouseleave'); + } + + if (this.isFocusToShow() || this.isBlurToHide()) { + newChildProps.onFocus = this.onFocus; + newChildProps.onBlur = this.onBlur; + } else { + newChildProps.onFocus = this.createTwoChains('onFocus'); + newChildProps.onBlur = e => { + if (e && (!e.relatedTarget || !contains(e.target, e.relatedTarget))) { + this.createTwoChains('onBlur')(e); + } + }; + } + const childrenClassName = classNames(child && child.props && child.props.class, $attrs.class); + if (childrenClassName) { + newChildProps.class = childrenClassName; + } + const trigger = cloneElement(child, { ...newChildProps, ref: 'triggerRef' }, true, true); + let portal; + // prevent unmounting after it's rendered + if (sPopupVisible || this.popupRef || forceRender) { + portal = ( + + ); + } + if (!sPopupVisible && autoDestroy) { + portal = null; + } + return ( + <> + {portal} + {trigger} + + ); + }, +}); diff --git a/components/vc-trigger/interface.ts b/components/vc-trigger/interface.ts new file mode 100644 index 000000000..7ef79c111 --- /dev/null +++ b/components/vc-trigger/interface.ts @@ -0,0 +1,72 @@ +import type { CSSProperties, TransitionProps } from 'vue'; +import type { VueNode } from '../_util/type'; + +/** Two char of 't' 'b' 'c' 'l' 'r'. Example: 'lt' */ +export type AlignPoint = string; + +export interface AlignType { + /** + * move point of source node to align with point of target node. + * Such as ['tr','cc'], align top right point of source node with center point of target node. + * Point can be 't'(top), 'b'(bottom), 'c'(center), 'l'(left), 'r'(right) */ + points?: AlignPoint[]; + /** + * offset source node by offset[0] in x and offset[1] in y. + * If offset contains percentage string value, it is relative to sourceNode region. + */ + offset?: number[]; + /** + * offset target node by offset[0] in x and offset[1] in y. + * If targetOffset contains percentage string value, it is relative to targetNode region. + */ + targetOffset?: number[]; + /** + * If adjustX field is true, will adjust source node in x direction if source node is invisible. + * If adjustY field is true, will adjust source node in y direction if source node is invisible. + */ + overflow?: { + adjustX?: boolean | number; + adjustY?: boolean | number; + }; + /** + * Whether use css right instead of left to position + */ + useCssRight?: boolean; + /** + * Whether use css bottom instead of top to position + */ + useCssBottom?: boolean; + /** + * Whether use css transform instead of left/top/right/bottom to position if browser supports. + * Defaults to false. + */ + useCssTransform?: boolean; + ignoreShake?: boolean; +} + +export type BuildInPlacements = Record; + +export type StretchType = string; + +export type ActionType = string; + +export type AnimationType = string; + +export type TransitionNameType = string; + +export interface Point { + pageX: number; + pageY: number; +} + +export interface CommonEventHandler { + remove: () => void; +} + +export interface MobileConfig { + /** Set popup motion. You can ref `rc-motion` for more info. */ + popupMotion?: TransitionProps; + popupClassName?: string; + popupStyle?: CSSProperties; + popupRender?: (originNode: VueNode) => VueNode; +} diff --git a/components/vc-trigger/utils/alignUtil.ts b/components/vc-trigger/utils/alignUtil.ts new file mode 100644 index 000000000..0041e84c6 --- /dev/null +++ b/components/vc-trigger/utils/alignUtil.ts @@ -0,0 +1,40 @@ +import type { AlignType, BuildInPlacements, AlignPoint } from '../interface'; + +function isPointsEq(a1: AlignPoint[], a2: AlignPoint[], isAlignPoint: boolean): boolean { + if (isAlignPoint) { + return a1[0] === a2[0]; + } + return a1[0] === a2[0] && a1[1] === a2[1]; +} + +export function getAlignFromPlacement( + builtinPlacements: BuildInPlacements, + placementStr: string, + align: AlignType, +): AlignType { + const baseAlign = builtinPlacements[placementStr] || {}; + return { + ...baseAlign, + ...align, + }; +} + +export function getAlignPopupClassName( + builtinPlacements: BuildInPlacements, + prefixCls: string, + align: AlignType, + isAlignPoint: boolean, +): string { + const { points } = align; + + const placements = Object.keys(builtinPlacements); + + for (let i = 0; i < placements.length; i += 1) { + const placement = placements[i]; + if (isPointsEq(builtinPlacements[placement].points, points, isAlignPoint)) { + return `${prefixCls}-placement-${placement}`; + } + } + + return ''; +} diff --git a/components/vc-trigger/utils/motionUtil.ts b/components/vc-trigger/utils/motionUtil.ts new file mode 100644 index 000000000..2146c5d3b --- /dev/null +++ b/components/vc-trigger/utils/motionUtil.ts @@ -0,0 +1,23 @@ +import type { AnimationType, TransitionNameType } from '../interface'; + +interface GetMotionProps { + animation: AnimationType; + transitionName: TransitionNameType; + prefixCls: string; +} + +export function getMotion({ prefixCls, animation, transitionName }: GetMotionProps) { + if (animation) { + return { + name: `${prefixCls}-${animation}`, + }; + } + + if (transitionName) { + return { + name: transitionName, + }; + } + + return null; +} diff --git a/examples/App.vue b/examples/App.vue index 65b1ba433..0a6557876 100644 --- a/examples/App.vue +++ b/examples/App.vue @@ -1,39 +1,61 @@ - + diff --git a/v2-doc b/v2-doc index e5fb2accb..7a7b52df8 160000 --- a/v2-doc +++ b/v2-doc @@ -1 +1 @@ -Subproject commit e5fb2accb9cf5e02e2fd0011310a70041b5ff7a1 +Subproject commit 7a7b52df8b3b69d8b1a8b8dcd96e1b0f7bb3f8c9