You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ant-design-vue/components/vc-trigger/Trigger.tsx

712 lines
21 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import type { HTMLAttributes } from 'vue';
import { computed, defineComponent, inject, provide, shallowRef } from 'vue';
import { triggerProps, noop } from './interface';
import contains from '../vc-util/Dom/contains';
import raf from '../_util/raf';
import {
hasProp,
getComponent,
getEvents,
filterEmpty,
getSlot,
findDOMNode,
} from '../_util/props-util';
import addEventListener from '../vc-util/Dom/addEventListener';
import Popup from './Popup';
import { getAlignFromPlacement, getAlignPopupClassName } from './utils/alignUtil';
import BaseMixin from '../_util/BaseMixin';
import Portal from '../_util/PortalWrapper';
import classNames from '../_util/classNames';
import { cloneElement } from '../_util/vnode';
import supportsPassive from '../_util/supportsPassive';
import { useProvidePortal } from './context';
const ALL_HANDLERS = [
'onClick',
'onMousedown',
'onTouchstart',
'onMouseenter',
'onMouseleave',
'onFocus',
'onBlur',
'onContextmenu',
];
export default defineComponent({
compatConfig: { MODE: 3 },
name: 'Trigger',
mixins: [BaseMixin],
inheritAttrs: false,
props: triggerProps(),
setup(props) {
const align = computed(() => {
const { popupPlacement, popupAlign, builtinPlacements } = props;
if (popupPlacement && builtinPlacements) {
return getAlignFromPlacement(builtinPlacements, popupPlacement, popupAlign);
}
return popupAlign;
});
const popupRef = shallowRef(null);
const setPopupRef = val => {
popupRef.value = val;
};
return {
vcTriggerContext: inject(
'vcTriggerContext',
{} as {
onPopupMouseDown?: (...args: any[]) => void;
onPopupMouseenter?: (...args: any[]) => void;
onPopupMouseleave?: (...args: any[]) => void;
},
),
popupRef,
setPopupRef,
triggerRef: shallowRef(null),
align,
focusTime: null,
clickOutsideHandler: null,
contextmenuOutsideHandler1: null,
contextmenuOutsideHandler2: null,
touchOutsideHandler: null,
attachId: null,
delayTimer: null,
hasPopupMouseDown: false,
preClickTime: null,
preTouchTime: null,
mouseDownTimeout: null,
childOriginEvents: {},
};
},
data() {
const props = this.$props;
let popupVisible;
if (this.popupVisible !== undefined) {
popupVisible = !!props.popupVisible;
} else {
popupVisible = !!props.defaultPopupVisible;
}
ALL_HANDLERS.forEach(h => {
(this as any)[`fire${h}`] = e => {
(this as any).fireEvents(h, e);
};
});
return {
prevPopupVisible: popupVisible,
sPopupVisible: popupVisible,
point: null,
};
},
watch: {
popupVisible(val) {
if (val !== undefined) {
this.prevPopupVisible = this.sPopupVisible;
this.sPopupVisible = val;
}
},
},
created() {
provide('vcTriggerContext', {
onPopupMouseDown: this.onPopupMouseDown,
onPopupMouseenter: this.onPopupMouseenter,
onPopupMouseleave: this.onPopupMouseleave,
});
useProvidePortal(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() {
const { vcTriggerContext = {} } = this;
if (vcTriggerContext.onPopupMouseenter) {
vcTriggerContext.onPopupMouseenter();
}
this.clearDelayTimer();
},
onPopupMouseleave(e) {
if (
e &&
e.relatedTarget &&
!e.relatedTarget.setTimeout &&
contains(this.popupRef?.getElement(), e.relatedTarget)
) {
return;
}
this.delaySetPopupVisible(false, this.$props.mouseLeaveDelay);
const { vcTriggerContext = {} } = this;
if (vcTriggerContext.onPopupMouseleave) {
vcTriggerContext.onPopupMouseleave(e);
}
},
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: any[]) {
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
) {
// https://github.com/vuejs/core/issues/4462
// vue 动画bug导致 https://github.com/vueComponent/ant-design-vue/issues/5259
// 改成延时解决
this.delaySetPopupVisible(false, 0.1);
}
},
getPopupDomNode() {
// for test
return this.popupRef?.getElement() || null;
},
getRootDomNode() {
const { getTriggerDOMNode } = this.$props;
if (getTriggerDOMNode) {
const domNode =
this.triggerRef?.$el?.nodeName === '#comment' ? null : findDOMNode(this.triggerRef);
return findDOMNode(getTriggerDOMNode(domNode));
}
try {
const domNode =
this.triggerRef?.$el?.nodeName === '#comment' ? null : 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 mouseProps: HTMLAttributes = {};
if (this.isMouseEnterToShow()) {
mouseProps.onMouseenter = this.onPopupMouseenter;
}
if (this.isMouseLeaveToHide()) {
mouseProps.onMouseleave = this.onPopupMouseleave;
}
mouseProps.onMousedown = this.onPopupMouseDown;
mouseProps[supportsPassive ? 'onTouchstartPassive' : 'onTouchstart'] = this.onPopupMouseDown;
const { handleGetPopupClassFromAlign, getRootDomNode, $attrs } = this;
const {
prefixCls,
destroyPopupOnHide,
popupClassName,
popupAnimation,
popupTransitionName,
popupStyle,
mask,
maskAnimation,
maskTransitionName,
zIndex,
stretch,
alignPoint,
mobile,
forceRender,
} = this.$props;
const { sPopupVisible, point } = this.$data;
const popupProps = {
prefixCls,
destroyPopupOnHide,
visible: sPopupVisible,
point: alignPoint ? point : null,
align: this.align,
animation: popupAnimation,
getClassNameFromAlign: handleGetPopupClassFromAlign,
stretch,
getRootDomNode,
mask,
zIndex,
transitionName: popupTransitionName,
maskAnimation,
maskTransitionName,
class: popupClassName,
style: popupStyle,
onAlign: $attrs.onPopupAlign || noop,
...mouseProps,
ref: this.setPopupRef,
mobile,
forceRender,
} as any;
return (
<Popup
{...popupProps}
v-slots={{ default: this.$slots.popup || (() => getComponent(this, 'popup')) }}
></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: boolean, event?: any) {
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: boolean, delayS: number, event?: any) {
const delay = delayS * 1000;
this.clearDelayTimer();
if (delay) {
const point = event ? { pageX: event.pageX, pageY: event.pageY } : null;
this.delayTimer = setTimeout(() => {
this.setPopupVisible(visible, point);
this.clearDelayTimer();
}, delay);
} else {
this.setPopupVisible(visible, event);
}
},
clearDelayTimer() {
if (this.delayTimer) {
clearTimeout(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: string) {
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 as any;
},
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: string, e: Event) {
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 { $attrs } = this;
const children = filterEmpty(getSlot(this));
const { alignPoint, getPopupContainer } = this.$props;
const child = children[0];
this.childOriginEvents = getEvents(child);
const newChildProps: any = {
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 as HTMLElement, e.relatedTarget as HTMLElement))
) {
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);
const portal = (
<Portal
key="portal"
v-slots={{ default: this.getComponent }}
getContainer={getPopupContainer && (() => getPopupContainer(this.getRootDomNode()))}
didUpdate={this.handlePortalUpdate}
visible={this.$data.sPopupVisible}
></Portal>
);
return (
<>
{trigger}
{portal}
</>
);
},
});